Rails Service Object: What? Why? and How?

hooopo for Shopper+ · March 22, 2015 · Last by victor replied at June 08, 2016 · 9084 hits
Topic has been selected as the excellent topic by the admin.

首先想说的是,Service Object 是在 Rails 里实践 SRP 的一种手段和模式,不仅仅是一个文件夹。

Controller 是交互入口

像 c/c++ 里,每个应用都会有一个入口,像下面这样:

#include <iostream>
// Many includes...

int main(int argc, char *argv[]) {
  // Fetch your data.
  // Ex. Input data = Input.readFromUser(argc, argv);

  Application app = Application(data);
  app.start();

  // Cleanup logic...
  return 0;
}

如果运行上面的应用,main 函数被调用,所有参数都被传递到argv变量。

随着 c/c++ 程序代码量增长,没有人会把逻辑放到 main 函数里面,main 函数里只初始化一些常驻对象,然后调用 start 之类的方法去调用我们的业务逻辑。

Rails 的每个 Action 其实就相当于 c/c++ 里的 main 函数。

Rails 里,每个 Action 和 main 函数一样都是与外部交互的入口。Controller 和 Action 虽然表现为类和方法,但是不同 Action 相互之间是没有交互的。 另外,Controller 已经承担很多职责:

  • 解析用户输入 -> params
  • 渲染 view -> render
  • logging -> log
  • routing -> redirect
  • 输出提示 -> flash

Servies Object

Service Object 封装了每一个业务流程。它负责组织应用领域模型(Model)之间的交互,并且不依赖于框架(Controller 层)。。你可以想象怎样的代码从 Sinatra 程序改成 Rails 会更容易,当然你不一定要这么做,我只是想解释一下什么是 不依赖框架层

引人 Service Object 之后,可以带来很多好处:

  • Controller 更容易测试。
  • 业务逻辑从 Controller 中剥离,更容易独立测试。
  • 业务与框架低耦合。
  • 让 Controller 更 slim。

Examples

重构之前的 charge more controller:

class OrdersController
  def charge_execute
    @order = Order.find(params[:id])
    @order.total = params[:order][:total].to_f

    redirect_to :action => 'charge_more', :id => @order and return unless @order.total > 0

    # 1. init beanstream payment gateway
    gateway = ActiveMerchant::Billing::BeanstreamGateway.new(
            :login    => $BEAN_STREAM_ID,
            :user     => $BEAN_STREAM_LOGIN,
            :password => $BEAN_STREAM_PASSWD
    )

    # 2. init creditcard, options or pair values
    options = {
      :order_id => @order.id,
      :billing_address => {
        :name     => "#{@order.billing_first_name} #{@order.billing_last_name}",
        :phone    => @order.billing_phone,
        :address1 => @order.billing_street_address,
        :address2 => @order.billing_street_address_2,
        :city     => @order.billing_city,
        :state    => @order.get_state_code,
        :country  => @order.get_country_code,
        :zip      => @order.billing_zip
      },
      :email  => @order.email,
      :ref1   => "#{$DOMAIN_NAME} Order"
    }

    # 3. send payment info to gateway and deal with response
    response = gateway.purchase(@order.total_in_cents, @order.get_creditcard, options)

    if response.success?
      credit_card = @order.credit_card
      credit_card.transaction_id = credit_card.transaction_id + ',' + response.authorization
      credit_card.save
      flash[:notice] = "Extra money #{@order.total} has been charged"
    else
      flash[:notice] = "Error of processing charge: #{response.message}."
    end
    redirect_to :action => 'show', :id => @order and return
  end
end

重构之后:

class OrdersController
  def charge_execute
    @order = Order.find(params[:id])
    @amount = params[:order][:total].to_f
    redirect_to :action => 'charge_more', :id => @order and return unless @amount > 0
    charge_logic = OrderChargeLogic.new(@order, @amount * 100)
    charge_logic.execute

    if charge_logic.success?
      flash[:notice] = "Extra money #{@amount} has been charged"
    else
      flash[:notice] = "Error of processing charge: #{charge_logic.message}."
    end
    redirect_to :action => 'show', :id => @order and return
  end
end

Servies:

class OrderChargeLogic
  attr_reader :order, :amount, :gateway, :response, :creditcard_options, :credit_card

  def initialize(order, amount)
    @order, @amount = order, amount
    @credit_card = @order.credit_card
  end

  def success?
    response.success?
  end

  def message
    response.message
  end

  def execute
    init_beanstream_payment_gateway
    init_creditcard_options
    send_payment_info_to_gateway
  end

  private

  def init_beanstream_payment_gateway
    @gateway = ActiveMerchant::Billing::BeanstreamGateway.new(
            :login    => $BEAN_STREAM_ID,
            :user     => $BEAN_STREAM_LOGIN,
            :password => $BEAN_STREAM_PASSWD
          )
  end

  def init_creditcard_options
    @creditcard_options = {
      :order_id => order.id,
      :billing_address => {
        :name     => "#{@order.billing_first_name} #{@order.billing_last_name}",
        :phone    => order.billing_phone,
        :address1 => order.billing_street_address,
        :address2 => order.billing_street_address_2,
        :city     => order.billing_city,
        :state    => order.get_state_code,
        :country  => order.get_country_code,
        :zip      => order.billing_zip
      },
      :email  => order.email,
      :ref1   => "#{$DOMAIN_NAME} Order"
    }
  end

  def send_payment_info_to_gateway
    @response = gateway.purchase(amount, order.get_creditcard, creditcard_options)
    if @response.success?
      log_credit_card_transaction
    end
  end

  def log_credit_card_transaction
    credit_card.transaction_id = credit_card.transaction_id + ',' + @response.authorization
    credit_card.save
  end
end

Example 2: https://gist.github.com/hooopo/f6a031dac417323dfec6

引人 OrderChargeLogic 之后,收款这一个业务逻辑脱离了 Controller,可以在任何地方(rake task,model 等)复用。Controller 只负责调用 OrderChargeLogic,根据 OrderChargeLogic 返回的状态去设置提示信息并且渲染。

也就是说,当你的项目越来越复杂,Model 和 Database Table 不会完全一一对应了,同时也会有多个 Model 之间衍生出的业务逻辑,Service Object 就是用来处理这部分内容。这部分逻辑不属于 Controller,也不属于某一个 Model。

如果你想让复杂 Rails 项目也能 SRP,那么 Service Object 是一个值得尝试的手段。

殊途同归,看来大家都在这么干了,Service Object 是精简 Controller 代码非常好的一个手段,简单直接,无副作用,容易移植,方便测试。

Unknow user #2 March 22, 2015

:plus1: good job ! 😄

业务复杂点的项目应该这么做。 15 分钟的博客不用。

针对示例代码,要重构的话,下面是我想到的一种解决办法:

class OrdersController < ApplicationController
  def charge_execute
    @order = Order.find(params[:id])
    @order.total = params[:order][:total].to_f

    redirect_to :action => 'charge_more', :id => @order and return unless @order.total > 0

    # 1. init beanstream payment gateway

    # 2. init creditcard, options or pair values

    # 3. send payment info to gateway and deal with response
    response = gateway.purchase(@order.total_in_cents, @order.get_creditcard, @order.options)

    if response.success?
      @order.update_credit_card(response.authorization)
      flash[:notice] = "Extra money #{@order.total} has been charged"
    else
      flash[:notice] = "Error of processing charge: #{response.message}."
    end
    redirect_to :action => 'show', :id => @order and return
  end

  private
  # 1. init beanstream payment gateway
  # 放到其它地方也可以(比如:ApplicationController)
  def gateway
    ActiveMerchant::Billing::BeanstreamGateway.new(
      :login    => $BEAN_STREAM_ID,
      :user     => $BEAN_STREAM_LOGIN,
      :password => $BEAN_STREAM_PASSWD
    )
  end
end

class Order < ActiveRecord::Base
  # 2. init creditcard, options or pair values
  # 你可以换个更好的名字
  def options
    {
      :order_id => self.id,
      :billing_address => {
        :name     => "#{self.billing_first_name} #{self.billing_last_name}",
        :phone    => self.billing_phone,
        :address1 => self.billing_street_address,
        :address2 => self.billing_street_address_2,
        :city     => self.billing_city,
        :state    => self.get_state_code,
        :country  => self.get_country_code,
        :zip      => self.billing_zip
      },
      :email  => self.email,
      :ref1   => "#{$DOMAIN_NAME} Order"
    }
  end

  # 你可以换个更好的名字
  def update_credit_card(authorization)
    credit_card = self.credit_card
    credit_card.transaction_id = credit_card.transaction_id + ',' + authorization
    credit_card.save
  end
