重构 DDD 4 Rails Developers.Part 1 : Layered Architecture

匿名 · 2014年01月26日 · 最后由 Turristan 回复于 2014年06月22日 · 10298 次阅读

题外话

这篇是公司里的怪叔叔殴打小朋友的产物,自此翻译,简而言之,接下来的两篇我是没打算翻译的。

正文 (高能预警,无脑直译最无聊.)

为什么不支持二级标题?

DDD 是神马东西?

(题外话:DDT 污染影响延续至今,请诸位关注生态.) DDD, 全称 Domain-Driven Design. 可以毫无想象力直译为领域驱动设计 ( 很装 X 的说法).

开发软件时,总是要处理层出不穷的复杂度的问题。所谓家家都有难念的经,家境一般的女孩子想嫁豪门,豪门家的女孩子确是 Lesbian. 每个应用都都有各自的奇葩到匪夷所思的问题。比如说,假如要打造次时代的 Twitter, 五毛人士自行脑补 Weibo, 可拓展性及容错机制就是你要推倒的两座大山。但是开发企业应用的人士对这两座高峰从来都是"呵呵"一笑而过的,让他们头疼的是因琐碎而复杂,因复杂而更让人头疼的业务流程。许多公司的业务流程复杂到令人发指。水涨船高命中注定般的,梳理问题域也需要极其丰富的经验和知识,才能使应用低耦合化,最终提供灵活易维护的服务,

特别推荐

Eric Evans, 《领域驱动设计》的作者,开创了许多应对处理领域复杂度的术语及经验做法,《领域驱动设计》一书也是从事企业应用开发的必读书。我 (Victor Savkin, 一下不再赘述) 特别推荐此书。

DDD&&Rails

在从事开发诸多大型 Rails 应用的过程中,我 (很中二地) 察觉到 DDD 和 Rails 之间有许多互为悖逆之处。因此我决定替天行道,撰写天书三篇,以警世人。切记要取中庸之道,调和二者,而非取一舍它。

在此,我很想介绍一下将 DDD 引入实际应用的经验,"可惜这里空白的地方太小,写不下". 作为秘鲁上升补偿流,我提供一锦囊秒计,Check it Out.

人生何处不相逢啊。人生处处是妥协。本天书也不例外。中二啊说错了,中庸才是王道。

分层架构

DDD 的一个核心概念就是分层架构。简单介绍一下它的益处以及典型的 Rails 应用与之相违之处。

顾名思义,分层架构意味着你需要将应用架构分层:

  • 展现层 (User Interface). 展示信息,提供交互界面。参照 Rails View 部分。
  • 应用层 (Application Layer). "瘦瘦"的衔接层,用于逻辑处理,参照 Rails Controller 部分。
  • 领域层 (Domain Layer). 抽象化业务概念,参照 Rails Model 部分。
  • 基础层 (Infrastructure Layer). 持久化服务,消息传递等通用服务。参照 Rails Concerns&&Helpers&&Service&&Proxy 部分。

分层架构的刺客信条是 每层只能依赖于下面的层次。依赖方向只是统一向下的。举例来说,领域层可以依赖于基础层,但却不能依赖于应用层。

分层架构&&Rails

典型 Rails 应用中也有诸多违反分层架构的信条的地方。譬如:

  • 域对象 (Domain objects, 妹夫的,说这么悬,就是 Model 实例) 可以自序列化为 JSON 或者 XML. 在我看来,无论是以 JSON 还是 Html 来展示域对象,都无所谓。两者都应是展现层的工作。因此每次你重写as_json方法时,你都违反了刺客信条:你违逆了信条规定的依赖方向。领域层受制于展现层。(注:为了展现的特定需要而改写领域层.)
  • 控制器 (Controller) 包含成吨的逻辑代码。这个实在是数不胜数,通常就是调用领域层 (Model) 的对象,展现,获得修改,然后使用底层的update_attributes方法保存 (中二的说法:持久化). 下次你在 Controller 里想调用update_attributes时,为了世界的和平与安宁,请驻足三思。幸好,许多 Rails 开发者已经意识到了这将使 Controller 变得难以维护。我坚信,即使是小小的玩具应用也没有借口继续这种丧心病狂的行为。
  • 域对象提供了诸多基础层的方法。不要写 Shit Mountain 一样的 Controller, 也不要试图实现瑞士军刀一样万能的 Model, 因为万能的 Model 很难简短,实际上就是 Shit Mountain. 这样的万能 Model 除了描述业务,还能调用远程服务,生成 pdf, 或者发送邮件。如果遇到这样的万能 Model, 施主还是劝代码作者放下屠刀吧,快快将业务描述和其他实用方法分离开。不然代表月亮消灭你。
  • 域对象和数据库耦合度过高。如果你扩展 (extend)ActiveRecord 实现了 model 类的话,恭喜你你成功的将领域层和基础层耦合在一起。该如何测试?(好在 ActiveRecord 值得信赖.)

以上问题将由我替天行道,一一解决。

分离 JSON 序列化功能

不要在 Model 中重写as_json及同类方法。Model 的职责只是描述业务概念。老夫纵横江湖多年,从未在业务逻辑里看到 JSON 这一字眼。所以请将 JSON 之类的的方法移出 Model.

譬如,有这样一个 action

def update
  p = Person.find(params[:id])
  if p.update_bank_information params[:bank_information]
    render json: p.as_json
  else
    render json: "error"
  end
end

如果你想自定义 JSON 序列结果,请不要在 Person 类里实现,请另建一个 module, 单一职责法则。

module PersonJsonSerializer
  def self.as_json person
    if person.errors.present?
      person.as_json(root: "root-attrs")
    else
      {errors: person.errors.full_messages}
    end
  end
end

更改 controller.

def update
  p = Person.find(params[:id])
  p.update_bank_information params[:bank_information]
  render json: PersonJsonSerializer.as_json(p)
end

这样做的优点是:

  • 领域层不再依赖展现层
  • 不违反单一职责法则
  • 更易于测试。if-else 可是要写两个测试的。什么,PersonJsonSerializer? 可以用 stub 伪造函数返回结果的。

从 Controller 中剥离逻辑代码

简而言之,Controller 的职责是处理用户输入数据和 (render) 呈现正确的视图 (view). 其余的业务逻辑代码请移至领域层。但这可能写成万能 Model, 两难之下,坚决选择有中国社会主义特色的中庸道路。把它丢到一个 Service 类里面吧。

比如,有以下的一个 Action.

def sell_book
  @book = Book.find(params[:id])
  if book.sold?
    bokk.errors.add :base, "Already sold"
  else
    book.sell
  end
end

更好的方法是:

def sell_book
  @book = Book.find(params[:id])
  BookSellingService.sell_book(@book)
end

优点是:

  • 易于测试
  • 业务逻辑更为清晰

抽象化域对象

一句话,Model 类内部禁止直接调用外部服务。举个博客引擎的例子,具体需求是发表一篇文章时自动同步到 Tweet. 最直接的方法是用 (after_create) 钩子函数。不得不吐槽一句,钩子函数这直译的叫人心碎。

class Post < ActiveRecord::Base
  has_many :comments
  after_create :send_tweet

  def send_tweet
    twitter = Twitter.login(username, password)
    twitter.send_tweet generate_tweet_from_subject(subject)
  end
......
end

尽管这用几行代码,但却相关的缺点却很多。

  1. 在 Post 测试中伪造 Twitter 服务的返回结果
  2. 违背了单一职责信条。保存 Post 数据和发推特很显然不是一回事。
  3. 假如推特被墙了

解耦合的方法有很多,一种方法是使用观察者模式。

class Post < ActiveRecord::Base
  has_many :comments
end

class TwitterNotification < ActiveRecord::Observer
  observe :post

  def after_create post
    twitter = Twitter.login(username, password)
    twitter.send_tweet generate_tweet_from_subject(post.subject)
  end
end

依据单一职责信条,还可进一步的改为:

class TwitterNotification < ActiveRecord::Observer
  observe :post

  def after_create post
    TwitterService.send_tweet post.subject
  end
end

class TwitterService
  def self.send_tweet subject
    twitter = Twitter.login(username, password)
    twitter.send_tweet generate_tweet_from_subject(subject)
  end
  ...
end

优点是:

  • 易于测试
  • 明确业务逻辑的边界
  • 灵活多变。推特墙了,转发微博就行了。

剥离 ActiveRecord

谦虚点讲,这即使是对于我,这也是个不可能的任务。人们早已经习惯了 Model 与 ActiveRecord 的耦合。但是,Model 本应该只是数据库模式的抽象,而不影响我们设计实体 (译者:ActiveRecord 提供了许多方便的动态方法,从而潜移默化的使开发者为了能使用这些方法而去设计应用).

至少在没有数据库的情况下可以测试你的 Model.

伟大的 DDD 教育我们要将分离持久化模块 (这么中二的词,无力吐槽).

class PostsRepository
  def find_by_id id
    ...
  end

  def new_posts_of_author author
    ...
  end

  def save post
    ...
  end
end

