- 公開日
銀座Rails#21で「Fat Modelの倒し方」を発表しました
銀座Rails#21で「Fat Modelの倒し方」と題して発表してきました。
発表スライド
目次
- 発表スライド
- 目次
- Fat Rails Stage
- Fat Model対処のための3つのアプローチ
- Rails Modelの限界
- Rails Modelはなぜ辛くなるのか?
- 目指すべきゴール
- Rails Way
- Sub-Rails Way
- Non-Rails Way
- 全体のまとめ
- 参考資料
- 後編(質問・感想編)
Fat Rails Stage
肥大化したRailsアプリケーション(Fat Rails Application)において最も辛いレイヤーはどこでしょうか?
- Fat View
- Fat Controller
- Fat Model
僕はFat Modelだと考えています。
下記は「RailsがどのようにFatになっていくか」段階を示した表です。
Fat Stage | Rails習熟度 | Fat Layer |
---|---|---|
1 | 低 | Fat View |
2 | 中 | Fat Controller |
3 | 高 | Fat Model1 |
まずはFatステージ1。Railsというものを全然知らない超初心者が陥るステージです。ビューに何でもかんでもロジックを書いちゃう。その結果がFat Viewです。
次にFatステージ2。ある程度Railsに慣れてきた開発者が陥るステージです。Modelへのロジック分離がうまくできず、Controllerにロジックが集中する。その結果はFat Controllerです。
最後がFatステージ3。Railsを習熟したエンジニアであればModelにロジックを寄せていくのが定石です。その結果出来上がるのはFat Modelです。
このように どんなにRailsに習熟してようと最終的にぶつかる壁がFat Model です。
Fat Model対処のための3つのアプローチ
Fat Modelを倒すためのアプローチとして、僕は下記の3つに分けて整理すれば良いのではと考えました。
- Rails Way
- Sub-Rails Way
- Non-Rails Way
Rails Modelの限界
なぜRailsアプリケーションのModel層は限界を迎えてしまうのでしょうか?
Railsの原始的な状態は、1つのModelに1つのControllerが結びついています。すなわち、User
モデルがあればUsersController
があり、ControllerのそれぞれのアクションにUser
モデルが紐づくという形です。
しかし下図2はそれが破綻した状態です。どうなっているかというと、複数のControllerからいろんなかたちで1つのモデルが触られる、そういう状態です。
続いてのスライドです3。
ここのキーワードとしては ユースケース。いろんなユースケースを1つのModelで表現しなければならないという状況が辛いと言えます。
Rails Modelはなぜ辛くなるのか?
- 1つのModelが複数の異なるユースケースに密結合して実装されるとき
- → ある条件やcontextに紐付いたValidation/Callback処理
- 1つのフォームで複数のサブリソースが更新されるとき(フォームとModelが1対1で紐付かないとき)
- → 1つのModelを起点とした複数Modelを跨ぐトランザクション処理
上述の限界は、Rails ModelとDBのテーブルが一対一で紐づくRailsの世界観に起因する限界と言えます。
目指すべきゴール
ではどうRailsの限界を乗り越えていけばいいでしょうか?
下記は横軸がコードベースのサイズ、縦軸がペイン(痛みの度合い)を描いたグラフです4。
赤線はバニラRailsです。コードベースのサイズとともにペインが増大しています。
緑線はストラクチャードRails。コードベースが増大してもペインが増大しません。
僕の発表の言うところでは、赤線(バニラRails)がRails Way、緑線(ストラクチャードRails)がSub-Rails・Non-Rails Wayにあたります。
ということで我々の基本的なゴールとしてはこの緑線、すなわち、 コードベースが大きくなってもペインが増大しないRailsコードベース を目指しましょう、ということになります。
Rails Way
小学生の絵みたいで恐縮なんですが、Rails Wayを絵にするとこんなイメージです。
つまり Railsのレールに沿った開発アプローチ です。
Concerns
まずはConcerns。Model/Controllerの共通の関心事(Concern)をmoduleに切り出す手法です(代表例: DHH’s Recording
Class5)。
注意すべきは、ConcernのRails公式ガイドはありません。強いて言うなら下記の記事でDHHがConcernを紹介しています。
Put chubby models on a diet with concerns
Modelの持っている能力(ability = -able
suffix)に着目してConcern moduleに切り出していくのが、Rails Wayっぽさがあると言えます。
# app/models/concerns/concernable.rb
module Concernable
extend ActiveSupport::Concern
...(your concern code)...
end
STI
RailsにおいてテーブルとModelは原則的に1対1で結びつきます。しかし、STIを使えば1つのテーブルで複数Model紐付けることができます。
下図はplayers
という単一テーブルに複数のクラスが結びついている図です6。
Railsのコード例です。 companies
テーブルに紐づく Firm
, Client
モデルの例だと下記の通りです。
# app/models/company.rb
class Company < ApplicationRecord
end
# app/models/firm.rb
class Firm < Company
end
# app/models/client.rb
class Client < Company
end
Polymorphic Association
1つのポリモーフィック関連付け定義で複数のテーブルを従属させることができるのがポリモーフィック関連です。
上図の場合、通常のRails DB設計であれば pictures
テーブルがemployee_id
, product_id
を持っているべきですが、imagable_id
という1つカラムで複数のテーブルを従属させることができています。
これをRailsのコードであらわすと下記の通りです。
# app/models/picture.rb
class Picture < ApplicationRecord
belongs_to :imageable, polymorphic: true
end
# app/models/employee.rb
class Employee < ApplicationRecord
has_many :pictures, as: :imageable
end
# app/models/product.rb
class Product < ApplicationRecord
has_many :pictures, as: :imageable
end
ただし注意点があります。ポリモーフィック関連は『SQLアンチパターン』6章でアンチパターンとして紹介されており、使用する際は気をつける必要があります。
詳しくは『SQLアンチパターン』を読んでいただければと思います。
accepts_nested_attributes_for
ネストされたアトリビュートで関連リソースの作成・更新・削除を行うのがaccepts_nested_attributes_for
です。
class Member < ActiveRecord::Base
has_many :posts
accepts_nested_attributes_for :posts
end
params = { member: {
name: 'joe', posts_attributes: [
{ title: 'Kari, the awesome Ruby documentation browser!' },
{ title: 'The egalitarian assumption of the modern citizen' },
]
}}
member = Member.create(params[:member])
ただしこのaccepts_nested_attributes_for
はDHH自らが「消したい」と発言しており7、積極的に使うのはやや躊躇われるかもしれません。
その他細かめのテクニック
- Serialize Attribute
- json型カラムへのメタデータ保存に便利
- ⚠️『SQLアンチパターン』5章 EAV
- Value Object (
compose_of
)- 複数カラムをValueオブジェクトとして展開するときに便利
- Validation Class/Callback Class
- クラスとして分離可能 → 分離することで複数モデルで再利用可能に
「Rails Way」まとめ
全体としては、Rails WayだけではFat Modelを倒す手段として手数が少なく物足りないと感じます。
アプリケーションサイズがFatになっている時点でそのRailsアプリケーションは中規模以上のサイズが見込まれますから、正直Rails WayだけでFat Modelを倒すのは無理だと思います。
❌ Concerns, Validation ClassなどFat ModelをDRYに記述する手段にはなるが、構造的にダイエットする手段にはなっていません。あくまでそれらは局所的なダイエットに留まっています。
❌ STI, PolymorphicなどはDB設計と密結合したソリューションで、完全なコードレベルの解決にはなっていません。また、アンチパターンとして紹介されているように、それ自体が技術負債になりえる構造的問題を孕んでいます。
Sub-Rails Way
Sub-Rails Wayはレールを補強・拡張しつつレールに乗るスタイルです。
レールを何を使って補強・拡張するのでしょうか?それは下記2つになります。
- gem
- SaaS
View Model
ModelにおけるView関連ロジックを View Model として切り出す手法です。
Development of Further PoEAAでPresentation Modelという概念で紹介されているパターンにあたると考えています。ModelをDecoratorパターンっぽく拡張しているのでDecoratorとも呼ばれることが多いです8。
このView Modelの良いところとしては、Fat Model の対処として機能するだけでなく、Fat View の対処としても機能する点です。
💎 gemの実装としては下記のようなものがあります。
🔧 draperの場合、コードは下記のようになります(ArticleモデルのDecoratorクラス)。
# app/decorators/article_decorator.rb
class ArticleDecorator < Draper::Decorator
delegate_all
def publication_status
if published?
"Published at #{published_at}"
else
"Unpublished"
end
end
def published_at
object.published_at.strftime("%A, %B %e")
end
end
権限管理・認可
管理画面実装において逃げられない実装は認証とあわせて、権限管理・認可ではないでしょうか?
ResourceのCRUDでユーザーのアクセス制御するのが「Railsらしい」権限管理と考えています。
💎 gemの実装としては下記のようなものです。
🔧 punditの場合、コードは下記の通りです(Postモデルの認可クラス)。
# app/policies/post_policy.rb
class PostPolicy
attr_reader :user, :post
def initialize(user, post)
@user = user
@post = post
end
def update?
user.admin? or not post.published?
end
end
Interactor
InteractorはClean Architecture由来する概念です。
下記の図は見たことある方も多くいらっしゃるかもしれません。赤い部分がClean Architectureにおけるユースケース層になります。このユースケース層に Interactor が表現されています。
ユースケース層というアプローチはとても良いと思っています。なぜなら先程「1つのModelが複数の異なるユースケースに密結合して実装されるとき―」と言いましたが、そのユースケースをまさにInteractorとして表現できるからです。
個人的にClean ArchitectureとRailsは相性が良いと思っていて、このようにMVC+InteractorでClean Architectureのそれぞれの層と一致させることができるからです。
💎 gemとしては下記があります。
- interactor-rails
- (not Rails) hanami’s Interactor
hanamiはRailsではありませんが、Clean Architectureに強く影響を受けたRuby製Webフレームワークです。hanamiには Interactor の仕組みが標準で実装されています。
🔧 interactor-railsの場合のコードは下記の通りです(ユーザーを認証するクラス)。
# app/interactors/authenticate_user.rb
class AuthenticateUser
include Interactor
def call
if user = User.authenticate(context.email, context.password)
context.user = user
context.token = user.secret_token
else
context.fail!(message: "authenticate_user.failure")
end
end
end
# Inside your controller,
result = AuthenticateUser.call(session_params)
特定の課題の解決
特定の課題を解決するgemとしては例えば下記のようなものがあります。
- 論理削除
- 要素のソート・並び替え
- 💎 gem: acts_as_list, ranked-model
- State Machine
- 💎 gem: aasm, stateful_enum
- Tagging
- 💎 gem: acts-as-taggable-on
- HashをActiveRecordっぽく操作
- 💎 gem: active_hash
解決したい課題に応じて導入していくのが良いと思います。
「それRailsでできるよ」9
逆にgemを使わずともRails標準で解決できるよって課題も多く存在します。例えば下記のような例です。
- enumerize (Emumerized Attributes)
- Rails 4.1: ActiveRecord enum
- 参考. ActiveRecord::Enum
- switch_point (Database R/W Split)
- Rails 6: Multi-DB
- 参考: Active Record で複数のデータベース利用 - Railsガイド
- activerecord-import (Bulk Import)
- Rails 6:
insert_all
,upsert_all
- Rails 6:
- carrierwave, shrine (File Uploader)
- Rails 5.2: Active Storage
- 参考: Active Storage の概要 - Railsガイド
- friendly_id
- ActiveRecord:
to_param
- 参考: ActiveRecord::Integration
- ActiveRecord:
- counter_culture
- ActiveRecord:
counter_cache
- 参考: Active Record の関連付け
- ActiveRecord:
- ID/Password認証
- ActiveModel:
has_secure_password
- 参考: ActiveModel::SecurePassword::ClassMethods
- ActiveModel:
- config (YAML Config Management)
- Rails Custom configuration:
Rails::Application.config_for
config.x
- 参考: Rails アプリケーションを設定する - Railsガイド
「それRubyでできるよ」9
gemを使わずともRubyでもできるよってケースもあります。
- pry
- Ruby 2.4:
binding.irb
- Ruby 2.7: REPL Syntax Highlighting
- Ruby 2.4:
SaaSに切り出す
処理をSaaSに切り出す、という意味では下記の例があります。
- Auth0
- ユーザー認証ロジックをAuth0に移譲
- 認証にともなうMFA、パスワードリセット、セキュリティ対策などの面倒な実装をAuth0が肩代わり
- Sentry
- エラー通知をSentryに移譲
- サービスにエラーをぽんぽん投げ込めばいい感じにエラーをアグリゲーション・可視化・各種通知してくれる
- NewRelic/Datadog
- APM (Application Performance Monitoring)を NewRelic/Datadog APMでやる
- 自前で Elasticsearch + Kibana 環境を構築してもいいが、構築コスト・運用コストともに高くつく
「Sub-Rails Way」まとめ
gem を使うことでFat Model対処法のバリエーションがぐっと広がります。独自実装でModelを太らせることをせず、使えるgemは積極的に利用していくとよいでしょう。
一方、gemを使わずともRails標準で解決できることも実は多くあるので見極めた上でgem導入しましょう。
また、選択肢はさほど多くないものの、最近はさまざまな便利SaaSが出ているので SaaSを使うのもFat Model対抗手段の1つとして検討してもよいでしょう。
Non-Rails Way
Non-Railsはレールに乗らない別のレール、独自路線のことです。
つまり自らレールを作っていくスタイルです。
Form Model
Form Modelとは、include ActiveModel
したRubyクラスのことです。
巷ではForm Objectと呼ばれることが多いですが、<Formに特化したActiveModel>という意味で、あえてForm Modelと本発表では呼んでいます。
フォームとForm Modelは一対一で紐付きます。こうすることで 特定の<Formのユースケース>に対応したModel が作成可能になります。
💎 gemの実装としては下記のようなものがあります。
Form Modelの使い所としては下記のように整理できると思います。
紐づく テーブル数 | Form Modelのユースケース |
---|---|
0 | 問い合わせフォームなどテーブルを作るまでもないフォームで利用 |
1 | - |
2以上 | accepts_nested_attributes_for の代わりとして、複雑なフォームの組み立て時に利用 |
テーブルとフォームが1対1で紐づく場合はRails Wayで解決させるのが素直な実装
その他の特定のユースケースに特化したForm Model実装としては、下記のようなものが考えられます。
- SearchForm: 条件に基づく検索に特化したフォーム
- DownloadForm: CSVなどのダウンロードに特化したフォーム
PORO
POROとはPlain Old Ruby Objectの略です。元ネタはPoEAAのPOJO (Plain Old Java Object)です。
POROは、ActiveRecordの機能に依存しない純粋なRuby実装です。なのでinclude ActiveModel
しているRubyクラスは個人的にはPOROとは呼んでいません。
純粋なRuby実装なのである意味、 Ruby Way とも言うことができます。
POROの主な用途としてはModelの補助輪的な役割だと考えています。
例えば下記の例ではクラスメソッドcreate!
呼び出し時に引数を受け取ってcreate!
インスタンスメソッド内でトランザクションを張って複数モデルの更新を行っています。
class PostWithNotifications
def self.create!(creator:, body:)
new(creator: creator, body: body).create!
end
def initialize(creator:, body:)
@creator = creator
@body = body
end
def create!
ActiveRecord::Base.transaction do
create_post!
create_notifications!
end
end
end
このRubyクラスの場合、<Post
作成とともにNotification
も作成する>という複数モデル更新のユースケースをPOROに閉じ込めたということができるでしょう。
Service Class
続いてはサービスクラスです。
サービスクラスに関してはもしかしたら賛否両論あるかもしれません。サービスという概念がデカすぎる故に、人によって使い方・解釈が異なり、サービスクラスにまつわる巷のすれ違いを起こしている印象があります。
サービスクラスに関してはまずはサービスの定義問題があると思っています。つまり「あなたの言うServiceってなんですか?」という問題です。
一口にサービスといっても様々な文脈のサービスがあります。
Architecture | Service Name |
---|---|
PoEAA | Service Layer |
DDD | Service Class |
Onion Architecture | Application Service, Domain Service |
Rails “Service” ? | 上記のどれでもない”Service” 上記を組み合わせた”Service” |
「どういう文脈のサービスか?」を明確にした上で議論しないとサービスクラスの定義・概念がボンヤリしてしまう印象です。なのでサービスクラスを導入する際は、サービスクラスの定義・使い方を明確にした上でチームに導入していくのが良いと思います。
個人的な見解にはなりますが、<特定のユースケースの解決>という意味においてはInteractorのほうが少なくともRailsにおいては筋が良いと考えています。
また、個人的に下手にサービスという巨大で強い概念を持ち込むより、POROという概念で雑にまとめたほうが好みだったりします。
1 Table Multiple Models
一つのテーブルに複数Modelを紐付けるアプローチです。
Rails WayだとSTIでのみこれは実現可能ですが、STIを使わずにがんばってアプリケーションコードで複数モデルを表現しちゃいましょうというやり方です。
コードにすると、例えば下記のようなコードになります。
class User < ApplicationRecord
end
class User::AsSignUp < User
validates :password, ...
after_create :send_welcome_email
private
def send_welcome_email
...
end
end
この例では<User
のサインアップ>というユースケースにのみ特化したActiveRecordのModelを作成しています。
ただこの実装に関しては、1 Table 1 ModelというRailsのパラダイム(規約)を壊すことになってしまうので、いささか危険思想という印象があります。
ただ僕自身実際にプロダクションに導入して運用した経験はないので、もし実運用における成功例お持ちの方がいれば教えていただけると幸いです。
「Non-Rails Way」まとめ
4つの Non-Railsを紹介しました。
- Form Model
- PORO
- Service Class
- 1 Table Multiple Models
これらをうまく導入できればFat Modelを倒す強力な武器となるのは間違いないでしょう。
どれをどう導入するかに関しては正解はないと思うのでチームにあった手法を選択すると良いと考えています。
といっても「どれを導入すればいいかわからん…」ってなると思うので個人的なおすすめアプローチを紹介すると、モデルを太らせてしまうような複雑なフォームに関してはForm Modelで表現するのがわかりやすいと思います。
何らかのユースケースに特化したクラスを作りたいのであれば、Sub-Railsのセクションで紹介したInteractorを使うのが個人的にはオススメです。
上記で足りないユースケースが出てきた場合、POROと総称してModelの補助輪となるようなRubyクラスを用意してあげると良いかと思います。
全体のまとめ
Fat Modelを倒すための3つのアプローチを紹介しました。
- Rails Way: Railsの規約に沿った開発アプローチ
- Sub-Rails Way: Railsの規約をgemで補強・拡張するアプローチ
- Non-Rails Way: Railsの規約から外れる独自実装アプローチ
まずは、 Rails Way + Sub-Rails Way でFat Modelをダイエットできないか考えましょう。小規模なRailsアプリケーションであれば Rails Way + Sub-Rails Way で十分戦えると思います。
Rails Way + Sub-Rails Way だけで立ち行かなくなった場合に、必要に応じて適切な Non-Rails Way を取り入れていきましょう。
Non-Rails Way はチーム毎に最適解があると思っています。チームで合意できる独自路線を選択・導入すればよいのではないでしょうか。
参考資料
- 書籍
- エンタープライズアプリケーションアーキテクチャパターン
- Clean Architecture 達人に学ぶソフトウェアの構造と設計
- エリック・エヴァンスのドメイン駆動設計
- Growing Rails Applications in Practice by Henning Koch and Thomas Eisenbarth
- アーキテクチャにまつわる資料
- Rails公式ドキュメント
- Form Model (Form Object)について
- Service Class (Service Object) について
- Concerns about Concerns - Speaker Deck
- Decorator と Presenter を使い分けて、 Rails を ViewModel ですっきりさせよう - KitchHike Tech Blog
- ActiveRecordのモデルが1つだとつらい - Qiita
後編(質問・感想編)
別記事にまとめました。
銀座Rails#21で「Fat Modelの倒し方」を発表しました 〜質問・感想編〜
PoEAA: Single Table Inheritance ↩
https://github.com/rails/rails/pull/26976#discussion_r87855694 ↩
参考: 『Rubyによるデザインパターン』 第11章 オブジェクトを改良する:Decorator ↩