Книга: Metaprogramming Ruby 2
Назад: Chapter 10: Active Support’s Concern Module
Дальше: ActiveSupport::Concern

Rails Before Concern

The Rails source code has changed a lot through the years, but some basic ideas haven’t changed much. One of these is the concept behind ActiveRecord::Base. As you’ve seen in , this class is an assembly of dozens of modules that define both instance methods and class methods. For example, Base includes ActiveRecord::Validations, and in the process it gets instance and class methods.

The mechanism that rolls those methods into Base, however, has changed. Let’s see how it worked in the beginning.

The Include-and-Extend Trick

Around the times of Rails 2, all validation methods were defined in ActiveRecord::Validations. (Back then, there was no Active Model library.) However, Validations pulled a peculiar trick:

 
module​ ActiveRecord
 
module​ Validations
 
# ...
 
 
def​ self.included(base)
 
base.extend ClassMethods
 
# ...
 
end
 
 
module​ ClassMethods
 
def​ validates_length_of(*attrs) ​# ...
 
# ...
 
end
 
 
def​ valid?
 
# ...
 
end
 
 
# ...
 
end
 
end

Does the code above look familiar? You’ve already seen this technique in . Here’s a quick recap. When ActiveRecord::Base includes Validations, three things happen:

  1. The instance methods of Validations, such as valid?, become instance methods of Base. This is just regular module inclusion.

  2. Ruby calls the included Hook Method () on Validations, passing ActiveRecord::Base as an argument. (The argument of included is also called base, but that name has nothing to do with the Base class—instead, it comes from the fact that a module’s includer is sometimes called “the base class.”)

  3. The hook extends Base with the ActiveRecord::Validations::ClassMethods module. This is a Class Extensions (), so the methods in ClassMethods become class methods on Base.

As a result, Base gets both instance methods like valid? and class methods like validates_length_of.

This idiom is so specific that I hesitate to call it a spell. I’ll refer to it as the include-and-extend trick. VCR borrowed it from Rails, as did many other Ruby projects throughout the years. Include-and-extend gives you a powerful way to structure a library: each module contains a well-isolated piece of functionality that you can roll into your classes with a simple include. That functionality can be implemented with instance methods, class methods, or both.

As clever as it is, include-and-extend has its own share of problems. For one, each and every module that defines class methods must also define a similar included hook that extends its includer. In a large codebase such as Rails’, that hook was replicated over dozens of modules. As a result, people often questioned whether include-and-extend was worth the effort. After all, they observed, you can get the same result by adding one line of code to the includer:

 
class​ Base
 
include Validations
 
extend Validations::ClassMethods
 
# ...

Include-and-extend allows you to skip the extend line and just write the include line. You might argue that removing this line from Base isn’t worth the additional complexity in Validations.

However, complexity is not include-and-extend’s only shortcoming. The trick also has a deeper issue—one that deserves a close look.

The Problem of Chained Inclusions

Imagine that you include a module that includes another module. You’ve seen an example of this in : ActiveRecord::Base includes ActiveRecord::Validations, which includes ActiveModel::Validations. What would happen if both modules used the include-and-extend trick? You can find an answer by looking at this minimal example:

 
module​ SecondLevelModule
 
def​ self.included(base)
 
base.extend ClassMethods
 
end
 
 
def​ second_level_instance_method; ​'ok'​; ​end
 
 
module​ ClassMethods
 
def​ second_level_class_method; ​'ok'​; ​end
 
end
 
end
 
 
module​ FirstLevelModule
 
def​ self.included(base)
 
base.extend ClassMethods
 
end
 
 
def​ first_level_instance_method; ​'ok'​; ​end
 
 
module​ ClassMethods
 
def​ first_level_class_method; ​'ok'​; ​end
 
end
 
 
include SecondLevelModule
 
end
 
 
class​ BaseClass
 
include FirstLevelModule
 
end

BaseClass includes FirstLevelModule, which in turn includes SecondLevelModule. Both modules get in BaseClass’s chain of ancestors, so you can call both modules’ instance methods on an instance of BaseClass:

 
BaseClass.new.first_level_instance_method ​# => "ok"
 
BaseClass.new.second_level_instance_method ​# => "ok"

Thanks to include-and-extend, methods in FirstLevelModule::ClassMethods also become class methods on BaseClass:

 
BaseClass.first_level_class_method ​# => "ok"

SecondLevelModule also uses include-and-extend, so you might expect methods in SecondLevelModule::ClassMethods to become class methods on BaseClass. However, the trick doesn’t work in this case:

 
BaseClass.second_level_class_method ​# => NoMethodError

Go through the code step by step, and you’ll see where the problem is. When Ruby calls SecondLevelModule.included, the base parameter is not BaseClass, but FirstLevelModule. As a result, the methods in SecondLevelModule::ClassMethods become class methods on FirstLevelModule—which is not what we wanted.

Rails 2 did include a fix to this problem, but the fix wasn’t pretty: instead of using include-and-extend in both the FirstLevelModule and the SecondLevelModule, Rails used it only in the FirstLevelModule. Then FirstLevelModule#included forced the includer to also include the SecondLevelModule, like this:

 
module​ FirstLevelModule
 
def​ self.included(base)
 
base.extend ClassMethods
*
base.send :include, SecondLevelModule
 
end
 
 
# ...

Distressingly, the code above made the entire system less flexible; it forced Rails to distinguish first-level modules from other modules, and each module had to know whether it was supposed to be first-level. (To make things clumsier, Rails couldn’t call Module#include directly, because it was a private method—so it had to use a Dynamic Dispatch () instead. Recent rubies made include public, but we’re talking ancient history here.)

At this point in our story, you’d be forgiven for thinking that include-and-extend created more problems than it solved in the first place. This trick forced multiple modules to contain the same boilerplate code, and it failed if you had more than one level of module inclusions. To address these issues, the authors of Rails crafted ActiveSupport::Concern.

Назад: Chapter 10: Active Support’s Concern Module
Дальше: ActiveSupport::Concern