为 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 文档
  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"
  # define routes here
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 个赞