[译] Rails autoloading - 知其然,知其所以然

[原文] Rails autoloading — how it works, and when it doesn’t

Rails(对部分人来说)因为它的上手简单而被熟知和喜受。其代码约定大大减少了从写代码到浏览器显示结果的时间。实现这些有两个重要的关键点,分别是:

  • 代码自动加载
  • 当代码发生变化时,自动重新加载

自动加载意味着你不用再手写代码去一个个的require,也不用担心load path;我们写的类可以从任何地方访问,我们可以直接的引用它,好像魔法一般。

重新加载则意味着每当修改了程序代码直接刷新浏览器就可以看到更新,而不用再去重启应用服务器。这样干起活来才多快好省,而且autoloading几乎不带任何副作用。

问题?

我不打算讲解这些特征是否有利于我们的开发实践,我碰巧相信他们是有害的。这里有另一篇文章,会代替我讲述这背后显著的复杂性和可能引发的问题。

在此之前,先对 Ruby constant 查找进行下背景铺垫。

Ruby Constant 查找

在Ruby中,一旦你知道了规则,constant 查找就相当简单,但有时却又不是那么直观。当你在一个给定的词法作用域内却引用一个constant时,它的查找顺序如下:

  1. Each entry in Module.nesting
  2. Each entry in Module.nesting.first.ancestors
  3. Each entry in Object.ancestors if Module.nesting.first is nil or a module.

Loosely speaking, the search first works upwards through the nesting at the point of reference, then upwards through either the inheritance chain of the containing class (if there is one), or that of Object otherwise.

粗略地讲,搜索第一个向上的作品通过嵌套的参考点,然后向上通过包含类的任何继承链(如果有的话),或该对象的其他方式。

你可以在Conrad Irwin 的博客找到这么一个好的示例和详尽的解释,而对于我们来说,就是需要足够的注意以下代码因处于不同的嵌套词法范围,导致其执行结果不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
C = "At the top level"
module A
C = "In A"
end
module A
module B
puts Module.nesting # => [A::B, A]
puts C # => "In A"
end
end
module A::B
puts Module.nesting # => [A::B]
puts C # => "At the top level"
end

在第一个例子中,因为A是Module.nesting的一员,它可以被搜索的constant C,所以作为A::C存在,它被返回。在第二个例子中,A是不嵌套的一部分,所以::C被返回。

铺垫完成,让我们进入正题:

Rails Constant 自动加载

Ruby 内置了autoload的功能^3,它允许程序员指定该文件的位置,从而找到一个已给定的constrant。当这个constrant是程序第一次引用时,Ruby会加载这个文件。

Rails中可以在运行时自动加载任意的constrant - 即使这些文件在应用程序启动时并不存在。它不是简单地使用Ruby的内置自动加载,因为Ruby需要预先知道每个constant 的名字和对应文件所在位置,但Rails在启动时也不知道这些。

Instead, it implements its own autoload system, augmenting Ruby’s constant lookup with a set of inference rules specifying which files are expected to define a given constant name. These can be lazily loaded when the constant is first used.

相反,Rails实现了自己的autoload系统,为Ruby扩增一系列的查找constant的推理规则,指定给定constant名字所预期对应文件位置。这些都可以在第一次使用constant时来延迟加载。

但如何工作的呢?

The autoload 入口点

大多数Ruby开发都很熟悉#method_missing,当一个消息被发送到一个接收者,而该接收者没有没有任何方法对该消息作出反应(它也因使用不慎造成大破坏而闻名)。

Rails中它有一个与constant查找相对应的Module#const_missing, 当对constant的引用未能被找到时而被调用:

1
2
3
4
5
6
module Foo
def self.const_missing(name)
puts "In #{self} looking for #{name}..."
super
end
end
1
2
3
> Foo::Bar
In Foo looking for Bar...
NameError: uninitialized constant Foo::Bar

当你引用一个constant,Ruby首先尝试根据其内置的查找规则,如上所述。如果没有相匹配的constant被发现,Module#const_missing将被调用 - 在上面的例子的情况下,被调用的是Foo.const_missing("Bar")

这就是Rails接管的地方。根据文件查找约定和已经被装载的constant,Rails通过覆写#const_missing来加载缺失的constant,而不需要程序员显示的require。

文件查找规则

再看Ruby中的autoload,它需要提前指定每一个已被自动加载过的constant的位置,而Rails则遵循一个简单的约定,将constant的名字映射到文件名上。嵌套则对应到目录上去,并且常量名都用下划线。

1
MyModule::SomeClass # => my_module/some_class.rb

For a given constant, this inferred filename is then searched for within a number of autoload paths, as determined by the autoload_paths configuration option. By default, Rails searches in all immediate subdirectories of the app/ directory, and additional paths can be added:

对于一个给定的constant,会去搜索一系列自动加载的路径,根据autoload_paths配置选项推断出对应的文件名。Rails的默认搜索在app/下的所有子目录,其他路径可以通过下面的方法添加:

