クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

active_decoratorを機能拡張したGemを作った話

こんにちは!!

「光のエンジニア」ってプロフィールでブランディングしようとしてるけど、検索順位で太陽光発電関連のエンジニアにどうしても勝てないことを悔しがってる板倉(itkrt2y)です。
セルフブランディング難しい!

さて、早速ですが本日はactive_decoratorを機能拡張したGemを作った話をしたいと思います。

active_decorator

active_decoratorとは、Ruby/Railsコミッターとして有名なamatsudaさんが作った、RailsにDecorator層を追加するためのGemです。
Decorator層の説明については本記事のスコープを外れるので、active_decoratorないし同目的のGemであるdraperのREADMEをご覧ください。

拡張Gemを作った動機

active_decoratorは非常に便利なGemですが、関連先のオブジェクトまではdecorateしないという仕様がしばしば問題になります(公式README)。

※ decorateする == オブジェクトにDecoratorをextendする(例:UserモデルのオブジェクトにUserDecoratorをextendする)

「関連先をdecorateしない」というのがどういうことかを公式READMEをお借りして説明すると、以下のようなことを言います。

# 以下のようなコードがあったとする

# app/models/blog_post.rb
class BlogPost < ActiveRecord::Base
  # published_at:datetime
end

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :blog_posts
end

# app/decorators/blog_post_decorator.rb
module BlogPostDecorator
  def published_date
    published_at.strftime("%Y.%m.%d")
  end
end

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # @userをViewに渡す
    @user = User.first
  end
end

この@userの渡し方だと@user自体はdecorate対象になるのですが@user.blog_postsblog_posts(関連先)はdecorateされないので、以下のようにはpublished_date呼べません

# app/views/users/index.html.erb
@user.blog_posts.first.published_date #=> NoMethodError

もし関連先もdecorateしたい場合は、以下のようにrender partialして関連先を渡してやる必要があります。

# app/views/users/index.html.erb
<%= render partial: "blog_post", locals: { blog_posts: @user.blog_posts } %>

# app/views/users/_blog_post.html.erb
<% blog_posts.each do |blog_post| %>
  <%= blog_post.published_date %>
<% end %>

このようにすればblog_postsBlogPostDecoratorがdecorateされます!

弊社の課題

関連先もdecorateしたいならrender partialで関連先オブジェクトを渡すという上記の方法、View内で使われるオブジェクトが明確になりコードの見通しが良くなるため、active_decoratorに関係なくそのようにすべきでしょう。
そのため、できる限りこの方法に則っていきたいところですが、そうもいかない事情がありました。

というのも、新規のコードはいいのですが、既存のメソッドをDecorator層に移した際にそのメソッドを関連越しに呼んでいたためエラーが発生する、直そうにも影響範囲が大きすぎる、といった問題が起こったからです。

そのため、苦肉の策としてactive_decoratorに関連先までdecorateさせる機能拡張を行いました。

余談

機能拡張については別Gemにせずactive_decoratorをforkして書いても良かったのですが、
保守運用がGemとして切り離されてた方が楽だと思ったのと、自分のOSS増やしたかったので切り出すことになりました(公私混同ではないですよ?)

active_decorator_with_decorate_associations

拡張Gemの名前は捻りゼロでactive_decorator_with_decorate_associations
英文法間違ってないか心配です。

どうでもいいですが、rubygems.orgで見ると明らかに想定してない文字数ではみ出し気味なのが個人的にじわじわきています。

使い方

使い方は簡単でGemfileに以下を定義すればOKです!
本家active_decoratorをいれている人は本Gemの依存関係で入るので削除して大丈夫です。

gem 'active_decorator_with_decorate_associations'

実装

実装方針

このGemの実装方針はざっくり言うと、以下の2段階になります。

  1. 関連元が本家active_decoratorのdecorate処理を通った後だったら
  2. 関連先の呼び出し時にdecorateする

active_decoratorでのdecorate方法

まず、そもそも本家active_decoratorはどのようにdecorate処理を行っているのでしょうか?

active_decoratorのdecorate処理を明示的に呼び出すには、以下のように書きます。

ActiveDecorator::Decorator.instance.decorate(obj)

active_decoratordeではControllerからViewに変数を渡す際にこれを実行するようモンキーパッチを当てることで、暗黙のdecorate処理を実現しています。
詳しい実装は以下のrailtie(Railsのinitialize時に呼ばれ、RailsとGemを結びつける処理をするところ)とモンキーパッチの実コードを参照ください。

つまり、関連先をdecorateするのも、何らかのタイミングで関連先を引数にActiveDecorator::Decorator.instance.decorateを呼んでやればいいわけです。

関連先のdecorateタイミング

没案:active_decoratorと同じタイミングに全ての関連先をdecorateする

最初に考えた方法は、active_decoratorがdecorateする時に一緒に関連先まで見るようにする方法でした。
ただ、この方法だと再帰的に関連を潜って全てのオブジェクトをdecorateするため、非常に非効率ということで没になりました。

採用案:関連先呼び出し時にdecorateする

「関連元はレンダリング時に必ずActiveDecorator::Decorator.instance.decorateを通る」というactive_decoratorの仕様を生かし、関連先の呼び出し時に関連元がactive_decoratorを通った後ならば関連先もdecorateする、という変更をいれることにしました。*1

この方法ならdecorate対象は実際に使うものに絞れるので、没案よりもはるかにエコに済みます。

関連先をdecorateする

