属性方法

此文翻译自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查看代码):

1
2
3
4
5
6
7
module Dirty
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

  included do
    attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
    #...

让我们打开ActiveModel库中的attribute_methods.rb文件,并且看一下到底发生了什么事情。

1
2
3
4
5
6
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定义的方法:

1
2
3
4
5
6
7
8
module AttributeMethods
  extend ActiveSupport::Concern

  included do
    class_attribute :attribute_aliases,
                    :attribute_method_matchers,
                    instance_writer: false
    #...

class_attribute方法帮助你在类上定义属性。你可以这样在你自己的代码中这样使用:

1
2
3
4
5
6
7
8
9
10
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

1
2
3
4
5
6
7
8
9
10
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型数据来说:

1
2
3
4
5
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将会有如下的实例变量:

1
2
3
4
5
@prefix                #=> ""
@suffix                #=> "_changed?"
@regex                 #=> /^(?:)(.*)(?:_changed\?)$/
@method_missing_target #=> "attribute_changed?"
@method_name           #=> "%s_changed?"

你一定想知道%s_changed中的%s是用来干什么的吧?这是一个格式化字符串(format string)。你可以使用sprintf方法对它插入值,或者使用缩写(shortcut)%

1
2
sprintf("%s_changed?", "name") #=> "named_changed?"
"%s_changed?" % "age"          #=> "age_changed?"

第二个比较有趣的地方就是正则表达式创建的方式。请留意创建@regex变量时Regexp.escape的用法。如果后缀没有被escape,则正则表达式中带有特殊含义的符号将会被错误解释(misinterpreted):

1
2
3
4
5
6
7
8
9
# 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是如何利用这个技巧调用属性方法的:

1
2
3
4
5
6
7
8
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对象:

1
2
3
4
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实例,它所做的事就是对比对象本身的正则表达式与当前的方法名:

1
2
3
4
5
def attribute_method_matcher(method_name)
  #...
  attribute_method_matchers.detect { |method| method.match(method_name) }
  #...
end

如果Rails找到了匹配当前调用的方法的属性,那么接下来所有参数都会被传递给attribute_missing方法:

1
2
3
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方法做到了对这类属性方法的自动定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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中的元编程。下面是这个方法被删掉了一些特殊场景下的代码的版本,你可以在阅读完这篇文章后自己去了解更多的代码。

1
2
3
4
5
6
7
8
9
10
11
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代码:

1
2
3
4
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?

1
2
3
4
5
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.escapeescape正则表达式。
  • 当你试图调用一个未定义的方法时,Ruby会调用method_missing方法。
  • HEREDOCs可以用在方法参数中,也可以用来定义多行的字符串。
  • __FILE__以及__LINE__指向当前的文件以及行号。
  • 你可以使用module_eval动态生成代码。

坚持浏览Rails的源代码吧,你总会发现你原本不知道的宝藏!

喜欢这篇文章?

阅读更多《解读Rails》中的文章。

Comments