将 Discourse 升级到 Zeitwerk

Rails 6 默认提供两种自动加载模式:zeitwerkclassic。在该拉取请求 DEV: Upgrading Discourse to Rails 6 by KrisKotlarek · Pull Request #8083 · discourse/discourse · GitHub 中,我将 Rails 升级到了 6.0.0 版本,并暂时使用 classic 自动加载器作为过渡方案。尝试切换到 Zeitwerk 会很有意思。

Zeitwerk 是一个高效且线程安全的 Ruby 代码加载器。只要项目遵循命名规范,Zeitwerk 就能找到正确的文件并按需或预先加载它们,而无需任何 requirerequire_dependency。此外,根据这篇文章 https://weblog.rubyonrails.org/2019/2/22/zeitwerk-integration-in-rails-6-beta-2/,它还可能为应用程序带来轻微的性能提升。

为了实现这一目标,我需要完成以下几个步骤:

  1. 修改部分类的名称以符合 Rails 命名规范。例如,文件 canonical_url.rb 应定义 CanonicalUrl 类,而不是 CanonicalURL。同样,文件 ondiff.rb 应定义 Onpdiff 类,而不是 ONPDiff。另一种方法是为项目挂载自定义推断器(inflector),但我认为遵循规范可能是更好的选择 - GitHub - fxn/zeitwerk: Efficient and thread-safe code loader for Ruby · GitHub

  2. 与上一点类似,按照规范,位于 validations 目录中的自定义验证应包裹在 Validations 模块中。此外,某些验证继承自 EachValidator,应无需命名空间即可访问。我计划将它们移至单独的目录,并添加到自动加载路径中。

  3. 移除所有 require_dependency,并确保项目正常运行。

  4. 移除所有 require,并确保 Discourse 正常运行。

  5. 确保所有插件都能访问所需的依赖项。目前我还不知道如何实现这一点。我打算先让 Discourse 在不加载任何插件的情况下运行。

当然,仍有许多未知问题需要解决。我会持续向您汇报进展。如果您有兴趣查看,我在此处开始尝试:Commits · KrisKotlarek/discourse · GitHub

如果您发现实施 Zeitwerk 存在任何弊端,或认为我遗漏了某些内容,请随时告知。

我在 Zeitwerk 方面取得了一些进展,但我改变了方法。我最初的计划是修改 Discourse 中所有不符合 Zeitwerk 文件名约定的地方。在修复了几处问题后,我意识到这仅仅是冰山一角,并且注意到如果继续沿着这条路走,拉取请求(PR)将难以阅读,也难以有信心地合并到主分支。例如,常规目录下的所有作业类都应具有 Regular 命名空间,OnceoffScheduled 同理。

我决定稍微退一步,思考一种更具演进性而非革命性的方法。

我决定最好引入一个自定义的 Inflector,以覆盖所有不符合 Zeitwerk 约定的文件。这样最大的好处是,我们可以先部署这一小改动,一旦我们对 Zeitwerk 感到满意且没有出现性能下降,就可以开始以合理的小型拉取请求逐个修复约定文件。

我发现有些问题无法通过自定义 Inflector 解决,因此我进行了额外的修复以确保其正常工作。

拉取请求仍在进行中,但在此阶段,我已经能够使用 Zeitwerk 和默认插件运行 Discourse,运行所有测试用例,并顺利执行基准测试。

我首先希望达到所有测试用例都通过的稳定状态。现在,我可以有信心地逐个移除所有的 require_dependency,并测试官方插件。一旦一切准备就绪,我将在此帖子中分享基准测试结果。

目前,如果您想了解进展,可以查看这个草稿 PR:DEV: Upgrading Discourse to Zeitwerk by KrisKotlarek · Pull Request #8098 · discourse/discourse · GitHub

最重要的文件是那个自定义的 Zeitwerk Inflector:DEV: Upgrading Discourse to Zeitwerk by KrisKotlarek · Pull Request #8098 · discourse/discourse · GitHub

为了让插件正常工作,我需要再提交几个小的拉取请求。一旦它们被合并,我认为 Discourse 上的测试应该能通过。

我还检查了在 Rails 6.0.0 使用 Classic 自动加载器以及 Rails 6.0.0 使用 Zeitwerk 时的性能。

