Service Object
Service Object with active_type
什么是 Service Object
Service Object 是一个普通的 Ruby 对象,用于将业务逻辑分解为可管理的类和方法。
因为和业务逻辑比较接近,Service Object 通常用在 Controller 中,但也可以单独使用(比如在 job , console 或者其他 Service Object 中嵌套使用)。
何时使用 Service Object
- 操作逻辑很复杂。(在会计期结束时关闭账本)
- 操作涉及到多个 model。(一个在线商城的 purchase 动作涉及到 Order, CreditCard 和 Customer 模型)
- 操作涉及到调用外部服务。(发送短信验证码)
- 操作不是 model 该关注的核心逻辑(比如定时清理过期数据)。
- 操作涉及到一系列不同的具体实现(比如用 token 认证或者 password 认证),策略模式就是干这个的。
Convention over Configuration
Convention 的意义在于,它就是一个最佳实践的表现形式,Rails 本质上是一系列 web 开发中最佳实践的集合体。遵循 Convention 的 Rails 项目都长得差不多,这使得 Rails 开发者的经验能够跨项目地重用,大家可以关注在商业逻辑目标上。
基本原则 Basic principles
- does only one thing and does it well (Unix philosophy)
- can be run synchronously (i.e. blocking/in the foreground) or asynchronously (i.e. non-blocking/in the background)
- can be configured as “unique”, meaning only one instance of it should be run at any time (including or ignoring parameters)
- logs all the things (start time, end time, duration, caller, exceptions etc.)
- has its own exception class(es) which all exceptions that might be raised inherit from
- does not care whether certain parameters are objects or object IDs
基于 active_type 实现
约定 Conventions
- Let your services inherit from
ApplicationService - Put your services in
app/services/ - 文件以
service结尾并能描述出功能,并以动词开头。例如:app/services/memberships/become_collaborator_service.rb - 文件继承
app/services/application_service.rb - 所有的商业逻辑都放在
perform方法中,也有人喜欢call, run, execute - Consider wrapping call methods in transactions
class ApplicationService < ActiveType::Object
self.abstract_class = true
after_save :perform
private
def perform
raise NotImplementedError, 'Must be implemented by subtypes.'
end
end
例子
# app/services/roles/become_admin_service.rb
class Roles::BecomeAdminService < ApplicationService
attribute :forum_id, :integer
attribute :user_id, :integer
belongs_to :user
belongs_to :forum
validate :need_membership
delegate :members, to: :forum
private
def perform
return if user.has_role?(:moderator, forum)
user.add_role(:admin, forum)
end
def need_membership
errors.add :base, :need_membership unless members.include?(user)
end
end
role = Roles::BecomeAdminService.new(forum: @forum, user: @member)
role.save
优势
Service objects 可以很清晰的体现出应用的功能
只要扫一眼 services 文件夹,看看名字就能猜出来个大概了。 ApproveTransaction, CancelTransaction, BlockAccount, SendTransactionApprovalReminder, ApproveTransaction, SendTestNewsletter, ImportUsersFromCsv, CreateUser, AuthenticateUsingTwitter, ObfuscateCode, CompleteOrder。 注意,起名的时候尽量以 commands or actions 的形式来命名,便于理解。
不需要关心 controller 中添加了什么特殊逻辑,model 添加了哪些 callback 和 observers,所有的商业逻辑都在 perform 方法中。
让 controller 和 model 更干净的,便于测试
现在 Controllers 只需要把携带的参数 params, session, cookies 传递给 service 并根据 service object 是否正确保存,做不同的响应状态就行 redirect or render。看起来的代码完全跟 Scaffold 生成的一样,利用 validate 方法可以拿到 errors 信息。
Models 也只需要关注 associations, scopes, validations and persistence。
# app/services/invite/accept_invite_service.rb
class Invite::AcceptInviteService < ApplicationService
attribute :invite
attribute :user
private
def perform
invite.accept!(user)
UserMailer.invite_accepted(invite).deliver
end
end
class InviteController < ApplicationController
def accept
invite = Invite.find_by_token!(params[:token])
service = Invite::AcceptInviteService.new(invite: invite, user: current_user)
if service.save
redirect_to invite.item, notice: "Welcome!"
else
redirect_to '/', alert: "Oopsy!"
end
end
end
class Invite < ActiveRecord::Base
def accept!(user, time=Time.now)
update_attributes!(accepted_by_user_id: user.id, accepted_at: time)
end
end
DRY 并且容易修改
保持 service objects 简单,可以把几个 service compose 在一起,方便复用。这样项目的模块化程度更高,修改起来也很容易。
class SendTestNewsletter < ApplicationService
attribute :newsletter
private
def perform
campaign = CreateMailChimpCampaign.create(newsletter)
DeliverTestEmail.create(campaign)
DeleteCampaign.create(campaign) # Don't keep the test campaign around
end
end
class SendNewsletter < ApplicationService
attribute :newsletter
private
def perform
campaign = CreateMailChimpCampaign.create(newsletter)
DeliverCampaign.create(campaign)
# Could easily delete here as well, but we want to retain the legit campaigns
end
end
其它优势
- 因为 service 本身就是简单的 Ruby 对象,所以测试起来也很容易
- 你可以在任何地方使用 service object,在
service object,DelayedJob / Rescue / Sidekiq,Rake Task,console,helper等等。
参考
articles
- Service Object 整理和小结
- 7 Patterns to Refactor Fat ActiveRecord Models
- Service Objects With Rails Using Aldous
- My take on services in Rails
