Структурирование плагина для автозагрузки в Rails

Многие плагины содержат множество определений классов внутри plugin.rb или используют require_relative для загрузки файлов Ruby. Это работает, но имеет ряд недостатков:

  1. Отсутствие автоматической перезагрузки изменений в режиме разработки. Любые изменения требуют полной перезагрузки сервера.
  2. Правильная расстановка вызовов require может быть болезненным процессом.
  3. Если они вызываются через require вне блока after_initialize, то другие автоматически загружаемые классы/модули могут оказаться недоступными.

Есть решение! Плагины могут опираться на стандартную систему автозагрузки Rails. Для новых плагинов всё необходимое уже определено в шаблоне плагина. В этой теме описано, как адаптировать существующий плагин и расширить его конфигурацию.

1. Определите модуль и Rails::Engine для вашего плагина

В файле plugin.rb определите модуль для вашего плагина с уникальным именем PLUGIN_NAME и добавьте строку require_relative для загрузки файла движка, который мы создадим.

# name: my-plugin-name
# ...

module ::MyPluginModule
  PLUGIN_NAME = "my-plugin-name"
end

require_relative "lib/my_plugin_module/engine"

Теперь создайте файл {plugin}/lib/my_plugin_module/engine.rb:

module ::MyPluginModule
  class Engine < ::Rails::Engine
    engine_name PLUGIN_NAME
    isolate_namespace MyPluginModule
  end
end

Важные замечания:

  1. В файле plugin.rb вы должны указать :: перед именем модуля, чтобы определить его в корневом пространстве имён (иначе он будет определён внутри Plugin::Instance).

  2. Строка require_relative "lib/.../engine" должна находиться в корне файла plugin.rb, а не внутри блока after_initialize.

  3. Размещение движка в отдельном файле внутри папки lib/ крайне важно. Определение его непосредственно в файле plugin.rb не сработает. (Rails использует наличие директории lib/ для определения корня движка).

  4. Путь к файлу должен включать имя модуля в соответствии с правилами Zeitwerk.

  5. engine_name используется как префикс для задач rake и любых маршрутов, определяемых движком ([:link: документация Rails]).

  6. isolate_namespace помогает предотвратить утечку данных между ядром и плагином ([:link: документация Rails]).

2. Определите файлы Ruby в правильной структуре директорий