1
2
3
4
5
6
# config/application.rb
module MyApp
class Application < Rails::Application
config.autoload_paths << Rails.root.join("lib")
end
end

如果autoload_paths被设置为["app/models", "lib"],那么constant User的查找顺序会看起来如下:

  • app/models/user.rb
  • lib/user.rb

Rails对这些查找路径依次进行检查,当发现其中一个存在时,推断加载该文件。观察该constant的期待位置,如果它在文件加载完成后出现,算法成功。否则,将会出现一个熟悉的错误提示:

1
LoadError: Expected app/models/user.rb to define User

嵌套查找

在这一点上,我们只看到了单个的constant名是如何如何对应到单个的文件名上。但是我们知道,在Ruby中的constant引用可以被解析到多个不同的constant定义,这取决于引用时的所使用的嵌套。而Rails是如何处理的呢?

答案是,“部分”。对于Module#const_missing来说,它并不传递嵌套信息到接收器,所以Rails并不知道引用中包含嵌套,Rails必须作出假设,对任何引用Foo::Bar::Bazconstant,它假设如下:

1
2
3
4
5
module Foo
module Bar
Baz # Module.nesting => [Foo::Bar, Foo]
end
end

换句话来说,它假设一个给定constant引用的最大嵌套可能。上面例子中的引用会像按下面的方式处理:

1
2
3
4
5
Foo::Bar::Baz # Module.nesting => []
module Foo::Bar
Baz # Module.nesting => [Foo::Bar]
end

While there’s been a significant loss of information, Rails does have some extra information it can use. It knows that Ruby failed to resolve this particular constant reference using its regular lookup, meaning that whatever constant it should refer to cannot be already loaded.

虽然是一次显著信息丢失时,Rails确实有一些额外的信息可供使用。它知道Ruby的使用标准查找未能找到该特别的constant,这意味constant未能被加载。

Foo::Bar::Baz被指向到,然后,Rails会尝试加载下面的常量,直到它一个已经被载入:

  • Foo::Bar::Baz
  • Foo::Baz
  • Baz

当一个已经加载的constant Baz 被遇到时,Rails知道这不是它要找的Baz,然后算法会抛出一个NameError的错误。

这一点是我认为整个流程中最难懂的一部分,它是一个会导致一些非常反直觉的行为,这对我们将很快看到一个例子。

将所有内容进行整合

现在我们可以为Rails 自动加载工作原理画一个流程图。(这并不是一个完整流程,但对于表现本文的目的已经足够了。)

一个未被加载的constant Foo::Bar::Baz 被引用到了。Ruby不能正确的解决这个问题,然后调用Foo::Bar.const_missing("Baz")。而Rails却是这样做:

  1. autoload_paths中查找文件foo/bar/baz.rb
  2. 如果该文件存在,它会被推断加载:
    • 如果constant被正确的定义,返回
    • 否则,抛出一个错误
  3. 如果文件不存在,依次查找Foo::Baz, Baz,直到它们被定义了
  4. 如果所有的候选constant都不存在,则抛出一个NameError的异常。

Rails将我们从手动加载代码中解脱出来。它采用多种假设来完成这件事,这将带来一定的成本。我们一探究竟。

Rails 自动加载陷阱

即然文章都已经这么长了,那我就再多花点文笔来讲述2种经常阻挠我们使用Rails 自动加载的点,当然还有更多,但这些能足够覆盖大多数由自动加载所引发的问题。

丢失嵌套信息

我们已看到Ruby是如何查找constant中嵌套。但是我们也看到了Rails并不接收任何嵌套信息,所以它强制根据假设嵌套和已被加载的constant,去猜测那些有意的constant。

假设autoload_paths中存在以下文件,并且constant尚未被加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# qux.rb
Qux = "I'm at the root!"
# foo.rb
module Foo
end
# foo/qux.rb
module Foo
Qux = "I'm in Foo!"
end
# foo/bar.rb
class Foo::Bar
def self.print_qux
puts Qux
end
end

猜想下,调用 Foo::Bar.print_qux 的结果是什么?

在一个变通的Ruby上下文中,在Foo::Bar中对Qux的引用不能只得指向到Foo::Bar::Qux(不存在),或者 ::Qux,所以如果我们加载合适的文件,我们将会看到:

1
2
3
4
# Using normal ruby loading
> Foo::Bar.print_qux
I'm at the root!
=> nil

在一个已被装载过的上下文中,这又将完全不同,第一时间,会发生这些:

1
2
3
4
# Using Rails autoloading
> Foo::Bar.print_qux
I'm in Foo!
=> nil

为什么会这样?

Rails并不知道嵌套Qux被指向到了form,它只知道Ruby未能在任何constant中找到Qux。所以它会首先查找Foo::Bar::Qux,而它并不存在。

