从 Classic 到 Zeitwerk 的迁移指南

本指南介绍如何将 Rails 应用程序从 `classic` 模式迁移到 `zeitwerk` 模式。

阅读完本指南后,您将了解以下内容:


1 `classic` 和 `zeitwerk` 模式是什么?

从一开始到 Rails 5,Rails 使用了在 Active Support 中实现的自动加载器。该自动加载器被称为 `classic`,它仍然可以在 Rails 6.x 中使用。Rails 7 不再包含此自动加载器。

从 Rails 6 开始,Rails 附带了一种新的、更好的自动加载方式,它委托给 Zeitwerk gem。这是 `zeitwerk` 模式。默认情况下,加载 Rails 6.0 和 6.1 框架默认值的应用程序在 `zeitwerk` 模式下运行,这也是 Rails 7 中唯一可用的模式。

2 为什么要从 `classic` 切换到 `zeitwerk`?

`classic` 自动加载器非常有用,但它存在一些 问题,使自动加载有时变得有些棘手和令人困惑。Zeitwerk 的开发旨在解决这些问题,以及其他一些 动机

升级到 Rails 6.x 时,强烈建议切换到 `zeitwerk` 模式,因为它是一个更好的自动加载器,`classic` 模式已弃用。

Rails 7 结束了过渡期,不再包含 `classic` 模式。

3 我害怕

别担心 :)

Zeitwerk 的设计尽可能地与 classic 自动加载器兼容。如果您现在有一个正常工作的应用程序,并且自动加载正确,那么切换过程很可能很简单。许多项目,无论是大项目还是小项目,都报告了非常顺利的切换。

本指南将帮助您充满信心地更改自动加载器。

如果由于某种原因,您发现了一种您不知道如何解决的情况,请不要犹豫,在 `rails/rails` 中创建一个新问题,并标记 @fxn

4 如何激活 `zeitwerk` 模式

4.1 运行 Rails 5.x 或更低版本的应用程序

在运行早于 6.0 的 Rails 版本的应用程序中,`zeitwerk` 模式不可用。您需要至少使用 Rails 6.0。

4.2 运行 Rails 6.x 的应用程序

在运行 Rails 6.x 的应用程序中,有两种情况。

如果应用程序正在加载 Rails 6.0 或 6.1 的框架默认值,并且它正在 `classic` 模式下运行,则它必须手动选择退出。您必须具有类似以下内容:

# config/application.rb
config.load_defaults 6.0
config.autoloader = :classic # DELETE THIS LINE

如前所述,只需删除覆盖即可,`zeitwerk` 模式是默认模式。

另一方面,如果应用程序正在加载旧的框架默认值,则您需要显式启用 `zeitwerk` 模式

# config/application.rb
config.load_defaults 5.2
config.autoloader = :zeitwerk

4.3 运行 Rails 7 的应用程序

在 Rails 7 中只有 `zeitwerk` 模式,您无需执行任何操作来启用它。

实际上,在 Rails 7 中,设置器 `config.autoloader=` 甚至不存在。如果 `config/application.rb` 使用它,请删除该行。

5 如何验证应用程序在 `zeitwerk` 模式下运行?

要验证应用程序是否在 `zeitwerk` 模式下运行,请执行以下操作:

$ bin/rails runner 'p Rails.autoloaders.zeitwerk_enabled?'

如果它打印 `true`,则 `zeitwerk` 模式已启用。

6 我的应用程序符合 Zeitwerk 规范吗?

6.1 config.eager_load_paths

兼容性测试仅针对急切加载的文件运行。因此,为了验证 Zeitwerk 兼容性,建议将所有自动加载路径都放在急切加载路径中。

这在默认情况下已经是这样了,但是如果项目具有自定义的自动加载路径,配置如下:

config.autoload_paths << "#{Rails.root}/extras"

它们不会被急切加载,也不会被验证。将它们添加到急切加载路径中很容易

config.autoload_paths << "#{Rails.root}/extras"
config.eager_load_paths << "#{Rails.root}/extras"

6.2 zeitwerk:check

启用 `zeitwerk` 模式并重新检查急切加载路径配置后,请运行以下命令:

$ bin/rails zeitwerk:check

成功的检查结果如下所示:

$ bin/rails zeitwerk:check
Hold on, I am eager loading the application.
All is good!

根据应用程序配置,可能会有其他输出,但最后一行 "All is good!" 是您要找的。

如果上一节中解释的双重检查确定必须有一些自定义自动加载路径位于急切加载路径之外,则任务将检测到这些路径并发出警告。但是,如果测试套件成功加载了这些文件,则您一切正常。

现在,如果任何文件都没有定义预期的常量,则任务会告诉您。它一次只处理一个文件,因为如果它继续执行,加载一个文件的失败可能会级联到与我们想要运行的检查无关的其他失败,从而导致错误报告变得混乱。

如果报告了一个常量,请修复该特定常量并再次运行该任务。重复此操作,直到您获得 "All is good!"。

例如:

$ bin/rails zeitwerk:check
Hold on, I am eager loading the application.
expected file app/models/vat.rb to define constant Vat

VAT 是欧洲税。文件 `app/models/vat.rb` 定义了 `VAT`,但自动加载器期望 `Vat`,为什么?

6.3 缩略语

这是您可能会遇到的最常见的差异类型,它与缩略语有关。让我们了解一下为什么我们会收到该错误消息。

classic 自动加载器能够自动加载 `VAT`,因为它接受的输入是缺少的常量名称 `VAT`,调用 `underscore` 函数,得到 `vat`,并查找名为 `vat.rb` 的文件。它能正常工作。

新自动加载器的输入是文件系统。给定文件 `vat.rb`,Zeitwerk 调用 `camelize` 函数处理 `vat`,得到 `Vat`,并期望该文件定义常量 `Vat`。这就是错误消息中的含义。

修复它很简单,您只需要告诉 inflector 有关这个缩略语的信息

# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "VAT"
end

这样做会影响 Active Support 的全局 inflect 方式。这可能没问题,但是如果您愿意,也可以将覆盖传递给自动加载器使用的 inflector

# config/initializers/zeitwerk.rb
Rails.autoloaders.main.inflector.inflect("vat" => "VAT")

使用此选项,您可以获得更多控制权,因为只有名为vat.rb的文件或名为vat的目录才会被转为VAT。名为vat_rules.rb的文件不受影响,可以正常定义VatRules。如果项目存在这种命名不一致的情况,这可能很有用。

有了这个,检查通过了!

$ bin/rails zeitwerk:check
Hold on, I am eager loading the application.
All is good!

一切就绪后,建议在测试套件中继续验证项目。部分 在测试套件中检查 Zeitwerk 兼容性 说明了如何做到这一点。

6.4 关注点

您可以使用带有concerns子目录的标准结构来自动加载和预加载

app/models
app/models/concerns

默认情况下,app/models/concerns 属于自动加载路径,因此它被认为是根目录。所以,默认情况下,app/models/concerns/foo.rb 应该定义Foo,而不是Concerns::Foo

如果您的应用程序使用Concerns 作为命名空间,您有两个选择

  1. 从这些类和模块中删除Concerns 命名空间,并更新客户端代码。
  2. app/models/concerns 从自动加载路径中删除,保持现状
  # config/initializers/zeitwerk.rb
  ActiveSupport::Dependencies.
    autoload_paths.
    delete("#{Rails.root}/app/models/concerns")

6.5 在自动加载路径中使用app

一些项目希望像app/api/base.rb 来定义API::Base,并将app 添加到自动加载路径以实现这一目的。

由于 Rails 会自动将app 的所有子目录添加到自动加载路径中(有一些例外),所以我们遇到了另一个存在嵌套根目录的情况,类似于app/models/concerns 的情况。这种设置不能按原样工作。

但是,您可以保留这种结构,只需在初始化程序中将app/api 从自动加载路径中删除即可

# config/initializers/zeitwerk.rb
ActiveSupport::Dependencies.
  autoload_paths.
  delete("#{Rails.root}/app/api")

注意没有要自动加载/预加载文件的子目录。例如,如果应用程序具有app/admin,其中包含用于 ActiveAdmin 的资源,则需要忽略它们。对于assets 及其同类目录也是如此。

