Книга: Metaprogramming Ruby 2
Назад: Rails Before Concern
Дальше: A Lesson Learned

ActiveSupport::Concern

ActiveSupport::Concern encapsulates the include-and-extend trick and fixes the problem of chained inclusions. A module can get this functionality by extending Concern and defining its own ClassMethods module:

 
require ​'active_support'
 
 
module​ MyConcern
 
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 MyConcern
 
end
 
 
MyClass.new.an_instance_method ​# => "an instance method"
 
MyClass.a_class_method ​# => "a class method"

In the rest of this chapter I’ll use the word “concern” with a lowercase C to mean “a module that extends ActiveSupport::Concern,” like MyConcern does in the example above. In modern Rails, most modules are concerns, including ActiveRecord::Validations and ActiveModel::Validations.

Let’s see how Concern works its magic.

A Look at Concern’s Source Code

The source code of Concern is quite short but also fairly complicated. It defines just two important methods: extended and append_features. Here is extended:

 
module​ ActiveSupport
 
module​ Concern
 
class​ MultipleIncludedBlocks < StandardError ​#:nodoc:
 
def​ initialize
 
super​ ​"Cannot define multiple 'included' blocks for a Concern"
 
end
 
end
 
 
def​ self.extended(base)
 
base.instance_variable_set(:@_dependencies, [])
 
end
 
 
# ...

When a module extends Concern, Ruby calls the extended Hook Method (), and extended defines an @_dependencies Class Instance Variable () on the includer. I’ll show you what happens to this variable in a few pages. For now, just remember that all concerns have it, and it’s initially an empty array.

To introduce Concern#append_features, the other important method in Concern, let me take you on a very short side-trip into Ruby’s standard libraries.

Module#append_features

Module#append_features is a core Ruby method. It’s similar to Module#included, in that Ruby will call it whenever you include a module. However, there is an important difference between append_features and included: included is a Hook Method that is normally empty, and it exists only in case you want to override it. By contrast, append_features is where the real inclusion happens. append_features checks whether the included module is already in the includer’s chain of ancestors, and if it’s not, it adds the module to the chain.

There is a reason why you didn’t read about append_features in the first part of this book: in your normal coding, you’re supposed to override included, not append_features. If you override append_features, you can get some surprising results, as in the following example:

 
module​ M
 
def​ self.append_features(base); ​end
 
end
 
 
class​ C
 
include M
 
end
 
 
C.ancestors ​# => [C, Object, Kernel, BasicObject]

As the code above shows, by overriding append_features you can prevent a module from being included at all. Interestingly, that’s exactly what Concern wants to do, as we’ll see soon.

Concern#append_features

Concern defines its own version of append_features.

 
module​ ActiveSupport
 
module​ Concern
 
def​ append_features(base)
 
# ...

Remember the Class Extension () spell? append_features is an instance method on Concern, so it becomes a class method on modules that extend Concern. For example, if a module named Validations extends Concern, then it gains a Validation.append_features class method. If this sounds confusing, look at this picture showing the relationships between Module, Concern, Validations, and Validation’s singleton class:

images/rails_concern.jpg

Figure 10. ActiveSupport::Concern overrides Module#append_features.

Let’s recap what we’ve learned so far. First, modules that extend Concern get an @_dependencies Class Variable. Second, they get an override of append_features. With those two concepts in place, we can look at the code that makes Concern tick.

Inside Concern#append_features

Here is the code in Concern#append_features:

 
module​ ActiveSupport
 
module​ Concern
 
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)
 
# ...
 
end
 
end
 
 
# ...

This is a hard piece of code to wrap your brain around, but its basic idea is simple: never include a concern in another concern. Instead, when concerns try to include each other, just link them in a graph of dependencies. When a concern is finally included by a module that is not itself a concern, roll all of its dependencies into the includer in one fell swoop.

Let’s look at the code step by step. To understand it, remember that it is executed as a class method of the concern. In this scope, self is the concern, and base is the module that is including it, which might or might not be a concern itself.

When you enter append_features, you want to check whether your includer is itself a concern. If it has an @_dependencies Class Variable, then you know it is a concern. In this case, instead of adding yourself to your includer’s chain of ancestors, you just add yourself to its list of dependencies, and you return false to signal that no inclusion actually happened. For example, this happens if you are ActiveModel::Validations, and you get included by ActiveRecord::Validations.

What happens if your includer is not itself a concern—for example, when you are ActiveRecord::Validations, and you get included by ActiveRecord::Base? In this case, you check whether you’re already an ancestor of this includer, maybe because you were included via another chain of concerns. (That’s the meaning of base < self.) If you are not, you come to the crucial point of the entire exercise: you recursively include your dependencies in your includer. This minimalistic dependency management system solves the issue that you’ve read about in .

After rolling all your dependent concerns into your includer’s chain of ancestors, you still have a couple of things to do. First, you must add yourself to that chain of ancestors, by calling the standard Module.append_features with super. Finally, don’t forget what this entire machinery is for: you have to extend the includer with your own ClassMethods module, like the include-and-extend trick does. You need Kernel#const_get to get a reference to ClassMethods, because you must read the constant from the scope of self, not the scope of the Concern module, where this code is physically located.

Concern also contains some more functionality, but you’ve seen enough to grasp the idea behind this module.

Concern Wrap-Up

ActiveSupport::Concern is a minimalistic dependency management system, wrapped into a single module with just a few lines of code. That code is complicated, but using Concern is easy, as you can see by looking into Active Model’s source:

 
module​ ActiveModel
 
module​ Validations
 
extend ActiveSupport::Concern
 
# ...
 
 
module​ ClassMethods
 
def​ validate(*args, &block)
 
# ...

Just by doing the above, ActiveModel::Validation adds a validate class method to ActiveRecord::Base, without worrying about the fact that ActiveRecord::Validation happens to be in the middle. Concern will work behind to scenes to sort out the dependencies between concerns.

Is ActiveSupport::Concern too clever for its own good? That’s up to you to decide. Some programmers think that Concern hides too much magic behind a seemingly innocuous call to include, and this hidden complexity carries hidden costs. Other programmers praise Concern for helping to keep Rails’ modules as slim and simple as they can be.

Whatever your take on ActiveSupport::Concern, you can learn a lot by exploring its insides. Here is one lesson I personally took away from this exploration.

Назад: Rails Before Concern
Дальше: A Lesson Learned