然后检查Foo::Qux是否已经加载。如果没有,那么它会尝试加载它,因为foo/qux.rb存在,并且定义了一个明显的正确的constant,成功。这颠覆了我们对Ruby的constant查找已存在的知识 - 我们已经加载的常量,嵌套通常不会允许。

不过我刚才只是说“第一时间”?当然是,这将引导我们到第二个陷阱:

加载顺序的依赖

如果constant在运行时第一次被碰到时就加载了,它们的加载顺序取决于单独的执行路径。这可以被理解为
同样的一段代码,运行2次会让同一个的constant引用指向到不同的constant定义。更糟糕的是,相同的constant连续引用两次会返回不同的结果。

让我们回到我们的最后一个例子。如果我们调用.print_qux两次,会发生什么?

1
2
3
4
5
> Foo::Bar.print_qux
I'm in Foo!
=> nil
> Foo::Bar.print_qux
NameError: uninitialized constant Foo::Bar::Qux

这是灾难性的!首先,我们已经给出了错误的结果,然后我们被错误地告知,我们所引用的constant不存在。究竟什么导致了这个?

第一次,和之前一样,错误是因为丢失了嵌套信息。Rails不知道Foo::Qux,所以一旦它意识到Foo::Bar::Qux不存在的,它愉快的加载了它。

第二次,然而,Foo::Qux已经加载。因此,我们引用不能到过该常量,这意味着Ruby会解决它,并且自动加载不会被调用。因此,查找终止并抛出NameError异常,尽管引用可以(而且应该)找到尚未卸载的::Qux

我们可以先引用::Qux来解决这个问题,以确保它已经被Ruby所加载来解决引用问题:

1
2
3
4
5
6
7
8
> Qux
=> "I'm at the root!"
> Foo::Bar.print_qux
I'm at the root!
=> nil
> Foo::Bar.print_qux
I'm at the root!
=> nil

这里一个有趣的事情发生了。为了得到正确的行为,我们特意在使用之前装载了该constant(尽管是间接的,参照它,而不是加载定义它的文件)。

But wait; isn’t this suspiciously close to explicitly loading our dependencies with require, the very thing autoloading was supposed to save us from?
等等;

To be fair, we could also have fixed the issue by fully qualifying all of our constant references, i.e. making sure that within .print_qux we referred to ::Qux and not the ambiguous Qux. But this still costs us our existing intuitions about Ruby’s behaviour. Moreover, without intimate knowledge of the autoloading process, we would have been hard pressed to deduce that this was necessary.

为了公平起见,我们也可以通过完全限定所有经常提到的,即确保在.print_qux我们提到:: QUX,而不是模棱两可的QUX固定的问题。但是,这仍然花费我们现有的直觉有关Ruby的行为。此外,如果没有自动加载程序的熟悉,我们就已经很难推断,这是必要的。

其他陷阱

这只是两个潜在的问题。事情在开发和生产环境之间更加恶化,如在生产环境下Rails过早加载某些路径。这将改变了加载顺序,正如我们所见,这将潜在改变constant引用的意思。

More potential problems lurk if you reopen class or module definitions - again, depending on load order, these could end up treated as the main definition, preventing autoloading from finding the “real” definition. And again, depending on execution path, you can end up with completely different behaviour.

如果你重新打开类或者module的定义,会发现 - 再次,取决于加载顺序,这些最终可能被视为主要的定义,防止自动加载从寻找“真实”的定义。还有,根据执行路径,你可以使用完全不同的行为来结束。

总结

Rails的自动加载提供了很大的简便。从表面上看,将我们从加载路径和依赖中解脱出来,并能和我们手写的代码一起工作。直到,我们碰到了问题。

这是在这一点上,方便性和复杂性之间的权衡开始斗争。在我上面的例子中,在Ruby的不断查找一个体面的接地是不够了解的问题。我们需要对Rails的自动装填系统的深刻理解,明白发生了什么事情不对。远从提供简单,这个所谓的便利性有了显着提高了我们的认知负荷。

那么,我们有没有真正得到了什么?当然,我们不再需要经常重新启动我们的开发服务器。但是,我们还是需要了解一些关于加载的知识。与此相反,我们不得不面对莫名其妙的错误,并且不得不更深入地钻研代码加载。也许,我们总是使用自动加载,导致我们的相关知识萎缩 - 或者从未接触过,这对我们的学习非常不利。

由于有这么多的Rails的方便,自动装填消除了进入的壁垒,这是一个值得追求的目标。当我用的越多,我却越想去避免。

PS:

  1. More than that, Rails automatically loads all of our gems for us, so we don’t even have to explicitly load other people’s code. Reasons this might not be such a swell idea are covered by Myron Marston on his blog. ↩

  2. Autoloading tracks what is autoloaded as part of its basic operation. All reloading then requires is to keep an eye on changes to the filesystem, and unload everything if a file has been written to. Autoloading then just reloads it all. ↩

  3. Although it may not have it much longer… ↩

REF::

http://www.tuicool.com/articles/6vq2yyE