# config/initializers/zeitwerk.rb
Rails.autoloaders.main.ignore(
  "app/admin",
  "app/assets",
  "app/javascripts",
  "app/views"
)

如果没有此配置,应用程序会预加载这些树。由于其文件没有定义常量,所以在app/admin 会出错,并且会定义一个Views 模块,例如,作为一种不必要的副作用。

正如您所见,在自动加载路径中使用app 在技术上是可行的,但有点棘手。

6.6 自动加载常量和显式命名空间

如果命名空间在文件中定义,例如这里的Hotel

app/models/hotel.rb         # Defines Hotel.
app/models/hotel/pricing.rb # Defines Hotel::Pricing.

则必须使用classmodule 关键字设置Hotel 常量。例如

class Hotel
end

很好。

像这样的替代方案

Hotel = Class.new

Hotel = Struct.new

将无法工作,子对象如Hotel::Pricing 找不到。

此限制仅适用于显式命名空间。未定义命名空间的类和模块可以使用这些习惯用法定义。

6.7 一个文件,一个常量(在同一顶层)

classic 模式下,您可以在同一个顶层定义多个常量,并且所有常量都会被重新加载。例如,给定

# app/models/foo.rb

class Foo
end

class Bar
end

虽然Bar 无法自动加载,但自动加载Foo 会将Bar 标记为已自动加载。

zeitwerk 模式下情况并非如此,您需要将Bar 移动到它自己的文件bar.rb 中。一个文件,一个顶层常量。

这仅影响与上述示例中相同的顶层的常量。内部类和模块可以。例如,考虑

# app/models/foo.rb

class Foo
  class InnerClass
  end
end

如果应用程序重新加载Foo,它也会重新加载Foo::InnerClass

6.8 config.autoload_paths 中的通配符

注意使用通配符的配置,例如

config.autoload_paths += Dir["#{config.root}/extras/**/"]

config.autoload_paths 的每个元素都应该代表顶层命名空间(Object)。那将无法工作。

要解决此问题,只需删除通配符即可

config.autoload_paths << "#{config.root}/extras"

6.9 从引擎装饰类和模块

如果您的应用程序装饰了来自引擎的类或模块,那么很可能它在某处执行了类似的操作

config.to_prepare do
  Dir.glob("#{Rails.root}/app/overrides/**/*_override.rb").sort.each do |override|
    require_dependency override
  end
end

这需要更新:您需要告诉main 自动加载器忽略包含覆盖的目录,并且需要使用load 加载它们。类似于这样

overrides = "#{Rails.root}/app/overrides"
Rails.autoloaders.main.ignore(overrides)
config.to_prepare do
  Dir.glob("#{overrides}/**/*_override.rb").sort.each do |override|
    load override
  end
end

6.10 before_remove_const

Rails 3.1 添加了对名为before_remove_const 的回调的支持,如果类或模块响应此方法并且将要被重新加载,则会调用此回调。此回调一直没有记录,您的代码不太可能使用它。

但是,如果它确实使用了,您可以重写类似

class Country < ActiveRecord::Base
  def self.before_remove_const
    expire_redis_cache
  end
end

# config/initializers/country.rb
if Rails.application.config.reloading_enabled?
  Rails.autoloaders.main.on_unload("Country") do |klass, _abspath|
    klass.expire_redis_cache
  end
end

6.11 Spring 和test 环境

如果某些内容发生变化,Spring 会重新加载应用程序代码。在test 环境中,您需要启用重新加载才能使其工作

# config/environments/test.rb
config.cache_classes = false

或者,从 Rails 7.1 开始

# config/environments/test.rb
config.enable_reloading = true

否则,您将得到

reloading is disabled because config.cache_classes is true

reloading is disabled because config.enable_reloading is false

这不会造成性能损失。

6.12 Bootsnap

请确保至少依赖于 Bootsnap 1.4.4。

7 在测试套件中检查 Zeitwerk 兼容性

任务zeitwerk:check 在迁移期间非常有用。项目符合要求后,建议自动化此检查。为此,只需预加载应用程序即可,这确实是zeitwerk:check 所做的全部工作。

