Tech Racho エンジニアの「?」を「!」に。
  • 開発

RubyのModule Builderパターン #3 Rails ActiveModelでの適用例(翻訳)

追記(2017/10/27)

  • 元記事からTechRachoにリンクいただきました🙇


元記事が非常に長いので次のように分割しました。

概要

原著者の許諾を得て翻訳・公開いたします。

RubyKaigi 2017@広島でも発表されたshioyamaさんです。このときのセッションでもModule Builderパターンを扱っています。

本記事の図はすべて元記事からの引用です。また、パターン名は英語で表記しました。

RubyのModule Builderパターン #3 Rails ActiveModelでの適用例(翻訳)

Module Builderとカプセル化

ここまでに学んだすべてを投入して、Rubyで最も有名な例のアプリフレームワークのコアで実際に動くいくつかのコードに適用してみることにしましょう。

直前のセクションの議論に続いて、ここではモジュールを使った典型的なステート設定方法(ステートをモジュールに保存する)と、この方法でモジュールを設定する場合の問題点に絞り込みたいと思います。私は、Module Builderならステートを本来あるべき場所、つまりモジュールそれ自身にずっと自然な方法でカプセル化できることを皆さんにご理解いただければと願っています。

ActiveModel::AttributeMethods

今回チェックするモジュールは、ActiveModelのAttributeMethodsです。このモジュールは、プレフィックスやサフィックスをサポートするattribute用メソッドをRailsに追加します1。Railsを少しでも扱ったことがあれば、ActiveModel::Dirtyname_changed?などの変更追跡系メソッドで、このようにプレフィックスやサフィックスを使うパターンに既に慣れ親しんでいることでしょう。このモジュールは、前述のAdderIncluderのようなモジュールブートストラップと、MethodFound::Interceptorのようなmethod_missingによるメッセージインターセプトの両方を実装しています。

メソッドのプレフィックスとアフィックス(訳注: affix: プレフィックスとサフィックスを両方とも指定すること)を1つずつ実装する単純なクラスから始めることにしましょう2

class Person
  include ActiveModel::AttributeMethods

  attribute_method_affix  prefix: 'reset_', suffix: '_to_default!'
  attribute_method_prefix 'clear_'
  define_attribute_methods :name

  attr_accessor :name, :title

  def attributes
    { 'name' => @name, 'title' => @title }
  end

  private

  def clear_attribute(attr)
    send("#{attr}=", nil)
  end

  def reset_attribute_to_default!(attr)
    send("#{attr}=", "Default #{attr.capitalize}")
  end
end

上の属性メソッドは次のように使います。

person = Person.new
person.name = "foo"
person.name
#=> "foo"
person.clear_name
person.name
#=> nil
person.reset_name_to_default!
person.name
#=> "Default Name"

上のコードを見ると、渡されたクラスについてどのようなプレフィックス/サフィックス/アフィックスが定義されているかを知るために、ステートがモジュールのどこかに保存されていることがわかります。そしてこのステートはattribute_method_prefixのコードに保存されることもわかります。これはPerson内で呼び出されてメソッドのプレフィックスを設定します。

def attribute_method_prefix(*prefixes)
  self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
  undefine_attribute_methods
end

これによりプレフィックス文字列のarrayがAttributeMethodMatcherクラスのいくつかのインスタンスにmapされ、Personクラスのattribute_method_matchersのarrayに保存されます(アフィックスやサフィックスのメソッドも同様です)。これらのマッチャはモジュールのコア設定ですが、モジュール自身の外部に保存されます

これらのマッチャの中身はどうなっているのでしょうか。理解のために、このクラスの初期化メソッドを見てみましょう。

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

つまり、マッチャは本質的に2つの要素「プレフィックスとサフィックスのペアと、そこから生成される正規表現」でできています(それ以外はほとんどが利便性のためのものです)。この内部ステートは、2つの異なる(しかし関連する)目的に用いられます。

2つの目的のうち1つは重要であるにもかかわらず、満足なドキュメントがまったくありません(このインラインコメントをご覧ください)。第1の目的とは、あるattributesメソッドから返されるキー/値ペアのハッシュ1つを、定義済みのプレフィックス/サフィックス/アフィックスメソッドをすべてサポートするファーストクラス(first-class)属性メソッドに変換する手段を提供することです。上の例では、属性はキーnametitleを含むハッシュを1つ返し、これがclear_attributereset_attribute_to_default!などのメソッドに割り当てられる属性になります。