end

对比使用“Service Object”,上面的重构方法,更常见,也更容易理解。 但问题是:这里的 OrderChargeLogic 逻辑不好重用!

举例: 我们要对外提供独立的 api . 我们要开发独立的 mobile 版本 . (别问我为什么 ...)

这里的 OrdersController#charge_execute 不能直接重用 (像下面的 redirect_to, flash, render 和 gateway 我们用不到或不好用)。

  redirect_to :action => 'charge_more', :id => @order and return unless @order.total > 0

  # ...
  if response.success?
    # ...
    flash[:notice] = "Extra money #{@order.total} has been charged"
  else
    flash[:notice] = "Error of processing charge: #{response.message}."
  end
  redirect_to :action => 'show', :id => @order and return

private

def gateway
  # ...
end

封装成"Service Object"的话,则可以重用。不管其它代码怎么变,只要提供 order, amount 就行

class Api < Sinatra::Base
  put '/v1/orders/:id' do
    # ...
    charge_logic = OrderChargeLogic.new(@order, @amount * 100)
    charge_logic.execute

    # ...
  end
end

一,有没有必要使用 Service Object

引人 service 层之后,可以带来很多好处:

Controller 更容易测试。 业务逻辑从 Controller 中剥离,更容易独立测试。 业务与框架低耦合。 让 Controller 更 slim。

但,我个人是觉得上面的重构方式更直观、容易理解; 使用 Service Object 的话,反而让简单的事变得复杂了。创建单独的 class OrderChargeLogic,新增一个类和多个方法,增加成本

这里关键是:有没有必要?

二,它的职责是什么?

Service Object 封装了每一个业务流程。它负责组织应用领域模型(Model)之间的交互,并且不依赖于框架(Controller 层)

上面举例里,就包括了所有: init_beanstream_payment_gateway init_creditcard_options send_payment_info_to_gateway log_credit_card_transaction

也是一团麻,怎么保证 OrderChargeLogic 这个 class 自身的“单一职责”。


@novtopro 已经更新

最近探讨这个话题的好多 ~

看到 rei 今天写了个 active service

我表示看不懂宽哥的描述。。。

Great,不能再同意更多。有时间我也来一篇相关的,但是目前真脱不开身哈哈…… 其实要设计好 services object,很多 rails 既有的东西会成为拦路虎,比方说 filter、callback,以及大家习以为常的 CV 间采用实例变量通信。所以说默认的 Rails 风跟更高层次的架构抽象之间,是有阻抗的。

与 rails4 引入的 concern 相比,好处在哪里呢? 我了解到 concern 只是抽出为了可以复用的类。然后 mixin 进需要他的地方。我觉得严格来说不是加入了层的概念。

10 Floor has deleted

没有通解 因地制宜

在曾经的一个项目中,我们就采用了该模式。在那个项目中效果很不错。 其实本质本质问题是,一个 Controller 有多个 Action,而 Action 之间的逻辑是独立的。 当一些相关度较低的代码,全都放在一个文件中,过于混乱,不易维护。

对应关系的变化导致我们采用不同的方案

  • Controller 和 Action 的对应关系
  • Action 和内部代码数量的对应关系

考虑以下几种情况

情况 1 一个 Controller 有多个重的 Action

class ControllerA
  def action_1
    #100行代码
  end
  def action_2
    #100行代码
  end
  def action_3
    #100行代码
  end
end

情况 2 一个 Controller 有多个轻的 Action

class ControllerA
  def action_1
    #仅1行代码
  end
  def action_2
    #仅1行代码
  end
  def action_3
    #仅1行代码
  end
end

情况 3 一个 Controller 有一个重的 Action

class ControllerA
  def action_1
    #100行代码
  end
end

class ControllerB
  def action_1
    #100行代码
  end
end
  • 情况 1 不符合 SRP
  • 情况 2 符合 SRP
  • 情况 3 符合 SRP

  • 情况 1 采用 Service Object 模式后,就会变成 情况 2

