たばりばりスタイル

たばりばりスタイル

バリバリバリ⚡︎

Railsでchubby model対策になるべくConcernを利用するなら

今回は、Rails Way な Fat モデルのダイエット方法である Concern をなるべく使うようにする場合、個人的にどうすればいいかをまとめてみます。

Concern とは

Concern を使えば、他クラス (モデル) と共有できる機能 (関心) を外に切り出すことができます。

使い方は下記から見れます。 signalvnoise.com

github.com

DHH の Tweet では、どのような Concern を作るかイメージが湧きます。(見た目クール

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 を作る

下記リンクを参考にさせていただきました。

qiita.com

完了機能をテストにするには下記のようになりました。

# 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 は機能毎に切り出されたファイルになると思うので、可読性もいいのではと考えています。

以上です。