MethodFoundの場合と同様に、これもmethod_missingを用いて実装されています。マッチャーの正規表現とマッチし、かつ正規表現でキャプチャされた対象がattributesのキーに含まれているメソッド呼び出しは、すべてインターセプトされて属性のハンドラに割り当てられます。このようにしてreset_title_to_default!reset_attribute_to_default!に割り当てられ、属性の名前(title)がその引数になります。

メソッドのインターセプトは属性のハッシュを制約なしに変更できる点では素晴らしいのですが、method_missingはシロップのように遅いのです。どんな属性が欲しいかが事前にわかっているのであれば、一般にはメソッドを明示的に定義する方がよいでしょう。

これがメソッドマッチャの第2の目的です。プレフィックス/サフィックス/アフィックスを設定(属性ごとにプレフィックスやサフィックスのペアを定義する)した後にdefine_attribute_methodsに1つ以上の属性を渡して呼び出すことでマッチャがトリガされます。Personnameの属性を定義しているので、clear_namereset_name_to_default!はそれぞれclear_attributereset_attribute_to_default!に割り当てられます。

これらのメソッドは、Personクラスに追加される別のメソッド(generated_attribute_methods)で定義およびincludeされる1個の無名モジュールに紐付けられます。これらはインスタンスメソッドとしてメソッドインターセプトより優先されるので、clear_titleclear_nameは両方とも同じ結果を返しますが、前者はmethod_missingにフォールスルーし、後者はこの属性メソッドで扱われます(後者の方がずっと高速です)。

ActiveModelのAttributeMethods

ActiveModelのAttributeMethods

次のようにPersonインスタンスのメソッドをgrepすれば、定義済みの属性メソッドを一覧できます。

person = Person.new
person.methods.grep /name/
#=> [:name, :name=, :reset_name_to_default!, :clear_name, ...]

モジュールのancestorsinstance_methodsを調べてみると、生成されたメソッドが(他の場所ではなく)このモジュールで定義されていることも確認できます。

Person.ancestors
#=> [Person, #<Module:Ox...>, ActiveModel::AttributeMethods, ...]
Person.ancestors[1].instance_methods
#=> [:name, :name=, :reset_name_to_default!, :clear_name]

また、前述したmethod_missingのオーバーライドがActiveModel::AttributeMethodsこの箇所、クラス階層における無名モジュールの直後で行われていることもわかります。

属性メソッドの2つの実装(method_missingによるメソッドインターセプトと、メソッド定義による実装)は、それぞれ適切なアクセスパターンが異なる(前者は大規模な属性セットに、後者は小規模な固定の属性セットに向いています)ので、互いに補完し合います。私のMobility gemでは、i18nアクセサの定義でこれと同じアプローチを実際に採用しています3。RailsのAttributeMethodsはクラスメソッドや変数を多数追加しますが、私のMobilityは何ひとつ追加しません。代わりに、すべてのステートはそれらモジュール内に隠蔽されます。

いよいよAttributeMethodsのもうひとつの実装をお見せしましょう。この実装は私のMobility gemと同様、Module Builderを用いてステートをモジュール内にカプセル化します。

MethodFound::AttributeMethods

このセクションでは、これまで議論してきたすべてのアイデアを集約して現実のアプリに適用します。コードの詳細を解説する前に、クラスに属性メソッドを実装する要素をおさらいしておきましょう。

  1. クラス変数(attribute_method_matchers): プレフィックス/サフィックス/アフィックスのマッチャーのarrayを保持する
  2. method_missingのオーバーライド: 一致する属性マッチャにメソッド呼び出しを割り当て、対応するハンドラにattributesハッシュを割り当てる
  3. includeされた無名モジュールgenerated_attribute_methods): 定義済みの属性メソッドを保持する

今度は、上の各コア要素が別の場所に置かれているところを考えてみましょう。

  • メソッドマッチャは、includeする側のクラスで定義されている
  • 生成された属性メソッドは、このクラスにincludeされている無名モジュールで定義されている
  • method_missingAttributeMethods自身に定義され、そのクラスにもincludeされている

これで、私たちの実装で使われる3つの要素が揃いました。3つの要素はメソッドマッチャのクラス変数を通じて互いに結合し、システム実装のさまざまな部分に広がります。しかしそれだけではありません。メソッドマッチャの機能と生成されたメソッドの機能は(この後でも説明するように)本質的に独立していますが、同じ場所(メソッドマッチャはarrayに、生成されたメソッドは無名モジュールに)に保存されます。

