写点什么

演进架构中的领域驱动设计

2009 年 9 月 21 日

领域驱动设计能非常容易地应用于稳定领域,其中的关键活动适合开发人员对用户脑海中的内容进行记录和建模。但在领域本身不断变化和发展的情况下,领域驱动 设计变得更具有挑战性。这在敏捷项目中很普遍,在业务本身试图演进的时候也会发生。本文分析了在反思、重建 guardian.co.uk 这一为期两年的计 划背景下我们是如何利用 DDD 的。我们展示了如何确保在软件架构中反映最终用户演变的认知,以及怎样实现该架构来保证以后的变化。我们提供了模型中重要项 目过程、具体演进步骤的细节。

顶层标题:

  1. 计划背景
  2. 从 DDD 开始
  3. 增量计划中的 DDD 过程
  4. 进化的领域模型
  5. 代码级别的演进
  6. 演进架构中 DDD 的一些教训
  7. 附录:具体示例

1. 计划背景

Guardian.co.uk 有相当长的新闻、评论、特性历史记录,目前已拥有超过 1800 万的独立用户和每月 1.8 亿的页面访问量。在此期间的大部分时间,网站都运行在原始的 Java 技术之上,但在 2006 年 2 月开始了一项重要的工作计划——将网站移植到一个更现代的平台上去,计划的最初阶段于 2006 年 11 月推出,当时推出了具有新外观的旅游网站,接着在 2007 年 5 月推出新的主页,之后相继推出了更多内容。尽管在 2006 年 2 月仅有少数几个人在做这项工作,但后来团队最多曾达到了 104 人。

然而,计划的动机远不止是要一个新外观。多年的经验告诉我们有更好的办法来组织我们的内容,有更好的方式将我们的内容商业化,以及在此背后,还有更先进的开发方法——这才是关键之所在。

其实,我们考虑工作的方式已超越了我们软件可以处理的内容。这就是为什么 DDD 对我们来说如此有价值。

遗留软件中阻碍我们的概念不匹配有两个方面,我们先简单看一下这两方面问题,首先是我们的内部使用者,其次是我们的开发人员。这些问题都需要借助 DDD 予以解决。

1.1. 内部使用者的问题

新闻业是一个古老的行业,有既定的培训、资格和职制,但对新闻方面训练有素的新编辑来说,加入我们的行列并使用 Web 工具有效地工作是不可能的,尤其在刚来的几个月里。要成为一个高效的使用者,了解我们 CMS 和网站的关键概念还远远不够,还需要了解它们是如何实现的。

比如说,将缓存内容的概念(这应该完全是系统内部的技术优化)暴露给编辑人员;编辑们要将内容放置在缓存中以确保内容已准备好,还需要理解缓存工作流以用 CMS 工具诊断和解决问题。这显然是对编辑人员不合理的要求。

1.2. 开发人员的问题

概念上的不匹配也体现在技术方面。举例来说,CMS 有一个概念是“制品”,这完全是所有开发人员每天工作的核心。以前团队中的一个人坦言,在足足九个月之后他才认识到这些“制品”其实就是网页。围绕“制品”形成的含义模糊的语言和软件越来越多,这些东西让他工作的真正性质变得晦涩难懂。

再举一个例子,生成我们内容的 RSS 订阅特别耗时。尽管各个版块的主页都包含一个清晰的列表,里面有主要内容和附加内容,但底层软件并不能对两者予以区分。因此,从页面抽取 RSS 订阅的逻辑是混乱的,比如“在页面上获取每个条目,如果它的布局宽大约一半儿、长度比平均长度长一些,那它可能是主要内容,这样我们就能抽取链接、将其作为一个订阅”。

很显然,对我们来说,人们对他们工作(开始、网页和 RSS 订阅)及其如何实现(缓存工作流、“制品”、混乱逻辑)的认识之间的分歧给我们的效益造成了明显而惨重的影响。

2. 从 DDD 开始

本部分阐述了我们使用 DDD 的场景:为什么选择它,它在系统架构中所处的位置,还有最初的领域模型。在后面的章节中,我们会看一下如何把最初的领域知识传播给扩充的团队,如何演进模型,以及如何以此为中心来演进我们的编码技术。

2.1. 选择 DDD

DDD 所倡导的首要方面就是统一一致的语言,以及在代码中直接体现用户自己的概念。这能有效地解决前面提及的概念上的不匹配问题。单独看来,这是一个有价值的见解,但其本身价值或许并不比“正确使用面向对象技术”多很多。

使其深入的是 DDD 引入的技术语言和概念:实体、值对象、服务、资源库等。这确保了在处理非常大的项目时,我们的大型开发团队有可能一致地进行开发——长远来看,这对维护质量是必不可少的。甚至在废弃我们更底层的代码时(我们稍后会进行演示),统一的技术语言能让我们恢复到过去、改进代码质量。

2.2. 在系统中嵌入领域模型

本节显示了 DDD 在整个系统架构中的地位。

我们的系统逐渐建立了三个主要的组件:渲染应用的用户界面网站;面向编辑、用于创建和管理内容的应用程序;跟系统交互数据的供稿系统。这些应用都是基于 Spring 和 Hibernate 构建的 Java Web 应用,并使用 Velocity 作为我们的模板语言。

我们可以看下这些应用的布局:

Hibernate 层提供数据访问,使用 EHCache 作为 Hibernate 的二级缓存。模型层包含领域对象和资源库,服务则处于其上一层。在此之上,我们有 Velocity 模板层,它提供页面渲染逻辑。最顶层则包含控制器,是应用的入口点。

看一下这个应用的分层架构,仅仅将模型当做是应用的一个自包含的层是很有意思的。这个想法大致正确,但模型层和其它层之间还是有一些细微的差别:由于我们使用领域驱动设计,所以我们需要统一语言,不仅要在我们谈论领域时使用,还要在应用的任何地方使用。模型层的存在不仅是为了从渲染逻辑中分离业务逻辑,还要为使用的其它层提供词汇表。

另外,模型层可作为代码的独立单元进行构建,而且可以作为 JAR 包导入到依赖于它的许多应用中。其它任何层则不是这样。对构建和发布应用来说,这意味着:在我们基础设施的模型层改变代码一定是跨所有应用的全局变化。我们可以在前端的网站中改变 Velocity 模板,只用部署前端应用就可以,管理系统或供稿系统则不用。如果我们在领域模型中改变了一个对象的逻辑,我们必须更新所有依赖于模型的应用,因为我们只有(而且期望只有)一个领域视图。

这有一种危害,就是领域建模的这种方法可能会导致单一模型,如果业务领域非常庞大,改变起来的代价会非常昂贵。我们认识到了这一点,因此随着领域不断增长,我们必须确保该层没有变得太过笨重。目前,虽然领域层也是相当庞大和复杂的,但是这还没有带来什么问题。在敏捷环境中工作,我们希望无论如何每两周都要推出所有系统的最新变化。但我们持续关注着该层代码改变的成本。如果成本上升到不能接受的程度,我们可能会考虑将单一模型细分成多个更小的模型,并在每个子模型之间给出适配层。但是我们在项目开始时没有这样做,我们更偏向于单一模型的简单性,而不是用多个模型时必须解决的更为复杂的依赖管理问题。

2.3. 早期的领域建模

在项目初期,大家在键盘上动手开始编写代码之前,我们就决定让开发人员、QA、BA、业务人员在同一个房间里一起工作,以便项目能够持续。在这个阶段我们有一个由业务人员和技术人员组成的小型团队,而且我们只要求有一个稳妥的初次发布。这确保了我们的模型和过程都相当简单。

我们的首要目标是让编辑(我们业务代表的关键组成部分)就项目最初迭代的期望有一个清楚的认识。我们跟编辑们坐在一起,就像一个整体团队一样,用英语与他们交流想法,允许各个功能的代表对这些想法提出质疑和澄清,直到他们认为我们正确理解了编辑需要的内容。

我们的编辑认为,项目初期优先级最高的功能是系统能生成网页,这些网页能显示文章和文章分类系统。

他们最初的需求可归纳为:

  • 我们应该能将一篇文章和任何给定的 URL 关联起来。
  • 我们要能改变选定的不同模板渲染结果页面的方式。
  • 为了管理,我们要将内容纳入宽泛的版面,也就是新闻、体育、旅游。
  • 系统必须能显示一个页面,该页面包含指向特定分类中所有文章的链接。

我们的编辑需要一种非常灵活的方式来对文章进行分类。他们采用了基于关键字的方法。每个关键字定义了一个与内容相关的主题。每篇文章可以和很多关键字关联,因为一篇文章可以有很多主题。

我们网站有很多编辑,每个人负责不同版块的内容。每个版块都要求有自己导航和特有关键字的所有权。

从编辑使用的语言来看,我们似乎在领域中引入了一些关键实体:

  • 页面 URL 的拥有者。负责选择模板来渲染内容。
  • 模板 页面布局,任何时候都有可能改变。技术人员将每个模板实现为磁盘上的一个 Velocity 文件。
  • 版块 页面更宽泛的分类。每个版块有一个编辑,还有对其中页面共同的感官。新闻、旅游和商业都是版块的例子。
  • 关键字 描述存在于版块中主题的方式。关键字过去用于文章分类,现在则驱动自动导航。这样它们将与某个页面关联,关于给定主题所有文章的自动页面也能生成。
  • 文章 我们能发布给用户的一段文本内容。

提取这些信息后,我们开始对领域建模。项目早期做出的一个决定:编辑拥有领域模型并负责设计,技术团队则提供协助。对过去不习惯这种技术设计的编辑来说,这是相当大的转变。我们发现,通过召开由编辑、开发人员、技术架构师组成的研习会,我们能用简单、技术含量较低的方法勾画出领域模型,并对它持续演进。讨论模型的范围,使用钢笔、档案卡和 Blu-Tak 绘制备选解决方案。每个候选模型都会进行讨论,技术团队把设计中的每个细节含义告诉给编辑。

尽管过程在最初相当缓慢,但很有趣。编辑发现这非常容易上手;他们能信手拈来、提出对象,然后及时从开发人员那里获得反馈,以知道生成的模型是否满足了他们的需求。对于编辑们能在过程中迅速掌握技术的要领,技术人员都感到很惊喜,而且所有人都对生成的系统是否能满足客户需求很有信心。

观察领域语言的演变也很有趣。有时文章对象会被说成是“故事”。显然对于同一实体的多个名称,我们并没有一种统一语言,这是一个问题。是我们的编辑发现他们描述事物时没有使用统一的语言,也是他们决心称该对象为文章。后来,任何时间有人说“故事”,就会有人说:“你的意思不是文章吗?”在设计统一语言时,持续、公共的改进过程是种很强大的力量。

我们的编辑最初设计生成的模型是这样的:

[网页及其版块之间的关系由应用于文章的关键字导出,文章是网页中的核心内容。网页的版块由应用于文章的首个关键字的版块确定。]

由于并非所有的团队成员都参与了该过程的所有阶段,所以我们需要向他们介绍工作进展,并将这些全部展现在了墙上。然后开发人员开始了敏捷开发之旅,而且由于编辑和开发人员、BA、QA 都在一起工作,任何有关模型及其意图的问题都能在开发过程的任何时候获得第一手的可靠信息。

经过几次迭代之后系统初具形态,我们还建立工具来创建和管理关键字、文章和页面。随着这些内容的创建,编辑们很快掌握了它们,并且给出了一些修改建议。大家普遍认为这一简单的关键模型能正常运行,而且可以继续下去、形成网站初次发布的基础。

3. 增量计划中的 DDD 过程

首次发布之后,我们的项目团队伴随着技术人员和业务代表们的成长,一起取得了进步,打算演进领域模型。很显然,我们需要一种有组织的方式来为领域模型引入新的内容、进行系统的演进。

3.1. 新员工入门

DDD 是入门过程中的核心部分。非技术人员在整个项目生命周期内都会加入项目,因为工作计划是横跨各个编辑领域的,反过来,在恰当的时机我们也会引入版面编辑。技术人员很容易就能加入项目,因为我们持续不断地雇用新员工。

我们的入门过程包括针对这两类人的 DDD 讲习,尽管细节不同,但高层次的议题涵盖两个相同的部分:DDD 是什么,它为什么重要;领域模型本身的特定范围。

描述 DDD 时我们强调的最重要的内容有:

  • 领域模型归业务代表所有。这就要从业务代表的头脑里抽象概念,并将这些概念嵌入到软件中,而不能从软件的角度思考,并试图影响业务代表。
  • 技术团队是关键的利益相关者。我们将围绕具体细节据理力争。

覆盖领域模型的特定范围本身就很重要,因为它予以就任者处理项目中特定问题的真正工具。我们的领域模型中有几十个对象,所以我们只关注于高级别和更为明显的少数几个,在我们的情况中就是本文所讨论的各种内容和关键字概念。在这里我们做了三件事:

  • 我们在白板上画出了概念及其关系,因此我们能给出系统如何工作的一个有形的表示。
  • 我们确保每位编辑当场解释大量的领域模型,以强调领域模型不属于技术团队所有这一事实。
  • 我们解释一些为了达到这一点而做出的历史变迁,所以就任者可以理解 (a) 这不是一成不变的,而是多变的,(b) 为了进一步开发模型,他们可以在即将进行的对话中扮演什么样的角色。

3.2. 规划中的 DDD

入门是必不可少的,不过在开始计划每次迭代的时候,知识才真正得以实践。

3.2.1 使用统一语言

DDD 强制使用的统一语言使得业务人员、技术人员和设计师能围坐在一起规划并确定具体任务的优先次序。这意味着有很多会议与业务人员有关,他们更接近技术人员,也更加了解技术过程。有一位同事,她先担任项目的编辑助理,然后成长为关键的决策者;她解释说,在迭代启动会议上她亲自去看技术人员怎样判断和(激烈地)评估任务,开始更多地意识到功能和努力之间的平衡。如果她不和技术团队共用一种语言,她就不会一直出席会议,也不会收获那些认识。

在规划阶段利用 DDD 时我们使用的两个重要原则是:

  1. 领域模型归属于业务;
  2. 领域模型需要一个权威的业务源。

领域模型的业务所有权在入门中就进行了解释,但在这里才发挥作用。这意味着技术团队的关键角色是聆听并理解,而不是解释什么可能、什么不可能。需求抽象要求将概念性的领域模型映射到具体的功能需求上,并在存在不匹配的地方对业务代表提出异议或进行询问。接着,存在不匹配的地方要么改变领域模型,要么在更高层次上解决功能需求(“你想用此功能达成什么效果?”)。

对领域模型来说,权威的业务源是我们组织的性质所明确需要的。我们正在构建一个独立的软件平台,它需要满足很多编辑团队的需求,编辑团队不一定以同样的方式来看世界。Guardian 不实行许多公司实行的“命令和控制”结构;编辑台则有很多自由,也能以他们认为合适的方式去开发自己的网站版面,并设定预期的读者。因此,不同的编辑对领域模型会有略微不同的理解和观点,这有可能会破坏单一的统一语言。我们的解决办法是确定并加入业务代表,他们在整个编辑台都有责任。对我们来说,这是生产团队,是那些处理日常构建版面、指定布局等技术细节的人。他们是文字编辑依赖的超级用户,作为专家工具建议,因此技术团队认为他们是领域模型的持有者,而且他们保证软件中大部分的一致性。他们当然不是唯一的业务代表,但他们是与技术人员保持一致的人员。

3.2.2 与 DDD 一起计划的问题

不幸的是,我们发现了在计划过程中应用 DDD 特有的挑战,尤其是在持续计划的敏捷环境中。这些问题是:

  1. 本质上讲,我们正在将软件写入新的、不确定商业模式中;
  2. 绑定到一个旧模型;
  3. 业务人员“入乡随俗”。

我们反过来讨论下这些问题……

Eric Evans 撰写关于创建领域模型的文章时,观点是业务代表的脑海中存在着一个模型,该模型需要提取出来;即便他们的模型不明确,他们也明白核心概念,而且这些概念基本上能解释给技术人员。然而在我们的情况中,我们正在改变我们的模型——事实上是在改变我们的业务——但并不知道我们目标的确切细节。(马上我们就会看到这一点的具体例子。)某些想法显而易见,也很早就建立起来了(比如我们会有文章和关键字),但很多并不是这样(引入页面的想法还有一些阻力;关键字如何关联到其它内容则完全是各有各的想法)。我们的教科书并没有提供解决这些问题的指南。不过,敏捷开发原则则可以:

  • 构建最简单的东西。尽管我们无法在早期解决所有的细节,但通常能对构建下一个有用的功能有足够的理解。
  • 频繁发布。通过发布此功能,我们能看到功能如何实际地运转。进一步的调整和进化步骤因此变得最为明显(不可避免,它们往往不是我们所预期的)。
  • 降低变化的成本。利用这些不可避免的调整和进化步骤,减少变化的成本很有必要。对我们来说这包括自动化构建过程和自动化测试等。
  • 经常重构。经过几个演进步骤,我们会看到技术债务累积,这需要予以解决。

与此相关的是第二个问题:与旧模型有太多的精神联系。比如说,我们的遗留系统要求编辑和制作人员单独安排页面布局,而新系统的愿景则是基于关键字自动生成页面。在新系统里,Guantánamo Bay 页面无需任何人工干预,许多内容会给出 Guantánamo Bay 关键字,仅简单地凭借这一事实就能显示出来。但结果却是,这只是由技术团队持有的过度机械化的愿景,技术团队希望减少体力劳动和所有页面的持续管理。相比之下,编辑人员高度重视人的洞察力,他们带入过程的不仅有记述新闻,还有展现新闻;对他们来说,为了突出最重要的故事(而不仅仅是最新的),为了用不同的方法和灵敏性(比如 9·11 和 Web 2.0 报导)区别对待不同的主题,个人的布局是很有必要的。

对这类问题没有放之四海而皆准的解决办法,但我们发现了两个成功的关键:专注于业务问题,而不是技术问题;铭记“创造性冲突”这句话。在这里的例子里,见解有分歧,但双方表达了他们在商业上的动机后,我们就开始在同一个环境里面工作了。该解决方案是创造性的,源于对每个人动机的理解,也因此解决了大家了疑虑。在这种情况下,我们构建了大量的模板,每个都有不同的感觉和影响等,编辑可以从中选择并切换。此外,每个模板的关键区域允许手动选择显示的故事、页面的其余部分自动生成内容(小心不要重复内容),对于该手动区域,如果管理变得繁重,就可以随时关闭,从而使网页完全自动化。

我们发现的第三个挑战是随着业务人员“入乡随俗”,也就是说他们已深深融入技术且牵涉到了要点,以至于他们会忘记对新使用系统的内部用户来说,系统该是什么样。当业务代表发现跟他们的同事很难沟通事情如何运转,或者很难指出价值有限的功能时,就有危险的信号了。Kent Beck 在《解析极限编程》第一版中说,现场客户与技术团队直接交互绝不会占用他们很多的时间,通过强调这一点,就可以保证在现场的客户。但我们在与有几十个开发人员、多名 BA、多名 QA 的团队一起工作时,我们发现即使有三个全职的业务代表,有时也是不够的。由于业务人员花费了太多的时间与技术人员在一起,以至他们与同事失去联系成为真正的问题。这些都是人力解决方案带来的人力问题。解决方案是要提供个人备份和支持,让新的业务人员轮流加入团队(可能从助理开始着手进行,逐步成长为关键决策角色),允许代表有时间回归他们自己的核心工作,比如一天、一周等。事实上,这还有一个附加的好处,就是能让更多的业务代表接触到软件开发,还可以传播技巧和经验。

4. 进化的领域模型

在本章中,我们看看模型在方案的后期是如何演进的。

4.1. 演进第一步:超越文章

首次发布后不久,编辑要求系统能处理更多的内容类型,而非只有文章一类。尽管这对我们来说毫不稀奇,但在我们构建模型的第一个版本时,我们还是明确决定对此不予考虑太多。

这是个关键点:我们关注整个团队能很好地理解小规模、可管理块中的模型和建模过程,而不是试图预先对整个系统进行架构设计。随着理解的深入或变化,稍后再改变模型并不是个错误。这种做法符合 YAGNI 编码原则(你不会需要它),因为该做法防止开发人员引入额外的复杂度,因而也能阻止 Bug 的引入。它还能让整个团队安排时间去对系统中很小的一块达成共识。我们认为,今天产出一个可工作的无 Bug 系统要比明天产出一个完美的、包括所有模型的系统更为重要。

我们的编辑在下一个迭代中要求的内容类型有音频和视频。我们的技术团队和编辑再次坐在一起讨论了领域建模过程。编辑先跟技术团队谈道,音频和视频很显然与文章相似:应该可以将视频或音频放在一个页面上。每个页面只允许有一种内容。视频和音频可以通过关键字分类。关键字可以属于版面。编辑还指明,在以后的迭代中他们会添加更多类型的内容,他们认为现在该是时候去理解我们应该如何随着时间的推移去演进内容模型。

对我们的开发人员来说,很显然编辑想在语言中明确引入两个新条目:音频和视频。音频、视频和文章有一些共同点:它们都是内容类型,这一点也很明确。我们的编辑并不熟悉继承的概念,所以技术团队可以给编辑讲解继承,以便技术团队能正确表述编辑所看到的模型。

这里有一个明显的经验:通过利用敏捷开发技术将软件开发过程细分为小的块,我们还能使业务人员的学习曲线变得平滑。久而久之他们能加深对领域建模过程的理解,而不用预先花费大量的时间去学习面向对象设计所有的组件。

这是我们的编辑根据添加的新内容类型设计的模型。

这个单一的模型演变是大量更细微的通用语言演进的结果。现在我们有三个外加的词:音频、视频和内容;我们的编辑已经了解了继承,并能在以后的模型迭代中加以利用;对添加新的内容类型,我们也有了以后的扩展策略,并使其对我们的编辑来说是简单的。如果编辑需要一个新的内容类型,而这一新的内容类型与我们已有的内容类型相同、在页面和关键字之间有大致相同的关系,那编辑就能要求开发团队产出一个新的内容类型。作为一个团队,我们正通过逐步生成模型来提高效率,因为我们的编辑不会再详细检查漫长的领域建模过程去添加新的内容类型。

4.2. 演进第二步:

由于我们的模型要扩展到包括更多的内容类型,它需要更灵活地去分类。我们开始在领域模型中添加额外的元数据,但编辑的最终意图是什么还不是非常清楚。然而这并不让我们太过担忧,因为我们对元数据进行建模的方法与处理内容的方法一样,将需求细分为可管理的块,将每个添加到我们的领域中。

我们的编辑想添加的第一个元数据类型是系列这一概念。系列是一组相关的内容,内容则有一个基于时间的隐含顺序。在报纸中有很多系列的例子,也需要将这一概念解释为适用于 Web 的说法。

我们对此的初步想法非常简单。我们将系列添加为一个领域对象,它要关联到内容和页面。这个对象将用来聚集与系列关联的内容。如果读者访问了一种内容,该内容属于某个系列,我们就能从页面链接到同一系列中的前一条和后一条内容。我们还能链接到并生成系列索引页面,该页面可以显示系列中的所有内容。

这里是编辑所设计的系列模型:

与此同时,我们的编辑正在考虑更多的元数据,他们想让这些元数据与内容关联。目前关键字描述了内容是关于什么的。编辑还要求系统能根据内容的基调对内容进行不同的处理。不同基调的例子有评论、讣告、读者供稿、来信。通过引入基调,我们就可以将其显示给读者,让他们找到类似的内容(其它讣告、评论等)。这像是除关键字或系列外另一种类型的关系。跟系列一样,基调可以附加到一条内容上,也能和页面有关系。

这里是编辑针对基调设计的模型:

完成开发后,我们有了一个能根据关键字、系列或基调对内容进行分类的系统。但编辑对达到这一点所需的技术工作量还有一些关注点。他们在我们下次演进模型时向技术团队提出了这些关注点,并能提出解决方案。

4.3. 演进第三步:重构元数据

模型的下一步演进是我们的编辑想接着添加类似于系列和基调的内容。我们的编辑想添加带有贡献者的内容这一概念。贡献者是创建内容的人,可能是文章的作者,或者是视频的制作人。跟系列一样,贡献者在系统中有一个页面,该页面会自动聚集贡献者制作的所有内容。

编辑还看到了另一个问题。他们认为随着系列和基调的引入,他们已经向开发人员指明了大量非常相似的细节。他们要求构建一个工具去创建系列,构建另一个工具去创建基调。他们不得不指明这些对象如何关联到内容和页面上。每次他们都发现,他们在为这两种类型的领域对象指定非常相似的开发任务;这很浪费时间,还是重复的。编辑更加关注于贡献者,还有更多的元数据类型会加入进来。这看起来又要让编辑再次指明、处理大量昂贵的开发工作,所有这些都非常相似。

这显然成为一个问题。我们的编辑似乎已经发现了模型的一些错误,而开发人员还没有。为什么添加新的元数据对象会如此昂贵呢?为什么他们不得不一遍又一遍地去指定相同的工作呢?我们的编辑问了一个问题,该问题是“这仅仅是‘软件开发如何工作’,还是模型有问题?”技术团队认为编辑熟悉一些事情,因为很显然,他们理解模型的方式与编辑不同。我们与编辑一起召开了另一个领域建模会议,试图找出问题所在。

在会议上我们的编辑建议,所有已有的元数据类型实际上源于相同的基本思想。所有的元数据对象(关键字、系列、基调和贡献者)可以和内容有多对多的关系,而且它们都需要它们自己的页面。(在先前的模型版本中,我们不得不知道对象和页面之间的关系)。我们重构了模型,引入了一个新的超类——Tag(标签),并作为其它元数据的超类。编辑们很喜欢使用“超类”这一技术术语,将整个重构称为“Super-Tag”,尽管最终也回到了现实。

由于标签的引入,添加贡献者和其它预期的新元数据类型变得很简单,因为我们能够利用已有的工具功能和框架。

我们修订后的模型现在看起来是这样的:

我们的业务代表在以这种方式考虑开发过程和领域模型,发现这一点非常好,还发现领域驱动设计有能力促进在两个方向都起作用的共同理解:我们发现技术团队对我们正努力解决的业务问题有良好且持续的理解,而且出乎意料,业务代表能“洞察”开发过程,还能改变这一过程以更好地满足他们的需求。编辑们现在不仅能将他们的需求翻译为领域模型,还能设计、检查领域模型的重构,以确保重构能与我们目前对业务问题的理解保持同步。

编辑规划领域模型重构并成功执行它们的能力是我们领域驱动设计 guardian.co.uk 成功的一个关键点。

5. 代码级别的演进

前面我们看了领域模型方面的进化。但 DDD 在代码级别也有影响,不断变化的业务需求也意味着代码要有变化。现在我们来看看这些变化。

5.1. 构建模型

在构建领域模型时,要确认的第一件事就是领域中出现的聚集。聚集可认为是相关对象的集合,这些对象彼此相互引用。这些对象不应该直接引用其它聚集中的其它对象;不同聚集之间的引用应该由根聚集来完成。

看一下我们在上面定义的模型示例,我们开始看到对象成形。我们有 Page 和 Template 对象,它们结合起来能给 Web 页面提供 URL 和观感。由于 Page 是系统的入口点,所以在这里 Page 就是根聚集。

我们还有一个聚集 Content,它也是根聚集。我们看到 Content 有 Article、Video、Audio 等子类型,我们认为这些都是内容的子聚集,核心的 Content 对象则是根聚集。

我们还看到形成了另一个聚集。它是元数据对象的集合:Tag、Series、Tone 等。这些对象组成了标签聚集,Tag 是根聚集。

Java 编程语言提供了理想的方式来对这些聚集进行建模。我们可以使用 Java 包来对每个聚集进行建模,使用标准的 POJO 对每个领域对象进行建模。那些不是根聚集、且只在聚集中使用的领域对象可以有包范围内使用的构造函数,以防它们在聚集外被构造。

上述模型的包结构如下所示(“r2”是我们应用套件的名称):

复制代码
com.gu.r2.model.page
com.gu.r2.model.tag
com.gu.r2.model.content
com.gu.r2.model.content.article
com.gu.r2.model.content.video
com.gu.r2.model.content.audio

我们将内容聚集细分为多个子包,因为内容对象往往有很多聚集特定的支持类(这里的简化图中没有显示)。所有以标签为基础的对象往往要更为简单,所以我们将它们放在了一个包里,而没有引入额外的复杂性。

不过不久之后,我们认识到上述包结构会给我们带来问题,我们打算修改它。看看我们前端应用的包结构示例,了解一下我们如何组织控制器,就能阐述清楚这一问题:

复制代码
com.gu.r2.frontend.controller.page
com.gu.r2.frontend.controller.articl