Теперь движок будет автоматически загружать все файлы в {plugin}/app/{type}/*. Например, мы можем определить контроллер:

{plugin}/app/controllers/my_plugin_module/examples_controller.rb

module ::MyPluginModule
  class ExamplesController < ::ApplicationController
    requires_plugin PLUGIN_NAME

    def index
      render json: { hello: "world" }
    end
  end
end

Теперь этот класс будет автоматически загружаться всякий раз, когда Rails пытается получить доступ к ::MyPluginModule::MyController. Чтобы проверить работу, попробуйте обратиться к этому классу из консоли Rails.

Для корректной работы автозагрузки пути к файлам должны соответствовать полной иерархии модулей/классов согласно правилам, определённым Zeitwerk.

3. Определение маршрутов для движка плагина

Создайте файл {plugin}/config/routes.rb:

MyPluginModule::Engine.routes.draw do
  get "/examples" => "examples#index"
  # определите маршруты здесь
end

Discourse::Application.routes.draw do
  mount ::MyPluginModule::Engine, at: "my-plugin"
end

Этот файл будет автоматически загружен движком, и изменения вступят в силу без перезагрузки сервера. В данном случае действие контроллера будет доступно по адресу /my-plugin/examples.json.

4. Добавление дополнительных путей для автозагрузки

Иногда может потребоваться добавить дополнительные директории с автоматически загружаемыми файлами Ruby. Наиболее частый пример — директория lib/ в плагине.

Измените определение движка, добавив lib/ в пути автозагрузки движка:

class Engine < ::Rails::Engine
  engine_name PLUGIN_NAME
  isolate_namespace MyPluginModule
  config.autoload_paths << File.join(config.root, "lib")
end

Теперь вы можете определить модуль lib, например:

{plugin}/lib/my_plugin_module/some_lib_module.rb

module ::MyPluginModule::SomeLibModule
end

Теперь любые ссылки на ::MyPluginModule::SomeLibModule будут автоматически загружать модуль из этого файла.

5. Прибыль!

Теперь все эти файлы будут автоматически загружаться без явных вызовов require. Изменения будут автоматически обнаруживаться Rails и перезагружаться на месте без перезагрузки сервера.


Этот документ находится под версионным контролем — предлагайте изменения на GitHub.

17 лайков

Дэвид, что послужило поводом для этой документации? Изменения в вышестоящем звене?

Не припоминаю, чтобы мне когда-либо приходилось беспокоиться о поддержании определённой структуры для обеспечения автоматической перезагрузки, или, возможно, мне просто повезло, что я следовал нужной структуре? …

1 лайк

Нет изменений в upstream — просто документируем структуру, которую мы использовали в нескольких плагинах (например, anonymous-moderators, client-performance), и хотели бы начать применять её в большем количестве наших плагинов.

Если вы согласны с тремя недостатками, указанными в начале оригинального сообщения, то да, подойдёт любая структура. Если же вы будете следовать этой структуре, эти проблемы будут решены (и опыт разработчика станет значительно более плавным!)

2 лайка

Да, автоматическая перезагрузка очень желательна.

Спасибо за уточнение и документацию!

1 лайк

Также для ясности: когда я говорю «автозагрузка», я имею в виду возможность Rails (через Zeitwerk) почти мгновенно подхватывать изменения в коде в запущенном процессе без необходимости перезапуска. Если в вашем файле plugin.rb есть вызовы load или require, то ваш плагин, скорее всего, не использует автозагрузку.

Отдельно стоит отметить, что в Discourse есть вспомогательный механизм, который обнаруживает изменения в неперезагружаемых файлах Ruby и автоматически выполняет полную перезагрузку сервера. Это занимает несколько секунд. Когда эта система срабатывает, в консоли вы увидите что-то вроде следующего:

[DEV]: Edited files which are not autoloaded. Restarting server...
       - plugins/discourse-follow/plugin.rb
2 лайка

Ах!! Вот именно это различие я упустил, спасибо! Меня устраивала просто автоматическая перезагрузка, но это действительно круто!

2 лайка

Дэвид,

У меня возникла интересная проблема, которую, как мне кажется, я уже решил. Не мог бы ты, если у тебя есть время, прокомментировать?

У меня была константа, которая определялась только один раз в модуле моего плагина внутри /lib/locations.

При каждой пересборке я получал следующее ошибку предупреждение:

image

warning: already initialized constant Locations::REQUEST_PARTS
warning: уже предыдущее определение REQUEST_PARTS было здесь

Перемещение этого в plugin.rb таким образом, судя по всему, решает проблему:

Но… почему?

Мое предположение: plugin.rb оценивается только один раз, а любой код в /lib/plugin-module может оцениваться несколько раз…? Но тогда почему он не жалуется на другой код?

1 лайк

Да, точно.

Когда Rails/Zeitwerk перезагружает код, они выполняют remove_const :Blah, а затем загружают файл с соответствующим именем. Так что для вашего файла lib/locations/geocode.rb автозагрузчик делает что-то вроде:

Locations.remove_const :Geocode
load "lib/locations/geocode.rb"

Именно поэтому вы не получаете ошибку о том, что константа Geocode уже определена — Rails/Zeitwerk автоматически удаляют её перед перезагрузкой файла.

Однако автозагрузчик не может знать, что ваш файл также определяет Locations::REQUEST_PARTS, поэтому он не выполнил remove_const перед загрузкой.

Таким образом, если вы хотите оставить константу в файле geocode.rb, вы можете переместить REQUEST_PARTS внутрь класса Geocode (Locations::Geocode::REQUEST_PARTS).

Но если вы хотите оставить её как Locations::REQUEST_PARTS, то, на мой взгляд, имеет смысл перенести её в plugin.rb. Или, если вы хотите сделать её полностью автозагружаемой, возможно, стоит перенести определение модуля ::Locations, включая константу REQUEST_PARTS, в отдельный файл, например lib/locations.rb.

3 лайка

Это имеет полный смысл, спасибо!

Кажется, есть ещё один момент?

Также ли справедливо утверждать, что явная загрузка этих файлов в plugin.rb, как в примере ниже, не требуется, поскольку они находятся в структуре, которая обрабатывается автозагрузкой?

after_initialize do
  %w(
    ../app/models/location_country_default_site_setting.rb
    ../app/models/location_geocoding_language_site_setting.rb
    ../app/models/locations/user_location.rb
    ../app/models/locations/topic_location.rb
**    ../lib/locations/user_location_process.rb**
**    ../lib/locations/topic_location_process.rb**
**    ../lib/locations/country.rb**
**    ../lib/locations/geocode.rb**
**    ../lib/locations/helper.rb**
**    ../lib/locations/map.rb**
    ../lib/users_map.rb
    ../app/serializers/locations/geo_location_serializer.rb
    ../app/controllers/locations/geocode_controller.rb
    ../app/controllers/locations/users_map_controller.rb
  ).each do |path|
    load File.expand_path(path, __FILE__)
  end

Поскольку они также обрабатываются автозагрузкой в рамках структуры?

Да, так как /lib включен в вашу конфигурацию автозагрузки, вручную load их не нужно :100:

2 лайка

Кто не любит хорошее упрощение кода?! :chefs_kiss:

1 лайк