一个功能性编程片段用于链接 Ruby 服务和代码块,从而提供一种新的流程控制方法。让它们流动吧!
Chain ruby commands, and treat them like a flow, which provides a new approach to application control flow.
When logic is complicated, waterfalls show their true power and let you write intention revealing code. Above all they excel at chaining services.
Upcoming book about failure management patterns, leveraging the gem: The Unhappy path
General presentation blog post there: Chain services objects like a boss.
Reach me @apneadiving
A waterfall object has its own flow of commands, you can chain your commands and if something wrong happens, you dam the flow which bypasses the rest of the commands.
Here is a basic representation:
chain by chainon_dam blocks are executed.class FetchUser
include Waterfall
def initialize(user_id)
@user_id = user_id
end
def call
chain { @response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}") }
when_falsy { @response.success? }
.dam { "Error status #{@response.code}" }
chain(:user) { @response.body }
end
endand call / chain:
Flow.new
.chain(user1: :user) { FetchUser.new(1) }
.chain(user2: :user) { FetchUser.new(2) }
.chain {|outflow| puts(outflow.user1, outflow.user2) } # report success
.on_dam {|error, context| puts(error, context) } # report errorWhich works like:
For installation, in your gemfile:
gem 'waterfall'then bundle as usual.
Each waterfall has its own outflow and error_pool.
outflow is an Openstruct so you can get/set its property like a hash or like a standard object.
Wiki contains many details, please check appropriate pages:
You can try and exercise your understanding of Waterfall using the Koans here
Doing
Flow.new
.chain(foo: :bar) { Flow.new.chain(:bar){ 1 } }is the same as doing:
Flow.new
.chain do |outflow, parent_waterfall|
unless parent_waterfall.dammed?
child = Wf.new.chain(:bar){ 1 }
if child.dammed?
parent_waterfall.dam(child.error_pool)
else
parent_waterfall.ouflow.foo = child.outflow.bar
end
end
endHopefully you better get the chaining power this way.
Given:
class MyWaterfall
include Waterfall
def call
self.chain { 1 }
end
endYou may have noticed that I usually write:
Flow.new
.chain { MyWaterfall.new }instead of
Flow.new
.chain { MyWaterfall.new.call }Both are not really the same:
Waterfall, it will apply chaining logic. If ever the waterfall was not executed yet, it will trigger call, hence the convention.call method returns. So the gem doesnt know there was a waterfall involved and cannot apply chaining logic... unless you ensure self is always returned, which is cumbersome, so it's better to avoid this# this is valid
self
.chain { Service1.new }
.chain { Service2.new }
# this is equivalent
self.chain { Service1.new }
self.chain { Service2.new }
# this is equivalent too
chain { Service1.new }
chain { Service2.new }
# this is invalid Ruby due to the extra line
self
.chain { Service1.new }
.chain { Service2.new }For the error_pool, its up to you. But using Rails, I usually include ActiveModel::Validations in my services.
Thus you:
In a service, there is one and single flow, so if you need conditionals to branch off, you can do:
self.chain { Service1.new }
if foo?
self.chain { Service2.new }
else
self.chain { Service3.new }
endSometimes you have a flow and you need a return value. You can use halt_chain, which is executed whether or not the flow is dammed. It returns what the block returns. As a consequence, it cannot be chained anymore, so it must be the last command:
self.halt_chain do |outflow, error_pool|
if error_pool
# what you want to return on error
else
# what you want to return from the outflow
end
endI'm used to wrap every single object involving database interactions within transactions, so it can be rolled back on error. Here is my usual setup:
module Waterfall
extend ActiveSupport::Concern
class Rollback < StandardError; end
def with_transaction(&block)
ActiveRecord::Base.transaction(requires_new: true) do
yield
on_dam do
raise Waterfall::Rollback
end
end
rescue Waterfall::Rollback
self
end
endAnd to use it:
class AuthenticateUser
include Waterfall
include ActiveModel::Validations
validates :user, presence: true
attr_reader :user
def initialize(email, password)
@email, @password = email, password
end
def call
with_transaction do
chain { @user = User.authenticate(@email, @password) }
when_falsy { valid? }
.dam { errors }
chain(:user) { user }
end
end
endThe huge benefit is that if you call services from services, everything will be rolled back.
If you get to dam a flow, this would trigger the reverse_flow method in all Services previously executed.
reverse_flow is not executed on the service which just failed, consider the on_dam hook in this case.
Take this as a hook to undo whatever you need to undo if things go wrong. Yet, you probably do not need to bother about databases inserts: this is the purpose of with_transaction.
Flow is just an alias for the Wf class, so just use the one you prefer :)
Huge thanks to robhorrigan for the help during infinite naming brainstorming.
暂无开放 Issues,或尚未同步最近议题。