2020-07-11

コードリーディング:DraperのDecorator探索ロジックを追う

Draper が、具体的にどういったロジックでDecoratorクラスを探索するかを調べた。
バージョンは v4.0.1

想定する状況

次のように、#decorate メソッドをActiveRecordのオブジェクトに対して呼び出す状況を想定する。

User.last.decorate
=> #<UserDecorator:0x00007fd0eae2ec18 ..>

ほとんどの場合、User モデルがあるならば UserDecorator を用意しておくことになるわけだが、Draperが内部的にこれをどうやって探索しているかというところを今回はコードレベルで追ってみたい。

最初に結論

  • #decorate メソッドのレシーバとなるオブジェクトのクラス名に "Decorator" というsuffixをつけたクラスがデコレータクラスになる
  • もしそのデコレータクラスが存在しない場合は、#decorator_class メソッドに応答する限り親クラスを再帰的に探索していく

コードリーディング

Draper::Decoratable#decorate

まずはエントリポイントとなる #decorate メソッドを探す。
単にgrepすると同名のメソッドが複数見つかるが、lib/draper/decoratable.rb のメソッドが探している方。

# lib/draper/decoratable.rb
def decorate(options = {})
  decorator_class.decorate(self, options)
end

内部では #decorator_class というメソッドが呼ばれており、その返り値に対して #decorate を呼んでいることがわかった。
早速ではあるが、今回の調査目的である「デコレータクラスを見つけ出す」処理に関連がありそうな名前のメソッドである。

Draper::Decoratable#decorator_class

ということで今度は #decorator_class メソッドを探す。これは同ファイル内のすぐ真下にある。

# lib/draper/decoratable.rb
def decorator_class
  self.class.decorator_class
end

self.class はライブラリの文脈で考えると、Draper::Decoratable を指す。なので、Draper::Decoratable にある特異メソッド .decorator_class を探せば良い。

Draper::Decoratable::ClassMethods#decorator_class

引き続きこのファイル内を探すと ClassMethods というモジュールの中に該当のメソッドが見つかる。これはActiveSupport::Concernの機能になるが、includeした側にクラスメソッドとして取り込む機能となる。ぱっと見た感じ、ここに今回の目的となる探索ロジックがありそうだ。ここは注意深く読んでいく。

# lib/draper/decoratable.rb
def decorator_class(called_on = self)
  prefix = respond_to?(:model_name) ? model_name : name
  decorator_name = "#{prefix}Decorator"
  decorator_name_constant = decorator_name.safe_constantize
  return decorator_name_constant unless decorator_name_constant.nil?

  if superclass.respond_to?(:decorator_class)
    superclass.decorator_class(called_on)
  else
    raise Draper::UninferrableDecoratorError.new(called_on)
  end
end

まず1行目。

prefix = respond_to?(:model_name) ? model_name : name

今回の想定ケースでは #model_name を使う方を頭に入れて次に進む。
もしUserモデルであれば、prefix 変数には "User" 文字列が代入されることになる。

その次の2行。

decorator_name = "#{prefix}Decorator"
decorator_name_constant = decorator_name.safe_constantize

上の行で取得したprefixに "Decorator" という文字列をくっつけて変数に代入している。 その次の #safe_constantize はActiveSupportがStringクラスを拡張して生やしたメソッドで、レシーバに対応するクラス(定数)を探索して見つかった場合はそのクラスを返し、見つからない場合はnilを返すという動きをする。

https://api.rubyonrails.org/classes/String.html#method-i-safe_constantize

つまり、Userモデルであれば "UserDecorator" という文字列が decorator_name 変数に格納され、その次の行で UserDecorator クラスが存在すればそれを、もし存在しなければnilが decorator_name_constant 変数に代入されることになる。

その次の1行。

return decorator_name_constant unless decorator_name_constant.nil?

decorator_name_constant がnilじゃない、すなわち対応するDecoratorクラスが見つかった場合は、returnでそのクラスを返してメソッドは終了する。
今回想定しているケースではここでreturnされるが、残り数行なので見つからなかった場合もついでに読んでみよう。それが次の5行となる。

if superclass.respond_to?(:decorator_class)
  superclass.decorator_class(called_on)
else
  raise Draper::UninferrableDecoratorError.new(called_on)
end

decorator_class メソッドの呼び出しに応答する限り、スーパークラスを再帰処理で辿っていくようだ。つまりもしUserモデルがBaseモデルを継承しているとして、UserDecoratorを用意していなくともBaseDecoratorがあればそれが使われるということがわかる。
decorator_class メソッドに応答できなくなった段階でデコレータクラス推測不可の例外が上がるようだ。

感想

普段から使っているライブラリで当然のように知っている仕様に対して内部のコードを読んでみた。振る舞いを理解できているので、コードを読むときの推測もおおよそ当たっていた気がする。
それでも親クラスを辿る仕様だったり、 #safe_constantize のようなメソッドだったり、ActiveSupport::Concern の機能だったりをコードを読む過程で学ぶことができたのが良かった。