Ruby Active Support 的 Concern 模块来由探究

bajiudongfeng · March 20, 2017 · Last by prothro replied at March 30, 2017 · 4965 hits
Topic has been selected as the excellent topic by the admin.

ActiveSupport::Concern 模块作用:让 rails 类包含模块之后同时获得实例方法和类方法。 例如:

require 'active_support'
module MyConcernA
  extend ActiveSupport::Concern

  def an_instance_method 
    "an_instance_method "
  end

  module ClassMethods
    def a_class_method
      "a_class_method"
    end
  end

end

class MyClass
  include MyConcernA
end

MyClass.a_class_method
=> "a_class_method" 
MyClass.new.an_instance_method
=> "an_instance_method "

那么这个是如何实现的呢? 让我们从头说起。 两个基本的概念:include 和 extend
include 让类获得实例方法
extend 让类获得类方法
最早使类同时获得实例方法和类方法的解决方法:

module A
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    def a_class_method
      puts "a_class_method"
    end
  end
  def a_instance_method
    puts "a_instance_method"
  end
end

class W
  include A
end
W.a_class_method
a_class_method
 => nil

模块 A 覆写了钩子方法 included,当在类 W 中 include A 的时候会调用覆写后的方法。
此时会把 W 作为一个参数传递进去,就是 included 钩子方法中的 base,所以就相当于 W 扩展了模块 ClassMethods,因此模块 ClassMethods 中的方法也就成为了类 A 的类方法。

但是这个技巧本身存在一些问题。每个需要定义类方法的模块都需要定义一个相似的 included 钩子方法。 除此之外还有一个更为关键的问题,例如:

module B
  def self.included(base)
    base.extend ClassMethods
    #base.send :include, A
  end

  module ClassMethods
    def b_class_method
      puts "b_class_method"
    end
  end
  def b_instance_method
    puts "b_instance_method"
  end
  include A
end

class C
  include B
end

C.b_class_method
b_class_method
 => nil 
C.a_class_method
NoMethodError: undefined method `a_class_method' for C:Class
B.a_class_method
a_class_method
 => nil 

本来 a_class_method 应该是类 C 的类方法才对,可是这里出了问题。原因在哪里呢?
在类C中 include B 此时参数 base 的值是 C
而在模块 B 中 include A 时候参数 base 的值是 B,所以 a_class_method 成为模块 B 的一个类方法。

为了解决这个问题,需要在模块 B 的钩子方法 included 中加入注释掉的代码

base.send :include, A

让类 C 调用一下模块 A 的钩子方法方法 included. 这样问题解决了,但是会造成其他的问题,如各个模块都包含了相似的代码,当超过一层模块包含的时候还有可能失败。

为了解决上述问题,ActiveSupport::Concern 出现了。其封装了包含并且扩展的技巧,同时解决了链式包含的问题。
一个模块可以通过扩展 concern 模块来定义自己的 ClassMethods 模块来实现包含并且扩展的功能,也就是文章最开始的例子。
那么这个是如何实现的呢?看源码

  module Concern
    class MultipleIncludedBlocks < StandardError #:nodoc:
      def initialize
        super "Cannot define multiple 'included' blocks for a Concern"
      end
    end

    def self.extended(base) #:nodoc:
      base.instance_variable_set(:@_dependencies, [])
    end

    def append_features(base)
      if base.instance_variable_defined?(:@_dependencies)
        base.instance_variable_get(:@_dependencies) << self
        return false
      else
        return false if base < self
        @_dependencies.each { |dep| base.send(:include, dep) }
        super
        base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
        base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
      end
    end
 
 # 。。。

主要是两个方法 extend 和 append_features
当模块扩展 Concern 时候,会调用钩子方法 extended。在这个方法中为扩展它的类定义了一个@_dependencies.默认值是 [].
方法append_features是一个内核方法。和module#included类似。
相同点:当包含一个模块的时候被调用。
不同的:included 默认是空的,只有覆写之后才会有内容。而 append_features 则包含有实际的动作。用于检查被包含模块是否已经在包含类的祖先链上,如果不在则将该模块加入其祖先链。

当一个模块扩展 ActiveSupport::Concern,我们称其为一个 concern
接着我们来分析 append_features 的源码:
主要分为两种情况:
第一种情况

module MyConcernB
  extend ActiveSupport::Concern

  def an_instance_methodb
    "an_instance_method "
  end

  module ClassMethods
    def a_class_methodb
      "a_class_method"
    end
  end
  def self.instance_check
    instance_variable_get(:@_dependencies)
  end
  include MyConcernA
end

代码会先判断 base 是否是一个 concern.
当前是一个 concern(MyConcernB) 在包含一个 concern(MyConcernA).此时包含的时候并没有真正的执行包含的动作,只是把链接放到一个依赖图中。也就是将 MyConcernA 放入数组@_dependencies.

MyConcernB.instance_check
 => [MyConcernA] 
MyConcernB.a_class_method
NoMethodError: undefined method `a_class_method' for MyConcernB:Module

第二种情况

class MyClass
  include MyConcernB
end

此时就是 else 后边的情况了。
首先会判断 MyClass 是否继承了 MyConcernB,也就是看 MyConcernB 是否在 MyClass 的继承链中。
如果没有就进行最关键的步骤:concern(MyConcernB) 中的依赖(也就是@_dependencies,上文已经打印出了值)会被递归包含到类 Myclass 中。这种最小化的依赖管理方式解决了之前链式包含的问题。
此处的

@_dependencies.each { |dep| base.send(:include, dep) }

就相当于在 MyClass 中

include MyConcernA

把所有的依赖的 concern(也就是@_dependencies中的值) 都加入类的祖先链之后,
需要把包含类自己(此处相当于 MyConcernB)也加入类的继承链中。这个通过调用 super 来实现。
最后是要通过 ClassMethods 模块来扩展类,就像最初做的事情一样。

以上内容全部来源于 ruby 元编程第十章。
第一次写,还请大家多多指导。

huacnlee mark as excellent topic. 20 Mar 23:41

其实现在base.send :include, A,可以直接写成base.include A了。https://github.com/rails/rails/pull/18763#issuecomment-72349769

@liukun_lk 元编程上也提到了,为了讲清楚来由,我就还按照以前的写法写了。

个人觉得老的写法很直观,关这一点就够了。说实话,第一次看到 concern 的时候,根本不知道这是啥东西……

You need to Sign in before reply, if you don't have an account, please Sign up first.