今回は、Rails Way な Fat モデルのダイエット方法である Concern をなるべく使うようにする場合、個人的にどうすればいいかをまとめてみます。
Concern とは
Concern を使えば、他クラス (モデル) と共有できる機能 (関心) を外に切り出すことができます。
使い方は下記から見れます。 signalvnoise.com
DHH の Tweet では、どのような Concern を作るかイメージが湧きます。(見た目クール
I heard you liked concerns, so I put a few of them in Recording 😄 (This is the meta-data/meta-capabilities class for all concrete content classes in Basecamp). pic.twitter.com/XfXAdyXzJk
— DHH (@dhh) 2018年2月15日
Concern を意識してモデルを考える
モデルを設計するときに、xxx という機能に関連する実装を xxxable の Concern に切り出すことで関心事を外に切り出すことができ、使いまわすことが出来ます。
例えば、あるモデルには 完了という状態を保持する機能 (完了機能) が存在する場合、その機能を Concern に切り出せないかと考えます。
まずはこの機能に関連して必要となる実装を抜き出します。
- 完了状態のレコードをひく completed という scope
- 完了状態を確認する completed? メソッド
- 完了状態に変更する complete! メソッド
この実装を Concern 化するなら下記のようになります。
# app/models/concern/completeable.rb module Completeable extend ActiveSupport::Concern included do # 完了状態のレコードをひく scope scope :completed, -> { where.not(completed_at: nil) } end # 完了状態を確認するメソッド def completed? completed_at? end # 完了状態に変更するメソッド def complete! update!(completed_at: Time.current) end end
完了機能をもつモデルらに include します。
# 完了機能をもつ Task モデル class Task < ApplicationRecord include Completeable end # 完了機能をもつ Project モデル class Project < ApplicationRecord include Completeable end
テストはどう書くか
対象モデル用に shared_examples を作る方法と Concern 用の Spec を作る方法がありそうです。
対象モデル用に shared_examples を作る方法
クラスメソッドの確認には described_class を使い、レコード生成には FactoryBot に登録されている factory 名を渡してあげればうまく共通化できました。
# spec/models/project_spec.rb RSpec.describe Project, type: :model do include_examples 'Completeable', :project end # spec/models/task_spec.rb RSpec.describe Task, type: :model do include_examples 'Completeable', :task end # spec/support/examples/model/concerns/completeable.rb RSpec.shared_examples 'Completeable' do |factory_name| describe '.completed' do subject { described_class.completed } let(:target_record1) { create factory_name, completed_at: 10.days.ago } let(:target_record2) { create factory_name, completed_at: 10.days.since } let!(:target_records) { [target_record1, target_record2] } let!(:other_record) { create factory_name, completed_at: nil } it 'returns the target records' do is_expected.to match_array target_records is_expected.to_not include other_record end end describe '#completed?' do subject { record.completed? } context '完了状態の場合' do let(:record) { create factory_name, completed_at: Time.current } it { is_expected.to be true } end context '完了状態ではない場合' do let(:record) { create factory_name, completed_at: nil } it { is_expected.to be false } end end describe '#complete!' do subject { record.complete! } context '完了状態の場合' do let(:record) { create factory_name, completed_at: Time.current } it { expect { subject }.to_not change(record, :completed?) } end context '完了状態ではない場合' do let(:record) { create factory_name, completed_at: nil } it { expect { subject }.to change(record, :completed?).from(false).to(true) } end end end
Concern 用の Spec を作る
下記リンクを参考にさせていただきました。
完了機能をテストにするには下記のようになりました。
# spec/models/concerns/completeable_spec.rb RSpec.describe Completeable, type: :concern do before(:all) do m = ActiveRecord::Migration.new m.verbose = false m.create_table :completeable_models do |t| t.datetime :completed_at end end after(:all) do m = ActiveRecord::Migration.new m.verbose = false m.drop_table :completeable_models end class CompleteableModel < ApplicationRecord include Completeable end describe '.completed' do subject { CompleteableModel.completed } let(:target_record1) { CompleteableModel.create completed_at: 10.days.ago } let(:target_record2) { CompleteableModel.create completed_at: 10.days.since } let!(:target_records) { [target_record1, target_record2] } let!(:other_record) { CompleteableModel.create completed_at: nil } it 'returns the target records' do is_expected.to match_array target_records is_expected.to_not include other_record end end describe '#completed?' do subject { record.completed? } context '完了状態の場合' do let(:record) { CompleteableModel.create(completed_at: Time.current) } it { is_expected.to be true } end context '完了状態ではない場合' do let(:record) { CompleteableModel.create(completed_at: nil) } it { is_expected.to be false } end end describe '#complete!' do subject { record.complete! } context '完了状態の場合' do let(:record) { CompleteableModel.create(completed_at: Time.current) } it do expect { subject }.to_not change(record, :completed?) end end context '完了状態ではない場合' do let(:record) { CompleteableModel.create(completed_at: nil) } it do expect { subject }.to change(record, :completed?).from(false).to(true) end end end end
Concern を使った感想
メリットは xxx 機能はこのテーブル設計で〜、このメソッドで〜という流れで Concern をベースにモデルの実装方法が決まるので、統一感が出るのではないかと思っています。
デメリットとしては実際の開発だと、その機能の関心 (Concern) の分離先 module をどこにすればいいか、どういう命名で作るかなど迷いポイントがありそうだなという点です。
ただ、個人的に Concern によるダイエットは、Rails 謹製のダイエット方法でもあるので、変に Rails Way を外れて独自のレイヤー追加でダイエットさせるよりは良い選択だと思っていて、正しく追加された Concern は機能毎に切り出されたファイルになると思うので、可読性もいいのではと考えています。
以上です。