2017-11-15

draperコードリーディング

drapergem/draperのコードリーディングの備忘録。

lib/draper/railtie.rbにRailtieが定義されています。

module Draper
  class Railtie < Rails::Railtie
    config.after_initialize do |app|
      app.config.paths.add 'app/decorators', eager_load: true

      if Rails.env.test?
        require 'draper/test_case'
        require 'draper/test/rspec_integration' if defined?(RSpec) and RSpec.respond_to?(:configure)
      end
    end

    initializer 'draper.setup_action_controller' do
      ActiveSupport.on_load :action_controller do
        Draper.setup_action_controller self
      end
    end

    initializer 'draper.setup_action_mailer' do
      ActiveSupport.on_load :action_mailer do
        Draper.setup_action_mailer self
      end
    end

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

メインは以下で、特にactive_recordに対するDraper.setup_ormメソッドが重要です。

setup_ormはDraper::DecoratableをActiveRecord::Baseにincludeします。
module Draper
  extend Draper::Configuration

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

ActiveRecordに対してdecorateを呼び出すときはDraper::Decoratableのdecorateメソッドが呼び出されます。ここでは"モデル名" + "Decorator" の名前のクラスのdecorateメソッドを呼び出します。

module Draper
  module Decoratable
    extend ActiveSupport::Concern
    include Draper::Decoratable::Equality

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

    # (see ClassMethods#decorator_class)
    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

Draper::Decoratorをextendしている場合、decorateメソッドはnewメソッドへのエイリアスなのでActiveRecordに対してdecorateを呼ぶと、Decoratorのインスタンスが生成されつつ元のオブジェクトが引数として渡されます。まさにDecoratorの作り方そのもので、元のオブジェクトはobjectプロパティとしてアクセスができます。

delegate_allを呼び出している場合はDraper::AutomaticDelegationがincludeされます。

def self.delegate_all
  include Draper::AutomaticDelegation
end

AutomaticDelagationはmethod_missingに関するオーバーライドになります。objectがメソッドを持っていれば委譲し、そうでなければ本来の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
...
    module ClassMethods
      # Proxies missing class methods to the source class.
      def method_missing(method, *args, &block)
        return super unless delegatable?(method)

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

ActiveRecord::Relationの場合はクラスメソッドのdecorateが呼び出されます。

module Draper
  module Decoratable
    module ClassMethods

      def decorate(options = {})
        decorator_class.decorate_collection(all, options.reverse_merge(with: nil))
      end

decorator_classのdecorate_collectionメソッドはモデル名の複数系(pluralize) + "Decorator" の名前のデコレータを検索し、decorate_collectionメソッドを呼び出します。対象のデコレータが存在しない場合は、Draper::CollectionDecoratorがdecorate_collectionメソッドを呼び出すことになります。

def self.decorate_collection(object, options = {})
  options.assert_valid_keys(:with, :context)
  collection_decorator_class.new(object, options.reverse_merge(with: self))
end

# @return [Class] the class created by {decorate_collection}.
def self.collection_decorator_class
  name = collection_decorator_name
  name_constant = name && name.safe_constantize

  name_constant || Draper::CollectionDecorator
end

def self.collection_decorator_name
  singular = object_class_name
  plural = singular && singular.pluralize

  "#{plural}Decorator" unless plural == singular
end

このオブジェクトはArray系のメソッドをdecorated_collectionに委譲します。

decorated_collectionはActiveRecord::Relation#mapによってデコレートしたActiveRecordの配列を生成します。この配列に対してArray系のメソッドを委譲することで、デコレートされたActiveRecordの配列を透過的に利用できます。

module Draper
  class CollectionDecorator
    include Enumerable
    include Draper::ViewHelpers
    extend Draper::Delegation

    array_methods = Array.instance_methods - Object.instance_methods
    delegate :==, :as_json, *array_methods, to: :decorated_collection

...
    # @return [Array] the decorated items.
    def decorated_collection
      @decorated_collection ||= object.map{|item| decorate_item(item)}
    end

    protected

    # Decorates the given item.
    def decorate_item(item)
      item_decorator.call(item, context: context)
    end

    private

    def item_decorator
      if decorator_class
        decorator_class.method(:decorate)
      else
        ->(item, options) { item.decorate(options) }
      end
    end

また、Draper::DecoratorやDraper::CollectionDecoratorはDraper::ViewHelpersをincludeしています。これによって各デコレータからhメソッドによってヘルパーメソッドを呼び出すことが可能になります。

module Draper
  # Provides the {#helpers} method used in {Decorator} and {CollectionDecorator}
  # to call the Rails helpers.
  module ViewHelpers
    extend ActiveSupport::Concern

    module ClassMethods

      # Access the helpers proxy to call built-in and user-defined
      # Rails helpers from a class context.
      #
      # @return [HelperProxy] the helpers proxy
      def helpers
        Draper::ViewContext.current
      end
      alias_method :h, :helpers

    end

    # Access the helpers proxy to call built-in and user-defined
    # Rails helpers. Aliased to `h` for convenience.
    #
    # @return [HelperProxy] the helpers proxy
    def helpers
      Draper::ViewContext.current
    end
    alias_method :h, :helpers

    # Alias for `helpers.localize`, since localize is something that's used
    # quite often. Further aliased to `l` for convenience.
    def localize(*args)
      helpers.localize(*args)
    end
    alias_method :l, :localize

  end