関連元にマーカーを仕込む

さて、「関連先呼び出し時に関連元を見てdecorateする」と言いましたが、そのままでは関連元を見てもactive_decoratorを通った後かどうかを判断する手段はありません。
そのため、マーカーとして空のmoduleを用意しておき、ActiveDecorator::Decorator.instance.decorate時にそのmoduleをextendするようにしました。
こうすることで、obj.is_a? Markerをすればdecorate処理を通った後か判別できるようになります。

実際のコードでは以下のようになりました。

# lib/active_decorator_with_decorate_associations/marker.rb
module ActiveDecoratorWithDecorateAssociations
  module Marker
  end
end

# lib/active_decorator_with_decorate_associations/active_decorator_extension.rb
require "active_decorator_with_decorate_associations/marker"
module ActiveDecoratorExtension
  def decorate(obj)
    # 中略

    super

    # マーカーを差し込む
    obj.extend ActiveDecoratorWithDecorateAssociations::Marker if obj.is_a?(ActiveRecord::Base)

    obj
  end
end

上記を用いてrailtieにてactive_decoratorを拡張します。

# lib/active_decorator_with_decorate_associations/railtie.rb
require "rails"
require "active_decorator"

module ActiveDecoratorWithDecorateAssociations
  class Railtie < ::Rails::Railtie
    initializer 'active_decorator_with_decorate_associations' do
      ActiveDecorator::Decorator.send :prepend, ActiveDecoratorExtension
    end
  end
end

これで関連元がdecorate処理を通ったかどうかを判別できるようになりました。

関連先呼び出し処理にモンキーパッチを当てる

上記処理で実装方針1の「関連元が本家active_decoratorのdecorate処理を通った後だったら」を判別できるようになりました。
次は実装方針2の「関連先の呼び出し時にdecorateする」です。

これを実現するにはActiveRecord::Associationsを理解しなくてはならないので、まずはこれを見ていきましょう。

ActiveRecord::Associations

Associationsという名前通り、関連を定義するためのクラスです。
ActiveRecord::Associationsモジュール内のクラス階層は以下のようになっています。

  • Association
    • SingularAssociation
      • HasOneAssociation + ForeignAssociation
        • HasOneThroughAssociation + ThroughAssociation
      • BelongsToAssociation
        • BelongsToPolymorphicAssociation
    • CollectionAssociation
      • HasManyAssociation + ForeignAssociation
        • HasManyThroughAssociation + ThroughAssociation

クラス名を見れば大体どんな役目を持ったものかわかるのではないでしょうか?

今回重要なのは ActiveRecord::Associations::Associationに定義されたattr_reader :owner, :targetです(Github)。

owner, target はそれぞれ「owner == 関連元」「target == 関連先」を表します。

ActiveRecord::Associationsにモンキーパッチを当てる

今回やりたいことは以下になります。

  1. 関連先を呼び出すときに
  2. 関連元にマーカーがextendされてるか見て
  3. extendされていれば関連先をdecorateする

そのため、以下の処理をすればいいことになります。

  1. targetを呼び出すときに
  2. ownerMarkerがextendされているか見て
  3. extendされていればtargetをdecorateする

コードに落とし込んだものがこちら。
targetをオーバーライドしてdecorateして返すようにしました。

def target
  if owner.is_a?(ActiveDecoratorWithDecorateAssociations::Marker)
    ActiveDecorator::Decorator.instance.decorate(super)
  else
    super
  end
end

これをActiveRecord::Asscoations::Associationにモンキーパッチすることで、
実装方針2の「関連先の呼び出し時にdecorateする」も出来ました!

CollectionAssociation

上記で基本的な対応は完了ですが、CollectionAssociationではもう少し対応しなければならないことがあります。

CollectionAssociationは上記クラス階層を見て貰えばわかる通り、has_manyを行うクラスの親になります。
このクラスでは#first, #lastなどのメソッドにて、「SQLで取得できるならSQLで取得し、できない場合はtargetの配列から取得する」という処理を行います (実態は#first_nth_or_last, Github)。

この#first_nth_or_lastの結果をまた上記と同じくownerを見てMarkerがいればdecorateする、とすれば、実装完了です!

想定問答

実際に運用してみてどう?

実はまだ実戦投入されてません・・・
これからリリースする予定です。バグがあったら泣きます。

ActiveRecordにモンキーパッチ当てるの怖くない?

めっちゃ怖いです

テスト頑張ります

最後に

今回拡張Gemを書くにあたって、active_decoratorActiveRecord::Associationsのコードを読み込みました。
はじめはGemのコードリーディングに慣れてなかったのですが、一度本気で読んでしまうとなんてことはなく、最近はRailsのコードもカジュアルに読むくらいには成長しました。
「Gemのドキュメントは読んでも、コードはそこまで読まないな〜」という人も多いかと思いますが、active_decoratorはコード量もそんなに多くなく読みやすいし勉強にもなるので、一度読んでみてはいかがでしょうか?

宣伝

Gemをガンガン作ってOSSに発信していきたいみなさん!!
一緒に クラウドソーシングのクラウドワークス で働きませんか?

お待ちしております!

www.wantedly.com

*1:Decoratorが定義されていないModelのオブジェクトはdecorateされないので、「関連元が”decorateされていれば”」では判別できない。そのため、「関連元が”active_decoratorを通った後ならば”」で判別する。

© 2016 CrowdWorks, Inc., All rights reserved.