ステートを別の方法で分散する、別の実装を考えます。この実装は、Module Builderを用いて以下の要素を単一のモジュールにまとめます。

  • 単独のプレフィックス/サフィックスペア: ここからマッチャの正規表現が生成される
  • 単独のmethod_missingオーバーライド: メソッドを正規表現でインターセプトする
  • 単独のメソッド: 渡されたプレフィックス/サフィックスペアに対応する属性メソッドを定義する

このModule BuilderにはAttributeInterceptorという名前が付けられており、MethodFoundにあります。これは、前のセクションでお見せしたMethodFound::Interceptorを継承し、形式の制約がないデフォルトのインターセプタビルダではなく、プレフィックスやサフィックスを受け取れるようにイニシャライザをカスタマイズします。コードのサイズはそれほど大きくありませんが、ここではコードをまるごと引用するのはやめておき、代わりにクラスで実際に行われている内容に注目します。

MethodFoundインターセプタを持つAttributeMethodsの実装

MethodFoundインターセプタを持つAttributeMethodsの実装

まずはインターセプトを見てみましょう。上のPersonクラスのdefine_attribute_prefix呼び出しやdefine_attribute_affix呼び出しを以下のように属性インターセプタに置き換えることでインターセプトを再現できます。

class Person
  include MethodFound::AttributeInterceptor.new(prefix: 'clear_')
  include MethodFound::AttributeInterceptor.new(prefix: 'reset_', suffix: '_to_default!')

  attr_accessor :name, :title

  def attributes
    { 'name' => @name, 'title' => @title }
  end

  # ...
end

ここでは2つの属性インターセプタをそれぞれインスタンス化してクラスにincludeしています。アフィックスのための特別な対応は不要なので、単にプレフィックス用とサフィックス用のインターセプタをビルドしています。

これらのモジュールがincludeされると、Personは外部からはActiveModelモジュールを用いた場合と完全に同一に振る舞っているように見えます。しかし内部の実装はかなり異なっており、ancestorsでその違いを確認できます。

Person.ancestors
#=> [Person,
 #<MethodFound::AttributeInterceptor: /\A(?:reset_)(.*)(?:_to_default!)\z/>,
 #<MethodFound::AttributeInterceptor: /\A(?:clear_)(.*)(?:)\z/>,
 ...  ]

前述のMethodFoundインターセプタと同様に、2つのモジュールがそれぞれmethod_missingをオーバーライドし、メソッド名が(モジュールに保存されている)正規表現と一致すればコードパスを分岐します。

このようにして得られる分散/ネスト条件セットは、ActiveModelのこの行と同等です。このコードは、一致をチェックする属性メソッドマッチャを列挙します。

matchers.map { |method| method.match(method_name) }.compact

2つの実装の主要な相違点は、この行がActiveModel::AttributeMethods(これはモジュールです)で実行され、かつPerson(これはクラスです)に保存されたクラス変数を用いるのに対し、私のMethodFoundバージョンではsuperを用い、メソッドのコンポジションを通じて、独立した各モジュールで実行されるという点です。

生成された属性メソッドの実装も同じ要領です。AttributeInterceptorにはdefine_attribute_methodsというメソッドがあり、これは1つ以上の属性名を受け取って、各属性メソッドにモジュールのプレフィックスやサフィックスを加えたものをそのモジュール自身に定義します。繰り返しになりますが、このモジュールには自身のプレフィックスやサフィックスが含まれているので、この作業に必要な情報はすべてモジュール内に揃っています。

したがって、機能は真の意味でカプセル化されます。単一のモジュールが自身のプレフィックスやサフィックスを含み、属性メソッドの呼び出しをキャッチするmethod_missingのオーバーライドや、自身の属性メソッドを生成するためのメソッドも含んでいます。4

このModule Builderを使えば、クラスメソッドやクラス変数をひとつも書かずにActiveModelの実装を以下のように再現できます。

class Person
  [ MethodFound::AttributeInterceptor.new(prefix: 'clear_'),
    MethodFound::AttributeInterceptor.new(prefix: 'reset_', suffix: '_to_default!')
  ].each do |mod|
    mod.define_attribute_methods(:name)
    include mod
  end

  #...
end

MethodFoundMethodFound::AttributeMethodsという別のモジュールをincludeしています。このモジュールは上のコードをシンプルにするクラスメソッド群を追加するので、ActiveModel::AttributeMethodsをまるごと置き換えることができます。このモジュールに含まれるdefine_attribute_methodsの実装も興味深いものです。

def define_attribute_methods(*attr_names)
  ancestors.each do |ancestor|
    ancestor.define_attribute_methods(*attr_names) if ancestor.is_a?(AttributeInterceptor)
  end
end

このクラスはマッチャを自分のステートに保存しないので、このモジュールではマッチャを単に列挙してメソッドを定義するという手が使えません。代わりに、自身のancestorsを通じて列挙します。列挙されるのはプレフィックスやサフィックスのペアを持つ各モジュールインスタンスなので、先祖が属性インターセプタの場合にdefine_attribute_methodsメソッドを呼び出します。これによってインターセプタモジュールごとに属性メソッドが生成され、クラスのインスタンスから呼び出せるようになります。

こうして得られた実装では、前述の関連する要素が独立したモジュールにカプセル化されて、しかもクラス変数やクラスメソッドで結合されていません。このようにカプセル化できるということは、まったく新しい属性インターセプタを設計して従来と同じ方法でincludeできるようになったということです。(属性メソッド生成のための)インターフェイスが同じである限り、内部が完全に変わってしまっても変更は不要です。

これは私にとって「モジュールとはそもそもどうあるべきか?」という問いかけに思えます。モジュールは、必要なものをすべて含み、必要な一部の機能のある側面だけを実行できる、独立した交換可能な単位であるべきです。しかしそれだけではなく、こうしたモジュールを構築するインターフェイスは、Ruby自身のオブジェクトモデル(言語そのものと同じぐらい長い歴史を持つモデル)から直接逸脱してしまいます。このような飾りが長年に渡ってRubyの衣装の袖口に縫い付けられていたにもかかわらず、その存在はほぼ誰にも気づかれずじまいだったのです

Module Builderパターンという名前について

私がご紹介した「Module Builderパターン」について、こんなのはRubyの単なるサブクラスで、そのクラスがたまたまModuleクラスだったというだけじゃないか、何を大げさな、とお思いの方もいらっしゃるかもしれません。技術的な観点からは、おっしゃるとおりでしょう。私はこのアイデアの第一発見者でもなければ、最初に記述したわけでもありません。単にFoo < Moduleしてみたらできたということです。

しかし、プログラミング言語を読むのも書くのも人間なのですから、名前は人間にとって大きな意味があります。もし仮にこのメソッドを持つモジュールがancestorsのリストに埋もれたまま見過ごされていたとしたら誰も気づかなかったでしょうし、どこかのブロガーがサブクラス化について書いたことをすっかり忘れてしまったら、この手法を使うこともないでしょう。このパターンがこれまでほぼ誰にも気づかれることなく存在し続けていたという事実そのものが、その証しです。

だからこそ、私はこれにキャッチーな名前を付けたのです。

私もRubyistとして、このパターンは私たちに必要だと考えます。ある巨大なRubyプロジェクトのコードを手にとって、モジュールが現場でどのように用いられ(かつ誤用され)ているかを考えてみてください。モジュールがトリガする多数のコールバックはありとあらゆる余分なステートをクラスにブートストラップし、クラスがincludeするモジュールをがっちりと結合してしまいます。モジュールは自己完結すべきですが、実行時にモジュールを設定できないことを言い訳に、いろんな場所に設定をばらまくことが正当化されてしまっています。

Rubyはこうしたことをもっとうまく扱えるのですから、私たちももっとうまくやれるはずです。だからこそ、肥大化したモジュールに、アプリの隅々にまでおぞましい触手を伸ばすタコのようなモジュールに今一度目を向け、自由になるチャンスを与えてやってください。それがモジュールのためにも、ひいては皆さまのためにも良いことであると信じています。

バックナンバー

関連記事

[保存版]人間が読んで理解できるデザインパターン解説#1: 作成系(翻訳)

[保存版]人間が読んで理解できるデザインパターン解説#2: 構造系(翻訳)

Rubyで学ぶデザインパターンを社内勉強会で絶賛?連載中


  1. 実際には、ActiveRecordのモデルにはActiveRecord::AttributeMethodsincludeされています。これ自身がActiveModel::AttributeMethodsincludeして、本記事で扱うメソッドの一部(特にdefine_attribute_methods)をオーバーライドしています。2つのモジュールの関連や、永続化(persisted)や、非永続化の属性メソッドの扱いはある意味で複雑なのですが、ここで議論されているアイデアはこの両方に関連しています。 
  2. 以後の説明では、標準的なドキュメントでは指摘されていない部分をあえて強調するために、文中でクラス例から引用するコードを少し変えてありますのでご了承ください。 
  3. Mobility gemではFallthroughAccessorsLocaleAccessorsというModule Builderで2つのケースを取り扱っています。私はこれらもMobilityからI18nAccessorsというgemに切り出しました。 
  4. 実際にはActiveModel::AttributeMethodsと同様、属性をエイリアスするalias_attributeも含まれています。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。