Larry’s Blog

如何重构 Rails 项目

| Comments

Am I doing it wrong?

前天在 GitHub Gist 上看到一个有趣的讨论

事情的起源是 @justinko 因为自己写的一段代码而失去了一份工作合约。那么究竟是什么样的代码「惹火」了雇主呢?

在那样项目中,当用户发表评论后,后台需要做的不仅仅是在数据库中插入一条评论记录,还需要识别出评论的语言、检查是否是垃圾评论、发送邮件以及同步到 Twitter 和 Facebook。@justinko 觉得这些代码既不应该属于 controller 也不应该属于 User model, 也不应该用 ActiveRecordcallbacks 来实现。

于是他新建了一个 PORO (Plain Old Ruby Object) 来处理这些逻辑。代码如下:

post_comment.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# app/use_cases/post_comment.rb
# Called from the "create" action in a controller

class PostComment
  def initialize(user, entry, attributes)
    @user = user
    @entry = entry
    @attributes = attributes
  end

  def post
    @comment = @user.comments.new
    @comment.assign_attributes(@attributes)
    @comment.entry = @entry
    @comment.save!

    LanguageDetector.new(@comment).set_language
    SpamChecker.new(@comment).check_spam
    CommentMailer.new(@comment).send_mail

    post_to_twitter  if @comment.share_on_twitter?
    post_to_facebook if @comment.share_on_facebook?

    @comment
  end

  private

  def post_to_twitter
    PostToTwitter.new(@user, @comment).post
  end

  def post_to_facebook
    PostToFacebook.new(@user, @comment).action(:comment)
  end
end

也就是说 justinko 把发表评论及之后的逻辑都从 controller 中抽离出来放到了一个 PORO 中处理,这样每次用户发表评论,只需要从 controller 里调用 PostComment.new(user, entry, attributes).post 即可。

而他的雇主,也就是因为这些代码解雇了 justinko 的 senior developer 对这段代码是这样评价的:

这只是简单的把逻辑从 controller 里移到了一个新的类中,并且需要添加更多的代码来维护它。当需要从多个 controller 中发表评论时有用,但是我们并没有这种情况。

然后在该 gist 的评论里,Rubyists(包括 Sidekiq 的作者 mperham 以及 garybernhardt 等社区有名的 coder) 除了表示对楼主的同情外都觉得楼主抽离逻辑到一个新类的做法很好,顺带鄙视了下那位 senior developer。直到 rGenta 觉得楼主的观念是对的,但是实现很烂,把这个称之为 SRP(Single Responsibility Principle) 简直就是个冷笑话。

最后 @dhh 给出了他的解法

dhh_solution.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PostsController
  before_filter :set_entry
  before_filter :reject_spam

  def create
    @comment = @entry.comments.create!(params[:post].permit(:title, :body).merge(author: current_user))

    Notifications.new_comment(@comment).deliver

    TwitterPoster.new(current_user, @comment.body).post              if @comment.share_on_twitter?
    FacebookPoster.new(current_user, @comment.body).action(:comment) if @comment.share_on_facebook?
  end

  private
    def set_entry
      @entry = current_account.entries.find(params[:id])
    end

    def reject_spam
      head :bad_request if SpamChecker.spammy?(params[:post][:body])
    end
end

dhh 的做法初看确实要比楼主的解法稍好一些:没有把邮件通知、twitter/fb 分享的逻辑统统放在一起,而是由相应的 model (PORO/AR) 单独来解决。后来有人说如果发表评论后需要做10件不同的事情呢, controller 不就会变得越来越臃肿吗? dhh 回应:别总拿如果说事,等到那天来了的时候再做抽象也不迟。

dhh 这里指的抽象应该就是楼主最初的做法,也就是将复杂的逻辑从 controller 中抽象出来做为 Service Object。

Concerns && Service Object

DHH 前些天写了篇新文章介绍在 Basecamp 项目中的实践。不同的的 AR Model 可能会有很多相同的功能,比如支持打标签,那么就可以新建一个 concern module 来共享标签类功能:

taggable.rb
1
2
3
4
5
6
7
8
9
10
11
12
module Taggable
  extend ActiveSupport::Concern

  included do
    has_many :taggings, as: :taggable, dependent: :destroy
    has_many :tags, through: :taggings
  end

  def tag_names
    tags.map(&:name)
  end
end

这样支持标签标注的 AR Model 就可以通过 mixin Taggable module来共享标签标注的逻辑,代码只需在一处维护,很好的做到了 DRY (Don’t Repeat Yourself).

编写 Rails 代码有个观点就是:Fat Model, Thin Controller。 但随着 domain logic 的增长,model 会变得越来越臃肿,这时可以通过从中抽象出若干 concern 来给 model 瘦身。Rails 4 默认加载 app/models/concernsapp/controllers/concerns 路径下的 concern module。在 Rails 3 中,只需在 config/application.rb 中添加一行代码即可:

config/application.rb
1
config.autoload_paths += %W(#{config.root}/app/models/concerns)

DHH 发表完这篇文章的当天,Ryan Bates 录制了一期 RailsCasts 介绍 Service Object,实际指的就是 DHH 所说的 concern module

umeng.com 重构实践

友盟统计平台需要给用户展现大量的移动应用统计数据,随着友盟支持越来越多的统计指标,造成对数据的查询以及交叉查询越来越多且复杂,所以最初的项目代码已经慢慢变成了 Fat Model, Fat Conotroller

所以我们在重构的时候,便在 Model 和 Controller 中间添加了一层结构,我们给起了个更符合我们需要的名称 Metric。Metric 层接收 controller 传来的数据请求逻辑,然后从 model 中取数据,组织成 controller 需要的数据格式并返回给 controller。

在给 model 重构的过程依然保留 model 自身的 scope query 方法(of course),抽象到 Metric 层的大多为不同指标数据组织在一起/或者需要交叉查询的逻辑。而对于 controller 的重构就像 justinko 把发表评论这类的逻辑抽离出来一样,把复杂的逻辑都交给相应的 metric 来处理,从而达到 Thin Model, Thin Controller

现在来看,虽然我们并没有用到 concerns,但是与 Service Object 做的异曲同工。

Conclusion

使用 Service Object / Concern Module, 可以使你的 Rails 项目不再臃肿,重构变得很简单。

相关链接:

Comments