odeの開発メモ日記

プログラマーやってます。今までの道のり ポケコンbasic→C(DirectX5 ネットやろうぜ)→Perl(ちょろっと)→Java→C#→Ruby→Android(Java)

ActiveRecordで発行するSQLのログをDBに保存する方法

管理ページのSQL(更新系のUPDATEやINSERT等)を保存しておきたい要望があるだろう。
(管理者の操作ログを詳細にとる代わりの代替案として等)
そんなときのやり方です。


ActiveRecordのソースを見た限りそういった機能はなさそうだったので
ActiveRecordの定義を書き換えで対応してます。


ちなみにクラス変数使ってるので非スレッドッセーフです。
そのうちrailsがマルチスレッド対応した場合はスレッドローカルストレージを使ってスレッドセーフにする必要があります。(rubyでできるか知らないですけど)

DB定義

テーブル:sql_logs

id	int ID
sql	longtext SQL文
url	text 発行元URL
client_id	int クライアントのID(ユーザーIDや管理者ID等を保存する)
run_time	float 実行時間
created_at	datetime 作成日
updated_at	timestamp 更新日(いらないですが一応。。)

rubyソース

environment.rbに追加

# sqlのログをDBに保存
module ActiveRecord
  module ConnectionAdapters
    class AbstractAdapter
      def log_info_with_db_log(sql, name, runtime)
        if SqlLogManager.instance.enabled
          # last_insert_idがSqlLogのidになってしまうので退避しておく
          if @db_log_write_before_insert_id_enabled
            @db_log_write_before_insert_id = @connection.insert_id
            @db_log_write_before_insert_id_enabled = false
          end
          SqlLogManager.instance.save_log(sql, runtime)
        end

        return log_info_without_db_log(sql, name, runtime)
      end
      alias_method_chain :log_info, :db_log

    end

    class MysqlAdapter < AbstractAdapter
      def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
        if SqlLogManager.instance.enabled
          @db_log_write_before_insert_id_enabled = true
        end
        super sql, name

        insert_id = @db_log_write_before_insert_id
        # SqlLogをとらない場合はinsert_idがとれてないので取得する
        unless SqlLogManager.instance.enabled
          insert_id = @connection.insert_id
        end
        # 退避しておいたlast_insert_idを返す。
        id_value || insert_id
      end
    end

    class MysqlReplicationAdapter < MysqlAdapter
      def insert(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil) #:nodoc:
        if SqlLogManager.instance.enabled
          @db_log_write_before_insert_id_enabled = true
        end
        ensure_master
        execute(sql, name = nil)

        insert_id = @db_log_write_before_insert_id
        # SqlLogをとらない場合はinsert_idがとれてないので取得する
        unless SqlLogManager.instance.enabled
          insert_id = @connection.insert_id
        end
        # 退避しておいたlast_insert_idを返す。
        id_value || insert_id
      end
    end
  end
end


モデル

class SqlLog < ActiveRecord::Base
end

class SqlLogManager
  include Singleton

  attr_accessor :enabled, :client_id, :url

  def save_log(sql, runtime)

    # showとselectの場合はログに書かない。
    # sqlの実行でエラーが出た場合にもログが書かれるため、Mysql::Errorも外す。ここは各DBにあわせて書き換える必要あり
    if sql.starts_with?("SHOW") || sql.starts_with?("SELECT") || sql.starts_with?("Mysql::Error")
      return
    end

    # sqllogをsaveしたときのinser文をまたsqllogとして保存しようとして無限ループするため、一時的に保存を解除する
    enabled_tmp = self.enabled
    self.enabled = false

    log = SqlLog.new(:sql=>sql, :run_time=>runtime, :client_id=>client_id, :url=>url)
    log.save!

    self.enabled = enabled_tmp
  end
end


コントローラ

class ApplicationController < ActionController::Base
  before_filter :bf_application
  def bf_application
    logm = SqlLogManager.instance
    logm.enabled = false

    #adminページのみSQLを保存する場合のif文。
    #ApplicationControllerでなくadminコントローラに書く場合等は不要
    if controller_path.starts_with?("admin") 
      logm.enabled = true
      logm.url = request.request_uri
      logm.client_id = 1#ユーザーIDや管理者ID等をいれれる
    end
  end
end


追記:2008/11/7
last_insert_idがSqlLogのidになってしまう不具合があったので修正しました。
きちゃない修正ですが。。

そもそもですが、この方法をとるのではなく
Railsレシピという本のレシピ59の
実行者と実行内容の記録(ActiveRecordのオブザーバを利用する方法)を採用するほうがスマートだと思いましたorz

ActiveRecordのコールバックでのトランザクション

下記のようにUserを作成した際に
コールバックでLogを自動的に作る場合において
トランザクションの範囲がどうなるかが気になった。

def class User
  def after_create
    UserLog.new.save!
  end
end

実行結果ログ

SQL (0.000301) BEGIN
User Create (0.000586) INSERT INTO ...
UserLog Create (0.000422) INSERT INTO ...\
SQL (0.002717) COMMIT

まとめ

問題なくコールバックで作ったデータもトランザクションの範囲にしてくれた。
単純に1レコード保存する場合でも毎回トランザクションを作ってくれてるみたい。

実行環境

rails2.1.1
mysql

名前空間付きコントローラーでの問題

たいていのサンプルだとコントローラーは1フォルダに全部作られているが、管理画面や、ユーザー画面ごとにフォルダ分けしたいとこだろう。
一応作れるみたいで、ちょいとつまづいたのでメモしときます。
(rails2.1.1で確認)


URL:XXX/u/indexにおいて

  • XXX_controller.rb(親コントローラ)
  • XXX/u_controller.rb(子コントローラ)

というファイル構成にしたい場合に
XXXの名前によって動かない場合があった。


例えばXXXがAの場合

a_controller.rb
class AController < ApplicationController
end

a/u_controller.rb
class A::UController < AController
  def index
    render :text=>"aaaa"
  end
end

XXXのフォルダ名を適当にいくつか試した結果

NGな名前

a
aa
aaa
aaaaa
ab
abcd
abcdef
b

OKな名前

admin
abcde
babc
babcde


とりあえず規則性がわからないためこのような構成の名前空間付きコントローラーは使えなそうだ。(そのうち直るかもしれないけども)
どうしても使いたい場合はroutes.rbでコントローラごとに1個ずつ書いていけばいいが大変。。
map.connect '/a/u/:action/:id', :controller=>"/a/u"


恐らくこの問題は親のコントローラー名が子のコントローラー名と重なるのが問題になると思われる。

例えばa/uというURLの場合
a/controller.rbのuメソッドなのか
a/u_controller.rbのindexメソッドなのかという問題もあるだろう。


なので下記のようなのにすることで解決した。
a/root_controller.rb(親)
a/u_controller.rb(子供)
親クラスの名前を子供の名前と重ならないようにする感じだ。
とりあえずrootって名前をつけたけどなんでもよい。

a/root_controller.rb
class A::RootController < ApplicationController
end

a/u_controller.rb
class A::UController < A::RootController 
  def index
    render :text=>"aaaa"
  end
end

これでとりあえず進めよう。


ちなみにこれの言い方ってどれがメジャーなんだろう。。
適当に思いつくもの一覧

  • 名前空間付きコントローラー
  • 階層化されたコントローラー
  • コントローラーでのフォルダの使用
  • nested controller
  • コントローラをグループ化したモジュール(RailsによるアジャイルWebアプリケーションの開発より)