パーフェクトRuby on Rails 13章 メモ・雑感&全体総括(Concern, コールバックオブジェクト)

# 13 複雑なデータ操作を実装する

## 雑感

この章では、`Concern`を利用して複雑なデータ操作に関するロジックをモデル・コントローラから分離・共通化する方法についての内容でした。
モジュールの実装についてはあやふやな部分が多く、個人開発では明確な基準がなく`Concern`に色々と長大なロジックをぶち込んでいたので、いくつかのデザインパターンを学習できて実装基準が明確になったと感じます。
以下、忘備録となります。

## Concern

### いつ導入すべきか

例えば下記コードにおけるタグ関連テーブルへの関連付けやそれを利用した`tagged_with`メソッドを他のモデルでも利用したくなった場合、これらのロジックの分離は値オブジェクトやその他のオブジェクトでは実現できない(また、それらの責務でもない)。

```ruby:app/models/photo.rb
class Photo < ApplicationRecord
  has_many :taggings, class_name: "PhotoTagging"
  has_many :tags, through: :taggings

  def self.tagged_with(tag, *others, any: false)
    tags = [tag, *others]

    joins(:tags)
      # mergeで異なるリレーション、スコープを組み合わせる
      # reflect_on_associationで関連の情報を取得
      # Tagsクラスのwith_nameスコープを呼び出している
      .merge(reflect_on_association(:tags).klass.with_name(tags))
      .merge(
        if tags.size == 1
          all
        elsif any
          # いずれかのタグを持つレコードを取得
          distinct
        else
          # 全てのタグを持つレコード取得
          group(primary_key).having("COUNT(*) = ?", tags.size)
        end
      )
  end
end
```

このような場合に`Concern`を利用することでデータ操作に関するロジックの実装と関連付けの宣言をモデルから分離することができる。

### Concern の導入

上記の例を Concern を利用することで以下のように関連づけも含めてロジックを分離でき、他のモデルでも再利用できる。

```ruby:app/models/concerns/taggable.rb
module Taggable
  # includedやclass_mehodsが使えるようになる
  extend ActiveSupport::Concern

  included do
    has_many :taggings, class_name: "PhotoTagging"
    has_many :tags, through: taggings
  end

  class_methods do
    def self.tagged_with(tag, *others, any: false)
      tags = [tag, *others]

      joins(:tags)
        .merge(reflect_on_association(:tags).klass.with_name(tags))
        .merge(
          if tags.size == 1
            all
          elsif any
            distinct
          else
            group(primary_key).having("COUNT(*) = ?", tags.size)
          end
        )
    end
  end
end
```

```ruby:app/models/photo.rb
class Photo < ApplicationRecord
  include Taggable # moduleへロジックを分離できる
end
```

### 導入時の注意点

- 特定のモデルのみに密接に結びつく再利用不可なモジュールであってはならない
- データ操作に関するロジックと関連づけの宣言を行う内容とする(その他の用途はまず他のデザインパターンの導入を検討する)

### ActiveSupport::Concern とは

`ActiveSupport::Concern`は以下の機能を提供し、Concern の実装を容易にする。

- ブロック渡しができる`included`メソッド
- クラスメソッドの定義を容易にできる`class_methods`メソッド
- モジュール間の依存関係の解決

#### ブロック渡しができる included メソッド

あるモジュールが include されたときになんらかの処理を行いたい場合、include されるモジュールの included メソッドを上書きする。
この処理内容を`ActiveSupport::Concern`を導入することでブロックとして渡すことができる。

```ruby
module Cancelable
  def self.included(base)
    base.class_eval do
      has_one :cancellation, class_name: "#{name}Cancellation"
    end
  end
end

# ActiveSupport::Concernを導入すると下記のように実装できる
module Cancelable
  extend ActiveSupport::Concern

  # ブロックの処理はincludedしたクラスのコンテキストで評価されるのでclass_evalを呼び出す必要がない
  included do
    has_one :cancellation, class_name: "#{name}Cancellation"
  end
end
```

#### クラスメソッドの定義を容易にできる class_methods メソッド

あるモジュールに定義されたメソッドをクラスやモジュールのクラスメソッドとして追加するにはこれらの定義内で`extend`を呼び出さなければならない。(インスタンスメソッドの場合は`include`)
この処理内容を`ActiveSupport::Concern`を導入することでより簡潔に書き換えることができる。

