まえおき
そこそこ大規模なRailsアプリを触っているといろんなモデルの状態を参照する魔のpartialを目にすることが稀によくあります。
_employees.html.erb というファイル名なので、Employeeを表示するだけ……かとおもいきや、評価だったりプロフィールだったり契約数だったり、たくさんのモデルを参照して頑張って表示してる!みたいなやつ。
一覧画面ではN+1問題がワンチャン起きてて、呼び元も呼び先もまぁまぁ規模があったりすると
EmployeesController#index
employees/index.html.erb
→ employees/_search_result.html.erb (600行くらい)
→ each { shared_partials/_employee.erb } (300行くらい)
→その先で読んでいる EmployeeHelperのメソッドたち... (200行くらい)
結局なにをpreload/eager_loadすればN+1が回避できるの???ってのを調べるのにかなり苦慮します。
(というか、ここ1ヶ月くらいそれでかなり苦慮していました )
その際に「ViewからカジュアルにSQLを叩いている場所を列挙する」っていうことをやると、割と効率的に調べることができたので、なんとなくその方法を書いておきます。
「おー、これは役に立つわ!」って人は多分ほとんど居ない気がするけど、Railsでこんなことできるんだ、くらいで思っていただければ幸いです。
シンプルな例で説明する
魔のパーシャルはサンプルコードを書くのもつらいので、おもちゃみたいなシンプルな例で説明します。
N+1を起こす例
class User < ApplicationRecord
has_one :profile
end
class Profile < ApplicationRecord
belongs_to :user
def full_name
"#{first_name} #{last_name}"
end
end
class UsersController < ApplicationController
def index
@users = User.order(created_at: :desc)
end
end
<% if @users.present? %>
<ul>
<%= render partial: 'shared_partials/user', collection: @users %>
</ul>
<% end %>
<li><%= user.profile.display_name %></li>
イメージ、こんな感じで名前をずらーーっと出すだけ。
Railsコンソールを見てみると
Processing by UsersController#index as HTML
User Load (0.4ms) SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 20 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 12 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 13 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 14 LIMIT 1
Profile Load (1.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 15 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 16 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 17 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 18 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 19 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 11 LIMIT 1
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 10 LIMIT 1
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 9 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 8 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 7 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 5 LIMIT 1
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 6 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 4 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 3 LIMIT 1
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 2 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 1 LIMIT 1
Rendered shared_partials/_user.html.erb (23.2ms)
Rendered users/index.html.erb within layouts/application (26.1ms)
Completed 200 OK in 29ms (Views: 22.5ms | ActiveRecord: 5.8ms)
(ノ∀`)アチャー N+1が起きてます。
パーシャルViewから叩かれているSQLはどれ??
先述のおもちゃな例だと
- Userのロード
User Load (0.4ms) SELECT
users.* FROM
usersORDER BY
users.
created_atDESC
× 1回 - Profileのロード
Profile Load (0.1ms) SELECT
profiles.* FROM
profilesWHERE
profiles.
user_id= 7 LIMIT 1
× Userの数
のSQL呼び出しがされています。このうち、パーシャルViewから叩かれているのはどれでしょう?
・・・正解は、全部です。
ActiveRecordは遅延評価が基本なので、 @users = User.order(created_at: :desc)
の時点ではSQLは叩かれず、ビュー側で必要となったときに初めてSQLが叩かれます。
ViewからカジュアルSQL叩いている場所を見つける
ようやく本題です。
ビューからSQLが叩かれている、ということを見つけ出すにはどうしましょう?という話です。
実は、単純にSQL叩いている場所を見つけるだけなら、ActiveRecord::Causeを入れることで、
User Load (0.4ms) SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC
User Load (ActiveRecord::Cause) caused by /home/ubuntu/workspace/app/views/users/index.html.erb:1:in `_app_views_users_index_html_erb__2451733280735124226_22222060'
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 20 LIMIT 1
Profile Load (ActiveRecord::Cause) caused by /home/ubuntu/workspace/app/views/shared_partials/_user.html.erb:1:in `_app_views_shared_partials__user_html_erb___725293523527224828_69998977673900'
:
:
Profile Load (ActiveRecord::Cause) caused by /home/ubuntu/workspace/app/views/shared_partials/_user.html.erb:1:in `_app_views_shared_partials__user_html_erb___725293523527224828_69998977673900'
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 2 LIMIT 1
Profile Load (ActiveRecord::Cause) caused by /home/ubuntu/workspace/app/views/shared_partials/_user.html.erb:1:in `_app_views_shared_partials__user_html_erb___725293523527224828_69998977673900'
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 1 LIMIT 1
Profile Load (ActiveRecord::Cause) caused by /home/ubuntu/workspace/app/views/shared_partials/_user.html.erb:1:in `_app_views_shared_partials__user_html_erb___725293523527224828_69998977673900'
のようなログ出力からSQLを叩いている場所をある程度特定することはできます。
しかし今回は
- コントローラからSQL叩いているところは別にいい。ビューから叩いているところだけ見たい
- 全部の処理じゃなくて、特定のコントローラの特定のアクションに絞って、ビューからSQL叩くのを検出したい
-
#show
アクションのようにN+1がそもそも起きない(起きづらい)ところでは無理してSQL叩くのを抑制しなくてもいい - 知らないコントローラのSQLログは出したくない
-
という要件です。
で、「上記の要件を満たすようなGemを新たに作ったぜ、ほい」だと記事として全然おもしろくないので、この記事では、車輪の再発明なところはあるかもしれないけど、それっぽいのを実現する仕組みを作った様子を順を追って書いていきます。
ActiveRecord::CauseがSQLを叩いている場所を特定している仕組みを探る
https://github.com/joker1007/activerecord-cause/blob/master/lib/activerecord/cause.rb を見ると ActiveRecord::LogSubscriber
という文字が見えるので、「ActiveSupport::Notificationのサブスクリプションで実現しているんだなー」ってのがなんとなくわかります。
ためしに
class HogeSubscriber < ActiveSupport::Subscriber
def sql(event)
Rails.logger.debug("sql: #{event.payload}")
end
end
class FugaSubscriber < ActiveSupport::Subscriber
def start_processing(event)
Rails.logger.debug("start_processing: #{event.payload}")
end
def process_action(event)
Rails.logger.debug("process_action: #{event.payload}")
end
end
HogeSubscriber.attach_to :active_record
FugaSubscriber.attach_to :action_controller
のようにサブスクライバをinitializerで雑にアタッチさせてアプリケーションを実行すると・・・
Started GET "/users" for 115.69.238.232 at 2018-12-18 10:26:07 +0000
Cannot render console from 115.69.238.232! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
sql: {:sql=>"SHOW TABLES LIKE 'schema_migrations'", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
ActiveRecord::SchemaMigration Load (0.2ms) SELECT `schema_migrations`.* FROM `schema_migrations`
sql: {:sql=>"SELECT `schema_migrations`.* FROM `schema_migrations`", :name=>"ActiveRecord::SchemaMigration Load", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
sql: {:sql=>"SHOW FULL FIELDS FROM `schema_migrations`", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
start_processing: {:controller=>"UsersController", :action=>"index", :params=>{"controller"=>"users", "action"=>"index"}, :format=>:html, :method=>"GET", :path=>"/users"}
Processing by UsersController#index as HTML
User Load (0.3ms) SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC
sql: {:sql=>"SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC", :name=>"User Load", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
sql: {:sql=>"SHOW FULL FIELDS FROM `users`", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
sql: {:sql=>"SHOW TABLES ", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
sql: {:sql=>"SHOW CREATE TABLE `users`", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
sql: {:sql=>"SHOW FULL FIELDS FROM `profiles`", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (20, 12, 13, 14, 15, 16, 17, 18, 19, 11, 10, 9, 8, 7, 5, 6, 4, 3, 2, 1)
sql: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (20, 12, 13, 14, 15, 16, 17, 18, 19, 11, 10, 9, 8, 7, 5, 6, 4, 3, 2, 1)", :name=>"Profile Load", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
sql: {:sql=>"SHOW CREATE TABLE `profiles`", :name=>"SCHEMA", :connection_id=>21111960, :statement_name=>nil, :binds=>[]}
Rendered shared_partials/_user.html.erb (23.2ms)
Rendered users/index.html.erb within layouts/application (27.7ms)
process_action: {:controller=>"UsersController", :action=>"index", :params=>{"controller"=>"users", "action"=>"index"}, :format=>:html, :method=>"GET", :path=>"/users", :status=>200, :view_runtime=>34.6481842715888, :db_runtime=>3.6058969999999997}
Completed 200 OK in 51ms (Views: 34.6ms | ActiveRecord: 3.6ms)
いろいろログに出力されます。
ActiveRecord::Cause は sql.active_record
を利用してSQLクエリ発行イベントを受けた際に Kernel#caller_locations
というコールスタックを得るメソッドを使って、それっぽい(あらかじめconfigしておいたパスにマッチする)呼び出し箇所を抽出する&ログ出力する、ということをやっています。
caller_locationsには具体的にどんな値が入っているか見てみる
ActiveRecord::Causeのコードでは caller_locationsに対して正規表現でマッチしたものを取得する、という処理があります。
def get_locations
return [] if ActiveRecord::Cause.match_paths.empty?
caller_locations.select do |l|
ActiveRecord::Cause.match_paths.any? do |re|
re.match(l.absolute_path)
end
end
end
でも、せっかくなので、そもそもこのcaller_locationsにはどういう値が入っているのか見ておこうと思います。
これも、とりあえず雑にイニシャライザでサブスクライバを仕掛けてみます。
class HogeSubscriber < ActiveSupport::Subscriber
def sql(event)
return if ["SCHEMA", "EXPLAIN"].include?(event.payload[:name])
caller_locations.each do |loc|
Rails.logger.debug("caller_location:#{loc}")
end
Rails.logger.debug("sql: #{event.payload}")
end
end
HogeSubscriber.attach_to :active_record
sql: {:sql=>"SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC", :name=>"User Load", :connection_id=>29621900, :statement_name=>nil, :binds=>[]}
Profile Load (0.5ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (20, 12, 13, 14, 15, 16, 17, 18, 19, 11, 10, 9, 8, 7, 5, 6, 4, 3, 2, 1)
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activesupport-4.2.10/lib/active_support/subscriber.rb:100:in `finish'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activesupport-4.2.10/lib/active_support/notifications/fanout.rb:102:in `finish'
〜(中略)〜
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activerecord-4.2.10/lib/active_record/relation.rb:243:in `to_a'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activerecord-4.2.10/lib/active_record/relation.rb:622:in `blank?'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activesupport-4.2.10/lib/active_support/core_ext/object/blank.rb:24:in `present?'
caller_location:/home/ubuntu/workspace/app/views/users/index.html.erb:1:in `_app_views_users_index_html_erb___1313782717592081990_69842311333420'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/actionview-4.2.10/lib/action_view/template.rb:145:in `block in render'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activesupport-4.2.10/lib/active_support/notifications.rb:166:in `instrument'
〜(中略)〜
caller_location:/usr/local/rvm/rubies/ruby-2.4.5/lib/ruby/2.4.0/webrick/httpserver.rb:140:in `service'
caller_location:/usr/local/rvm/rubies/ruby-2.4.5/lib/ruby/2.4.0/webrick/httpserver.rb:96:in `run'
caller_location:/usr/local/rvm/rubies/ruby-2.4.5/lib/ruby/2.4.0/webrick/server.rb:308:in `block in start_thread'
sql: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (20, 12, 13, 14, 15, 16, 17, 18, 19, 11, 10, 9, 8, 7, 5, 6, 4, 3, 2, 1)", :name=>"Profile Load", :connection_id=>29621900, :statement_name=>nil, :binds=>[]}
Rendered users/index.html.erb within layouts/application (38.3ms)
Completed 200 OK in 63ms (Views: 45.6ms | ActiveRecord: 3.9ms)
Railsのバージョンによって細かいところは違うと思いますが、
caller_location:/home/ubuntu/workspace/app/views/users/index.html.erb:1:in `_app_views_users_index_html_erb___1313782717592081990_69842311333420'
app/viewsから呼ばれているなー、ってのが改めてわかります。
じゃあ、仮にビューからSQLが叩かれないようなコードにした場合はどうなるでしょう。
# app/views/shared_partials/_user.html.erbで使用する変数をまとめた、単なるValue Object
class SharedPartialUserViewModel
def initialize(full_name:)
@full_name = full_name
end
attr_reader :full_name
end
のように、パーシャルビューに必要なデータをまとめたビューモデルクラスをActiveRecord非依存な形で定義し
class UsersController < ApplicationController
def index
users = User.order(created_at: :desc).includes(:profile)
@user_view_models = users.map{|u| SharedPartialsUserViewModel.new(full_name: u.profile.full_name }
end
のように変えてからログを見てみます。
Profile Load (0.6ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (20, 12, 13, 14, 15, 16, 17, 18, 19, 11, 10, 9, 8, 7, 5, 6, 4, 3, 2, 1)
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activesupport-4.2.10/lib/active_support/subscriber.rb:100:in `finish'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activesupport-4.2.10/lib/active_support/notifications/fanout.rb:102:in `finish'
〜(中略)〜
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/activerecord-4.2.10/lib/active_record/relation/delegation.rb:46:in `map'
caller_location:/home/ubuntu/workspace/app/controllers/users_controller.rb:6:in `index'
caller_location:/usr/local/rvm/gems/ruby-2.4.5/gems/actionpack-4.2.10/lib/action_controller/metal/implicit_render.rb:4:in `send_action'
〜(中略)〜
caller_location:/usr/local/rvm/rubies/ruby-2.4.5/lib/ruby/2.4.0/webrick/httpserver.rb:140:in `service'
caller_location:/usr/local/rvm/rubies/ruby-2.4.5/lib/ruby/2.4.0/webrick/httpserver.rb:96:in `run'
caller_location:/usr/local/rvm/rubies/ruby-2.4.5/lib/ruby/2.4.0/webrick/server.rb:308:in `block in start_thread'
sql: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (20, 12, 13, 14, 15, 16, 17, 18, 19, 11, 10, 9, 8, 7, 5, 6, 4, 3, 2, 1)", :name=>"Profile Load", :connection_id=>29621900, :statement_name=>nil, :binds=>[]}
Rendered users/index.html.erb within layouts/application (0.2ms)
Completed 200 OK in 34ms (Views: 6.5ms | ActiveRecord: 5.4ms)
お。期待通り
caller_location:/home/ubuntu/workspace/app/controllers/users_controller.rb:6:in `index'
になりました。app/viewsのファイルからの呼び出しが無くなってることがわかります。
以上のことから、ビューからSQLを叩いているかどうかは
-
sql.active_record
のサブスクリプション - caller_locations の中に
app/views/...
がある?
を組み合わせることで判別できそうです。
特定のコントローラの特定のアクションでのみ、ビューからのSQL叩きを検出したい
ビューからSQLを叩いている箇所だけをログ出力するということはできました。
しかし、単純に attach_to
で登録する方法だと、全部のコントローラの全アクションが対象になってしまいます。
class UsersController < ApplicationController
def index
@users = ...
end
def show
@user = User.find(params[:id])
end
end
のようなコントローラが有って「showアクションは対象外にしたい・・・」みたいなケースも大いにあるので、まずは特定のコントローラの特定のアクションだけをSQL監視対象にするようにしてみます。
またしても、雑にイニシャライザで・・・
class HogeSubscriber < ActiveSupport::Subscriber
def sql(event)
return if ["SCHEMA", "EXPLAIN"].include?(event.payload[:name])
if caller_locations.any?{|loc| loc.absolute_path.include?("app/views") }
Rails.logger.debug("SQL in view: #{event.payload}")
end
end
end
class ActionControllerSubscriber < ActiveSupport::Subscriber
def start_processing(event)
payload = event.payload
if payload[:controller] == UsersController.to_s && payload[:action] == 'index'
subscriptions[key_for(payload)] ||= ActiveSupport::Notifications.subscribe("sql.active_record", HogeSubscriber.new)
end
end
def process_action(event)
payload = event.payload
if payload[:controller] == UsersController.to_s && payload[:action] == 'index'
subscriptions.delete(key_for(payload)).try do |s|
ActiveSupport::Notifications.unsubscribe(s)
end
end
end
private
def key_for(payload)
"#{payload[:controller]}##{payload[:action]}"
end
def subscriptions
@__subscriptions ||= {}
end
end
ActionControllerSubscriber.attach_to :action_controller
今回は、action_controllerのサブスクリプションをメインに使ってみました。
start_processing.action_controller
と process_action.action_controller
が各リクエスト・レスポンスのトランザクションの開始と終了になることを利用し、start_processingでsubscribe, process_actionでunsubscribeするようにしました。
Started GET "/users/1" for 115.69.238.232 at 2018-12-23 13:51:17 +0000
Cannot render console from 115.69.238.232! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#show as HTML
Parameters: {"id"=>"1"}
User Load (0.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 1 LIMIT 1
Rendered users/show.html.erb within layouts/application (75.3ms)
Completed 200 OK in 106ms (Views: 36.1ms | ActiveRecord: 54.3ms)
Started GET "/users" for 115.69.238.232 at 2018-12-23 13:51:21 +0000
Cannot render console from 115.69.238.232! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#index as HTML
User Load (0.3ms) SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC
SQL in view: {:sql=>"SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC", :name=>"User Load", :connection_id=>20179940, :statement_name=>nil, :binds=>[]}
Profile Load (0.2ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 20 LIMIT 1
SQL in view: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 20 LIMIT 1", :name=>"Profile Load", :connection_id=>20179940, :statement_name=>nil, :binds=>[]}
Profile Load (0.3ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 12 LIMIT 1
SQL in view: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 12 LIMIT 1", :name=>"Profile Load", :connection_id=>20179940, :statement_name=>nil, :binds=>[]}
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 13 LIMIT 1
SQL in view: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 13 LIMIT 1", :name=>"Profile Load", :connection_id=>20179940, :statement_name=>nil, :binds=>[]}
:
:
Profile Load (0.1ms) SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 1 LIMIT 1
SQL in view: {:sql=>"SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 1 LIMIT 1", :name=>"Profile Load", :connection_id=>20179940, :statement_name=>nil, :binds=>[]}
Rendered shared_partials/_user.html.erb (23.6ms)
Rendered users/index.html.erb within layouts/application (26.9ms)
Completed 200 OK in 30ms (Views: 24.8ms | ActiveRecord: 4.4ms)
期待通り、UsersControllerのindexのみを対象として、ViewからカジュアルにSQLを叩いているのをログ出力できました。
コントローラ側で「このアクションをSQL監視したい」と書けるようにする
「さすがにイニシャライザで各コントローラのことを書くのはちょっと・・・」と誰しもが感じるでしょう。
コントローラ側で
class UsersController < ApplicationController
observe_sql_in_view only: [:index]
def index
@users = User.order(created_at: :desc).includes(:profile)
end
def show
@user = User.find(params[:id])
end
end
のように明示的に書けたら理想的ですね。ということでいざトライ
# HogeSubscriberの定義は前と同じなので省略
class ActionControllerSubscriber < ActiveSupport::Subscriber
class << self
def target
@@target ||= {}
end
def add_target(controller:, action:)
target[key_for(controller, action)] = 1
end
def target?(controller:, action:)
target[key_for(controller, action)] == 1
end
def key_for(controller, action)
"#{controller}##{action}"
end
end
def start_processing(event)
payload = event.payload
if ActionControllerSubscriber.target?(controller: payload[:controller], action: payload[:action])
subscriptions[key_for(payload)] ||= ActiveSupport::Notifications.subscribe("sql.active_record", HogeSubscriber.new)
end
end
def process_action(event)
payload = event.payload
subscriptions.delete(key_for(payload)).try do |s|
ActiveSupport::Notifications.unsubscribe(s)
end
end
private
def key_for(payload)
ActionControllerSubscriber.key_for(payload[:controller], payload[:action])
end
def subscriptions
@__subscriptions ||= {}
end
end
class ActionController::Base
def self.observe_sql_in_view(only:)
only.each do |action|
ActionControllerSubscriber.add_target(controller: self.to_s, action: action)
end
end
end
ActionControllerSubscriber.attach_to :action_controller
対象とするコントローラ・アクションはクラス変数で保持し、ベタで文字列比較していたところを置き換えました。
ログは変わらずです。
SQL in view のログ出力を少しだけまともにする
SQL in view: {:sql=>"SELECT `users`.* FROM `users` ORDER BY `users`.`created_at` DESC", :name=>"User Load", :connection_id=>16084600, :statement_name=>nil, :binds=>[]}
はちょっと雑すぎるので、少しだけまともにしましょう。HogeSubscriberを
- color を使って目立たせる
- SQL以外の情報やJSON構造はログ出力しない
- クラス名をまともにする
のように、少し改変する。
class SqlInViewSubscriber < ActiveSupport::LogSubscriber
IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"]
def sql(event)
payload = event.payload
log_sql_in_view(payload) unless IGNORE_PAYLOAD_NAMES.include?(payload[:name])
end
private
def log_sql_in_view(payload)
location = caller_locations.find{|loc| loc.absolute_path.include?("app/views/")}
if location.present?
logger.info("#{color("SQL in view", RED)}: #{payload[:sql]} caused by #{location}")
end
end
end
実際のログ出力はこんな感じになります
それっぽい感じになったぞー\(^o^)/わーい
まとめ
class SqlInViewSubscriber < ActiveSupport::LogSubscriber
IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"]
def sql(event)
payload = event.payload
log_sql_in_view(payload) unless IGNORE_PAYLOAD_NAMES.include?(payload[:name])
end
private
def log_sql_in_view(payload)
location = caller_locations.find{|loc| loc.absolute_path.include?("app/views/")}
if location.present?
logger.info("#{color("SQL in view", RED)}: #{payload[:sql]} caused by #{location}")
end
end
end
class SqlInViewControllerSubscriber < ActiveSupport::Subscriber
class << self
def target
@@target ||= {}
end
def add_target(controller:, action:)
target[key_for(controller, action)] = 1
end
def target?(controller:, action:)
target[key_for(controller, action)] == 1
end
def key_for(controller, action)
"#{controller}##{action}"
end
end
def start_processing(event)
payload = event.payload
if SqlInViewControllerSubscriber.target?(controller: payload[:controller], action: payload[:action])
subscriptions[key_for(payload)] ||= ActiveSupport::Notifications.subscribe("sql.active_record", SqlInViewSubscriber.new)
end
end
def process_action(event)
payload = event.payload
subscriptions.delete(key_for(payload)).try do |s|
ActiveSupport::Notifications.unsubscribe(s)
end
end
private
def key_for(payload)
SqlInViewControllerSubscriber.key_for(payload[:controller], payload[:action])
end
def subscriptions
@__subscriptions ||= {}
end
end
class ActionController::Base
def self.observe_sql_in_view(only:)
only.each do |action|
SqlInViewControllerSubscriber.add_target(controller: self.to_s, action: action)
end
end
end
SqlInViewControllerSubscriber.attach_to :action_controller
Railsのロード時に のようにパッチを当てて
class UsersController < ApplicationController
observe_sql_in_view only: [:index]
def index
...
end
end
のように observe_sql_in_view
で、「ViewからカジュアルにSQLを叩いている」ってのを検出したいアクションを指定すると
こんな感じでログに出て、べんり。