7.1 持续集成

如果您的项目已实施持续集成,那么在套件在那里运行时预加载应用程序是个好主意。如果应用程序由于某种原因无法预加载,您希望在 CI 中知道这一点,而不是在生产环境中,对吧?

CI 通常设置一些环境变量来指示测试套件在那里运行。例如,它可能是CI

# config/environments/test.rb
config.eager_load = ENV["CI"].present?

从 Rails 7 开始,默认情况下,新生成的应用程序会以这种方式进行配置。

7.2 简单的测试套件

如果您的项目没有持续集成,您仍然可以通过调用Rails.application.eager_load! 在测试套件中预加载。

7.2.1 Minitest

require "test_helper"

class ZeitwerkComplianceTest < ActiveSupport::TestCase
  test "eager loads all files without errors" do
    assert_nothing_raised { Rails.application.eager_load! }
  end
end

7.2.2 RSpec

require "rails_helper"

RSpec.describe "Zeitwerk compliance" do
  it "eager loads all files without errors" do
    expect { Rails.application.eager_load! }.not_to raise_error
  end
end

8 删除任何require 调用

根据我的经验,项目通常不会这样做。但我见过一些,也听说过其他一些。

在 Rails 应用程序中,您仅使用require 加载来自lib 的代码或来自第三方(如 gem 依赖项或标准库)的代码。切勿使用require 加载可自动加载的应用程序代码。请参阅classic 模式下为什么这是一个糟糕的主意 这里

require "nokogiri" # GOOD
require "net/http" # GOOD
require "user"     # BAD, DELETE THIS (assuming app/models/user.rb)

请删除所有此类require 调用。

9 可以利用的新功能

9.1 删除require_dependency 调用

使用 Zeitwerk 已消除了require_dependency 的所有已知用例。您应该使用 grep 搜索项目并删除它们。

如果您的应用程序使用单表继承,请参阅自动加载和重新加载常量 (Zeitwerk 模式) 指南的 单表继承部分

9.2 现在可以在类和模块定义中使用限定名称

您现在可以稳健地在类和模块定义中使用常量路径

# Autoloading in this class body matches Ruby semantics now.
class Admin::UsersController < ApplicationController
  # ...
end

需要注意的是,根据执行顺序,经典自动加载器有时可以自动加载Foo::Wadus,如下所示

class Foo::Bar
  Wadus
end

这与 Ruby 语义不匹配,因为Foo 不在嵌套中,并且在zeitwerk 模式下根本无法工作。如果您发现这种特殊情况,您可以使用限定名称Foo::Wadus

class Foo::Bar
  Foo::Wadus
end

或将Foo 添加到嵌套中

module Foo
  class Bar
    Wadus
  end
end

9.3 无处不在的线程安全

classic 模式下,常量自动加载不是线程安全的,尽管 Rails 有锁到位,例如,使 Web 请求线程安全。

zeitwerk 模式下,常量自动加载是线程安全的。例如,您现在可以在runner 命令执行的多线程脚本中自动加载。

9.4 预加载和自动加载是一致的

classic 模式下,如果app/models/foo.rb 定义了Bar,则您将无法自动加载该文件,但预加载会起作用,因为它会以递归方式盲目加载文件。如果您首先预加载测试事物,这可能会导致错误,因为执行可能会在以后自动加载时失败。

zeitwerk 模式下,两种加载模式都是一致的,它们在相同的文件中失败并出错。


反馈

鼓励您帮助提高本指南的质量。

如果您发现任何错别字或事实错误,请贡献。要开始,您可以阅读我们的 文档贡献 部分。

您也可能会发现内容不完整或过时。请添加任何缺少的 main 文档。确保首先检查 Edge Guides 以验证问题是否已在 main 分支上修复。检查 Ruby on Rails Guides Guidelines 以了解样式和约定。

如果由于某种原因您发现需要修复但无法自行修补的内容,请 提交问题

最后但并非最不重要的是,关于 Ruby on Rails 文档的任何讨论都非常欢迎在 官方 Ruby on Rails 论坛 上进行。