zed.0xff.me

Особенности кэширования классов в Rails

Задача

Вот есть например у нас некий самописный класс для взаимодействия со сторонним API:

1
2
3
4
5
6
7
class SomeAPIClient
  def initialize params = {}
    @host  = params[:host]  || 'some.api.host'
    @port  = params[:port]  || 12345
  end
...
end

И кладем мы его в #{RAILS_ROOT}/lib/, чтобы его можно было легко юзать из классов рельсового приложения.

Дальше мы задумываемся о том, что из нашего Rails-приложения, мы будем всегда обращаться к одним и тем же хосту и порту апи-сервера. И надо где-то их вконфигурить. А приложение-то у нас opensource, и хостится на github-e, поэтому хост и порт (и возможно какие-то другие параметры типа api-key) у нас для разных юзеров приложения могут быть разными.

Отсюда вопрос: Как бы это сконфигурить так, чтобы и либу не особо корежить, и чтоб наиболее Rails-way было?..

Мне вот лично пришла в голову следующая мысль:

1
2
3
4
5
6
7
8
9
class SomeAPIClient
  cattr_accessor :default_port, :default_host

  def initialize params = {}
    @host  = params[:host] || default_host || 'some.api.host'
    @port  = params[:port] || default_port || 12345
  end
...
end

Проблема

Всё вроде бы красиво, свои значения для default_host & default_port мы назначаем в #{RAILS_ROOT}/config/initializers/my_api_client.rb, который исключаем из git-репозитария путём помещения в .gitignore.

Но! Но не зря в топике написано про кэширование классов.. в development окружении первый запрос к API выполняется отлично, а вот уже на втором и всех следующих – вступает в действие config.cache_classes = false, рельсы перечитывают декларацию нашего апи, при этом удаляя старый класс из памяти через remove_const. Ну а initializers естественно не перечитывают.

В production всё ОК, потому что там обычно config.cache_classes = true.

Решение

Копаясь во внутренностях Rails обнаружил вот это:
( actionpack-2.3.2/lib/action_controller/dispatcher.rb )

1
2
3
4
5
6
7
8
9
10
11
      # Add a preparation callback. Preparation callbacks are run before every
      # request in development mode, and before the first request in production
      # mode.
      #
      # An optional identifier may be supplied for the callback. If provided,
      # to_prepare may be called again with the same identifier to replace the
      # existing callback. Passing an identifier is a suggested practice if the
      # code adding a preparation block may be reloaded.

      def to_prepare(identifier = nil, &block)
...

Таким образом, чтобы решение стало работоспособным надо обернуть нашу инициализацию в do_prepare:
( #{RAILS_ROOT}/config/initializers/my_api_client.rb )

1
2
3
4
ActionController::Dispatcher.to_prepare(:my_api) do
  SomeAPIClient.default_host       = 'my.api.host'
  SomeAPIClient.default_port       = '23456'
end

И в development mode этот код будет срабатывать перед обработкой каждого запроса, а в production – только перед обработкой самого первого запроса.

Done.