end

Draper::ViewContextはActionControllerにincludeされます。これはActionControllerのview_contextを利用するためです。

module Draper
  extend Draper::Configuration

  def self.setup_action_controller(base)
    base.class_eval do
      include Draper::Compatibility::ApiOnly if base == ActionController::API
      include Draper::ViewContext
      extend  Draper::HelperSupport
      extend  Draper::DecoratesAssigned

      before_action :activate_draper
    end
  end

ViewContextは基本的にview_contextメソッドをhookしてRequestStoreにview_contextを保存します。RequestStoreはThread.currentを使ってリクエストごとにグローバル変数を保存、取得できるクラス+ミドルウェアです。

module Draper
  module ViewContext
    # Hooks into a controller or mailer to save the view context in {current}.
    def view_context
      super.tap do |context|
        Draper::ViewContext.current = context
      end
    end
...
    # Returns the current view context, or builds one if none is saved.
    #
    # @return [HelperProxy]
    def self.current
      RequestStore.store.fetch(:current_view_context) { build! }
    end

    # Sets the current view context.
    def self.current=(view_context)
      RequestStore.store[:current_view_context] = Draper::HelperProxy.new(view_context)
    end

Draper::HelperProxyはこんな感じで、method_missingをするとview_contextにdelegateするものの、define_methodでメソッドを動的に定義するため2回目移行はmethod_missingが叩かれず、オーバーヘッドを減らしています。

module Draper
  # Provides access to helper methods - both Rails built-in helpers, and those
  # defined in your application.
  class HelperProxy

    # @overload initialize(view_context)
    def initialize(view_context)
      @view_context = view_context
    end

    # Sends helper methods to the view context.
    def method_missing(method, *args, &block)
      self.class.define_proxy method
      send(method, *args, &block)
    end

    # Checks if the context responds to an instance method, or is able to
    # proxy it to the view context.
    def respond_to_missing?(method, include_private = false)
      super || view_context.respond_to?(method)
    end

    delegate :capture, to: :view_context

    protected

    attr_reader :view_context

    private

    def self.define_proxy(name)
      define_method name do |*args, &block|
        view_context.send(name, *args, &block)
      end
    end
  end
end

デコレータクラス内でdecorates_associationを呼び出すとアソシエーションのメソッドを動的に定義します。

def self.decorates_association(association, options = {})
  options.assert_valid_keys(:with, :scope, :context)
  define_method(association) do
    decorated_associations[association] ||= Draper::DecoratedAssociation.new(self, association, options)
    decorated_associations[association].call
  end
end

Draper::DecoratedAssociation#callはDraper::Factory#decorateでアソシエーションをデコレートします。

module Draper
  # @private
  class DecoratedAssociation

    def initialize(owner, association, options)
      options.assert_valid_keys(:with, :scope, :context)

      @owner = owner
      @association = association

      @scope = options[:scope]

      decorator_class = options[:with]
      context = options.fetch(:context, ->(context){ context })
      @factory = Draper::Factory.new(with: decorator_class, context: context)
    end

    def call
      decorate unless defined?(@decorated)
      @decorated
    end

    private

    attr_reader :factory, :owner, :association, :scope

    def decorate
      associated = owner.object.send(association)
      associated = associated.send(scope) if scope

      @decorated = factory.decorate(associated, context_args: owner.context)
    end

  end
end

Draper::Factory#decorateはさらにWorkerに処理を委譲します。

module Draper
  class Factory
    def initialize(options = {})
      options.assert_valid_keys(:with, :context)
      @decorator_class = options.delete(:with)
      @default_options = options
    end

    def decorate(object, options = {})
      return nil if object.nil?
      Worker.new(decorator_class, object).call(options.reverse_merge(default_options))
    end

    private

    attr_reader :decorator_class, :default_options

Worker#callでやっていることは、アソシエーションのデコレートです。collectionであればdecorate_collectionが呼ばれ、単一レコードであればdecorateメソッドが呼ばれます。

# @private
class Worker
  def initialize(decorator_class, object)
    @decorator_class = decorator_class
    @object = object
  end

  def call(options)
    update_context options
    decorator.call(object, options)
  end

  def decorator
    return decorator_method(decorator_class) if decorator_class
    return object_decorator if decoratable?
    return decorator_method(Draper::CollectionDecorator) if collection?
    raise Draper::UninferrableDecoratorError.new(object.class)
  end

  private

  attr_reader :decorator_class, :object

  def object_decorator
    if collection?
      ->(object, options) { object.decorator_class.decorate_collection(object, options.reverse_merge(with: nil))}
    else
      ->(object, options) { object.decorate(options) }
    end
  end

  def decorator_method(klass)
    if collection? && klass.respond_to?(:decorate_collection)
      klass.method(:decorate_collection)
    else
      klass.method(:decorate)
    end
  end

  def collection?
    object.respond_to?(:first) && !object.is_a?(Struct)
  end

  def decoratable?
    object.respond_to?(:decorate)
  end

  def update_context(options)
    args = options.delete(:context_args)
    options[:context] = options[:context].call(*Array.wrap(args)) if options[:context].respond_to?(:call)
  end
end
このエントリーをはてなブックマークに追加