```ruby
module Foo
  def self.included(base)
    base.extend(ClassMethods) # クラスメソッドを追加する処理
  end

  # クラスメソッドとして追加したいメソッドをモジュールとして実装してextendする
  module ClassMethods
    def class_method_injected_by_foo
      puts "Class method defined in Foo"
    end
  end

  # インスタンスメソッドはincludeされれば利用可能
  def instance_method_injected_by_foo
    puts "Instance method defined in Foo"
  end
end


# ActiveSupport::Concernを導入すると下記のように実装できる
module Foo
  extend ActiveSupport::Concern

  class_methods do
    def class_method_injected_by_foo
      puts "Class method defined in Foo"
    end
  end

  def instance_method_injected_by_foo
    puts "Instance method defined in Foo"
  end
end
```

#### モジュール間の依存関係の解決

例えば、特定のモジュールに依存する処理を持つ別のモジュールがあった場合、実際に利用したいモデルにおいては二つのモジュールを両方 include する必要がある。その場合、include 先で依存関係がわかりづらくなるので、下記のように依存されているモジュールを依存しているモジュールへ先に include することで利用したいモデルでは一つのみを include すればよく、依存関係もわかりやすくなる。

```ruby
module Bar
  include Foo

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    # Fooモジュールで定義されたクラスメソッドに依存しているクラスメソッド
    def class_method_injected_by_bar
      class_method_injected_by_foo
    end
  end

  # Fooモジュールで定義されたインスタンスメソッドに依存しているインスタンスメソッド
  def instance_method_injected_by_bar
    instance_method_injected_by_foo
  end
end
```

```ruby
class Baz
  # 2つのモジュールをincludeせず片方をincludeしたモジュールをincludeする
  include Bar
end
```

しかし、この場合、`Baz`クラスで`class_method_injected_by_bar`を使用するとエラーとなる。これは`Bar`モジュールへ`Foo`モジュールを included した時に、`class_method_injected_by_bar`が依存している`class_method_injected_foo`メソッドを`Bar`モジュールのクラスメソッドとして追加するため(そして追加されたが、extend されていないので include 先では使用できない)。

このような依存関係を`ActiveSupport::Concern`を導入することで適切に解決できる。
なお、依存関係にある全てのモジュールで`ActiveSupport::Concern`を extend する必要があるので注意。

```ruby
module Foo
  extend ActiveSupport::Concern

  class_methods do
    def class_method_injected_by_foo
      puts "Class method defined in Foo"
    end
  end

  def instance_method_injected_by_foo
    puts "Instance method defined in Foo"
  end
end
```

```ruby
module Bar
  extend ActiveSupport::Concern

  include Foo

  class_methods do
    def class_method_injected_by_bar
      class_method_injected_by_foo
    end
  end

  def instance_method_injected_by_bar
    instance_method_injected_by_foo
  end
end
```

```ruby
class Baz
  include Bar
end
```

### ルーティングにおける Concern

ルーティングにおける`concern`メソッドは共通のルーティング設定(リソース、アクション)を抽出して再利用できる。

```ruby
Rails.apprication.routes.draw do
  # 共通化したいcancellationリソースのルーティングを定義
  concern :cancelable do |options|
    resource :cancellation, options.merge(only: :create)
  end

  # collectionに対して追加するconfirmアクションのルーティングを定義
  concern :confirmable do
    post "confirm", on: :collection
  end

  resources :orders, concerns: :confirmable, except: %i[edit update destroy] do
     concerns :cancelable, module: "orders"
  end
end
```

```ruby
# 注文の基本ルーティング（編集、更新、削除を除く）
GET    /orders              # orders#index  - 注文一覧を表示
POST   /orders              # orders#create - 新しい注文を作成
GET    /orders/new          # orders#new    - 新しい注文フォームを表示
GET    /orders/:id          # orders#show   - 特定の注文詳細を表示

# confirmable concernから追加されるルーティング
POST   /orders/confirm      # orders#confirm - 注文確認プロセス

# cancelable concernから追加されるルーティング
POST   /orders/:order_id/cancellation  # orders/cancellations#create - 注文をキャンセル
```

## コールバックオブジェクト

### いつ導入すべきか

主にデータの更新処理を伴うロジックを分離、共通化したい場合にコールバックオブジェクトを利用する。
例えば、以下のモデルに含まれる`phone_number`のコールバックによる暗号化ロジックを他の属性や他のモデルでも使用したい場合、コールバックオブジェクトを導入することでロジックを分離して共通化し、モデルの肥大化を防ぐことができる。

```ruby:app/models/user.rb
class User < ApplicationRecord
  validates :phone_number, format: { with: /\A0\d{9,10}\z/ }

  after_find :decrypt_phone_number
  before_save :encrypt_phone_number
  after_save :decrypt_phone_number

  def self.attribute_encryptor
    @attribute_encryptor ||= Rails.application.key_generator.generate_key("attribute_encryptor", ActiveSupport::MessageEncryptor.key_len).
      then { |key| ActiveSupport::MessageEncryptor.new(key, serializer: JSON) }
  end

  private

  def encrypt_phone_number
    self[:phone_number] = self.class.attribute_encryptor.encrypt_and_sign(self[:phone_number])
  end

  def decrypt_phone_number
    self[:phone_number] = self.class.attribute_encryptor.decrypt_and_verify(self[:phone_number])
  end
end
```

