Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

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はもっと使われてもいい(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。