Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

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による実装に変更することも可能になりました。

参考:

ainame
好きなラーメン二郎は新小金井街道店です. Rubyも好きです.
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした