Rails code 整理系列 - Service Object 初探


Rails在设计上有太多可以自行调整的风格,这也是为什么在高阶开发者之间有许多争议和讨论,例如今年 在RailsConf引爆的『TDD已死』就是一个很好的例子,没有对错,只有风格。

而在Rails结构上也有许多可以调整的地方,例如常听到的『skinny controller、fat model』就是一例,认为所谓controller就是掌管各种『行为』(action),而不去干涉前端(view)的设计,也不去牵扯太多跟数据库(model)有关的计算。 Fat model就是将各种跟计算、储存有关的事情都交给model来处理,就这样的逻辑来分配,对开发者来说比较容易理解。

但照这样逻辑来推算,如果把所有东西都交给model,在逻辑、各种计算不分类的情况下,整个code也会变得相当混乱。 经典文章『7 Patterns to Refactor Fat ActiveRecord Models』便提到利用七种方法让过度拥挤的model可以有喘息的地方 ,解决这个整理上的困扰。 而本篇则分享其中一个Service Object的使用方法。

1. 为何要使用Service Object

顾名思义,Service Object是因为有某些类似的特定功能,像是一个『service』,跟数据库中的model并无直接关系,因此拉出来独立成为一个class,在逻辑上会更容易管理。 不过在文章中有定义了几个需要使用service object的情况:

  • method逻辑极其复杂的时候
  • 跨Model使用,无法特别归类于特定Model
  • 与外部服务有较多关连
  • 并非重要功能
  • 同一种method有许多类似的使用方法

2. Service Object 使用时机

文章中以用户登入机制为范例,将登入的authenticate功能另外放在UserAuthenticator这个class当中,非常简单的一个介绍。 但通常在实作中会遇到的情况都复杂许多,我个人遇过的情况是:

实作规格:

  1. Group、Post、Comment 三个model
  2. 要能将Post输出成四种格式:html、json、xlsx、pdf
  3. 在每种格式中要能列出相对应Group和Comment,并依据数量画出线图

这听起来就需要非常多分门别类的功能,但聪明的大家应该不难看出,这些功能几乎都是使用外部服务,包括gem 'jbuilder'gem 'axlsx'gem 'prawn'gem 'gruff'四个都是不需要自己土法炼钢的功能,而是直接使用gem安装以后,就可以将格式输出了。

因此,这些内容都是专门处理『输出』(render)的部份,可以将他们收拢为一个service object,管理比较方便。

3. 如何建立Service Object

在一个Rails app中,直接在controller或model文件夹内建立一个*.rb档案即可,叫什么名字不重要,你以为一定要叫service.rb吗? 那只是一个概念,其实我们可以叫各种名字,而在这个范例当中,我把档名叫做document.rb。 重点不在档案,而是里面的内容。

像图中另外再开一个services的文件夹也ok! 如果没有与controller或model开在同一个目录底下,需要在code中多安插一行require "service"

接下来我们要在档案中写入Document class

class Document
    def initialize(post)
     @the_post = post
    end

    def to_pdf
     @the_post = ... # 处理pdf档案
     post
    end

    def to_xlsx
     @the_post = ... # 处理xlsx档案
     post
    end

    def graph_generation
     @the_post = ... # 绘制线图
     graph
    end
end

其中部分省略,重点在于clas s本身有一个initialize的method是默认在class呼叫时会执行的,这里我们直接将变量指定给这个class当中的@post变量,再利用其instance variable特性传送给各个method进行后续处理。 到目前为止service object就算建立完成了!

4. 如何使用Service Object

大家可以直接用Ruby的逻辑来思考,呼叫一个class,就是直接呼叫即可,我们可以在controller action中呼叫:

    def show
        @post = Post.find(params[:id]).includes(:comment)
 document = Document.new(@ post)
        respond_to do |format|
            format.html { 
                @graph = document.graph_generation 
            }
            format.json { render :json => @post}
            format.pdf { @data = document.to_pdf }
            format.xlsx { @data = document.to_xlsx }
        end
    end

简单说明,使用 Service Object时,

1. 呼叫class

在这里使用开头大写的class名称,由于呼叫的方式跟呼叫model相同,因此记得不要取相同的名字。

2. 加上new method

加上new method代表的是执行刚才定义的initialize这个method,由于呼叫service object时一定要先启动,所以这个new method是不可省略的。 接着在后方加上要带入的变量,在这里是带入@post,会对照到刚才我们在class中定义的the_post

3. 再加上要使用的method

变量带入以后,我们就可以再带入其他的method,针对不同输出方式来调整。 由于这边的范例是在controller里面使用instance variable,所以instance variable的部份(前面有加上@符号的变量,可以在controller之间互相传递)要记得不要取相同的名字,以免重复使用。 如果是在model里面使用service object比较不会有这个困扰。

由于这边是重复呼叫Document,才会先指定给在document。 如果只使用一次,也可以只写成一行:@data = Document.new(@post).to_pdf


依照这样的写法,我们就把所有的功能都放到service object当中了,剩下的事情交给前端的view去处理。 优点是controller保持干净简单的特性,其中各项功能都有标注是来自Document这个class的method,因此未来在维护时也很方便辨识。

希望大家都能抓到个概念~

延伸阅读

BrewHouseRailsCast(付费)