此文翻译自Reading Rails – Attribute Methods,限于本人水平,翻译不当之处,敬请指教!
在我们上一篇的探讨中,我们已经看到了Rails在跟踪属性变更中使用到的属性方法(attribute methods)。有三种类型的属性方法:前缀式(prefix)、后缀式(suffix)以及固定词缀式( affix)。为了表述简洁,我们将只关注类似attribute_method_suffix
这样的后缀式属性方法,并且特别关注它是如何帮助我们实现类似name
这样的模型属性以及对应生成的类似name_changed?
这样的方法的。
如果需要跟着我的步骤走,请使用qwandry打开每一个相关的代码库,或者直接从github查看源码即可。
声明(Declarations)
属性方法是Rails中众多使用了元编程技术的案例之一。在元编程中,我们编写可以编写代码的代码。举例来说,attribute_method_suffix
后缀式方法是一个为每个属性都定义了一个helper方法的方法。在之前的讨论中,ActiveModel使用这种方式为您的每一个属性都定义了一个_changed?
方法(提示: 命令行中键入qw activemodel
查看代码):
module Dirty
extend ActiveSupport::Concern
include ActiveModel::AttributeMethods
included do
attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
#...
让我们打开ActiveModel库中的attribute_methods.rb
文件,并且看一下到底发生了什么事情。
def attribute_method_suffix(*suffixes)
self.attribute_method_matchers += suffixes.map! do |suffix|
AttributeMethodMatcher.new suffix: suffix
end
#...
end
当你调用attribute_method_suffix
方法的时候,每一个后缀都通过map!
方法转换为一个AttributeMethodMatcher
对象。这些对象会被存储在attribute_method_matchers
中。如果你重新看一下这个module的顶部,你会发现attribute_method_matchers
是在每一个包含此module的类中使用class_attribute
定义的方法:
module AttributeMethods
extend ActiveSupport::Concern
included do
class_attribute :attribute_aliases,
:attribute_method_matchers,
instance_writer: false
#...
class_attribute
方法帮助你在类上定义属性。你可以这样在你自己的代码中这样使用:
class Person
class_attribute :database
#...
end
class Employee < Person
end
Person.database = Sql.new(:host=>'localhost')
Employee.database #=> <Sql:host='localhost'>
Ruby中并没有class_attribute
的内置实现,它是在ActiveSupport(提示:命令行中键入qw activesupport
查看代码)中定义的方法。如果你对此比较好奇,可以简单看下attribute.rb
现在我们来看一下AttributeMethodMatcher
。
class AttributeMethodMatcher #:nodoc:
attr_reader :prefix, :suffix, :method_missing_target
def initialize(options = {})
#...
@prefix, @suffix = options.fetch(:prefix, ''), options.fetch(:suffix, '')
@regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
@method_missing_target = "#{@prefix}attribute#{@suffix}"
@method_name = "#{prefix}%s#{suffix}"
end
代码中的prefix
以及suffix
是通过Hash#fetch
方法提取出来的。这会返回一个对应键的值,或者是一个默认值。如果调用方法的时候没有提供默认值,Hash#fetch
方法将会抛出一个异常,提示指定的键不存在。对于options的处理来说是一种不错的模式,特别是对于boolean型数据来说:
options = {:name => "Mortimer", :imaginary => false}
# Don't do this:
options[:imaginary] || true #=> true
# Do this:
options.fetch(:imaginary, true) #=> false
对于我们的attribute_method_suffix
其中的'_changed'
示例来说,AttributeMethodMatcher
将会有如下的实例变量:
@prefix #=> ""
@suffix #=> "_changed?"
@regex #=> /^(?:)(.*)(?:_changed\?)$/
@method_missing_target #=> "attribute_changed?"
@method_name #=> "%s_changed?"
你一定想知道%s_changed
中的%s
是用来干什么的吧?这是一个格式化字符串(format string)。你可以使用sprintf
方法对它插入值,或者使用缩写(shortcut)%
:
sprintf("%s_changed?", "name") #=> "named_changed?"
"%s_changed?" % "age" #=> "age_changed?"
第二个比较有趣的地方就是正则表达式创建的方式。请留意创建@regex
变量时Regexp.escape
的用法。如果后缀没有被escape,则正则表达式中带有特殊含义的符号将会被错误解释(misinterpreted):
# Don't do this!
regex = /^(?:#{@prefix})(.*)(?:#{@suffix})$/ #=> /^(?:)(.*)(?:_changed?)$/
regex.match("name_changed?") #=> nil
regex.match("name_change") #=> #<MatchData "name_change" 1:"name">
# Do this:
@regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
regex.match("name_changed?") #=> #<MatchData "name_changed?" 1:"name">
regex.match("name_change") #=> nil
请仔细记住regex
以及method_name
,它们可以用来匹配和生成属性方法,我们在后面还会继续用到它们。
我们现在已经搞明白了属性方法是如何声明的,但是实际中,Rails又是如何使用它们的呢?
通过Method Missing调用(Invocation With Method Missing)
当我们调用了一个未定义的方法时,Rails将会在抛出异常之前调用对象的method_missing
方法。让我们看看Rails是如何利用这个技巧调用属性方法的:
def method_missing(method, *args, &block)
if respond_to_without_attributes?(method, true)
super
else
match = match_attribute_method?(method.to_s)
match ? attribute_missing(match, *args, &block) : super
end
end
传给method_missing
方法的第一个参数是一个用symbol类型表示的方法名,比如,我们的:name_changed?
。*args
是(未定义的)方法被调用时传入的所有参数,&block
是一个可选的代码块。Rails首先通过调用respond_to_without_attributes
方法检查是否有别的方法可以对应这次调用。如果别的方法可以处理这次调用,则通过super
方法转移控制权。如果找不到别的方法可以处理当前的调用,ActiveModel则会通过match_attribute_method?
方法检查当前调用的方法是否是一个属性方法。如果是,它则会接着调用attribute_missing
方法。
match_attribute_method
方法利用了之前声明过的AttributeMethodMatcher
对象:
def match_attribute_method?(method_name)
match = self.class.send(:attribute_method_matcher, method_name)
match if match && attribute_method?(match.attr_name)
end
在这个方法里边发生了两件事。第一,Rails查找到了一个匹配器(matcher),并且检查这是否真的是一个属性。说实话,我自己也是比较迷惑,为什么match_attribute_method?
方法调用的是self.class.send(:attribute_method_matcher, method_name)
,而不是self.attribute_method_matcher(method_name)
,但是我们还是可以假设它们的效果是一样的。
如果我们再接着看attribute_method_matcher
,就会发现它的最核心的代码仅仅只是扫描匹配了AttributeMethodMatcher
实例,它所做的事就是对比对象本身的正则表达式与当前的方法名:
def attribute_method_matcher(method_name)
#...
attribute_method_matchers.detect { |method| method.match(method_name) }
#...
end
如果Rails找到了匹配当前调用的方法的属性,那么接下来所有参数都会被传递给attribute_missing
方法:
def attribute_missing(match, *args, &block)
__send__(match.target, match.attr_name, *args, &block)
end
这个方法将匹配到的属性名以及传入的任意参数或者代码块代理给了match.target
。回头看下我们的实例变量,match.target
将会是attribute_changed?
,而且match.attr_name
则是”name”。__send__
方法将会调用attribute_changed?
方法,或者是你定义的任意一个特殊的属性方法。
元编程(Metaprogramming)
有很多的方式可以对一个方法的调用进行分发(dispatch),如果这个方法经常被调用,那么实现一个name_changed?
方法将会更为有效。Rails通过define_attribute_methods
方法做到了对这类属性方法的自动定义:
def define_attribute_methods(*attr_names)
attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) }
end
def define_attribute_method(attr_name)
attribute_method_matchers.each do |matcher|
method_name = matcher.method_name(attr_name)
define_proxy_call true,
generated_attribute_methods,
method_name,
matcher.method_missing_target,
attr_name.to_s
end
end
matcher.method_name
使用了我们前面见到过的格式化字符串,并且插入了attr_name
。在我们的例子中,"%s_changed?"
变成了"name_changed?"
。现在我们我们准备好了了解在define_proxy_call
中的元编程。下面是这个方法被删掉了一些特殊场景下的代码的版本,你可以在阅读完这篇文章后自己去了解更多的代码。
def define_proxy_call(include_private, mod, name, send, *extra)
defn = "def #{name}(*args)"
extra = (extra.map!(&:inspect) << "*args").join(", ")
target = "#{send}(#{extra})"
mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
#{defn}
#{target}
end
RUBY
end
这里为我们定义了一个新的方法。name
就是正要被定义的方法名,而send
则是处理器(handler),另外的extra
是属性名。mod
参数是一个Rails用generated_attribute_methods
方法生成的特殊的模块(module),它被嵌入(mixin)到我们的类中。现在让我们多看一下module_eval
方法。这里有三件有趣的事情发生了。
第一件事就是HEREDOC被用作一个参数传给了一个方法。这是有点难懂的,但是对某些场景却是非常有用的。举个例子,想象我们在一个服务器响应(response)中有一个方法要用来嵌入Javascript代码:
include_js(<<-JS, :minify => true)
$('#logo').show();
App.refresh();
JS
这将会把字符串"$('#logo').show(); App.refresh();"
作为调用include_js
时传入的第一个参数,而:minify => true
作为第二个参数。在Ruby中需要生成代码时,这是一个非常有用的技巧。值得高兴的是,诸如TextMate这类编辑器都能够识别这个模式,并且正确地高亮显示字符串。即使你并不需要生成代码,HEREDOC对于多行的字符串也是比较有用的。
现在我们就知道了<<-RUBY
做了些什么事,但是__FILE__
以及__LINE__ + 1
呢?__FILE__
返回了当前文件的(相对)路径,而__LINE__
返回了当前代码的行号。module_eval
接收这些参数,并通过这些参数决定新的代码定义在文件中“看起来”的位置。在对于栈跟踪(stack traces)来说是特别有用的。
最后,让我们看一些module_eval
中实际执行的代码。我们可以把值替换成我们的name_changed?
:
mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
def name_changed?(*args)
attribute_changed?("name", *args)
end
RUBY
现在name_changed?
就是一个真实的方法了,比起依赖于method_missing
方法的实现,这种方法的开销要小得多。
总结(Recap)
我们发现了调用attribute_method_suffix
方法会保存一个配置好的对象,这个对象用于Rails中两种元编程方法中的一种。不考虑是否使用了method_missing
,或者通过module_eval
定义了新的方法,方法的调用最后总会被传递到诸如attribute_changed?(attr)
这样的方法上。
走过这次比较宽泛的旅途,我们也收获了一些有用的技巧:
- 你必须使用
Hash#fetch
从options中读取参数,特别是对于boolean类型参数来说。 - 诸如
"%s_changed"
这样的格式化字符串,可以被用于简单的模板。 - 可以使用
Regexp.escape
escape正则表达式。 - 当你试图调用一个未定义的方法时,Ruby会调用
method_missing
方法。 - HEREDOCs可以用在方法参数中,也可以用来定义多行的字符串。
-
__FILE__
以及__LINE__
指向当前的文件以及行号。 - 你可以使用
module_eval
动态生成代码。
坚持浏览Rails的源代码吧,你总会发现你原本不知道的宝藏!
喜欢这篇文章?
阅读另外8篇《解读Rails》中的文章。