不知大家可曾想过,如果一个 Controller 只有一个 Action,那么天生就是 SRP。 但是这不符合 Rails 的 Convention。

#10 楼 @emanon 取名字的目的是为了让大家知道在谈论的是什么,否则就无法交流。说简单了是 PORO(plain old ruby object),当然这也可以说又引入了一种概念。职责分离和“程序就是数据结构 + 算法”什么的类似,是一句人人都懂,而又人人都不懂的话。Service Object 是一个更具体的方法。

第二点担心很多余,现在的 Rails 程序员都懒的很呐,helper 都懒得用,直接 view 里写逻辑的大有人在。原因很简单,简单粗暴不用思考啊。但你说的属于另外一个方向,我几乎没见过。

Startup 项目有 Startup 的做法,遗留项目有遗留项目的维护方式。1k、1w、10w 行代码的项目也都有不同的维护方式,各自找到适合自己的方案就好,不必刻意模仿,也不必随意否定。

#12 楼 @hooopo 看来在你回复我的期间我把自己的回复删除了,因为仔细想了想并不是针对这一个帖子而是同一系列话题,单独放你的帖子里回复不合适。

确实毫无设计跟过度设计是两个极端。只是待在中间很难,人们比较习惯于从一个极端直接走向另一个极端,那样最不费脑。

我也没有否定任何 startup 项目还是遗留项目的 做事方式,整理清楚代码我是绝对赞同的。况且这段代码跟 startup 还是 legacy 没关系吧,任何时候都应该整理。只是觉得 讲述 的方式有点不太对而已,觉得弄 Service Object/Layer 这个概念不是太好。

认真追究的话,实际上这个帖子里的 Service Object 完全可以说成是“提炼类”。而 @scriptfans 的帖子里比较倾向于把它弄成一个层,我主要觉得这个不对。

之所以说这个帖子里讲述的方式不好,是因为我觉得这个例子跟那边的“Service 层”完全扯不上关系,就是重构手法当中的“提炼类”,如 @rubyu2 所说“并没有实现‘加一层’的概念”,但是却又强调了 Service Object 这么个名字(好吧,虽然不是 Service Layer,但是看你俩在帖子里的互动,我觉得这个帖子像是在拿这个“Service Object”的必要来证明“Service Layer”的必要。)

哎呀我发现我净说废话…… 还是写代码实在。

#14 楼 @emanon 不知道‘层’要怎么理解。我理解的 Service Object 就是普通 AR Object 之外的普通 Ruby 对象,就是 Model。硬要说层的话应该是这样:(M=(AR+SO) -> V -> C)。

这东西之所以会成为一个话题,是由于之前人们对 AR 之外的 Object 无所适从,甚至排斥,要么扔到 lib 里,要么硬写到 AR 里。

说实话,这种类,我有时候也不单独放单 service 目录,直接扔 model 目录里,都是 Business Model.

#16 楼 @hooopo 对,我也是比较喜欢这种说法:它就只是个 Ruby 对象。“层”就像你说的那样,比如说所有的 Controller 合起来算一个层。

实际上“Rails 那些坑”一帖也是把 Object 和 Layer 混着说,我也没看清楚逻辑。当然一个 Layer 是由许许多多 Object 组成的。

我只是觉得别把 J2EE 的东西搬过来。如果有人完整的看过以前 JavaEye 关于充血贫血讨论相关的帖子(不建议搞 Ruby 的去翻帖,Ruby 程序员没有那种历史遗留包袱,没弄明白历史的前提下去看方法论只会把思维弄乱),应该要搞清楚“充血模型”实际上说的是面向对象,而面向对象是强调单一职责的,不存在什么过度充血的情况,那只会是程序员们自己干出来的。不能说要把逻辑还给 Model 了,就把所有的逻辑都往 Model(Rails 的 ActiveRecord)里塞,哪有这么理解充血模型的——这又是个走极端的例子吧。

#9 楼 @flowerwrong 都是 extract method 的方法,但 concern 有一点局限性:

  • Service Object 更适合组织多个 model 之间的交互。
  • Concern 需要 ActiveSupport::Concern 才能玩的转,而 Service Object 就是普通 Object。

