Rails Service Object: What? Why? and How?

hooopo for Shopper+ · 2015年03月22日 · 最后由 victor 回复于 2016年06月08日 · 6326 次阅读
本帖已被设为精华帖!

首先想说的是,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是一个值得尝试的手段。

共收到 36 条回复

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

: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楼 已删除

没有通解 因地制宜

在曾经的一个项目中,我们就采用了该模式。在那个项目中效果很不错。 其实本质本质问题是,一个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楼 已删除

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

大体三块。

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

来学习思想

25楼 已删除

#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, 当然项目足够负责完全可以这么干。

#27楼 @Tim_Lang where is dao?

#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 发布 / 订阅模式 中提及了此贴 06月08日 12:50
hooopo Breaking Up a Monolithic Rails App Without MicroService 中提及了此贴 07月12日 13:37
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册