Rails: Waterfall gemでコントローラとモデルを整理(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: Waterfall gemでコントローラとモデルを整理(翻訳)

ファットモデルや巨大アクションはRails開発者にとって常に問題になっていました。Service Objectに助けてもらうようになったものの、ダメなコードをコントローラから素のRubyオブジェクトに移動しただけなのではないかと思ったりもします。しかしWaterfall gemでこの問題を解決できそうです。早速チェックしてみましょう。

Waterfallについて

Waterfallは、Service Objectの新しいアプローチのひとつです。Waterfallは関数型(functional)的なアイデアに支えられており、ブロックやその他のWaterfallオブジェクトを相互に渡すことで、簡単にチェインできるというものです。オブジェクトのどれかが失敗する(呼び出したときに文字どおり「堰き止められる(dam)」)とWaterfall全体が失敗し、トランザクションでロールバックすることも#reverseフローメソッドを用いて手動でロールバックすることもできます。

平均的なWaterfallサービスは次のような感じになります。

class MakeUserHappy < Struct.new(:user)
  include Waterfall

  def call
    with_transaction do
      chain { MakeUserHappy.new(user) }

      when_falsy { user.happy? }
        .dam { "#{user.name}はまるきり不幸です >_<" }

      chain { notify_happy_user }

      on_dam { |error_pool| puts error_pool + '恥を知れ'}
    end
  end

  private

  def reverse_flow
    make_user_unhappy(user)
  end

  def notify_happy_user
    ...
  end 
end

このアプローチの最もよい点は、基本的なService Objectをいくつか作成してそれらを自由に組み合わせられることです。コントローラは次のような感じになります。

def make_user_happy
  authorize(@user)

  Wf.new
    .chain  { MakeUserHappy.new(@user) }
    .chain  { flash[:notice] = 'ユーザーは幸せです!' }
    .on_dam { |err| flash[:alert] = err }

  redirect_to user_path(@user)
end

もちろん#chainメソッドは必要に応じていくつでも追加できますが、コントローラが散らからないようシンプルを心がけています。

用例1: ネスト属性やaroundコールバック

コード例を見てみましょう。ここで行いたいのはネストした属性の除去と、予約(問い合わせ)自身とは別に予約の旅客を更新することです。また、更新の直前や直後に何らかのトラッキングを行う必要もあります。このトラッキングは最終的にオプションにする必要があります。

booking: 予約
enquiry: 問い合わせ

ネストした属性の除去が必須でなければ、around_updateコールバックあたりを使って予約を行い、ネストした属性を使えば済むのでもっと楽にできたでしょう。しかし、around_updateコールバックは属性を即座に代入するため、オブジェクトの古いステートを取得しようとすると困難が生じます。最終的に、このコールバックを状況に応じてスキップする方法を検討せざるを得なくなり、しかも現在のRailsにはきれいな解決方法がありません。結局トラッキングをシンプルにする方向で行うしかありませんでした。これでも既に相当複雑になっています。

そこで私たちは、手順ごとに個別のService Objectを作成してこれらをチェインすることに決めました。更新後のアクションは次のような感じになります。

def update
  authorize @enquiry

  Wf.new
    .chain { UpdateEnquiry.new(@enquiry, enquiry_params, partners_params) }
    .chain do
      redirect_to edit_enquiry_path(@enquiry), notice: '問い合わせの更新に成功しました。'
    end
    .on_dam do |err|
      redirect_to edit_enquiry_path(@enquiry), alert: "問い合わせ更新中のエラー: #{err}."
    end
end

UpdateEnquiryサービスは次のようになります。

class UpdateEnquiry
  include Waterfall

  attr_accessor :enquiry, :enquiry_params, :partners_params

  def initialize(enquiry, enquiry_params, partners_params)
    @enquiry = enquiry
    @enquiry_params = enquiry_params
    @partners_params = partners_params
  end

  def call
    with_transaction do
      TrackEnquiryUpdate.new(enquiry).call do
        chain { UpdatePartners.new(enquiry.partners, partners_params) }

        when_falsy { enquiry.update(enquiry_params) }
          .dam { enquiry.errors.full_messages.join('; ') }
      end
    end
  end 
end

ご覧のとおり、#chainメソッドはブロックの内部でも簡単に使えます。これでトラッキングがずっと簡単になり、トラックしたい問い合わせを渡して、どこかのブロックで問い合わせか問い合わせの関連付けを更新するだけで済むようになりました。これはaround_updateコールバックと似ていますが、私たちのルールに沿って動作し、使うかどうかも自由に決められます。TrackEnquiryUpdateのコードは次のとおりです。

class TrackEnquiryUpdate < Struct.new(:enquiry)
  include Waterfall

  def call
    chain do
      enquiry.track(:remove_from_cart)
      yield
      enquiry.reload.track(:add_to_cart)
    end
  end

  private

  def reverse_flow
    ...
  end 
end

私たちはService ObjectにStructを使うことにしました。Structには初期化ロジックがないので、コードがずっとスリムになります。Waterfallが堰き止められた場合のカスタムロールバックを実装できる#reverse_flowメソッドにご注目ください。

Ruby on Railsで使ってうれしい19のgem(翻訳)

次の例に進みます。

用例2: 支払い処理とロールバック

私たちの仕事に支払い処理は付きものです。APIがデータベースに支払いを1件作成した後でエラーを返すこともあれば、支払いを1件登録したのにデータベースへの保存に失敗することもあります。Waterfallはこのような問題を断ち切るときにも役立ちます。

How to Choose a Payment Platform for Your Project: PayPal, Stripe, Braintree

コントローラの内部は次のようになります。

def create
  payment_form = CardPaymentForm.new(card_payment_form_params)

  Wf.new
    .chain { EnrollPayment.new(payment_form) }
    .chain { head :ok }
    .on_dam do |errors|
      render json: { msg: errors.join(";\n ") }, status: 422
    end
end

EnrollPayment Waterfallは次のようになります。

class EnrollPayment < Struct.new(:payment_form)
  include Waterfall

  def call
    chain(charge: :charge) do
      ChargeStripe.new(charge_params)
    end

    chain(balance: :balance) do |flow|
      GetStripeBalanceTransaction.new(flow.charge.balance_transaction)
    end

    when_falsy do |flow|
      payment_form.attributes = charge_payment_params(flow.charge, flow.balance)
      payment_form.save
    end
      .dam { payment_form.errors.full_messages }

    chain { notify_after_payment }
  end

  private

  def charge_params
    payment_form.stripe_charge_params
  end

  def charge_payment_params(charge, balance)
    ...
  end

  def notify_after_payment
    ...
  end 
end

API呼び出しとデータベーストランザクションの分離方法にご注目ください。分離したことで、Waterfallはこれらにカスタムロールバックを適用できるようになります。with_transactionブロックはデータベースロールバックを担当します。APIのロールバックについてはChargeStripe Waterfallの方をご覧いただく必要があります。

class ChargeStripe < Struct.new(:stripe_charge_params)
  include Waterfall

  def call
    chain(:charge) { Stripe::Charge.create(stripe_charge_params) }
  rescue Stripe::StripeError => e
    dam([e.message])
  end

  private

  def reverse_flow
    Stripe::Refund.create(charge: self.outflow.charge.id)
  end
end

#reverse_flowはカスタムロールバックで、現在のWaterfallがジョブを終了した後で親のWaterfallが堰き止められた場合にのみ実行されます。したがって、データベースでエラーが発生した場合、つまりGetStripeBalanceTransaction Waterfallが堰き止められた場合、ChargeStripe Waterfallは以前行われた支払いを返金します。

まとめ

Waterfallは、Service Objectの素晴らしい新実装であり、コントローラやモデルに属さないコードをきれいに片付けることができます。サービスは、瓦礫の山のような恐ろしいコードではなく、アクションの連続として明確に表現されます。

関連記事

Railsで重要なパターンpart 1: Service Object(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

Rails: Service Objectはもっと使われてもいい(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