概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: How to Clear Out Your Controllers and Models with Waterfall Gem
- 原文公開日: 2017/11/15
- 著者: Dmitry Staver
- サイト: https://blog.rubyroidlabs.com/
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
メソッドにご注目ください。
次の例に進みます。
用例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の素晴らしい新実装であり、コントローラやモデルに属さないコードをきれいに片付けることができます。サービスは、瓦礫の山のような恐ろしいコードではなく、アクションの連続として明確に表現されます。