为 Rails 自动加载构建插件结构

许多插件在 plugin.rb 中包含大量类定义,或使用 require_relative 来加载 ruby 文件。这可行,但也有一些缺点:

  1. 在开发环境中,更改不会自动重新加载。任何更改都需要完全重启服务器
  2. 使 require 调用顺序正确可能会很痛苦
  3. 如果它们在 after_initialize 块之外被 require,那么其他自动加载的类/模块可能不可用

有一个解决方案!插件可以利用标准的 Rails 自动加载系统。对于新插件,您需要的一切都在 plugin-skeleton 中定义。本文介绍如何修改现有插件并扩展配置。

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 docs

  6. isolate_namespace 有助于防止核心和插件之间发生泄漏(:link: Rails docs

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 个赞

没有上游更改 - 只是记录我们已在多个插件中使用的结构(例如 anonymous-moderatorsclient-performance),并且希望在更多插件中使用它。

如果您接受 OP 开头的三个缺点,那么任何结构都可以。如果您遵循此结构,这些问题将得到解决(并且开发人员体验应该会顺畅得多!)

2 个赞

是的,自动重新加载非常理想。

感谢您的澄清和文档!

1 个赞

另外需要明确的是——当我提到“自动加载”时,我指的是 Rails 的能力(通过 Zeitwerk),可以在不重新启动的情况下几乎即时地将代码更改拉入正在运行的进程。如果你在 plugin.rb 文件中有 loadrequire 调用,那么你的插件几乎肯定没有使用自动加载。

另外,Discourse 有一个助手,可以检测到不可重新加载的 Ruby 文件的更改,并自动执行完整的服务器重启。这需要几秒钟。当此系统被触发时,你会在控制台中看到类似以下内容:

[DEV]: 编辑了不可自动加载的文件。正在重启服务器...
       - plugins/discourse-follow/plugin.rb
2 个赞

啊!!这就是我错过的区别,谢谢!我对自动重启感到满意,但这真的很酷!

2 个赞

大卫,

我遇到了一个有趣的问题,我认为我已经解决了,不知道您是否有时间评论一下?

我有一个常量,它只在我的插件模块中定义一次,位于 /lib/locations

每次重建时,我都会收到以下错误警告:

image

warning: already initialized constant Locations::REQUEST_PARTS
warning: already previous definition of REQUEST_PARTS was here

将其移至 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 是有意义的。或者,如果您想使其完全可自动加载,您可以将包括 REQUEST_PARTS 常量在内的 module ::Locations 定义移到一个单独的文件中,例如 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 个赞