この文章は先日開催された大阪Ruby会議02での登壇内容Concerns about Concernsをブログエントリにしたものです。書いている内容は登壇内容とだいたい同じですが完全一致ではなく、構成を変更したり喋っていない情報を足したりしてます*1。
大阪Ruby会議02に出席していない方でもスライドを読めば大体の内容を把握できると思いますが、これだと細かいニュアンスは伝えられない(し、この手の話はその細かいニュアンスが大事だったりする)のでちゃんとブログエントリにしておこうと思ったのでした。
意見がある人はこちらのスレに書いてもらえると嬉しいです(\( ⁰⊖⁰)/)
Concernsとはなにか
Concernsという概念は、Rails 4.0から導入されました。具体的にはrails newしたときに生成されるファイルたちの中に
- app/models/concerns
- app/controllers/concerns
という2つの空のディレクトリが追加され、かつConcernsを定義するのに便利なモジュールActiveSupport::Concernが追加されました。基本的にはこれだけです。これだけなのですが、僕たちにConcernsという概念を認識させるには十分でした。
使い方に関してはDHHのブログに書かれています*2。
Put chubby models on a diet with concerns – Signal v. Noise
このブログに端を発して、いろんなブログや書籍などでConcernsの説明が書かれましたが、基本的には「Concernsは関心事を分離するものである」というようなことが書いてあるはずです。みなさんもそのように認識しているのではないでしょうか。
例えばDHHのブログ中では次のようなサンプルコードが書かれています。
module Taggable extend ActiveSupport::Concern included do has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings end def tag_names tags.map(&:name) end end
このようにモデル中のタグ機能を分離するのはわかりやすい例ですね。しかし「関心事を分離する」というのは結構難しい作業です。僕は仕事柄いろんな会社のいろんなRailsプロジェクトのコードを読むのですが「名前をつけるのが難しいが処理としては複数のクラスで使われているので適当な単位でモジュールとして切り出されているなにか」をよく見かけます*3。
DHHは上記のブログエントリ内で、Concernsを使うとモデルの本質的ではない箇所を「別クラスに切り出して単一責任原則」とかせずに切り出せて便利である。と言っているように見えます(下記の意訳なのですが間違ってたらごめんなさい)。
Concerns are also a helpful way of extracting a slice of model that doesn’t seem part of its essence (what is and isn’t in the essence of a model is a fuzzy line and a longer discussion) without going full-bore Single Responsibility Principle and running the risk of ballooning your object inventory.
僕個人としては、このConcernsの使い方はよくないと考えています。恐らくDHHの所属するBasecamp社のエンジニアはみんな腕利きで、Concernsを大量に使っていても破綻せずに開発できているのではないかと思いますが、そのような会社はごくごく少数です。普通の会社でDHHの書いていることを鵜呑みにしてConcernsを多用すると負債になってしまうのではないでしょうか。
Concernsアンチパターン
ではどのようにConcernsを扱うのが良いのかをアンチパターンを提示し、その改善案を出すという流れで書いていこうと思います。
コントローラのビジネスロジックをConcernsにする
次のようなコントローラがあったとします。
class PostsController < ApplicationController def show @post = Post.find(params[:id]) @same_category_posts = same_category_posts(@post) end # 他のアクション... private def same_category_posts(post) category_ids = post.category_ids same_category_post_ids = PostCategory.where(category_id: category_ids) .pluck(:post_id) Post.where(id: same_category_post_ids - [post.id]) .includes(:categories) .order(updated_at: :desc).limit(5) end end
ここでprivateメソッドとして書かれているsame_category_posts
は、引数として受け取ったPostオブジェクトと同カテゴリのPostオブジェクトを最大5つ返すメソッドです。
仮に、このメソッドが他のコントローラでも定義されていたとします。そうするとDRYにしたくなりますよね。ではConcernsとしてモジュールに切り出してみましょう。
module PostFindable extend ActiveSupport::Concern def same_category_posts(post) category_ids = post.category_ids same_category_post_ids = PostCategory.where(category_id: category_ids) .pluck(:post_id) Post.where(id: same_category_post_ids - [post.id]) .includes(:categories) .order(updated_at: :desc).limit(5) end end
はい。そのままモジュールとして切り出しました。するともとのコントローラは次のようになります。
class PostsController < ApplicationController include PostFindable def show @post = Post.find(params[:id]) @same_category_posts = same_category_posts(@post) end # 他のアクション...
メソッドが減って、なんとなくスッキリしたような気がしますね。しかしこれは良くないリファクタリング方法です。
そもそもsame_category_postsメソッドはビジネスロジックであり、コントローラに書くべきものではないからです。そこでsame_category_posts
をPostモデルに書いてみます*4。
class Post < ApplicationRecord def same_category_posts same_category_post_ids = PostCategory.where(category_id: category_ids) .pluck(:post_id) Post.where(id: same_category_post_ids - [id]) .includes(:categories) .order(updated_at: :desc).limit(5) end end
するとコントローラは次のようになります。
class PostsController < ApplicationController def show @post = Post.find(params[:id]) @same_category_posts = @post.same_category_posts end # 他のアクション...
先程のConcernsを使った方法と対して変わらないのでは?という人もいるかも知れませんが、これはサンプルコードが単純だからそう見えるだけです*5。仕事で開発するRailsアプリケーションはもっと複雑なビジネスロジックを多数含んでいます。そのビジネスロジックがコントローラ、モデルの両方に存在していると、特定の処理を追いかけるときにいろんな場所を見に行かなければならず、コードの見通しが悪くなります。コントローラのConcernsはコントローラのコンテキストに存在するので、仮にコントローラそのものからメソッドが見えなくなっても「ビジネスロジックをモデルに寄せる」という原則からは外れることになります。
rubocopのClassLength対策でConcernsにする
開発フローにrubocopを取り入れており、ClassLength設定が有効なプロジェクトがあったとします。仮にClassLengthが300だとすると、次のように何らかの修正の結果Postモデルが300行を超えたときにCIが失敗します。
class Post < ApplicationRecord # たくさんのロジックが300行ある end
CIが失敗したときにPostモデルを修正していた人は、CIを通すため、なんとしてでもPostの行数を減らさなければなりません。そこで次のようにしてみます。
class Post < ApplicationRecord include Previewable include Reservable include Bookmarkable end
ここで新しく定義したPreviewable, Resarvable, BookmarkableはもちろんConcernsとしてモジュールに切り出したものです。これでPostの行数は劇的に削減され、ClassLengthに引っかかることはなくなりました。めでたしめでたし…本当にそうでしょうか?
これらのConcernsはPostでのみ参照されるモジュールです。そしてPostの持っているメソッドは何一つとして減っていません。単にpost.rbから別のファイルに移動しただけで、実行時にはPostモデルのメソッドとしてこれまでと同様に振る舞います。
このようなケースでは、Concernsとしてではなく別クラスとして切り出し、Postモデルの責務を減らす必要があります。このアンチパターンは抽象的*6なので、別のクラスとして切り出す実例は次の「複雑なビジネスロジックをConcernsにする」を参照してください。
現実にはこのアンチパターンを擁護する人もいそうです。そのときに考えられる意見としては「コードの塊に名前がついて切り出されたぶん少し可読性が上がったのでは?」というのがあります。しかし、Active Recordは同じことを同一ファイル内で実現する方法を提供してくれているので「名前をつけて切り出す」のであればこちらの方法で良さそうです。
class Post < ApplicationRecord concerning :Previewable do # ... end concerning :Reservable do # ... end concerning :Bookmarkable do # ... end end
rubocopのClassLength警告は「該当クラスが責務を持ちすぎである」ということの指摘であるはずなので、実質的な責務を減らさないConcernsは意味がないですし、モデルに所属しているコードが追いづらくなるのでかえって可読性を下げる結果になるのではないでしょうか。
複雑なビジネスロジックをConcernsにする
次のモジュールは、ぱっと見ただけだとなにをするものなのか分かりづらいですね。引数として渡されたjsonの形式を見て、モデルを更新または削除するメソッドupdate_by_api!
を提供するものです。
module UpdatableByApi extend ActiveSupport::Concern def update_by_api!(json) parsed_json = JSON.parse(json) if parsed_json['_destroy'].to_i == 1 resouce.destroy! else raise '不正な値です' if parsed_json['reason'].blank? resource.attributes = parsed_json.except('_destroy').merge('updated_by' => 'api') resource.save! end end end
(このくらいならすんなり読めるよ、という人もいるかも知れませんが)全体として何が行われているか、すぐにはわかりづらいコードであると感じます。リファクタリングしてみましょう。モジュールではなく、別クラスに切り出す(委譲)の方式で試してみます。
class UpdatingByApi def self.call(json) new(json).call end def initialize(json) @json = json end def call if destroy? resource.destroy! else raise '不正な値です' if invalid? resource.update!(new_attributes) end end private attr_reader :json def parsed_json @parsed_json ||= JSON.parse(json) end def destroy? parsed_json['_destroy'].to_i == 1 end def invalid? parsed_json['reason'].blank? end def new_attributes parsed_json.except('_destroy').merge('updated_by' => 'api') end end
行数は長くなりましたが、次のような効能が得られました。
- それぞれの処理の詳細がプライベートメソッドに切り出されたことで詳細を把握しやすくなった
#call
はプライベートメソッドの名前として説明されているメソッド名を読むことで、詳細を見ずに概要を把握できるようになった
今回わざわざモジュールからクラスに変更してリファクタリングしましたが、「プライベートメソッドとして切り出す」だけならモジュールでもできそうです。モジュールで同様にプライベートメソッドへの切り出しをやってみましょう。
module UpdatableByApi extend ActiveSupport::Concern def update_by_api!(json) if destroy? destroy! else raise '不正な値です' if invalid? update!(new_attributes) end end private def parsed_json @parsed_json ||= JSON.parse(json) end def destroy? parsed_json['_destroy'].to_i == 1 end def invalid? parsed_json['reason'].blank? end def new_attributes parsed_json.except('_destroy').merge('updated_by' => 'api') end end
同じようにできましたね。ではわざわざクラスにする必要はないのでしょうか?
そんなことはありません。この例だと、invalid?
メソッドがActive Recordの提供するinvalid?
メソッドをオーバーライドしてしまっているため、どこか別のコードが上手く動かなくなっている可能性が高いです。
ではinvalid?
をリネームしたら良いでしょうか?しかし他のメソッド名はどうでしょう。Active Recordの提供するメソッド名とは重複していませんが、別のモジュールやモデルで普通に定義していそうな名前ですよね。また、@parsed_json
インスタンス変数も同様です。モジュールでプライベートメソッドやインスタンス変数を定義する場合、すべて他のモジュールやinclude先のクラスの定義と重複しないように気をつけて実装する必要があります。
では、Concernsはどのように使うとよいのでしょうか。例えば次のようにupdate_from_api!
というインタフェースだけを提供するモジュールとして実装するという方法があります。update_from_api!
メソッドの実態は別クラスとして実装することで、includeで手軽にメソッドを増やせるのと、コンテキストを分けることで実装をリファクタリングしやすくなるメリットがあります。
module UpdatableByApi def update_form_api!(user, json) UpdatingByAPI.call(user: user, json: json) end end
hookをConcernsにする
次のように、Active RecordのコールバックをConcernsにしているコードを時々見かけます。
module PublishedAtSettable extend ActiveSupport::Concern included do before_create :set_published_at end private def set_published_at self.published_at ||= Time.zone.now end end class Post < ApplicationRecord include PublishedAtSettable end
しかし、コールバックを共通化して複数のクラスに適用したいのであれば、次のようにコールバックメソッド(この例だと before_create
)にオブジェクトを渡すやり方でも実現できます。
class SetPublishedAt def before_create(publishable) publishable.published_at ||= Time.zone.now end end class Post < ApplicationRecord before_create SetPublishedAt.new end
こちらのほうが、「Postモデルはbefore_createコールバックを定義している」ということがわかりやすくなり可読性が高いのではないでしょうか。
まとめ
今回の話で特に言いたかったことをまとめると次の3点です。
- Concerns(module)はinclude先とコンテキストを共有するので、クラスから単純にConcernsに切り出しても責務が減るわけではない
- 責務を減らしたいのであればクラスとして切り出したほうが良い
- 安易にConcernsとして切り出すのではなく、まず別の方法も検討してみましょう
Concernsとして処理を切り出すと、目の前からコードがなくなるのでなんとなくリファクタリングできた気分になりますが、これは部屋にあるたくさんの荷物を部屋の隅に寄せて掃除をした気分になるのに似ていると感じます。これからConcernsを使うときには、これで本当に可読性が上がるのか一度考えてみてもらえると嬉しいです!