#9 楼 @flowerwrong 都是 extract method 的方法,但 concern 有一点局限性: Service Object 更适合组织多个 model 之间的交互。 Concern 需要 ActiveSupport::Concern 才能玩的转,而 Service Object 就是普通 Object。

很赞成这种理解,简单明了。而且大多数情况下,mvc 已经完全足够用。即便有重复的代码,想要重用,搞一个 service 层也不一定是“划算”的。

比如我们项目中,在 grape 的 api 里有大量代码逻辑其实和 controller 重复的,但是因为 grape 语法里 present,error,以及 params 的验证等 dsl 语法很多。将这部分代码抽出到 model 或者重新建一个 service 都是非常繁杂的过程,而且会生产很多 ugly 的代码,api 的改动和升级也会造成很多麻烦,反而会增加很大的阅读和维护难度。

#12 楼 @hooopo 👍“实用主义”才是第一原则。

功力尚浅,围观一下,随手贴个链接:https://gist.github.com/justinko/2838490

22 Floor has deleted

看到一本教材里面写 controler 的测试。

大体三块。

  1. call the method with right input
  2. render right template
  3. assign instance variables with right result.

来学习思想

25 Floor has deleted

#18 楼 @hooopo

Concern 需要 ActiveSupport::Concern 才能玩的转,而 Service Object 就是普通 Object。

ActiveSupport::Concern 不是必要的,它其实只是一种简写。

官方文档 activesuport concern

# metaprogramming ruby 一书中的写法
module M
  def self.included(base)
    base.extend ClassMethods
    base.class_eval do
      scope :disabled, -> { where(disabled: true) }
    end
  end

  module ClassMethods
    ...
  end
end
# rails的简写
require 'active_support/concern'

module M
  extend ActiveSupport::Concern

  included do
    scope :disabled, -> { where(disabled: true) }
  end

  class_methods do
    ...
  end
end

又看到了 dao -> service -> controller, 当然项目足够负责完全可以这么干。

#28 楼 @hooooopo AR,不过 rails 的 model 是 fat 的,确实不应该称之为 DAO,不过目前 Rails 开发越来越重用 service 来解耦了。

比较喜欢把 Service Object 做成 Form Object 的形式,对参数加一些 validators,然后把逻辑放在 submit 方法里 Q.Q

#28 楼 和楼主是什么关系 ... 😯

#30 楼 @fleuria 我觉得 正像 14 楼 @emanon 说的,这里说的 Service Object 就是提炼类(非 AR 类),当然 Form Object 算是一种形式。上面的一个例子可能有误导,另外再补充一下多 model 交互的例子:

https://gist.github.com/hooopo/f6a031dac417323dfec6

传统的 Rails Way,这些可能都会被放到 Order 或 Package 里。

目前是把类似 Service Object 的东西直接放在 models。大的项目确实不好,因为 models 里面东西太多。

赞!之前对充血模型和贫血模型争论挺多,我觉得像 JavaBean 中纯 get/set 不可取,Rails 所有逻辑都在 model 也不可取。Service Object 到是一种折中的方法。@liyijie

#30 楼 @fleuria Form Object 不太好的地方是,最后 model 保存还是会报错的,还得手动把这些报错信息统一起来...

#30 楼 @fleuria 做成 Form Object 是個好辦法,可以透過 active_attr gem 來完成 ActiveModel 介面,這樣的好處是可以快速應用 simple_form 等表單生成套件來生成前端表單。

但是,如果說你的項目需要開 API 的時候,還是單純的 plain old object 的 Service Object 好用

@luikore 之前弄了这么一个 trick 把 model 的 errors 合并过来囧:

def save
  if [self.valid?, self.order.valid?] == [true, true]
    OrderForm.insert!(self.withraw)
    return self.order
  else
    self.order.errors.each do |field, message|
      self.errors[field] << message
    end
    return nil
  end
end

@luikore 最近比较喜欢不在 Model 里放任何 validation 而把所有 validation 都弄在 form object 里倒是

#38 楼 @fleuria

你可以这么想:把 activerecord 添加的 getter setter 无视掉,把 order.attributes 当成 model, 把 order 当成 form object ...

victor in 发布 / 订阅模式 mention this topic. 08 Jun 12:50
You need to Sign in before reply, if you don't have an account, please Sign up first.