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メソッドが重要です。
- app/decoratorsディレクトリ以下のファイルをeager_loadする
- Draper.setup_action_controller
- Draper.setup_action_mailer
- Draper.setup_orm
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