测试 Classic Zeitwerk 百分比
categories-50 32 26 81.25
categories-75 37 29 78.38
categories-90 47 35 74.47
categories-99 67 49 73.13
home-50 30 29 96.67
home-75 37 31 83.78
home-90 44 40 90.91
home-99 67 52 77.61
topic-50 35 35 100.00
topic-75 36 36 100.00
topic-90 48 36 75.00
topic-99 57 58 101.75
categories_admin-50 51 48 94.12
categories_admin-75 62 50 80.65
categories_admin-90 89 66 74.16
categories_admin-99 135 101 74.81
home_admin-50 48 47 97.92
home_admin-75 58 49 84.48
home_admin-90 67 64 95.52
home_admin-99 101 81 80.20
topic_admin-50 48 48 100.00
topic_admin-75 55 49 89.09
topic_admin-90 63 65 103.17
topic_admin-99 92 69 75.00
load_rails 2617 2165 82.73
rss_kb 282428 315684 111.78
pss_kb 270491 303504 112.20

结果并不总是一致,因此请谨慎看待这些数据。

这里的中间值不一致程度有点奇怪,我不明白结果为何波动如此之大

没想到加载器竟会有任何影响

我又试了一次,结果如下:

测试项 Classic Zeitwerk 百分比
categories-50 25 25 100.00
categories-75 26 26 100.00
categories-90 37 33 89.19
categories-99 57 48 84.21
home-50 26 26 100.00
home-75 27 28 103.70
home-90 38 35 92.11
home-99 60 50 83.33
topic-50 27 26 96.30
topic-75 35 27 77.14
topic-90 41 33 80.49
topic-99 54 50 92.59
categories_admin-50 48 50 104.17
categories_admin-75 60 61 101.67
categories_admin-90 76 71 93.42
categories_admin-99 122 122 100.00
home_admin-50 47 46 97.87
home_admin-75 58 55 94.83
home_admin-90 66 63 95.45
home_admin-99 99 121 122.22
topic_admin-50 50 49 98.00
topic_admin-75 62 50 80.65
topic_admin-90 72 65 90.28
topic_admin-99 103 74 71.84
load_rails 2675 2216 82.84
rss_kb 279924 315240 112.62
pss_kb 267659 303026 113.21

我们可以尝试另一种基准测试方法。你觉得增加迭代次数、让测试运行一个小时怎么样?此外,与其取最佳结果,不如比较每次实验的平均值。这样可能会得到更稳定的数据。你怎么看?

在我为插件提交的拉取请求中,您会注意到许多修复都涉及全局命名空间中的搜索问题。

我将类似以下的代码:

module ::Jobs
  class TranslatorMigrateToAzurePortal < Jobs::Onceoff

修改为:

module ::Jobs
  class TranslatorMigrateToAzurePortal < ::Jobs::Onceoff

这个解决方案让我有些困惑:为什么在 Zeitwerk 之前它能正常工作?当某段代码能运行但实际上不应该运行时,这个问题总是很棘手 :slight_smile:

我认为我在经典自动加载器的描述中找到了一个潜在答案(https://guides.rubyonrails.org/autoloading_and_reloading_constants_classic_mode.html#resolution-algorithms)——“如果未找到,则算法会沿着创生引用(cref)的祖先链向上遍历”。

Zeitwerk 更加严格。在我尝试加载修复前的代码时,它报错说找不到 Jobs::Jobs::Onceoff

Sam 在拉取请求 FIX: Use top-level namespace for base classes · discourse/discourse-prometheus-alert-receiver@ef9c238 · GitHub 中建议,与其使用 < ::Jobs::Onceoff,不如直接使用 < Onceoff,他说得对。我验证了不带命名空间也能正常工作。我认为使用 :: 是明确表示我们继承自 Discourse 核心类,不过两种方式都可以。

我认为当代码靠得这么近时,这样读起来很顺畅:

module ::Jobs
  class TranslatorMigrateToAzurePortal < Onceoff

但如果代码开始分散,显式声明会更有意义……例如:

module ::Jobs
  [ 50 行省略 ]
  class TranslatorMigrateToAzurePortal < ::Jobs::Onceoff

不过,我对此持中立态度,所以两种写法我都可以接受。::Jobs::Onceoff 足够简短且非常明确,因此我们现在可以先采用这种方式。

我很希望能尽快合并这个。@kris.kotlarek 现在是否可以合并了?时机正好,因为我们刚发布了 beta 版本。

让我把最近的 master 分支变基到那里,并确保它仍然能正常工作,我今天会完成。

@sam 我认为我们可以开始了。我已基于最新的 master 分支进行了变基,并对 Webauthn 做了小幅调整。
我检查了以下三项:
在本地运行服务器并点击测试,确保其按预期工作
运行测试规范
下载所有官方插件,并确保插件的测试规范通过(我们需要先合并针对插件的调整)

好的,我合并这个。看看结果如何!

我们是否也可以合并插件的修复?否则,如果您尝试运行带有插件的 Discourse,将会失败

请继续合并!

现在已合并!如果有任何插件作者在此遇到困难,请在新的专用主题中告诉我们!

干得漂亮,Kris!!!