リクエスト単位でグローバルな参照を持たせてAuditログをスッキリ実装したい

  • 82
    いいね
  • 1
    コメント
この記事は最終更新日から1年以上が経過しています。

Rails等で毎日APIを納品している界隈の皆様方は、リクエスト毎にグローバルな変数を持ちたい時はたまにあるかと思います。例えば、ログを吐くためにRack::Requestオブジェクトをロガーから参照したいとか。モデルやサービス層である処理が呼ばれる毎にリクエスト者のAuditログを残したい場合などそうなりそうです。

class PostEditService
  def initialize(post, request)
    @post, @request = post, request
  end

  def call(new_body)
    @post.body = some_normalize(new_body)
    #...
    Rails.logger.info("[audit] ip: #{@request.ip}, ua: #{@request.user_agent}")
  end
end

サンプルのコードはすごい適当ですが、本当に何も考えずに実装するとこんなかんじで引数で引き回す実装が思い浮かびます。しかし、これではService層に本質と関係ないログを残すためにいちいちrequestオブジェクトをControllerから渡すのはいまいちです。では、グローバルな所から参照できるようになると引き回さずに済んで良さそうです。

こちらも何も考えずに実装すると、こんな感じになっちゃうかもしれません。

class GlobalRequestStore
  cattr_accessor :request
end

# .... 
class ApplicationController
  before_action :store_request

  private
  def store_request
    GlobalRequestStore.request = request
  end
end

# ...
class PostEditService
  def call(new_body)
    #...
    request = GlobalRequestStore.request
    Rails.logger.info("[audit] ip: #{request.ip}, ua: #{request.user_agent}")
  end
end

これだと、controllerから保持するrequestオブジェクトは、RailsのModule#cattr_accessorによってGlobalRequestStoreのクラス変数(@@request)に格納されるだけでスレッドセーフではないです。

じゃあスレッドセーフなら良いのかということで、こういう実装を考えてみます。

class ThreadLocalRequestStore
  def self.request=(req)
    Thread.current[:request] = req
  end

  def self.request
    Thread.current[:request]
  end
end

# .... 
class ApplicationController
  before_action :store_request

  private
  def store_request
    ThreadLocalRequestStore.request = request
  end
end

# ...
class PostEditService
  def call(new_body)
    #...
    request = ThreadLocalRequestStore.request
    Rails.logger.info("[audit] ip: #{request.ip}, ua: #{request.user_agent}")
  end
end

これはThread#[]=メソッドを使っていて、currentのスレッドのローカルなら領域にデータを格納できてます。しかしこれでもまだダメで、1リクエストごとにRubyプロセスが終了してくれなければ、同じスレッドを使って何らかのリクエスト以外の処理が実行された時に前回のリクエストでThreadLocalRequestStoreに格納した値が削除されずに残ってしまっているため、参照されてしまいます。

そこでRequestStore gemの登場です。このgemも上記と同様Thread.current#[]=を利用していて、さらにRack::Middlewareの層で、リクエスト毎にThread.currentの値をクリアしています。まぁ仕組みは単純です良いです。

lib/request_store/middleware.rb
module RequestStore
  class Middleware
    def initialize(app)
      @app = app
    end

    def call(env)
      @app.call(env)
    ensure
      RequestStore.clear!
    end
  end
end

https://github.com/steveklabnik/request_store/blob/master/lib/request_store/middleware.rb

class ApplicationController
  before_action :store_request

  private
  def store_request
    RequestStore.store[:request] = request
  end
end

class PostsController < ApplicationController
  def update
    service = PostEditService.new(Post.find(params[:id])
    service.call(params[:body])
    # ...
  end
end

class PostEditService
  def initialize(post)
    @post = post
  end

  def call(body)
    @post.body = some_normalize(body)
    #...
    ActiveSupport::Notification.instrument('posts.audit')
  end
end

class AuditLogger < ActiveSupport::LogSubscriber
  def posts(event)
    request = RequestStore.store[:request]
    info("[request] - ip: #{request.ip}, ua: #{request.user_agent}")
  end
end

AuditLogSubscriber.attach_to :audit

これでようやくリクエスト毎にグローバルな参照でオブジェクトがやりとり出来るようになり、ログを残すなどのためだけに各種クラスに本質的ではないrequestオブジェクトを引数として引き回すなどの設計を廃止し、ActiveSupport::Notificationによる実装に変更することも可能になりました。

参考: