Rails に Trailblazer の概念を導入したお話
はじめに
おてつたび の川尻(@tomoki_kawajiri)です。
先日、株式会社ラクスさん主催の エンジニアの勉強法ハックLT- vol.10 に登壇させていただきました。 今回はその中で触れた Trailblazer の概念を取り入れた件についてご紹介します。
登壇資料は以下になります。
内容はフレームワークから学べるメリットやソースコードを見る観点が中心になります。
フレームワークを学習する中で出会った一つが Trailblazer でした。
Trailblazer
Trailblazer は Ruby のフレームワークです。
ビジネスロジックを独自のレイヤーでカプセル化することで、責務の分離を果たし、ワークフローをシンプルにできるところが特徴です。
導入の背景
Ruby といえば Ruby on Rails ですが、柔軟性が高いがゆえに長く運用していると MVC のどれかにロジックが寄り過ぎてしまう、いわゆるファット化に陥ることがあります。
おてつたびも同様で、ビジネスロジックがコントローラーとモデルに偏り肥大化する、モデルのバリデーションに複数のユースケースが混在して不要なバリデーションが実行されるなど、保守性が著しく下がっていました。
こういった状況を改善したく、他のフレームワークの設計を調査していた時に出会ったのが、Trailblazer でした。
Trailblazer は責務ごとにレイヤーが細かく分かれており、それぞれ独立させることで、当時抱えていた肥大化や依存関係を解消できるのではないかと思い、導入に踏み切ることにしました。
どのように導入したか
とはいえ、Rails の構成からいきなり移行するのは現実的ではないので、app
配下に Trailblazer のレイヤーとベースクラスを作成して導入しました。
ベースクラスは Trailblazer が提供しているクラスを参考に実装しました。
それでは、具体的にどうしたかをサンプルコードとともに導入したレイヤーを説明します。
Operation
ビジネスロジックを担う Trailblazer では根幹なレイヤーになります。
Trailblazer では前提条件としてコントローラーとモデルにはビジネスロジックを一切含みません。
コントローラーやモデルから切り離されたビジネスロジックは Operation に移譲されます。
Operation にビジネスロジックが内包され、Operation を起点に各レイヤーへデータが受け渡しされます。
これにより、コントローラーはリクエストを受け取り、Operation へ渡した結果をレンダリングするだけになります。
モデルはスコープやEnum、アソシエーションの等の定義のみで、Operation からレコードの保存、更新、削除のみ実行されます。
これだけでも、ビジネスロジックが集約され、かつユースケースごとに Operation があるので、依存関係も解消してスッキリします。
コードベースで解説すると、以下のようになります。
まずは、ベースの Operation クラスを定義します。
context
は外部で利用するデータを管理、call
メソッドはロジックを実行して実行結果をBooleanで返却します。
呼び出し元は result
メソッドを実行して実行結果と context データにアクセスします。
class ApplicationOperation
attr_reader :context
def initialize(*)
@context = OpenStruct.new
end
def call
raise NotImplementedError
end
def result
Result.new(success: call, context: @context)
end
class Result
def initialize(success:, context:)
@success = success
@context = context
end
def success?
@success
end
delegate :[], to: :@context
end
end
次にモデルを定義します。
アソシエーションの定義のみです。
class Book < ApplicationRecord
belongs_to :author
end
この時、Book レコードを作成する Operation はベースクラスを継承して以下のようになります。
class Books::CreateOperation < ApplicationOperation
def initialize(title:)
super
@title = title
end
def call
context.book = Book.create(title: @title)
context.book.persisted?
end
end
Controller はリクエストから受け取った title
を Operation に渡し、実行結果に応じてレンダリングするだけになります。
class BooksController < ApplicationController
def create
operation = Books::CreateOperation.new(title: params[:title])
result = operation.result
if resiult.success?
# 成功した場合の処理
@book = result[:book]
else
# 失敗した場合の処理
end
end
end
Contract
Rails のモデルにはバリデーションの役割もありますが、数が増えると肥大化の原因になります。
Trailblazer では Contract に移譲します。
モデルからバリデーションが切り離されることで、1モデルに複数のバリデーションが混在して、意図しないバリデーションが実行される事態をなくすことができます。
コードベースで解説すると、以下のようになります。
まずは、ベースの Contract クラスを定義します。
ActiveRecord と同じ attribute
や valid?
メソッドを利用するために ActiveModel のモジュールをインクルードします。
class ApplicationContract
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
end
Book レコードを作成する際のバリデーションクラスは以下のようになります。
レコードを作成する場合は title
を必須の条件にします。
このようにユースケースごとにバリデーションが独立するので、意図しないバリデーションが実行されることがなくなります。
class Books::CreateContract < ApplicationContract
attribute :title, :string
validates :name, presence: true
end
このバリデーションを先ほどの Operation 内に入れます。
これで title
に値がある状態で確実に Book レコードを作成することが可能になります。
class Books::CreateOperation < ApplicationOperation
def initialize(title:)
super
@contract = Books::CreateContract.new(title: title)
end
def call
return false unless @contract.valid?
context.book = Book.create(title: @contract.title)
context.book.persisted?
end
end
Finder
SQL も同じような条件文が散財したり、条件が多いと可読性も下がる要因になります。
Trailblazer では Finder にカプセル化することで、共通化と複雑な条件文も分割して生成するできようになるので、可読性も向上させることができます。
コードベースで解説すると、以下のようになります。
まずは、ベースの Finder クラスを定義します。
Contract と同様に attribute
メソッドで動的に SQL へ値を適用できるようにします。
scope
メソッドにベースの SQL を定義して、filter_by
で条件文を適用する流れになります。
呼び出し元は result
で生成された SQL を受け取り、all
や find
等でレコードを取得します。
class ApplicationFinder
include ActiveModel::Model
include ActiveModel::Attributes
class_attribute :filters
def initialize(*)
super
@sql = nil
end
def self.filter_by(*names, &block)
filter = proc do
values = names.map { |n| send(n) }
res = instance_exec(*values, &block)
@sql.merge!(res) if res
end
(self.filters ||= []).push(filter)
end
def scope
raise NotImplementedError
end
def result
@sql = scope
filters&.each { |filter| instance_exec(&filter) }
@sql
end
end
この時、Book レコードを取得する Finder は以下のようになります。
author_id
でフィルタリングする条件と scope
で is_public = true
のレコードのみ対象とします。
今回は条件が一つだけですが、数が多い場合に filter_by
は条件を分割して複雑な SQL もシンプルになる効果を発揮します。
class Books::Finder < ApplicationFinder
attribute :author_id, :integer
filter_by(:author_id) { |val| Book.where(author_id: val) if val.present? }
def scope
Book.where(is_public: true)
end
end
Controller から Finder の実行は以下になります。
author_id
がある場合は author_id
でフィルタリングした結果を取得することができます。
このように SQL も分離して共通化が可能になります。
class BooksController < ApplicationController
def index
finder = Books::Finder.new(author_id: params[:author_id])
@books = finder.result.all
end
def show
finder = Books::Finder.new(author_id: params[:author_id])
@book = finder.result.find(params[:id])
end
end
まとめ
いかがでしたでしょうか。MVC に加えて管理するクラスは増えますが、それぞれが独立して単一責任を担うことで、 複雑なワークフローもシンプルになり、保守の観点でもレイヤーごとに修正できるので修正コストも少なくすることができます。
おてつたびでも導入してから保守性は格段に向上しました。
今回紹介した Operation, Contact, Finder 以外にも認証を担う Policy や View パーツをカプセル化する Cell など他にもあるので、興味を持った方は是非公式ドキュメントをご覧ください。
Rails では Service 層を入れることをよく見受けられますが、単一責任で責務を明確にしてワークフローをシンプルにできる Trailblazer の思想を導入する選択もおすすめです。
終わりに
株式会社おてつたびでは、一緒に働く仲間を募集しています!
自分の経験を活かして様々な事にチャレンジすることが出来る土壌があると思います。
Web と iOS エンジニアを積極募集しております。
ご応募お待ちしております。
カジュアルに話したい方は、Meety からご応募お待ちしております!
Discussion