Draperソースコードリーディング

初めまして、19年新卒webエンジニアの平井蒼大です。

弊社では、昼休憩時間を使って、最近勉強したこと、 興味があること、最近行った勉強会やカンファレンスの内容などをLT形式で自由に発表するHacker’s Delightという場が設けられています。

私も先日、「Draperのソースコードリーディング」というお題で発表しましたので、その内容を掲載したいと思います。

動機

今回、Draperのソースコードリーディングに至った理由は以下の二つです。

  • Draperの仕組みを知りたい。
  • Ruby, Ruby on Railsについての知識を増やす。

Draperとは

DraperはPresenter層を提供するgemです。

draperを使うことで以下の利点があります。

  • モデルの肥大化を防ぐ
  • グローバル空間ににヘルパーメソッドが追加されることを防ぐ。

理解する部分

今回、ソースコードリーディングを通して理解するDraperの機能は以下の二つにしました。

また、今回、ArticleクラスをラップしたArticleDecoratorクラスを生成してDraperを使った状況を下に、説明したいと思います。

Decoratorクラスのインスタンス生成方法

下のコードのように、ラップされたクラス(Articleクラス)のインスタンスに.decorateすることで、Decoratorクラスのインスタンスが生成されます。この動きをソースコードを読んで理解しました。

Article.first.decorate

ラップされたクラスのメソッドの呼び出し

Decoratorクラス内でdelegate_allを呼び出すと、ラップされたクラスのメソッドを使うことができます。下の例では、delegate_allすることで、Articleクラスで定義されているpublished?メソッドを呼び出しています。

この動きもソースコードを読んで理解しました。

class ArticleDecorator < Draper::Decorator
  delegate_all
  def publication_status
    if published? 
      "Published at #{published_at}"
    end
  end
end

Decoratorクラスのインスタンス生成方法

Decoratorクラスのインスタンスが生成されるまでの流れを以下の二つのセクションに分けて、説明したいと思います。

  • Railtieによって、Draper::DecoratableモジュールがActiveRecord::Baseにincludeされる流れ
  • decorateメソッドの中身

つまり .decorateが呼ばれる前のDraperが読み込まれた際の処理と、.decorateが呼ばれた後の流れを分けて説明します。

Railtieによって、Draper::DecoratableモジュールがActiveRecord::Baseにincludeされる流れ

まずは、railtie.rbを読んで、どのような初期化ステップが設定されているかを見ます。

今回関係するソースコードは以下になります。

module Draper
  class Railtie < Rails::Railtie

   # 他の初期化ステップが追加されている

    initializer 'draper.setup_orm' do
      [:active_record, :mongoid].each do |orm|
        ActiveSupport.on_load orm do
          Draper.setup_orm self
        end
      end
    end

Draper::Railtieクラスは、Rails:Railtieクラスのサブクラスなので、Railsのブートプロセス時に呼ばれます。

initializerの働きは以下です。

  • ブロックの内容をdraper.setup_ormという名前の初期化ステップとして設定
  • 初期化ステップ自体はMyapp.application.initialize!が呼ばれた時に呼び出される

次に、initializerのブロック内の説明をします。

ActiveSupport.on_loadは activesupport/lib/active_support/lazy_load_hooks.rbで定義されています。

    def on_load(name, options = {}, &block)
      @loaded[name].each do |base|
        execute_hook(name, base, options, block)
      end

      @load_hooks[name] << [block, options]
    end

    def run_load_hooks(name, base = Object)
      @loaded[name] << base
      @load_hooks[name].each do |hook, options|
        execute_hook(name, base, options, hook)
      end
    end

    private
      def with_execution_control(name, block, once)
        unless @run_once[name].include?(block)
          @run_once[name] << block if once

          yield
        end
      end

      def execute_hook(name, base, options, block)
        with_execution_control(name, block, options[:run_once]) do
          if options[:yield]
            block.call(base)
          else
            if base.is_a?(Module)
              base.class_eval(&block)
            else
              base.instance_eval(&block)
            end
          end
        end
      end

今回の場合だとon_loadは、@loaded(:active_record)に対応するものがあれば、そのブロックを即時実行し、なければ@load_hooksに :active_recordというキーでDraper.setup_orm selfを格納します。

この@load_hooksに格納されたDraper.setup_orm selfはlazy_load_hooks.rb内で定義されているrun_load_hooksが呼ばれた時に実行されます。

run_load_hooksはActiveRecord::Base内で、以下のように呼ばれています。

ActiveSupport.run_load_hooks(:active_record, Base)

今回の場合、run_load_hooksメソッド内のbaseはActiveRecord::Baseです。@load_hooks[:active_record]にはload(:active_record) { Draper.setup_orm self } によって Draper.setup_orm selfが格納されています。

つまり、ActiveRecord::Baseで、run_load_hooks(:active_record, Base)が呼ばれると、lazy_load_hooks.rb 内のexecute_hook内でActiveRecord::Base.instance_eval{ Draper.setup_orm self } が実行されます。

Draper.setup_ormはDraperモジュールで定義されています。

def self.setup_orm(base)
  base.class_eval do
    include Draper::Decoratable
  end
end

instance_eval内のselfはレシーバーのオブジェクトなのでDraper.setup_orm self のselfはActiveRecord::Baseです。

つまり、Draper.setup_orm(ActiveRecord:Base)が呼ばれ、ActiveRecord::BaseがDraper::Decoratableをincludeします。

decorateメソッドの中身

先程説明した流れで、Draper::DecoratableがActiveRecord::Baseにincludeされます。

Article.first.decorate

のdecorateメソッドはDraper::Decoratable内で定義されているdecorateメソッドに対応します。

Draper::Decoratableは以下のようになっています。

module Draper
    module Decoratable

      extend ActiveSupport::Concern

      def decorate(options = {})
        decorator_class.decorate(self, options)
      end

      def decorator_class
        self.class.decorator_class
      end

      module ClassMethods
        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

decorateメソッド内では直下のdecorator_classメソッドを呼びます。decorator_class内のselfはArticleのインスタンス(Article.first)です。

extend ActiveSupport::ConcernがあるのでClassMethods配下のメソッドはそのモジュールがincludeされたクラスのクラスメソッドになります。ですので、上のdecoratorメソッド直下のdecoratar_class内では、Decoratable::ClassMethods内のdecorator_classを呼び出しています。

このdecorator_classで、ラップされたクラス(Articleクラス)のDecoratorクラス(ArticleDecorator)を取得しています。

このdecorator_class内では、Articleのインスタンスがmodel_nameを持つか調べ、あったらprefixとします。そのprefixとDecoratorを文字列連結した変数とsafe_constantizeでDecoratorクラスを取得しています。

つまり、Decoratable直下のdecorateメソッド内のdecorator_classはArticleDecoratorになり、メソッド内ではArticleDecorator.decorate(self, options)を実行しています。

この時の、decorateは ArticleDecoratorクラスが継承しているDraper::Decoratorクラスのinitializeに対応しています。

module Draper
  class Decorator

    def initialize(object, options = {})
      options.assert_valid_keys(:context)
      @object = object
      @context = options.fetch(:context, {})
      handle_multiple_decoration(options) if object.instance_of?(self.class)
    end

    class << self
      alias_method :decorate, :new
    end

このような流れで、ArticleDecoratorクラスのインスタンスが生成されます。

ラップされたクラスのメソッドの呼び出し

Decoratorクラス内でdelegate_allを呼び出すと、ラップされたクラスのメソッドを使うことができる仕組みをソースコードを読んで理解したいと思います。

delegate_allメソッドはDraper::Decoratorクラスで定義されています。

module Draper
  class Decorator
    def self.delegate_all
      include Draper::AutomaticDelegation
    end

AutomaticDelegationモジュールをincludeしています。

AutomaticDelegationではmethod_missingが定義されています。

module Draper
  module AutomaticDelegation
    extend ActiveSupport::Concern
    
    def method_missing(method, *args, &block)
      return super unless delegatable?(method)

      object.send(method, *args, &block)
    end

    def delegatable?(method)
      return if private_methods.include?(method)

      object.respond_to?(method)
    end


# 他のメソッド

method_missingの中で使用されているobjectは、ArticleDecoratorクラスが継承しているDraper::Decoratorクラスのinitializeの中で定義されています。

def initialize(object, options = {})
  options.assert_valid_keys(:context)
  @object = object
  @context = options.fetch(:context, {})
  handle_multiple_decoration(options) if object.instance_of?(self.class)
end

このinitializeはDraper::Decoratableモジュール(ActiveRecord::Baseにincludeされた)のdecorateメソッド内で、.decorate(self, options)という形で呼ばれています。

module Draper
    module Decoratable

      extend ActiveSupport::Concern

      def decorate(options = {})
        decorator_class.decorate(self, options)
      end

この第一引数がobjectに当たるので、今回の場合のmethod_missing内のobjectはActicleクラスのインスタンスになります。

method_missingのソースコードに戻ります。

module Draper
  module AutomaticDelegation
    extend ActiveSupport::Concern
    
    def method_missing(method, *args, &block)
      return super unless delegatable?(method)

      object.send(method, *args, &block)
    end

    def delegatable?(method)
      return if private_methods.include?(method)

      object.respond_to?(method)
    end


# 他のメソッド

method_missingは呼び出さそうとしたメソッドが定義されていない時に、呼び出されます。引数のmethodには呼び出そうとしたメソッド名がシンボルで入ります。delegatable?では、プライベートメソッドを呼び出そうとしているか、また、Articleクラスのインスタンスが引数methodに入れたメソッドを呼び出せるかを調べています。

よって、method_missingの1行目では、呼び出そうとしているメソッドがプライベートメソッドまたは、Articleクラスのインスタンスメソッドでない場合、祖先クラス(objectクラス) のmethod_missingが呼ばれ、NoMethodErrorを発生させます。

そうではない場合、object(Articleクラスのインスタンス)にメソッドを渡して、メソッドを実行しています。このような仕組みでdelegate_allをするとラップされたクラスのメソッドを使うことができます。

所感

最初にも、記載させていただきましたが、今回ソースコードリーディングに至った理由としては以下の二つが挙げられます。

  • Draperの仕組みを知りたい
  • Ruby, Ruby on Railsについての知識を増やしたい

最後にこれらの理由に沿って、今回のソースコードリーディングの感想を書きたいと思います。

Draperの仕組みを知りたい

Draper全体の機能と比べるとかなり一部ではありますが、仕組みをソースコードベースで理解することができました。ソースコードリーディング前の仕組みを理解できていないモヤモヤ感が晴れた瞬間がソースコードリーディングの楽しみの一つなのかなと感じました。

Ruby, Ruby on Railsについての知識を増やしたい

railtieで、Railsの初期化処理を拡張したり、method_missingの使い方、その他のメソッドなど今回のソースコードリーディングで知識を増やすことができたと思います。ただ、railtieによって初期化ステップが設定される流れ、その初期化ステップが実際に実行される部分などは、さらに調べて理解度をあげたいと感じました。

また、 発表という形をとったため、Draperの仕組みについて言葉で伝えられる程理解する必要がありました。準備は大変でしたが、理解度をあげることができたのはとても良かったと感じています。

最後まで読んでいただきありがとうございます。