### コールバックオブジェクトの作成

```ruby:属性を暗号化するロジックを実装したコールバックオブジェクト
# 他の属性でも使えるようにattributeを設定できるようにする
class AttributeEncryptionCallbacks
  def initialize(attribute)
    @attribute = attribute
  end

  def self.encryptor
    @encryptor ||= Rails.application.key_generator.generate_key("attribute encryptor", ActiveSupport::MessageEncryptor.key_len).
    then { |key| ActiveSupport::MessageEncryptor.new(key, serializer: JSON) }
  end

  def after_save(record)
    record[@attribute] = self.class.encryptor.decrypt_and_verify(record[@attribute])
  end
  alias_method :after_find, :after_save

  def before_save(record)
    record[@attribute] = self.class.encryptor.encrypt_and_sign(record[@attribute])
  end
end
```

```ruby:暗号化ロジックを分離したapp/models/user.rb
classs User < ApplicationRecord
  validates :phone_number, format: { with: /\A0\d{9,10}\z/ }

  # この実装だとモデル内でオブジェクトの作成が必要
  AttributeEncryptionCallbacks.new(:phone_number).tap do |callbacks|
    after_find callbacks
    before_save callbacks
    after_save callbacks
  end
end
```

### Concernを利用してロジックを完全に分離する

上記までの状態だと、モデル内にコールバックオブジェクトを設定するための記述が残っているので完全にロジックを分離できていない。
この記述とコールバックオブジェクトをConcernを利用することでまとめて抽出できる。

```ruby:app/models/concerns/attribute_encryptable
module AttributeEncryptable
  extend ActiveSupport::Concern

  class Callbacks
    # AttributeEncryptionCallbacksクラスと同じ内容
  end

  class_methods do
    def encrypt_attributes(*attributes)
      attributes.map(&Callbacks.method(:new)).each do |callbacks|
        after_find callbacks
        before_save callbacks
        after_save callbacks
      end
    end
  end
end
```

```ruby:暗号化ロジックが完全に分離したapp/models/user.rb
classs User < ApplicationRecord
  include AttributeEncryptable

  validates :phone_number, format: { with: /\A0\d{9,10}\z/ }

  encrypt_attributes :phone_number
end
```

::: note info

Concernsを利用すればコールバックオブジェクトを作成しなくても同じロジックをモジュールへ分離することは可能である。
しかし、全てをモジュールとして定義するとテスト時にダミークラスの用意や実際にinclude先のモデルレコードを保存する処理が必要なことから、用意が煩雑になる。
クラスとして定義することによって、コールバックオブジェクトクラスの単体テストとして各メソッドをテストできるようになる利点がある。

:::

## 総括

以下、本書を通しての感想になります。
総括としては、少し古い技術書ではありますが、現在進行形で取り扱っているライブラリやデザインパターンなどの情報が多く含まれ、それらの基礎を学ぶ上ではとても良い技術書でした。
もちろん本書の内容のみですぐにそれらを使いこなせるわけではありませんが、業務で取り扱っているコードの設計パターンの意図が理解できるようになったり、ミーティングで飛び交う先輩エンジニア同士の会話の指す内容がなんとなくわかるようになったのが大きいと感じています。
また、以下の悪い点としてモノリシックなRailsアプリケーションを取り扱っていることを挙げましたが、業務用の社内システムなどではRails単体で作成している場合が多いと思うのでそれらの情報も役立つ場面はあると思います。
一方、これからRails APIを含めたSPAアプリケーションを作成しようとしている初学者の方などはある程度情報を取捨選択して進めないと少しボリューミーすぎる内容と感じました。

### 良かった点

- めちゃくちゃ`rails new`できるので手を動かして学びたい人にはおすすめ
- railsチュートリアルではちょろっとだけ触れる`scaffold`も何度も使うのでより理解が深まる
- `Elasticsearch`や`Concern`など、実務に近い技術を具体的かつ幅広く多く学べる

### 悪かった点

- フルスタックRailsアプリケーションをメインで扱うため、Viewの取り扱いなど、最近主流のRails APIの開発においては不要な情報も多く含まれる
- 記事執筆時の`2025年2月`時点では取り扱うrubyのバージョンも古く、`Dockerfile`の記述方法などにも古い情報が含まれる
- コードをコピペできないので実際に本書のコードを動かすためには地道にコーディングする必要がある(自分としては実際に書くことで理解深まる点もあると感じている&最近はAIツールで時間短縮できるのでこの辺りは人によって一長一短かもしれません。)