13 複雑なデータ操作を実装する
雑感
この章では、Concern
を利用して複雑なデータ操作に関するロジックをモデル・コントローラから分離・共通化する方法についての内容でした。
モジュールの実装についてはあやふやな部分が多く、個人開発では明確な基準がなくConcern
に色々と長大なロジックをぶち込んでいたので、いくつかのデザインパターンを学習できて実装基準が明確になったと感じます。
以下、忘備録となります。
Concern
いつ導入すべきか
例えば下記コードにおけるタグ関連テーブルへの関連付けやそれを利用したtagged_with
メソッドを他のモデルでも利用したくなった場合、これらのロジックの分離は値オブジェクトやその他のオブジェクトでは実現できない(また、それらの責務でもない)。
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 を利用することで以下のように関連づけも含めてロジックを分離でき、他のモデルでも再利用できる。
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
class Photo < ApplicationRecord
include Taggable # moduleへロジックを分離できる
end
導入時の注意点
- 特定のモデルのみに密接に結びつく再利用不可なモジュールであってはならない
- データ操作に関するロジックと関連づけの宣言を行う内容とする(その他の用途はまず他のデザインパターンの導入を検討する)
ActiveSupport::Concern とは
ActiveSupport::Concern
は以下の機能を提供し、Concern の実装を容易にする。
- ブロック渡しができる
included
メソッド - クラスメソッドの定義を容易にできる
class_methods
メソッド - モジュール間の依存関係の解決
ブロック渡しができる included メソッド
あるモジュールが include されたときになんらかの処理を行いたい場合、include されるモジュールの included メソッドを上書きする。
この処理内容をActiveSupport::Concern
を導入することでブロックとして渡すことができる。
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
を導入することでより簡潔に書き換えることができる。
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 すればよく、依存関係もわかりやすくなる。
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
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 する必要があるので注意。
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
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
class Baz
include Bar
end
ルーティングにおける Concern
ルーティングにおけるconcern
メソッドは共通のルーティング設定(リソース、アクション)を抽出して再利用できる。
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
# 注文の基本ルーティング(編集、更新、削除を除く)
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
のコールバックによる暗号化ロジックを他の属性や他のモデルでも使用したい場合、コールバックオブジェクトを導入することでロジックを分離して共通化し、モデルの肥大化を防ぐことができる。
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
コールバックオブジェクトの作成
# 他の属性でも使えるように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
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を利用することでまとめて抽出できる。
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
classs User < ApplicationRecord
include AttributeEncryptable
validates :phone_number, format: { with: /\A0\d{9,10}\z/ }
encrypt_attributes :phone_number
end
Concernsを利用すればコールバックオブジェクトを作成しなくても同じロジックをモジュールへ分離することは可能である。
しかし、全てをモジュールとして定義するとテスト時にダミークラスの用意や実際にinclude先のモデルレコードを保存する処理が必要なことから、用意が煩雑になる。
クラスとして定義することによって、コールバックオブジェクトクラスの単体テストとして各メソッドをテストできるようになる利点がある。
総括
以下、本書を通しての感想になります。
総括としては、少し古い技術書ではありますが、現在進行形で取り扱っているライブラリやデザインパターンなどの情報が多く含まれ、それらの基礎を学ぶ上ではとても良い技術書でした。
もちろん本書の内容のみですぐにそれらを使いこなせるわけではありませんが、業務で取り扱っているコードの設計パターンの意図が理解できるようになったり、ミーティングで飛び交う先輩エンジニア同士の会話の指す内容がなんとなくわかるようになったのが大きいと感じています。
また、以下の悪い点としてモノリシックなRailsアプリケーションを取り扱っていることを挙げましたが、業務用の社内システムなどではRails単体で作成している場合が多いと思うのでそれらの情報も役立つ場面はあると思います。
一方、これからRails APIを含めたSPAアプリケーションを作成しようとしている初学者の方などはある程度情報を取捨選択して進めないと少しボリューミーすぎる内容と感じました。
良かった点
- めちゃくちゃ
rails new
できるので手を動かして学びたい人にはおすすめ - railsチュートリアルではちょろっとだけ触れる
scaffold
も何度も使うのでより理解が深まる -
Elasticsearch
やConcern
など、実務に近い技術を具体的かつ幅広く多く学べる
悪かった点
- フルスタックRailsアプリケーションをメインで扱うため、Viewの取り扱いなど、最近主流のRails APIの開発においては不要な情報も多く含まれる
- 記事執筆時の
2025年2月
時点では取り扱うrubyのバージョンも古く、Dockerfile
の記述方法などにも古い情報が含まれる - コードをコピペできないので実際に本書のコードを動かすためには地道にコーディングする必要がある(自分としては実際に書くことで理解深まる点もあると感じている&最近はAIツールで時間短縮できるのでこの辺りは人によって一長一短かもしれません。)