这样分离可以简化分离,并且可以灵活切换不同的接口实现。简单的来说可以。普遍的做法是提供 (SQLPostsRepository) 和 (InMemoryPostsRepositoty). 前者可以用作集成测试,后者用作单元测试。但这是个真实的世界,你怎么可能不用 ActiveRecord. 天知道,ActiveRecord 是伟大。我只能无奈走具有中国特色社会主义的中庸大道了。

只能妥协。

module PostsRepository
  def new_posts_of_author author
    ...
  end
end

class Post < ActiveRecord::Base
  extend PostsRepository
end

Post.new_posts_of_author "Jim"

优点:

  • 业务和持久化服务一定程度地解耦合。
  • 易于测试。(mock&&stub)
  • 运行时动态切换代码实现。(Post.extend InMemoryPostsRepository)

总结

处理领域复杂度是个艰难的问题。复杂度随应用规模增长而增长。在玩具应用中或许可以将代码堆成一座 Shit Mountain, 但在规模较大的应用中,估计会崩溃。假如遇到代码失控的情况,试试 DDD 分层架构吧。

但是,分层架构只是 DDD 的冰山一角而已。实体&&值,服务&&工厂,聚合根,领域边界,发腐层等等。理解并应用这些概念和信条对 Rails 开发者很有帮助。我会围绕这个主题继续写两篇博客。

(但我不会翻译了)

Cool, 很赞的文章,系列文章的第一篇,请继续翻译第二、第三篇。

但,你会继续翻译的

匿名 #4 2014年01月26日

#2 楼 @OneMagicAnt 卡尔真心难玩。我最喜欢船长和发条。

“秘鲁上升补偿流”?楼主你是文科生么?

匿名 #6 2014年01月26日

#5 楼 @fenprace 不是。这个不是常识吗?

#6 楼 @Turristan 呃,直到高一地理课上才知道这个东西。

想起个八卦 http://martinfowler.com/eaaCatalog/

Many of these diagrams demonstrate the rather poor GIF output of Visio. The nice diagrams were redrawn for me by David Heinemeier Hansson

匿名 #9 2014年01月26日

#7 楼 @fenprace 厄尔尼诺和拉尼娜。不觉得地球很神奇吗?

要是非这么写的话,倒不如直接用 Java 了……

匿名 #11 2014年01月26日

#10 楼 @wuwx 直接惊呆了!

#11 楼 @Turristan 为啥惊呆了……

匿名 #13 2014年01月26日

#12 楼 @wuwx 为啥用 Java?

设计模式与架构模式会受语言特性的影响而变得不同 Ruby 与 Java 语言的不同,使得 Java 的模式不一定适用于 Ruby Rails 的特点就是肥大的 Model,模型自己去验证维护与其相关的东西 如果真要搞那么多层的话,Model 的 validates 都该独立出去了 那对 Rails 来说可真不是个好事情呢……

匿名 #15 2014年01月26日

#14 楼 @wuwx 高人高见; 教训的是。

感觉就是在用 Java 的方式来写 ruby 代码

这样的万能 Model 除了描述业务,还能调用远程服务,生成 pdf, 或者发送邮件。如果遇到这样的万能 Model, 施主还是劝代码作者放下屠刀吧,快快将业务描述和其他实用方法分离开。不然代表月亮消灭你。

跪了,我真写过这个的 Model, 远程调用 OpenOffice 去生成 PDF, 和发送邮件

我对 DDD 的理解是

Deadline Driven Development

#19 楼 @PrideChung

咦..不是 Documentation Driven Development 嘛..

21 楼 已删除

翻译的很好

为什么要用 Rails 来实现这个东西?

匿名 #24 2014年04月15日

#23 楼 @simlegate 确切来说,是为了让 Rails 更具美感。

#14 楼 @wuwx 其实这篇文章里很多人都没看懂原文作者为什么要这么做。

一切是为了容易测试,这才是做的这么麻烦的原因,一个胖对象试问你要怎么测试他?无数个逻辑跟在 after create 之后难道真的好吗?职责不清晰,耦合紧密这样真的好吗?

易于测试的项目,往往维护也更加容易,所以现在有种说法叫做可测试性驱动开发 testable Driven development。

所以我从来都不觉得这是 ruby 的写法或者 java 写法的区别,好的代码追求的目标总是一致的,逻辑清晰,易于测试,冗余最少。

Why you should think about TOOP- Testable Object Oriented Programming http://osherove.com/blog/2007/2/25/why-you-should-think-about-toop-testable-object-oriented-pro.html

#24 楼 @Turristan 我觉得 Rails 美感体现在快速开发,少量代码上~

你现在公司在应用这个了吗?

匿名 #28 2014年06月22日

#27 楼 @ShiningRay 之前的公司,在我个人负责的模块,的确用过,但推广很难。先设计、再测试、最后编码,这种用户体验不太好。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号