这里看到我们的代码集要开始细分为片段。我们提取了所有的聚集,将其放入包中,但我们没有单独的包去包含与聚集相关的所有对象。这意味着,如果以后领域变得太大而不能作为一个单独的单元来管理,我们希望将应用分解,处理依赖就会有困难。目前这还没有真正带来什么问题,但我们要重构应用,以便不会有太多的跨包依赖。经过改进的结构如下:

复制代码
com.gu.r2.page.model (domain objects in the page aggregate)
com.gu.r2.page.controller (controllers providing access to aggregate)
com.gu.r2.content.article.model
com.gu.r2.content.article.controller
...
etc

除了约定,我们在代码集中没有其它任何的领域驱动设计实施原则。创建注解或标记接口来标记聚集根是有可能的,实际上是争取在模型包锁定开发,减少开发人员建模时出错的几率。但实际上并不是用这些机械的强制来保证在整个代码集中都遵循标准约定,而是我们更多地依赖了人力技术,比如结对编程和测试驱动开发。如果我们确实发现已创建的一些内容违反了我们的设计原则(这相当少见),那我们会告诉开发人员并让他完善设计。我们还是喜欢这个轻量级的方法,因为它很少在代码集中引入混乱,反而提升了代码的简单性和可读性。这也意味着我们的开发人员更好地理解了为什么一些内容是按这种方式组织,而不是被迫去简单地做这些事情。

5.2. 核心 DDD 概念的演进

根据领域驱动设计原则创建的应用会具有四种明显的对象类型:实体、值对象、资源库和服务。在本节中,我们将看看应用中的这些例子。

5.2.1 实体

实体是那些存在于聚集中并具有标识的对象。并不是所有的实体都是聚集根,但只有实体才能成为聚集根。

开发人员,尤其是那些使用关系型数据库的开发人员,都很熟悉实体的概念。不过,我们发现这个看似很好理解的概念却有可能引起一些误解。

这一误解似乎跟使用 Hibernate 持久化实体有点儿关系。由于我们使用 Hibernate,我们一般将实体建模为简单的 POJO。每个实体具有属性,这些属性可以利用 setter 和 getter 方法进行存取。每个属性都映射到一个 XML 文件中,定义该属性如何持久化到数据库中。为了创建一个新的持久化实体,开发人员需要创建用于存储的数据库表,创建适当的 Hibernate 映射文件,还要创建有相关属性的领域对象。由于开发人员要花费一些时间处理持久化机制,他们有时似乎认为实体对象的目的仅仅只是数据的持久化,而不是业务逻辑的执行。等他们后来开始实现业务逻辑时,他们往往在服务对象中实现,而不是在实体对象本身中。

在下面(简化)的代码片段中可以看出此类错误。我们用一个简单的实体对象来表示一场足球赛:

复制代码
public class FootballMatch extends IdBasedDomainObject
{
private final FootballTeam homeTeam;
private final FootballTeam awayTeam;
private int homeTeamGoalsScored;
private int awayTeamGoalsScored;
FootballMatch(FootballTeam homeTeam, FootballTeam awayTeam) {
this.homeTeam = homeTeam;
this.awayTeam = awayTeam;
}
public FootballTeam getHomeTeam() {
return homeTeam;
}
public FootballTeam getAwayTeam() {
return awayTeam;
}
public int getHomeTeamScore() {
return homeTeamScore;
}
public void setHomeTeamScore(int score) {
this.homeTeamScore = score;
}
public void setAwayTeamScore(int score) {
this.awayTeamScore = score;
}
}

该实体对象使用 FootballTeam 实体去对球队进行建模,看起来很像使用 Hibernate 的开发人员所熟悉的对象类型。该实体的每个属性都持久化到数据库中,尽管从领域驱动设计的角度来说这个细节并不真的重要,我们的开发人员还是将持久化的属性提升到一个高于它们应该在的水平上去。在我们试图从 FootballTeam 对象计算出谁赢得了比赛的时候这一点就可以显露出来。我们的开发人员要做的事情就是造出另一种所谓的领域对象,就像下面所示:

复制代码
public class FootballMatchSummary {
public FootballTeam getWinningTeam(FootballMatch footballMatch) {
if(footballMatch.getHomeTeamScore() > footballMatch.getAwayTeamScore()) {
return footballMatch.getHomeTeam();
}
return footballMatch.getAwayTeam();
}
}

片刻的思考应该表明已经出现了错误。我们已经创建了一个 FootballMatchSummary 类,该类存在于领域模型中,但对业务来说它并不意味着什么。它看起来是充当了 FootballMatch 对象的服务对象,提供了实际上应该存在于 FootballMatch 领域对象中的功能。引起这一误解的原因好像是开发人员将 FootballMatch 实体对象简单地看成了是反映数据库中持久化信息,而不是解决所有的业务问题。我们的开发人员将实体考虑为了传统 ORM 意义上的实体,而不是业务所有和业务定义的领域对象。

不愿意在领域对象中加入业务逻辑会导致贫血的领域模型,如果不加以制止还会使混乱的服务对象激增——就像我们等会儿看到的一样。作为一个团队,现在我们来检视一下创建的服务对象,看看它们实际上是否包含业务逻辑。我们还有一个严格的规则,就是开发人员不能在模型中创建新的对象类型,这对业务来说并不意味着什么。

作为团队,我们在项目开始时还被实体对象给弄糊涂了,而且这种困惑也与持久化有关。在我们的应用中,大部分实体与内容有关,而且大部分都被持久化了。但当实体不被持久化,而是在运行时由工厂或资源库创建的话,有时候还是会混淆。

一个很好的此类例子就是“标签合成的页面”。我们在数据库中持久化了编辑创建的所有页面的外观,但我们可以自动生成从标记组合(比如 USA+Economics 或 Technology+China)聚集内容的页面。由于所有可能的标记组合总数是个天文数字,我们不可能持久化所有的这些页面,但系统还必须能生成页面。在渲染标记组合页面时,我们必须在运行时实例化尚未持久化的新 Page 实例。项目初期我们倾向于认为这些非持久化对象与“真正的”持久化领域对象不同,也不像在对它们建模时那么认真。从业务观点看,这些自动生成的实体与持久化实体实际上并没有什么不同,因此从领域驱动设计观点来看也是如此。不论它们是否被持久化,对业务来说它们都有同样的定义,都不过是领域对象;没有“真正的”和“不是真正的”领域对象概念。

5.2.2 值对象

值对象是实体的属性,它们没有特性标识去指明领域中的内容,但表达了领域中有含义的概念。这些对象很重要,因为它们阐明了统一语言。

值对象阐述能力的一个例子可以从我们的 Page 类更详细地看出。系统中任何 Page 都有两个可能的 URLs。一个 URL 是读者键入 Web 浏览器以访问内容的公开 URL。另一个则是从应用服务器直接提供服务时内容依存的内部 URL。我们的 Web 服务器处理从用户那里传入的 URL,并将它转换为适合后端 CMS 服务器的内部 URL。

一种简化的观点是,在 Page 类中两个可能的 URL 都被建模为字符串对象:

复制代码
public String getUrl();
public String getCmsUrl();

不过,这并没有什么特别的表现力。除了这些方法返回字符串这一事实之外,只看这些方法的签名很难确切地知道它们会返回什么。另外想象一下这种情况,我们想基于页面的 URL 从一个数据访问对象中加载页面。我们可能会有如下的方法签名:

复制代码
public Page loadPage(String url);

这里需要的 URL 是哪一个呢?是公开的那个还是 CMS URL?不检查该方法的代码是不可能识别出来的。这也很难与业务人员谈论页面的 URL。我们指的到底是哪一个呢?在我们的模型中没有表示每种类型 URL 的对象,因此在我们的词汇里面也就没有相关条目。

这里还含有更多的问题。我们对内部 URL 和外部 URL 可能有不同的验证规则,也希望对它们执行不同的操作。如果我们没有地方放置这个逻辑,那我们怎么正确地封装该逻辑呢?控制 URLs 的逻辑一定不属于 Page,我们也不希望引入更多不必要的服务对象。

领域驱动设计建议的演进方案是明确地对这些值对象进行建模。我们应该创建表示值对象的简单包装类,以对它们进行分类。如果我们这样做,Page 里面的签名就如下所示:

复制代码
public Url getUrl();
public CmsPath getCmsPath();

现在我们可以在应用中安全地传递 CmsPath 或 Url 对象,也能用业务代表理解的语言与他们谈论代码。

5.2.3 资源库

资源库是存在于聚集中的对象,在抽象任何持久化机制时提供对聚集根对象实体的访问。这些对象由业务问题请求,与领域对象一起响应。

将资源库看成是类似于有关数据库持久化功能的数据访问对象,而非存在于领域中的业务对象,这一点很不错。但资源库是领域对象:他们响应业务请求。资源库始终与聚集关联,并返回聚集根的实例。如果我们请求一个页面对象,我们会去页面资源库。如果我们请求一个页面对象列表来响应特定的业务问题,我们也会去页面资源库。

我们发现一个很好的思考资源库的方式,就是把它们看成是数据访问对象集合之上的外观。然后它们就成为业务问题和数据传输对象的结合点,业务问题需要访问特定的聚集,而数据传输对象提供底层功能。

这里举一小段页面资源库的代码例子,我们来实际看下这个问题:

复制代码
private final PageDAO<Page> pageDAO;
private final PagesRelatedBySectionDAO pagesRelatedBySectionDAO;
public PageRepository(PageDAO<Page> pageDAO,
EditorialPagesInThisSectionDAO pagesInThisSectionDAO,
PagesRelatedBySectionDAO pagesRelatedBySectionDAO) {
this.pageDAO = pageDAO;
this.pagesRelatedBySectionDAO = pagesRelatedBySectionDAO;
}
public List<Page> getAudioPagesForPodcastSeriesOrderedByPublicationDate(Series series, int maxNumberOfPages) {
return pageDAO.getAudioPagesForPodcastSeriesOrderedByPublicationDate(series, maxNumberOfPages);
}
public List<Page> getLatestPagesForSection(Section section, int maxResults) {
return pagesRelatedBySectionDAO.getLatestPagesForSection(section, maxResults);
}

我们的资源库有业务请求:获取 PublicationDate 请求的特定播客系列的页面。获取特定版面的最新页面。我们可以看看这里使用的业务领域语言。它不仅仅是一个数据访问对象,它本身就是一个领域对象,跟页面或文章是领域对象一样。

我们花了一段时间才明白,把资源库看成是领域对象有助于我们克服实现领域模型的技术问题。我们可以在模型中看到,标签和内容是一种双向的多对多关系。我们使用 Hibernate 作为 ORM 工具,所以我们对其进行了映射,Tag 有如下方法:

复制代码
public List<Content> getContent();

Content 有如下方法:

复制代码
public List<Tag> getTags();

尽管这一实现跟我们的编辑看到的一样,是模型的正确表达,但我们有了自己的问题。对开发人员来说,代码可能会编写成下面这样:

复制代码
if(someTag.getContent().size() == 0){
... do some stuff
}

这里的问题是,如果标签关联有大量的内容(比如“新闻”),我们最终可能会往内存中加载几十万的内容条目,而只是为了看看标记是否包含内容。这显然会引起巨大的网站性能和稳定性问题。

随着我们演进模型、理解了领域驱动设计,我们意识到有时候我们必须要注重实效:模型的某些遍历可能是危险的,应该予以避免。在这种情况下,我们使用资源库来用安全的方式解决问题,会为系统的性能和稳定性牺牲模型个别的清晰性和纯洁性。

5.2.4. 服务

服务是通过编排领域对象交互来处理业务问题执行的对象。我们所理解的服务是随着我们过程演进而演进最多的东西。

首要问题是,对开发人员来说创建不应该存在的服务相当容易;他们要么在服务中包含了本应存在于领域对象中的领域逻辑,要么扮演了缺失的领域对象角色,而这些领域对象并没有作为模型的一部分去创建。

项目初期我们开始发现服务开始突然涌现,带着类似于 ArticleService 的名字。这是什么呀?我们有一个领域对象叫 Article;那文章服务的目的是什么?检查代码时,我们发现该类似乎步了前面讨论的 FootballMatchSummary 的后尘,有类似的模式,包含了本该属于核心领域对象的领域逻辑。

为了对付这一行为,我们对应用中的所有服务进行了代码评审,并进行重构,将逻辑移到适当的领域对象中。我们还制定了一个新的规则:任何服务对象在其名称中必须包含一个动词。这一简单的规则阻止了开发人员去创建类似于 ArticleService 的类。取而代之,我们创建 ArticlePublishingService 和 ArticleDeletionService 这样的类。推动这一简单的命名规范的确帮助我们将领域逻辑移到了正确的地方,但我们仍要求对服务进行定期的代码评审,以确保我们在正轨上,以及对领域的建模接近于实际的业务观点。

6. 演进架构中 DDD 的一些教训

尽管面临挑战,但我们发现了在不断演进和敏捷的环境中利用 DDD 的显著优势,此外我们还总结了一些经验教训:

  • 你不必理解整个领域来增加商业价值。你甚至不需要全面的领域驱动设计知识。团队的所有成员差不多都能在他们需要的任何时间内对模型达成一个共同的理解。
  • 随着时间的推移,演进模型和过程是可能的,随着共同理解的提高,纠正以前的错误也是可能的。

我们系统的完整领域模型要比这里描述的简化版本大很多,而且随着我们业务的扩展在不断变化。在一个大型网站的动态世界里,创新永远发生着;我们始终要保持领先地位并有新的突破,对我们来说,有时很难在第一次就得到正确的模型。事实上,我们的业务代表往往想尝试新的想法和方法。有些人会取得成果,其他人则会失败。是逐步扩展现有领域模型——甚至在现有领域模型不再满足需求时进行重构——的业务能力为 guardian.co.uk 开发过程中遇到的大部分创新提供了基础。

7. 附录:具体示例

为了了解我们的领域模型如何生成真实的结果,这里给出了一个例子,先看单独的内容……

8. 关于作者

Nik Silver 是 Guardian News & Media 软件开发总监。他于 2003 年在公司引入敏捷软件开发,负责软件开发、前端开发和质量保证。Nik 偶尔会在 blogs.guardian.co.uk/inside 上写 Guardian 技术工作相关的内容,并在他自己的站点 niksilver.com 上写更宽泛的软件问题。

Matthew Wall 是 Guardian News & Media 的软件架构师,深入研究敏捷环境下大型 Web 应用的开发。他目前最关心的是为 guardian.co.uk 开发下一代的 Web 平台。他在 JAOO、ServerSide、QCon、XTech 和 OpenTech 上做过关于此及相关主题的各种演讲。

阅读英文原文 Domain-Driven Design in an Evolving Architecture


感谢韩锴对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2009 年 9 月 21 日 11:0910743
用户头像

发布了 151 篇内容, 共 52.3 次阅读, 收获喜欢 10 次。

关注

评论

发布
暂无评论
发现更多内容

使用WebMaker快速预览Ionic页面效果

davidce

Ionic WebMaker 混合应用开发

免费下载 | 阿里云实时计算整体解决方案白皮书重磅发布!

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

依赖倒置架构

AIK

看清远处模糊的事,不如做好身边清楚的事

Neco.W

创业心态 未知

第二周作业

晨光

从字符串到常量池,一文看懂String类设计

程序员DMZ

JVM 常量池 intern

POJO类中布尔类型为啥不让用isXxx命名

Java课代表

实时即未来?一个小微企业心中的流计算

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

课程总结

AIK

28岁程序员期权过亿,彪悍从字节退休,网友:酸了酸了!

程序员生活志

程序员 字节跳动 开发 退休

面向开发者的 WSL2 安装指南

simpleapples

Python golang Windows 10 wsl

数仓大法好!跨境电商 Shopee 的实时数仓之路

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

【Week02】框架设计

Aldaron

红警1游戏开源,代码非常规范。网友:秀色可餐

程序员生活志

游戏开源 红警1

【大厂面试06期】谈一谈你对Redis持久化的理解?

NotFound9

数据库 redis 后端

第二周-作业

JI

极客大学架构师训练营

Flink on Zeppelin (4) - 机器学习篇

章剑锋_Jeff

大数据 flink 学习 流计算 Zeppelin

第二周总结

qqq

极客大学架构师训练营

如何构建低延时的直播体验,让互动更实时?

巨侠说

CDN 短视频 直播 视频

第二周总结

晨光

TiDB原理解析

Chank

第二周作业

Aldaron

20年行业变革与技术演进,当下CDN如何为政企数字化转型加速?

巨侠说

CDN 边缘计算 移动视频

Flink作业问题分析和调优实践

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

《实现领域驱动设计》拆书稿 DDD入门 & 领域、子域和限界上下文

三界

架构 领域驱动设计 DDD

小师妹学JVM之:JDK14中JVM的性能优化

程序那些事

JVM 「Java 25周年」 小师妹 JIT JDK14

当你启动Redis的时候,Redis做了什么

老胡爱分享

redis 源码分析 面试题

数仓系列 | 深入解读 Flink 资源管理机制

Apache Flink

大数据 flink 流计算 实时计算

Apache Flink 误用之痛

Apache Flink

大数据 flink 流计算 实时计算 数据处理

第二周-总结

JI

极客大学架构师训练营

Flink 在快手实时多维分析场景的应用

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

演进架构中的领域驱动设计-InfoQ