🛤

Rails に Trailblazer の概念を導入したお話

2022/09/23に公開

はじめに

おてつたび の川尻(@tomoki_kawajiri)です。

先日、株式会社ラクスさん主催の エンジニアの勉強法ハックLT- vol.10 に登壇させていただきました。 今回はその中で触れた Trailblazer の概念を取り入れた件についてご紹介します。

登壇資料は以下になります。
内容はフレームワークから学べるメリットやソースコードを見る観点が中心になります。
https://speakerdeck.com/weistom/huremuwakunososukodokarazhi-shi-woshen-meru

フレームワークを学習する中で出会った一つが Trailblazer でした。

Trailblazer

Trailblazer は Ruby のフレームワークです。
ビジネスロジックを独自のレイヤーでカプセル化することで、責務の分離を果たし、ワークフローをシンプルにできるところが特徴です。
https://trailblazer.to/

導入の背景

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 と同じ attributevalid? メソッドを利用するために 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 を受け取り、allfind 等でレコードを取得します。

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 でフィルタリングする条件と scopeis_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 エンジニアを積極募集しております。
ご応募お待ちしております。

https://otetsutabi.notion.site/7312d1ad95d043a7a824752a389dc111
https://www.wantedly.com/companies/company_1381802
https://sokudan.work/top/projects/4192

カジュアルに話したい方は、Meety からご応募お待ちしております!
https://meety.net/matches/ehIndfduAZJe

Discussion