first commit
This commit is contained in:
92
专栏/领域驱动设计实践(完)/001「战略篇」访谈DDD和微服务是什么关系?.md
Normal file
92
专栏/领域驱动设计实践(完)/001「战略篇」访谈DDD和微服务是什么关系?.md
Normal file
@ -0,0 +1,92 @@
|
||||
|
||||
|
||||
阿里云2C2G3M 99元/年,老用户 也可以哦
|
||||
|
||||
|
||||
001 「战略篇」访谈 DDD 和微服务是什么关系?
|
||||
相信很多朋友对领域驱动设计会有这样或那样的困惑,比如领域驱动设计是什么?它在工作中有什么作用?为什么国内关于这方面的书籍少之又少?…… 为了解决这些疑惑,有幸邀请到专家张逸老师来聊聊领域驱动设计,下面是 GitChat 独家采访记录。
|
||||
|
||||
|
||||
GitChat:领域驱动设计(Domain Driven Design,DDD)自诞生以来已有十几年时间,这门本已步入老年的方法学却因为微服务的兴起而焕发了第二春。您说过这可能要归功于 DDD 的“坚硬生长”,但不可否认微服务确实也是一个重要因素,能否请您解释一下领域驱动设计和微服务这种深层次的匹配关系?
|
||||
|
||||
|
||||
张逸:领域驱动设计是由 Eric Evans 在一本《领域驱动设计》书中提出的,它是针对复杂系统设计的一套软件工程方法;而微服务是一种架构风格,一个大型复杂软件应用是由一个或多个微服务组成的,系统中的各个微服务可被独立部署,各个微服务之间是松耦合的,每个微服务仅关注于完成一件任务并很好地完成该任务。
|
||||
|
||||
两者之间更深入的关系,在我写的课程中已有详细讲解。主要体现在领域驱动设计中限界上下文与微服务之间的映射关系。假如限界上下文之间需要跨进程通信,并形成一种零共享架构,则每个限界上下文就成为了一个微服务。在微服务架构大行其道的当今,我们面临的一个棘手问题是:如何识别和设计微服务?领域驱动的战略设计恰好可以在一定程度上解决此问题。
|
||||
|
||||
|
||||
GitChat:如果说轻量化处理、自动部署,以及容器技术的发展使得微服务的兴起成为必然,那么是否可以说领域驱动设计今日的再续辉煌也是一种必然(或者说 DDD 在其诞生之时过于超前)?您能否预测一下 DDD 未来可能会和什么样的新理念相结合?
|
||||
|
||||
|
||||
张逸:好像领域驱动设计就从未真正“辉煌”过,所以谈不上再续辉煌,但确实是因为微服务引起了社区对它的重燃热情。推行领域驱动设计确乎有许多阻力,一方面要做到纯粹的领域驱动设计,许多团队成员的技能达不到;另一方面,似乎领域驱动设计带来的价值不经过时间的推移无法彰显其价值,这就缺乏足够的说服力让一家公司不遗余力地去推广领域驱动设计。微服务似乎给了我们一个推动领域驱动设计的理由!因为软件系统的微服务化已经成为了一种潮流,领域驱动设计又能够为微服务化保驾护航,还有什么理由不推行呢?
|
||||
|
||||
我个人认为,未来 DDD 的发展可能会出现以下趋势:
|
||||
|
||||
|
||||
以函数式编程思想为基础的领域建模理念与事件驱动架构和响应式编程的结合,可能在低延迟高并发的项目中发挥作用。这种领域驱动设计思想已经比较成熟,但目前还没有看到太多成功的运用。
|
||||
以 DDD 设计方法为基础的框架的出现,让微服务设计与领域建模变得更加容易,降低领域驱动设计的门槛。
|
||||
|
||||
|
||||
|
||||
GitChat:能否尽可能地详细(或举例)说明您在阅读并审校《实现领域驱动设计》一书时所认识到的领域驱动设计的本质—— 一个开放的设计方法体系 ——是什么?
|
||||
|
||||
|
||||
张逸:在《实现领域驱动设计》一书中,Vernon 不仅对整个领域驱动设计过程作了一番有益的梳理,还结合社区发展在书中引入了六边形架构和领域事件等概念,这为当时的我打开了一扇全新的窗户——原来领域驱动设计并不是一套死板的方法,而是一种设计思想、一种开放的设计方法体系,只要有利于领域驱动设计的实践,都可以引入其中。于是,在我的书中我才敢于大胆地引入用例、敏捷实践、整洁架构,以期为领域驱动设计提供补充。
|
||||
|
||||
Eric Evans 的《领域驱动设计》是以面向对象设计作为模型驱动设计的基础,但时下被频繁运用的函数式编程思想也给模型驱动设计带来了另一种视角。从开放的设计方法体系的角度讲,我们完全可以把更多的编程范式引入到领域驱动设计中。因为有了更多的选择,针对不同的业务场景就可以选择更适合的 DDD 实践,而不仅仅限于 Eric Evans 最初提出的范畴。
|
||||
|
||||
|
||||
GitChat:团队内外成员之间的协作与沟通一直以来都是个难题,也是大家经常喜欢调侃的话题之一,能否举例说明一下领域驱动设计是如何解决这一问题的?
|
||||
|
||||
|
||||
张逸:我觉得这个问题问反了。领域驱动设计解决不了这个问题,它只是重视这个问题;相反,我们应该说只有解决了团队内外成员之间的协作与沟通,才能更好地进行领域驱动设计。为此,我尝试用一些敏捷实践来解决这种协作问题。
|
||||
|
||||
|
||||
GitChat:您在学习和实践领域驱动设计的过程中是否有哪些(有趣的)故事可以和读者们分享?
|
||||
|
||||
|
||||
张逸:我在 ThoughtWorks 的时候,公司邀请《实现领域驱动设计》作者 Vaughn Vernon 到北京 Office 给我们做了一次 DDD 培训。借着这次亲炙大师教诲的机会,我向他请教了一个一直缠绕在我心中困惑不解的问题:“如何正确地识别限界上下文?”结果他思考了一会儿,严肃地回答了我:“By experience!” 我唯有无言以对。
|
||||
|
||||
|
||||
GitChat:有很多读者对您即将在课程中给出全真案例“EAS 系统”很感兴趣,能否简单介绍一下这个案例以及它在实际应用中的意义?
|
||||
|
||||
|
||||
张逸:EAS 系统是我之前做过的一个真实项目,之所以选择这个项目来作为这个专栏的全真案例,原因如下:
|
||||
|
||||
|
||||
学习 DDD 必须理论联系实际。虽然在我写的课程内容中已经结合理论讲解提供了较多的实际案例,但这些零散的案例无法给读者提供一个整体的印象。
|
||||
EAS 系统的业务知识门槛相对较低,不至于因为不熟悉领域知识而影响对 DDD 的学习。
|
||||
EAS 系统具备一定的业务复杂度,既适合战略设计阶段,又适合战术阶段。
|
||||
|
||||
|
||||
|
||||
GitChat:您提到这次的 DDD 系列专栏分为《战略篇》和《战术篇》两部分,这两个课程在内容设计上侧重有什么不同?很多读者关心《领域驱动战术设计实践》何时发布,可否透露一下?
|
||||
|
||||
|
||||
张逸:这两部分对应于 DDD 的战略设计阶段与战术设计阶段,粗略地说,前者更偏向于架构,后者更偏向于设计与编码。事实上,就我个人的规划来说,计划还有第三部分,是围绕着函数式编程讲解与 DDD 有关的实践,包括 EDA、CQRS、Domain Event 等知识。
|
||||
|
||||
目前,《战略篇》还有最后几个章节没有完成。一旦完成后,就可以开始撰写《战术篇》内容了。当然,战术设计的相关内容已有部分初稿,我争取能够在 11 月发布这部分内容。
|
||||
|
||||
|
||||
GitChat:您觉得这门课的学员/读者应该是什么样的人?对于这些人,要想掌握领域驱动设计乃至在专业领域更上一层楼,您有哪些学习建议?
|
||||
|
||||
|
||||
张逸:学习课程的学员/读者最好要有一定的软件设计能力,并对 DDD 学习抱有好奇心,希望能够将 DDD 学以致用。
|
||||
|
||||
学习建议:
|
||||
|
||||
|
||||
积累领域知识,以提高沟通与协作能力;
|
||||
以 Eric Evans 的《领域驱动设计》为主体,广泛涉猎与 DDD 相关的书籍与文章,并关注 DDD 社区的最新知识;
|
||||
要善于总结,理清 DDD 中各个概念之间的区别与应用场景。
|
||||
|
||||
|
||||
|
||||
GitChat:作为一位曾就职于中兴、惠普、中软、ThoughtWorks 等大型中外企业的架构师/技术总监/首席咨询师,在职业发展方面,您对您的读者们有哪些建议?
|
||||
|
||||
|
||||
张逸:我之前在 ThoughtWorks 的同事郑晔(校长)给我提过一个建议,就是打造自己的技术标签。例如,现在 DDD 就成为了我其中的一个技术标签了。这个说法的内在含义,就是要寻找和定位自己的技术发展方向,然后往更深的方向钻研,最终成为这个方向的技术专家。因此,结合自己的能力特长、兴趣点以及技术发展趋势去规划自己的技术发展方向,才是技术人员最应该思考并践行的。
|
||||
|
||||
|
||||
|
||||
|
100
专栏/领域驱动设计实践(完)/002「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md
Normal file
100
专栏/领域驱动设计实践(完)/002「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md
Normal file
@ -0,0 +1,100 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
002 「战略篇」开篇词:领域驱动设计,重焕青春的设计经典
|
||||
专栏背景
|
||||
|
||||
领域驱动设计确实已不再青春,从 Eric Evans 出版的那本划时代的著作《领域驱动设计》至今,已有将近十五年的时间,在软件设计领域中,似乎可以称得上是步入老年时代了。可惜的是,对于这样一个在国外 IT 圈享有盛誉并行之有效的设计方法学,国内大多数的技术人员却并不了解,也未曾运用到项目实践中,真可以说是知音稀少。领域驱动设计似乎成了一门悄悄发展的隐学,它从来不曾大行其道,却依旧顽强地发挥着出人意料的价值。
|
||||
|
||||
直到行业内吹起微服务的热风,人们似乎才重新发现了领域驱动设计的价值,并不是微服务拯救了领域驱动设计,是因为领域驱动设计一直在坚硬的生长,然而看起来,确乎因为微服务,领域驱动设计才又焕发了青春。
|
||||
|
||||
我从 2006 年开始接触领域驱动设计,一开始我就发现了它的魅力并沉迷其间。从阅读 Eric Evans 的《领域驱动设计》入门,然后尝试在软件项目中运用它,也取得了一定成效。然而,我的学习与运用一直处于摸索之中,始终感觉不得其门而入,直到有机会拜读 Vaughn Vernon 出版的《实现领域驱动设计》一书,并负责该书的审校工作,我才触摸到了领域驱动从战略设计到战术设计的整体脉络,并了解其本质:领域驱动设计是一个开放的设计方法体系。
|
||||
|
||||
即使如此,许多困惑与谜题仍然等待我去发现线索和答案。设计总是如此,虽然前人已经总结了许多原则与方法,却不能像数学计算那样,按照公式与公理进行推导就一定能得到准确无误的结果。设计没有唯一的真相。
|
||||
|
||||
即使如此,如果我们能够走在迈向唯一真相的正确道路上,那么每前进一步,就会离这个理想的唯一真相更近一步,这正是我推出这门课的初衷。也并不是说我贴近了唯一真相,更不是说我已经走在了正确道路上,但我可以自信地说,对于领域驱动设计,我走在了大多数开发人员的前面,在我发现了更多新奇风景的同时,亦走过太多荒芜的分岔小径,经历过太多坎坷与陷阱。我尝试着解答领域驱动设计的诸多谜题,期望能从我的思考与实践中发现正确道路的蛛丝马迹。我写的这门专栏正是我跌跌撞撞走过一路的风景拍摄与路径引导,就好似你要去银河系旅游,最好能有一本《银河系漫游指南》在手一样,不至于迷失在浩瀚的星空之中,我期待这门专栏能给你带来这样的指导。
|
||||
|
||||
专栏框架
|
||||
|
||||
本专栏是我计划撰写的领域驱动设计实践系列的第一部分内容(第二部分内容是领域驱动战术设计实践,后面陆续更新),其全面覆盖了领域建模分析与架构设计的战略设计过程,从剖析软件复杂度的根源开始,引入了领域场景分析与敏捷项目实践,帮助需求分析人员与软件设计人员分析软件系统的问题域,提炼真实表达的领域知识,最终建立系统的统一语言。同时,本专栏将主流架构设计思想、微服务架构设计原则与领域驱动设计中属于战略设计层面的限界上下文、上下文映射、分层架构结合起来,完成从需求到架构设计再到构建代码模型的架构全过程。
|
||||
|
||||
本专栏分为五部分,共计 34 篇。
|
||||
|
||||
开篇词:领域驱动设计,重焕青春的设计经典
|
||||
|
||||
第一部分(第 3~7 篇):软件复杂度
|
||||
|
||||
|
||||
领域驱动设计的目的是应对软件复杂度。本部分内容以简练的笔触勾勒出了领域驱动设计的全貌,然后深入剖析了软件复杂度的本质,总结了控制软件复杂度的原则,最终给出了领域驱动设计应对软件复杂度的基本思想与方法。
|
||||
|
||||
|
||||
第二部分(第 8~12 篇):领域知识
|
||||
|
||||
|
||||
领域驱动设计的核心是“领域”,也是进行软件设计的根本驱动力。因此,团队在进行领域驱动设计时,尤其需要重视团队内外成员之间的协作与沟通。本部分内容引入了敏捷开发思想中的诸多实践,并以领域场景分析为主线讲解了如何提炼领域知识的方法。
|
||||
|
||||
|
||||
第三部分(第 13~22 篇):限界上下文
|
||||
|
||||
|
||||
限界上下文是领域驱动设计最重要的设计要素,我们需要充分理解限界上下文的本质与价值,突出限界上下文对业务、团队与技术的“控制”能力。
|
||||
提出了从业务边界、工作边界到应用边界分阶段分步骤迭代地识别限界上下文的过程方法,使得领域驱动设计的新手能够有一个可以遵循的过程来帮助识别限界上下文。
|
||||
剖析上下文映射,确定限界上下文之间的协作关系,进一步帮助我们合理地设计限界上下文。
|
||||
|
||||
|
||||
第四部分(第 23~30 篇):架构与代码模型
|
||||
|
||||
|
||||
作为一个开放的设计方法体系,本部分引入了分层架构、整洁架构、六边形架构与微服务架构等模式,全面剖析了领域驱动设计的架构思想与原则。
|
||||
结合限界上下文,并针对限界上下文的不同定义,对领域驱动的架构设计进行了深度探索,给出了满足整洁架构思想的代码模型。
|
||||
|
||||
|
||||
第五部分(第 31~36 篇):EAS 系统的战略设计实践
|
||||
|
||||
|
||||
给出一个全真案例——EAS 系统,运用各篇介绍的设计原则、模式与方法对该系统进行全方位的战略设计,并给出最终的设计方案。
|
||||
|
||||
|
||||
本专栏并非是对 Eric Evans《领域驱动设计》的萧规曹随,而是吸纳了领域驱动设计社区的各位专家大师提出的先进知识,并结合我多年来运用领域驱动设计收获的项目经验,同时还总结了自己在领域驱动设计咨询与培训中对各种困惑与问题的思考与解答。本专栏内容既遵循了领域驱动设计的根本思想,又有自己的独到见解;既给出了权威的领域驱动知识阐释,又解答了在实践领域驱动设计中最让人困惑的问题。
|
||||
|
||||
为什么要学习领域驱动设计
|
||||
|
||||
如果你已经能设计出美丽优良的软件架构,如果你只希望脚踏实地做一名高效编码的程序员,如果你是一位注重用户体验的前端设计人员,如果你负责的软件系统并不复杂,那么,你确实不需要学习领域驱动设计!
|
||||
|
||||
领域驱动设计当然并非“银弹”,自然也不是解决所有疑难杂症的“灵丹妙药”,请事先降低对领域驱动设计的不合现实的期望。我以中肯地态度总结了领域驱动设计可能会给你带来的收获:
|
||||
|
||||
|
||||
领域驱动设计是一套完整而系统的设计方法,它能带给你从战略设计到战术设计的规范过程,使得你的设计思路能够更加清晰,设计过程更加规范。
|
||||
领域驱动设计尤其善于处理与领域相关的高复杂度业务的产品研发,通过它可以为你的产品建立一个核心而稳定的领域模型内核,有利于领域知识的传递与传承。
|
||||
领域驱动设计强调团队与领域专家的合作,能够帮助团队建立一个沟通良好的团队组织,构建一致的架构体系。
|
||||
领域驱动设计强调对架构与模型的精心打磨,尤其善于处理系统架构的演进设计。
|
||||
领域驱动设计的思想、原则与模式有助于提高团队成员的面向对象设计能力与架构设计能力。
|
||||
领域驱动设计与微服务架构天生匹配,无论是在新项目中设计微服务架构,还是将系统从单体架构演进到微服务设计,都可以遵循领域驱动设计的架构原则。
|
||||
|
||||
|
||||
专栏寄语
|
||||
|
||||
没有谁能够做到领域驱动设计的一蹴而就,一门专栏也不可能穷尽领域驱动设计的方方面面,从知识的学习到知识的掌握,进而达到能力的提升,需要一个漫长的过程。所谓“理论联系实际”虽然是一句耳熟能详的老话,但其中蕴含了颠扑不破的真理。我在进行领域驱动设计培训时,总会有学员希望我能给出数学公式般的设计准则或规范,似乎软件设计就像拼积木一般,只要遵照图示中给出的拼搭过程,不经思考就能拼出期待的模型。——这是不切实际的幻想。
|
||||
|
||||
要掌握领域驱动设计,就不要被它给出的概念所迷惑,而要去思索这些概念背后蕴含的原理,多问一些为什么。同时,要学会运用设计原则去解决问题,而非所谓的“设计规范”。例如:
|
||||
|
||||
|
||||
思考限界上下文边界的划分,实际上还是“高内聚、低耦合”原则的体现,只是我们需要考虑什么内容才是高内聚的,如何抽象才能做到低耦合?
|
||||
是否需要提取单独的限界上下文?是为了考虑职责的重用,还是为了它能够独立进化以应对未来的变化?
|
||||
在分层架构中,各层之间该如何协作?如果出现了依赖,该如何解耦?仍然需要从重用与变化的角度去思考设计决策。
|
||||
为什么同样遵循领域驱动设计,不同的系统会设计出不同的架构?这是因为不同的场景对架构质量的要求并不一样,我们要学会对架构的关注点做优先级排列,从而得出不同的架构决策。
|
||||
|
||||
|
||||
我强烈建议读者诸君要学会对设计的本质思考,不要只限于对设计概念的掌握,而要追求对设计原则与方法的融汇贯通。只有如此,才能针对不同的业务场景灵活地运用领域驱动设计,而非像一个牵线木偶般遵照着僵硬的过程进行死板地设计。
|
||||
|
||||
分享交流
|
||||
|
||||
我们为本专栏付费读者创建了微信交流群,以方便更有针对性地讨论专栏相关问题。入群方式请到第 5 篇末尾添加小编的微信号。
|
||||
|
||||
阅读文章过程中有任何疑问随时可以跟其他小伙伴讨论,或者直接向作者提问(作者看到后抽空回复)。你的分享不仅帮助他人,更会提升自己。
|
||||
|
||||
|
||||
|
||||
|
79
专栏/领域驱动设计实践(完)/003领域驱动设计概览.md
Normal file
79
专栏/领域驱动设计实践(完)/003领域驱动设计概览.md
Normal file
@ -0,0 +1,79 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
003 领域驱动设计概览
|
||||
领域驱动设计(Domain Driven Design,DDD)是由 Eric Evans 最早提出的综合软件系统分析和设计的面向对象建模方法,如今已经发展成为了一种针对大型复杂系统的领域建模与分析方法。它完全改变了传统软件开发工程师针对数据库进行的建模方法,从而将要解决的业务概念和业务规则转换为软件系统中的类型以及类型的属性与行为,通过合理运用面向对象的封装、继承和多态等设计要素,降低或隐藏整个系统的业务复杂性,并使得系统具有更好的扩展性,应对纷繁多变的现实业务问题。
|
||||
|
||||
领域驱动设计的开放性
|
||||
|
||||
领域驱动设计是一种方法论(Methodology),根据维基百科的定义,方法论是一套运用到某个研究领域的系统与理论分析方法。领域驱动设计就是针对软件开发领域提出的一套系统与理论分析方法。Eric Evans 在创造性地提出领域驱动设计时,实则是针对当时项目中聚焦在以数据以及数据样式为核心的系统建模方法的批判。面向数据的建模方法是关系数据库理论的延续,关注的是数据表以及数据表之间关系的设计。这是典型的面向技术实现的建模方法,面对日渐复杂的业务逻辑,这种设计方法欠缺灵活性与可扩展性,也无法更好地利用面向对象设计思想及设计模式,建立可重用的、可扩展的代码单元。领域驱动设计的提出,是设计观念的转变,蕴含了全新的设计思想、设计原则与设计过程。
|
||||
|
||||
由于领域驱动设计是一套方法论,它建立了以领域为核心驱动力的设计体系,因而具有一定的开放性。在这个体系中,你可以使用不限于领域驱动设计提出的任何一种方法来解决这些问题。例如,可以使用用例(Use Case)、测试驱动开发(TDD)、用户故事(User Story)来帮助我们对领域建立模型;可以引入整洁架构思想及六边形架构,以帮助我们建立一个层次分明、结构清晰的系统架构;还可以引入函数式编程思想,利用纯函数与抽象代数结构的不变性以及函数的组合性来表达领域模型。这些实践方法与模型已经超越了 Eric Evans 最初提出的领域驱动设计范畴,但在体系上却是一脉相承的。这也是为什么在领域驱动设计社区,能够不断诞生新的概念诸如 CQRS 模式、事件溯源(Event Sourcing)模式与事件风暴(Event Storming);领域驱动设计也以开放的心态拥抱微服务(Micro Service),甚至能够将它的设计思想与原则运用到微服务架构设计中。
|
||||
|
||||
领域驱动设计过程
|
||||
|
||||
领域驱动设计当然不是架构方法,也并非设计模式。准确地说,它其实是“一种思维方式,也是一组优先任务,它旨在加速那些必须处理复杂领域的软件项目的开发”。领域驱动设计贯穿了整个软件开发的生命周期,包括对需求的分析、建模、架构、设计,甚至最终的编码实现,乃至对编码的测试与重构。
|
||||
|
||||
领域驱动设计强调领域模型的重要性,并通过模型驱动设计来保障领域模型与程序设计的一致。从业务需求中提炼出统一语言(Ubiquitous Language),再基于统一语言建立领域模型;这个领域模型会指导着程序设计以及编码实现;最后,又通过重构来发现隐式概念,并运用设计模式改进设计与开发质量。这个过程如下图所示:
|
||||
|
||||
|
||||
|
||||
这个过程是一个覆盖软件全生命周期的设计闭环,每个环节的输出都可以作为下一个环节的输入,而在其中扮演重要指导作用的则是“领域模型”。这个设计闭环是一个螺旋式的迭代设计过程,领域模型会在这个迭代过程中逐渐演进,在保证模型完整性与正确性的同时,具有新鲜的活力,使得领域模型能够始终如一的贯穿领域驱动设计过程、阐释着领域逻辑、指导着程序设计、验证着编码质量。
|
||||
|
||||
如果仔细审视这个设计闭环,会发现在针对问题域和业务期望提炼统一语言,并通过统一语言进行领域建模时,可能会面临高复杂度的挑战。这是因为对于一个复杂的软件系统而言,我们要处理的问题域实在太庞大了。在为问题域寻求解决方案时,需要从宏观层次划分不同业务关注点的子领域,然后再深入到子领域中从微观层次对领域进行建模。宏观层次是战略的层面,微观层次是战术的层面,只有将战略设计与战术设计结合起来,才是完整的领域驱动设计。
|
||||
|
||||
战略设计阶段
|
||||
|
||||
领域驱动设计的战略设计阶段是从下面两个方面来考量的:
|
||||
|
||||
|
||||
问题域方面:针对问题域,引入限界上下文(Bounded Context)和上下文映射(Context Map)对问题域进行合理的分解,识别出核心领域(Core Domain)与子领域(SubDomain),并确定领域的边界以及它们之间的关系,维持模型的完整性。
|
||||
架构方面:通过分层架构来隔离关注点,尤其是将领域实现独立出来,能够更利于领域模型的单一性与稳定性;引入六边形架构可以清晰地表达领域与技术基础设施的边界;CQRS 模式则分离了查询场景和命令场景,针对不同场景选择使用同步或异步操作,来提高架构的低延迟性与高并发能力。
|
||||
|
||||
|
||||
Eric Evans 提出战略设计的初衷是要保持模型的完整性。限界上下文的边界可以保护上下文内部和其他上下文之间的领域概念互不冲突。然而,如果我们将领域驱动设计的战略设计模式引入到架构过程中,就会发现限界上下文不仅限于对领域模型的控制,而在于分离关注点之后,使得整个上下文可以成为独立部署的设计单元,这就是“微服务”的概念,上下文映射的诸多模式则对应了微服务之间的协作。因此在战略设计阶段,微服务扩展了领域驱动设计的内容,反过来领域驱动设计又能够保证良好的微服务设计。
|
||||
|
||||
一旦确立了限界上下文的边界,尤其是作为物理边界,则分层架构就不再针对整个软件系统,而仅仅针对粒度更小的限界上下文。此时,限界上下文定义了技术实现的边界,对当前上下文的领域与技术实现进行了封装,我们只需要关心对外暴露的接口与集成方式,形成了在服务层次的设计单元重用。
|
||||
|
||||
边界给了实现限界上下文内部的最大自由度,这也是战略设计在分治上起到的效用,我们可以在不同的限界上下文选择不同的架构模式。例如,针对订单的查询与处理,选择 CQRS 模式来分别处理同步与异步场景;还可以针对核心领域与子领域重要性的不同,分别选择领域模型(Domain Model)和事务脚本(Transaction Script)模式,灵活地平衡开发成本与开发质量。在宏观层面,面对整个软件系统,我们可以采用前后端分离与基于 REST 的微服务架构,保证系统具有一致的架构风格。
|
||||
|
||||
战术设计阶段
|
||||
|
||||
整个软件系统被分解为多个限界上下文(或领域)后,就可以分而治之,对每个限界上下文进行战术设计。领域驱动设计并不牵涉到技术层面的实现细节,在战术层面,它主要应对的是领域的复杂性。领域驱动设计用以表示模型的主要要素包括:
|
||||
|
||||
|
||||
值对象(Value Object)
|
||||
实体(Entity)
|
||||
领域服务(Domain Service)
|
||||
领域事件(Domain Event)
|
||||
资源库(Repository)
|
||||
工厂(Factory)
|
||||
聚合(Aggregate)
|
||||
应用服务(Application Service)
|
||||
|
||||
|
||||
Eric Evans 通过下图勾勒出了战术设计诸要素之间的关系:
|
||||
|
||||
|
||||
|
||||
领域驱动设计围绕着领域模型进行设计,通过分层架构(Layered Architecture)将领域独立出来。表示领域模型的对象包括:实体、值对象和领域服务,领域逻辑都应该封装在这些对象中。这一严格的设计原则可以避免业务逻辑渗透到领域层之外,导致技术实现与业务逻辑的混淆。在领域驱动设计的演进中,又引入了领域事件来丰富领域模型。
|
||||
|
||||
聚合是一种边界,它可以封装一到多个实体与值对象,并维持该边界范围之内的业务完整性。在聚合中,至少包含一个实体,且只有实体才能作为聚合根(Aggregate Root)。注意,在领域驱动设计中,没有任何一个类是单独的聚合,因为聚合代表的是边界概念,而非领域概念。在极端情况下,一个聚合可能有且只有一个实体。
|
||||
|
||||
工厂和资源库都是对领域对象生命周期的管理。前者负责领域对象的创建,往往用于封装复杂或者可能变化的创建逻辑;后者则负责从存放资源的位置(数据库、内存或者其他 Web 资源)获取、添加、删除或者修改领域对象。领域模型中的资源库不应该暴露访问领域对象的技术实现细节。
|
||||
|
||||
演进的领域驱动设计过程
|
||||
|
||||
战略设计会控制和分解战术设计的边界与粒度,战术设计则以实证角度验证领域模型的有效性、完整性与一致性,进而以演进的方式对之前的战略设计阶段进行迭代,从而形成一种螺旋式上升的迭代设计过程,如下图所示:
|
||||
|
||||
|
||||
|
||||
面对客户的业务需求,由领域专家与开发团队展开充分的交流,经过需求分析与知识提炼,以获得清晰的问题域。通过对问题域进行分析和建模,识别限界上下文,利用它划分相对独立的领域,再通过上下文映射建立它们之间的关系,辅以分层架构与六边形架构划分系统的逻辑边界与物理边界,界定领域与技术之间的界限。之后,进入战术设计阶段,深入到限界上下文内对领域进行建模,并以领域模型指导程序设计与编码实现。若在实现过程中,发现领域模型存在重复、错位或缺失时,再进而对已有模型进行重构,甚至重新划分限界上下文。
|
||||
|
||||
两个不同阶段的设计目标是保持一致的,它们是一个连贯的过程,彼此之间又相互指导与规范,并最终保证一个有效的领域模型和一个富有表达力的实现同时演进。
|
||||
|
||||
|
||||
|
||||
|
106
专栏/领域驱动设计实践(完)/004深入分析软件的复杂度.md
Normal file
106
专栏/领域驱动设计实践(完)/004深入分析软件的复杂度.md
Normal file
@ -0,0 +1,106 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
004 深入分析软件的复杂度
|
||||
软件复杂度的成因
|
||||
|
||||
Eric Evans 的经典著作《领域驱动设计》的副标题为“软件核心复杂性应对之道”,这说明了 Eric 对领域驱动设计的定位就是应对软件开发的复杂度。Eric 甚至认为:“领域驱动设计只有应用在大型项目上才能产生最大的收益”。他通过 Smart UI 反模式逆向地说明了在软件设计与开发过程中如果出现了如下问题,就应该考虑运用领域驱动设计:
|
||||
|
||||
|
||||
没有对行为的重用,也没有对业务问题的抽象,每当操作用到业务规则时,都要重复这些业务规则。
|
||||
快速的原型建立和迭代很快会达到其极限,因为抽象的缺乏限制了重构的选择。
|
||||
复杂的功能很快会让你无所适从,所以程序的扩展只能是增加简单的应用模块,没有很好的办法来实现更丰富的功能。
|
||||
|
||||
|
||||
因此,选择领域驱动设计,就是要与软件系统的复杂作一番殊死拼搏,以降低软件复杂度为己任。那么,什么才是复杂呢?
|
||||
|
||||
什么是复杂?
|
||||
|
||||
即使是研究复杂系统的专家,如《复杂》一书的作者 Melanie Mitchell,都认为复杂没有一个明确得到公认的定义。不过,Melanie Mitchell 在接受 Ubiquity 杂志专访时,还是“勉为其难”地给出了一个通俗的复杂系统定义:由大量相互作用的部分组成的系统,与整个系统比起来,这些组成部分相对简单,没有中央控制,组成部分之间也没有全局性的通讯,并且组成部分的相互作用导致了复杂行为。
|
||||
|
||||
这个定义庶几可以表达软件复杂度的特征。定义中的组成部分对于软件系统来说,就是我所谓的“设计单元”,基于粒度的不同可以是函数、对象、模块、组件和服务。这些设计单元相对简单,然而彼此之间的相互作用却导致了软件系统的复杂行为。
|
||||
|
||||
Jurgen Appelo 从理解力与预测能力两个维度分析了复杂系统理论,这两个维度又各自分为不同的复杂层次,其中,理解力维度分为 Simple 与 Comlicated 两个层次,预测能力维度则分为 Ordered、Complex 与 Chaotic 三个层次,如下图所示:
|
||||
|
||||
|
||||
|
||||
参考复杂的含义,Complicated 与 Simple(简单)相对,意指非常难以理解,而 Complex 则介于 Ordered(有序的)与 Chaotic(混沌的)之间,认为在某种程度上可以预测,但会有很多出乎意料的事情发生。显然,对于大多数软件系统而言,系统的功能都是难以理解的;在对未来需求变化的把控上,虽然我们可以遵循一些设计原则来应对可能的变化,但未来的不可预测性使得软件系统的演进仍然存在不可预测的风险。因此,软件系统的所谓“复杂”其实覆盖了 Complicated 与 Complex 两个方面。要理解软件复杂度的成因,就应该结合理解力与预测能力这两个因素来帮助我们思考。
|
||||
|
||||
理解力
|
||||
|
||||
在软件系统中,是什么阻碍了开发人员对它的理解?想象团队招入一位新人,就像一位游客来到了一座陌生的城市,他是否会迷失在阡陌交错的城市交通体系中,不辨方向?倘若这座城市实则是乡野郊外的一座村落,不过只有房屋数间,一条街道连通城市的两头,还会疑生出迷失之感吗?
|
||||
|
||||
因而,影响理解力的第一要素是规模。
|
||||
|
||||
规模
|
||||
|
||||
软件的需求决定了系统的规模。当需求呈现线性增长的趋势时,为了实现这些功能,软件规模也会以近似的速度增长。由于需求不可能做到完全独立,导致出现相互影响相互依赖的关系,修改一处就会牵一发而动全身。就好似城市的一条道路因为施工需要临时关闭,此路不通,通行的车辆只能改道绕行,这又导致了其他原本已经饱和的道路,因为涌入更多车辆,超出道路的负载从而变得更加拥堵,这种拥堵现象又会顺势向这些道路的其他分叉道路蔓延,形成一种辐射效应的拥堵现象。
|
||||
|
||||
软件开发的拥堵现象或许更严重:
|
||||
|
||||
|
||||
函数存在副作用,调用时可能对函数的结果作了隐含的假设;
|
||||
类的职责繁多,不敢轻易修改,因为不知这种变化会影响到哪些模块;
|
||||
热点代码被频繁变更,职责被包裹了一层又一层,没有清晰的边界;
|
||||
在系统某个角落,隐藏着伺机而动的 bug,当诱发条件具备时,则会让整条调用链瘫痪;
|
||||
不同的业务场景包含了不同的例外场景,每种例外场景的处理方式都各不相同;
|
||||
同步处理与异步处理代码纠缠在一起,不可预知程序执行的顺序。
|
||||
|
||||
|
||||
当需求增多时,软件系统的规模也会增大,且这种增长趋势并非线性增长,会更加陡峭。倘若需求还产生了事先未曾预料到的变化,我们又没有足够的风险应对措施,在时间紧迫的情况下,难免会对设计做出妥协,头疼医头、脚疼医脚,在系统的各个地方打上补丁,从而欠下技术债(Technical Debt)。当技术债务越欠越多,累计到某个临界点时,就会由量变引起质变,整个软件系统的复杂度达到巅峰,步入衰亡的老年期,成为“可怕”的遗留系统。正如饲养场的“奶牛规则”:奶牛逐渐衰老,最终无奶可挤;然而与此同时,饲养成本却在上升。
|
||||
|
||||
结构
|
||||
|
||||
不知大家是否去过迷宫?相似而回旋繁复的结构使得本来封闭狭小的空间被魔法般地扩展为一个无限的空间,变得无穷大,仿佛这空间被安置了一个循环,倘若没有找到正确的退出条件,循环就会无休无止,永远无法退出。许多规模较小却格外复杂的软件系统,就好似这样的一座迷宫。
|
||||
|
||||
此时,结构成了决定系统复杂度的关键因素。
|
||||
|
||||
结构之所以变得复杂,在多数情况下还是因为系统的质量属性决定的。例如,我们需要满足高性能、高并发的需求,就需要考虑在系统中引入缓存、并行处理、CDN、异步消息以及支持分区的可伸缩结构。倘若我们需要支持对海量数据的高效分析,就得考虑这些海量数据该如何分布存储,并如何有效地利用各个节点的内存与 CPU 资源执行运算。
|
||||
|
||||
从系统结构的视角看,单体架构一定比微服务架构更简单,更便于掌控,正如单细胞生物比人体的生理结构要简单数百倍;那么,为何还有这么多软件组织开始清算自己的软件资产,花费大量人力物力对现有的单体架构进行重构,走向微服务化?究其主因,不还是系统的质量属性在作祟吗?
|
||||
|
||||
纵观软件设计的历史,不是分久必合、合久必分,而是不断拆分、继续拆分、持续拆分的微型化过程。分解的软件元素不可能单兵作战,怎么协同、怎么通信,就成为了系统分解后面临的主要问题。如果没有控制好,这些问题固有的复杂度甚至会在某些场景下超过因为分解给我们带来的收益。
|
||||
|
||||
无论是优雅的设计,还是拙劣的设计,都可能因为某种设计权衡而导致系统结构变得复杂。唯一的区别在于前者是主动地控制结构的复杂度,而后者带来的复杂度是偶发的,是错误的滋生,是一种技术债,它可能会随着系统规模的增大而导致一种无序设计。
|
||||
|
||||
在 Pete Goodliffe 讲述的《两个系统的故事:现代软件神话》中详细地罗列了无序设计系统的几种警告信号:
|
||||
|
||||
|
||||
代码没有显而易见的进入系统中的路径;
|
||||
不存在一致性、不存在风格、也没有统一的概念能够将不同的部分组织在一起;
|
||||
系统中的控制流让人觉得不舒服,无法预测;
|
||||
系统中有太多的“坏味道”,整个代码库散发着腐烂的气味儿,是在大热天里散发着刺激气体的一个垃圾堆;
|
||||
数据很少放在使用它的地方,经常引入额外的巴罗克式缓存层,目的是试图让数据停留在更方便的地方。
|
||||
|
||||
|
||||
我们看一个无序设计的软件系统,就好像隔着一层半透明的玻璃观察事物一般,系统中的软件元素都变得模糊不清,充斥着各种技术债。细节层面,代码污浊不堪,违背了“高内聚、松耦合”的设计原则,导致许多代码要么放错了位置,要么出现重复的代码块;架构层面,缺乏清晰的边界,各种通信与调用依赖纠缠在一起,同一问题域的解决方案各式各样,让人眼花缭乱,仿佛进入了没有规则的无序社会。
|
||||
|
||||
预测能力
|
||||
|
||||
当我们掌握了事物发展的客观规律时,我们就具有了一定的对未来的预测能力。例如,我们洞察了万有引力的本质,就可以对我们能够观察到的宇宙天体建立模型,较准确地推测出各个天体在未来一段时间的运行轨迹。然而,宇宙空间变化莫测,或许因为一个星球的死亡产生黑洞的吸噬能力,就可能导致那一片星域产生剧烈的动荡,这种动荡会传递到更远的星空,从而干扰了我们的预测。坦白说,我们现在连自己居住的地球天气都不能做一个准确的预测呢。之所以如此,正是因为未知的变化的产生。
|
||||
|
||||
变化
|
||||
|
||||
未来总会出现不可预测的变化,这种不可预测性带来的复杂度,使得我们产生畏惧,因为我们不知道何时会发生变化,变化的方向又会走向哪里,这就导致心理滋生一种仿若失重一般的感觉。变化让事物失去控制,受到事物牵扯的我们会感到惶恐不安。
|
||||
|
||||
在设计软件系统时,变化让我们患得患失,不知道如何把握系统设计的度。若拒绝对变化做出理智的预测,系统的设计会变得僵化,一旦变化发生,修改的成本会非常的大;若过于看重变化产生的影响,渴望涵盖一切变化的可能,一旦预期的变化不曾发生,我们之前为变化付出的成本就再也补偿不回来了。这就是所谓的“过度设计”。
|
||||
|
||||
从需求的角度讲,变化可能来自业务需求,也可能来自质量属性。以对系统架构的影响而言,尤以后者为甚,因为它可能牵涉到整个基础架构的变更。George Fairbanks在《恰如其分的软件架构》一书中介绍了邮件托管服务公司 RackSpace 的日志架构变迁,业务功能没有任何变化,却因为邮件数量的持续增长,为满足性能需求,架构经历了三个完全不同系统的变迁:从最初的本地日志文件,到中央数据库,再到基于 HDFS 的分布式存储,整个系统几乎发生了颠覆性的变化。这并非 RackSpace 的设计师欠缺设计能力,而是在公司草创之初,他们没有能够高瞻远瞩地预见到客户数量的增长,导致日志数据增多,以至于超出了已有系统支持的能力范围。俗话说:“事后诸葛亮”,当我们在对一个软件系统的架构设计进行复盘时,总会发现许多设计决策是如此的愚昧。殊不知这并非愚昧,而是在设计当初,我们手中掌握的筹码不足以让自己赢下这场面对未来的战争罢了。
|
||||
|
||||
这就是变化之殇!
|
||||
|
||||
如果将软件系统中我们自己开发的部分都划归为需求的范畴,那么还有一种变化,则是因为我们依赖的第三方库、框架或平台、甚至语言版本的变化带来的连锁反应。例如,作为 Java 开发人员,一定更垂涎于 Lambda 表达式的简洁与抽象,又或者 Jigsaw 提供的模块定义能力,然而现实是我们看到多数的企业软件系统依旧在 Java 6 或者 Java 7 中裹足不前。
|
||||
|
||||
这还算是幸运的例子,因为我们尽可以满足这种故步自封,由于情况并没有到必须变化的境地。当我们依赖的第三方有让我们不得不改变的理由时,难道我们还能拒绝变化吗?
|
||||
|
||||
许多软件在版本变迁过程中都尽量考虑到 API 变化对调用者带来的影响,因而尽可能保持版本向后兼容。我亲自参与过系统从 Spring 2.0 到 4.0 的升级,Spark 从 1.3.1 到 1.5 再到 1.6 的升级,感谢这些框架或平台设计人员对兼容性的体贴照顾,使得我们的升级成本能够被降到最低;但是在升级之后,倘若没有对系统做全方位的回归测试,我们的内心始终是惴惴不安的。
|
||||
|
||||
对第三方的依赖看似简单,殊不知我们所依赖的库、平台或者框架又可能依赖了若干对于它们而言又份属第三方的更多库、平台和框架。每回初次构建软件系统时,我都为漫长等待的依赖下载过程而感觉烦躁不安。多种版本共存时可能带来的所谓依赖地狱,只要亲身经历过,就没有不感到不寒而栗的。倘若你运气欠佳,可能还会有各种古怪问题接踵而来,让你应接不暇、疲于奔命。
|
||||
|
||||
如果变化是不可预测的,那么软件系统也会变得不可预测。一方面我们要尽可能地控制变化,至少要将变化产生的影响限制在较小的空间范围内;另一方面又要保证系统不会因为满足可扩展性而变得更加复杂,最后背上过度设计的坏名声。软件设计者们就像走在高空钢缆的技巧挑战者,惊险地调整重心以维持行动的平衡。故而,变化之难,在于如何平衡。
|
||||
|
||||
|
||||
|
||||
|
54
专栏/领域驱动设计实践(完)/005控制软件复杂度的原则.md
Normal file
54
专栏/领域驱动设计实践(完)/005控制软件复杂度的原则.md
Normal file
@ -0,0 +1,54 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
005 控制软件复杂度的原则
|
||||
虽然说认识到软件系统的复杂本性,并不足以让我们应对其复杂,并寻找到简化系统的解决之道;然而,如果我们连导致软件复杂度的本源都茫然不知,又怎么谈得上控制复杂呢?既然我们认为导致软件系统变得复杂的成因是规模、结构与变化三要素,则控制复杂度的原则就需要对它们进行各个击破。
|
||||
|
||||
分而治之、控制规模
|
||||
|
||||
针对规模带来的复杂度,我们应注意克制做大、做全的贪婪野心,尽力保证系统的小规模。简单说来,就是分而治之的思想,遵循小即是美的设计美学。
|
||||
|
||||
丹尼斯·里奇(Dennis MacAlistair Ritchie)从大型项目 Multics 的失败中总结出 KISS(Keep it Simple Stupid)原则,基于此原则,他将 Unix 设计为由许多小程序组成的整体系统,每个小程序只能完成一个功能,任何复杂的操作都必须分解成一些基本步骤,由这些小程序逐一完成,再组合起来得到最终结果。从表面上看,运行一连串小程序很低效,但是事实证明,由于小程序之间可以像积木一样自由组合,所以非常灵活,能够轻易完成大量意想不到的任务。而且,计算机硬件的升级速度非常快,所以性能也不是一个问题;另一方面,当把大程序分解成单一目的的小程序,开发会变得很容易。
|
||||
|
||||
Unix 的这种设计哲学被 Doug McIlroy、Elliot Pinson 和 Berk Tague 总结为以下两条:
|
||||
|
||||
|
||||
Make each program do one thing well. To do a new job, build a fresh rather than complicate old programs by adding new “features.”
|
||||
Expect the output of every program to become the input to another, as yet unknown, program.
|
||||
|
||||
|
||||
这两条原则是相辅相成的。第一条原则要求一个程序只做一件事情,符合“单一职责原则”,在应对新需求时,不会直接去修改一个复杂的旧系统,而是通过添加新特性,然后对这些特性进行组合。要满足小程序之间的自由组合,就需要满足第二条原则,即每个程序的输入和输出都是统一的,因而形成一个统一接口(Uniform Interface),以支持程序之间的自由组合(Composability)。利用统一接口,既能够解耦每个程序,又能够组合这些程序,还提高了这些小程序的重用性,这种“统一接口”,其实就是架构一致性的体现。
|
||||
|
||||
保持结构的清晰与一致
|
||||
|
||||
所有设计质量高的软件系统都有相同的特征,就是拥有清晰直观且易于理解的结构。
|
||||
|
||||
Robert Martin 分析了这么多年诸多设计大师提出的各种系统架构风格与模式,包括 Alistair Cockburn 提出的六边形架构(Hexagonal Architecture),Jeffrey Palermo 提出的洋葱架构(Onion Architecture),James Coplien 与 Trygve Reenskaug 提出的 DCI 架构,Ivar Jacobson 提出的 BCE 设计方法。结果,他认为这些方法的共同特征都遵循了“关注点分离”架构原则,由此提出了整洁架构的思想。
|
||||
|
||||
整洁架构提出了一个可测试的模型,无需依赖于任何基础设施就可以对它进行测试,只需通过边界对象发送和接收对应的数据结构即可。它们都遵循稳定依赖原则,不对变化或易于变化的事物形成依赖。整洁架构模型让外部易变的部分依赖于更加稳定的领域模型,从而保证了核心的领域模型不会受到外部的影响。典型的整洁架构如下图所示:
|
||||
|
||||
|
||||
|
||||
整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更为清晰,以减少不必要的耦合。要做到这一点,则需要合理地进行职责分配,良好的封装与抽象,并在约束的指导下为架构建立一致的风格,这是许多良好系统的设计特征。
|
||||
|
||||
拥抱变化
|
||||
|
||||
变化对软件系统带来的影响可以说是无解,然而我们不能因此而消极颓废,套用 Kent Beck 的话来说,我们必须“拥抱变化”。除了在开发过程中,我们应尽可能做到敏捷与快速迭代,以此来抵消变化带来的影响;在架构设计层面,我们还可以分析哪些架构质量属性与变化有关,这些质量属性包括:
|
||||
|
||||
|
||||
可进化性(Evolvability)
|
||||
可扩展性(Extensibility)
|
||||
可定制性(Customizability)
|
||||
|
||||
|
||||
要保证系统的可进化性,可以划分设计单元的边界,以确定每个设计单元应该履行的职责以及需要与其他设计单元协作的接口。这些设计单元具有不同的设计粒度,包括函数、对象、模块、组件及服务。由于每个设计单元都有自己的边界,边界内的实现细节不会影响到外部的其他设计单元,我们就可以非常容易地替换单元内部的实现细节,保证了它们的可进化性。
|
||||
|
||||
要满足系统的可扩展性,首先要学会识别软件系统中的变化点(热点),常见的变化点包括业务规则、算法策略、外部服务、硬件支持、命令请求、协议标准、数据格式、业务流程、系统配置、界面表现等。处理这些变化点的核心就是“封装”,通过隐藏细节、引入间接等方式来隔离变化、降低耦合。一些常见的架构风格,如基于事件的集成、管道—过滤器等的引入,都可以在一定程度上提高系统可扩展性。
|
||||
|
||||
可定制性意味着可以提供特别的功能与服务。Fielding 在《架构风格与基于网络的软件架构设计》提到:“支持可定制性的风格也可能会提高简单性和可扩展性”。在 SaaS 风格的系统架构中,我们常常通过引入元数据(Metadata)来支持系统的可定制。插件模式也是满足可定制性的常见做法,它通过提供统一的插件接口,使得用户可以在系统之外按照指定接口编写插件来扩展定制化的功能。
|
||||
|
||||
|
||||
|
||||
|
153
专栏/领域驱动设计实践(完)/006领域驱动设计对软件复杂度的应对(上).md
Normal file
153
专栏/领域驱动设计实践(完)/006领域驱动设计对软件复杂度的应对(上).md
Normal file
@ -0,0 +1,153 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
006 领域驱动设计对软件复杂度的应对(上)
|
||||
不管是因为规模与结构制造的理解力障碍,还是因为变化带来的预测能力问题,最终的决定因素还是因为需求。Eric Evans 认为“很多应用程序最主要的复杂性并不在技术上,而是来自领域本身、用户的活动或业务”。因而,领域驱动设计关注的焦点在于领域和领域逻辑,因为软件系统的本质其实是给客户(用户)提供具有业务价值的领域功能。
|
||||
|
||||
需求引起的软件复杂度
|
||||
|
||||
需求分为业务需求与质量属性需求,因而需求引起的复杂度可以分为两个方面:技术复杂度与业务复杂度。
|
||||
|
||||
技术复杂度来自需求的质量属性,诸如安全、高性能、高并发、高可用性等需求,为软件设计带来了极大的挑战,让人痛苦的是这些因素彼此之间可能又互相矛盾、互相影响。例如,系统安全性要求对访问进行控制,无论是增加防火墙,还是对传递的消息进行加密,又或者对访问请求进行认证和授权等,都需要为整个系统架构添加额外的间接层,这不可避免会对访问的低延迟产生影响,拖慢了系统的整体性能。又例如,为了满足系统的高并发访问,我们需要对应用服务进行物理分解,通过横向增加更多的机器来分散访问负载;同时,还可以将一个同步的访问请求拆分为多级步骤的异步请求,再通过引入消息中间件对这些请求进行整合和分散处理。这种分离一方面增加了系统架构的复杂性,另一方面也因为引入了更多的资源,使得系统的高可用面临挑战,并增加了维护数据一致性的难度。
|
||||
|
||||
业务复杂度对应了客户的业务需求,因而这种复杂度往往会随着需求规模的增大而增加。由于需求不可能做到完全独立,一旦规模扩大到一定程度,不仅产生了功能数量的增加,还会因为功能互相之间的依赖与影响使得这种复杂度产生叠加,进而影响到整个系统的质量属性,比如系统的可维护性与可扩展性。在考虑系统的业务需求时,还会因为沟通不畅、客户需求不清晰等多种局外因素而带来的需求变更和修改。如果不能很好地控制这种变更,则可能会因为多次修改而导致业务逻辑纠缠不清,系统可能开始慢慢腐烂而变得不可维护,最终形成一种如 Brian Foote 和 Joseph Yoder 所说的“大泥球”系统。
|
||||
|
||||
以电商系统的促销规则为例。针对不同类型的顾客与产品,商家会提供不同的促销力度;促销的形式多种多样,包括赠送积分、红包、优惠券、礼品;促销的周期需要支持定制,既可以是特定的日期,如双十一促销,也可以是节假日的固定促销模式。如果我们在设计时没有充分考虑促销规则的复杂度,并处理好促销规则与商品、顾客、卖家与支付乃至于物流、仓储之间的关系,开发过程则会变得踉踉跄跄、举步维艰。
|
||||
|
||||
技术复杂度与业务复杂度并非完全独立,二者混合在一起产生的化合作用更让系统的复杂度变得不可预期,难以掌控。同时,技术的变化维度与业务的变化维度并不相同,产生变化的原因也不一致,倘若未能很好地界定二者之间的关系,系统架构缺乏清晰边界,会变得难以梳理。复杂度一旦增加,团队规模也将随之扩大,再揉以严峻的交付周期、人员流动等诸多因素,就好似将各种不稳定的易燃易爆气体混合在一个不可逃逸的密闭容器中一般,随时都可能爆炸:
|
||||
|
||||
|
||||
|
||||
随着业务需求的增加与变化,以及对质量属性的高标准要求,自然也引起了软件系统规模的增大与结构的繁杂,至于变化,则是软件开发绕不开的话题。因此,当我们面对一个相对复杂的软件系统时,通常面临的问题在于:
|
||||
|
||||
|
||||
问题域过于庞大而复杂,使得从问题域中寻求解决方案的挑战增加,该问题与软件系统的规模有关。
|
||||
开发人员将业务逻辑的复杂度与技术实现的复杂度混淆在一起,该问题与软件系统的结构有关。
|
||||
随着需求的增长和变化,无法控制业务复杂度和技术复杂度,该问题与软件系统的变化有关。
|
||||
|
||||
|
||||
针对这三个问题,领域驱动设计都给出了自己的应对措施。
|
||||
|
||||
领域驱动设计的应对措施
|
||||
|
||||
隔离业务复杂度与技术复杂度
|
||||
|
||||
要避免业务逻辑的复杂度与技术实现的复杂度混淆在一起,首要任务就是确定业务逻辑与技术实现的边界,从而隔离各自的复杂度。这种隔离也是题中应有之义,毕竟技术与业务的关注点完全不同。例如,在电商的领域逻辑中,订单业务关注的业务规则包括验证订单有效性、计算订单总额、提交和审核订单的流程等;技术关注点则从实现层面保障这些业务能够正确地完成,包括确保分布式系统之间的数据一致性,确保服务之间通信的正确性等。
|
||||
|
||||
业务逻辑并不关心技术是如何实现的,无论采用何种技术,只要业务需求不变,业务规则就不会发生变化。换言之,在理想状态下,我们应该保证业务规则与技术实现是正交的。
|
||||
|
||||
领域驱动设计通过分层架构与六边形架构来确保业务逻辑与技术实现的隔离。
|
||||
|
||||
分层架构的关注点分离
|
||||
|
||||
分层架构遵循了“关注点分离”原则,将属于业务逻辑的关注点放到领域层(Domain Layer)中,而将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中。同时,领域驱动设计又颇具创见的引入了应用层(Application Layer),应用层扮演了双重角色。一方面它作为业务逻辑的外观(Facade),暴露了能够体现业务用例的应用服务接口;另一方面它又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。
|
||||
|
||||
下图展现的就是一个典型的领域驱动设计分层架构,蓝色区域的内容与业务逻辑有关,灰色区域的内容与技术实现有关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来:
|
||||
|
||||
|
||||
|
||||
六边形架构的内外分离
|
||||
|
||||
由 Cockburn 提出的六边形架构则以“内外分离”的方式,更加清晰地勾勒出了业务逻辑与技术实现的边界,且将业务逻辑放在了架构的核心位置。这种架构模式改变了我们观察系统架构的视角:
|
||||
|
||||
|
||||
|
||||
体现业务逻辑的应用层与领域层处于六边形架构的内核,并通过内部的六边形边界与基础设施的模块隔离开。当我们在进行软件开发时,只要恪守架构上的六边形边界,则不会让技术实现的复杂度污染到业务逻辑,保证了领域的整洁。边界还隔离了变化产生的影响。如果我们在领域层或应用层抽象了技术实现的接口,再通过依赖注入将控制的方向倒转,业务内核就会变得更加的稳定,不会因为技术选型或其他决策的变化而导致领域代码的修改。
|
||||
|
||||
案例:隔离数据库与缓存的访问
|
||||
|
||||
领域驱动设计建议我们在领域层建立资源库(Repository)的抽象,它的实现则被放在基础设施层,然后采用依赖注入在运行时为业务逻辑注入具体的资源库实现。那么,对于处于内核之外的 Repositories 模块而言,即使选择从 MyBatis 迁移到 Sprint Data,领域代码都不会受到牵连:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
@Transaction
|
||||
public class OrderAppService {
|
||||
@Service
|
||||
private PlaceOrderService placeOrder;
|
||||
|
||||
public void placeOrder(Identity buyerId, List<OrderItem> items, ShippingAddress shipping, BillingAddress billing) {
|
||||
try {
|
||||
palceOrder.execute(buyerId, items, shipping, billing);
|
||||
} catch (OrderRepositoryException | InvalidOrderException | Exception ex) {
|
||||
ex.printStackTrace();
|
||||
logger.error(ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.domain;
|
||||
|
||||
public interface OrderRepository {
|
||||
List<Order> forBuyerId(Identity buyerId);
|
||||
void add(Order order);
|
||||
}
|
||||
|
||||
public class PlaceOrderService {
|
||||
@Repository
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Service
|
||||
private OrderValidator orderValidator;
|
||||
|
||||
public void execute(Identity buyerId, List<OrderItem> items, ShippingAddress shipping, BillingAddress billing) {
|
||||
Order order = Order.create(buyerId, items, shipping, billing);
|
||||
if (orderValidator.isValid(order)) {
|
||||
orderRepository.add(order);
|
||||
} else {
|
||||
throw new InvalidOrderException(String.format("the order which placed by buyer with %s is invalid.", buyerId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.infrastructure.db;
|
||||
|
||||
public class OrderMybatisRepository implements OrderRepository {}
|
||||
public class OrderSprintDataRepository implements OrderRepository {}
|
||||
|
||||
|
||||
|
||||
对缓存的处理可以如法炮制,但它与资源库稍有不同之处。资源库作为访问领域模型对象的入口,其本身提供的增删改查功能,在抽象层面上是对领域资源的访问。因此在领域驱动设计中,我们通常将资源库的抽象归属到领域层。对缓存的访问则不相同,它的逻辑就是对 key 和 value 的操作,与具体的领域无关。倘若要为缓存的访问方法定义抽象接口,在分层的归属上应该属于应用层,至于实现则属于技术范畴,应该放在基础设施层:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
@Transaction
|
||||
public class OrderAppService {
|
||||
@Repository
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Service
|
||||
private CacheClient<List<Order>> cacheClient;
|
||||
|
||||
public List<Order> findBy(Identity buyerId) {
|
||||
Optional<List<Order>> cachedOrders = cacheClient.get(buyerId.value());
|
||||
if (cachedOrders.isPresent()) {
|
||||
return orders.get();
|
||||
}
|
||||
List<Order> orders = orderRepository.forBuyerId(buyerId);
|
||||
if (!orders.isEmpty()) {
|
||||
cacheClient.put(buyerId.value(), orders);
|
||||
}
|
||||
return orders;
|
||||
}
|
||||
}
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application.cache;
|
||||
|
||||
public interface CacheClient<T> {
|
||||
Optional<T> get(String key);
|
||||
void put(String key, T value);
|
||||
}
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.infrastructure.cache;
|
||||
|
||||
public class RedisCacheClient<T> implements CacheClient<T> {}
|
||||
|
||||
|
||||
|
||||
本例中对应的代码结构在分层架构中的体现将会在后续章节中深入介绍,敬请期待~
|
||||
|
||||
|
||||
|
||||
|
127
专栏/领域驱动设计实践(完)/007领域驱动设计对软件复杂度的应对(下).md
Normal file
127
专栏/领域驱动设计实践(完)/007领域驱动设计对软件复杂度的应对(下).md
Normal file
@ -0,0 +1,127 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
007 领域驱动设计对软件复杂度的应对(下)
|
||||
限界上下文的分而治之
|
||||
|
||||
在第1-4课中分析缓存访问接口的归属时,我们将接口放在了系统的应用层。从层次的职责来看,这样的设计是合理的,但它却使得系统的应用层变得更加臃肿,职责也变得不够单一了。这是分层架构与六边形架构的局限所在,因为这两种架构模式仅仅体现了软件系统的逻辑划分。倘若我们将一个软件系统视为一个纵横交错的魔方,前述的逻辑划分仅仅是一种水平方向的划分;至于垂直方向的划分,则是面向垂直业务的切割。这种方式更利于控制软件系统的规模,将一个庞大的软件系统划分为松散耦合的多个小系统的组合。
|
||||
|
||||
针对前述案例,我们可以将缓存视为一个独立的子系统,它同样拥有自己的业务逻辑和技术实现,因而也可以为其建立属于缓存领域的分层架构。在架构的宏观视角,这个缓存子系统与订单子系统处于同一个抽象层次。这一概念在领域驱动设计中,被称之为限界上下文(Bounded Context)。
|
||||
|
||||
针对庞大而复杂的问题域,限界上下文采用了“分而治之”的思想对问题域进行了分解,有效地控制了问题域的规模,进而控制了整个系统的规模。一旦规模减小,无论业务复杂度还是技术复杂度,都会得到显著的降低,在对领域进行分析以及建模时,也能变得更加容易。限界上下文对整个系统进行了划分,在将一个大系统拆分为一个个小系统后,我们再利用分层架构与六边形架构思想对其进行逻辑分层,以确保业务逻辑与技术实现的隔离,其设计会变得更易于把控,系统的架构也会变得更加清晰。
|
||||
|
||||
案例:限界上下文帮助架构的演进
|
||||
|
||||
国际报税系统是为跨国公司的驻外出差雇员(系统中被称之为 Assignee)提供方便一体化的税收信息填报平台。客户是一家会计师事务所,该事务所的专员(Admin)通过该平台可以收集雇员提交的报税信息,然后对这些信息进行税务评审。如果 Admin 评审出信息有问题,则返回给 Assignee 重新修改和填报。一旦信息确认无误,则进行税收分析和计算,并获得最终的税务报告提交给当地政府以及雇员本人。
|
||||
|
||||
系统主要涉及的功能包括:
|
||||
|
||||
|
||||
驻外出差雇员的薪酬与福利
|
||||
税收计划与合规评审
|
||||
对税收评审的分配管理
|
||||
税收策略设计与评审
|
||||
对驻外出差雇员的税收合规评审
|
||||
全球的 Visa 服务
|
||||
|
||||
|
||||
主要涉及的用户角色包括:
|
||||
|
||||
|
||||
Assignee:驻外出差雇员
|
||||
Admin:税务专员
|
||||
Client:出差雇员的雇主
|
||||
|
||||
|
||||
在早期的架构设计时,架构师并没有对整个系统的问题域进行拆分,而是基于用户角色对系统进行了简单粗暴的划分,分为了两个相对独立的子系统:Frond End 与 Office End,这两个子系统单独部署,分别面向 Assignee 与 Admin。系统之间的集成则通过消息和 Web Service 进行通信。两个子系统的开发分属不同的团队,Frond End 由美国的团队负责开发与维护,而 Office End 则由印度的团队负责。整个架构如下图所示:
|
||||
|
||||
|
||||
|
||||
采用这种架构面临的问题如下:
|
||||
|
||||
|
||||
庞大的代码库:整个 Front End 和 Office End 都没有做物理分解,随着需求的增多,代码库会变得格外庞大。
|
||||
分散的逻辑:系统分解的边界是不合理的,没有按照业务分解,而是按照用户的角色进行分解,因而导致大量相似的逻辑分散在两个不同的子系统中。
|
||||
重复的数据:两个子系统中存在业务重叠,因而也导致了部分数据的重复。
|
||||
复杂的集成:Front End 与 Office End 因为某些相关的业务需要彼此通信,这种集成关系是双向的,且由两个不同的团队开发,导致集成的接口混乱,消息协议多样化。
|
||||
知识未形成共享:两个团队完全独立开发,没有掌握端对端的整体流程,团队之间没有形成知识的共享。
|
||||
无法应对需求变化:新增需求包括对国际旅游、Visa 的支持,现有系统的架构无法很好地支持这些变化。
|
||||
|
||||
|
||||
采用领域驱动设计,我们将架构的主要关注点放在了“领域”,与客户进行了充分的需求沟通和交流。通过分析已有系统的问题域,结合客户提出的新需求,对整个问题域进行了梳理,并利用限界上下文对问题域进行了分解,获得了如下限界上下文:
|
||||
|
||||
|
||||
Account Management:管理用户的身份与配置信息;
|
||||
Calendar Management:管理用户的日程与旅行足迹。
|
||||
|
||||
|
||||
之后,客户希望能改进需求,做到全球范围内的工作指派与管理,目的在于提高公司的运营效率。通过对领域的分析,我们又识别出两个限界上下文。在原有的系统架构中,这两个限界上下文同时处于 Front End 与 Office End 之中,属于重复开发的业务逻辑:
|
||||
|
||||
|
||||
Work Record Management:实现工作的分配与任务的跟踪;
|
||||
File Sharing:目的是实现客户与会计师事务所之间的文件交换。
|
||||
|
||||
|
||||
随着我们对领域知识的逐渐深入理解与分析,又随之识别出如下限界上下文:
|
||||
|
||||
|
||||
Consent:管理合法的遵守法规的状态;
|
||||
Notification:管理系统与客户之间的交流;
|
||||
Questionnaire:对问卷调查的数据收集。
|
||||
|
||||
|
||||
这个领域分析的过程实际上就是通过对领域的分析而引入限界上下文对问题域进行分解,通过降低规模的方式来降低问题域的复杂度;同时,通过为模型确定清晰的边界,使得系统的结构变得更加的清晰,从而保证了领域逻辑的一致性。一旦确定了清晰的领域模型,就能够帮助我们更加容易地发现系统的可重用点与可扩展点,并遵循“高内聚、松耦合”的原则对系统职责进行合理分配,再辅以分层架构以划分逻辑边界,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们将识别出来的限界上下文定义为微服务,并对外公开 REST 服务接口。UI Applications 是一个薄薄的展现层,它会调用后端的 RESTful 服务,也使得服务在保证接口不变的前提下能够单独演化。每个服务都是独立的,可以单独部署,因而可以针对服务建立单独的代码库和对应的特性团队(Feature Team)。服务的重用性和可扩展性也有了更好的保障,服务与 UI 之间的集成变得更简单,整个架构会更加清晰。
|
||||
|
||||
领域模型对领域知识的抽象
|
||||
|
||||
领域模型是对业务需求的一种抽象,其表达了领域概念、领域规则以及领域概念之间的关系。一个好的领域模型是对统一语言的可视化表示,通过它可以减少需求沟通可能出现的歧义;通过提炼领域知识,并运用抽象的领域模型去表达,就可以达到对领域逻辑的化繁为简。模型是封装,实现了对业务细节的隐藏;模型是抽象,提取了领域知识的共同特征,保留了面对变化时能够良好扩展的可能性。
|
||||
|
||||
案例:项目管理系统的领域模型
|
||||
|
||||
我们开发的项目管理系统需要支持多种软件项目管理流程,如瀑布、RUP、XP 或者 Scrum,这些项目管理流程是迥然不同的,如果需要各自提供不同的解决方案,则会使得系统的模型变得非常复杂,也可能会引入许多不必要的重复。通过领域建模,我们可以对项目管理领域的知识进行抽象,寻找具有共同特征的领域概念。这就需要分析各种项目管理流程的主要特征与表现,才能从中提炼出领域模型。
|
||||
|
||||
瀑布式软件开发由需求、分析、设计、编码、测试、验收六个阶段构成,每个阶段都由不同的活动构成,这些活动可能是设计或开发任务,也可能是召开评审会。流程如下图所示:
|
||||
|
||||
|
||||
|
||||
RUP 清晰地划分了四个阶段:先启阶段(Inception)、细化阶段(Elaboration)、构造阶段(Construction)与交付阶段(Transition),每个阶段可以包含一到多个迭代,每个迭代有不同的工作,如业务建模、分析设计、配置与变更管理等,RUP 的流程如下图所示:
|
||||
|
||||
|
||||
|
||||
XP 作为一种敏捷方法,采用了迭代的增量式开发,提倡为客户交付具有业务价值的可运行软件。在执行交付计划之前,XP 要求团队对系统的架构做一次预研(Architectual Spike,又被译为架构穿刺)。当架构的初始方案确定后,就可以进入每次小版本的交付。每个小版本交付又被划分为多个周期相同的迭代。在迭代过程中,要求执行一些必须的活动,如编写用户故事、故事点估算、验收测试等。XP 的流程如下图所示:
|
||||
|
||||
|
||||
|
||||
Scrum 同样是迭代的增量开发过程。项目在开始之初,需要在准备阶段确定系统愿景、梳理业务用例、确定产品待办项(Product Backlog)、制定发布计划以及组建团队。一旦在确定了产品待办项以及发布计划之后,就进入了 Sprint 迭代阶段。Sprint 迭代过程是一个固定时长的项目过程,在这个过程中,整个团队需要召开计划会议、每日站会、评审会议和回顾会议。Scrum 的流程如下图所示:
|
||||
|
||||
|
||||
|
||||
不同的项目管理流程具有不同的业务概念。例如,瀑布式开发分为了六个阶段,但却没有发布和迭代的概念;RUP 没有发布的概念,而 Scrum 又为迭代引入了 Sprint 的概念。
|
||||
|
||||
不同的项目管理流程具有不同的业务规则。例如,RUP 的四个阶段会包含多个迭代周期,每个迭代周期都需要完成对应的工作,只是不同的工作在不同阶段所占的比重不同。XP 需要在进入发布阶段之前,进行架构预研,而在每次小版本发布之前,都需要进行验收测试和客户验收。Scrum 的 Sprint 是一个基本固定的流程,每个迭代召开的四会(计划会议、评审会议、回顾会议与每日站会)都有明确的目标。
|
||||
|
||||
领域建模就是要从这些纷繁复杂的领域逻辑中寻找到能够表示项目管理领域的概念,并利用面向对象建模范式或其他范式对概念进行抽象,并确定它们之间的关系。经过对这些项目管理流程的分析,我们虽然发现在业务概念和规则上确有不同之处,但由于它们都归属于软件开发领域,我们自然也能寻找到某些共同特征的蛛丝马迹。
|
||||
|
||||
首先,从项目管理系统的角度看,无论针对何种项目管理流程,我们的主题需求是不变的,就是要为这些管理流程制定软件开发计划(Plan)。不同之处在于,计划可以由多个阶段(Phase)组成,也可以由多个发布(Release)组成。一些项目管理流程没有发布的概念,我们可以认为是一个发布。那么,到底是发布包含了多个阶段,还是阶段包含了多个发布呢?我们发现在 XP 中,明显地划分了两个阶段:Architecture Spike 与 Release Planning,而发布只属于 Release Planning 阶段。因而从概念内涵上,我们可以认为是阶段(Phase)包含了发布(Release)。每个发布又包含了一到多个迭代(Iteration),至于 Scrum 的 Sprint 概念其实可以看做是迭代的一种特例。每个迭代可以开展多种不同的活动(Activity),这些活动可以是整个团队参与的会议,也可以是部分成员或特定角色执行的实践。对于计划而言,我们还需要跟踪任务(Task)。与活动不同,任务具有明确的计划起止时间、实际起止时间、工作量、优先级与承担人。
|
||||
|
||||
于是,我们提炼出如下的统一领域模型:
|
||||
|
||||
|
||||
|
||||
为了项目管理者更加方便地制定项目计划,产品经理提出了计划模板功能。当管理者选择对应的项目管理生命周期类型后,系统会自动创建满足其规则的初始计划。基于该需求,我们更新了之前的领域模型:
|
||||
|
||||
|
||||
|
||||
在增加的领域模型中,LifeCycle Specification 是一个隐含的概念,遵循领域驱动设计提出的规格(Specification)模式,封装了项目开发生命周期的约束规则。
|
||||
|
||||
领域模型以可视化的方式清晰地表达了业务含义,我们可以根据这个模型来指导后面的程序设计与编码实现。当增加新的需求或者需求发生变化时,我们能够敏锐地捕捉到现有模型的不匹配之处,并对其进行更新。领域模型传递了知识,可以作为交流的载体,符合人们的心智模型,有利于让开发人员从纷繁复杂的业务中解脱出来。这是领域驱动设计针对第04课中遇到的第三个问题——控制业务复杂度的解答。
|
||||
|
||||
|
||||
|
||||
|
73
专栏/领域驱动设计实践(完)/008软件开发团队的沟通与协作.md
Normal file
73
专栏/领域驱动设计实践(完)/008软件开发团队的沟通与协作.md
Normal file
@ -0,0 +1,73 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
008 软件开发团队的沟通与协作
|
||||
领域驱动设计的核心是“领域”,因此要运用领域驱动设计,从一开始就要让团队走到正确的点儿上。当我们组建好了团队之后,应该从哪里开始?不是 UI 原型设计、不是架构设计、也不是设计数据库,这些事情虽然重要但却非最高优先级。试想,项目已经启动,团队却并不了解整个系统的目标和范围,未对系统的领域需求达成共识,那么项目开发的航向是否会随着时间的推移而逐渐偏离?用正确的方法做正确的事情,运用领域驱动设计,就是要先识别问题域,进而为团队提炼达成共识的领域知识。
|
||||
|
||||
要做到这一点,就离不开团队各个角色的沟通与协作。客户的需求不是从一开始就生长在那里的,就好像在茫茫森林中的一棵树木,等待我们去“发现”它。相反,需求可能只是一粒种子,需要土壤、阳光与水分,在人们的精心呵护与培植下才能茁壮成长。因此,我们无法“发现”需求,而是要和客户一起“培育”需求,并在这个培育过程中逐渐成熟。
|
||||
|
||||
达成共识
|
||||
|
||||
“培育”需求的过程需要双向的沟通、反馈,更要达成对领域知识理解的共识。原始的需求是“哈姆雷特”,每个人心中都有一个“哈姆雷特”,如果没有正确的沟通与交流方式,团队达成的所谓“需求一致”不过是一种假象罢了。
|
||||
|
||||
由于每个人获得的信息不同,知识背景不同,又因为角色不同因而导致设想的上下文也不相同,诸多的不同使得我们在对话交流中好像被蒙了双眼的盲人,我们共同捕捉的需求就好似一头大象,各自只获得局部的知识,却自以为掌控了全局:
|
||||
|
||||
|
||||
|
||||
或许有人会认为客户提出的需求就应该是全部,我们只需理解客户的需求,然后积极响应这些需求即可。传统的开发合作模式更妄图以合同的形式约定需求知识,要求甲、乙双方在一份沉甸甸的需求规格说明书上签字画押,如此即可约定需求内容和边界,一旦发生超出该文档边界的变更,就需要将变更申请提交到需求变更委员会进行评审。这种方式从一开始就站不住脚,因为我们对客户需求的理解,存在三个方向的偏差:
|
||||
|
||||
|
||||
我们从客户那里了解到的需求,并非用户最终的需求;
|
||||
若无有效的沟通方式,需求的理解偏差则会导致结果大相径庭;
|
||||
理解到的需求并没有揭示完整的领域知识,从而导致领域建模与设计出现认知障碍。
|
||||
|
||||
|
||||
Jeff Patton 在《用户故事地图》中给出了一副漫画来描述共识达成的问题。我在 ThoughtWorks 给客户开展 Inception 活动时,也使用了这幅漫画:
|
||||
|
||||
|
||||
|
||||
这幅漫画形象地表现了如何通过可视化的交流形式逐渐在多个角色之间达成共识的过程。正如前面所述,在团队交流中,每个人都可能成为“盲人摸象的演员”。怎么避免认知偏差?很简单,就是要用可视化的方式表现出来,例如,绘图、使用便签、编写用户故事或测试用例等都是重要的辅助手段。在下一课,我会结合着领域场景分析来讲解这些提炼领域知识的手段。
|
||||
|
||||
可视化形式的交流可以让不同角色看到需求之间的差异。一旦明确了这些差异,就可以利用各自掌握的知识互补不足去掉有余,最终得到大家都一致认可的需求,形成统一的认知模型。
|
||||
|
||||
团队协作
|
||||
|
||||
在软件开发的不同阶段,团队协作的方式与目标并不相同。在项目的先启(Inception)阶段,团队成员对整个项目的需求完全一无所知,此时与客户或领域专家的沟通,应该主要专注于宏观层面的领域知识,例如,系统愿景和目标、系统边界与范围,还有主要的需求功能与核心业务流程。在管理层面,还需要在先启阶段确定团队与利益相关人(包括客户与领域专家)的沟通方式。
|
||||
|
||||
先启阶段
|
||||
|
||||
在敏捷开发过程中,我们非常重视在项目之初开展的先启阶段,尤其是有客户参与的先启阶段,是最好的了解领域知识的方法。如果团队采用领域驱动设计,就可以在先启阶段运用战略设计,建立初步的统一语言,在识别出主要的史诗级故事与主要用户故事之后,进而识别出限界上下文,并建立系统的逻辑架构与物理架构。
|
||||
|
||||
在先启阶段,与提炼领域知识相关的活动如下图所示:
|
||||
|
||||
|
||||
|
||||
上图列出的七项活动存在明显的先后顺序。首先我们需要确定项目的利益相关人,并通过和这些利益相关人的沟通,来确定系统的业务期望与愿景。在期望与愿景的核心目标指导下,团队与客户才可能就问题域达成共同理解。这时,我们需要确定项目的当前状态与未来状态,从而确定项目的业务范围。之后,就可以对需求进行分解了。在先启阶段,对需求的分析不宜过细,因此需求分解可以从史诗级(Epic)到主故事级(Master)进行逐层划分,并最终在业务范围内确定迭代开发需要的主故事列表。
|
||||
|
||||
迭代开发阶段
|
||||
|
||||
在迭代开发阶段,针对迭代生命周期和用户故事生命周期可以开展不同形式的沟通与协作。在这个过程中,所有沟通协作的关键点如下图所示:
|
||||
|
||||
|
||||
|
||||
迭代生命周期是针对迭代目标与范围进行需求分析与沟通的过程。团队首先要了解本次迭代的目标,对迭代中的每个任务要建立基本的领域知识的理解。在迭代开发过程中,我们可以借鉴 Scrum 敏捷管理的过程。
|
||||
|
||||
Scrum 要求团队在迭代开始之前召开计划会议,由产品负责人(Product Owner)在会议中向团队成员介绍和解释该迭代需要完成的用户故事,包括用户故事的业务逻辑与验收标准。团队成员对用户故事有任何不解或困惑,都可以通过这个会议进行沟通,初步达成领域知识的共识。每天的站立会议要求产品负责人参与,这就使得开发过程中可能出现的需求理解问题能够及时得到解答。Scrum Master 则通过每天的站立会议了解当前的迭代进度,并与产品负责人一起基于当前进度和迭代目标确定是否需要调整需求的优先级。迭代结束后,团队需要召开迭代演示会议,除了开发团队之外,该会议还可以邀请客户、最终用户以及领域专家参与,由团队的测试人员演示当前迭代已经完成的功能。这种产品演示的方法更容易消除用户、客户、领域专家、产品负责人与团队在需求沟通与理解上的偏差。由于迭代周期往往较短,即使发现了因为需求理解不一致导致的功能实现偏差,也能够做到及时纠偏,从而能够将需求问题扼杀于摇篮之中。
|
||||
|
||||
每一个功能的实现、每一行代码的编写都是围绕着用户故事开展的,它是构成领域知识的最基本单元。用户故事指导着开发人员的开发、测试人员的测试,其质量会直接影响领域驱动设计的质量。
|
||||
|
||||
敏捷方法非常重视发生在用户故事生命周期中的各个关键节点。对于用户故事的编写,敏捷开发实践强调业务分析人员与测试人员共同编写验收测试的自动化测试脚本,这在《实例化需求》一书中被称之为“活文档(Living Document)”。测试人员与需求分析人员的合作,可以为需求分析提供更多观察视角,尤其是异常场景的识别与验收标准的确认。
|
||||
|
||||
当用户故事从需求分析人员传递给开发人员时,不管这个用户故事的描述是多么的准确和详细,都有可能导致知识流失。因此,在开发人员领取了用户故事,并充分理解了用户故事描述的需求后,不要急匆匆地开始编码实现,而是建议将需求分析人员与测试人员叫过来,大家一起做一个极短时间的沟通与确认,我们称这一活动为“Kick Off”,这种方式实际就是对“盲人摸象”问题的一种应对。在这个沟通过程中,开发人员应尽可能地多问需求分析人员“为什么”,以探索用户故事带来的价值。只有如此,开发人员才能更好地理解业务逻辑与业务规则。同时,开发人员还要与测试人员再三确认验收标准,以形成一种事实上的需求规约。
|
||||
|
||||
当开发完成后,是否就意味着我们可以将实现的故事卡移交给测试呢?虽然通过迭代开发以及建立特性团队已经大大地拉近了开发人员与测试人员的距离,缩短了需求从开发到测试的周期。但我们认为,有价值的沟通与交流怎么强调都不过分!磨刀不误砍柴工。我们认为从开发完成到测试开始也是一个关键节点,建议在这个关键节点再进行一次交流活动,即在开发环境下,由开发人员向需求分析人员与测试人员“实地”演示刚刚完成的功能,并对照着验收标准进行验收,我们称这个过程为“Desk Check”,是一个快速迷你的功能演示,目的是快速反馈,也减少了任务卡在开发与测试之间频繁切换的沟通成本。
|
||||
|
||||
通过 Desk Check 的用户故事卡才会被移动到“待测试”,不用等到迭代结束,更不用等到版本发布,只要开发人员完成了用户故事,测试人员就应该在迭代周期内进行测试,未经过测试的用户故事其交付价值为 0,可以认为这张用户故事卡没有完成,这也是大多数敏捷实践对所谓“完成(Done)”的定义。无数研究与实践也证明了,修改 Bug 的成本会随着时间的推移而增加,如果在开发完成后即刻对其进行测试,一旦发现了 Bug,开发人员便能够快速响应,降低修改 Bug 的成本。当然,测试的过程同样是沟通与交流的过程,是最有效的需求验证和质量保障的手段。
|
||||
|
||||
敏捷思想强调个体和团队的协作与沟通,强调快速反馈与及时响应。前面探讨的这些敏捷实践都是行之有效的沟通机制和交流手段,可以帮助团队对需求的理解更加全面、更加准确。只有频繁的沟通,才能就业务需求达成整个团队的共识;只有良好的协作,才能有助于大家一起提炼领域知识,建立统一语言;只有快速反馈,才能尽可能保证领域模型与程序实现的一致。这些都是实践领域驱动设计的基本前提。
|
||||
|
||||
|
||||
|
||||
|
141
专栏/领域驱动设计实践(完)/009运用领域场景分析提炼领域知识(上).md
Normal file
141
专栏/领域驱动设计实践(完)/009运用领域场景分析提炼领域知识(上).md
Normal file
@ -0,0 +1,141 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
009 运用领域场景分析提炼领域知识(上)
|
||||
领域场景分析的 6W 模型
|
||||
|
||||
在软件构造过程中,我们必须正确地理解领域,一种生动的方式是通过“场景”来展现领域逻辑。领域专家或业务分析师从领域中提炼出“场景”,就好像是从抽象的三维球体中,切割出具体可见的一片,然后以这一片场景为舞台,上演各种角色之间的悲欢离合。每个角色的行为皆在业务流程的指引下展开活动,并受到业务规则的约束。当我们在描述场景时,就好像在讲故事,又好似在拍电影。
|
||||
|
||||
组成场景的要素常常被称之为 6W 模型,即描写场景的过程必须包含 Who、What、Why、Where、When 与 hoW 这六个要素,6W 模型如下图所示:
|
||||
|
||||
|
||||
|
||||
通过场景分析领域需求时,首先需要识别参与该场景的用户角色。我们可以为其建立用户画像(Persona),通过分析该用户的特征与属性来辨别该角色在整个场景中参与的活动。这意味着我们需要明确业务功能(What),思考这一功能给该角色能够带来什么样的业务价值(Why)。注意,这里所谓的“角色”是参差多态的,同一个用户在不同场景可能是完全不同的角色。例如,在电商系统中,倘若执行的是下订单功能,则角色就是买家;针对该订单发表评论,参与的角色就变成了评论者。
|
||||
|
||||
在 6W 模型中,我将领域功能划分为三个层次,即业务价值、业务功能和业务实现,我将其称之为“职责的层次”。定义为“职责(Responsibility)”才能够更好地体现它与角色之间的关系,即“角色履行了职责”。业务价值体现了职责存在的目的,即解释了该领域需求的 Why。只有提供了该职责,这个场景对于参与角色才是有价值的。为了满足业务价值,我们可以进一步剖析为了实现该价值需要哪些支撑功能,这些业务功能对应 6W 模型中的 What。进一步,我们对功能深入分析,就可以分析获得具体的业务实现。业务实现关注于如何去实现该业务价值,因而对应于 hoW。
|
||||
|
||||
在电商系统中,买家要购买商品,因而下订单这一职责是具有业务价值的。通过领域分析,结合职责的层次概念,我们就可以得到如下的职责分层结构:
|
||||
|
||||
|
||||
下订单
|
||||
|
||||
|
||||
验证订单是否有效
|
||||
|
||||
|
||||
验证订单是否为空
|
||||
验证订单信息是否完整
|
||||
验证订单当前状态是否处于“待提交”状态
|
||||
验证订单提交者是否为合法用户
|
||||
验证商品库存量是否大于等于订单中的数量
|
||||
|
||||
基于业务规则计算订单总价、优惠与配送费
|
||||
|
||||
|
||||
获取用户信息
|
||||
获取当前促销规则
|
||||
计算订单总价
|
||||
计算订单优惠
|
||||
计算商品配送费
|
||||
|
||||
提交订单
|
||||
|
||||
|
||||
将订单项插入到数据表中
|
||||
将订单插入到数据表中
|
||||
更新订单状态为“待付款”
|
||||
|
||||
更新购物车
|
||||
|
||||
|
||||
删除购物车中对应的商品
|
||||
|
||||
发送通知
|
||||
|
||||
|
||||
给买家发送电子邮件,通知订单提交成功,等待付款
|
||||
|
||||
|
||||
|
||||
|
||||
当我们获得这样的职责层次结构之后,就可以帮助我们更加细致地针对领域进行建模。在利用场景进行建模时,还要充分考虑场景的边界,即 6W 模型中的 Where。例如,在“下订单”的案例中,验证商品库存量的业务实现需要调用库存提供的接口,该功能属于下订单场景的边界之外。领域驱动设计引入了限界上下文(Bounded Context)来解决这一问题。
|
||||
|
||||
针对问题域提炼领域知识是一个空泛的概念,业务场景分析的 6W 模型给出了具有指导意义的约束,要求我们提炼的领域知识必须具备模型的六个要素,这就好比两位侃侃而谈的交谈者,因为有了确定的主题与话题边界,一场本来是漫无目的野鹤闲云似的闲聊就变成了一次深度交流的专题高端对话。6W 模型也是对领域逻辑的一种检验,如果提炼出来的领域逻辑缺乏部分要素,就有可能忽略一些重要的领域概念、规则与约束。这种缺失会对后续的领域建模直接产生影响。正本清源,按照领域场景分析的 6W 模型去分析领域逻辑,提炼领域知识,可以从一开始在一定程度上保证领域模型的完整性。
|
||||
|
||||
领域场景分析的方法
|
||||
|
||||
我发现许多主流的领域分析方法都满足领域场景分析的 6W 模型,如果将 6W 模型看做是领域分析的抽象,那么这些领域分析方法就是对 6W 模型各种不同的实现。Eric Evans 在《领域驱动设计》一书中并没有给出提炼领域知识的方法,而是给出工程师与领域专家的对话模拟了这个过程。在领域驱动设计中,团队与领域专家的对话必须是一种常态,但要让对话变得更加高效,使不同角色对相同业务的理解能够迅速达成一致,最佳的做法还是应该在团队中形成一种相对固定的场景分析模式,这些模式包括但不限于:
|
||||
|
||||
|
||||
用例(Use Case)
|
||||
用户故事(Use Story)
|
||||
测试驱动开发(Test Driven Development)
|
||||
|
||||
|
||||
用例
|
||||
|
||||
用例(Use Case)的概念来自 Ivar Jacobson,它帮助我们思考参与系统活动的角色,即用例中所谓的“参与者(Actor)”,然后通过参与者的角度去思考为其提供“价值”的业务功能。Jacobson 认为:“用例是通过某部分功能来使用系统的一种具体的方式……因此,用例是相关事务的一个具体序列,参与者和系统以对话的方式执行这些事务。……从用户的观点来看,每个用例都是系统中一个完整序列的事件。”显然,用例很好地体现了参与者与系统的一种交互,并在这种交互中体现出完整的业务价值。
|
||||
|
||||
用例往往通过用例规格说明来展现这种参与者与系统的交互,详细说明该用例的顺序流程。例如,针对“买家下订单”这个用例,编写的用例规格说明如下所示:
|
||||
|
||||
用例名称:买家下订单
|
||||
用例目的:本用例为买家提供了购买心仪商品的功能。
|
||||
参与者:买家
|
||||
前置条件:买家已经登录并将自己心仪的商品添加到了购物车。
|
||||
|
||||
基础流程:
|
||||
1. 买家打开购物车
|
||||
2. 买家提交订单
|
||||
3. 验证订单是否有效
|
||||
4. 计算订单总价
|
||||
5. 计算订单优惠
|
||||
6. 计算配送费
|
||||
7. 系统提交订单
|
||||
8. 删除购物车中对应的商品
|
||||
9. 系统通过电子邮件将订单信息发送给买家
|
||||
|
||||
替代流程:系统验证订单无效
|
||||
在第3步,系统确认订单无效,提示验证失败原因
|
||||
|
||||
替代流程:提交订单失败
|
||||
在第7步,系统提交订单失败,提示订单失败原因
|
||||
|
||||
|
||||
|
||||
虽然文本描述的用例规格说明会更容易地被业务分析人员和开发人员使用和共享,但是这种文本描述的形式其可读性较差,尤其是针对异常流程较多的复杂场景,非常不直观。UML 引入了用例图来表示用例,它是用例的一种模型抽象,通过可视化的方式来表示参与者与用例之间的交互,用例与用例之间的关系以及系统的边界。组成一个用例图的要素包括:
|
||||
|
||||
|
||||
参与者(Actor):代表了 6W 模型的 Who;
|
||||
用例(Use Case):代表了 6W 模型的 What;
|
||||
用例关系:包括使用、包含、扩展、泛化、特化等关系,其中使用(use)关系代表了 Why;
|
||||
边界(Boundary):代表了 6W 模型的 Where。
|
||||
|
||||
|
||||
通过用例图来表示上面的用例规格说明:
|
||||
|
||||
|
||||
|
||||
在这个用例图中,为什么只有 place order 用例与 buyer 参与者之间才存在使用(use)关系?我们可以看看上图中的所有用例,只有“下订单”本身对于买家而言才具有业务价值,也是买家“参与”该业务场景的主要目的。因此,我们可以将该用例视为体现这个领域场景的主用例,其他用例则是与该主用例产生协作关系的子用例。
|
||||
|
||||
用例之间的协作关系主要分为两种:
|
||||
|
||||
|
||||
包含(include)
|
||||
扩展(extend)
|
||||
|
||||
|
||||
如何理解包含与扩展之间的区别?大体而言,“包含”关系意味着子用例是主用例中不可缺少的一个执行步骤,如果缺少了该子用例,主用例可能会变得不完整。“扩展”子用例是对主用例的一种补充或强化,即使没有该扩展用例,对主用例也不会产生直接影响,主用例自身仍然是完整的。倘若熟悉面向对象设计与分析方法,可以将“包含”关系类比为对象之间的组合关系,如汽车与轮胎,是一种 must have 关系,而“扩展”关系就是对象之间的聚合关系,如汽车与车载音响,是一种 nice to have 关系。当然,在绘制用例图时,倘若实在无法分辨某个用例究竟是包含还是扩展,那就“跟着感觉走”吧,这种设计决策并非生死攸关的重大决定,即使辨别错误,几乎也不会影响到最后的设计。
|
||||
|
||||
无论是包含还是扩展,这些子用例都是为主用例服务,体现了用例规格描述的流程,即为 6W 模型中的 When 与 hoW。
|
||||
|
||||
根据用例代表的职责相关性,我们可以对用例图中的所有用例进行分类,从而划分用例的边界。确定用例相关性就是分析何谓内聚的职责,是根据关系的亲密程度来判断的。显然,上图中的 remove shopping cart items、notify buyer 与 validate inventory 与 place order 用例的关系,远不如 validate order 等用例与 place order 之间的关系紧密。因此,我们将这些用例与 order 分开,分别放到 shopping cart、notification 与 inventory 中,这是用例边界(Where)的体现。
|
||||
|
||||
用例图是领域专家与开发团队可以进行沟通的一种可视化手段,简单形象,还可以避免从一开始就陷入到技术细节中——用例的关注点就是领域。
|
||||
|
||||
绘制用例图时,切忌闭门造车,最好让团队一起协作。用例表达的领域概念必须精准!在为每个用例进行命名时,我们都应该采纳统一语言中的概念,然后以言简意赅的动宾短语描述用例,并提供英文表达。很多时候,在团队内部已经形成了中文概念的固有印象,一旦翻译成英文,就可能呈现百花齐放的面貌,这就破坏了“统一语言”。为保证用例描述的精准性,可以考虑引入“局外人”对用例提问,局外人不了解业务,任何领域概念对他而言可能都是陌生的。通过不断对用例表达的概念进行提问,团队成员就会在不断的阐释中形成更加清晰的术语定义,对领域行为的认识也会更加精确。
|
||||
|
||||
|
||||
|
||||
|
301
专栏/领域驱动设计实践(完)/010运用领域场景分析提炼领域知识(下).md
Normal file
301
专栏/领域驱动设计实践(完)/010运用领域场景分析提炼领域知识(下).md
Normal file
@ -0,0 +1,301 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
010 运用领域场景分析提炼领域知识(下)
|
||||
用户故事
|
||||
|
||||
敏捷开发人员对用户故事(User Story)绝不陌生,不过很多人并未想过为何极限编程的创始人 Kent Beck 要用用户故事来代替传统的“需求功能点”。传统的需求分析产生的是冷冰冰的需求文档,它把着重点放在了系统功能的精确描述上,却忽略了整个软件系统最重要的核心——用户。一个软件系统,只有用户在使用它的功能时才会真正产生价值。传统的功能描述忽略了在需求场景中用户的参与,因而缺乏了需求描写的“身临其境”。用户故事则站在了用户角度,以“讲故事”的方式来阐述需求;这种所谓“故事”其实就是对领域场景的描述,因而一个典型的用户故事,无论形式如何,实质上都是领域场景 6W 模型的体现。
|
||||
|
||||
一种经典的用户故事模板要求以如下格式来描述故事:
|
||||
|
||||
As a(作为)<角色>
|
||||
I would like(我希望)<活动>
|
||||
so that(以便于)<业务价值>
|
||||
|
||||
|
||||
|
||||
格式中的角色、活动与业务价值正好对应了 6W 模型的 Who、What 与 Why。形如这样的模板并非形式主义,而是希望通过这种显式的格式来推动需求分析师站在用户角色的角度,去挖掘隐藏在故事背后的“业务价值”。需求分析师要做一个好的故事讲述者,就需要站在角色的角度不停地针对用户故事去问为什么。
|
||||
|
||||
针对如下用户故事:
|
||||
|
||||
作为一名用户,
|
||||
我希望可以提供查询功能,
|
||||
以便于了解分配给我的任务情况。
|
||||
|
||||
|
||||
|
||||
我们可以询问如下问题:
|
||||
|
||||
|
||||
到底谁是用户?需要执行这一活动的角色到底是谁?
|
||||
为什么需要查询功能?
|
||||
究竟要查询什么样的内容?
|
||||
为什么需要了解分配给我的任务情况?
|
||||
|
||||
|
||||
显然前面给出的用户故事含糊不清,并没有清晰地表达业务目标。这样的用户故事并不利于我们提炼领域知识。倘若我们将用户识别为项目成员,则这个角色与项目跟踪管理这个场景才能够互相呼应。从角色入手,就可以更好地理解所谓的“业务价值”到底是什么?——项目成员希望跟踪自己的工作进度。如何跟踪工作进度?那就需要获得目前分配给自己的未完成任务。于是,前面的故事描述就应该修改为:
|
||||
|
||||
作为一名项目成员,
|
||||
我希望获取分配给自己的未完成任务,
|
||||
以便于跟踪自己的工作进度。
|
||||
|
||||
|
||||
|
||||
我以“获取”代替“查询”,是不希望在用户故事中主观地认定该功能一定是通过查询获得的。“查询(Query)”这个词语始终还是过于偏向技术实现,除非该用户故事本身就是描述搜索查询的业务。
|
||||
|
||||
显然,在这个用户故事中,“项目成员”是行为的发起者,“跟踪工作进度”是故事发生的“因”,是行为发起者真正关心的价值,为了获得这一价值,所以才“希望获取分配给自己的未完成任务”,是故事发生的果。通过这种深度挖掘价值,就可以帮助我们发现真正的业务功能。业务功能不是“需要提供查询功能”,而是希望系统提供“获取未完成任务”的方法。至于如何获取,则是技术实现层面的细节。
|
||||
|
||||
Dean Leffingwell 在《敏捷软件需求》一书中对这三部分做出了如下阐释:
|
||||
|
||||
|
||||
角色支持对产品功能的细分,而且它经常引出其他角色的需要以及相关活动的环境;活动通常表述相关角色所需的“系统需求”;价值则传达为什么要进行相关活动,也经常可以引领团队寻找能够提供相同价值而且更少工作量的替代活动。
|
||||
|
||||
|
||||
敏捷实践要求需求分析人员与测试人员结对编写用户故事,一个完整的用户故事必须是可测试(Testable)的,因此验收标准(Acceptance Criteria)是用户故事不可缺少的部分。所谓“验收标准”是针对系统设立的一些满足条件,因此这些标准并非测试的用例,而是对业务活动的细节描述,有时候甚至建议采用 Given-When-Then 模式结合场景来阐述验收标准,又或者通过实例化需求的方式,直接提供“身临其境”的案例。例如,针对电商的订单处理,需要为订单设置配送免费的总额阈值,用户故事可以编写为:
|
||||
|
||||
作为一名销售经理
|
||||
我希望为订单设置合适的配送免费的总额阈值
|
||||
以便于促进平均订单总额的提高
|
||||
|
||||
验收标准:
|
||||
* 订单总额的货币单位应以当前国家的货币为准
|
||||
* 订单总额阈值必须大于0
|
||||
|
||||
|
||||
|
||||
如果采用 Given-When-Then 模式,并通过实例化需求的方式编写用户故事,可以改写为:
|
||||
|
||||
作为一名销售经理
|
||||
我希望为订单设置合适的配送免费的总额阈值
|
||||
以便于促进平均订单总额的提高
|
||||
|
||||
场景1:订单满足配送免费的总额阈值
|
||||
Given:配送免费的总额阈值设置为95元人民币
|
||||
And:我目前的购物车总计90元人民币
|
||||
When:我将一个价格为5元人民币的商品添加到购物车
|
||||
Then:我将获得配送免费的优惠
|
||||
|
||||
场景2:订单不满足配送免费的总额阈值
|
||||
Given:配送免费的总额阈值设置为95元人民币
|
||||
And:我目前的购物车总计85元人民币
|
||||
When:我将一个价格为9元人民币的商品添加到购物篮
|
||||
Then:我应该被告知如果我多消费1元人民币,就能享受配送免费的优惠
|
||||
|
||||
|
||||
|
||||
第一个例子的验收标准更加简洁,适合于业务逻辑不是特别复杂的用户故事;Given-When-Then 模式的验收标准更加详细和全面,从业务流程的角度去描述,体现了 6W 模型的 hoW,但有时候显得过于冗余,编写的时间成本更大,这两种形式可以根据具体业务酌情选用。
|
||||
|
||||
编写用户故事时,可以参考行为驱动开发(Behavior-Driven Development,BDD)的实践,即强调使用 DSL(Domain Specific Language,领域特定语言)描述用户行为,编写用户故事。DSL 是一种编码实现,相比自然语言更加精确,又能以符合领域概念的形式满足所谓“活文档(Living Document)”的要求。
|
||||
|
||||
行为驱动开发的核心在于“行为”。当业务需求被划分为不同的业务场景,并以“Given-When-Then”的形式描述出来时,就形成了一种范式化的领域建模规约。使用领域特定语言编写用户故事的过程,就是不断发现领域概念的过程。这些领域概念会因为在团队形成共识而成为统一语言。这种浮现领域模型与统一语言的过程又反过来可以规范我们对用户故事的编写,即按照行为驱动开发的要求,将核心放在“领域行为”上。这就需要避免两种错误的倾向:
|
||||
|
||||
|
||||
从 UI 操作去表现业务行为
|
||||
描述技术实现而非业务需求
|
||||
|
||||
|
||||
例如,我们要编写“发送邮件”这个业务场景的用户故事,可能会写成这样:
|
||||
|
||||
Scenario: send email
|
||||
|
||||
Given a user "James" with password "123456"
|
||||
And I sign in
|
||||
And I fill in "[email protected]" in "to" textbox
|
||||
And fill in "test email" in "subject" textbox
|
||||
And fill in "This is a test email" in "body" textarea
|
||||
|
||||
When I click the "send email" button
|
||||
|
||||
Then the email should be sent sucessfully
|
||||
And shown with message "the email is sent sucessfully"
|
||||
|
||||
|
||||
|
||||
该用户故事描写的不是业务行为,而是用户通过 UI 进行交互的操作流程,这种方式实则是让用户界面捆绑了你对领域行为的认知。准确地说,这种 UI 交互操作并非业务行为,例如上述场景中提到的 button 与 textbox 控件,与发送邮件的功能并没有关系。如果换一个 UI 设计,使用的控件就完全不同了。
|
||||
|
||||
那么换成这样的写法呢?
|
||||
|
||||
Scenario: send email
|
||||
|
||||
Given a user "James" with password "123456"
|
||||
And I sign in after OAuth authentification
|
||||
And I fill in "[email protected]" as receiver
|
||||
And "test email" as subject
|
||||
And "This is a test email" as email body
|
||||
|
||||
When I send the email
|
||||
|
||||
Then it should connect smtp server
|
||||
And all messages should be composed to email
|
||||
And a composed email should be sent to receiver via smtp protocal
|
||||
|
||||
|
||||
|
||||
该用户故事的编写暴露了不必要的技术细节,如连接到 smtp 服务器、消息组合为邮件、邮件通过 smtp 协议发送等。我们在编写用户故事时,应该按照行为驱动开发的要求,关注于做什么(what),而不是怎么做(how)。如果在业务分析过程中,纠缠于技术细节,就可能导致我们忽略了业务价值。在业务建模阶段,业务才是重心,不能舍本逐末。
|
||||
|
||||
那么,该怎么写?
|
||||
|
||||
编写用户故事时,不要考虑任何 UI 操作,甚至应该抛开已设计好的 UI 原型,也不要考虑任何技术细节,不要让这些内容来干扰你对业务需求的理解。如果因为更换 UI 设计和调整 UI 布局,又或者因为改变技术实现方案,而需要修改编写好的用户故事,那就是不合理的。用户故事应该只受到业务规则与业务流程变化的影响。
|
||||
|
||||
让我们修改前面的用户故事,改为专注领域行为的形式编写:
|
||||
|
||||
Scenario: send email
|
||||
|
||||
Given a user "James" with password "123456"
|
||||
And I sign in
|
||||
And I fill in a subject with "test email"
|
||||
And a body with "This is a test email"
|
||||
|
||||
When I send the email to "Mike" with address "[email protected]"
|
||||
|
||||
Then the email should be sent sucessfully
|
||||
|
||||
|
||||
|
||||
只要发送邮件的流程与规则不变,这个用户故事就不需要修改。
|
||||
|
||||
测试驱动开发
|
||||
|
||||
测试驱动开发看起来与提炼领域知识风马牛不相及,那是因为我们将测试驱动开发固化为了一种开发实践。测试驱动开发强调“测试优先”,但实质上这种“测试优先”其实是需求分析优先,是任务分解优先。测试驱动开发强调,开发人员在分析了需求之后,并不是一开始就编写测试,而是必须完成任务分解。对任务的分解其实就是对职责的识别,且识别出来的职责在被分解为单独的任务时,必须是可验证的。
|
||||
|
||||
在进行测试驱动开发时,虽然要求从一开始就进行任务分解,但并不苛求任务分解是完全合理的。随着测试的推进,倘若我们觉察到一个任务有太多测试用例需要编写,则意味着分解的任务粒度过粗,应对其进行再次分解;也有可能会发现一些我们之前未曾发现的任务,则需要将它们添加到任务列表中。
|
||||
|
||||
例如,我们要实现一个猜数字的游戏。游戏有四个格子,每个格子有 0~9 的数字,任意两个格子的数字都不一样。玩家有 6 次猜测的机会,如果猜对则获胜,失败则进入下一轮直到六轮猜测全部结束。每次猜测时,玩家需依序输入 4 个数字,程序会根据猜测的情况给出形如“xAxB”的反馈。A 前面的数字代表位置和数字都对的个数,B 前面的数字代表数字对但位置不对的个数。例如,答案是 1 2 3 4,那么对于不同的输入,会有如下的输出:
|
||||
|
||||
|
||||
|
||||
|
||||
输入
|
||||
输出
|
||||
说明
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1 5 6 7
|
||||
1A0B
|
||||
1 位置正确
|
||||
|
||||
|
||||
|
||||
2 4 7 8
|
||||
0A2B
|
||||
2 和 4 位置都不正确
|
||||
|
||||
|
||||
|
||||
0 3 2 4
|
||||
1A2B
|
||||
4 位置正确,2 和 3 位置不正确
|
||||
|
||||
|
||||
|
||||
5 6 7 8
|
||||
0A0B
|
||||
没有任何一个数字正确
|
||||
|
||||
|
||||
|
||||
4 3 2 1
|
||||
0A4B
|
||||
4 个数字位置都不对
|
||||
|
||||
|
||||
|
||||
1 2 3 4
|
||||
4A0B
|
||||
胜出 全中
|
||||
|
||||
|
||||
|
||||
1 1 2 3
|
||||
输入不正确,重新输入
|
||||
|
||||
|
||||
|
||||
|
||||
1 2
|
||||
输入不正确,重新输入
|
||||
|
||||
|
||||
|
||||
|
||||
答案在游戏开始时随机生成,只有 6 次输入的机会。每次猜测时,程序会给出当前猜测的结果,如果猜测错误,还会给出之前所有猜测的数字和结果以供玩家参考。输入时,用空格分隔数字。
|
||||
|
||||
针对猜数字游戏的需求,我们可以分解出如下任务:
|
||||
|
||||
|
||||
随机生成答案
|
||||
判断每次猜测的结果
|
||||
检查输入是否合法
|
||||
记录并显示历史猜测数据
|
||||
判断游戏结果。判断猜测次数,如果满 6 次但是未猜对则判负;如果在 6 次内猜测的 4 个数字值与位置都正确,则判胜
|
||||
|
||||
|
||||
当在为分解的任务编写测试用例时,不应针对被测方法编写单元测试,而应该根据领域场景进行编写,这也是为何测试驱动开发强调测试优先的原因。由于是测试优先,事先没有被测的实现代码,就可以规避这种错误方式。
|
||||
|
||||
编写测试的过程是进一步理解领域逻辑的过程,更是驱动我们去寻找领域概念的过程。由于在编写测试的时候,没有已经实现的类,这就需要开发人员站在调用者的角度去思考,即所谓“意图导向编程”。从调用的角度思考,可以驱动我们思考并达到如下目的:
|
||||
|
||||
|
||||
如何命名被测试类以及方法,才能更好地表达设计者的意图,使得测试具有更好的可读性;
|
||||
被测对象的创建必须简单,这样才符合测试哲学,从而使得设计具有良好的可测试性;
|
||||
测试使我们只关注接口,而非实现;
|
||||
|
||||
|
||||
在编写测试方法时,应遵循 Given-When-Then 模式,这种方式描述了测试的准备、期待的行为以及验收条件。Given-When-Then 模式体现了 TDD 对设计的驱动力:
|
||||
|
||||
|
||||
当编写 Given 时,“驱动”我们思考被测对象的创建,以及它与其他对象的协作;
|
||||
当编写 When 时,“驱动”我们思考被测接口的方法命名,以及它需要接收的传入参数;考虑行为方式,究竟是命令式还是查询式方法;
|
||||
当编写 Then 时,“驱动”我们分析被测接口的返回值。
|
||||
|
||||
|
||||
例如,针对任务“判断每次的猜测结果”,我们首先要考虑由谁来执行此任务。从面向对象设计的角度来讲,这里的任务即“职责”,我们要找到职责的承担者。从拟人化的角度去思考所谓“对象”,就是要找到能够彻底理解(understand)该职责的对象。基于这样的设计思想,驱动我们获得了 Game 对象。进一步分析任务,由于我们需要判断猜测结果,这必然要求获知游戏的答案,从而寻找出表达了猜测结果这一领域知识的概念:Answer,这实际上就是以测试驱动的方式来帮助我们进行领域建模。
|
||||
|
||||
编写 When 可以帮助开发者思考类的行为,一定要从业务而非实现的角度去思考接口。例如:
|
||||
|
||||
|
||||
实现角度的设计:check()
|
||||
业务角度的设计:guess()
|
||||
|
||||
|
||||
注意两个方法命名表达意图的不同,显然后者更好地表达了领域知识。
|
||||
|
||||
编写 Then 考虑的是如何验证,没有任何验证的测试不能称其为测试。由于该任务为判断输入答案是否正确,并获得猜测结果,因而必然需要返回值。从需求来看,只需要返回一个形如 xAxB 的字符串即可。通过 Given-When-Then 模式组成了一个测试方法所要覆盖的领域场景,而测试方法自身则以描述业务的形式命名。例如,针对“判断每次猜测的结果”任务,可以编写其中的一个测试方法:
|
||||
|
||||
@Test
|
||||
public void should_return_0A0B_when_no_number_is_correct() {
|
||||
//given
|
||||
Answer actualAnswer = Answer.createAnswer("1 2 3 4");
|
||||
Game game = new Game(actualAnswer);
|
||||
Answer inputAnswer = Answer.createAnswer("5 6 7 8");
|
||||
|
||||
//when
|
||||
String result = game.guess(inputAnswer);
|
||||
|
||||
//then
|
||||
assertThat(result , is("0A0B"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
测试方法名可以足够长,以便于清晰地表述业务。为了更好地辨别方法名表达的含义,我们提倡用 Ruby 风格的命名方法,即下划线分隔方法的每个单词,而非 Java 传统的驼峰风格。建议测试方法名以 should 开头,此时,默认的主语为被测类,即这里的 Game。因此,该测试方法就可以阅读为:Game should return 0A0B when no number guessed correctly。显然,这是一条描述了业务规则的自然语言。
|
||||
|
||||
这三种方法各有风格,驱动领域场景的力量也各自不同,甚至这些方法在开发实践中并非处于同一个维度,然而在领域场景分析这个大框架下,又都直接或间接体现了场景的 6W 模型。当然,这里展现的仅仅是这些方法的冰山一角,讲解的侧重点还是在于通过这些方法来帮助我们提炼领域知识。同时,借助类似用例、用户故事、任务等载体,可以更加有效而直观地帮助我们理解问题域,抽象领域模型,从而为我们建立统一语言奠定共识基础。
|
||||
|
||||
提炼领域知识
|
||||
|
||||
提炼领域知识需要贯穿整个领域驱动设计全过程,无论何时,都必须重视领域知识,并时刻维护统一语言。在进行领域场景分析时,这是一个双向的过程。一方面,我们已提炼出来的领域知识会指导我们识别用例,编写用户故事以及测试用例;另一方面,具体的领域场景分析方法又可以进一步帮助我们确认领域知识,并将在团队内达成共识的统一语言更新到之前识别的领域知识中。
|
||||
|
||||
这种双向的指导与更新非常重要,因为我们提炼的领域知识以及统一语言是领域模型的重要源头。“问渠那得清如许,为有源头活水来。”,只有源头保证了常新,领域模型才能保证健康,才能更好地指导领域驱动设计。
|
||||
|
||||
通过前面对用例、用户故事与测试驱动开发的介绍,我们发现这三个方法虽然都是领域场景分析的具体实现,但它们在运用层次上各有其优势。用例尤其是用例图的抽象能力更强,更擅长于对系统整体需求进行场景分析;用户故事提供了场景分析的固定模式,善于表达具体场景的业务细节;测试驱动开发则强调对业务的分解,利用编写测试用例的形式驱动领域建模,即使不采用测试先行,让开发者转换为调用者角度去思考领域对象及行为,也是一种很好的建模思想与方法。
|
||||
|
||||
在提炼领域知识的过程中,我们可以将这三种领域场景分析方法结合起来运用,在不同层次的领域场景中选择不同的场景分析方法,才不至于好高骛远,缺乏对细节的把控,也不至于一叶障目,只见树木不见森林。
|
||||
|
||||
|
||||
|
||||
|
148
专栏/领域驱动设计实践(完)/011建立统一语言.md
Normal file
148
专栏/领域驱动设计实践(完)/011建立统一语言.md
Normal file
@ -0,0 +1,148 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
011 建立统一语言
|
||||
统一语言是提炼领域知识的产出物,获得统一语言就是需求分析的过程,也是团队中各个角色就系统目标、范围与具体功能达成一致的过程。
|
||||
|
||||
使用统一语言可以帮助我们将参与讨论的客户、领域专家与开发团队拉到同一个维度空间进行讨论,若没有达成这种一致性,那就是鸡同鸭讲,毫无沟通效率,相反还可能造成误解。因此,在沟通需求时,团队中的每个人都应使用统一语言进行交流。
|
||||
|
||||
一旦确定了统一语言,无论是与领域专家的讨论,还是最终的实现代码,都可以通过使用相同的术语,清晰准确地定义领域知识。重要的是,当我们建立了符合整个团队皆认同的一套统一语言后,就可以在此基础上寻找正确的领域概念,为建立领域模型提供重要参考。
|
||||
|
||||
统一语言体现在两个方面:
|
||||
|
||||
|
||||
统一的领域术语
|
||||
领域行为描述
|
||||
|
||||
|
||||
统一的领域术语
|
||||
|
||||
形成统一的领域术语,尤其是基于模型的语言概念,是沟通能够达成一致的前提。尤其是开发人员与领域专家之间,他们掌握的知识存在巨大的差异。善于技术的开发人员关注于数据库、通信机制、集成方式与架构体系,而精通业务的领域专家对这些却一窍不通,但他们在讲解业务知识时,使用各种概念如呼吸一般自然,这些对于开发人员来说,却成了天书,这种交流就好似使用两种不同语言的外国人在交谈。记得有一次我去洛杉矶出差,居住期间,需要到一家洗衣店干洗衣服,交付完衣服后,我想向洗衣店老板索要收据,以作为之后领取衣服的凭证。好不容易在我脑中贫瘠的英文词典里搜索到 receipt 这个词语,自以为正确,谁知道讲出来后老板一脸茫然,不是 receipt,那么是 ……invoice?手舞足蹈说了半天,老板才反应过来,递过来一张收据,嘴里吐出 ticket 这个词语,My God,受了中学英语的流毒,我还以为 ticket 这个词语只能用到电影院呢。
|
||||
|
||||
显然,从需求中提炼出统一语言,其实就是在两个不同的语言世界中进行正确翻译的过程。
|
||||
|
||||
某些领域术语是有行业规范的,例如财会领域就有标准的会计准则,对于账目、对账、成本、利润等概念都有标准的定义,在一定程度上避免了分歧。然而,标准并非绝对的,在某些行业甚至存在多种标准共存的现象。以民航业的运输统计指标为例,牵涉到与运量、运力以及周转量相关的术语,就存在 ICAO(International Civil Aviation Organization,国际民用航空组织)与IATA(International Air Transport Association,国际航空运输协会)两大体系,而中国民航局又有自己的中文解释,航空公司和各大机场亦有自己衍生的定义。
|
||||
|
||||
例如,针对一次航空运输的运量,就要分为城市对与航段的运量统计。城市对运量统计的是出发城市到目的城市两点之间的旅客数量,机场将其称之为流向。ICAO 定义的领域术语为 City-pair(OFOD),而 IATA 则命名为 O & D。航段运量又称为载客量,指某个特定航段上所承载的旅客总数量,ICAO将其定义为 TFS(Traffic by flight stage),而 IATA 则称为 Segment Traffic。
|
||||
|
||||
即使针对航段运量这个术语,我们还需要明确地定义这个运量究竟指的是载客量,还是包含了该航段上承载的全部旅客、货物与邮件数量;我们还需要明确城市对与航段之间的区别,它们在指标统计时,实则存在细微的差异,一不小心忽略,结果就可能谬以千里。以航班 CZ5724 为例,该航班从北京(目的港代码 PEK)出发,经停武汉(目的港代码 WUH)飞往广州(目的港代码 CAN)。假定从北京到武汉的旅客数为 105,从北京到广州的旅客数为 14,从武汉到广州的旅客数为 83,则统计该次航班的城市对运量,应该分为三个城市对分别统计,即统计 PEK-WUH、PEK-CAN、WUH-CAN。而航段运量的统计则仅仅分为两个航段 PEK-WUH 与 WUH-CAN,至于从北京到广州的 14 名旅客,这个数量值则被截分为了两段,分别计数,如下图所示:
|
||||
|
||||
|
||||
|
||||
显然,如果我们不明白城市对运量与航段运量的真正含义,就可能混淆这两种指标的统计计算规则。这种术语理解错误带来的缺陷往往难以发现,除非业务分析人员、开发人员与测试人员能就此知识达成一致的正确理解。
|
||||
|
||||
在领域建模过程中,我们往往需要在文档中建立一个大家一致认可的术语表。术语表中需要包括整个团队精炼出来的术语概念,以及对该术语的清晰明白的解释。若有可能,可以为难以理解的术语提供具体的案例。该术语表是领域建模的关键,是模型的重要参考规范,能够真实地反应模型的领域意义。一旦发生变更,也需要及时地对其进行更新。
|
||||
|
||||
在维护领域术语表时,一定需要给出对应的英文术语,否则可能直接影响到代码实现。在我们的一个产品开发中,根据需求识别出了“导入策略”的领域概念。由于这个术语非常容易理解,团队就此达成了一致,却没有明确给出英文名称,最后导致前端和后端在开发与“导入策略”有关的功能时,分别命名为 ImportingPolicy 与 ImportingStrategy,人为地制造了混乱。
|
||||
|
||||
即使术语的英语并不需要对外暴露给用户,我们仍然需要引起重视,就算不强调英文翻译的纯正,也必须保证概念的一致性,倘若认为英文表达不合理或者不标准,牵涉到对类、方法的重命名,则需要统一修改。在大数据分析领域中,针对“维度”与“指标”两个术语,我们在过去开发的产品中就曾不幸地衍生出了两套英文定义,分别为 Dimension 与 Metric,Category 与 Measure,这种混乱让整个团队的开发成员痛苦不堪,带来了沟通和交流的障碍。就我而言,我宁愿代码命名没有正确地表达领域概念,也不希望出现命名上的不一致性。倘若在建模之初就明确母语和英语的术语表达,就可以做到正本清源!
|
||||
|
||||
领域行为描述
|
||||
|
||||
从某种程度讲,领域行为描述可以视为领域术语甄别的一种延伸。领域行为是对业务过程的描述,相对于领域术语而言,它体现了更加完整的业务需求以及复杂的业务规则。在描述领域行为时,需要满足以下要求:
|
||||
|
||||
|
||||
从领域的角度而非实现角度描述领域行为
|
||||
若涉及到领域术语,必须遵循术语表的规范
|
||||
强调动词的精确性,符合业务动作在该领域的合理性
|
||||
要突出与领域行为有关的领域概念
|
||||
|
||||
|
||||
例如,在项目管理系统中,倘若我们采用 Scrum 的敏捷项目管理流程,要描述 Sprint Backlog 的任务安排,则编写的用户故事如下所示:
|
||||
|
||||
作为一名Scrum Master,
|
||||
我希望将Sprint Backlog分配给团队成员,
|
||||
以便于明确Backlog的负责人并跟踪进度。
|
||||
|
||||
验收标准:
|
||||
* 被分配的Sprint Backlog没有被关闭
|
||||
* 分配成功后,系统会发送邮件给指定的团队成员
|
||||
* 一个Sprint Backlog只能分配给一个团队成员
|
||||
* 若已有负责人与新的负责人为同一个人,则取消本次分配
|
||||
* 每次对Sprint Backlog的分配都需要保存以便于查询
|
||||
|
||||
|
||||
|
||||
用户故事中的分配(assign)Sprint Backlog 给团队成员就是一种领域行为,这种行为是在特定上下文中由角色触发的动作,并由此产生的业务流程和操作结果。同时,这种领域行为还是一种契约,明确地表达了服务提供者与消费者之间的业务关系,即明确了领域行为的前置条件、执行主语和宾语以及行为的执行结果,这些描述丰富了该领域的统一语言,并直接影响了 API 的设计。例如,针对分配 Sprint Backlog 的行为,用户故事就明确了未关闭的 SprintBacklog 只能分配给一个团队成员,且不允许重复分配,这体现了分配行为的业务规则。验收标准中提出对分配的保存,实际上也帮助我们得到了一个领域概念 SprintBacklogAssignment,该行为的代码实现如下所示:
|
||||
|
||||
package practiceddd.projectmanager.scrumcontext.domain;
|
||||
|
||||
import practiceddd.projectmanager.dddcore.Entity;
|
||||
import practiceddd.projectmanager.scrumcontext.domain.exception.InvalidAssignmentException;
|
||||
import practiceddd.projectmanager.scrumcontext.domain.exception.InvalidBacklogException;
|
||||
import practiceddd.projectmanager.scrumcontext.domain.role.MemberId;
|
||||
import practiceddd.projectmanager.scrumcontext.domain.role.TeamMember;
|
||||
|
||||
public class SprintBacklog extends Entity<BacklogId> {
|
||||
private String title;
|
||||
private String description;
|
||||
private BacklogStatus backlogStatus;
|
||||
private MemberId ownerId;
|
||||
|
||||
public SprintBacklog(BacklogId backlogId, String title, String description) {
|
||||
if (title == null) {
|
||||
throw new InvalidBacklogException("the title of backlog can't be null");
|
||||
}
|
||||
|
||||
this.id = backlogId;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.backlogStatus = new NewBacklogStatus();
|
||||
}
|
||||
|
||||
public SprintBacklogAssignment assignTo(TeamMember assignee) {
|
||||
if (this.backlogStatus.isClosed()) {
|
||||
throw new InvalidAssignmentException(
|
||||
String.format("The closed sprint backlog %s can not be assigned to %s.", this.title, assignee.getName()));
|
||||
}
|
||||
if (assignee.isSame(this.ownerId)) {
|
||||
throw new InvalidAssignmentException(
|
||||
String.format("The sprint backlog %s not allow to assign to same team member %s.", this.title, assignee.getName()));
|
||||
}
|
||||
return new SprintBacklogAssignment(this.id, assignee.id());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
基于“信息专家模式”,SprintBacklog 类的 assignTo() 方法只承担了它能够履行的职责。作为 SprintBacklog 对象自身,它知道自己的状态,知道自己是否被分配过,分配给谁,也知道遵循不同的业务规则会导致产生不同的结果。但由于它不具备发送邮件的知识,针对邮件发送它就无能为力了,因此这里实现的 assignTo() 方法仅仅完成了部分领域行为,若要完成整个用户故事描述的业务场景,需要交给领域服务 AssignSprintBacklogService 来完成:
|
||||
|
||||
package practiceddd.projectmanager.scrumcontext.domain;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import practiceddd.projectmanager.scrumcontext.domain.role.TeamMember;
|
||||
import practiceddd.projectmanager.scrumcontext.interfaces.notification.NotificationService;
|
||||
|
||||
@Service
|
||||
public class AssignSprintBacklogService {
|
||||
@Autowired
|
||||
private SprintBacklogRepository backlogRepository;
|
||||
@Autowired
|
||||
private SprintBacklogAssignmentRepository assignmentRepository;
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
|
||||
public void assign(SprintBacklog backlog, TeamMember assignee) {
|
||||
SprintBacklogAssignment assignment = backlog.assignTo(assignee);
|
||||
backlogRepository.update(backlog);
|
||||
assignmentRepository.add(assignment);
|
||||
|
||||
AssignmentNotification notification = new AssignmentNotification(assignment);
|
||||
notificationService.send(notification.address(), notification.content());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
注意:我在这里将发送邮件的行为定义为领域行为,因此分配 Sprint Backlog 的业务行为被定义在了领域服务 AssignSprintBacklogService 中。如果将发送邮件视为是一种横切关注点,正确的做法则是将发送邮件的调用放到应用服务 SprintBacklogAppService 中。当然,一旦将该逻辑放到了应用服务,就存在如何组装邮件内容的问题,即前述方法中对 AssignmentNotification 实例的创建。针对这些疑问和解决方案在后续内容都有详细介绍。
|
||||
|
||||
定义和确定统一语言,将有利于消除领域专家与团队、以及团队成员之间沟通的分歧与误解,使得各种角色能够在相同的语境下行事,避免盲人摸象的“视觉”障碍。领域的统一语言还是领域建模的重要输入与基础,无论是采用“名词动词法”进行领域建模,还是“四色建模法”或“职责驱动建模”,统一语言都是确定模型的重要参考。如果在确定统一语言的同时,针对领域概念与领域行为皆以英文来表达,就直接为编码实现提供了类、方法、属性等命名的依据,保证代码自身就能直观表达领域含义,提高代码可读性。
|
||||
|
||||
磨刀不误砍柴工,多花一些时间去打磨统一语言,并非时间的浪费,相反还能改进领域模型乃至编码实现的质量,反过来,领域模型与实现的代码又能避免统一语言的“腐化”,保持语言的常新。重视统一语言,就能促成彼此正面影响的良性循环;否则领域模型与代码会因为沟通不明而泥足深陷,就真是得不偿失了。
|
||||
|
||||
|
||||
|
||||
|
118
专栏/领域驱动设计实践(完)/012理解限界上下文.md
Normal file
118
专栏/领域驱动设计实践(完)/012理解限界上下文.md
Normal file
@ -0,0 +1,118 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
012 理解限界上下文
|
||||
理解限界上下文的定义
|
||||
|
||||
什么是限界上下文(Bounded Context)?让我们来读一个句子:
|
||||
|
||||
|
||||
wǒ yǒu kuài dì
|
||||
|
||||
|
||||
到底是什么意思?
|
||||
|
||||
|
||||
|
||||
我们能确定到底是哪个意思吗?确定不了!!! 我们必须结合说话人的语气与语境来理解,例如:
|
||||
|
||||
|
||||
wǒ yǒu kuài dì,zǔ shàng liú xià lái de → 我有块地,祖上留下来的。
|
||||
wǒ yǒu kuài dì,shùn fēng de → 我有快递,顺丰的。
|
||||
|
||||
|
||||
在日常的对话中,说话的语气与语境就是帮助我们理解对话含义的上下文(Context)。当我们在理解系统的领域需求时,同样需要借助这样的上下文,而限界上下文的含义就是用一个清晰可见的边界(Bounded)将这个上下文勾勒出来,如此就能在自己的边界内维持领域模型的一致性与完整性。Eric Evans 用细胞来形容限界上下文,因为“细胞之所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。”这里,细胞代表上下文,而细胞膜代表了包裹上下文的边界。
|
||||
|
||||
因此,若要理解限界上下文,就需要从 Bounded 与 Context 这两个单词的含义来理解,Context 表现了业务流程的场景片段。整个业务流程由诸多具有时序的活动组成,随着流程的进行,不同的活动需要不同的角色参与,并导致上下文因为某个活动的产生随之发生切换。因而,上下文(Context)其实是动态的业务流程被边界(Bounded)静态切分的产物。
|
||||
|
||||
假设有这样一个业务场景:我作为一名咨询师从成都出发前往深圳为客户做领域驱动咨询,无论是从家乘坐地铁到达成都双流机场,还是乘坐飞机到达深圳宝安,再从宝安机场乘坐出租车到达酒店,我的身份都是一名乘客(Passenger),虽然因为交通工具的不同,参与的活动也不尽相同,但无论上车、下车,还是办理登机手续、安检、登机和下机等活动,终归都与交通出行有关。那么,我坐在交通工具上就一定代表我属于这个上下文吗?未必!注意在交通出行上下文中,其实模糊了“我”这个概念,强调了“乘客”这个概念,这是参与到该上下文的角色(Role),或者说“身份”。
|
||||
|
||||
例如,我在飞机上,忽然想起给客户提供的咨询方案还需要完善,于是我拿出电脑,在一万米高空上继续思考我的领域驱动设计方案,这时的我虽然还在飞机上,身份却切换成了一名咨询师(Consultant)。当我作为乘客乘坐出租车前往酒店,并到前台办理入住手续时,我又“撕下了乘客的面具”,摇身一变成为了酒店的宾客(Guest)。次日早晨,我在酒店餐厅用完早餐后,离开酒店前往客户公司。随着我走出酒店这个活动的发生,酒店上下文又切换回交通出行。当我到达客户所在地时,面对客户,我开始以一名咨询师身份与客户团队交谈,了解他们的咨询目标与现有痛点。我制定咨询计划与方案,并与客户一起评审咨询方案,这时的上下文就切换为咨询工作了。巧合的是,无论是交通出行还是酒店,都需要支付费用,支付的费用虽然不同,支付的行为也有所差别,需要用到的领域知识却是相同的,因此这个活动又可以归为支付上下文。
|
||||
|
||||
上下文在流程中的切换犹如电影画面的场景切换,相同的人物扮演了不同的角色,在不同的上下文参与了不同的活动。由于活动的目标发生了改变,履行的职责亦有所不同,上述场景如下图所示:
|
||||
|
||||
|
||||
|
||||
整个业务流程由诸多活动(Actions)组成,参与这些活动的有不同的角色。在每一个上下文中,角色与角色之间通过活动产生协作,以满足业务流程的需求。这些活动是分散的,活动的目标也不相同,但在同一个上下文中,这些活动却是为同一个目标提供服务。
|
||||
|
||||
因此,在理解限界上下文时,我们需要重视几个关键点:
|
||||
|
||||
|
||||
知识:不同的限界上下文需要的领域知识是不相同的,这实则就是业务相关性,参与到限界上下文中的活动也与“知识”有关。如果执行该活动却不具备对应知识,则说明对活动的分配不合理;如果该活动的目标与该限界上下文保持一致,却缺乏相应知识,则说明该活动需要与别的限界上下文协作。
|
||||
角色:一定要深入思考参与到这个上下文的对象究竟扮演了什么样的角色,以及角色与角色在这个上下文中是如何协作的。
|
||||
边界:限界上下文按照不同关注点进行分离,各自的边界则根据耦合关系的强弱来确定,越是关系最弱的地方,越是需要划定边界。
|
||||
|
||||
|
||||
我们需要根据业务相关性、耦合的强弱程度、分离的关注点对这些活动进行归类,找到不同类别之间存在的边界,这就是限界上下文的含义。上下文(Context)是业务目标,限界(Bounded)则是保护和隔离上下文的边界,避免业务目标的不单一而带来的混乱与概念的不一致。
|
||||
|
||||
理解限界上下文的价值
|
||||
|
||||
Eric Evans 是在战略设计中引入限界上下文概念的,他认为:
|
||||
|
||||
|
||||
既然无法维护一个涵盖整个企业的统一模型,那就不要再受到这种思路的限制。通过预先决定什么应该统一,并实际认识到什么不能统一,我们就能够创建一个清晰的、共同的视图,然后需要用一种方式来标记出不同模型之间的边界和关系。
|
||||
|
||||
为了解决多个模型的问题,我们需要明确地定义模型的范围——模型的范围是软件系统中一个有界的部分,这部分只应用一个模型,并尽可能使其保持统一。团队组织中必须一致遵守这个定义。
|
||||
|
||||
明确地定义模型所应用的上下文。根据团队的组织、软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界,在这些边界中严格保持模型的一致性,而不要受到边界之外问题的干扰和混淆。
|
||||
|
||||
|
||||
基于以上引用的三段描述,我们可以清晰地勾勒出 Eric Evans 对于限界上下文的着眼点,那就是对边界的控制。倘若将上下文视为一国,则领域之王就应该捍卫国土疆域,国界内的一寸一尺之地都是神圣不可侵犯的。因而,我们要理解限界上下文的价值,就须得从边界来理解。
|
||||
|
||||
观察角度的不同,限界上下文划定的边界也有所不同。大体可以分为如下三个方面:
|
||||
|
||||
|
||||
领域逻辑层面:限界上下文确定了领域模型的业务边界,维护了模型的完整性与一致性,从而降低系统的业务复杂度。
|
||||
团队合作层面:限界上下文确定了开发团队的工作边界,建立了团队之间的合作模式,避免团队之间的沟通变得混乱,从而降低系统的管理复杂度。
|
||||
技术实现层面:限界上下文确定了系统架构的应用边界,保证了系统层和上下文领域层各自的一致性,建立了上下文之间的集成方式,从而降低系统的技术复杂度。
|
||||
|
||||
|
||||
这三种边界体现了限界上下文对不同边界的控制力。业务边界是对领域模型的控制,工作边界是对开发协作的控制,应用边界是对技术风险的控制。引入限界上下文的目的,其实不在于如何划分边界,而在于如何控制边界。
|
||||
|
||||
我曾经有机会向 EventStorming 的创始人 Alberto Brandolini 请教他对限界上下文的理解,他做了一个非常精彩的总结:bounded context are a mean of safety(限界上下文意味着安全)。这里的 safety 做何解呢?他的意思是:being in control and no surprise,对限界上下文是可控制的,就意味着你的系统架构与组织结构都是可控的;没有出乎意料的惊讶,虽然显得不够浪漫,但其实只有这样才能使得团队避免过大的压力。Alberto 告诉我:
|
||||
|
||||
|
||||
Surprise leads to stress and stress leads to no learning, just hard work. (出乎意料的惊讶会导致压力,而压力就会使得团队疲于加班,缺少学习。)
|
||||
|
||||
|
||||
这是真正看破限界上下文本质的大师高论!显然,限界上下文并不是像大多数程序员理解的那样,是模块、服务、组件或者子系统,而是你对领域模型、团队合作以及技术风险的控制。在《Entity Framework 模型在领域驱动设计限界上下文中的应用》一文中,作者 Juelie Lerman 认为:“当开发一个具有大型领域模型的超大规模的应用程序时,与设计一个单一的大领域模型相比,将大领域模型根据应用程序的业务需要“切割”成一系列较小的模型是非常重要的,我们也往往能够从中获得更多的好处。”她还提到:“更小的模型为我们的软件设计和开发带来了更多的好处,它使得团队能够根据自己的设计和开发职责确定更为明确的工作边界。小的模型也为项目带来了更好的可维护性:由于上下文由边界确定,因此对其的修改也不会给整个模型的其他部分造成影响。”显然,通过限界上下文对领域模型进行分解,就能保证在其边界内创建的模型内聚性更高,在边界隔离下,受到变化的影响也更小,反映为团队合作的工作边界,就更容易保证团队之间的沟通与协作。
|
||||
|
||||
限界上下文是“分而治之”架构原则的体现,我们引入它的目的其实为了控制(应对)软件的复杂度,它并非某种固定的设计单元,我们不能说它就是模块、服务或组件,而是通过它来帮助我们做出高内聚低耦合的设计。只要遵循了这个设计,则限界上下文就可能成为模块、服务或组件。所以,文章《Bounded Contexts as a Strategic Pattern Beyond DDD》才会写到:“限界上下文体现的是高层的抽象机制,它并非编程语言或框架的产出工件,而是体现了人们对领域思考的本质。”
|
||||
|
||||
宋代禅宗大师青原行思提出参禅的三重境界:
|
||||
|
||||
|
||||
参禅之初:看山是山,看水是水;
|
||||
禅有悟时:看山不是山,看水不是水;
|
||||
禅中彻悟:看山仍然山,看水仍然是水。
|
||||
|
||||
|
||||
我觉得理解限界上下文与模块、服务或组件的关系,似乎也存在这三重境界:
|
||||
|
||||
|
||||
参悟之初:模块、服务或组件就是限界上下文。
|
||||
当有悟时:模块、服务或组件不是限界上下文。
|
||||
彻底悟透:模块、服务或组件仍然是限界上下文。
|
||||
|
||||
|
||||
能理解吗?——更糊涂了!好吧,以上三重境界纯属忽悠,还是让我上一点干货吧。注意了,我要提到一个重要的概念,就是“自治”,抛开模块、服务或组件对你的影响,请大家先把限界上下文看做是一个“自治”的单元。所谓“自治”就是满足四个特征:最小完备、稳定空间、自我履行、独立进化。如下图所示的自治单元就是限界上下文,映射到编码实现,则可能是模块、组件或服务:
|
||||
|
||||
|
||||
|
||||
最小完备是实现“自治”的基本条件。所谓“完备”,是指自治单元履行的职责是完整的,无需针对自己的信息去求助别的自治单元,这就避免了不必要的依赖关系。而“最小完备”则进一步地限制了完备的范围,避免将不必要的职责被错误地添加到该自治单元上。对于限界上下文而言,就是要根据业务价值的完整性进行设计。例如,对于支付上下文,其业务价值就是“安全地完成在线支付业务”,那么在确定限界上下文的时候,就应该以完成该业务价值的最小功能集为设计边界。
|
||||
|
||||
自我履行意味着由自治单元自身决定要做什么。从拟人的角度来思考,就是这些自治单元能够对外部请求做出符合自身利益的明智判断,是否应该履行该职责,由限界上下文拥有的信息来决定。例如,可以站在自治单元的角度去思考:“如果我拥有了这些信息,我究竟应该履行哪些职责?”这些职责属于当前上下文的活动范围,一旦超出,就该毫不犹豫地将不属于该范围的请求转交给别的上下文。例如,在当订单上下文履行了验证订单的职责之后,需要执行支付活动时,由于与支付相关的业务行为要操作的信息已经超出了订单上下文的范畴,就应该将该职责转移到支付上下文。自我履行其实意味着对知识的掌握,为避免风险,你要履行的职责一定是你掌握的知识范畴之内。
|
||||
|
||||
稳定空间指的是减少外界变化对限界上下文内部的影响。自治的设计就是要划定分属自己的稳定空间,让自治单元拥有空间内的掌控权,保持空间的私密性,开放空间接口应对外部的请求。划分自治空间,需要找到限界上下文之间的间隙处,然后依势而为,沿着间隙方向顺势划分,而所谓“间隙”,其实就是依赖最为薄弱之处。例如,在电商系统中,管理商品上架、下架与评价商品都与商品直接相关,但显然评价商品与商品的依赖关系更弱。倘若需要分解限界上下文,保证上下文的稳定性,就可以将评价商品的职责从商品上下文中分离出去,但却不能分离商品上架和下架功能。稳定空间符合开放封闭原则(OCP),即对修改是封闭的,对扩展是开放的,该原则其实体现了一个单元的封闭空间与开放空间。封闭空间体现为对细节的封装与隐藏,开放空间体现为对共性特征的抽象与统一,二者共同确保了整个空间的稳定。
|
||||
|
||||
独立进化与稳定空间刚好相反,指的是减少限界上下文的变化对外界的影响。如果借用限界上下文的上下游关系来阐释,则稳定空间寓意下游限界上下文,无论上游怎么变,我自岿然不动;独立进化寓意上游限界上下文,无论下游有多少,我凌寒独自开。实现上看,要做到独立进化,就必须保证对外公开接口的稳定性,因为这些接口往往被众多消费者使用,一旦修改,就会牵一发而动全身。一个独立进化的限界上下文,需要接口设计良好,符合标准规范,并在版本上考虑了兼容与演化。
|
||||
|
||||
自治的这四个要素是相辅相成的。最小完备意味着职责是完备的,从而减少了变化的可能;自我履行意味着自治单元能够智能地判断行为是否应该由其履行,当变化发生时,也能聪明审慎地做出合理判断;稳定空间通过隐藏细节和开放抽象接口来封装变化;独立进化则通过约束接口的规范与版本保证内部实现的演化乃至于对实现进行全面地替换。最小完备是基础,只有赋予了限界上下文足够的信息,才能保证它的自我履行。稳定空间与独立进化则一个对内一个对外,是对变化的有效应对,而它们又是通过最小完备和自我履行来保障限界上下文受到变化的影响最小。
|
||||
|
||||
这四个要素又是高内聚低耦合思想的体现。我们需要根据业务关注点和技术关注点,尽可能将强相关性的内容放到同一个限界上下文中,同时降低限界上下文之间的耦合。对于整个系统架构而言,不同的限界上下文可以采用不同的架构风格与技术决策,而在每个限界上下文内部保持自己的技术独立性与一致性。由于限界上下文边界对技术实现的隔离,不同限界上下文内部实现的多样性并不会影响整体架构的一致性。
|
||||
|
||||
|
||||
|
||||
|
99
专栏/领域驱动设计实践(完)/013限界上下文的控制力(上).md
Normal file
99
专栏/领域驱动设计实践(完)/013限界上下文的控制力(上).md
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
013 限界上下文的控制力(上)
|
||||
既然我们认为:引入限界上下文的目的,不在于如何划分,而在于如何控制边界。因此,我们就需要将对限界上下文的关注转移到对控制边界的理解。显然,对应于统一语言,限界上下文是语言的边界,对于领域模型,限界上下文是模型的边界,二者可以帮助我们界定问题域(Problem Space)。对于系统的架构,限界上下文确定了应用边界和技术边界,进而帮助我们确定整个系统及各个限界上下文的解决方案。可以说,限界上下文是连接问题域与解决方案域的重要桥梁。
|
||||
|
||||
下面将分别针对业务边界、工作边界与应用边界来深入探讨限界上下文的这种控制力。
|
||||
|
||||
限界上下文分离了业务边界
|
||||
|
||||
限界上下文首先分离了业务边界,用以约束不同上下文的领域模型。这种对领域模型的划分符合架构设计的基本原则,即从更加宏观和抽象的层次去分析问题域,如此既可以避免分析者迷失在纷繁复杂的业务细节知识中,又可以保证领域概念在自己的上下文中的一致性与完整性。
|
||||
|
||||
例如,在电商系统中,主要的产品实体 Product 在不同的限界上下文具有不同的含义,关注的属性与行为也不尽相同。在采购上下文,需要关注产品的进价、最小起订量与供货周期;在市场上下文中,则关心产品的品质、售价,以及用于促销的精美图片和销售类型;在仓储上下文中,仓库工作人员更关心产品放在仓库的哪个位置,产品的重量与体积,是否易碎品以及订购产品的数量;在推荐上下文中,系统关注的是产品的类别、销量、收藏数、正面评价数、负面评价数。
|
||||
|
||||
对于这种情况,我们不应该将这一概念建模为单个类,否则就可能导致不同限界上下文对应的领域模型为了代码重用,而共享这个共同的 Product 类,导致限界上下文之间产生代码的耦合,随之而来的,与领域模型相对应的数据模型也要产生耦合,如下图所示:
|
||||
|
||||
|
||||
|
||||
产品(Product)实体的设计也违背了“单一职责原则(SRP)”,它包含了太多本应分离的职责,适用于不同的上下文,从而变成了一个臃肿的上帝类:
|
||||
|
||||
public class Product {
|
||||
private Identity id;
|
||||
private String name;
|
||||
private Category category;
|
||||
private Preriod leadTime;
|
||||
private int minimumOrderQuant;
|
||||
private Weight weight;
|
||||
private Volumn volumn;
|
||||
private int quantity;
|
||||
private long annualSales;
|
||||
private long favoritePoints;
|
||||
private long positiveComments;
|
||||
private long negetiveComments;
|
||||
|
||||
public Price priceFor(CustomerType customerType) {}
|
||||
public PurchaseOrder buyFrom(Supplier supplier) {}
|
||||
public Location allocate() {}
|
||||
public boolean isFragile() {}
|
||||
public Image[] loadImagesFrom(String filePath) {}
|
||||
public Recommendations similar() {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
如果我们将产品看做是参与业务场景的角色,进而在不同场景中考虑对象之间的协作;那么,是否可以遵循接口隔离原则(ISP)对 Product 实体类进行抽象呢?例如,在不同的限界上下文(作为 Product 的调用者)中,确定 Product 类扮演的不同角色,然后基于面向接口设计的原则为其定义多个细粒度的接口,如 Allocation 接口、Recommendation 接口、ImageLoader 接口等。这样的接口即 Martin Fowler 提出的角色接口(Role Interface),然后,再让定义的 Product 类去实现这多个接口,体现了“大对象小角色”的设计思路。
|
||||
|
||||
如果只考虑设计层面,这样基于接口隔离原则进行设计的方案是合理的。例如,我们可以在各自的限界上下文中定义这些接口,然而,实现了这些接口的 Product 类又应该放在哪里?譬如说,我们可以引入一个产品上下文,然后在其内部定义 Product 类去实现这些接口。这样的设计是不合理的,它导致了产品上下文同时依赖其余四个限界上下文,形成了架构层面上限界上下文之间不必要的耦合,如下所示:
|
||||
|
||||
|
||||
|
||||
引入的限界上下文对设计产生了影响。在考虑设计方案时,我们需要时刻警醒限界上下文边界的控制力。限界上下文内部的协作成本要远远低于限界上下文之间的协作成本。在面向对象设计中,行之有效的“接口隔离原则”如果跨越了多个限界上下文,就变得不合理了。为了避免重复,我们引入了耦合,这种设计上的顾此失彼是不可取的。要降低耦合同时又能避免重复,更好的解决方案是让每一个限界上下文拥有自己的领域模型,该领域模型仅仅满足符合当前上下文需要的产品唯一表示。这其实是领域驱动设计引入限界上下文的主要目的:
|
||||
|
||||
|
||||
|
||||
虽然不同的限界上下文都存在相同的 Product 领域模型,但由于有了限界上下文作为边界,使得我们在理解领域模型时,是基于当前所在的上下文作为概念语境的。这样的设计既保证了限界上下文之间的松散耦合,又能够维持限界上下文各自领域模型的一致性,此时的限界上下文成为了保障领域模型不受污染的边界屏障。
|
||||
|
||||
限界上下文明确了工作边界
|
||||
|
||||
一个理想的开发团队规模最好能符合亚马逊公司提出的“Two-Pizza Teams”,即 2PTs 规则,该规则认为“让团队保持在两个披萨能让成员吃饱的小规模”,大体而言,就是将团队成员人数控制在 7~10 人左右。为何要保证这样的规模呢?因为小团队能够更有效保证有效的沟通,如下图所示:
|
||||
|
||||
|
||||
|
||||
2PTs 规则自有其科学依据。如果我们将人与人之间的沟通视为一个“联结(link)”,则联结的数量遵守如下公式,其中 n 为团队的人数:
|
||||
|
||||
[Math Processing Error]N(link)=n(n−1)2
|
||||
|
||||
联结的数量直接决定了沟通的成本,以 6 人团队来计算,联结的数量为 15。如果在原有六人团队的规模上翻倍,则联结数陡增至 66。对于传统项目管理而言,一个 50 人的团队其实是一个小型团队,根据该公式计算得出的联结数竟然达到了惊人的 1225。如下图所示,我们可以看到随着团队规模的扩大,联结数的增长以远超线性增长的速度发展,因而沟通的成本也将随之发生颠覆性的改变:
|
||||
|
||||
|
||||
|
||||
随着沟通成本的增加,团队的适应性也会下降。Jim Highsmith 在 Adaptive Software Development 一书中写道:
|
||||
|
||||
|
||||
最佳的单节点(你可以想象成是通信网络中可以唯一定位的人或群体)联结数是一个比较小的值,它不太容易受网络规模的影响。即使网络变大,节点数量增加,每个节点所拥有的联结数量也一定保持着相对稳定的状态。
|
||||
|
||||
|
||||
要做到人数增加不影响到联结数,就是要找到这个节点网络中的最佳沟通数量,也即前面提到的 2PTs 原则。然而团队规模并非解决问题的唯一办法,如果在划分团队权责时出现问题,则团队成员的数量不过是一种组织行为的表象罢了。如果结合领域驱动设计的需求,则我们应该考虑在保持团队规模足够小的前提下,按照软件的特性(Feature)而非组件(Component)来组织软件开发团队,这就是所谓“特性团队”与“组件团队”之分。
|
||||
|
||||
传统的“组件团队”强调的是专业技能与功能重用,例如,熟练掌握数据库开发技能的成员组建一个数据库团队,深谙前端框架的成员组建一个前端开发团队。这种遵循“专业的事情交给专业的人去做”原则的团队组建模式,可以更好地发挥每个人的技能特长,然而牺牲的却是团队成员业务知识的缺失,客户价值的漠视。这种团队组建模式也加大了团队之间的沟通成本,导致系统的整体功能无法持续和频繁的集成。例如,由于业务变更需要针对该业务特性修改用户描述的一个字段,就需要从数据存储开始考虑到业务模块、服务功能,最后到前端设计。一个小小的修改就需要横跨多个组件团队,这种交流的浪费是多么不必要啊。在交流过程中,倘若还出现了知识流失,或者沟通不到位导致修改没有实现同步,就会带来潜在的缺陷。这种缺陷非常难以发现,即使在高覆盖率的集成测试下暴露了,缺陷定位、问题修复又是一大堆破事儿,需要协调多个团队。邮件沟通、电话沟通、你来我往、扯皮推诿,几天的时光如白驹过隙、转眼就过,问题还未必得到最终的解决。倘若这样的组件团队还是不同供应商的外包团队,分处于不同城市,可以想象这样的场景是多么“美好”!很“幸运”,我在参与某汽车制造商的零售商管理系统时,作为 CRM 模块的负责人,就摊上了这样的破事儿,如今思之,仍然不寒而栗啊!
|
||||
|
||||
为了规避这些问题,组建特性团队更有必要。所谓“特性团队”,就是一个端对端的开发垂直细分领域的跨职能团队,它将需求分析、架构设计、开发测试等多个角色糅合在一起,专注于领域逻辑,实现该领域特性的完整的端对端开发。一个典型的由多个特性团队组成的大型开发团队如下图所示:
|
||||
|
||||
|
||||
|
||||
如上图所示,我们按照领域特性来组建团队,使得团队成员之间的沟通更加顺畅,至少针对一个领域而言,知识在整个特性团队都是共享的。当然,我们在上图中也看到了组件团队的存在。这是因为在许多复杂软件系统中,毕竟存在一些具有相当门槛的专有功能,需要具有有专门知识或能够应对技术复杂度的团队成员去解决那些公共型的基础型的问题。二者的结合可以取长补短,但应以组建特性团队为主。
|
||||
|
||||
特性团队专注的领域特性,是与领域驱动设计中限界上下文对应的领域是相对应的。当我们确定了限界上下文时,其实也就等同于确定了特性团队的工作边界,确定了限界上下文之间的关系,也就意味着确定了特性团队之间的合作模式;反之亦然。之所以如此,则是因为康威定律(Conway’s Law)为我们提供了理论支持。
|
||||
|
||||
康威定律认为:“任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。” 在康威定律中起到关键杠杆作用的是沟通成本。如果同一个限界上下文的工作交给了两个不同的团队分工完成,为了合力解决问题,就必然需要这两个团队进行密切的沟通。然而,团队间的沟通成本显然要高于团队内的沟通成本,为了降低日趋增高的成本,就需要重新划分团队。反过来,如果让同一个团队分头做两个限界上下文的工作,则会因为工作的弱相关性带来自然而然的团队隔离。
|
||||
|
||||
|
||||
|
||||
如上图所示,我们可以设想这样一种场景,如果有两个限界上下文的工作,分配给两个不同的团队。分配工作时,却没有按照限界上下文的边界去组建团队,即每个团队会同时承担两个限界上下文的工作。试想,这会造成多少不必要的沟通成本浪费?借用 ORM(Object Relational Mapping,对象关系映射)的概念,我将这种职责分配的错位称之为“限界上下文与团队的阻抗不匹配”。如果能够将团队与限界上下文重合,就能够降低沟通成本,打造高效的领域特性团队,专注于属于自己的限界上下文开发。
|
||||
|
||||
|
||||
|
||||
|
83
专栏/领域驱动设计实践(完)/014限界上下文的控制力(下).md
Normal file
83
专栏/领域驱动设计实践(完)/014限界上下文的控制力(下).md
Normal file
@ -0,0 +1,83 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
014 限界上下文的控制力(下)
|
||||
限界上下文封装了应用边界
|
||||
|
||||
架构师在划分限界上下文时,不能只满足于业务边界的确立,还得从控制技术复杂度的角度来考虑技术实现,从而做出对系统质量属性的响应与承诺,这种技术因素影响限界上下文划分的例子可谓是不胜枚举。
|
||||
|
||||
高并发
|
||||
|
||||
一个外卖系统的订单业务与门店、支付等领域存在业务相关性,然而考虑外卖业务的特殊性,它往往会在某个特定的时间段比如中午 11 点到 13 点会达到订单量的高峰值。系统面临高并发压力,同时还需要快速地处理每一笔外卖订单,与电商系统的订单业务不同,外卖订单具有周期短的时效性,必须在规定较短的时间内走完从下订单、支付、门店接单到配送等整个流程。如果我们将订单业务从整个系统中剥离出来,作为一个单独的限界上下文对其进行设计,就可以从物理架构上保证它的独立性,在资源分配上做到高优先级地扩展,在针对领域进行设计时,尽可能地引入异步化与并行化,来提高服务的响应能力。
|
||||
|
||||
功能重用
|
||||
|
||||
对于一个面向企业雇员的国际报税系统,报税业务、旅游业务与 Visa 业务都需要账户功能的支撑。系统对用户的注册与登录有较为复杂的业务处理流程。对于一个新用户而言,系统会向客户企业的雇员发送邀请信,收到邀请信的用户只有通过了问题验证才能成为合法的注册用户,否则该用户的账户就会被锁定,称之为 Registration Locked。在用户使用期间,若违背了系统要求的验证条件,也可能会根据不同的条件锁定账户,分别称之为 Soft Locked 和 Hard Locked。只有用户提供了可以证明其合法身份的材料,其账户才能被解锁。
|
||||
|
||||
账户管理并非系统的核心领域,但与账户相关的业务逻辑却相对复杂。从功能重用的角度考虑,我们应该将账户管理作为一个单独的限界上下文,以满足不同核心领域对这一功能的重用,避免了重复开发和重复代码。
|
||||
|
||||
实时性
|
||||
|
||||
在电商系统中,商品自然是核心,而价格(Price)则是商品概念的一个重要属性。倘若仅仅从业务的角度考虑,在进行领域建模时,价格仅仅是一个普通的领域值对象,可倘若该电商系统的商品数量达到数十亿种,每天获取商品信息的调用量在峰值达到数亿乃至数百亿次时,价格就不再是业务问题,而变成了技术问题。对价格的每一次变更都需要及时同步,真实地反馈给电商客户。
|
||||
|
||||
为了保证这种在高并发情况下的实时性,我们就需要专门针对价格领域提供特定的技术方案,例如,通过读写分离、引入 Redis 缓存、异步数据同步等设计方法。此时,价格领域将作为一个独立的限界上下文,形成自己与众不同的架构方案,同时,为价格限界上下文提供专门的资源,并在服务设计上保证无状态,从而满足快速扩容的架构约束。
|
||||
|
||||
第三方服务集成
|
||||
|
||||
一个电商系统需要支持多种常见的支付渠道,如微信支付、支付宝、中国银联以及各大主要银行的支付。买家在购买商品以及进行退货业务时,可以选择适合自己的支付渠道完成支付。电商系统需要与这些第三方支付系统进行集成。不同的支付系统公开的 API 并不相同,安全、加密以及支付流程对支付的要求也不相同。
|
||||
|
||||
在技术实现上,一方面我们希望为支付服务的客户端提供完全统一的支付接口,以保证调用上的便利性与一致性,另一方面我们希望能解除第三方支付服务与电商系统内部模块之间的耦合,避免引起“供应商锁定(Vender Lock)”,也能更好地应对第三方支付服务的变化。因此,我们需要将这种集成划分为一个单独的限界上下文。
|
||||
|
||||
遗留系统
|
||||
|
||||
当我们在运用领域驱动设计对北美医疗内容管理系统提出的新需求进行设计与开发时,这个系统的已有功能已经运行了数年时间。我们的任务是在现有系统中增加一个全新的 Find & Replace 模块,其目的是为系统中的医疗内容提供针对医疗术语、药品以及药品成分的查询与替换。这个系统已经定义了自己的领域模型。这些领域模型与新增模块的领域有相似之处。但是,为了避免已有模型对新开发模块的影响,我们应该将这些已有功能视为具有技术债的遗留系统,并将该遗留系统整体视为一个限界上下文。
|
||||
|
||||
通过这个遗留系统限界上下文的边界保护,就可以避免我们在开发过程中陷入遗留系统庞大代码库的泥沼。由于新增需求与原有系统在业务上存在交叉功能,因而可能失去了部分代码的重用机会,却能让我们甩开遗留系统的束缚,放开双手运用领域驱动设计的思想建立自己的领域模型与架构。只有在需要调用遗留系统的时候,作为调用者站在遗留系统限界上下文之外,去思考我们需要的服务,然后酌情地考虑模型对象之间的转换以及服务接口的提取。
|
||||
|
||||
如上的诸多案例都是从技术层面而非业务层面为系统划分了应用边界,这种边界是由限界上下文完成的,通过它形成了对技术实现的隔离,避免不同的技术方案选择互相干扰导致架构的混乱。
|
||||
|
||||
案例:生成税务报告的技术风险
|
||||
|
||||
国际税务系统需要在政府指定的周期提交税务报告,凡是满足条件的 Assignee 都需要在规定时间内生成税务报告。在生成税务报告时,需要对 Assignee 提交的 Questionnaire 数据进行合并,并基于税收策略与 Assignee 个人情况执行计算。生成税务报告的时序图如下所示:
|
||||
|
||||
|
||||
|
||||
代码如下所示:
|
||||
|
||||
public class TaxReportGenerator {
|
||||
@Service
|
||||
private HtmlReportProvider provider;
|
||||
@Service
|
||||
private PdfConverter converter;
|
||||
@Repository
|
||||
private ReportRepository repository;
|
||||
|
||||
public void generateReports(String calendarReportName) {
|
||||
Byte[] bytes = provider.getHtmlBytes(calendarReportName);
|
||||
Byte[] pdfBytes = converter.getPdfBytes(bytes, provider.getTitle());
|
||||
repository.save(new TaxReport(pdfBytes));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
由于每个 Assignee 的报告内容多,生成的 PDF 文件较大,使得生成税务报告的单位时间也较长。在最初用户量较少的情况下,所有税务报告的生成时间在客户预期范围内,因而并未针对报告生成功能做特别的架构设计。后来,随着系统的 Assignee 用户数增多,在提交税务报告的高峰期时,报告生成的时间越来越长。以高峰期需要提交 2000 个税务报告为例,如果每个税务报告的提交时间为 1 分钟,在只有一个 worker 的情况下,我们需要2000*1/60=33小时。
|
||||
|
||||
由于单个税务报告的生成性能已经达到瓶颈,没有优化的空间,因而需要在架构层面对方案进行优化,包括如下两方面:
|
||||
|
||||
|
||||
引入消息队列,将整个税务报告生成过程拆分为消息队列的生产者和消费者。处于应用服务器一端的生产者仅负责收集税务报告需要的数据,而将生成报告的职责交给消息队列的消费者,从而减轻应用服务器的压力。
|
||||
将报告生成识别为限界上下文,定义为可以单独部署的微服务,以便于灵活地实现水平扩展。
|
||||
|
||||
|
||||
如下图是我们基于技术实现识别出来的 report 限界上下文。在上下文边界内,引入了消息队列。server 作为生成者,在收集了税务数据后组装消息,然后将消息入队;作为消费者的 worker 订阅该消息,一旦消息传递到达,则负责生成报告:
|
||||
|
||||
|
||||
|
||||
无论是 server 还是 worker,皆为并行执行,且在理论上可以无限制地水平扩展。倘若在性能上无法满足要求,我们可以增加 server 或 worker 节点。例如,我们希望所有税务报告能够在 4 小时内处理完毕,通过公式2000*1/60/4计算,预估需要 7 个 worker 并行执行即可满足目标。
|
||||
|
||||
|
||||
|
||||
|
94
专栏/领域驱动设计实践(完)/015识别限界上下文(上).md
Normal file
94
专栏/领域驱动设计实践(完)/015识别限界上下文(上).md
Normal file
@ -0,0 +1,94 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
015 识别限界上下文(上)
|
||||
不少领域驱动设计的专家都非常重视限界上下文。Mike 在文章《DDD: The Bounded Context Explained》中写道:“限界上下文是领域驱动设计中最难解释的原则,但或许也是最重要的原则,可以说,没有限界上下文,就不能做领域驱动设计。在了解聚合根(Aggregate Root)、聚合(Aggregate)、实体(Entity)等概念之前,需要先了解限界上下文。”,然而,现实却是很少有文章或著作专题讲解该如何识别限界上下文。
|
||||
|
||||
我曾经向《实现领域驱动设计》的作者 Vaughn Vernon 请教如何在领域驱动设计中识别出正确的限界上下文?他思索了一会儿,回答我:“By experience.(凭经验)”,这是一个机智的回答,答案没有错,可是也没有任何借鉴意义,等于说了一句正确的废话。
|
||||
|
||||
在软件开发和设计领域,任何技能都是需要凭借经验积累而逐步提升的。然而作为一种设计方法,领域驱动设计强调了限界上下文的重要性,却没有提出一个值得参考并作为指引的过程方法,这是不负责任的。
|
||||
|
||||
Andy Hunt 在《程序员的思维修炼》这本书中分析了德雷福斯模型的 5 个阶段:新手、高级新手、胜任者、精通者和专家。对于最高阶段的“专家”,Andy Hunt 得到一个有趣的结论:“专家根据直觉工作(Experts work from intuition),而不需要理由。”,这似乎充满了神秘主义,然而这种专家的直觉实际上是通过不断的项目实践千锤百炼出来的,也可以认为是经验的累积。经验的累积过程需要方法,否则所谓数年经验不过是相同的经验重复多次罢了,没有价值。Andy Hunt 认为需要给新手提供某种形式的规则去参照,之后,高级新手会逐渐形成一些总体原则,然后通过系统思考和自我纠正,建立或者遵循一套体系方法,就能从高级新手慢慢成长为胜任者、精通者。因此,从新手到专家是一个量变引起质变的过程,在没有能够养成直觉的经验之前,我们需要有一套方法。
|
||||
|
||||
|
||||
|
||||
我在一些项目中尝试着结合了诸多需求分析方法与设计原则,慢慢摸索出了属于自己的一套体系。归根结底,限界上下文就是“边界”,这与面向对象设计中的职责分配其实是同一道理。限界上下文的识别并不是一蹴而就的,需要演化和迭代,结合着我对限界上下文的理解,我认为通过从业务边界到工作边界再到应用边界这三个层次抽丝剥茧,分别以不同的视角、不同的角色协作来运用对应的设计原则,会是一个可行的识别限界上下文的过程方法。当然,这个过程相对过重,如果仅以此作为输出限界上下文的方法,未免有些得不偿失。需要说明的是,这个过程除了能够帮助我们更加准确地识别限界上下文之外,还可以帮助我们分析需求、识别风险、确定架构方案。整体过程如下图所示:
|
||||
|
||||
|
||||
|
||||
从业务边界识别限界上下文
|
||||
|
||||
领域驱动设计围绕着“领域”来开展软件设计。在明确了系统的问题域和业务期望后,开发团队与领域专家经过充分地沟通与交流,可以梳理出主要的业务流程,这些业务流程体现了各种参与者在这个过程中通过业务活动共同协作,最终完成具有业务价值的领域功能。显然,业务流程结合了参与角色(Who)、业务活动(What)和业务价值(Why)。在业务流程的基础上,我们就可以抽象出不同的业务场景,这些业务场景又由多个业务活动组成,我们可以利用前面提到的领域场景分析方法剖析场景,以帮助我们识别业务活动,例如采用用例对场景进行分析,此时,一个业务活动实则就是一个用例。
|
||||
|
||||
例如,在针对一款文学阅读产品进行需求分析时,我们得到的业务流程为:
|
||||
|
||||
|
||||
登录读者根据作品名或者作者名查询自己感兴趣的作品。在找到自己希望阅读的作品后,开始阅读。若阅读的作品为长篇,可以按照章节阅读,倘若作品为收费作品,则读者需要支付相应的费用,支付成功后可以阅读购买后的作品。在阅读时,倘若读者看到自己喜欢的句子或段落,可以作标记,也可以撰写读书笔记,还可以将自己喜欢的内容分享给别的朋友。读者可以对该作品和作者发表评论,关注自己喜欢的作品和作者。
|
||||
注册用户可以申请成为驻站作者。审核通过的作者可以在创作平台上发布自己的作品,发布作品时,可以根据需要设置作品的章节。作者可以在发布作品之前预览作品,无论作品是否已经发布,都可以对作品的内容进行修改。作者可以设置自己的作品为收费或免费作品,并自行确定阅读作品所需的费用。如果是新作品发布,系统会发送消息通知该作者的关注者;若连载作品有新章节发布,系统会发送消息通知该作品的关注者。
|
||||
驻站作者可以为自己的作品建立作品读者群,读者可以申请加入该群,加入群的读者与作者可以在线实时聊天,也可以发送离线信息,或者将自己希望分享的内容发布到读者群中。注册用户之间可以发起一对一的私聊,也可以直接给注册用户发送私信。
|
||||
|
||||
|
||||
通过对以上业务流程进行分析,结合在各个流程环节中需要的知识以及参与角色的不同,可以划分如下业务场景:
|
||||
|
||||
|
||||
阅读作品
|
||||
创作作品
|
||||
支付
|
||||
社交
|
||||
消息通知
|
||||
注册与登录
|
||||
|
||||
|
||||
可以看到,业务流程是一个由多个用户角色参与的动态过程,而业务场景则是这些用户角色执行业务活动的静态上下文。从业务流程中抽象出来的业务场景可能是交叉重叠的,例如在读者阅读作品流程与作者创作流程中,都牵涉到支付场景的相关业务。
|
||||
|
||||
接下来,我们利用领域场景分析的用例分析方法剖析这些场景。我们往往通过参与者(Actor)来驱动对用例的识别,这些参与者恰好就是参与到场景业务活动的角色。根据用例描述出来的业务活动应该与统一语言一致,最好直接从统一语言中撷取。业务活动的描述应该精准地表达领域概念,且通过尽可能简洁的方式进行描述,通常格式为动宾形式。以阅读作品场景为例,可以包括如下业务活动:
|
||||
|
||||
|
||||
查询作品
|
||||
收藏作品
|
||||
关注作者
|
||||
浏览作品目录
|
||||
阅读作品
|
||||
标记作品内容
|
||||
撰写读书笔记
|
||||
评价作品
|
||||
评价作者
|
||||
分享选中的作品内容
|
||||
分享作品链接
|
||||
购买作品
|
||||
|
||||
|
||||
一旦准确地用统一语言描述出这些业务活动,我们就可以从如下两个方面识别业务边界,进而提炼出初步的限界上下文:
|
||||
|
||||
|
||||
语义相关性
|
||||
功能相关性
|
||||
|
||||
|
||||
语义相关性
|
||||
|
||||
从语义角度去分析业务活动的描述,倘若是相同的语义,可以作为归类的特征。语义相关性主要来自于描述业务活动的宾语。例如,前述业务活动中的查询作品、收藏作品、分享作品、阅读作品都具有“作品”的语义,基于这一特征,我们可以考虑将这些业务活动归为同一类。
|
||||
|
||||
识别语义相关性的前提是准确地使用统一语言描述业务活动。在描述时,应尽量避免使用“管理(manage)”或“维护(maintain)”等过于抽象的词语。抽象的词语容易让我们忽视隐藏的领域语言,缺少对领域的精确表达。例如,在文学阅读产品中,我们不能宽泛地写出“管理作品”、“管理作者”、“维护支付信息”等业务活动,而应该挖掘业务含义,只有如此才能得到诸如收藏作品、撰写作品、发布作品、设置作品收费模式、查询支付流水、对账等符合领域知识的描述。当然,这里也有一个业务活动层次的问题。在进行业务分析时,若我们发现只能使用“管理”或“维护”之类的抽象字眼来表述该用户活动时,则说明我们选定的用户活动层次过高,应该继续细化。细化后的业务活动既能更好地表达领域知识,又能让我们更好地按照语义相关性去寻找业务的边界,可谓一举两得。
|
||||
|
||||
在进行语义相关性判断时,还需要注意业务活动之间可能存在不同的语义相关性。例如,在文学阅读产品中,查询作品、阅读作品与撰写作品具有“作品”的语义相关,而评价作品与评价作者又具有“评价”的语义相关,究竟应该以哪个语义为准呢?没有标准!我们只能按照相关性的耦合程度进行判断。如果我们将评价视为一个相对独立的限界上下文,则评价作品与评价作者放入评价上下文会更好。
|
||||
|
||||
功能相关性
|
||||
|
||||
从功能角度去分析业务活动是否彼此关联和依赖,倘若存在关联和依赖,可以作为归类的特征,这种关联性,代表了功能之间的相关性。倘若两个功能必须同时存在,又或者缺少一个功能,另一个功能是不完整的,则二者就是功能强相关的。通常,这种功能相关性极具有欺骗性,因为系统总是包含这样那样彼此依赖的功能。要判断这种依赖关系的强弱,并不比分析人与人之间的关系简单。倘若我们运用用例分析方法,就可以通过用例之间的关系来判别功能相关性,如用例的包含与扩展关系,其中包含关系展现了功能的强相关性。所谓“功能相关性”,指的就是职责的内聚性,强相关就等于高内聚。故而从这个角度看,功能相关性的判断标准恰好符合“高内聚、松耦合”的设计原则。
|
||||
|
||||
仍然以前面提到的文学阅读产品为例。发布作品与验证作品内容是功能相关的,且属于用例的包含关系,因为如果没有对发布的作品内容进行验证,就不允许发布作品。对于这种强相关的功能,我们通常都会考虑将其归入到同一个限界上下文。又例如发布作品与设置作品收费模式是功能相关的,但并非强相关,因为设置作品收费模式并非发布作品的前置约束条件,属于用例中的扩展关系。但由于二者还存在语义相关性,因而将其放入到同一个限界上下文中也是合理的。
|
||||
|
||||
两个相关的功能未必一定属于同一个限界上下文。例如,购买作品与支付购买费用是功能相关的,且前者依赖于后者,但后者从领域知识的角度判断,却应该分配给支付上下文,我们非但不能将其紧耦合在一起,还应该竭尽所能降低二者之间的耦合度。因此,我在识别限界上下文时,仅仅将“功能相关性”作为一种可行的参考,它并不可靠,却能给你一些提醒。事实上,功能相关性往往会与上下文之间的协作关系有关。由于这种功能相关性恰恰对应了用例之间的包含与扩展关系,它们往往又可成为识别限界上下文边界的关键点。我在后面讲解上下文映射时还会详细阐释。
|
||||
|
||||
为业务边界命名
|
||||
|
||||
无论是语义相关性还是功能相关性,都是分类业务活动的一种判断标准。一旦我们将识别出来的业务活动进行归类,就自然而然地为它们划定了业务边界,接下来,我们需要对划定的业务边界进行命名,这个命名的过程其实就是识别所有业务活动共同特征,并以最准确地名词来表达该特征。倘若我们划分的业务活动欠妥当,对这个业务边界命名就会成为一种巨大的挑战。例如,我们从建立读者群、加入读者群,发布群内消息、实时聊天、发送离线消息、一对一私聊与发送私信等业务活动找到“社交”的共同特征,因而得到社交上下文。但如果我们将阅读作品、收藏作品与关注作者、查看作者信息放在一个业务边界内,命名就变得有些棘手了,我们总不可能称呼其为“作品与作者”上下文吧!因此,对业务边界的命名可以算作是对限界上下文识别的一种检验手段。
|
||||
|
||||
整体而言,从业务边界识别上下文的重点在于“领域”。若理解领域逻辑有误,就可能影响限界上下文的识别。因此,这个阶段需要开发团队与领域专家紧密合作,这个阶段也将是一个充分讨论和分析的过程。它是一个迭代的过程。很多时候,如果我们没有真正去实现这些限界上下文,我们有可能没有完全正确地理解它。当我们距离真正理解业务还有距离的时候,不妨先“草率”地规划它,待到一切都明朗起来,再寻机重构。
|
||||
|
||||
|
||||
|
||||
|
107
专栏/领域驱动设计实践(完)/016识别限界上下文(下).md
Normal file
107
专栏/领域驱动设计实践(完)/016识别限界上下文(下).md
Normal file
@ -0,0 +1,107 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
016 识别限界上下文(下)
|
||||
从工作边界识别限界上下文
|
||||
|
||||
正如架构设计需要多个视图来全方位体现架构的诸多要素,我们也应借助更多的角度全方位分析限界上下文。如果说为限界上下文划分业务边界,更多的是从业务相关性(内聚)判断业务的归属,那么基于团队合作划分工作边界可以帮助我们确定限界上下文合理的工作粒度。
|
||||
|
||||
倘若我们认可第 3-2 课中提及的三个原则或实践:2PTs 规则、特性团队、康威定律,则意味着项目经理需要将一个限界上下文要做的工作分配给大约 7~10 人的特性团队。如此看来,对限界上下文的粒度识别就变成了对工作量的估算。我们并没有严谨的算法去准确估算工作量,可是对于一个有经验的项目经理(或者技术负责人),要进行工作量的大致估算,还是能够办到的。当我们发现一个限界上下文过大,又或者特性团队的工作分配不均匀时,就应该果断对已有限界上下文进行切分。
|
||||
|
||||
工作分配的基础在于“尽可能降低沟通成本”,遵循康威定律,沟通其实就是项目模块之间的依赖,这个过程同样不是一蹴而就的。康威认为:
|
||||
|
||||
|
||||
在大多数情况下,最先产生的设计都不是最完美的,主导的系统设计理念可能需要更改。因此,组织的灵活性对于有效的设计有着举足轻重的作用,必须找到可以鼓励设计经理保持他们的组织精简与灵活的方法。
|
||||
|
||||
|
||||
特性团队正是用来解决这一问题的。换言之,当我们发现团队规模越来越大,失去了组织精简与灵活的优势,实际上就是在传递限界上下文过大的信号。项目经理对此需要有清醒认识,当团队规模违背了 2PTs 时,就该坐下来讨论一下如何细分团队的问题了。因此,按照团队合作的角度划分限界上下文,其实是一个动态的过程、演进的过程。
|
||||
|
||||
我在给某音乐网站进行领域驱动设计时,通过识别业务相关性划分了如下限界上下文。
|
||||
|
||||
|
||||
Media Player(online & offline):提供音频和视频文件的播放功能,区分在线播放与离线播放;
|
||||
Music:与音乐相关的业务,包括乐库、歌单、歌词;
|
||||
FM Radio:电台;
|
||||
Live:直播;
|
||||
MV:短视频和 MV;
|
||||
Singer:歌手;
|
||||
Musician:音乐人,注意音乐人与歌手的区别;
|
||||
Music Community:音乐社区;
|
||||
File Sharing:包括下载和传歌等与文件有关的功能;
|
||||
Tag:支持标签管理,包括音乐的分类如最新、话题等分类标签还有歌曲标签;
|
||||
Loyalty:与提高用户粘度有关的功能,如关注、投票、收藏、歌单等功能;
|
||||
Utilities:音乐工具,包括音效增强等功能;
|
||||
Recommendation:推荐;
|
||||
Search:对整个音乐网站内容的搜索,包括对人、歌曲、视频等内容的搜索;
|
||||
Activity:音乐网站组织的活动;
|
||||
Advertisement:推广与广告;
|
||||
Payment:支付。
|
||||
|
||||
|
||||
在识别限界上下文时,我将直播(Live)视为与音乐、电台、MV 短视频同等层次的业务分类,然而,殊不知该音乐网站直播模块的开发团队已经随着功能的逐渐增强发展到了接近 200 人规模的大团队,这显然不是一个限界上下文边界可以控制的规模。即使属于直播业务的业务活动都与直播领域知识有关,我们也应该基于 2PTs 原则对直播限界上下文作进一步分解,以满足团队管理以及团队成员充分沟通的需要。
|
||||
|
||||
如果我们从团队合作层面看待限界上下文,就从技术范畴上升到了管理范畴。Jurgen Appelo 在《管理 3.0:培养和提升敏捷领导力(Management 3.0: Leading Agile Developers,Developing Agile Leaders)》这本书中提到,一个高效的团队需要满足两点要求:
|
||||
|
||||
|
||||
共同的目标
|
||||
团队的边界
|
||||
|
||||
|
||||
|
||||
|
||||
虽然 Jurgen Appelo 在提及边界时,是站在团队结构的角度来分析的;可在设计团队组织时确定工作边界的原则,恰恰与限界上下文的控制边界暗暗相合。总结书中对边界的阐释,大致包括:
|
||||
|
||||
|
||||
团队成员应对团队的边界形成共识,这就意味着团队成员需要了解自己负责的限界上下文边界,以及该限界上下文如何与外部的资源以及其他限界上下文进行通信。
|
||||
团队的边界不能太封闭(拒绝外部输入),也不能太开放(失去内聚力),即所谓的“渗透性边界”,这种渗透性边界恰恰与“高内聚、松耦合”的设计原则完全契合。
|
||||
|
||||
|
||||
针对这种“渗透性边界”,团队成员需要对自己负责开发的需求“抱有成见”,在识别限界上下文时,“任劳任怨”的好员工并不是真正的好员工。一个好的员工明确地知道团队的职责边界,他应该学会勇于承担属于团队边界内的需求开发任务,也要敢于推辞职责范围之外强加于他的需求。通过团队每个人的主观能动,就可以渐渐地形成在组织结构上的“自治单元”,进而催生出架构设计上的“自治单元”。同理,“任劳任怨”的好团队也不是真正的好团队,团队对自己的边界已经达成了共识,为什么还要违背这个共识去承接不属于自己边界内的工作呢?这并非团队之间的“恶性竞争”,也不是工作上的互相推诿;恰恰相反,这实际上是一种良好的合作,表面上维持了自己的利益,然而在一个组织下,如果每个团队都以这种方式维持自我利益,反而会形成一种“互利主义”。
|
||||
|
||||
这种“你给我搔背,我也替你抓抓痒”的互利主义最终会形成团队之间的良好协作。如果团队领导者与团队成员能够充分认识到这一点,就可以从团队层面思考限界上下文。此时,限界上下文就不仅仅是架构师局限于一孔之见去完成甄别,而是每个团队成员自发组织的内在驱动力。当每个人都在思考这项工作该不该我做时,变相地就是在思考职责的分配是否合理,限界上下文的划分是否合理。
|
||||
|
||||
从应用边界识别限界上下文
|
||||
|
||||
质量属性
|
||||
|
||||
管理的目的在于打造高效的团队,但最后还是要落脚到技术实现上来,不懂业务分析的架构师不是一个好的程序员,而一个不懂得提前识别系统风险的程序员更不是一个好的架构师。站在技术层面上看待限界上下文,我们需要关注的其实是质量属性(Quality Attributes)。如果把关乎质量属性的问题都视为在将来可能会发生,其实就是“风险(Risk)”。
|
||||
|
||||
架构是什么?Martin Fowler 认为:架构是重要的东西,是不容易改变的决策。如果我们未曾预测到系统存在的风险,不幸它又发生了,带给系统架构的改变可能是灾难性的。利用限界上下文的边界,就可以将这种风险带来的影响控制在一个极小的范围,这也是前面提及的安全。为什么说限界上下文是领域驱动设计中最重要的元素,答案就在这里。
|
||||
|
||||
我曾经负责开发一款基于大数据平台的 BI 产品,在架构设计时,对性能的评估方案是存在问题的,我们当时考虑了符合生产规模的数据量,并以一个相对可行的硬件与网络环境,对 Spark + Parquet 的技术选型进行测试,测试结果满足了设定的响应时间值。然而,两个因素的缺失为我们的架构埋下了祸根。在测试时,我们没有考虑并发访问量,测试的业务场景也过于简单。我们怀着一种鸵鸟心态,在理论上分析这种决策(Spark 是当时最快速的基于内存的数据分析平台,Parquet 是列式存储,尤为适合统计分析)是对的,然后就按照我们期望的形式去测试,实际上是将风险悄悄地埋藏起来。
|
||||
|
||||
当产品真正销售给客户使用时,我们才发现客户的业务场景非常复杂,对性能的要求也更加苛刻。例如,它要求达到 100 ~ 500 的并发访问量,同时对大数据量进行统计分析与指标运算,并期望实时获得分析结果;而客户所能提供的 Spark 集群却是有限度的。事实上,基于 Spark 的 driver-worker 架构,它本身并不擅长完成高并发的数据分析任务。对于一个分析任务,Spark 可以利用集群的力量由多个 worker 同时并行地执行成百上千的 task,但瓶颈在 driver 端,一旦上游同时有多个请求涌入,响应能力就不足了。最终,我们的产品在真正的压力测试下一败涂地。
|
||||
|
||||
幸而,我们划定了限界上下文,并由此建立了数据分析微服务。针对客户高并发的实时统计分析需求,在保证 REST API 不变的情况下,我们更改了技术选型,选择基于 ElasticSearch 的数据分析微服务替换旧服务。这种改变几乎不影响产品的其他模块与功能,前端代码仅仅做了少量修改。3 个人的团队在近一个月的周期内基本完成了这部分数据分析功能,及时掐断了炸药的导火线。
|
||||
|
||||
重用和变化
|
||||
|
||||
无论是重用领域逻辑还是技术实现,都是在设计层面上我们必须考虑的因素,需求变化更是影响设计策略的关键因素。我在前面分析限界上下文的本质时,就提及一个限界上下文其实是一个“自治”的单元。基于自治的四个特征,我们也可以认为这个自治的单元其实就是逻辑重用和封装变化的设计单元。这时,对限界上下文边界的考虑,更多是出于技术设计因素,而非业务因素。在后面讲解的上下文映射(Context Map)模式时,Eric Evans 总结的共享内核其实就是重用的体现,而开放主机服务与防腐层则是对变化的主动/被动应对。
|
||||
|
||||
运用重用原则分离出来的限界上下文往往对应于子领域(Sub Domain),尤其作为支撑子领域。我在为一家公司的物流联运管理系统提供领域驱动设计咨询时,通过与领域专家的沟通,我注意到他在描述运输、货站以及堆场的相关业务时,都提到了作业和指令的概念。虽然属于不同的领域,但指令的收发、作业的制订与调度都是相同的,区别只在于作业与指令的内容,以及作业调度的周期。为了避免在运输、货站与堆场各自的限界上下文中重复设计与实现作业与指令等领域模型,我们可以将作业与指令单独划分到一个专门的限界上下文中。它作为上游限界上下文,提供对运输、货站与堆场的业务支撑。
|
||||
|
||||
限界上下文对变化的应对,其实是“单一职责原则”的体现,即一个限界上下文不应该存在两个引起它变化的原因。还是这个物流联运管理系统,最初团队的设计人员将运费计算与账目、结账等功能放在了财务上下文中。当国家的企业征税策略发生变化时,会引起财务上下文的变化,引起变化的原因是财务规则与政策的调整。倘若运费计算的规则也发生了变化,同样会引起财务上下文的变化,但引起变化的原因却是物流运输的业务需求。如果我们将运费计算单独从财务上下文中分离出来,就可以独立演化,符合前面提及的“自治”原则,实现了两种不同关注点的分离。
|
||||
|
||||
遗留系统
|
||||
|
||||
自治原则的唯一例外是遗留系统,因为领域驱动设计建议的通常做法是将整个遗留系统视为一个限界上下文。那么,什么是遗留系统?根据维基百科的定义,它是一种旧的方法、旧的技术、旧的计算机系统或应用程序,这个定义并不能解释遗留系统的真相。我认为,系统之所以成为遗留系统,关键在于知识的缺乏。文档不够全面真实,掌握系统知识的团队成员泰半离开,系统的代码可能是一个大泥团。因此,我对遗留系统的定义是“一个还在运行和使用,但已步入软件生命衰老期的缺乏足够知识的软件系统”。
|
||||
|
||||
倘若运用领域驱动设计的系统要与这样一个遗留系统打交道,应该怎么办?窃以为,粗暴地将整个遗留系统包裹在一个限界上下文中,未免太理想化和简单化了。要点还是自治,这时候我们应该站在遗留系统的调用者来观察它,考虑如何与遗留系统集成,然后逐步对遗留系统进行抽取与迁移,形成自治的限界上下文。
|
||||
|
||||
在这个过程中,我们可以借鉴技术栈迁移中常常运用的“抽象分支(Branch By Abstraction)”手法。该手法会站在消费者(Consumer)一方观察遗留系统,找到需要替换的单元(组件);然后对该组件进行抽象,从而将消费者与遗留系统中的实现解耦。最后,提供一个完全新的组件实现,在保留抽象层接口不变的情况下替换掉遗留系统的旧组件,达到技术栈迁移的目的:
|
||||
|
||||
|
||||
|
||||
如上图所示的抽象层,本质就是后面我们要提到的“防腐层(Anticorruption Layer)”,通过引入这么一个间接层来隔离与遗留系统之间的耦合。这个防腐层往往是作为下游限界上下文的一部分存在。若有必要,也可以单独为其创建一个独立的限界上下文。
|
||||
|
||||
设计驱动力
|
||||
|
||||
结合业务边界、工作边界和应用边界,形成一种层层推进的设计驱动力,可以让我们对限界上下文的设计变得更加准确,边界的控制变得更加合理,毕竟,限界上下文的识别对于整个系统的架构至关重要。在领域驱动的战略设计阶段,如果我们对识别出来的限界上下文的准确性还心存疑虑,那么比较实际的做法是保持限界上下文一定的粗粒度。倘若觉得功能的边界不好把握分寸,可以考虑将这些模棱两可的功能放在同一个限界上下文中。待到该限界上下文变得越来越庞大,以至于一个 2PTs 团队无法完成交付目标;又或者该限界上下文的功能各有不同的质量属性要求;要么就是因为重用或变化,使得我们能够更清楚地看到分解的必要性;此时我们再对该限界上下文进行分解,就会更加有把握。这是设计的实证主义态度。
|
||||
|
||||
通过以上过程去识别限界上下文,仅仅是一种对领域问题域的静态划分,我们还缺少另外一个重要的关注点,即:限界上下文之间是如何协作的?倘若限界上下文识别不合理,协作就会变得更加困难,尤其当一个限界上下文对应一个微服务时,协作成本更会显著增加。反过来,当我们发现彼此协作存在问题时,说明限界上下文的划分出现了问题,这算是对识别限界上下文的一种验证方法。Eric Evans 将这种体现限界上下文协作方式的要素称之为“上下文映射(Context Map)”。
|
||||
|
||||
|
||||
|
||||
|
29
专栏/领域驱动设计实践(完)/017理解上下文映射.md
Normal file
29
专栏/领域驱动设计实践(完)/017理解上下文映射.md
Normal file
@ -0,0 +1,29 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
017 理解上下文映射
|
||||
一个软件系统通常被分为多个限界上下文,这是运用“分而治之”思想来降低业务复杂度的有效手段,设计的难题往往会停留在“如何分”,然而限界上下文之间的“怎么合”问题同样值得关注,分与合遵循的还是软件设计的最高原则——高内聚、松耦合。分是合的基础,基于内聚相关度进行合理的分配,可以在一定程度减少限界上下文之间不必要的关联。假设分配是合理的,则接下来的“合”就是要尽可能地降低彼此之间的耦合。
|
||||
|
||||
既然前面提及限界上下文的识别是一个迭代过程,当我们在思考限界上下文该如何协作时,倘若发现协作总有不合理之处,就可能会是一个“设计坏味道”的信号,它告诉我们:之前识别的限界上下文或有不妥,由是可以审视之前的设计,进而演进为更为准确的限界上下文划分。即使抛开对设计的促进作用,思考限界上下文是如何协作的,仍然格外重要,我们既要小心翼翼地维护限界上下文的边界,又需要它们彼此之间良好的协作,并思考协作的具体实现方式,这个思考过程既牵涉到逻辑架构层面,又与物理架构有关,足以引起我们的重视。
|
||||
|
||||
领域驱动设计通过上下文映射(Context Map) 来讨论限界上下文之间的协作问题,上下文映射是一种设计手段,Eric Evans 总结了诸如共享内核(Shared Kernel)、防腐层(Anticorruption Layer)、开放主机服务(Open Host Service)等多种模式。由于上下文映射本质上是与限界上下文一脉相承的,因此要掌握这些协作模式,应该从限界上下文的角度进行理解,着眼点还是在于“边界”。领域驱动设计认为:上下文映射是用于将限界上下文边界变得更清晰的重要工具。所以当我们正在为一些限界上下文的边界划分而左右为难时,不妨先放一放,在定下初步的限界上下文后,通过绘制上下文映射来检验,或许会有意外收获。
|
||||
|
||||
限界上下文的一个核心价值,就是利用边界来约束不同上下文的领域模型,以保证模型的一致性。然而,每个限界上下文都不是独立存在的,多数时候,都需要多个限界上下文通力协作,才能完成一个完整的用例场景。例如,客户之于商品、商品之于订单、订单之于支付,贯穿起来才能完成“购买商品”的核心流程。
|
||||
|
||||
两个限界上下文之间的关系是有方向的,领域驱动设计使用两个专门的术语来表述它们:“上游(Upstream)”和“下游(Downstream)”,在上下文映射图中,以 U 代表上游,D 代表下游,理解它们之间的关系,正如理解该术语隐喻的河流,自然是上游产生的变化会影响到下游,反之则不然。故而从上游到下游的关系方向,代表了影响产生的作用力,影响作用力的方向与程序员惯常理解的依赖方向恰恰相反,上游影响了下游,意味着下游依赖于上游。
|
||||
|
||||
|
||||
|
||||
在划分限界上下文的业务边界时,我们常常从“语义相关性”与“功能相关性”两个角度去判别职责划分的合理性。在上下文映射中,我发现之所以两个业务边界的限界上下文能产生上下游协作关系,皆源于二者的功能相关性,这种功能相关存在主次之分,往往是上游限界上下文作为下游限界上下文的功能支撑,这就意味着在当前的协作关系下,下游限界上下文中的用例才是核心领域。例如,订单与支付,下订单用例才是核心功能,支付功能作为支撑的公开服务而被调用;例如,邮件与文件共享,写邮件用例才是核心功能,上传附件作为支撑的公开服务而被调用;例如,项目管理与通知,分配任务用例才是核心功能,通知功能作为支撑的公开服务而被调用。巧的是,这种主次功能的调用关系,几乎对应的就是用例图中的包含用例或扩展用例。
|
||||
|
||||
|
||||
|
||||
如果我们通过用例图来帮助识别限界上下文,那么,用例图中的包含用例或扩展用例或许是一个不错的判断上下文协作关系的切入点。选择从包含或扩展关系切入,既可能确定了职责分离的逻辑边界,又可以确定协作关系的方向,这就是用例对领域驱动设计的价值所在了。
|
||||
|
||||
那么,如何将上下文映射运用到领域驱动的战略设计阶段?Eric Evans 为我们总结了常用的上下文映射模式。为了更好地理解这些模式,结合限界上下文对边界的控制力,再根据这些模式的本质,我将这些上下文映射模式分为了两大类:团队协作模式与通信集成模式。前者对应的其实是团队合作的工作边界,后者则从应用边界的角度分析了限界上下文之间应该如何进行通信才能提升设计质量。针对通信集成模式,结合领域驱动设计社区的技术发展,在原有上下文映射模式基础上,增加了发布/订阅事件模式。
|
||||
|
||||
|
||||
|
||||
|
105
专栏/领域驱动设计实践(完)/018上下文映射的团队协作模式.md
Normal file
105
专栏/领域驱动设计实践(完)/018上下文映射的团队协作模式.md
Normal file
@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
018 上下文映射的团队协作模式
|
||||
如果我们将限界上下文理解为是对工作边界的控制,则上下文之间的协作实则就是团队之间的协作,高效的团队协作应遵循“各司其职、权责分明”的原则。从组织层面看,需要预防一个团队的“权力膨胀”,导致团队的“势力范围”扩大到整个组织;从团队层面,又需要避免自己的权力遭遇压缩,导致自己的话语权越来越小,这中间就存在一个平衡问题。映射到领域驱动设计的术语,就是要在满足合理分配职责的前提下,谨慎地确保每个限界上下文的粒度。
|
||||
|
||||
当然,一个高效的组织,其内部团队之间必然不是“老死不相往来”的陌生客。职责的合理分配,可以更好地满足团队的自组织或者说自治,但不可能做到“万事不求人”,全靠自己来做。如果什么事情都由这一个团队完成,这个团队也就成为无所不能的“上帝”团队了。Vaughn Vernon 就认为:“上下文映射展现了一种组织动态能力(Organizational Dynamic),它可以帮助我们识别出有碍项目进展的一些管理问题。”这也是我为何要在识别上下文的过程中引入项目经理这个角色的原因所在,因为在团队协作层面,限界上下文与项目管理息息相关。
|
||||
|
||||
领域驱动设计根据团队协作的方式与紧密程度,定义了五种团队协作模式。
|
||||
|
||||
合作关系(Partnership)
|
||||
|
||||
合作(Partnership)是一个美好的词语,但在软件设计中,却未必是一个正面的褒义词,因为合作得越多,就意味着依赖越多。Vaughn Vernon 在其著作《实现领域驱动设计》中如此定义这种关系:
|
||||
|
||||
|
||||
如果两个限界上下文的团队要么一起成功,要么一起失败,此时他们需要建立起一种合作关系,他们需要一起协调开发计划和集成管理。两个团队应该在接口的演化上进行合作以同时满足两个系统的需求。应该为相互关联的软件功能制定好计划表,这样可以确保这些功能在同一个发布中完成。
|
||||
|
||||
|
||||
这种一起成功或一起失败的“同生共死”关系代表的固然是良好的合作,却也说明二者可能存在强耦合关系,甚至是糟糕的双向依赖。对于限界上下文的边界而言,即使是逻辑边界,出现双向依赖也是不可饶恕的错误。倘若我们视限界上下文为微服务,则这种“确保这些功能在同一个发布中完成”的要求,无疑抵消了许多微服务带来的好处,负面影响不言而喻。
|
||||
|
||||
在我过去参与的一个面向教育行业的 SaaS 系统中,我们划分了 ReportEngine、EntityEngine 与 DataEngine 以及 ReportDesigner 等限界上下文。当绘制出上下文映射图时,我们发现这多个限界上下文之间出现了双向依赖与循环依赖,如下图所示:
|
||||
|
||||
|
||||
|
||||
说明: 虽然在领域驱动设计中,我们应该以标准的模式来表示限界上下文之间的关系,例如标注 U 和 D 代表上游和下游,标注 Partnership 说明二者为合作关系。但在上图我却采用了依赖方式来说明,目的是可以更清晰地体现双向依赖和循环依赖的特征。
|
||||
|
||||
ReportEngine 与 EntityEngine 之间存在双向依赖,二者又与 DataEngine 之间产生了循环依赖。这种依赖导致三个限界上下文“貌离神合”,边界控制不够彻底,使得它们并不能真正的分开。倘若这三个限界上下文被构建为三个 JAR 包,这种依赖会导致它们在编译时谁也离不开谁。如果是微服务,则任何一个服务出现故障,其他服务都不可用。
|
||||
|
||||
我个人认为限界上下文的“合作关系”其实是一种“反模式”,罪魁祸首是因为职责分配的不当,是一种设计层面的“特性依恋(Feature envy)”坏味道。解决的办法通常有三种:
|
||||
|
||||
|
||||
既然限界上下文存在如此紧密的合作关系,就说明当初拆分的理由较为牵强,与其让它们因为分开而“难分难舍”,不如干脆让它们合在一起。
|
||||
将产生特性依赖的职责分配到正确的位置,尽力减少一个方向的多余依赖。
|
||||
识别产生双向依赖或循环依赖的原因,然后将它们从各个限界上下文中剥离出来,并为其建立单独的限界上下文,这就是所谓的“共享内核(Shared Kernel)”。
|
||||
|
||||
|
||||
分析前面的例子,之所以 ReportEngine、EntityEngine 与 DataEngine 之间存在不正确的循环依赖,原因是我们错误地将元数据功能放到了 ReportEngine 限界上下文中。EntityEngine 与DataEngine 之所以依赖 ReportEngine,并不是需要调用属于 ReportEngine 本身职责的功能,而是需要访问元数据。事实上,我们还发现 ReportDesigner 也是因为需要访问元数据,才会依赖 ReportEngine。此时,拆分出单独的元数据限界上下文才是最佳选择:
|
||||
|
||||
|
||||
|
||||
新引入的 Metadata 成为了其余限界上下文的上游,却解除了 DataEngine 对 ReportEngine 的依赖,同样解除了 EntityEngine 以及 ReportDesigner 对 ReportEngine 的依赖。多余引入的 Metadata 上下文就是我们之前在识别上下文时未曾发现的,现在通过上下文映射,帮助我们甄别了这一错误,及时调整了系统的限界上下文。
|
||||
|
||||
共享内核(Shared Kernel)
|
||||
|
||||
前面提取“元数据限界上下文”的模式,就是“共享内核”的体现。从设计层面看,共享内核是解除不必要依赖实现重用的重要手段。当我们发现了属于共享内核的限界上下文后,需要确定它的团队归属。注意,共享内核仍然属于领域的一部分,它不是横切关注点,也不是公共的基础设施。分离出来的共享内核属于上游团队的职责,因而需要处理好它与下游团队的协作。
|
||||
|
||||
虽然名为“内核”,但这是一种技术层面的命名,并不一定意味着该限界上下文的逻辑属于核心领域(Core Domain)。相反,多数情况下,共享内核属于子领域(SubDomain)。
|
||||
|
||||
共享内核往往被用来解决合作关系引入的问题。
|
||||
|
||||
共享内核是通过上下文映射识别出来的,通过它可以改进设计质量,弥补之前识别限界上下文的不足。与其说它是上下文映射的一种模式,不如说它是帮助我们识别隐藏限界上下文的模式,主要的驱动力就是“避免重复”,即 DRY(Don’t Repeat Yourself)原则的体现。在前面讲解通过应用边界识别限界上下文时,我提到了物流联运管理系统。运输、货站以及堆场都用到了作业与指令功能。显然,作业与指令功能放在运输、货站或堆场都不合理,这时就是运用“共享内核”的时机。为了避免重复,也为了避免不必要的依赖,可以提取出作业上下文。
|
||||
|
||||
当然,这种重用是需要付出代价的。Eric Evans 指出:“共享内核不能像其他设计部分那样自由更改,在做决定时需要与另一个团队协商。”至于修改产生的影响有多大,需要视该限界上下文与其他限界上下文之间的集成关系。尤其是大多数共享内核可能是多个限界上下文共同的上游,每次修改都可能牵一发而动全身。因此在对共享内核进行修改时,需要充分评估这种修改可能带来的影响。
|
||||
|
||||
客户方-供应方开发(Customer-Supplier Development)
|
||||
|
||||
正常情况下,这是团队合作中最为常见的合作模式,体现的是上游(供应方)与下游(客户方)的合作关系。这种合作需要两个团队共同协商:
|
||||
|
||||
|
||||
下游团队对上游团队提出的领域需求
|
||||
上游团队提供的服务采用什么样的协议与调用方式
|
||||
下游团队针对上游服务的测试策略
|
||||
上游团队给下游团队承诺的交付日期
|
||||
当上游服务的协议或调用方式发生变更时,该如何控制变更
|
||||
|
||||
|
||||
注意,在很多业务系统中,下游团队往往都不止一个。如何排定不同领域需求的优先级,如何针对不同的领域需求建立统一的抽象,都是上游团队需要考虑的问题。若上游服务还未提供,下游团队应采取模拟上游服务的方式来规避可能存在的集成风险,并且需要考虑上游团队不能按时履行交付承诺时的应对方案。上游团队需要及时就服务的任何变更与所有下游团队进行协商,而下游团队的领域需求一旦有变,也应及时告知上游团队。如果能够采用持续集成(Continuous Integration)为上、下游限界上下文建立集成测试、API 测试等自动化测试的构建与发布管道,可以更好地规避集成的风险,也能够更好地了解因为上游服务发生变更时对所有下游产生的影响。
|
||||
|
||||
例如,我们在设计通知(Notification)上下文时,作为上游服务的开发团队,需要考虑各种信息通知的领域需求。从通知类型看,可以是邮件、短信、微信推送和站内信息推送等多种方式。从通知格式看,可能是纯文本、HTML 或微信文章。从通知内容看,可以是固定内容,也可能需要提供通知模板,由调用者提供数据填充到模板中的正确位置。
|
||||
|
||||
设计该服务时,我们既要考虑这些通知服务实现的多样化,又要考虑服务调用的简单与一致性。至于发送的通知内容,则需要上游团队事先定义通知上下文的领域模型。该领域模型既要覆盖所有的业务场景,又要保证模型的稳定性,同时还必须注意维持通知上下文的职责边界。
|
||||
|
||||
譬如说,我们在通知上下文中定义了 Message 与 Template 领域对象,后者内部封装了一个HashMap<String, String>类型的属性。Map 的 key 对应模板中的变量,value 则为实际填充的值。建模时,我们明确了通知上下文的职责,它仅负责模板内容正确地填充,并不负责对值的解析。这就是上游定义的契约,它清晰地勾勒了上下文之间协作的边界。倘若下游团队在填充通知模板的值时,还需要根据自己的业务规则进行运算,就应该在调用通知服务之前,首先在自己的限界上下文中进行计算,然后再将计算后的值作为模板的 value 传入。
|
||||
|
||||
遵奉者(Conformist)
|
||||
|
||||
我们需要从两个角度来理解遵奉者模式,即需求的控制权与对领域模型的依赖。
|
||||
|
||||
一个正常的客户方-供应方开发模式,是上游团队满足下游团队提出的领域需求;但当需求的控制权发生了逆转,由上游团队来决定是响应还是拒绝下游团队提出的请求时,所谓的“遵奉者”模式就产生了。从这个角度来看,我们可以将遵奉者模式视为一种“反模式”。糟糕的是在现实的团队合作中,这种情形可谓频频发生,尤其是当两个团队分属于不同的管理者时,牵涉到的因素就不仅仅是与技术有关了。所以说领域驱动设计提出的“限界上下文”实践,影响的不仅仅是设计决策与技术实现,还与企业文化、组织结构直接有关。许多企业推行领域驱动设计之所以不够成功,除了团队成员不具备领域驱动设计的能力之外,还要归咎于企业文化和组织结构层面。例如,企业的组织结构人为地制造了领域专家与开发团队的壁垒,又比如两个限界上下文因为利益倾轧而导致协作障碍,而团队领导的求稳心态,也可能导致领域驱动设计“制造”的变化屡屡碰壁,无法将这种良性的“变化”顺利地传递下去。
|
||||
|
||||
遵奉者还有一层意思是下游限界上下文对上游限界上下文模型的追随。当我们选择对上游限界上下文的模型进行“追随”时,就意味着:
|
||||
|
||||
|
||||
可以直接重用上游上下文的模型(好的)
|
||||
减少了两个限界上下文之间模型的转换成本(好的)
|
||||
使得下游限界上下文对上游产生了模型上的强依赖(坏的)
|
||||
|
||||
|
||||
做出遵奉模型决策的前提是需要明确这两个上下文的统一语言是否存在一致性,因为限界上下文的边界本身就是为了维护这种一致性而存在的。理想状态下,即使是上下游关系的两个限界上下文都应该使用自己专属的领域模型,因为原则上不同限界上下文对统一语言的观察视角多少会出现分歧,但模型转换的成本确实会令你左右为难。设计总是如此,没有绝对好的解决方案,只能依据具体的业务场景权衡利弊得失,以求得到相对好(而不是最好)的方案。这是软件设计让人感觉棘手的原因,却也是它如此迷人的魅力所在。
|
||||
|
||||
分离方式(Separate Ways)
|
||||
|
||||
分离方式的合作模式就是指两个限界上下文之间没有哪怕一丁点儿的丝毫关系。这种“无关系”仍然是一种关系,而且是一种最好的关系。这意味着我们无需考虑它们之间的集成与依赖,它们可以独立变化而互相不产生影响,还有什么比这更美好的呢?
|
||||
|
||||
在典型的电商网站中,支付上下文与商品上下文之间就没有任何关系,二者是“分离方式”的体现。虽然从业务角度理解,客户购买商品,确乎是为商品进行支付,但在商品上下文中,我们关心的是商品的价格(另一种可能是将价格作为一个独立的上下文),在支付上下文,关注的却是每笔交易的金额。商品价格影响的是订单上下文,支付上下文会作为订单上下文的上游,被订单上下文调用,但这种调用传递的是每条订单的总金额,支付上下文并不关心每笔订单究竟包含了哪些商品。唯一让支付上下文与商品上下文之间可能存在关联的因素,是二者的领域模型中都需要 Money 值对象。我们可以在这两个限界上下文中重复定义 Money 值对象。如果 Money 值对象其实还牵涉到复杂的货币转换以及高精度的运算逻辑,我宁可将类似 Money 这样的对象剥离到单独的上下文中,例如单独拎出来一个货币上下文。此时的货币上下文其实是支付上下文与商品上下文的共享内核:
|
||||
|
||||
|
||||
|
||||
“分离方式”的映射模式看起来容易识别,然而一旦系统的领域知识变得越来越复杂,导致多个限界上下文之间存在错综复杂的关系时,要识别两个限界上下文之间压根没有一点关系,就需要敏锐的“视力”了。这种没有关系的关系似乎无足轻重,其实不然,它对改进设计质量以及团队组织都有较大帮助。两个毫无交流与协作关系的团队看似冷漠无情,然而,正是这种“无情”才能促进它们独立发展,彼此不受影响。
|
||||
|
||||
|
||||
|
||||
|
160
专栏/领域驱动设计实践(完)/019上下文映射的通信集成模式.md
Normal file
160
专栏/领域驱动设计实践(完)/019上下文映射的通信集成模式.md
Normal file
@ -0,0 +1,160 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
019 上下文映射的通信集成模式
|
||||
无论采用何种设计,限界上下文之间的协作都是不可避免的,应用边界的上下文映射模式会以更加积极的态度来应对这种不可避免的协作;从设计的角度来讲,就是不遗余力地降低限界上下文之间的耦合关系。防腐层与开放主机服务的目的正是如此。
|
||||
|
||||
防腐层(Anticorruption Layer)
|
||||
|
||||
防腐层其实是设计思想“间接”的一种体现。在架构层面,通过引入一个间接的层,就可以有效隔离限界上下文之间的耦合,这个间接的防腐层还可以扮演“适配器”的角色、“调停者”的角色、“外观”的角色,没错,这都是 GOF 设计模式中常见的几种结构型模式。
|
||||
|
||||
防腐层往往属于下游限界上下文,用以隔绝上游限界上下文可能发生的变化。因为不管是遵奉者模式,还是客户方-供应方模式,下游团队终究可能面临不可掌控的上游变化。在防腐层中定义一个映射上游限界上下文的服务接口,就可以将掌控权控制在下游团队中,即使上游发生了变化,影响的也仅仅是防腐层中的单一变化点,只要防腐层的接口不变,下游限界上下文的其他实现就不会受到影响。
|
||||
|
||||
我们可以通过下图来对比引入防腐层的价值:
|
||||
|
||||
|
||||
|
||||
显然,在没有引入防腐层时,下游上下文可能存在多处对上游上下文领域模型的依赖,一旦上游发生变更,就会影响到下游的多处实现;引入防腐层后,之前产生的多处依赖转为对防腐层的依赖,再由防腐层指向上游上下文,形成单一依赖。上游变更时,影响的仅仅是防腐层,下游上下文自身并未受到影响。
|
||||
|
||||
用以对付遗留系统时,防腐层可谓首选利刃。我在前面讲解限界上下文对遗留系统的应对时,已经述及采用“抽象分支”与“防腐层”的手法。对于遗留系统,我们不能粗暴地用新系统取代它,而应采用渐进的手段尽可能重用它的资产,剔除不好的设计与实现,完成逐步替换;我们可以将遗留系统视为一个整体的限界上下文,然后为调用它的下游上下文建立防腐层。由于防腐层是我们自己掌控的,就可以在其内动动手脚,例如,从调用者角度思考需要公开的服务接口,并引入领域驱动设计为其提炼出清晰的领域模型,然后再从遗留系统中去寻找对应的实现,慢慢将合适的代码搬移过来,适时对其重构。这种做法既保有了新设计的新鲜感,不受技术债的影响,又不至于走向极端,对旧有系统大动干戈,可谓选择了一条“中庸之道”,能够新旧并存地小步前行。
|
||||
|
||||
开放主机服务(Open Host Service)
|
||||
|
||||
如果说防腐层是下游限界上下文对抗上游变化的利器,那么开放主机服务就是上游服务用来吸引更多下游调用者的诱饵。设计开放主机服务,就是定义公开服务的协议,包括通信的方式、传递消息的格式(协议)。同时,也可视为是一种承诺,保证开放的服务不会轻易做出变化。
|
||||
|
||||
开放主机服务常常与发布语言(Published Language)模式结合起来使用。当然,在定义这样的公开服务时,为了被更多调用者使用,需要力求语言的标准化,在分布式系统中,通常采用 RPC(Protocol Buffer)、WebService 或 RESTful。若使用消息队列中间件,则需要事先定义消息的格式,例如,在我参与过的一个分布式 CIMS(计算集成制造系统)中,客户端与服务端以及服务端之间的通信皆以消息形式传递,我们定义了如下的消息格式:
|
||||
|
||||
Message——Name
|
||||
——ID
|
||||
——Body(MessageItemSequence)
|
||||
——Value
|
||||
——Item(MessageItem)
|
||||
——SubValue
|
||||
——SubItem(MessageItem)
|
||||
|
||||
|
||||
|
||||
采用这种消息格式,几乎所有的分布式服务都可以抽象为这样的接口:
|
||||
|
||||
public interface RemotingService {
|
||||
/**
|
||||
* @param serviceName为需要调用的远程服务名
|
||||
* @param request为Message类型的request消息
|
||||
* @return 返回Message类型的response消息
|
||||
* @throws 自定义的RemotingException,其中包含的message仍然为Message结构,表达Error
|
||||
*/
|
||||
Message execute(String serviceName, Message request) throws RemotingException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
为了降低上游与下游限界上下文之间的依赖,防腐层与开放主机服务都是一种有效的手段,前者归属于下游限界上下文的范围,后者则属于上游限界上下文的边界,但二者是存在区别的,上游限界上下文作为被依赖方,往往会被多个下游限界上下文消费,如果需要引入防腐层,意味着需要为每个下游都提供一个几乎完全相似的防腐层,导致了防腐层的重复。因此,倘若上、下游限界上下文都在开发团队内部,又或者二者之间建立了良好的团队协作,我更倾向于在上游限界上下文中定义开放主机服务。当然,在极端情况下,可能需要在为上游限界上下文提供开放主机服务的同时,还需要为下游限界上下文定义防腐层。
|
||||
|
||||
在绘制上下文映射图时,我们往往用 ACL 缩写来代表防腐层,用 OHS 缩写代表开放主机服务。
|
||||
|
||||
发布/订阅事件
|
||||
|
||||
即使是确定了发布语言规范的开放主机服务,仍然会导致两个上下文之间存在耦合关系,下游限界上下文必须知道上游服务的 ABC(Address、Binding 与 Contract),对于不同的分布式实现,还需要在下游定义类似服务桩的客户端。例如,在基于 Spring Cloud 的微服务架构中,虽然通过引入 Euraka 实现了对服务的注册与发现,降低了对 Address、Binding 的依赖,但仍然需要在下游限界上下文定义 Feign 客户端,你可以将这个 Feign 客户端理解为是真实服务的一个代理(Proxy)。基于代理模式,我们要求代理与被代理的真实服务(Subject)保持相同的接口,这就意味着,一旦服务的接口发生变化,就需要修改客户端代码。
|
||||
|
||||
采用发布/订阅事件的方式可以在解耦合方面走得更远。一个限界上下文作为事件的发布方,另外的多个限界上下文作为事件的订阅方,二者的协作通过经由消息中间件进行传递的事件消息来完成。当确定了消息中间件后,发布方与订阅方唯一存在的耦合点就是事件,准确地说,是事件持有的数据。由于业务场景通常较为稳定,我们只要保证事件持有的业务数据尽可能满足业务场景即可。这时,发布方不需要知道究竟有哪些限界上下文需要订阅该事件,它只需要按照自己的心意,随着一个业务命令的完成发布事件即可。订阅方也不用关心它所订阅的事件究竟来自何方,它要么通过 pull 方式主动去拉取存于消息中间件的事件消息,要么等着消息中间件将来自上游的事件消息根据事先设定的路由推送给它,通过消息中间件,发布方与订阅方完全隔离了。在上下文映射中,这种基于发布/订阅事件的协作关系,已经做到了力所能及的松耦合极致了。
|
||||
|
||||
以电商购物流程为例,从买家搜索商品并将商品加入到购物车开始,到下订单、支付、配送完成订单结束,整个过程由多个限界上下文一起协作完成。倘若以发布/订阅事件作为这些限界上下文之间的协作模式,则发布和订阅事件的流程如下所示:
|
||||
|
||||
|
||||
|
||||
如果将发布事件的限界上下文定义为上游,订阅事件的限界上下文定义为下游,则下表展现了事件在上下游限界上下文之间的流转:
|
||||
|
||||
|
||||
|
||||
|
||||
ID
|
||||
Event
|
||||
Upstream Context
|
||||
Downstream Context
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
|
||||
ProductSelected
|
||||
Product Context
|
||||
Basket Context
|
||||
|
||||
|
||||
|
||||
2
|
||||
OrderRequested
|
||||
Basket Context
|
||||
Order Context
|
||||
|
||||
|
||||
|
||||
3
|
||||
InventoryRequested
|
||||
Order Context
|
||||
Inventory Context
|
||||
|
||||
|
||||
|
||||
4
|
||||
AvailabilityValidated
|
||||
Inventory Context
|
||||
Order Context
|
||||
|
||||
|
||||
|
||||
5
|
||||
OrderValidated
|
||||
Order Context
|
||||
Payment Context
|
||||
|
||||
|
||||
|
||||
6
|
||||
PaymentProcessed
|
||||
Payment Context
|
||||
Order Context
|
||||
|
||||
|
||||
|
||||
7
|
||||
OrderConfirmed
|
||||
Order Context
|
||||
Shipment Context
|
||||
|
||||
|
||||
|
||||
8
|
||||
ShipmentDelivered
|
||||
Shipment Context
|
||||
Order Context
|
||||
|
||||
|
||||
|
||||
采用发布/订阅事件模式的限界上下文不必一定是分布式架构,关键在于负责传递事件的介质是什么?如果采用独立进程运行的消息中间件,例如 RabbitMQ 或者 Kafka,可以更加有效地利用资源,整个系统的可伸缩性会变得更好。然而,考虑到进程间通信带来的成本,以及维护事务一致性带来的阻碍,我们也可以开发运行在同一个 JVM 进程中的事件总线(Event Bus)来负责事件的发布和订阅。我们还可以采用 Actor 模式支持事件的发布与订阅,Actor 会维持一个 mailbox,它相当于是一个轻量级的消息队列。以电商系统为例,例如,Order Context 的 OrderActor 接收 OrderRequested 事件,Basket Context 的 BasketActor 负责处理 ConfirmBasket 命令:
|
||||
|
||||
class BasketActor(eventPublisher: ActorRef) extends Actor with ActorLogging {
|
||||
def receive: Receive = {
|
||||
case cmd: ConfirmBasket =>
|
||||
// compose OrderRequested event with product list
|
||||
eventPublisher ! OrderRequested(cmd.customerId(), cmd.products())
|
||||
}
|
||||
}
|
||||
|
||||
class OrderActor extends Actor with ActorLogging {
|
||||
def receive: Receive = {
|
||||
case event: OrderRequested =>
|
||||
// validate order
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
发布/订阅事件模式是松耦合的,但它有特定的适用范围,通常用于异步非实时的业务场景。当然,它的非阻塞特性也使得整个架构具有更强的响应能力,因而常用于业务相对复杂却没有同步要求的命令(Command)场景。这种协作模式往往用于事件驱动架构或者 CQRS(Command Query Responsibility Segregation,命令查询职责分离)架构模式中。
|
||||
|
||||
|
||||
|
||||
|
208
专栏/领域驱动设计实践(完)/020辨别限界上下文的协作关系(上).md
Normal file
208
专栏/领域驱动设计实践(完)/020辨别限界上下文的协作关系(上).md
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
020 辨别限界上下文的协作关系(上)
|
||||
在思考限界上下文之间的协作关系时,首先我们需要确定是否存在关系,然后再确定是何种关系,最后再基于变化导致的影响来确定是否需要引入防腐层、开放主机服务等模式。倘若发现协作关系有不合理之处,则需要反思之前我们识别出来的限界上下文是否合理。
|
||||
|
||||
限界上下文通信边界对协作的影响
|
||||
|
||||
确定限界上下文之间的关系不能想当然,需得全面考虑参与到两个限界上下文协作的业务场景,然后在场景中识别二者之间产生依赖的原因,确定依赖的方向,进而确定集成点,需要注意的是,限界上下文的通信边界对于界定协作关系至为关键。限界上下文的通信边界分为进程内边界与进程间边界,这种通信边界会直接影响到我们对上下文映射模式的选择。例如,采用进程间边界,就需得考虑跨进程访问的成本,如序列化与反序列化、网络开销等。由于跨进程调用的限制,彼此之间的访问协议也不尽相同,同时还需要控制上游限界上下文可能引入的变化,一个典型的协作方式是同时引入开放主机服务(OHS)与防腐层(ACL),如下图所示:
|
||||
|
||||
|
||||
|
||||
限界上下文 A 对外通过控制器(Controller)为用户界面层暴露 REST 服务,而在内部则调用应用层的应用服务(Application Service),然后再调用领域层的领域模型(Domain Model)。倘若限界上下文 A 需要访问限界上下文 B 的服务,则通过放置在领域层的接口(Interface)去访问,但真正的访问逻辑实现则由基础设施层的客户端(Client)完成,这个客户端就是上下文映射模式的防腐层。客户端访问的其实是限界上下文 B 的控制器,这个控制器处于基础设施层,相当于上下文映射模式的开放主机服务。限界上下文 B 访问限界上下文 C 的方式完全一致,在限界上下文 C 中,则通过资源库(Repository)接口经由持久化(Persistence)组件访问数据库。
|
||||
|
||||
从图中可以看到,当我们在界定限界上下文的协作关系时,需要考虑分层架构设计。通常,我们会将分层架构的应用层、领域层与基础设施层都视为在限界上下文的边界之内。如果限界上下文并未采用“零共享架构”,那么,在考虑协作关系时还需要考虑数据库层是否存在耦合。
|
||||
|
||||
唯独分层架构的用户界面层是一个例外,我们在领域建模时,通常不会考虑用户界面层,它并不属于限界上下文。究其原因,在于用户界面层与领域的观察视角完全不同。用户界面层重点考虑的是用户体验,而非业务的垂直划分,更不会考虑到业务之间的高内聚、松耦合。许多时候,为了用户操作的方便性,减少用户的操作次数,提高用户体验,可能会在一个 UI 页面中聚合许多属于不同限界上下文的业务。我们可以看看亚马逊或京东的页面,例如,在“我的京东”页面下,几乎将整个电商系统中各方面的业务都一网打尽了。这不符合我们对限界上下文的划分原则。事实上,在“前后端分离”的架构中,用户界面层往往会作为后端服务的调用者,当然应该被排除在限界上下文之外了。
|
||||
|
||||
这里存在一个设计决策,即引入开放主机服务与防腐层是否必要?这就需要设计者权衡变化、代码重用、架构简单性的优先级。没有标准答案,而需结合具体的应用场景帮助你做出判断。我自然无法穷尽所有的业务场景,这里给出的无非是其中一种选择罢了。譬如说,倘若限界上下文采用进程内通信,那么下游限界上下文是否还需要通过客户端与控制器去访问,就值得斟酌了。如果需要考虑未来从进程内通信演化为进程间通信,则保留客户端及其接口就是有必要的。
|
||||
|
||||
说明:以上提到的限界上下文通信边界、领域驱动设计分层架构、零共享架构、代码模型结构以及北向网关、南向网关的知识,都会在后面章节详细阐述。
|
||||
|
||||
协作即依赖
|
||||
|
||||
如果限界上下文之间存在协作关系,必然是某种原因导致这种协作关系。从依赖的角度看,这种协作关系是因为一方需要“知道”另一方的知识,这种知识包括:
|
||||
|
||||
|
||||
领域行为:需要判断导致行为之间的耦合原因是什么?如果是上下游关系,要确定下游是否就是上游服务的真正调用者。
|
||||
领域模型:需要重用别人的领域模型,还是自己重新定义一个模型。
|
||||
数据:是否需要限界上下文对应的数据库提供支撑业务行为的操作数据。
|
||||
|
||||
|
||||
领域行为产生的依赖
|
||||
|
||||
所谓领域行为,落到设计层面,其实就是每个领域对象的职责,职责可以由实体(Entity)、值对象(Value Object)来承担,也可以是领域服务(Domain Service)或者资源库(Repository)乃至工厂(Factory)对象来承担。
|
||||
|
||||
对象履行职责的方式有三种,Rebecca Wirfs-Brock 在《对象设计:角色、职责与协作》一书中总结为:
|
||||
|
||||
|
||||
亲自完成所有的工作。
|
||||
请求其他对象帮忙完成部分工作(和其他对象协作)。
|
||||
将整个服务请求委托给另外的帮助对象。
|
||||
|
||||
|
||||
|
||||
|
||||
如果我们选择后两种履行职责的形式,就必然牵涉到对象之间的协作。一个好的设计,职责一定是“分治”的,就是让每个高内聚的对象只承担自己擅长处理的部分,而将自己不擅长的职责转移到别的对象。《建筑的永恒之道》作者 Christepher Alexander 就建议,在遇到设计问题时尽量少用集权的机制。还是在《对象设计:角色、职责与协作》这本书,作者认为:
|
||||
|
||||
|
||||
软件对象通过相互作用和共享责任联系在一起。在对象之间建立简单、一致的通信机制,避免了解决方案的集权性,局部变化的影响不应扩散到整个系统,这是系统的强适应性所要求的。当职责得以划分,组织有序,同时协作遵循可预测性模式,那么复杂的软件系统就更便于管理。
|
||||
|
||||
|
||||
领域驱动设计提出的限界上下文事实上是架构层次的“分权”,通过它的边界让“职责得以划分,组织有序”,限界上下文之间的协作也“遵循可预测性模式”,就可以有效地控制业务复杂度与技术复杂度。因此,在考虑限界上下文的协作关系时,关键要辨别这些分离的职责,弄清楚到底是限界上下文内的对象协作,还是限界上下文之间的对象协作,主要考虑有如下两个方面:
|
||||
|
||||
|
||||
职责由谁来履行?——这牵涉到领域行为该放置在哪一个限界上下文。
|
||||
谁发起对该职责的调用?——倘若发起调用者与职责履行者在不同限界上下文,则意味着二者存在协作关系,且能够帮助我们确定上下游关系。
|
||||
|
||||
|
||||
以电商系统的订单功能为例。考虑一个业务场景,客户已经选择好要购买的商品,并通过购物车提交订单,这就牵涉到一个领域行为:提交订单。假设客户属于客户上下文,而订单属于订单上下文,现在需要考虑提交订单的职责由谁来履行。
|
||||
|
||||
从电商系统的现实模型看,该领域行为由客户发起,也就是说客户应该具有提交订单的行为,这是否意味着应该将该行为分配给 Customer 聚合根?其实不然,我们需要注意现实模型与领域模型尤其是对象模型的区别。在“下订单”这个业务场景中,Customer 是一个参与者,角色为买家。领域建模的一种观点认为:领域模型是排除参与者在外的客观世界的模型,作为参与者的 Customer 应该排除在这个模型之外。
|
||||
|
||||
当然,这一观点亦存在争议,例如,四色建模就不这样认为,四色建模建议在时标性对象与作为人的实体对象之间引入角色对象,也就是说,角色对象会作为领域模型的一份子。当然,我们不能直接给角色与模型的参与者划上等号。在 DCI(Data Context Interation)模式中,则需要在一个上下文(Context)中,通过识别角色来思考它们之间的协作关系。譬如在转账业务场景中,银行账户 Account 作为数据对象(Data)参与到转账上下文的协作,此时应抽象出 Source 与 Destination 两个角色对象。
|
||||
|
||||
说明:在战术设计内容中,我会再深入探讨领域建模、四色建模与 DCI 之间的关系与建模细节。
|
||||
|
||||
领域模型的确定总是会引起争论,毕竟每个人观察领域模型的角度不同,对设计的看法也不相同。领域模型最终要落实到代码实现,交给实践去检验设计的合理性,不要在领域建模过程中过多纠缠建模的细节,选择一个恰好合理的模型即可。从建模到设计,再从设计到编码开发,其实是一个迭代的过程,倘若在实现时确实发现模型存在瑕疵,再回过头来修改即可,孜孜以求领域模型的完美,纯属浪费时间,在建模过程中,最重要的是守住最根本的设计原则。在合理运用设计原则之前,要紧的是明确:我们究竟要解决什么问题?
|
||||
|
||||
这里的问题不是如何确定领域模型,而是要确定提交订单这个行为究竟应该分配给谁?首先,这牵涉到对象的职责分配问题。从语义相关性剖析,这个领域行为虽然由客户发起,但操作的信息(知识)主体其实是订单,这就意味着它们应该分配给订单上下文。这种分配实际上也符合面向对象设计原则的“信息专家模式”,即“信息的持有者即为操作该行为的专家”;其次,从分层架构的角度看,这里所谓的“由客户发起调用”,仅仅代表客户通过用户界面层发起对后端服务的请求,换言之,并不是由属于客户上下文的 Customer 领域对象发起调用。
|
||||
|
||||
后面我们会讲到,如果遵循整洁架构的思想,领域层应该处于限界上下文的核心。为了保证业务用例的完整性,并避免暴露太多领域协作的细节,领域驱动设计引入了应用层,它包裹了整个领域层;然而,应用层并不会直接与作为调用者的前端进行通信,通常的方式是引入 RESTful 服务,这个 RESTful 服务等同于上下文映射中的开放主机服务(OHS),又相当于是 MVC 模式中的控制器(Controller),属于基础设施层的组件。针对下订单这个场景,客户通过用户界面层的 OrderController 发起调用。OrderController 收到请求后,在处理了请求消息的验证与转换工作后,又将职责转交给了 OrderAppService,然后通过它访问领域层中的领域服务 PlaceOrderService,如下图所示:
|
||||
|
||||
|
||||
|
||||
下订单场景的实现代码如下所示:
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = "/orders/")
|
||||
public class OrderController {
|
||||
@Autowired
|
||||
private OrderAppService service;
|
||||
|
||||
@RequestMapping(method = RequestMethod.POST)
|
||||
public void create(@RequestParam(value = "request", required = true) CreateOrderRequest request) {
|
||||
if (request.isInvalid()) {
|
||||
throw new BadRequestException("the request of placing order is invalid.");
|
||||
}
|
||||
Order order = request.toOrder();
|
||||
service.placeOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
public class OrderAppService {
|
||||
@Autowired
|
||||
private PlaceOrderService orderService;
|
||||
|
||||
public void placeOrder(Order order) {
|
||||
try {
|
||||
placeOrderService.execute(order);
|
||||
} catch (InvalidOrderException | Exception ex) {
|
||||
throw new ApplicationException(ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
既然 PlaceOrderService、OrderAppService 与 OrderController 都属于订单上下文,而该行为调用的真正发起者又不是 Customer 领域对象,而是通过用户界面与系统进行交互操作的用户,因此在这个业务场景中,并不存在我们想象的因为客户下订单导致客户上下文对订单上下文在领域行为上的依赖。
|
||||
|
||||
在将调用职责分配给前端时,我们需要时刻保持谨慎,不能将对限界上下文调用的工作全都交给前端,以此来解除后端限界上下文之间的耦合。前端确乎是发起调用的最佳位置,但前提是:我们不能让前端来承担后端应该封装的业务逻辑。当一个领域行为成为另一个领域行为“内嵌”的一个执行步骤时,发起的调用者就不再是前端 UI,因为该执行步骤组成了业务逻辑的一部分。例如,在计算订单总价时,需要根据客户的类别确定不同的促销策略,然后根据促销策略计算订单的总价,这里牵涉到四个领域行为:
|
||||
|
||||
|
||||
计算订单总价
|
||||
获得客户类别
|
||||
确定促销策略
|
||||
计算促销折扣
|
||||
|
||||
|
||||
后面三个领域行为都是为“计算订单总价”提供功能支撑的,这就是前面所谓的“内嵌”执行步骤。除了订单总价属于订单上下文的行为,获得客户类别属于客户上下文,而促销策略与折扣计算则属于促销上下文。因为产生了领域行为的依赖,它们会作为订单上下文的上游限界上下文。
|
||||
|
||||
这里其实存在设计上的变化,这取决于我们对职责的分层(在前面讲解的领域场景分析中介绍了职责的分层):
|
||||
|
||||
|
||||
计算订单总价——订单上下文
|
||||
|
||||
|
||||
获得客户类别——客户上下文
|
||||
根据客户类别获得促销策略——促销上下文
|
||||
通过促销策略计算促销折扣——促销上下文
|
||||
|
||||
|
||||
|
||||
当采用这种职责分层结构时,客户上下文与促销上下文就是订单上下文的上游。如果我们将获得客户类别视为促销上下文内含的业务逻辑,则职责的分层结构就变为:
|
||||
|
||||
|
||||
计算订单总价——订单上下文
|
||||
|
||||
|
||||
获得促销策略——促销上下文
|
||||
|
||||
|
||||
获得客户类别——客户上下文
|
||||
根据客户类别获得促销策略——促销上下文
|
||||
|
||||
通过促销策略计算促销折扣——促销上下文
|
||||
|
||||
|
||||
|
||||
这时候,订单上下文的上游为促销上下文,而在促销上下文内部,又需要去调用客户上下文的领域行为。
|
||||
|
||||
我们甚至可以对职责做进一步封装。因为对于计算订单总价而言,其实它并不关心促销折扣究竟是怎样得来的,也就是说,获得促销策略这个职责其实是计算促销折扣的细节,于是职责的分层结构再次变化:
|
||||
|
||||
|
||||
计算订单总价——订单上下文
|
||||
|
||||
|
||||
计算促销折扣——促销上下文
|
||||
|
||||
|
||||
获得促销策略——促销上下文
|
||||
|
||||
|
||||
获得客户类别——客户上下文
|
||||
根据客户类别获得促销策略——促销上下文
|
||||
|
||||
通过促销策略计算促销折扣——促销上下文
|
||||
|
||||
|
||||
|
||||
|
||||
这样的设计既可以减少其他限界上下文与订单上下文的协作,又可以减少彼此协作时需要依赖的领域行为。例如,我们如果希望降低订单上下文与促销上下文之间的耦合,从而避免促销上下文可能发生的变化对订单上下文的影响,就可以引入上下文映射中的防腐层。由于订单上下文只需要知道“计算促销折扣”这一个领域行为职责,防腐层接口的设计就变得更加容易:
|
||||
|
||||
package praticeddd.ecommerce.saleordercontext.domain;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
// DiscountCalculator 是定义在订单上下文的防腐层接口
|
||||
import praticeddd.ecommerce.saleordercontext.interfaces.DiscountCalculator;
|
||||
|
||||
@Service
|
||||
public class PriceCalculator {
|
||||
@Autowired
|
||||
private DiscountCalculator discountCalculator;
|
||||
|
||||
public Price priceFor(Order order) {
|
||||
double discount = discountCalculator.calculate(order);
|
||||
return order.totalPrice().multiply(discount);
|
||||
}
|
||||
}
|
||||
|
||||
package praticeddd.ecommerce.saleordercontext.interfaces;
|
||||
|
||||
public interface DiscountCalculator {
|
||||
double calculate(Order order);
|
||||
}
|
||||
|
||||
|
||||
|
||||
显然,不同的职责分层会直接影响到我们对限界上下文协作关系的判断。归根结底,还是彼此之间需要了解的“知识”起着决定作用。我们应尽可能遵循“最小知识法则”,在保证职责合理分配的前提下,产生协作的限界上下文越少越好。
|
||||
|
||||
|
||||
|
||||
|
119
专栏/领域驱动设计实践(完)/021辨别限界上下文的协作关系(下).md
Normal file
119
专栏/领域驱动设计实践(完)/021辨别限界上下文的协作关系(下).md
Normal file
@ -0,0 +1,119 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
021 辨别限界上下文的协作关系(下)
|
||||
领域模型产生的依赖
|
||||
|
||||
针对领域行为产生的依赖,我们可以通过抽象接口来解耦。例如,前面提到订单上下文对促销上下文的调用就通过引入防腐层(ACL)解除了对促销上下文的直接依赖;然而,限界上下文依赖的领域模型呢,又该如何处理?
|
||||
|
||||
与领域行为相同,我们首先还是要判断限界上下文是否真正对别的领域模型产生了依赖!例如,要查询客户拥有的所有订单信息,应该像如下代码那样将订单列表当做客户的一个属性吗?
|
||||
|
||||
public class Customer {
|
||||
private List<SaleOrder> saleOrders;
|
||||
public List<SaleOrder> getSaleOrders() {
|
||||
return saleOrders;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
如果采取这样的设计,自然就会在客户上下文中产生对 SaleOrder 领域模型的依赖,然而,这种实现并不可取。因为这样的设计会导致在加载 Customer 时,需要加载可能压根儿就用不上的List<SaleOrder>,影响了性能。虽然通过延迟加载可以在一定程度解决性能问题,但既然存在延迟加载,就说明二者不一定总是同时需要。故而,延迟加载成为了判断领域实体对象设计是否合理的标志。
|
||||
|
||||
那么,是否可以用查询方法来替换属性?例如:
|
||||
|
||||
public class Customer {
|
||||
public List<SaleOrder> saleOrders() {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
在领域驱动设计中,我们通常不会这样设计,而是引入资源库对象来履行查询职责。若要查询订单,则 SaleOrder 会作为聚合根,对应的 SaleOrderRepository 作为资源库被放到订单上下文。在分层架构中,资源库对象可能会被封装到应用服务中,也可能直接暴露给作为适配器的 REST 服务中,例如,定义为:
|
||||
|
||||
@Path("/saleorder-context/saleorders/{customerId}")
|
||||
public class SaleOrderController {
|
||||
@Autowired
|
||||
private SaleOrderRepository repository;
|
||||
|
||||
public List<SaleOrder> allSaleOrdersBy(CustomerId customerId) {
|
||||
return repository.allSaleOrdersBy(customerId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
REST 服务的调用者并非客户上下文,而是前端或第三方服务以及客户端,貌似自然的客户与订单的包含关系,就如此被解开了。客户上下文与订单上下文并没有因为客户与订单的包含关系,使得它们二者产生协作。
|
||||
|
||||
如果确实存在跨限界上下文消费领域模型的场景,例如,订单上下文在查询订单时需要获得订单对应的商品信息时,我们该如何设计?存在两种设计决策:
|
||||
|
||||
|
||||
在订单上下文中重用商品上下文的领域模型,即两个限界上下文之间采用遵奉者模式,且商品上下文作为上游。
|
||||
在订单上下文中定义属于自己的与 Product 有关的领域模型。
|
||||
|
||||
|
||||
在确定采用何种设计决策时,又会受到限界上下文边界的影响!进程内和进程间边界带来的影响是完全不同的。倘若商品上下文与订单上下文属于两个架构零共享的限界上下文,就不应采用重用领域模型的方式。因为这种模型的重用又导致了二者不再是“零共享”的。之所以采用零共享架构,是希望这两个限界上下文能够独立演化,包括部署与运行的独立性。倘若一个限界上下文重用了另一个限界上下文的领域模型,就意味着二者的代码模型是耦合的,即产生了包之间的依赖,而非服务的依赖。一旦该重用的模型发生了变化,就会导致依赖了该领域模型的服务也要重新部署。零共享架构带来的福利就荡然无存了。
|
||||
|
||||
如果二者之间的通信是发生在进程内,又该如何决策呢?
|
||||
|
||||
这其实是矛盾的两面,以 Product 领域对象为例:
|
||||
|
||||
|
||||
重用:当需求发生变更,需要为商品增加新的属性时,可以保证只修改一处,避免了霰弹式修改;但是,如果两个限界上下文对商品的需求不相同,Product 领域对象就需要同时应对两种不同的需求,随着需求的逐渐演化,可能会导致 Product 对象渐渐成为一个低内聚的对象,这就是所谓的“重用的代价”。
|
||||
分离:在两个限界上下文中分别建立 Product 领域对象,这会带来代码的重复,当两个限界上下文都需要商品的新属性时,两边的领域模型都需要修改;倘若两个上下文对商品的需求并不相同,分离的两个模型就可以独自应对不同的需求变化了,这就是所谓的“独立演化”。
|
||||
|
||||
|
||||
事实上,在两个不同的限界上下文中为相同或相似的领域概念分别建立独立的领域模型为常见做法。例如,Martin Fowler 在介绍限界上下文时,就给出了如下的设计图:
|
||||
|
||||
|
||||
|
||||
Sales Context 与 Support Context 都需要客户与商品信息,但它们对客户与商品的关注点是不相同的。销售可能需要了解客户的性别、年龄与职业,以便于他更好地制定推销策略,售后支持则不必关心这些信息,只需要客户的住址与联系方式。正如前面在讲解限界上下文的边界时,我们已经提到了限界上下文作为保持领域概念一致性的业务边界而存在。上图清晰地表达了为两个不同限界上下文分别建立独自的 Customer 与 Product 领域模型对象,而非领域模型的重用。
|
||||
|
||||
我个人倾向于分离的领域模型,因为相较于维护相似领域对象的成本,我更担心随着需求变化的不断发生需要殚精竭虑地规避(降低)重用的代价。
|
||||
|
||||
当我们选择分离方式时,很多人会担心所谓的“数据同步”问题,其实只要我们正确地进行了领域建模,这个问题是不存在的。大体说来,这种数据同步可能存在以下三种情况:
|
||||
|
||||
|
||||
数据存在一处,领域模型仅仅是内存中的对象。例如,前面提到的订单上下文获得的商品对象。商品的信息无疑还是持久化在商品上下文的数据库中,在订单上下文定义的 Product 领域对象,仅仅是商品上下文中 Product 对象在内存中的一种转换,订单上下文并不承担持久化商品信息的职责。
|
||||
数据按照不同的业务边界分散存储,但它们之间用相同的 Identity 来保持关联。例如,在前面介绍的限界上下文的业务边界时提到的 Product 案例。采购上下文、市场上下文、仓储上下文、推荐上下文与产品上下文对产品关注的属性并不相同,因此它们关心的数据应该被分散存储到各自的数据库中,并未出现数据冗余的情况。
|
||||
数据虽然出现了冗余,但是导致它们产生变化的原因却不相同。例如,订单中包含了配送地址与联系人信息,这些信息在客户中同样存在。当客户修改了配送地址以及联系人信息时,是否需要同步保存在订单中的对应信息?事实上,这种情况并不需要同步。因为当客户提交订单后,订单的配送地址与联系人信息就与提交订单的买家脱离了关系,而被订单上下文单独管理。客户更新自己的配送地址,并不会影响到已有订单的配送地址。如果订单还未完成,客户希望修改订单中的配送地址和联系人信息,这个修改也是发生在订单上下文。
|
||||
|
||||
|
||||
数据产生的依赖
|
||||
|
||||
所谓“数据产生的依赖”,来自于数据库。倘若严格遵循领域驱动设计,通常不会产生这种数据库层面的依赖,因为我们往往会通过领域模型的资源库去访问数据库,与数据库交互的对象也应该是领域模型对象(实体和值对象)。即使有依赖,也应该是领域行为与领域模型导致的。
|
||||
|
||||
有时候,出于性能或其他原因的考虑,一个限界上下文去访问属于另外一个限界上下文边界的数据时,有可能跳过领域模型,直接通过 SQL 或存储过程的方式对多张表执行关联查询,CQRS 模式的读模型(Read Model)正是采用了这种形式。而在许多报表分析场景中,这种访问跨限界上下文数据表的方式确实是最高效最简单的实现方式。当然,这一切建立在一个前提:即限界上下文之间至少是数据库共享的。
|
||||
|
||||
我们必须警惕这种数据产生的依赖,没有绝对的理由,我们不要轻易做出这种妥协。SQL 乃至存储过程形成的数据表关联,是最难进行解耦的。一旦我们的系统架构需要从单体架构(或数据库共享架构)演进到微服务架构,最大的障碍不是代码层面而是数据库层面的依赖,这其中就包括大量复杂的 SQL 与存储过程。
|
||||
|
||||
SQL 与存储过程的问题在于:
|
||||
|
||||
|
||||
无法为 SQL 与存储过程编写单元测试,无法对其进行调试,也不利于缺陷排查。
|
||||
SQL 与存储过程的可读性通常较差,也较难对其进行重用。
|
||||
SQL 与存储过程的优化策略限制太大,虽然看起来 SQL 或存储过程的运行更贴近数据库,似乎性能更佳,但是无法在架构层面上对其进行进一步的优化,如分库分表以及水平扩展。
|
||||
|
||||
|
||||
如果选择使用 SQL 与存储过程,当数据库自身出现瓶颈时,会陷入进退两难的境地,要么继续保持使用 SQL 与存储过程,但调优空间非常小;要么就采用垂直扩展,直接更换性能更好的机器。但这种应对方法无异于饮鸩止渴,不能解决根本问题。如果想要去掉 SQL 与存储过程,又需要对架构做重大调整,需要耗费较大的架构重构成本,对架构师的能力也要求颇高。在调整架构时,由于需要将 SQL 与存储过程中蕴含的业务逻辑进行迁移,还可能存在迁移业务逻辑时破坏原有功能的风险。选择架构调整,需得管理层具备壮士扼腕的勇气与魄力才行。
|
||||
|
||||
无论是零共享架构的分库模式,还是数据库共享模式,我们都需要尽量避免因为在数据库层面引起多个限界上下文的依赖。获取数据有多种方式,除了通过领域模型中聚合根的资源库访问数据之外,我们也可以通过数据同步的方式,对多个限界上下文的数据进行整合。例如,电商网站的推荐系统,它将作为整个系统中一个独立的限界上下文。为了获得更加精准精细的推荐结果,推荐系统需要获取买家的访问日志、浏览与购买的历史记录、评价记录,需要获得商品的类别、价格、销售量、评价分数等属性,需要获取订单的详细记录,是否有退换货等一系列的信息。但这并不意味着推荐上下文会作为客户上下文、商品上下文、订单上下文等的下游客户方,也未必需要在数据库层面对多张表执行关联操作。
|
||||
|
||||
推荐算法的数据分析往往是一个大数据量分析,它需要获得的数据通常存储在扮演 OLTP(On-Line Transaction Processing,联机事务处理)角色的业务数据库。业务数据库是支撑系统业务应用的主要阵地,并被各种请求频繁读写。倘若该数据库成为瓶颈,有可能会影响到整个电商系统的运行;倘若推荐系统通过上游限界上下文的服务从各自的数据库中加载相关数据,并存入到内存中进行分析,会大量耗用网络资源和内存资源,影响电商网站的业务系统,也无法保证推荐系统的性能需求。
|
||||
|
||||
从数据分析理论来说,作为 OLTP 的业务数据库是面向业务进行数据设计的,这些数据甚至可能独立存在,并未形成数据仓库的主题数据特征,即集成了多个业务数据库,并能全面一致体现历史数据变化。因此,推荐系统需要利用大数据的采集技术,通过离线或实时流处理方式采集来自多数据源的多样化数据,然后可结合数据仓库技术,为其建立主题数据区和集市数据区,为 OLAP(On-Line Analytical Processing,联机分析处理)提供支撑,也为如协同决策这样的推荐算法提供了数据支持。
|
||||
|
||||
当然,推荐系统需要“知道”的数据不仅限于单纯的客户数据、商品数据与订单数据,还包括针对客户访问与购买商品的行为数据,如查询商品信息、添加购物车、添加订单、提交评论等行为产生的数据,这些行为数据未必存储在业务数据库中,相反可能会以如下形式存储:
|
||||
|
||||
|
||||
日志:即记录这些行为数据为日志信息。我们可以将每次产生的日志存放到 ElasticSearch 中,并作为推荐系统要访问的数据库,常见架构就是所谓的 ELK(ElasticSearch + LogStash + Kibana)架构。
|
||||
事件:倘若采用事件溯源(Event Sourcing)模式,每次行为都会触发一个事件,并通过事件存储(Event Store)将它们存储到对应的数据库中,以待推荐系统读取这些事件溯源数据,结合业务数据运用推荐算法进行计算。
|
||||
|
||||
|
||||
由于推荐系统需要分析的数据已经通过专门的数据采集器完成了多数据源数据的采集,并写入到属于推荐上下文的主题数据库中,因而并不存在与其他业务数据库之间的依赖。从实现看,推荐上下文作为一个独立的限界上下文,与其他限界上下文之间并不存在依赖关系,属于上下文映射的“分离方式”模式。从这个例子获得的经验是:技术方案有时候会影响到我们对上下文映射的识别。
|
||||
|
||||
|
||||
|
||||
|
119
专栏/领域驱动设计实践(完)/022认识分层架构.md
Normal file
119
专栏/领域驱动设计实践(完)/022认识分层架构.md
Normal file
@ -0,0 +1,119 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
022 认识分层架构
|
||||
分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层(Layer)来隔离不同的关注点(Concern Point),以此应对不同需求的变化,使得这种变化可以独立进行;此外,分层架构模式还是隔离业务复杂度与技术复杂度的利器,《领域驱动设计模式、原理与实践》这样写道:
|
||||
|
||||
|
||||
为了避免将代码库变成大泥球(BBoM)并因此减弱领域模型的完整性且最终减弱可用性,系统架构要支持技术复杂性与领域复杂性的分离。引起技术实现发生变化的原因与引起领域逻辑发生变化的原因显然不同,这就导致基础设施和领域逻辑问题会以不同速率发生变化。
|
||||
|
||||
|
||||
这里所谓的“以不同速率发生变化”,其实就是引起变化的原因各有不同,这正好是单一职责原则(Single-Responsibility Principle,SRP)的体现。Robert Martin 认为单一职责原则就是“一个类应该只有一个引起它变化的原因”,换言之,如果有两个引起类变化的原因,就需要分离。单一职责原则可以理解为架构原则,这时要考虑的就不是类,而是层次,我们为什么要将业务与基础设施分开?正是因为引起它们变化的原因不同。
|
||||
|
||||
经典分层架构
|
||||
|
||||
分层架构由来已久,把一个软件系统进行分层,似乎已经成为了每个开发人员的固有意识,甚至不必思考即可自然得出,这其中最为经典的就是三层架构以及领域驱动设计提出的四层架构。
|
||||
|
||||
经典三层架构
|
||||
|
||||
在软件架构中,经典三层架构自顶向下由用户界面层(User Interface Layer)、业务逻辑层(Business Logic Layer)与数据访问层(Data Access Layer)组成,该分层架构之所以能够流行,是有其历史原因的。在提出该分层架构的时代,多数企业系统往往较为简单,本质上都是一个单体架构(Monolithic Architecture)的数据库管理系统。这种分层架构已经是 Client-Server 架构的进化了,它有效地隔离了业务逻辑与数据访问逻辑,使得这两个不同关注点能够相对自由和独立地演化。一个经典的三层架构如下所示:
|
||||
|
||||
|
||||
|
||||
领域驱动设计的经典分层架构
|
||||
|
||||
领域驱动设计在经典三层架构的基础上做了进一步改良,在用户界面层与业务逻辑层之间引入了新的一层,即应用层(Application Layer)。同时,一些层次的命名也发生了变化,将业务逻辑层更名为领域层自然是题中应有之义,而将数据访问层更名为基础设施层(Infrastructure Layer),则突破了之前数据库管理系统的限制,扩大了这个负责封装技术复杂度的基础层次的内涵。下图为 Eric Evans 在其经典著作《领域驱动设计》中的分层架构:
|
||||
|
||||
|
||||
|
||||
该书对各层的职责作了简单的描述:
|
||||
|
||||
|
||||
|
||||
|
||||
层次
|
||||
职责
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
用户界面/展现层
|
||||
负责向用户展现信息以及解释用户命令
|
||||
|
||||
|
||||
|
||||
应用层
|
||||
很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态,但它保有应用任务的进度状态
|
||||
|
||||
|
||||
|
||||
领域层
|
||||
本层包含关于领域的信息,这是业务软件的核心所在。在这里保留业务对象的状态,对业务对象和它们状态的持久化被委托给了基础设施层
|
||||
|
||||
|
||||
|
||||
基础设施层
|
||||
本层作为其他层的支撑库存在。它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用
|
||||
|
||||
|
||||
|
||||
|
||||
追溯分层架构的本源
|
||||
|
||||
当分层架构变得越来越普及时,我们的设计反而变得越来越僵化,一部分软件设计师并未理解分层架构的本质,只知道依样画葫芦地将分层应用到系统中,要么采用经典的三层架构,要么遵循领域驱动设计改进的四层架构,却未思考和探究如此分层究竟有何道理?这是分层架构被滥用的根源。
|
||||
|
||||
视分层(Layer)为一个固有的架构模式,其根源应为 Frank Buschmann 等人著的《面向模式的软件架构》第一卷《模式系统》,该模式参考了 ISO 对 TCP/IP 协议的分层。《模式系统》对分层的描述为:
|
||||
|
||||
|
||||
分层架构模式有助于构建这样的应用:它能被分解成子任务组,其中每个子任务组处于一个特定的抽象层次上。
|
||||
|
||||
|
||||
显然,这里所谓的“分层”首先是一个逻辑的分层,对子任务组的分解需要考虑抽象层次,一种水平的抽象层次。既然为水平的分层,必然存在层的高与低;而抽象层次的不同,又决定了分层的数量。因此,对于分层架构,我们需要解决如下问题:
|
||||
|
||||
|
||||
分层的依据与原则是什么?
|
||||
层与层之间是怎样协作的?
|
||||
|
||||
|
||||
分层的依据与原则
|
||||
|
||||
我们之所以要以水平方式对整个系统进行分层,是我们下意识地确定了一个认知规则:机器为本,用户至上,机器是运行系统的基础,而我们打造的系统却是为用户提供服务的。分层架构中的层次越往上,其抽象层次就越面向业务、面向用户;分层架构中的层次越往下,其抽象层次就变得越通用、面向设备。为什么经典分层架构为三层架构?正是源于这样的认知规则:其上,面向用户的体验与交互;居中,面向应用与业务逻辑;其下,面对各种外部资源与设备。在进行分层架构设计时,我们完全可以基于这个经典的三层架构,沿着水平方向进一步切分属于不同抽象层次的关注点。因此,分层的第一个依据是基于关注点为不同的调用目的划分层次。以领域驱动设计的四层架构为例,之所以引入应用层(Application Layer),就是为了给调用者提供完整的业务用例。
|
||||
|
||||
分层的第二个依据是面对变化。分层时应针对不同的变化原因确定层次的边界,严禁层次之间互相干扰,或者至少把变化对各层带来的影响降到最低。例如,数据库结构的修改自然会影响到基础设施层的数据模型以及领域层的领域模型,但当我们仅需要修改基础设施层中数据库访问的实现逻辑时,就不应该影响到领域层了。层与层之间的关系应该是正交的,所谓“正交”,并非二者之间没有关系,而是垂直相交的两条直线,唯一相关的依赖点是这两条直线的相交点,即两层之间的协作点,正交的两条直线,无论哪条直线进行延伸,都不会对另一条直线产生任何影响(指直线的投影);如果非正交,即“斜交”,当一条直线延伸时,它总是会投影到另一条直线,这就意味着另一条直线会受到它变化的影响。
|
||||
|
||||
在进行分层时,我们还应该保证同一层的组件处于同一个抽象层次。这是分层架构的设计原则,它借鉴了 Kent Beck 在 Smalltalk Best Practice Patterns 一书提出的“组合方法”模式,该模式要求一个方法中的所有操作处于相同的抽象层,这就是所谓的“单一抽象层次原则(SLAP)”,这一原则可以运用到分层架构中。例如,在一个基于元数据的多租户报表系统中,我们特别定义了一个引擎层(Engine Layer),这是一个隐喻,相当于为报表系统提供报表、实体与数据的驱动引擎。引擎层之下,是基础设施层,提供了多租户、数据库访问与元数据解析与管理等功能。在引擎层之上是一个控制层,通过该控制层的组件可以将引擎层的各个组件组合起来,分层架构的顶端是面向用户的用户展现层,如下图所示:
|
||||
|
||||
|
||||
|
||||
层与层之间的协作
|
||||
|
||||
在我们固有的认识中,分层架构的依赖都是自顶向下传递的,这也符合大多数人对分层的认知模型。从抽象层次来看,层次越处于下端,就会变得越通用越公共,与具体的业务隔离得越远。出于重用的考虑,这些通用和公共的功能往往会被单独剥离出来形成平台或框架,在系统边界内的低层,除了面向高层提供足够的实现外,就都成了平台或框架的调用者。换言之,越是通用的层,越有可能与外部平台或框架形成强依赖。若依赖的传递方向仍然采用自顶向下,就会导致系统的业务对象也随之依赖于外部平台或框架。
|
||||
|
||||
依赖倒置原则(Dependency Inversion Principle,DIP)提出了对这种自顶向下依赖的挑战,它要求“高层模块不应该依赖于低层模块,二者都应该依赖于抽象”,这个原则正本清源,给了我们严重警告——谁规定在分层架构中,依赖就一定要沿着自顶向下的方向传递?我们常常理解依赖,是因为被依赖方需要为依赖方(调用方)提供功能支撑,这是从功能重用的角度来考虑的。但我们不能忽略变化对系统产生的影响!与建造房屋一样,我们自然希望分层的模块“构建”在稳定的模块之上,谁更稳定?抽象更稳定。因此,依赖倒置原则隐含的本质是:我们要依赖不变或稳定的元素(类、模块或层),也就是该原则的第二句话:抽象不应该依赖于细节,细节应该依赖于抽象。
|
||||
|
||||
这一原则实际是“面向接口设计”原则的体现,即“针对接口编程,而不是针对实现编程”。高层模块对低层模块的实现是一无所知的,带来的好处是:
|
||||
|
||||
|
||||
低层模块的细节实现可以独立变化,避免变化对高层模块产生污染
|
||||
在编译时,高层模块可以独立于低层模块单独存在
|
||||
对于高层模块而言,低层模块的实现是可替换的
|
||||
|
||||
|
||||
倘若高层依赖于低层的抽象,必然会面对一个问题:如何把具体的实现传递给高层的类?由于在高层通过接口隔离了对具体实现的依赖,就意味着这个具体依赖被转移到了外部,究竟使用哪一种具体实现,由外部的调用者来决定。只有在运行调用者代码时,才将外面的依赖传递给高层的类。Martin Fowler 形象地将这种机制称为“依赖注入(Dependency injection)”。
|
||||
|
||||
为了更好地解除高层对低层的依赖,我们往往需要将依赖倒置原则与依赖注入结合起来。
|
||||
|
||||
层之间的协作并不一定是自顶向下的传递通信,也有可能是自底向上通信。例如,在 CIMS(计算机集成制造系统)中,往往会由低层的设备监测系统监测(侦听)设备状态的变化。当状态发生变化时,需要将变化的状态通知到上层的业务系统。如果说自顶向下的消息传递往往被描述为“请求(或调用)”,则自底向上的消息传递则往往被形象地称之为“通知”。倘若我们颠倒一下方向,自然也可以视为这是上层对下层的观察,故而可以运用观察者模式(Observer Pattern),在上层定义 Observer 接口,并提供 update() 方法供下层在感知状态发生变更时调用;或者,我们也可以认为这是一种回调机制。虽然本质上这并非回调,但设计原理是一样的。
|
||||
|
||||
如果采用了观察者模式,则与前面讲述的依赖倒置原则有差相仿佛之意,因为下层为了通知上层,需要调用上层提供的 Observer 接口。如此看来,无论是上层对下层的“请求(或调用)”,抑或下层对上层的“通知”,都颠覆了我们固有思维中那种高层依赖低层的理解。
|
||||
|
||||
现在,我们对分层架构有了更清醒的认识。我们必须要打破那种谈分层架构必为经典三层架构又或领域驱动设计推荐的四层架构这种固有思维,而是将分层视为关注点分离的水平抽象层次的体现。既然如此,架构的抽象层数就不是固定的,甚至每一层的名称也未必遵循固有(经典)的分层架构要求。设计系统的层需得结合该系统的具体业务场景而定。当然,我们也要认识到层次多少的利弊:过多的层会引入太多的间接而增加不必要的开支,层太少又可能导致关注点不够分离,导致系统的结构不合理。
|
||||
|
||||
我们还需要正视架构中各层之间的协作关系,打破高层依赖低层的固有思维,从解除耦合(或降低耦合)的角度探索层之间可能的协作关系。另外,我们还需要确定分层的架构原则(或约束),例如是否允许跨层调用,即每一层都可以使用比它低的所有层的服务,而不仅仅是相邻低层。这就是所谓的“松散分层系统(Relaxed Layered System)”。
|
||||
|
||||
|
||||
|
||||
|
90
专栏/领域驱动设计实践(完)/023分层架构的演化.md
Normal file
90
专栏/领域驱动设计实践(完)/023分层架构的演化.md
Normal file
@ -0,0 +1,90 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
023 分层架构的演化
|
||||
分层架构是一种架构模式,但终归它的目的是为了改进软件的架构质量,我们在运用分层架构时,必须要遵守架构设计的最高原则,即建立一个高内聚、松耦合的软件系统架构。于是,许多设计大师们纷纷提出了自己的洞见。
|
||||
|
||||
整洁架构
|
||||
|
||||
在架构设计时,我们应设计出干净的应用层和领域层,保持它们对业务逻辑的专注,而不掺杂任何具体的技术实现,从而完成领域与技术之间的完全隔离,这一思想被 Robert Martin 称之为整洁架构(Clean Architecture)。下图展现了 Robert Martin 的这一设计思想:
|
||||
|
||||
|
||||
|
||||
该架构思想提出的模型并非传统的分层架构,而是类似于一个内核模式的内外层架构,由内及外分为四层,包含的内容分别为:
|
||||
|
||||
|
||||
企业业务规则(Enterprise Business Rules)
|
||||
应用业务规则(Application Business Rules)
|
||||
接口适配器(Interface Adapters)
|
||||
框架与驱动器(Frameworks & Drivers)
|
||||
|
||||
|
||||
注意“企业业务规则”与“应用业务规则”的区别,前者是纯粹领域逻辑的业务规则,后者则面向应用,需要串接支持领域逻辑正常流转的非业务功能,通常为一些横切关注点,如日志、安全、事务等,从而保证实现整个应用流程(对应一个完整的用例)。
|
||||
|
||||
仔细解读这一架构模型,我们会发现许多有用的特征:
|
||||
|
||||
|
||||
层次越靠内的组件依赖的内容越少,处于核心的 Entities 没有任何依赖。
|
||||
层次越靠内的组件与业务的关系越紧密,因而越不可能形成通用的框架。
|
||||
Entities 层封装了企业业务规则,准确地讲,它应该是一个面向业务的领域模型。
|
||||
Use Cases 层是打通内部业务与外部资源的一个通道,因而提供了输出端口(Output Port)与输入端口(Input Port),但它对外的接口展现的其实是应用逻辑,或者说是一个用例。
|
||||
Gateways、Controllers 与 Presenters 其本质都是适配器(Adapter),用于打通应用业务逻辑与外层的框架和驱动器,实现逻辑的适配以访问外部资源。
|
||||
系统最外层包括框架和驱动器,负责对接外部资源,不属于系统(仅指限界上下文而言)开发的范畴,但选择这些框架和驱动器,是属于设计决策要考虑的内容。这一层的一些组件甚至与要设计的系统不处于同一个进程边界。
|
||||
|
||||
|
||||
我们学到了什么?Robert Martin 的整洁架构将领域模型放在整个系统的核心,这一方面体现了领域模型的重要性,另外一方面也说明了领域模型应该与具体的技术实现无关。领域模型就是业务逻辑的模型,它应该是完全纯粹的,无论你选择什么框架,什么数据库,或者什么通信技术,按照整洁架构的思想都不应该去污染领域模型。如果以 Java 语言来实现,遵循整洁架构的设计思想,则所有领域模型对象都应该是 POJO(Plain Ordinary Java Object)。整洁架构的 Entities 层对应于领域驱动设计的领域层。
|
||||
|
||||
说明:注意 POJO 与 Java Bean 的区别。Java Bean 是指仅定义了为私有字段提供 get 与 set 方法的 Java 对象,这种 Java Bean 对象除了这些 get 和 set 方法之外,几乎没有任何业务逻辑,Martin Fowler 将这种对象称之为“贫血对象”,根据这种贫血对象建立的模型就是“贫血模型”。POJO 指的是一个普通的 Java 对象,意味着这个 Java 对象不依赖除 JDK 之外的其他框架,是一个纯粹 Java 对象,Java Bean 是一种特殊的 POJO 对象。在领域驱动设计中,如果我们遵循面向对象设计范式,就应避免设计出贫血的 Java Bean 对象;如果我们要遵循整洁架构设计思想,则应尽量将领域模型对象设计为具有领域逻辑的 POJO 对象。
|
||||
|
||||
属于适配器的 Controllers、Gateways 与 Presenters 对应于领域驱动设计的基础设施层。就我个人的理解来说,适配器这个词并不能准确表达这些组件的含义,反而更容易让我们理解为是对行为的适配,我更倾向于将这些组件都视为是网关(Gateway)。对下,例如,针对数据库、消息队列或硬件设备,可以认为是一个南向网关,对于当前限界上下文是一种输出的依赖;对上,例如,针对 Web 和 UI,可以认为是一个北向网关,对于当前限界上下文是一种输入的依赖。
|
||||
|
||||
这两种方向的网关与 Use Cases 层之间的关系是不尽相同的。北向网关会调用 Use Cases 层中表示应用逻辑的服务组件,即发起的是一个由外向内的调用,这种调用在整洁架构体系下是合乎道理的。Use Cases 层的服务组件并不需要关心北向网关的组件,例如,作为 RESTful 服务的 OrderController,就是北向网关中的一个类,它通过调用 Use Cases 层的 OrderAppService 服务来实现一个提交订单的业务用例。OrderAppService 并不需要知道作为调用者的 OrderController,如果存在将 Entities 层的领域模型对象转换为 RESTful 服务的 Resource 对象,也是 OrderController 或者说北向网关的职责。
|
||||
|
||||
南向网关作为底层资源的访问者,往往成为 Use Cases 层甚至 Entities 层的被调用者。由于整洁架构思想并不允许内层获知外层的存在,这就导致了我们必须在内层定义与外层交互的接口,然后通过依赖注入的方式将外层的实现注入到内层中,这也是“控制反转(Inversion of Control)”的含义,即将调用的控制权转移到了外层。由是我们可以得出一个结论,即南向网关封装了与外部资源(DB、Devices、MQ)交互的实现细节,但其公开暴露的接口却需要被定义在内层的 Use Cases 或 Entities 中,这实际上阐释了为什么领域驱动设计要求将 Repository 的接口定义在领域层的技术原因。当然,将 Repository 接口定义在领域层还有其业务原因,在后面我会详细介绍。
|
||||
|
||||
六边形架构
|
||||
|
||||
整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更加清晰,减少不必要的耦合。它采用了内外层的架构模型弥补了分层架构无法体现领域核心位置的缺陷。由 Alistair Cockburn 提出的六边形架构(Hexagonal Architecture)在满足整洁架构思想的同时,更关注于内层与外层以及与外部资源之间通信的本质:
|
||||
|
||||
|
||||
|
||||
如上图所示,六边形架构通过内外两个六边形为系统建立了不同层次的边界。核心的内部六边形对应于领域驱动设计的应用层与领域层,外部六边形之外则为系统的外部资源,至于两个六边形之间的区域,均被 Cockburn 视为适配器(Adapter),并通过端口(Port)完成内外区域之间的通信与协作,故而六边形架构又被称为端口-适配器模式(port-adapter pattern)。在第04课中,我曾经给出了如下的设计图,该图更加清晰地表达了领域驱动设计分层架构与六边形架构的关系,同时也清晰地展现了业务复杂度与技术复杂度的边界:
|
||||
|
||||
|
||||
|
||||
我在前面分析整洁架构时,将 Gateways、Controllers 与 Presenters 统一看做是网关,而在六边形架构中,这些内容皆为适配器。事实上,它们代表的含义是一致的,不同的命名代表的是对其职责认识上的不同。如果认为是“网关”,则将该组件的实现视为一种门面,内部负责多个对象之间的协作以及职责的委派;如果认为是“适配器”,则是为了解决内外协议(数据协议与服务接口)之间的不一致而进行的适配。若依据领域驱动设计的分层架构,则无论网关还是适配器,都属于基础设施层的内容。
|
||||
|
||||
无论理解为网关还是适配器,通过这种架构思想都可以认识到在基础设施层的组件应该是轻量级的实现,甚至可以认为它不过是对第三方框架或平台有选择的调用罢了,归根结底,它虽然是技术实现,却是为业务场景提供服务的。例如,需要操作订单数据库,DB 适配器就是一个传递通道,将需要操作的领域模型传递给它,最后返回结果,真正的实现则通过 JDBC、Hibernate、MyBatis 或 Spring Data 等第三方框架来完成。同理,如果需要为前端提供订单服务能力,Web 适配器负责验证与转换前端消息,至于请求到资源的路由等功能皆由 Spring Boot、DropWizard 或 Airlift 等 REST 框架来完成。所以说这里所谓的“适配器”与“端口”其实就是领域与外部资源的一个转换通道。适配器向内的一端连接了 Application 的领域,向外的一端则通过端口连接了外部资源。
|
||||
|
||||
六边形架构通过内外六边形的两个不同边界清晰地展现了领域与技术的边界,同时,外部六边形还体现了系统的进程边界,有助于我们思考分布式架构的物理视图,并通过识别端口来引导我们专注于六边形之间的通信机制,这些通信机制可能包括:
|
||||
|
||||
|
||||
与外部资源(数据库、文件、消息队列等)之间的通信
|
||||
与 Web 和 UI 等用户界面之间的通信
|
||||
与第三方服务之间的通信
|
||||
与其他六边形边界之间的通信
|
||||
|
||||
|
||||
微服务架构
|
||||
|
||||
Toby Clemson 在《微服务架构的测试策略》一文中深入探讨了如何对微服务架构制定测试策略。要明确如何对这样的系统进行测试,就需要明确该系统架构的组成部分以及各组成部分承担的职责,同时还需要了解各组成部分之间的协作关系。为此,Toby 在这篇文章中给出了一个典型的微服务架构,如下图所示:
|
||||
|
||||
|
||||
|
||||
该架构图并未严格按照分层架构模式来约定各个组件的位置与职责,这是完全合理的设计!当我们需要将一个分层架构进行落地实践时,在任何一门语言中我们都找不到所谓 layer 的明确语法。在 Java 语言中,我们可以通过 package 与 module 去划分包与模块,在 Ruby 语言中我们也可以限定 module 的范畴,但我们并不能通过某种语法甚至语法糖去规定 layer 的边界。所以在编码实现中,layer 其实是一个松散且不够严谨的逻辑概念,即使我们规定了层的名称以及各层的职责,但各种“犯规行为”依然屡见不鲜。与其如此,不如将各个组件在逻辑架构中的位置与职责明确定义出来。对于系统的概念模型与设计模型,我们要明确分层架构的本质与设计原则;对于代码模型,分层架构则主要负责设计指导,并酌情弱化层在代码模型中的意义,强化对包与模块的划分。
|
||||
|
||||
上图的逻辑边界代表了一个微服务,这是基于微服务的设计原则——“每个微服务的数据单独存储”,因此需要将物理边界(图中定义为网络边界)外的数据库放在微服务的内部。
|
||||
|
||||
整幅图的架构其实蕴含了两个方向:自顶向下和由内至外。
|
||||
|
||||
外部请求通过代表协议(Protocol)的 Resources 组件调用 Service Layer、Domain 或 Repositories,如果需要执行持久化任务,则通过 Repositories 将请求委派给 ORM,进而访问网络边界外的数据库。所谓“外部请求”可以是前端 UI 或第三方服务,而 Resource 组件就是我们通常定义的 Controller,对应于上下文映射中的开放主机服务。之所以命名为 Resources,则是因为 REST 架构是一种面向资源的架构,它将服务操作的模型抽象为资源(Resource),这是自顶向下的方向。
|
||||
|
||||
若当前微服务需要调用外部服务(External Service),且外部服务籍由 HTTP 协议通信,就需要提供一个 HTTP Client 组件完成对外部服务的调用。为了避免当前微服务对外部服务的强依赖,又或者对客户端的强依赖,需要引入 Gateways 来隔离。事实上,这里的 Gateways 即为上下文映射中的防腐层,这是由内至外的方向。
|
||||
|
||||
说明:文中的微服务架构图虽然由 Toby Clemson 在《微服务架构的测试策略》一文中给出,但肖然在《分层架构的代码架构》一文中又明确提出这一架构图来自 Martin Fowler。究竟是谁的创见,我就此咨询了肖然,肖然说他亲自问过老马(即 Martin Fowler),老马说这个架构是他认为的。Toby 的文章本身就发表在老马的官方 biliki 上,作者在文章的开篇对老马表示了致谢。
|
||||
|
||||
|
||||
|
||||
|
88
专栏/领域驱动设计实践(完)/024领域驱动架构的演进.md
Normal file
88
专栏/领域驱动设计实践(完)/024领域驱动架构的演进.md
Normal file
@ -0,0 +1,88 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
024 领域驱动架构的演进
|
||||
我们回顾了经典三层架构与领域驱动设计四层架构,然后又对分层架构模式的产生与设计原则做了一次历史回顾。我们先后参考了 Robert Martin 的整洁架构、Cockburn 的六边形架构以及 Toby Clemson 给出的微服务架构模型。现在,是时候为领域驱动设计的架构模型做一次总结陈词了。然而事情并未结束,因为任何技术结论都并非句点,而仅仅代表了满足当时技术背景的一种判断,技术总是在演进,领域驱动架构亦是如此。与其关心结果,不如将眼睛投往这个演进的过程,或许风景会更加动人。
|
||||
|
||||
根据“依赖倒置原则”与 Robert Martin 提出的“整洁架构”思想,我们推翻了 Eric Evans 在《领域驱动设计》书中提出的分层架构。Vaughn Vernon 在《实现领域驱动设计》一书中给出了改良版的分层架构,他将基础设施层奇怪地放在了整个架构的最上面:
|
||||
|
||||
|
||||
|
||||
整个架构模型清晰地表达了领域层别无依赖的特质,但整个架构却容易给人以一种错乱感。单以这个分层模型来看,虽则没有让高层依赖低层,却又反过来让低层依赖了高层,这仍然是不合理的。当然你可以说此时的基础设施层已经变成了高层,然而从之前分析的南向网关与北向网关来说,基础设施层存在被“肢解”的可能。坦白讲,这个架构模型仍然没有解决人们对分层架构的认知错误,例如它并没有很好地表达依赖倒置原则与依赖注入。还需要注意的是,这个架构模型将基础设施层放在了整个分层架构的最顶端,导致它依赖了用户展现层,这似乎并不能自圆其说。我们需要重新梳理领域驱动架构,展示它的演进过程。
|
||||
|
||||
该怎么演进领域驱动架构?可以从两个方向着手:
|
||||
|
||||
|
||||
避免领域模型出现贫血模型
|
||||
保证领域模型的纯粹性
|
||||
|
||||
|
||||
避免贫血的领域模型
|
||||
|
||||
我们需要回顾经典的 Java 三层架构对领域模型的设计。在这个三层架构中,领域逻辑被定义在业务逻辑层的 Service 对象中,至于反映了领域概念的领域对象则被定义为 Java Bean,这些 Java Bean 并没有包含任何领域逻辑,因此被放在了数据访问层。注意,这是经典三层架构的关键,即代表领域概念的 Java Bean 被放在了数据访问层,而非业务逻辑层。 经典三层架构采用了 J2EE 开发的 DAO 模式,即将访问数据库的逻辑封装到数据访问对象(Data Access Object)中。这些 DAO 对象仅负责与数据库的交互,并实现领域对象到数据表的 CRUD(增删改查)操作,因而也被放到了数据访问层中,如下图所示:
|
||||
|
||||
|
||||
|
||||
如果以面向对象设计范式进行领域建模,我们需要遵循面向对象的设计原则,其中最重要的设计原则就是“数据与行为应该封装在一起”,这也是 GRASP 模式中“信息专家模式”的体现。前面提及的 Java Bean 由于仅包含了访问私有字段的 get 和 set 方法,可以说是对面向对象设计原则的“背叛”,Martin Fowler 则将这种没有任何业务行为的对象称之为“贫血对象”。基于这样的贫血对象进行领域建模,得到的模型则被称之为“贫血模型”。这种贫血模型被认为是简单的,却不具备对象的丰富表达能力,当业务逻辑变得复杂时,在表达领域模型方面就会变得“力不从心”,无法有效应对重用与变化,且可能导致臃肿的“上帝类”。贫血模型的种种问题会在战术设计中再做深入探讨,这里我们姑且给出一个结论,即:在面向对象设计背景下,当我们面对相对复杂的业务逻辑时,应避免设计出贫血模型。
|
||||
|
||||
要避免贫血模型,就需要合理地将操作数据的行为分配给这些领域模型对象(Domain Model),即战术设计中的 Entity 与 Value Object,而不是前面提及的 Service 对象。由于领域模型对象包含了领域逻辑,就需要从数据访问层转移到业务逻辑层。至于那些不属于任何领域模型对象的领域逻辑,仍然放到 Service 对象中。由于 DAOs 对象需要操作这些领域模型对象,使得处于数据访问层的 DAOs 对象必须依赖领域层的领域模型对象,也就是说,要避免贫血的领域模型,就不可能避免底层的数据访问层对业务逻辑层的依赖。
|
||||
|
||||
从分层的职责和意义讲,一个系统的基础不仅仅限于对数据库的访问,还包括访问诸如网络、文件、消息队列或者其他硬件设施,因此 Eric Evans 将其更名为“基础设施层”是非常合理的。至于将业务逻辑层更名为领域层也是题中应有之义。遵循整洁架构思想,基础设施层属于架构的外层,它依赖于处于内部的领域层亦是正确的做法。在领域层,封装了领域逻辑的 Services 对象则可能需要持久化领域对象,甚至可能依赖基础设施层的其他组件。于是,之前的分层架构就演进为:
|
||||
|
||||
|
||||
|
||||
保证领域模型的纯粹性
|
||||
|
||||
若将整个层次看做一个整体,在刚才给出的分层架构图中,加粗的两条依赖线可以清晰地看到领域层与基础设施层之间产生了“双向依赖”。在实际开发中,若这两层又被定义为两个模块,双向依赖就成为了设计坏味,它导致了两个层次的紧耦合。此时,领域模型变得不再纯粹,根由则是高层直接依赖了低层,而不是因为低层依赖了高层。故而我们需要去掉右侧 Services 指向 DAOs 的依赖。
|
||||
|
||||
DAOs 负责访问数据库,其实现逻辑是容易变化的。基于“稳定依赖原则”,我们需要让领域层建立在一个更加稳定的基础上。抽象总是比具体更稳定,因此,改进设计的方式是对 DAOs 进行抽象,然后利用依赖注入对数据访问的实现逻辑进行注入,如下图所示:
|
||||
|
||||
|
||||
|
||||
DAOs 的抽象到底该放在哪里?莫非需要为基础设施层建立一个单独的抽象层吗?这牵涉到我们对数据库访问的认知。任何一个软件系统的领域对象都存在其生命周期,代表领域逻辑的业务方法其实就是在创造它,发现它,更新它的状态,最后通常也会销毁它。倘若部署软件系统的计算机足够强劲与稳定,就不再需要任何外部资源了;这时,对领域对象的生命周期管理就变成了对普通对象的内存管理。因此,从业务角度看,管理对象的生命周期是必须的,访问外部资源却并非必须。只是因为计算机资源不足以满足这种稳定性,才不得已引入外部资源罢了。也就是说,访问这些领域对象属于业务要素,而如何访问这些领域对象(如通过外部资源),则属于具体实现的技术要素。
|
||||
|
||||
从编码角度看,领域对象实例的容身之处不过就是一种数据结构而已,区别仅在于存储的位置。领域驱动设计将管理这些对象的数据结构抽象为资源库(Repository)。通过这个抽象的资源库访问领域对象,自然就应该看作是一种领域行为。倘若资源库的实现为数据库,并通过数据库持久化的机制来实现领域对象的生命周期管理,则这个持久化行为就是技术因素。
|
||||
|
||||
结合前面对整洁架构的探讨,抽象的资源库接口代表了领域行为,应该放在领域层;实现资源库接口的数据库持久化,需要调用诸如 MyBatis 这样的第三方框架,属于技术实现,应该放在基础设施层。于是,分层架构就演进为:
|
||||
|
||||
|
||||
|
||||
由于抽象的 Repositories 被搬迁至领域层,图中的领域层就不再依赖任何其他层次的组件或类,成为一个纯粹的领域模型。我们的演进正逐步迈向整洁架构!
|
||||
|
||||
用户展现层的变迁
|
||||
|
||||
现代软件系统变得日趋复杂,对于一个偏向业务领域的分层架构,领域层的调用者决不仅限于用户展现层的 UI 组件,比如说可以是第三方服务发起对领域逻辑的调用。即使是用户展现层,也可能需要不同的用户交互方式与呈现界面,例如 Web、Windows 或者多种多样的移动客户端。因此在分层架构中,无法再用“用户展现层”来涵盖整个业务系统的客户端概念。通常,我们需要采用前后端分离的架构思想,将用户展现层彻底分离出去,形成一个完全松耦合的前端层。
|
||||
|
||||
不管前端的展现方式如何,它的设计思想是面向调用者,而非面向领域。因此,我们在讨论领域驱动设计时,通常不会将前端设计纳入到领域驱动设计的范围。有人尝试将领域驱动设计引入到前端设计中,那是将前端自身当做一种领域。在设计后端 API 时,我们确乎需要从调用者的角度考虑 API 的定义,并确定从 Domain Model(或者 Service Model,又或者是 Resource Model)到 View Model 的转换,又或者考虑引入所谓“DTO(Data Transfer Object,数据传输对象)”,但这些都只限于后端 API 协议的设计。
|
||||
|
||||
准确地讲,前端可以视为是与基础设施层组件进行交互的外部资源,如前面整洁架构中的 Web 组件与 UI 组件。为了简化前端与后端的通信集成,我们通常会为系统引入一个开放主机服务(OHS),为前端提供统一而标准的服务接口。该接口实际上就是之前整洁架构中提及的 Controllers 组件,也即我提出的基础设施层的北向网关。于是,分层架构就演变为:
|
||||
|
||||
|
||||
|
||||
这个分层架构展现了“离经叛道”的一面,因为基础设施层在这里出现了两次,但同时也充分说明了基础设施层的命名存在不足。当我们提及基础设施(Infrastructure)时,总还是会想当然地将其视为最基础的层。同时,这个架构也凸显了分层架构在表现力方面的缺陷。
|
||||
|
||||
引入应用层
|
||||
|
||||
即使我们分离了前后端,又引入了扮演北向网关角色的 Controllers,都不可规避一个问题,那就是领域层的设计粒度过细。由于有了 Controllers,我们可以将 Controllers 看成是领域层的客户端,这就使得它需要与封装了 Entity 与 Value Object 的 Aggregate、Services 以及抽象的 Repositories 接口协作。基于 KISS(Keep It Simple and Stupid)原则或最小知识原则,我们希望调用者了解的知识越少越好,调用变得越简单越好,这就需要引入一个间接的层来封装,这就是应用层存在的主要意义:
|
||||
|
||||
|
||||
|
||||
领域驱动分层架构中的应用层其实是一个外观(Facade)。GOF 的《设计模式》认为外观模式的意图是“为子系统中的一组接口提供一个一致的接口,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。”我们要理解“高层接口”的含义。一方面,它体现了一个概念层次的高低之分,以上图的分层架构来说,应用层是高层抽象的概念,但表达的是业务的含义,领域层是底层实现的概念,表达的是业务的细节。领域驱动设计要求应用层“不包含业务逻辑”,但对外它却提供了一个一致的体现业务用例的接口。注意,这里的接口概念并非指 Java 或 C# 等语言的 interface 语法。
|
||||
|
||||
基础设施层的本质
|
||||
|
||||
引入应用层后,整个分层架构的职责变得更加清晰了,唯一显得较为另类的是同为灰色部分的基础设施层。目前,该分层架构图的基础设施层被分成了两个,分别位于应用层上端和领域层下端。从依赖关系看,处于领域层下端的基础设施层是通过实现抽象 Repository 接口导致的。虽然我也可以将其置于领域层甚至应用层上端,以此来表达这种依赖关系;但我仍然选择保留原来的层次位置,我希望通过该图清晰地体现所谓“北向网关”与南向网关“的语义。正如我在前面分析整洁架构思想时,提到“属于适配器的 Controllers、Gateways 与 Presenters 对应于领域驱动设计的基础设施层。”我们将整洁架构、六边形架构与领域驱动设计的四层架构综合起来考虑,可以得到结论:
|
||||
|
||||
Controllers + Gateways + Presenters = Adapters = Infrastructure Layer
|
||||
|
||||
|
||||
|
||||
我个人认为,这些组件确乎有适配的语义,将它们视为适配器(Adapter)并无不对之处,但我觉得 Martin Fowler 在《企业应用架构模式》中提出的网关(Gateway)模式似乎更准确。Martin Fowler 对该模式的定义为:An object that encapsulates access to an external system or resource. (封装访问外部系统或资源行为的对象。)基础设施层要做的事情不正是封装对外部系统或资源的访问吗?至于“适配”的语义,仅仅是这种封装的实现模式罢了,更何况在这些组件中,不仅仅做了适配的工作。基于此,我才将这些组件统统视为“网关”,并根据其方向分别划分为北向网关与南向网关。理解网关的含义,可以帮助我们更好地理解基础设施层的本质。扮演网关角色的组件其实是一个出入口(某种情况下,网关更符合六边形架构中端口+适配器的组合概念),所以它们的行为特征是:网关组件自身会参与到业务中,但真正做的事情只是对业务的支撑,提供了与业务逻辑无关的基础功能实现。
|
||||
|
||||
经历了多次演进,我们的分层架构终于在避免贫血模型的同时保证了领域逻辑的纯粹性,有效地隔离了业务复杂度与技术复杂度。演进后的分层架构既遵循了整洁架构思想,又参考了六边形架构与微服务架构的特点。但我们不能说这样的分层架构就是尽善尽美的,更不能僵化地将演化得来的分层架构视为唯一的标准。分层架构是一种架构模式,遵循了“关注点分离”原则。因此,在针对不同限界上下文进行分层架构设计时,还需要结合当前限界上下文的特点进行设计,合理分层,保证结构的清晰和简单。
|
||||
|
||||
|
||||
|
||||
|
435
专栏/领域驱动设计实践(完)/025案例层次的职责与协作关系(图文篇).md
Normal file
435
专栏/领域驱动设计实践(完)/025案例层次的职责与协作关系(图文篇).md
Normal file
@ -0,0 +1,435 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
025 案例 层次的职责与协作关系(图文篇)
|
||||
经历多次演进,我们已经初步得到了符合领域驱动设计思想的分层架构,但这种架构仅仅是一种静态的逻辑划分,在实现一个业务用例时,各层之间是怎么协作的,我们似乎还不得而知。辨别这种动态的协作关系,还是应该从职责的角度入手,即必须清楚地理解分层架构中每个逻辑层的职责。
|
||||
|
||||
一味的理论讲解可能已经让爱好案例与代码的程序员昏昏欲睡了,何况用纯理论知识来讲解职责与协作,也让我力有未逮。不如通过一个具体案例,来说明层次的职责以及层次之间的协作关系。还是以电商系统的下订单场景为例,在买家提交订单时,除了与订单直接有关的业务之外,还需要执行以下操作。
|
||||
|
||||
|
||||
订单数据的持久化:OrderRepository 提供插入订单功能。它属于支撑订单提交业务的基础功能,但将订单持久化到数据库的实现 OrderMapper 并不属于该业务范畴。
|
||||
发送通知邮件:NotificationService 提供通知服务。它属于支撑通知业务的基础功能,但邮件发送的实现 EmailSender 却不属于该业务范畴。
|
||||
异步发送消息给仓储系统:提交订单成功后需要异步发送消息 OrderConfirmed 给仓储系统,这一通信模式是通过消息队列来完成的。EventBus 发送 OrderConfirmed 事件属于支撑订单提交成功的基础功能,但发送该事件到 RabbitMQ 消息队列的 RabbitEventBus 则不属于该业务范畴。
|
||||
|
||||
|
||||
同时,为了用户界面客户端或第三方服务的分布式调用,需要通过 OrderController 暴露 RESTful 服务。它本身不提供任何业务实现,而是通过将请求委派给应用层的 OrderAppService 来完成订单的提交。
|
||||
|
||||
下图体现了前述三个流程在各层之间以及系统内外部之间的协作关系。注意,在这里我将牵涉到的类型放在了同一个限界上下文中,如果牵涉到多个限界上下文之间的协作,实现会略有不同,对应的代码模型也将有所调整。我会在后续内容中深入探讨限界上下文之间的协作对代码模型的影响。
|
||||
|
||||
|
||||
|
||||
基础设施层的 OrderController 扮演了北向网关的角色,承担了与用户界面层或第三方服务交互的进出口职责。它通过 Spring Boot 来完成对 HTTP 请求的响应、路由和请求/响应消息的序列化与反序列化。它的自有职责仅仅是对请求/响应消息的验证,以及对 OrderAppService 的调用。或许有人会质疑处于后端顶层的控制器为何属于基础设施层?但我认为这样的分配是合理的,因为 Controller 要做的事情与基础设施层所要履行的职责完全匹配,即它提供的是 REST 服务的基础功能。
|
||||
|
||||
基础设施层南向网关包括 OrderMapper、EmailSender 和 RabbitEventBus,它们对内为具体的某个业务提供支撑功能,对外则需要借助框架或驱动器访问外部资源。与北向网关不同,对它们的调用由属于内层的应用服务 OrderAppService 发起,因此需要为它们建立抽象来解除内层对外层的依赖。前面已经分析,由于 Repository 提供的方法分属领域逻辑,故而将 OrderMapper 所要实现的接口 OrderRepository 放到核心的领域层。至于 EmailSender 与 RabbitEventBus 各自的抽象 NotificationService 与 EventBus 并未代表领域逻辑,为了不污染领域层的纯洁性,放在应用层似乎更为合理。
|
||||
|
||||
无论是北向网关还是南向网关,它们都要与外部资源进行协作,不管是对内/外协议的适配,还是对内部协作对象的封装,本质上它们只做与业务直接有关的基础功能。真正与业务无关的通用基础功能,是与具体某个软件系统无关的,属于更加基础和通用的框架。例如,OrderController 调用的 Spring Boot APIs,EmailSender 调用的 JavaMail APIs、OrderMapper 调用的 MyBatis APIs 以及 RabbitEventBus 调用的 RabbitMQ APIs,都是这样的通用框架。它们是系统代码边界外的外部框架,通常为第三方的开源框架或商业产品;即使是团队自行研发,也不应该属于当前业务系统的代码模型。
|
||||
|
||||
我们可以基于这个案例归纳各个层次的职责。
|
||||
|
||||
|
||||
领域层:包含 PlaceOrderService、Order、Notification、OrderConfirmed 与抽象的 OrderRepository,封装了纯粹的业务逻辑,不掺杂任何与业务无关的技术实现。
|
||||
应用层:包含 OrderAppService 以及抽象的 EventBus 与 NotificationService,提供对外体现业务价值的统一接口,同时还包含了基础设施功能的抽象接口。
|
||||
基础设施层:包含 OrderMapper、RabbitEventBus 与 EmailSender,为业务实现提供对应的技术功能支撑,但真正的基础设施访问则委派给系统边界之外的外部框架或驱动器。
|
||||
|
||||
|
||||
注意:这里定义了两个分属不同层次的服务,二者极容易混淆。PlaceOrderService 是领域服务,定义在领域层中;OrderAppService 是应用服务,定义在应用层中。这二者的区别属于战术设计的层面,我会在之后的战术设计讲解中深入阐释,我的博客《如何分辨应用服务与领域服务》也有比较详细的介绍。
|
||||
|
||||
OrderController 的实现代码如下所示:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.infrastucture;
|
||||
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.infrastructure.message.CreateOrderRequest;
|
||||
import practiceddd.ecommerce.ordercontext.application.OrderAppService;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Order;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = "/orders/")
|
||||
public class OrderController {
|
||||
@Autowired
|
||||
private OrderAppService service;
|
||||
|
||||
@RequestMapping(method = RequestMethod.POST)
|
||||
public void create(@RequestParam(value = "request", required = true) CreateOrderRequest request) {
|
||||
if (request.isInvalid()) {
|
||||
throw new BadRequestException("the request of placing order is invalid.");
|
||||
}
|
||||
Order order = request.toOrder();
|
||||
service.placeOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
应用服务 OrderAppService 的代码如下所示:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Order;
|
||||
import practiceddd.ecommerce.ordercontext.domain.OrderCompleted;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Notification;
|
||||
import practiceddd.ecommerce.ordercontext.domain.OrderNotification;
|
||||
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
|
||||
|
||||
@Serivce
|
||||
public class OrderAppService {
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
@Autowired
|
||||
private EventBus eventBus;
|
||||
@Autowired
|
||||
private PlaceOrderService placeOrderService;
|
||||
|
||||
public void placeOrder(Order order) {
|
||||
try {
|
||||
placeOrderService.execute(order);
|
||||
notificatonService.send(composeNotification(order));
|
||||
eventBus.publish(composeEvent(order));
|
||||
} catch (InvalidOrderException | Exception ex) {
|
||||
throw new ApplicationException(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Notification composeNotification(Order order) {
|
||||
// 组装通知邮件的内容,实现略
|
||||
}
|
||||
private OrderConfirmed composeEvent(Order order) {
|
||||
// 组装订单确认事件的内容,实现略
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
既然 OrderAppService 属于应用层的应用服务,它就不应该包含具体的业务逻辑。倘若我们将发送邮件和异步消息发送视为“横切关注点”,那么在应用服务中调用它们是合乎情理的;然而,通过 Order 组装 Notification 与 OrderConfirmed 的职责,却应该放在领域层,因为基于订单去生成邮件内容以及发布事件包含了业务逻辑与规则。问题出现!由于这两个对象是由领域层生成的对象,我们该如何将领域层生成的对象传递给处于它之上的应用层对象?
|
||||
|
||||
有三种解决方案可供选择。
|
||||
|
||||
第一种方案是将组装通知邮件与订单确认事件的职责封装到领域层的相关类中,然后在应用层调用这些类的方法,如此可以减少应用层的领域逻辑:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.domain;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class NotificationComposer {
|
||||
public Notification compose(Order order) {
|
||||
// 实现略
|
||||
}
|
||||
}
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.domain;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class OrderConfirmedComposer {
|
||||
public OrderConfirmed compose(Order order) {
|
||||
// 实现略
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
则应用服务就可以简化为:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Order;
|
||||
import practiceddd.ecommerce.ordercontext.domain.OrderConfirmed;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Notification;
|
||||
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
|
||||
import practiceddd.ecommerce.ordercontext.domain.NotificationComposer;
|
||||
import practiceddd.ecommerce.ordercontext.domain.OrderConfirmedComposer;
|
||||
|
||||
@Service
|
||||
public class OrderAppService {
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
@Autowired
|
||||
private EventBus eventBus;
|
||||
@Autowired
|
||||
private PlaceOrderService placeOrderService;
|
||||
@Autowired
|
||||
private NotificationComposer notificationComposer;
|
||||
@Autowired
|
||||
private OrderConfirmedComposer orderConfirmedComposer;
|
||||
|
||||
public void placeOrder(Order order) {
|
||||
try {
|
||||
placeOrderService.execute(order);
|
||||
notificatonService.send(notificationComposer.compose(order));
|
||||
eventBus.publish(orderConfirmedComposer.compose(order));
|
||||
} catch (InvalidOrderException | Exception ex) {
|
||||
throw new ApplicationException(ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
采用这种方案的代码结构如下所示:
|
||||
|
||||
ordercontext.infrastructure
|
||||
- OrderController
|
||||
- OrderMapper
|
||||
- EmailSender
|
||||
- RabbitEventBus
|
||||
ordercontext.application
|
||||
- OrderAppService
|
||||
- NotificationService
|
||||
- EventBus
|
||||
ordercontext.domain
|
||||
- OrderRepository
|
||||
- PlaceOrderService
|
||||
- Order
|
||||
- Notification
|
||||
- OrderConfirmed
|
||||
- NotificationComposer
|
||||
- OrderConfirmedComposer
|
||||
|
||||
|
||||
|
||||
第二种方案则将“上层对下层的调用”改为“下层对上层的通知”,即前面讲解层之间协作时所谓“自底向上”的通信问题,这就需要在领域层为订单业务定义 OrderEventPublisher 接口。当满足某个条件时,通过它在领域层发布事件,这个事件即所谓“领域事件(Domain Event)”。如果我们将建模的视角切换到以“事件”为中心,则意味着领域服务在下订单完成后,需要分别发布 NotificationComposed 与 OrderConfirmed 事件,并由应用层的 OrderEventHandler 作为各自事件的订阅者。这里的前提是:发送邮件与异步发送通知属于应用逻辑的一部分。
|
||||
|
||||
我们需要先在领域层定义发布者接口:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.domain;
|
||||
|
||||
public interface OrderEventPublisher {
|
||||
void publish(NotificationComposed event);
|
||||
void publish(OrderConfirmed event);
|
||||
}
|
||||
|
||||
|
||||
|
||||
实现 OrderEventPublisher 接口的类放在应用层:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.domain.OrderEventPublisher;
|
||||
import practiceddd.ecommerce.ordercontext.domain.NotificationComposed;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Notification;
|
||||
import practiceddd.ecommerce.ordercontext.domain.OrderConfirmed;
|
||||
|
||||
public class OrderEventHandler implements OrderEventPublisher {
|
||||
private NotificationService notificationService;
|
||||
private EventBus eventBus;
|
||||
|
||||
public OrderEventHandler(NotificationService notificationService, EventBus eventBus) {
|
||||
this.notificationService = notificationService;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
public void publish(NotificationComposed event) {
|
||||
notificationService.send(event.notification());
|
||||
}
|
||||
|
||||
public void publish(OrderConfirmed event) {
|
||||
eventBus.publish(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
应用层的应用服务则修改为:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Order;
|
||||
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
|
||||
|
||||
@Service
|
||||
public class OrderAppService {
|
||||
@Autowired
|
||||
private PlaceOrderService placeOrderService;
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
@Autowired
|
||||
private EventBus eventBus;
|
||||
|
||||
public void placeOrder(Order order) {
|
||||
try {
|
||||
placeOrderService.register(new OrderEventHandler(notificationService, eventBus));
|
||||
placeOrderService.execute(order);
|
||||
} catch (InvalidOrderException ex) {
|
||||
throw new ApplicationException(ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
领域服务修改为:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.domain;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
|
||||
|
||||
@Service
|
||||
public class PlaceOrderService {
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
@Autowired
|
||||
private NotificationComposer notificationComposer;
|
||||
|
||||
private OrderEventPublisher publisher;
|
||||
|
||||
public void register(OrderEventPublisher publisher) {
|
||||
this.publisher = publisher;
|
||||
}
|
||||
|
||||
public void execute(Order order) {
|
||||
if (!order.isValid()) {
|
||||
throw new InvalidOrderException(String.format("The order with id %s is invalid.", order.id()));
|
||||
}
|
||||
orderRepository.save(order);
|
||||
fireNotificationComposedEvent(order);
|
||||
fireOrderConfirmedEvent(order);
|
||||
}
|
||||
|
||||
private void fireNotificationComposedEvent(Order order) {
|
||||
Notification notification = notificationComposer.compose(order);
|
||||
publisher.publish(new NotificationComposed(notification));
|
||||
}
|
||||
private void fireOrderConfirmedEvent(Order order) {
|
||||
publisher.publish(new OrderConfirmed(order));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
倘若采用这种方案,则代码结构如下所示:
|
||||
|
||||
ordercontext.infrastructure
|
||||
- OrderController
|
||||
- OrderMapper
|
||||
- EmailSender
|
||||
- RabbitEventBus
|
||||
ordercontext.application
|
||||
- OrderAppService
|
||||
- NotificationService
|
||||
- EventBus
|
||||
- OrderEventHandler
|
||||
ordercontext.domain
|
||||
- OrderRepository
|
||||
- PlaceOrderService
|
||||
- NotificationComposer
|
||||
- OrderEventPublisher
|
||||
- Order
|
||||
- OrderConfirmed
|
||||
- NotificationComposed
|
||||
|
||||
|
||||
|
||||
第三种方案需要重新分配 NotificationService 与 EventBus,将这两个抽象接口放到单独的一个名为 interfaces 的包中,这个 interfaces 包既不属于应用层,又不属于领域层。在后面讲解代码模型时,我会解释这样设计的原因,详细内容请移步阅读后面的章节。
|
||||
|
||||
通过这样的职责分配后,业务逻辑发生了转移,发送邮件与异步发送通知的调用不再放到应用服务 OrderAppService 中,而是封装到了 PlaceOrderService 领域服务。这时,应用服务 OrderAppService 的实现也变得更加简单。看起来,修改后的设计似乎更符合领域驱动分层架构对应用层的定义,即“应用层是很薄的一层,不包含业务逻辑”。这里的前提是:发送邮件与异步发送通知属于业务逻辑的一部分。
|
||||
|
||||
应用服务的定义如下所示:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.application;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
|
||||
import practiceddd.ecommerce.ordercontext.domain.Order;
|
||||
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
|
||||
|
||||
@Service
|
||||
public class OrderAppService {
|
||||
@Autowired
|
||||
private PlaceOrderService placeOrderService;
|
||||
|
||||
public void placeOrder(Order order) {
|
||||
try {
|
||||
placeOrderService.execute(order);
|
||||
} catch (InvalidOrderException | Exception ex) {
|
||||
throw new ApplicationException(ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
不过,领域服务就变得不太纯粹了:
|
||||
|
||||
package practiceddd.ecommerce.ordercontext.domain;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import practiceddd.ecommerce.ordercontext.interfaces.NotificationService;
|
||||
import practiceddd.ecommerce.ordercontext.interfaces.EventBus;
|
||||
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
|
||||
|
||||
@Service
|
||||
public class PlaceOrderService {
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
@Autowired
|
||||
private EventBus eventBus;
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
@Autowired
|
||||
private NotificationComposer notificationComposer;
|
||||
|
||||
public void execute(Order order) {
|
||||
if (!order.isValid()) {
|
||||
throw new InvalidOrderException(String.format("The order with id %s is invalid.", order.id()));
|
||||
}
|
||||
orderRepository.save(order);
|
||||
notificatonService.send(notificationComposer.compose(order));
|
||||
eventBus.publish(new OrderConfirmed(order));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
代码结构如下所示:
|
||||
|
||||
ordercontext.infrastructure
|
||||
- OrderController
|
||||
- OrderMapper
|
||||
- EmailSender
|
||||
- RabbitEventBus
|
||||
ordercontext.application
|
||||
- OrderAppService
|
||||
ordercontext.interfaces
|
||||
- NotificationService
|
||||
- EventBus
|
||||
ordercontext.domain
|
||||
- OrderRepository
|
||||
- PlaceOrderService
|
||||
- Order
|
||||
- OrderConfirmed
|
||||
- Notification
|
||||
- NotificationComposer
|
||||
|
||||
|
||||
|
||||
这三个方案该如何选择?根本的出发点在于你对业务逻辑和应用逻辑的认知,进而是你对领域服务与应用服务的认知,这些内容,就留待战术设计部分来讨论。由于并不存在绝对完美的正确答案,因此我的建议是在满足功能需求与松散耦合的前提下,请尽量选择更简单的方案。
|
||||
|
||||
|
||||
|
||||
|
106
专栏/领域驱动设计实践(完)/026限界上下文与架构.md
Normal file
106
专栏/领域驱动设计实践(完)/026限界上下文与架构.md
Normal file
@ -0,0 +1,106 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
026 限界上下文与架构
|
||||
作为领域驱动战略设计的重要元素,限界上下文对领域驱动架构有着直接的影响。在领域驱动的架构设计过程中,识别限界上下文与上下文映射都是一个重要的过程。限界上下文可以作为逻辑架构与物理架构的参考模型,而上下文映射则非常直观地体现了系统架构的通信模型。
|
||||
|
||||
限界上下文的架构范围
|
||||
|
||||
这里,我需要再一次澄清 Eric Evans 提出的“限界上下文”概念:限界上下文究竟是仅仅针对领域模型的边界划分,还是对整个架构(包括基础设施层以及需要使用的外部资源)垂直方向的划分? 正如前面对 Eric Evans 观点的引用,他在《领域驱动设计》一书中明确地指出:“根据团队的组织、软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界”,显然,限界上下文不仅仅作用于领域层和应用层,它是架构设计而非仅仅是领域设计的关键因素。
|
||||
|
||||
限界上下文体现的是一个垂直的架构边界,主要针对后端架构层次的垂直切分。例如,订单上下文的内部就包含了应用层、领域层和基础设施层,每一层的模块都是面向业务进行划分,甚至可能是一一对应的。
|
||||
|
||||
对于基础设施层需要访问的外部资源,以及为了访问它需要重用的框架或平台,与技术决策和选型息息相关,仍然属于架构设计的考量范围,但它们不属于限界上下文的代码模型。例如,订单上下文需要访问数据库和消息队列。在技术决策上,我们需要确定是选择 NoSQL 数据库还是关系数据库,消息队列是采用 Pull 模式还是 Push 模式。在技术选型上,我们需要确定具体是哪一种数据库和消息队列中间件,同时还需要确定访问它们的框架。对资源的规划与设计也属于限界上下文的设计范围,例如,如何设计数据表、如何规划消息队列的主题。在进行这一系列的技术选型和决策时,依据的其实是该限界上下文的业务场景与质量属性,这些架构活动自然就属于该限界上下文的范畴。我们还需要决定框架的版本,这些框架并不属于系统的代码库,但需要考虑它们与限界上下文代码模型的集成、构建与部署。
|
||||
|
||||
限界上下文的通信边界
|
||||
|
||||
我们对整个业务系统按照限界上下文进行了划分。划分时,限界上下文之间是否为进程边界隔离,直接影响架构设计。此为限界上下文的通信边界,以进程为单位分为进程内与进程间两种边界。之所以这么分类,是因为进程内与进程间在如下方面存在迥然不同的处理方式:
|
||||
|
||||
|
||||
通信
|
||||
消息的序列化
|
||||
资源管理
|
||||
事务与一致性处理
|
||||
部署
|
||||
|
||||
|
||||
除此之外,通信边界的不同还影响了系统对各个组件(服务)的重用方式与共享方式。
|
||||
|
||||
进程内的通信边界
|
||||
|
||||
若限界上下文之间为进程内的通信方式,则意味着在运行时它们的代码模型都运行在同一个进程中,可以通过实例化的方式重用领域模型或其他层次的对象。即使都属于进程内通信,限界上下文的代码模型(Code Model)仍然存在两种级别的设计方式。以 Java 为例,归纳如下。
|
||||
|
||||
|
||||
命名空间级别:通过命名空间进行界定,所有的限界上下文其实都处于同一个模块(Module)中,编译后生成一个 Jar 包。
|
||||
模块级别:在命名空间上是逻辑分离的,不同限界上下文属于同一个项目的不同模块,编译后生成各自的 Jar 包。这里所谓的“模块”,在 Java 代码中也可以创建为 Jigsaw 的 module。
|
||||
|
||||
|
||||
这两种级别的代码模型仅仅存在编译期的差异,后者的解耦会更加彻底,倘若限界上下文的划分足够合理,也能提高它们对变化的应对能力。例如,当限界上下文 A 的业务场景发生变更时,我们可以只修改和重编译限界上下文 A 对应的 Jar 包,其余 Jar 包并不会受到影响。由于它们都运行在同一个 Java 虚拟机中,意味着当变化发生时,整个系统需要重新启动和运行。
|
||||
|
||||
即使处于同一个进程的边界,我们仍需重视代码模型的边界划分,因为这种边界隔离有助于整个系统代码结构变得更加清晰。限界上下文之间若采用进程内通信,则彼此之间的协作会更加容易、更加高效。然而,正所谓越容易重用,就越容易产生耦合。编写代码时,我们需要谨守这条无形的逻辑边界,时刻注意不要逾界,并确定限界上下文各自对外公开的接口,避免它们之间产生过多的依赖。此时,防腐层(ACL)就成了抵御外部限界上下文变化的最佳场所。一旦系统架构需要将限界上下文调整为进程间的通信边界,这种“各自为政”的设计与实现能够更好地适应这种演进。
|
||||
|
||||
以第 10 课介绍的项目管理系统为例,假设项目上下文与通知上下文之间的通信为进程内通信,当项目负责人将 Sprint Backlog 成功分配给团队成员之后,系统将发送邮件通知该团队成员。这个职责由项目上下文的 AssignSprintBacklogService 领域服务承担,而发送通知的职责则由通知上下文的 NotificationAppService 应用服务承担。考虑到未来限界上下文通信边界的变化,我们就不能直接在 AssignSprintBacklogService 服务中实例化 NotificationAppService 对象,而是在项目上下文中定义通知服务的接口 NotificationService,并由 NotificationClient 去实现这个接口,它们扮演的就是防腐层的作用。AssignSprintBacklogService 服务依赖该防腐层的接口,并将具体实现通过依赖注入。这个协作过程如下面的时序图所示:
|
||||
|
||||
|
||||
|
||||
倘若在未来需要将通知上下文分离为进程间的通信边界,这种变动将只会影响到防腐层的实现,作为 NotificationService 服务的调用者,并不会受到这一变化的影响。
|
||||
|
||||
采用进程内通信的系统架构属于单体(Monolithic)架构,所有限界上下文部署在同一个进程中,因此不能针对某一个限界上下文进行水平伸缩。当我们需要对限界上下文的实现进行替换或升级时,也会影响到整个系统。即使我们守住了代码模型的边界,耦合仍然存在,导致各个限界上下文的开发互相影响,团队之间的协调成本也随之而增加。
|
||||
|
||||
进程间的通信边界
|
||||
|
||||
倘若限界上下文之间的通信是跨进程的,则意味着限界上下文是以进程为边界。此时,一个限界上下文就不能直接调用另一个限界上下文的方法,而是要通过分布式的通信方式。
|
||||
|
||||
当我们将一个限界上下文限定在进程边界内时,并不足以决定领域驱动架构的设计质量。我们还需要将这个边界的外延扩大,考虑限界上下文需要访问的外部资源,这就产生了两种不同风格的架构:
|
||||
|
||||
|
||||
数据库共享架构
|
||||
零共享架构
|
||||
|
||||
|
||||
数据库共享架构
|
||||
|
||||
数据库共享架构其实是一种折中的手段。在考虑限界上下文划分时,分开考虑代码模型与数据库模型,就可能出现代码的运行是进程分离的,数据库却共享彼此的数据,即多个限界上下文共享同一个数据库。由于没有分库,在数据库层面就可以更好地保证事务的 ACID。这或许是该方案最有说服力的证据,但也可以视为是对“一致性”约束的妥协。
|
||||
|
||||
数据库共享的问题在于数据库的变化方向与业务的变化方向并不一致,这种不一致性体现在两方面,具体如下。
|
||||
|
||||
|
||||
耦合:虽然限界上下文的代码模型是解耦的,但在数据库层面依然存在强耦合关系。
|
||||
水平伸缩:部署在应用服务器的应用服务可以根据限界上下文的边界单独进行水平伸缩,但在数据库层面却无法做到。
|
||||
|
||||
|
||||
根据 Netflix 团队提出的微服务架构最佳实践,其中一个最重要特征就是“每个微服务的数据单独存储”,但是服务的分离并不绝对代表数据应该分离。数据库的样式(Schema)与领域模型未必存在一对一的映射关系。在对数据进行分库设计时,如果仅仅站在业务边界的角度去思考,可能会因为分库的粒度太小,导致不必要的跨库关联。因此,我们可以将“数据库共享”模式视为一种过渡方案。如果没有想清楚微服务的边界,就不要在一开始设计微服务时,就直接将数据彻底分开,而应采用演进式的设计。
|
||||
|
||||
为了便于在演进设计中将分表重构为分库,从一开始要注意避免在分属两个限界上下文的表之间建立外键约束关系。某些关系型数据库可能通过这种约束关系提供级联更新与删除的功能,这种功能反过来会影响代码的实现。一旦因为分库而去掉表之间的外键约束关系,需要修改的代码太多,会导致演进的成本太高,甚至可能因为某种疏漏带来隐藏的 Bug。
|
||||
|
||||
如果设计数据表时没有外键约束关系,可能在当前增加了开发成本,却为未来的演进打开了方便之门。例如,在针对某手机品牌开发的舆情分析系统中,危机查询服务提供对识别出来的危机进行查询。查询时,需要通过 userID 获得危机处理人、危机汇报人的详细信息。左图为演进前直接通过数据库查询的方式,右图则切断了这种数据库耦合,改为服务调用的方式:
|
||||
|
||||
|
||||
|
||||
数据库共享架构也可能是一种“反模式”。当两个分处不同限界上下文的服务需要操作同一张数据表(这张表被称之为“共享表”)时,就传递了一个信号,即我们的设计可能出现了错误。
|
||||
|
||||
|
||||
遗漏了一个限界上下文,共享表对应的是一个被重用的服务:买家在查询商品时,商品服务会查询价格表中的当前价格,而在提交订单时,订单服务也会查询价格表中的价格,计算当前的订单总额;共享价格数据的原因是我们遗漏了价格上下文,通过引入价格服务就可以解除这种不必要的数据共享。
|
||||
职责分配出现了问题,操作共享表的职责应该分配给已有的服务:舆情服务与危机服务都需要从邮件模板表中获取模板数据,然后再调用邮件服务组合模板的内容发送邮件;实际上从邮件模板表获取模板数据的职责应该分配给已有的邮件服务。
|
||||
共享表对应两个限界上下文的不同概念:仓储上下文与订单上下文都需要访问共享的产品表,但实际上这两个上下文需要的产品信息并不相同,应该按照限界上下文的边界分开为各自关心的产品信息建表。
|
||||
|
||||
|
||||
为什么会出现这三种错误的设计?根本原因在于我们没有通过业务建模,而是在数据库层面隐式地进行建模,因而在代码中没有体现正确的领域模型,从而导致了数据库的耦合或共享。遵循领域驱动设计的原则,我们应该根据领域逻辑去识别限界上下文。
|
||||
|
||||
零共享架构
|
||||
|
||||
当我们将两个限界上下文共享的外部资源彻底斩断后,就成为了零共享架构。例如,前面介绍的舆情分析系统,在去掉危机查询对用户表的依赖后,就演进为零共享架构。如下图所示,危机分析上下文与用户上下文完全零共享:
|
||||
|
||||
|
||||
|
||||
这是一种限界上下文彻底独立的架构风格,它保证了边界内的服务、基础设施乃至于存储资源、中间件等其他外部资源的完整性与独立性,最终形成自治的微服务。这种架构的表现形式为:每个限界上下文都有自己的代码库、数据存储以及开发团队,每个限界上下文选择的技术栈和语言平台也可以不同,限界上下文之间仅仅通过限定的通信协议和数据格式进行通信。
|
||||
|
||||
此时的限界上下文形成了一个相对自由的“独立王国”。从北向网关的 Controller 到应用层,从应用层到领域层的领域模型,再到南向网关对数据库的访问实现,进而到数据库的选型都可以由当前限界上下文独立做主。由于它们是“零共享”的,使得它们彼此之间可以独立演化,在技术选型上也可以结合自己上下文的业务场景做出“恰如其分”的选择。譬如说,危机分析需要存储大规模的非结构化数据,同时业务需要支持对危机数据的全文本搜索,我们选择了 ElasticSearch 作为持久化的数据库。考虑到开发的高效以及对 JSON 数据的支持,我们选择了 Node.js 为后端开发框架。至于用户上下文,数据量小,结构规范,采用传统的基于关系型数据库的架构会更简单更适合。二者之间唯一的耦合就是危机分析通过 HTTP 协议访问上游的用户服务,根据传入的 userID 获得用户的详细信息。
|
||||
|
||||
彻底分离的限界上下文变得小而专,使得我们可以很好地安排遵循 2PTs 规则的小团队去治理它。然而,这种架构的复杂度也不可低估。限界上下文之间的通信是跨进程的,我们需要考虑通信的健壮性。数据库是完全分离的,当需要关联之间的数据时,需得跨限界上下文去访问,无法享受数据库自身提供的关联福利。由于每个限界上下文都是分布式的,如何保证数据的一致性也是一件棘手的问题。当整个系统都被分解成一个个可以独立部署的限界上下文时,运维与监控的复杂度也随之而剧增。
|
||||
|
||||
|
||||
|
||||
|
120
专栏/领域驱动设计实践(完)/027限界上下文对架构的影响.md
Normal file
120
专栏/领域驱动设计实践(完)/027限界上下文对架构的影响.md
Normal file
@ -0,0 +1,120 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
027 限界上下文对架构的影响
|
||||
通信边界对架构的影响
|
||||
|
||||
限界上下文的通信边界会对系统的架构产生直接的影响,在此之前,我们需要理清几个和边界有关的概念。如前所述,我提出了限界上下文的通信边界的概念,并将其分为进程内通信与进程间通信两种方式。在 Toby Clemson 给出的微服务架构中,则将逻辑边界视为整个微服务的边界,而将微服务代码模型中的所有模块视为在同一个网络边界内。但我认为在引入了虚拟化以及容器技术后,仍将这种边界描述为网络边界似乎并不准确,因此我以进程边界来表示前面提到的通信边界。
|
||||
|
||||
显然,倘若限界上下文之间采用进程间通信,则每个限界上下文就可以认为是一个微服务——对于微服务,我更愿意用进程边界来界定代码模型的部署与运行。
|
||||
|
||||
无论是网络边界,还是进程边界,都可以视为物理边界;而代码模型中对于层以及模块的划分,则属于逻辑边界的范畴。逻辑边界有时候会和物理边界重合,但这仅仅是针对代码模型而言。一个系统多数情况下都会访问其物理边界之外的外部资源,如此看来,一个系统的逻辑边界往往要大于物理边界。
|
||||
|
||||
在进行架构设计时,我们往往会将整个系统的架构划分为多个不同的视图,其中最主要的视图就是逻辑视图和物理视图,这是我们看待系统的两种不同视角。前者关注代码结构、层次以及职责的分配,后者关注部署、运行以及资源的分配,这两种视图都需要考虑限界上下文以及它们之间的协作关系。在考虑逻辑视图时,我们会为限界上下文履行的职责所吸引,同时又需得关注它们之间的协作,此时,就该物理视图粉墨登场了。若两个限界上下文的代码模型属于同一个物理边界,就是部署和运行在同一个进程中的好哥俩儿,调用方式变得直接,协作关系较为简单,我们只需要在实现时尽可能维护好逻辑边界即可。如果限界上下文代码模型的逻辑边界与物理边界完全重叠,要考虑的架构要素就变得复杂了。
|
||||
|
||||
对于跨进程边界进行协作的限界上下文,我建议为其绘制上下文映射,并通过六边形架构来确定二者之间的通信端口与通信协议。上游限界上下文公开的接口可以是基于 HTTP 的 REST 服务,也可以通过 RPC 访问远程对象,又或者利用消息中间件传递消息。选择的通信协议不同,传递的消息格式以及序列化机制也不同,为下游限界上下文建立的客户端也不相同。由于这种协作关系其实是一种分布式调用,自然存在分布式系统与身俱来的缺陷,例如,网络总是不可靠,维护数据一致性要受到 CAP 原则的约束。这时,就需要考虑服务调用的熔断来及时应对故障,避免因单一故障点带来整个微服务架构的连锁反应。我们还需要权衡数据一致性问题,若不要求严格的数据一致性,则可以引入最终一致性(BASE),如采用可靠事件模式、补偿模式或者 TCC(Try-Confirm-Cancel)模式等。当然我们还需要考虑安全、部署和运维等诸多与分布式系统有关的问题,这些问题已经超出了本课程讨论的范围,这里就略过不提了。
|
||||
|
||||
限界上下文、六边形架构与微服务
|
||||
|
||||
如前所述,倘若我们将单个限界上下文代码模型的边界视为物理边界,则可以认为一个限界上下文就是一个微服务。而在前面介绍六边形架构时,我也提到该架构模式外部的六边形边界实则也是物理边界。基于这些前提,我们得出结论:
|
||||
|
||||
|
||||
一个限界上下文就是一个六边形,限界上下文之间的通信通过六边形的端口进行;
|
||||
一个微服务就是一个六边形,微服务之间的协作就是限界上下文之间的协作。
|
||||
|
||||
|
||||
显然,在将限界上下文的代码模型边界视为物理边界时,限界上下文、六边形与微服务之间就成了“三位一体”的关系。我们可以将三者的设计原则与思想结合起来,如下图所示:
|
||||
|
||||
|
||||
|
||||
该图清晰地表达了这种“三位一体”的关系。
|
||||
|
||||
|
||||
限界上下文即微服务:我们可以利用领域驱动设计对限界上下文的定义,以及根据前述识别限界上下文的方法来设计微服务。
|
||||
微服务即限界上下文:运用微服务设计原则,可以进一步甄别限界上下文的边界是否合理,对限界上下文进行进一步的演化。
|
||||
微服务即六边形:深刻体会微服务的“零共享架构”,并通过六边形架构来表达微服务。
|
||||
限界上下文即六边形:运用上下文映射来进一步探索六边形架构的端口与适配器角色。
|
||||
六边形即限界上下文:通过六边形架构的端口确定限界上下文之间的集成关系。
|
||||
|
||||
|
||||
我们试以电商系统的购物流程来说明这种“三位一体”的关系。首先,我们通过领域场景分析的用例图来分析该购物流程:
|
||||
|
||||
|
||||
|
||||
通过对各个用例的语义相关性与功能相关性,结合这些用例的业务能力,可以确定用例的边界。当我们为这些边界进行命名时,就初步获得了如下六个限界上下文:
|
||||
|
||||
|
||||
Product Context
|
||||
Basket Context
|
||||
Order Context
|
||||
Inventory Context
|
||||
Payment Context
|
||||
Notification Context
|
||||
|
||||
|
||||
结合购买流程,电商系统还需要用到第三方物流系统对商品进行配送,这个物流系统可以认为是电商系统的外部系统(External Service)。如果这六个限界上下文之间采用跨进程通信,实际上就是六个微服务,它们应该单独部署在不同节点之上。现在,我们需要站在微服务的角度对其进行思考。需要考虑的内容包括如下。
|
||||
|
||||
|
||||
每个微服务是如何独立部署和运行的?如果我们从运维角度去思考微服务,就可以直观地理解所谓的“零共享架构”到底是什么含义。如果我们在规划系统的部署视图时,发现微服务之间在某些资源存在共用或纠缠不清的情况,就说明微服务的边界存在不合理之处,换言之,也就是之前识别限界上下文存在不妥。
|
||||
微服务之间是如何协作的?这个问题牵涉到通信机制的决策、同步或异步协作的选择、上游与下游服务的确定。我们可以结合上下文映射与六边形架构来思考这些问题。上下文映射帮助我们确定这种协作模式,并在确定了上下游关系后,通过六边形架构来定义端口。
|
||||
|
||||
|
||||
现在我们可以将六边形架构与限界上下文结合起来,即通过端口确定限界上下文之间的协作关系,绘制上下文映射。如果采用客户方—供应商开发模式,则各个限界上下文六边形的端口就是上游(Upstream,简称 U)或下游(Downstream,简称 D)。由于这些限界上下文都是独立部署的微服务,因此,它们的上游端口应实现为 OHS 模式(下图以绿色端口表示),下游端口应实现为 ACL 模式(下图以蓝色端口表示):
|
||||
|
||||
|
||||
|
||||
每个微服务都是一个独立的应用,我们可以针对每个微服务规划自己的分层架构,进而确定微服务内的领域建模方式。微服务的协作也有三种机制,分别为命令、查询和事件。Ben Stopford 在文章 Build Services on a Backbone of Events 中总结了这三种机制,具体如下。
|
||||
|
||||
|
||||
命令:是一个动作,是一个要求其他服务完成某些操作的请求,它会改变系统的状态,命令会要求响应。
|
||||
查询:是一个请求,查看是否发生了什么事。重要的是,查询操作没有副作用,它们不会改变系统的状态。
|
||||
事件:既是事实又是触发器,用通知的方式向外部表明发生了某些事。
|
||||
|
||||
|
||||
发出命令或查询请求的为下游服务,而服务的定义则处于上游。如上图所示,我以菱形端口代表“命令”,矩形端口代表“查询”,这样就能直观地通过上下文映射以及六边形的端口清晰地表达微服务的服务定义以及服务之间的协作方式。例如,Product Context 同时作为 Basket Context 与 Order Context 的上游限界上下文,其查询端口提供的是商品查询服务。Basket Context 作为 Order Context 的上游限界上下文,其命令端口提供了清除购物篮的命令服务。
|
||||
|
||||
如果微服务的协作采用事件机制,则上下文映射的模式为发布/订阅事件模式。这时,限界上下文之间的关系有所不同,我们需要识别在这个流程中发生的关键事件。传递关键事件的就是六边形的端口,具体实现为消息队列,适配器则负责发布事件。于是,系统的整体架构就演变为以事件驱动架构(Event-Driven Architecture,EDA)风格构建的微服务系统。Vaughn Vernon 在《实现领域驱动设计》一书中使用六边形架构形象地展现了这一架构风格。
|
||||
|
||||
|
||||
|
||||
六边形之间传递的三角形就是导致限界上下文切换的关键事件,在领域驱动设计中,作为领域事件(Domain Event)被定义在领域层。为了与限界上下文内部传递的领域事件区分开,我们可以名其为“关键领域事件”,又或者称为“应用事件”,它仍然属于领域模型中的一部分。在前面所示的上下文映射中,我们可以用三角形端口来代表“事件”,事件端口所在的限界上下文为发布者,该事件对应的下游端口则为订阅者。然而,当我们采用“事件”的协作机制时,上下文映射中的上下游语义却发生了变化,原来作为“命令”或“查询”提供者的上游,却成为了“事件”机制下处于下游的订阅者。以购物篮为例,“清除购物篮”命令服务被定义在 Basket Context 中。当提交订单成功后,Order Context 就会发起对该服务的调用。倘若将“提交订单”视为一个内部命令(Command),在订单被提交成功后,就会触发 OrderConfirmed 事件,此时,Order Context 反而成为了该事件的发布者,Basket Context 则会订阅该事件,一旦侦听到该事件触发,就会在 Basket Context 内部执行“清除购物篮”命令。显然,“清除购物篮”不再作为服务发布,而是在事件的 handler 中作为内部功能被调用。
|
||||
|
||||
采用“事件”协作机制会改变我们习惯的顺序式服务调用形式,整个调用链会随着事件的发布而产生跳转,尤其是暴露在六边形端口的“关键事件”,更是会产生跨六边形(即限界上下文)的协作。仍以电商系统的购买流程为例,我们只考虑正常流程。在 Basket Context 中,一旦购物篮中的商品准备就绪,买家就会请求下订单,此时开始了事件流。
|
||||
|
||||
|
||||
Basket Context 发布 OrderRequested 事件,Order Context 订阅该事件,然后执行提交订单的流程。
|
||||
Order Context 验证订单,并发布 InventoryRequested 事件,要求验证订单中购买商品的数量是否满足库存要求。
|
||||
Inventory Context 订阅此事件并对商品库存进行检查,倘若检查通过,则发布 AvailabilityValidated 事件。
|
||||
Order Context 侦听到 AvailabilityValidated 事件后,验证通过,发布 OrderValidated 事件从而发起支付流程。
|
||||
Payment Context 响应 OrderValidated 事件,在支付成功后发布 PaymentProcessed 事件。
|
||||
Order Context 订阅 PaymentProcessed 事件,确认支付完成进而发布 OrderConfirmed 事件。
|
||||
Basket Context、Notification Context 与 Shipment Context 上下文都将订阅该事件。Basket Context 会清除购物篮,Notification Context 会发起对买家和卖家的通知,而 Shipment Context 会发起配送流程,在交付商品给买家后,发布 ShipmentDelivered 事件并被 Order Context 订阅。
|
||||
|
||||
|
||||
整个协作过程如下图所示(图中的序号对应事件流的编号):
|
||||
|
||||
|
||||
|
||||
与订单流程相关的事件包括:
|
||||
|
||||
|
||||
OrderRequested
|
||||
InventoryRequested
|
||||
AvailabilityValidated
|
||||
OrderValidated
|
||||
PaymentProcessed
|
||||
OrderConfirmed
|
||||
ShipmentDelivered
|
||||
|
||||
|
||||
我们注意到这些事件皆以“过去时态”命名,这是因为事件的本质是“事实(Fact)”,意味着它是过去发生的且不可变更的数据,代表了某种动作的发生,并以事件的形式留下了足迹。
|
||||
|
||||
正如前面给出的事件驱动架构所示,事件的发布者负责触发输出事件(Outgoing Event),事件的订阅者负责处理输入事件(Incoming Event),它们作为六边形的事件适配器,也就是我所说的网关,被定义在基础设施层。事件适配器的抽象则被定义在应用层。假设电商系统选择 Kafka 作为事件传递的通道,我们就可以为不同的事件类别定义不同的主题(Topic)。此时,Kafka 相当于是连接微服务之间进行协作的事件总线(Event Bus)。Ben Stopford 将采用这种机制实现的微服务称为“事件驱动服务(Event Driven Services)”。
|
||||
|
||||
通过电商系统的这个案例,清晰地为我们勾勒出限界上下文、六边形与微服务“三位一体”的设计脉络,即它们的设计思想、设计原则与设计方法是互相促进互相融合的。在架构设计层面上,三者可谓浑然一体。
|
||||
|
||||
|
||||
|
||||
|
210
专栏/领域驱动设计实践(完)/028领域驱动设计的代码模型.md
Normal file
210
专栏/领域驱动设计实践(完)/028领域驱动设计的代码模型.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
028 领域驱动设计的代码模型
|
||||
理解了限界上下文和分层架构的本质,要确认系统的代码模型自然也就水到渠成。提醒注意,没有必要要求每个团队都遵守一套代码模型,但在同一个项目中,代码模型应作为架构规范要求每个团队成员必须遵守。当然,在遵守规范的同时,每个人需要理解如此划分代码模型的意义所在、价值所在。
|
||||
|
||||
遵循领域驱动设计思想的代码模型
|
||||
|
||||
结合领域驱动分层架构设计思想,通过引入整洁架构与六边形架构以及上下文映射等设计原则与模式,我们对层、层之间协作、跨限界上下文之间的协作已经有了深入的理解。当我们考虑限界上下文的代码模型时,需要考虑纵向架构除前端之外的所有层次或模块。故而在代码模型设计因素中,需要考虑层与模块之间的职责分离与松散耦合,同时还必须将整个限界上下文作为基本设计单元,照顾到限界上下文之间的协作关系。基于这样的设计因素,结合我自己的项目经验,给出了如下代码模型推荐:
|
||||
|
||||
- application
|
||||
- interfaces
|
||||
- domain
|
||||
- repositories
|
||||
- gateways
|
||||
- controllers
|
||||
- persistence
|
||||
- mq
|
||||
- client
|
||||
- ...
|
||||
|
||||
|
||||
|
||||
以下是对代码结构的说明。
|
||||
|
||||
|
||||
application:对应了领域驱动设计的应用层,主要内容为该限界上下文中所有的应用服务。
|
||||
interfaces:对 gateways 中除 persistence 之外的抽象,包括访问除数据库之外其他外部资源的抽象接口,以及访问第三方服务或其他限界上下文服务的抽象接口。从分层架构的角度讲,interfaces 应该属于应用层,但在实践时,往往会遭遇领域层需要访问这些抽象接口的情形,单独分离 出 interfaces,非常有必要。
|
||||
domain:对应了领域驱动设计的领域层,但是我将 repositories 单独分了出来,目的是为了更好地体现它在基础设施层扮演的与外部资源打交道的网关语义。
|
||||
repositories:代表了领域驱动设计中战术设计阶段的资源库,皆为抽象类型。如果该限界上下文的资源库并不复杂,可以将 repositories 合并到 domain 中。
|
||||
gateways:对应了领域驱动设计的基础设施层,命名为 gateways,是为了更好地体现网关的语义,其下可以视外部资源的集成需求划分不同的包。其中,controllers 相对特殊,它属于对客户端提供接口的北向网关,等同于上下文映射中“开放主机服务(OHS)”的概念。如果为了凸显它的重要性,可以将 controllers 提升到与 application、domain、gateways 同等层次。我之所以将其放在 gateways 之下,还是想体现它的网关本质。persistence 对应了 repositories 抽象,至于其余网关,对应的则是 interfaces 下的抽象,包括消息队列以及与其他限界上下文交互的客户端。例如,通过 http 通信的客户端。其中,client 包下的实现类与 interfaces 下的对应接口组合起来,等同于上下文映射中“防腐层(ACL)”的概念。
|
||||
|
||||
|
||||
我们看到,这里给出的代码结构并未严格按照领域驱动设计的分层架构来划分,我们需要把握以下内容。
|
||||
|
||||
|
||||
分层架构的层并非编程语言可以限定的,因此它只是一种设计概念,最后都需要映射到模块或包的概念上。
|
||||
无论代码结构是否表达了层的概念,都需要充分理解分层的意义,并使得整个代码结构在架构上要吻合分层架构的理念。
|
||||
每个模块或包都是单一职责的设计,在整个代码模型中扮演着不同的角色,有的对应了分层架构的层,有的代表了领域驱动设计的设计要素,有的则是为了保证架构的松散耦合。
|
||||
|
||||
|
||||
如果不考虑 Repository 在领域驱动设计中的特殊性,而仅仅将其视为一种网关,则上述结构中 gateways 与 interfaces 恰恰建立了一一对应的对称关系。唯有 controllers 因为不需要依赖注入的关系,没有对应在 interfaces 模块中的抽象定义。考虑到 controllers 对应上下文映射的开放主机服务(OHS)模式,client 对应上下文映射的防腐层(ACL)模式,我们还可以定义如下更符合领域驱动设计特色的代码模型:
|
||||
|
||||
- application
|
||||
- domain
|
||||
- interfaces
|
||||
- repositories
|
||||
- mq
|
||||
- acl
|
||||
- ...
|
||||
- gateways
|
||||
- ohs
|
||||
- persistence
|
||||
- mq
|
||||
- acl
|
||||
- ...
|
||||
|
||||
|
||||
|
||||
代码模型中的 ohs 和 acl 不言自明,充分说明了它们在架构中发挥的作用。倘若我们在团队中明确传递这一设计知识,不仅可以让团队成员更加充分地理解“开放主机服务”与“防腐层”的意义,也更有利于保证限界上下文在整个架构中的独立性。诸如 ohs 与 acl 的命名,也可以认为是代码模型中的一种“统一语言”吧。
|
||||
|
||||
虽然都遵循了领域驱动设计,但限界上下文的通信边界会直接影响到代码模型的设计决策。
|
||||
|
||||
进程间通信的代码模型
|
||||
|
||||
如果限界上下文的边界是进程间通信,则意味着每个限界上下文就是一个单独的部署单元,此即微服务的意义。通常,我们希望一个微服务应该设计为单一职责的高内聚服务,然而麻雀虽小,五脏俱全,在微服务的边界范围内,我认为仍然需要为其建立分层架构。当然,由于微服务的粒度较小,它的代码模型一般采用命名空间级别的方式,整个微服务的代码模型生成一个 JAR 包即可。
|
||||
|
||||
架构的设计需要“恰如其分”,在不同的微服务中,各自的领域逻辑复杂程度亦不尽相同,故而不必严格遵循领域驱动设计的规范。Martin Fowler 在《企业应用架构模式》一书中针对不同复杂度的领域,总结了三种不同的领域建模模式,包括事务脚本(Transaction Script)、表模块(Table Module)或领域模型(Domain Model)。在物理隔离的限界上下文内部,我们可以有针对性地选择不同的领域模型。Scott Millett 的著作《Patterns、Principles and Practices of Domain-Driven Design》就此给出了如下图所示的架构:
|
||||
|
||||
|
||||
|
||||
领域模型不同,代码结构也会受到影响。例如,选择了事务脚本,领域模型就不一定要规避贫血模型,依赖注入也就未必成为必选项了,Repositories 的抽象意义也有所不同。既然本课程讲解领域驱动设计,因此这里主要探讨领域模型的建模方式,即领域驱动战术设计所建议的模式与原则。
|
||||
|
||||
还记得前面在讲解层次的职责与协作关系给出的下订单案例吗?当我们选择第三种方案时,给出的代码模型如下所示:
|
||||
|
||||
ordercontext.infrastructure
|
||||
- OrderController
|
||||
- CreateOrderRequest
|
||||
- OrderMapper
|
||||
- EmailSender
|
||||
- RabbitEventBus
|
||||
ordercontext.application
|
||||
- OrderAppService
|
||||
ordercontext.interfaces
|
||||
- NotificationService
|
||||
- EventBus
|
||||
ordercontext.domain
|
||||
- OrderRepository
|
||||
- PlaceOrderService
|
||||
- Order
|
||||
- OrderConfirmed
|
||||
- Notification
|
||||
- NotificationComposer
|
||||
|
||||
|
||||
|
||||
现在,为了更好地体现限界上下文之间的协作,我们将本例中的邮件通知放到一个单独的限界上下文 Notification Context 中。Order Context 与 Notification Context 之间采用了进程间通信,则遵循前面的建议,修改代码模型为:
|
||||
|
||||
ordercontext
|
||||
- gateways
|
||||
- controllers
|
||||
- OrderController
|
||||
- messages
|
||||
- CreateOrderRequest
|
||||
- persistence
|
||||
- OrderMapper
|
||||
- client
|
||||
- NotificationClient
|
||||
- mq
|
||||
- RabbitEventBus
|
||||
- application
|
||||
- OrderAppService
|
||||
- interfaces
|
||||
- client
|
||||
- NotificationService
|
||||
- SendNotificationRequest
|
||||
- mq
|
||||
- EventBus
|
||||
- domain
|
||||
- PlaceOrderService
|
||||
- Order
|
||||
- OrderConfirmed
|
||||
- Notification
|
||||
- NotificationComposer
|
||||
- repositories
|
||||
- OrderRepository
|
||||
|
||||
notificationcontext
|
||||
- controllers
|
||||
- NotificationController
|
||||
- messages
|
||||
- SendNotificationRequest
|
||||
- application
|
||||
- NotificationAppService
|
||||
- interfaces
|
||||
- EmailSender
|
||||
- domain
|
||||
- NotificationService
|
||||
- Destination
|
||||
- Message
|
||||
- gateways
|
||||
- JavaMailSender
|
||||
|
||||
|
||||
|
||||
与之前的代码模型比较,现在的代码模型去掉了 infrastructure 的概念,改以各种 gateway 来表示。同时,还单独定义了 interfaces 模块,包含各种网关对应的抽象接口。
|
||||
|
||||
代码模型需要考虑 Order Context 与 Notification Context 之间的跨进程协作。设计的目标是确保彼此之间的解耦合,此时可以引入上下文映射的开放主机服务模式与防腐层模式,同时还应避免遵奉者模式,即避免重用上游上下文的领域模型。因此,针对邮件通知功能,在 Order Context 中定义了调用 Notification Context 上下文服务的客户端 NotificationClient 与对应的抽象接口 NotificationService。这两个类型合起来恰好就是针对 Notification Context 的防腐层。Notification Context 定义了 NotificationController,相当于是该限界上下文的开放主机服务。
|
||||
|
||||
Notification Context 定义了自己的领域模型,包括 Destination 与 Message。同时,在 controllers 中定义了服务消息 SendNotificationRequest;Order Context 则针对通知服务的调用,定义了自己的领域模型 Notification,以及匹配服务调用的请求消息对象 SendNotificationRequest。由于 Order Context 与 Notification Context 属于两个不同的微服务,因此在 Order Context 微服务中 gateways/client 的 NotificationClient 会发起对 NotificationController 的调用,这种协作方式如下图所示:
|
||||
|
||||
|
||||
|
||||
由于限界上下文之间采用进程间通信,因此在 Notification Context 中,提供开放主机服务是必须的。倘若 NotificationController 以 RESTful 服务实现,则在 Order Context 发起对 RESTful 服务的调用属于基础设施的内容,因而必须定义 NotificationService 接口来隔离这种实现机制,使其符合整洁架构思想。
|
||||
|
||||
进程内通信的代码结构
|
||||
|
||||
如果限界上下文之间采用进程内通信,需要注意如何在代码模型中体现限界上下文的边界,更关键的则是要考虑两个处于相同进程中的限界上下文彼此之间该如何协作。如下是针对各种设计因素的考量。
|
||||
|
||||
|
||||
简单:在下游限界上下文的领域层直接实例化上游限界上下文的领域类。
|
||||
解耦:在下游限界上下文的领域层通过上游限界上下文的接口和依赖注入进行调用。
|
||||
迁移:在下游限界上下文中定义一个防腐层,而非直接调用。
|
||||
清晰:要保证领域层代码的纯粹性,应该避免在当前限界上下文中依赖不属于自己的代码模型。
|
||||
|
||||
|
||||
综合考虑,如果确有迁移可能,且架构师需要追求一种纯粹的清晰架构,可以考虑在 interface 中定义自己的服务接口,然后在 gateway/client 中提供一个适配器,在实现该接口的同时,调用上游限界上下文的服务,无论这个服务是领域服务还是应用服务,甚至也可以是领域层的领域对象。因为这个调用的事实已经被 interface 中的接口隔离了。
|
||||
|
||||
仍然以下订单场景为例,但此时的 Notification Context 与 Order Context 采用进程内通信,则这种协作方式如下图所示:
|
||||
|
||||
|
||||
|
||||
与进程间通信的唯一区别在于:NotificationClient 不再通过跨进程调用的方式发起对 RESTful 服务的调用,即使在 Notification Context 中定义了这样的开放主机服务。如上图所示,NotificationClient 直接通过实例化的方式调用了 Notification Context 应用层的 NotificationAppService。这是在 Order Context 中,唯一与 Notification Context 产生了依赖的地方。
|
||||
|
||||
如此看来,即使限界上下文采用进程内通信,也仅仅是封装在防腐层中发起调用的实现有所不同,即前面例子中的 NotificationClient,而这其实并不影响代码模型。因而,无论是进程间通信,还是进程内通信,我们设计的代码模型其实是一致的,并不受通信边界的影响。之所以这样设计,理由有二,具体如下。
|
||||
|
||||
|
||||
通信边界的划分是物理意义,代码模型的划分是逻辑意义,二者互相并不影响。
|
||||
为保证系统从单体架构向微服务架构迁移,应保证代码结构不受架构风格变化的影响。
|
||||
|
||||
|
||||
例如,假设本书的域名为 practiceddd,对于一个电商系统,无论限界上下文的边界为进程间通信还是进程内通信,上下文的命名空间都应该为practiceddd.ecommerce.{contextname},其下的层次则是上述提及的代码模型。例如,订单上下文的命名空间为praticeddd.ecommerce.ordercontext,商品上下文的命名空间为praticeddd.ecommerce.productcontext。整个系统的代码结构如下所示:
|
||||
|
||||
- praticeddd
|
||||
-ecommerce
|
||||
- ordercontext
|
||||
- application
|
||||
- interfaces
|
||||
- domain
|
||||
- repositories
|
||||
- gateways
|
||||
- productcontext
|
||||
- application
|
||||
- interfaces
|
||||
- domain
|
||||
- repositories
|
||||
- gateways
|
||||
- ......
|
||||
|
||||
|
||||
|
||||
或许有人会提出疑问,如果 ordercontext 与 productcontext 或者其他限界上下文之间存在共同代码,该如何分配?首先我们要认识到,这里的所有组织单元(层、模块或包)都是围绕着领域逻辑来划分的。之所以在限界上下文之下还要分级划分,原因只是各个组织单元的关注点不同而已,至于一些公共的与外部资源有关的代码,都是系统边界之外的第三方框架或平台,这一点在前面介绍架构演进时已反复提及。
|
||||
|
||||
基于这样的设计前提,如果两个或多个限界上下文还存在共同代码,只能说明一点:那就是我们之前识别的限界上下文有问题!在第17课“上下文映射的团队协作模式”中,我们提到的“共享内核”模式就是用来解决此类问题的一种方法。一旦提炼或发现了这个隐藏的限界上下文,就应该将它单列出来,与其他限界上下文享受相同的待遇,即处于代码模型的相同层次,然后再通过 interfaces 与 gateways/client 下的相关类配合完成限界上下文之间的协作即可。
|
||||
|
||||
|
||||
|
||||
|
208
专栏/领域驱动设计实践(完)/029代码模型的架构决策.md
Normal file
208
专栏/领域驱动设计实践(完)/029代码模型的架构决策.md
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
029 代码模型的架构决策
|
||||
代码模型属于软件架构的一部分,它是设计模型的进化与实现,体现出了代码模块(包)的结构层次。在架构视图中,代码模型甚至会作为其中的一个视图,通过它来展现模块的划分,并定义运行时实体与执行视图建立联系,如下图所示:
|
||||
|
||||
|
||||
|
||||
确定软件系统的代码模型属于架构决策的一部分。在领域驱动设计背景下,代码模型的设计可以分为两个层次,具体如下。
|
||||
|
||||
|
||||
系统层次:设计整个软件系统的代码模型。
|
||||
限界上下文层次:设计限界上下文内部的代码模型。
|
||||
|
||||
|
||||
在领域驱动设计中,限界上下文对整个系统进行了划分,以便于实现“分而治之”的架构设计思想。正如前面几课所述,我们可以将每个限界上下文视为一个自治单元,这个自治单元就像一个独立的子系统,可以有自己的架构。尤其是当我们将一个限界上下文视为一个微服务时,这种控制在边界内的独立架构就显得更加明显。上一课介绍的代码模型,其实是这样的模型设计层次。
|
||||
|
||||
针对整个软件系统,我们可以将这些限界上下文视为一个黑盒子。我们仅需关心限界上下文暴露的接口以及它们之间的协作关系。而对于整个软件系统,则需要保证其在架构风格上的一致性。所谓“风格”,可以参考 Roy Fielding 的定义:
|
||||
|
||||
|
||||
风格是一种用来对架构进行分类和定义它们的公共特征的机制。每一种风格都为组件的交互提供了一种抽象,并且通过忽略架构中其余部分的偶然性细节,来捕获一种交互模式(pattern of interation)的本质特征。
|
||||
|
||||
|
||||
Roy Fielding 对“风格”的定义突出了对架构的分类和公共特征的定义。无论是分类,还是识别公共特征,都是一种抽象。同时,定义中明确指出风格为组件的协作提供了抽象,这说明架构风格并不涉及实现细节。在架构设计时,需要找出那些稳定不变的本质特征,且这个特征与系统的目标还有需求是相匹配的。结合领域驱动设计,限界上下文以及上下文映射就是这样的一种抽象:
|
||||
|
||||
|
||||
如果我们将限界上下文视为微服务,则该系统的架构风格就是微服务架构风格;
|
||||
如果我们将上下文协作模式抽象为发布/订阅事件,则该系统的架构风格就是事件驱动架构风格;
|
||||
如果在限界上下文层面将查询与命令分为两种不同的实现模式,则该系统的架构风格就是命令查询职责分离(CQRS)架构风格。
|
||||
|
||||
|
||||
显然,这些架构风格适应于不同的应用场景,即这些风格的选择应与系统要解决的问题域相关。为了保证整个软件系统架构设计的一致性,我们可以结合 Simon Brown 提出的 C4 模型来考虑设计元素的粒度和层次:
|
||||
|
||||
|
||||
|
||||
自上而下,Simon Brown 将整个软件系统分为了四个层次,分别为系统上下文(System Context)、容器(Containers)、组件(Components)以及类(Classes),这些层次的说明如下所示。
|
||||
|
||||
|
||||
系统上下文:是最高的抽象层次,代表了能够提供价值的东西。一个系统由多个独立的容器构成。
|
||||
容器:是指一个在其内部可以执行组件或驻留数据的东西。作为整个系统的一部分,容器通常是可执行文件,但未必是各自独立的进程。从容器的角度理解一个软件系统的关键在于,任何容器间的通信可能都需要一个远程接口。
|
||||
组件:可以想象成一个或多个类组成的逻辑群组。组件通常由多个类在更高层次的约束下组合而成。
|
||||
类:在一个面向对象的世界里,类是软件系统的最小结构单元。
|
||||
|
||||
|
||||
在系统上下文层次,我们需要考虑的架构因素是将系统作为一个完整单元,然后思考它和用户之间的交互方式是怎样的,需要集成的外部系统有哪些,采用怎样的通信协议?若在这个层次考虑架构风格,就更容易建立架构的抽象体系。例如:
|
||||
|
||||
|
||||
倘若我们采用微服务的架构风格,意味着包括用户和外部系统在内的客户端都需要通过 API Gateway 实现微服务的调用;
|
||||
倘若采用事件驱动架构风格,意味着系统与外部系统之间需要引入消息中间件,以便于事件消息的传递;
|
||||
倘若采用 CQRS 架构风格,意味着系统暴露的 API 接口需要分解为命令接口和查询接口,接口类型不同,处理模式和执行方式都不相同。
|
||||
|
||||
|
||||
C4 模型中的容器基本等同于微服务的概念,推而广之也就代表了限界上下文的概念。其实,容器与组件之间的边界很模糊,这取决于我们对限界上下文之间通信机制的决策。不仅限于此,即使采用了微服务架构风格,我们识别出来的限界上下文亦未必一定要部署为一个微服务。它们可能为整个系统提供公共的基础功能,因而在微服务架构中实际是以公共组件的形式而存在的。
|
||||
|
||||
在代码模型上,这些公共组件又分为两种。
|
||||
|
||||
一种公共组件具有业务价值,因而对应于一个限界上下文,可以视为是支撑子域(Supporting SubDomain)或通用子域(Generic SubDomain)在解决方案上的体现,如规则引擎、消息验证、分析算法等。那么,在微服务架构风格中,为何不将这样的限界上下文部署为微服务呢?这实际上是基于微服务的优势与不足来做出的设计决策。
|
||||
|
||||
|
||||
|
||||
如上图所示,微服务保证了技术选择的自由、发布节奏的自由、独立升级的自由以及自由扩展硬件配置资源的自由。为了获得这些自由,付出的代价自然也不少,其中就包括分布式系统固有的复杂度、数据的一致性问题以及在部署和运维时带来的挑战。除此之外,我们还需要考虑微服务协作时带来的网络传输成本。如果我们能结合具体的业务场景考虑这些优势与不足,就可以在微服务与公共组件之间做出设计权衡。
|
||||
|
||||
基于我个人的经验,我认为当满足以下条件时,应优先考虑设计为微服务:
|
||||
|
||||
|
||||
实现经常变更,导致功能需要频繁升级;
|
||||
采用了完全不一样的技术栈;
|
||||
对高并发与低延迟敏感,需要单独进行水平扩展;
|
||||
是一种端对端的垂直业务体现(即需要与外部环境或资源协作)。
|
||||
|
||||
|
||||
当满足以下条件时,应优先考虑设计为公共组件:
|
||||
|
||||
|
||||
需求几乎不会变化,提供了内聚而又稳定的实现;
|
||||
在与其进行协作时,需要传输大量的数据;
|
||||
无需访问外部资源。
|
||||
|
||||
|
||||
如果找不到支持微服务的绝对理由,我们应优先考虑将其设计为公共组件。
|
||||
|
||||
另一种公共组件并非领域的一部分,仅仅提供了公共的基础设施功能,如文件读写、FTP 传输、Telnet 通信等。本质上,这些公共组件属于基础设施层的模块。如果是多个限界上下文都需要重用的公共模块,甚至可以将该公共组件视为一种基础的平台或框架。这时,它不再属于限界上下文中的代码模型,而是作为整个系统的代码模型。当然,倘若我们将这种公共组件视为基础平台或框架,还可以为其建立单独的模块(或项目),放在专有的代码库中,并以二进制依赖的形式被当前软件系统所使用。
|
||||
|
||||
整体而言,一个典型的微服务架构通常如下图所示:
|
||||
|
||||
|
||||
|
||||
采用微服务架构风格时,诸如 Spring Cloud 之类的微服务框架事实上间接地帮我们完成了整个系统架构的代码模型。例如:
|
||||
|
||||
|
||||
API Gateway 作为所有微服务的访问入口,Euraka 或 Consul 提供的服务注册与发现,帮我们解决了微服务协作的访问功能;
|
||||
Feign 提供的声明式服务调用组件让我们省去了编写防腐层中 Client 的代码实现;
|
||||
Spring Cloud Config 提供了分布式的配置中心等。
|
||||
|
||||
|
||||
这些组件在上面所示的架构中,作为微服务架构的基础设施而存在。当我们使用这样的微服务框架时,就可以让设计与开发人员只需要专注于微服务内部的设计,即领域逻辑的实现,实际上就是对软件复杂度的应对。通过限界上下文(微服务)实现对业务的分而治之,从而降低业务复杂度;而微服务架构自身虽然会带来技术复杂度的增加,但技术复杂度已经转移到了微服务框架来完成,从而整体降低了应用开发人员的开发难度。
|
||||
|
||||
倘若采用单体架构,我们也需保证其向微服务演化的可能。因此,这两种风格的选择对于限界上下文内部的代码模型并无影响。但我们还需要为整个系统建立一个一致的系统架构。为了保证关注点分离,整个系统的架构同样需要进行分层,并遵循整洁架构的思想。
|
||||
|
||||
在对整个系统架构进行分层架构设计时,需要考虑用户展现层、应用层和基础设施层与限界上下文内各层次之间的关系。我认为,限界上下文的范围包含了除用户展现层在外的其他各层。其中,应用层包含了应用服务,由它来完成领域对象的调用以及对其他横切关注点的调用。基础设施层的北向网关提供了对外公开的开放主机服务,通常被定义为 RESTful 服务。那么,对于整个系统架构而言,还需要定义系统层次的应用服务与 RESTful 服务么?如下图所示:
|
||||
|
||||
|
||||
|
||||
如果我们参考微服务架构风格,就会发现上图的控制器层暴露了所有的服务接口,相当于 API Gateway 的功能,上图的应用层用于管理各个限界上下文应用服务的协作,相当于服务注册与发现组件的功能。微服务框架提供的 API Gateway 和服务注册与发现组件仅仅是一个外观(Facade),内部并没有包含任何应用逻辑和领域逻辑。而在单体架构中,由于所有的限界上下文都部署在一个服务中,因而并不需要服务的注册与发现功能;每个限界上下文都有控制器定义了对外公开的 RESTful 服务,且所有的这些 RESTful 服务都绑定到唯一的入口(如 Spring Boot 要求定义的 Application 类)上,区别仅仅是代码模型的隔离罢了,自然也就不需要 API Gateway。故而在系统架构层次,保留二者并没有任何意义。
|
||||
|
||||
当然,也有例外。譬如说我们需要为整个单体架构提供一些与业务无关的 RESTful 服务,如健康检查、监控等。另外,倘若需要组合多个限界上下文的领域模型,似乎也有了保留应用层的必要。Vernon 在《实现领域驱动设计》一书中就提到了这种“组合多个限界上下文”的场景,如下图所示:
|
||||
|
||||
|
||||
|
||||
Vernon 认为:
|
||||
|
||||
|
||||
……应用层不是成了一个拥有内建防腐层的新领域模型吗?是的,它是一个新的、廉价的限界上下文。在该上下文中,应用服务对多个 DTO 进行合并,产生的结果有点像贫血领域模型。
|
||||
|
||||
|
||||
实际上,Vernon 在这里谈到的组合功能,目的是为了组装一个满足客户端调用的服务对象。但我认为定义这样专属的应用服务并非必须。归根结底,这个应用服务要做的事情就是对多个限界上下文领域模型的协调与组装。这种需求必然要结合具体的业务场景,例如订单对象需要组合来自不同限界上下文的商品信息、客户信息、店铺信息等。该业务场景虽然牵涉到多个限界上下文领域模型的协调,但必然存在一个主领域对应的限界上下文。这个限界上下文提供的应用服务才是该业务场景需要实现的业务价值,因此就应该将这个应用服务定义在当前限界上下文,而非整个系统架构的应用层,又或者为其建立一个新的廉价的限界上下文。而在该限界上下文内部,应用层或领域层可以通过防腐层与其他限界上下文协作,共同为这个业务提供支持。除非,这个业务场景要完成的业务目标不属于之前识别的任何一个限界上下文。
|
||||
|
||||
再来考虑用户展现层的场景。假设需要支持多种前端应用,且不同前端应用需要不同的视图模型和交互逻辑。考虑到前端资源有限,同时保证前端代码的业务无关性,我们可以在系统架构层面上,定义一个统一的接口层。这个接口层位于服务端,提供了与前端界面对应的服务端组件,并成为组成用户界面的一部分。在这个接口层中,我们可以为不同的前端提供不同的服务端组件。由于引入的这一接口层具有后端服务的特征,却又为前端提供服务,因而被称之为 BFF(Backends For Frontends,为前端提供的后端)。
|
||||
|
||||
引入的 BFF 往往是为了协调前端开发人员与后端开发人员的沟通协作问题。前端开发人员理解用户界面的设计,后端开发人员却只为垂直领域(即特性)设计服务接口,就使得二者并不能很好地实现模型之间的匹配。既然 BFF 是为前端 UI 提供的,最佳的做法就是让前端开发人员自己来定义。这也是在项目实践中 Node.js 扮演的重要作用,如下图所示:
|
||||
|
||||
|
||||
|
||||
图中为浏览器 UI 调用提供的 UI Layer,即 BFF,它实则是在服务器与浏览器之间增加了一个 Node.js 中间层。各层的职责如下表所示:
|
||||
|
||||
|
||||
|
||||
|
||||
Java
|
||||
Node.js
|
||||
JS + HTML + CSS
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
服务层
|
||||
跑在服务器上的 JS
|
||||
跑在浏览器上的 JS
|
||||
|
||||
|
||||
|
||||
提供数据接口
|
||||
转发数据,串接服务
|
||||
CSS、JS 加载与运行
|
||||
|
||||
|
||||
|
||||
维持数据稳定
|
||||
路由设计,控制逻辑
|
||||
DOM 操作
|
||||
|
||||
|
||||
|
||||
封装业务逻辑
|
||||
渲染页面,体验优化
|
||||
共用模板、路由
|
||||
|
||||
|
||||
|
||||
显然,BFF 的引入虽然是架构决策的一部分,但严格意义上讲,它并不属于后端架构。因而,BFF 的设计并不在领域驱动战略设计的考虑范围之内。
|
||||
|
||||
最后再来考虑基础设施层。除了限界上下文自身需要的基础设施之外,在系统架构层面仍然可能需要为这些限界上下文提供公共的基础设施组件,例如对 Excel 或 CSV 文件的导入导出,消息的发布与订阅、Telnet 通信等。这些组件往往是通用的,许多限界上下文都会使用它们,因而应该放在系统的基础设施层而被限界上下文重用,又或者定义为完全独立的与第三方框架同等级别的公共组件。理想状态下,这些公共组件的调用应由属于限界上下文自己的基础设施实现调用。倘若它们被限界上下文的领域对象或应用服务直接调用(即绕开自身的基础设施层),则应该遵循整洁架构思想,在系统架构层引入 interfaces 包,为这些具体实现定义抽象接口。
|
||||
|
||||
在运用领域驱动设计时,还需要提供遵照领域驱动设计尤其是战术设计要素提供的基本构造块(Building Block),例如对 Identity 的实现、值对象、实体以及领域事件的抽象、聚合根的构造型等。你可以理解为这些构造块组成了支持领域驱动设计的框架。如果没有单独剥离出这个框架,这些构造块也将作为系统代码模型的一部分。
|
||||
|
||||
综上所述,我们选择的架构风格会影响到系统的代码模型。假设我们要设计的系统为 ecommerce,选择单体架构风格,则系统架构与限界上下文的代码模型如下所示:
|
||||
|
||||
practiceddd
|
||||
- ecommerce
|
||||
- core
|
||||
- Identity
|
||||
- ValueObject
|
||||
- Entity
|
||||
- DomainEvent
|
||||
- AggregateRoot
|
||||
- controllers
|
||||
- HealthController
|
||||
- MonitorController
|
||||
- application(视具体情况而定)
|
||||
- interfaces
|
||||
- io
|
||||
- telnet
|
||||
- message
|
||||
- gateways
|
||||
- io
|
||||
- telnet
|
||||
- message
|
||||
- ordercontext
|
||||
- application
|
||||
- interfaces
|
||||
- domain
|
||||
- repositories
|
||||
- gateways
|
||||
- productcontext
|
||||
- application
|
||||
- interfaces
|
||||
- domain
|
||||
|
||||
|
||||
|
||||
如果选择微服务架构风格,通常不需要建立一个大一统的代码模型,而是按照内聚的职责将这些职责分别封装到各自的限界上下文中,又或者定义为公共组件以二进制依赖的方式被微服务调用。这些公共组件应该各自构建为单独的包,保证重用和变化的粒度。如果选择 CQRS 架构风格,就可以在限界上下文的代码模型中为 command 和 query 分别建立 module(领域驱动设计中的设计要素),使得它们的代码模型可以独自演化,毕竟命令和查询的领域模型是完全不同的。基于质量因素的考虑,我们甚至可以为同一个领域的 command 和 query 各自建立专有的限界上下文。在 command 上下文中,除了增加了 command 类和 event 类以及对应的 handler 之外,遵循前面讲述的限界上下文代码模型,而 query 上下文的领域模型就可以简化,例如直接运用事务脚本或表模块模式。
|
||||
|
||||
|
||||
|
||||
|
274
专栏/领域驱动设计实践(完)/030实践先启阶段的需求分析.md
Normal file
274
专栏/领域驱动设计实践(完)/030实践先启阶段的需求分析.md
Normal file
@ -0,0 +1,274 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
030 实践 先启阶段的需求分析
|
||||
从本课开始,我将通过一个完整的真实案例 EAS 系统来展示领域驱动的战略设计过程。通过 EAS 项目,我会把前面讲解的各个知识点贯穿起来,作为实践领域驱动设计的参考设计过程呈现出来。在这个战略设计过程中,曾经因为未曾识别出项目的业务愿景而让需求分析走了一段较长的弯路;因为没有就领域概念形成统一语言,而导致领域建模出现偏差;限界上下文的识别也经历了反复迭代与修改,并经历了领域驱动架构的演进,直至获得相对稳定的领域模型与代码模型。限于篇幅,我无法呈现整个设计过程的完整全貌,但也尽可能将设计过程中遭遇的典型问题、做出的设计决策进行了阐述,并给出了部分设计结果作为参考。
|
||||
|
||||
|
||||
通过访问 GitHub 上的 eas-ddd 项目 获得该项目的介绍与源代码,访问 eas-ddd 项目的 Wiki 可以获得 EAS 项目的需求与项目概况,限界上下文划分;访问问题列表可以获得该项目的任务列表。
|
||||
|
||||
|
||||
背景:企业应用套件
|
||||
|
||||
企业应用套件(Enterprise Application Suite,EAS)是一个根据软件集团公司应用信息化的要求而开发的企业级应用软件。EAS 系统提供了大量简单、快捷的操作接口,使得集团相关部门能够更快捷、更方便、更高效地处理日常事务工作,并为管理者提供决策参考、流程简化,建立集团与各部门、员工之间交流的通道,有效地提高工作效率,实现整个集团的信息化管理。
|
||||
|
||||
EAS 系统为企业搭建了一个数据共享与业务协同平台,实现了人力资源、客户资源与项目资源的整合,系统包括人力资源管理、客户关系管理和项目过程管理等主要模块。系统用户为集团的所有员工,但角色的不同,决定了他们关注点之间的区别。
|
||||
|
||||
实施先启阶段
|
||||
|
||||
先启阶段是软件开发生命周期的准备阶段,力求通过较短的周期让开发团队与客户就系统范围、愿景与目标、主要需求、风险与问题、技术架构和发布迭代计划达成共识。在领域驱动设计过程中,可以将先启阶段当做是需求捕获、场景分析与建立统一语言的一种敏捷实践。
|
||||
|
||||
确定利益相关人
|
||||
|
||||
在制定先启计划时,我们需要先确定利益相关人,EAS 涉及到的组织部门包括人力资源部、市场部、项目管理部、服务中心、财务中心以及各子公司。因此,除了开发团队之外,利益相关人就包括整个集团的决策层,相关部门的负责人与具体操作 EAS 系统的集团员工。在先启阶段执行相关活动时,我们会根据这些活动的情况邀请对应的利益相关人。
|
||||
|
||||
制订先启计划
|
||||
|
||||
先启阶段是一个重要的项目开发环节,也可以视为一个特殊的迭代。尤其是先启阶段需要协调利益相关人和开发团队之间的交流与协作,就需要积极地将利益相关人引入到整个先启阶段,参与具体的先启活动。为此,我们需要事先制订一个明确的先启计划,并与利益相关人确定活动(会议)时间,保证这些提供重要输入的利益相关人都能准时参加。
|
||||
|
||||
EAS 先启计划(仅列出与需求分析有关的活动)如下所示:
|
||||
|
||||
|
||||
|
||||
|
||||
活动
|
||||
活动项目
|
||||
集团决策层
|
||||
子公司负责人
|
||||
人力资源部
|
||||
市场部
|
||||
项目管理部
|
||||
服务中心
|
||||
财务中心
|
||||
项目经理
|
||||
需求分析师
|
||||
技术负责人
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
启动会议
|
||||
项目介绍
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
|
||||
|
||||
启动会议
|
||||
确定业务期望和愿景
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
X
|
||||
X
|
||||
|
||||
|
||||
|
||||
|
||||
启动会议
|
||||
优先级权衡
|
||||
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
X
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
需求
|
||||
对问题域的共同理解
|
||||
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
|
||||
|
||||
需求
|
||||
确定项目的业务范围
|
||||
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
|
||||
|
||||
需求
|
||||
确定业务流程
|
||||
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
|
||||
|
||||
需求
|
||||
确定史诗级故事与主故事
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
X
|
||||
X
|
||||
X
|
||||
|
||||
|
||||
|
||||
|
||||
确定业务期望和愿景
|
||||
|
||||
在确定业务愿景时,我们一开始重点调研了人力资源部、市场部与项目管理部的相关人员,他们都是识别出来的利益相关人。每个部门的员工都向我们提出了切合他们实际需要的业务功能,这些功能包括:
|
||||
|
||||
|
||||
市场部对客户和需求的管理,对合同的跟踪;
|
||||
项目管理部对项目和项目人员的管理,对项目进度的跟踪;
|
||||
人力资源部负责招聘人才,管理员工的日常工作包括工作日志、考勤等。
|
||||
|
||||
|
||||
然而,随着需求的越来越多,我们反而越来越迷茫,仿佛迷失在一张巨细无靡的需求大网中。这张网没有放过任何可能溜走的需求,可需求的详尽非但没有呈现出清晰的业务目标,反而越发的不明朗。看起来,我们撒出了一张威能强大的网,可惜选错了捕捉鱼虾的水域。
|
||||
|
||||
我们需要确定系统的业务期望与愿景,而不是从一开始就沉入到如蛛丝一般细而密的需求细节中。重要的利益相关人是集团管理层,他们只关注整体需求与系统目标,至于各个细节的功能不过是为了完成这一目标才提供的功能支持罢了。这正是先启阶段需要开展的需求活动,正如在[第 1-6 课]中所说:“需要确定项目的利益相关人,并通过和这些利益相关人的沟通,确定系统的业务期望与愿景。在期望与愿景的核心目标指导下,团队与客户才可能就问题域达成共同理解。”
|
||||
|
||||
通过与集团决策层领导的沟通交流,我们最终确定了整个系统的业务期望与愿景:“避免信息孤岛,实现人力资源的可控,从而达到人力资源的供需平衡。” 例如,客户需要集团提供 20 名各个层次的 Java 开发人员,则市场部门在确定是否签订该合同之前,需要通过 EAS 查询集团的人力资源库,了解现有的人力资源是否匹配客户需求。如果匹配,还需要各个参与部门审核人力成本,决定合同标的;如果集团当前的人力资源无法满足客户需求,就需要人力资源部提早启动招聘流程,或从人才储备库中寻找到满足需求的候选人。通过 EAS,管理人员还能够及时了解开发人员的闲置率,跟踪项目的进展情况,明确开发人员在项目中承担的职责和任务完成质量。
|
||||
|
||||
当我们与客户就这个业务期望与愿景达成共识后,它就成为了整个需求分析与建模阶段最重要的指导原则。当我们需要判断某个需求是否有业务价值时,可以参考这一指导原则;当我们无法识别不同用户故事的优先级时,可以借鉴这一指导原则;当我们需要确定核心领域与子领域时,我们可以遵循这一指导原则,这一指导原则可以视为是团队与客户就问题域达成的共同理解。
|
||||
|
||||
优先级权衡
|
||||
|
||||
在面对客户源源不断提出的需求时,最难做出决策的是确定这些需求的优先级。如果是业务需求,我们应该基于系统的业务期望与愿景,判断这些业务需求与实现该愿景的关联程度,并以此来作为优先级的衡量标准。如果是质量属性又或者是管理上的要求,就需要客户和我们一起给出高屋建瓴般的权衡标准。
|
||||
|
||||
一种比较好的实践是采用所谓的“价值滑条(Value Slider)”,即基于该需求协商谈判的可能性高低,列出所有的质量属性需求与管理要求,由客户来做出判断。若协商谈判的可能性高,则说明该需求是可以协商的,可以做出让步的,则滑条向左,意味着优先级低;若协商谈判的可能性低,就说明不可商量,没有妥协的余地,滑条向右,意味着优先级高。在由客户确定每条需求处于这个“价值滑条”的位置时,有一个约束是:任何两个或两个以上的需求对应的滑条都不能出现在同一列上,如下图所示:
|
||||
|
||||
|
||||
|
||||
如果需求对应的滑条可能出现在同一列,就需要客户做出权衡和决策,强迫他们移动滑条的位置,这就意味着调整了它们的优先级。上图作为 EAS 的“价值滑条”,意味着我们必须在规定的“最终期限”交付可用的产品,但是我们可以根据对功能的排期,优先实现高优先级的主要功能,同时也可以在人力不足或周期紧张的情况下,增加人手,并适度降低产品质量。
|
||||
|
||||
对问题域的共同理解
|
||||
|
||||
在先启阶段,对问题域(Problem Solution)的识别其实就是对客户痛点的识别。之所以要开发这个软件,目的就是解决这些痛点,为应对这些问题提供具有业务价值的功能。在识别痛点的过程中,需要始终从业务期望与愿景出发,与不同的利益相关人进行交流,如此才能达成对问题域的共同理解。
|
||||
|
||||
对于集团决策层,要解决“供需平衡”这个痛点,就需要及时了解我们面临哪些客户需求,目前有哪些人力资源可用,这就需要打破市场部、人力资源部与项目管理部之间的信息壁垒,对市场需求、人力资源、项目的信息进行统计,提供直观的分析结果,进而根据这些分析结果为管理决策提供支持。
|
||||
|
||||
市场部员工面临的痛点是如何与客户建立良好的合作关系,快速地响应客户需求,敏锐地发现潜在客户,掌握客户动态,进而针对潜在客户开展针对性的市场活动。市场部员工希望能够建立快速通道,及时明确项目承担部门(即子公司)是否能够满足客户需求,降低市场成本。市场部门还需要准确把握需求的进展情况,跟踪合同签署流程,提高客户满意度。
|
||||
|
||||
人力资源部员工(招聘专员)的痛点是如何制订合理的招聘计划,使得招聘的人才满足日益增长的客户需求,又不至于产生大量的人力资源闲置,导致集团的人力成本浪费。站在精细领域的角度考虑,从潜在的市场需求开始,招聘专员就需要与市场部、子公司共同确定招聘计划,制定计划的依据在于潜在的人力资源需求,包括对技能水平的要求、语言能力的要求,同时也需要考虑目前子公司的员工利用率,并参考历史的供需关系来做出尽可能准确的预测。
|
||||
|
||||
由于集团的项目类型复杂,特别牵涉到外派项目,项目成员不在公司集团内部,对人员的管理成为项目管理部的核心问题。此外,跟踪和了解项目进度不仅仅是项目管理人员的诉求,市场部同样关心,因为这牵涉到他们与客户的合作关系,并影响到客户满意度。
|
||||
|
||||
针对前面对客户痛点的分析,围绕“供需平衡”这一业务期望与愿景,我们可以将 EAS 划分为如下核心子领域:
|
||||
|
||||
|
||||
决策分析
|
||||
市场需求管理
|
||||
客户关系管理
|
||||
员工管理
|
||||
人才招聘
|
||||
项目进度管理
|
||||
|
||||
|
||||
除了这些核心子领域外,诸如组织结构、认证与授权都属于通用的子领域,每个核心子领域都需要调用这些子领域提供的功能。注意,通用子领域提供的功能虽然不是系统业务的核心,但缺少这些功能,业务却无法流转。之所以没有将其识别为核心子领域,实则是通过对问题域的理解分析得来。例如,组织结构管理是保证业务流程运转以及员工管理的关键,用户的认证与授权则是为了保证系统的访问安全,但它并没有直接对“供需平衡”这一业务愿景提供业务价值,在前面的痛点分析中,它们也不是利益相关人亟待解决的痛点。
|
||||
|
||||
在分辨系统的利益相关人时,服务中心作为参与 EAS 的业务部门,主要是为项目及项目人员提供工位和硬件资源。它要解决的是资源分配的问题,虽然在某种程度上可以降低运营成本,但却与我们确定的业务愿景没有直接的关系。因此我们将该子域作为一种支撑子领域。
|
||||
|
||||
通过先启阶段对客户痛点的分析,我们形成了对 EAS 问题域的共同理解:
|
||||
|
||||
|
||||
|
||||
问题域对统一语言的影响
|
||||
|
||||
当我们在分辨市场需求管理这个问题域时,我们认为有几个领域概念是模糊不清的,即合同(Contract)、市场需求(Market Requirement)、客户需求(Client Requirement),它们三者之间的关系是什么?究竟有什么样的区别?如果不为它们建立一个达成一致共识的统一语言,就有可能影响该问题域的领域模型。
|
||||
|
||||
通过与市场部员工的交流,我们发现市场部对这些概念也是模糊不清的,甚至在很多场景中交替使用了这些概念,而没有一个清晰的定义。在与市场部人员的交谈过程中,他们有时候还提到了“市场需求订单”这个概念。例如,在描写市场需求时,他们会提到“录入市场需求”,但同时又会提到“跟踪市场需求订单”和“查询市场需求订单”。在讨论“客户需求”时,他们提到需要为其指定“承担者”,而在讨论“市场需求”时,却从未提及这一功能。这似乎是“客户需求”与“市场需求”之间的区别。对于“合同”的理解,他们一致认为这是一个法律概念,等同于集团或子公司作为乙方和作为甲方的客户签订的合作协议,并以合同要件的形式存在。
|
||||
|
||||
鉴于这些概念存在诸多歧义,我们和市场部人员一起梳理统一语言,一致认为需要引入“订单(Order)”的概念。订单不是需求(无论是客户需求还是市场需求),而是借鉴了电商系统中订单的概念,把客户提出的项目合作视为订单的商品。“客户提出的项目合作”其实就是“客户需求”,相当于是“订单”中的订单项。一个订单可以包含多个“客户需求”,例如,同一个客户可能提出三条需求:
|
||||
|
||||
|
||||
需求1,需要 5 名高级 Java 程序员、10 名中级程序员
|
||||
需求2,需要 8 名初级 .NET 程序员
|
||||
需求3,需要开发一个 OA 系统
|
||||
|
||||
|
||||
虽然是同一个客户,且向市场部同时提出了这些需求,但毫无疑问,这应该是三个不同的需求。但从“订单”的角度来说,这些客户需求都属于同一个订单。这与一个销售订单可以包含多个不同订单项是相似的。当然,一个订单到底包含哪几个客户需求,取决于市场部与客户洽谈合作的业务背景。例如我们也可以将前面提到的需求1和需求2放入到一个订单中,而把需求3单独放到另一个订单。
|
||||
|
||||
在引入“订单”概念后,市场需求与客户需求的区别也就一目了然了。市场需求是市场部售前人员了解到的需求,并未经过评估,公司也不知道能否满足需求,以及该需求是否值得去做。这也是为何市场需求无需指定“需求承担者”的原因。市场需求在经过各子公司的评估以及财务人员的审核后,就可以细化该市场需求,并经过与客户充分沟通后,最后形成订单。这个订单形成了一个初步合作意向,评估通过的每一条市场需求,则转换为订单中的客户需求。
|
||||
|
||||
我们仍然保留了“合同”的概念。“合同”领域概念与现实世界的“合同”法律概念相对应,它与订单存在相关性,但本质上并不相同。例如,一个订单中的每个客户需求可以由不同的子公司来承担(Owner),但合同却需要明确甲方和乙方。订单并没有合同需要的那些法律条款。未签订的合同内容确实有很大部分来自订单的内容,但也只是其中商务合作内容的一部分而已。在确定了订单后,市场部人员可以跟踪订单的状态,并且在订单状态发生变更时,修改对应的合同状态。显然,合同的状态与订单的状态并不一致。
|
||||
|
||||
在我们引入“订单”这个概念后,市场需求管理这个问题域就发生了细微的变化。我们可以将这个问题域更名为订单,也可以将订单领域概念视为解决方案域的组成部分,继续保留市场需求管理这个问题域,而将订单视为限界上下文。
|
||||
|
||||
在先启阶段,我们不一定需要领域建模。不过,当我们在识别问题域时发现领域概念无法形成统一语言时,确实可以就领域概念的定义展开讨论与分析。若发现仍有不清晰的地方,就可以通过可视化的领域模型来打消开发团队与领域专家包括客户在概念认知上的不一致。例如,我们针对市场需求问题域建立了如下的领域模型:
|
||||
|
||||
|
||||
|
||||
这个领域模型非常清晰地表达了订单(Order)与客户需求(Client Requirement)的一对多关系,且客户需求是放在了订单的聚合边界内。合同(Contract)是一个单独的领域概念,但它与订单存在一个弱关联关系。市场需求(Market Requirment)在通过评估(Assessment)后它会成为订单的一个输入,转换为客户需求。在这个领域模型中,我们可以直观地看到客户需求需要指定承担者(Owner),同时订单还会和客户关系管理问题域中的客户(Client)产生关联。显然,这样清晰表达的领域模型有助于我们和领域专家(客户)的沟通,进而针对这些领域概念达成共识,形成统一语言。
|
||||
|
||||
确定项目的业务范围
|
||||
|
||||
之所以要确定项目的业务范围,是为了明确整个系统的边界。明确系统边界是架构设计的重要前提,它一方面可以明确职责划分,了解哪些内容才属于领域驱动设计的范畴;另一方面则可以事先明确当前系统需要与哪些外部系统集成。
|
||||
|
||||
EAS 是软件集团公司的信息化平台,但这个信息化平台是为了解决项目开发的“供需平衡”,因此它围绕着市场需求、人力资源和项目开发为需求主线,其他的信息化产品,如办公自动化(OA)系统、财务系统、工资结算系统等都会作为外部系统与 EAS 的功能集成。明确了这样的业务范围有助于我们甄别需求的边界,并做到功能的收敛。在识别系统的史诗级故事与主故事时,也应该确保这个业务范围边界,同时这个业务范围还会影响到发布与迭代计划的制订。
|
||||
|
||||
确定项目的业务范围还有助于架构层面的考量,通常,我建议引入 C4 模型的系统上下文(System Context)来体现系统的边界。EAS 的系统上下文如下所示:
|
||||
|
||||
|
||||
|
||||
确定了系统上下文后,可以为后续的上下文映射提供重要参考,如上图所示的 OA 系统、财务系统与工资结算系统可以被视为第三方服务而与 EAS 的限界上下文产生协作。依赖的方向决定了我们选择上下文协作的模式。而系统上下文中的考勤机则会作为 EAS 访问的外部资源,需得做好系统与该机器设备的抽象与隔离。
|
||||
|
||||
确定业务流程
|
||||
|
||||
在明确了系统的业务愿景,并就问题域达成共同理解后,我们还需要让主要的业务功能“动”起来。这就需要确定业务流程,因为它可以更好地体现完整全面的领域场景,突出参与者(部门)与用例之间的协作。
|
||||
|
||||
在先启阶段,没有必要将整个系统的所有业务流程都绘制出来,重点是抓住体现业务愿景这个核心价值的主流程。既然 EAS 以“人力资源的供需平衡”为关注核心,因此所有参与方需要执行的主要功能都与该核心价值有关。通过梳理需求,开发团队在与客户充分交流后,抽象出需求方、供应方这两个核心参与者,从而绘制出供需双方的协作示意图:
|
||||
|
||||
|
||||
|
||||
这个协作示意图非常清晰地体现了需求与供应之间的关系,展现了这个核心流程的关键环节。注意,这个协作示意图并非项目开始之前的当前状态(As-Is),而是期望解决供需平衡问题的将来状态(To-Be)。这种协作关系正好体现了打破部门之间信息壁垒的愿望。由此,我们就可以绘制出整个系统的核心流程:
|
||||
|
||||
|
||||
|
||||
作为核心流程的子流程,项目管理流程与招聘流程是更低一级的业务流程。在先启阶段,如果为了获得更加准确的主故事列表,仍然有必要进一步细化这些子流程。从敏捷开发的角度讲,我们也可以将这些流程的细化放到对应迭代的需求分析活动中,以便于尽快完成先启阶段,进入到项目的正式迭代阶段。毕竟在确定了产品的待办项(史诗级故事与主故事)之后,已经足以帮助团队确定发布与迭代计划了。
|
||||
|
||||
|
||||
|
||||
|
318
专栏/领域驱动设计实践(完)/031实践先启阶段的领域场景分析(上).md
Normal file
318
专栏/领域驱动设计实践(完)/031实践先启阶段的领域场景分析(上).md
Normal file
@ -0,0 +1,318 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
031 实践 先启阶段的领域场景分析(上)
|
||||
在先启阶段,我们确定了 EAS 的问题域与核心的业务流程,然后根据业务期望与愿景确定项目的业务范围,明确史诗级故事和主故事。这个过程既有利于我们对项目的整体理解,以便于确定需求列表、排定需求的优先级从而制订发布与迭代计划,又利于对领域进行建模,确定限界上下文和上下文映射,进而设计整个系统的架构。同时,我们又要准确地把握“故事(Story)”的粒度,不至于沉入到过分细粒度的需求实现细节,影响了先启阶段的实施进度。
|
||||
|
||||
所谓故事的层次(粒度)并没有固定的标准,在用户故事地图中,提出了三个层次:
|
||||
|
||||
|
||||
用户活动
|
||||
用户任务
|
||||
用户故事
|
||||
|
||||
|
||||
这里提到的用户活动就相当于史诗级故事,组成用户活动的用户任务相当于主故事,再按照 INVEST 原则继续对其进行分解,就可以获得在敏捷迭代中构成开发任务的用户故事。在[第 2-4 课:运用领域场景分析提炼领域知识(下)]中,我给出了三种不同层次的领域场景分析方法。
|
||||
|
||||
|
||||
|
||||
用例尤其是用例图的抽象能力更强,更擅长于对系统整体需求进行场景分析;
|
||||
用户故事提供了场景分析的固定模式,善于表达具体场景的业务细节;
|
||||
测试驱动开发则强调对业务的分解,利用编写测试用例的形式驱动领域建模,即使不采用测试先行,让开发者转换为调用者角度去思考领域对象及行为,也是一种很好的建模思想与方法。
|
||||
|
||||
|
||||
|
||||
因此,我个人比较倾向于在先启阶段的需求分析过程中,使用用例来表述领域场景。恰好在 Cockburn 的著作《有效编写用例》中,他提到用例的层次包括:概要目标、用户目标和子功能。例如:
|
||||
|
||||
|
||||
|
||||
这里的用户目标就代表着具有业务价值的领域场景,也就是我们需要识别出来的主用例,它由多个子功能组成,它们之间的关系就是主故事与用户故事之间的差别。结合前面分析的问题域和业务流程,我们可以初步获得 EAS 的史诗级故事与主故事。
|
||||
|
||||
史诗级故事和主故事
|
||||
|
||||
结合 EAS 的业务愿景和核心流程,我们通过深入地需求调研,获得了 EAS 的史诗级故事和主故事。
|
||||
|
||||
管理客户关系
|
||||
|
||||
通过对客户全方位信息的统一管理,可以实现市场部工作人员、需求承担部门之间快捷、方便的信息共享,提高工作效率,并且可为集团负责人提供最实时、有效的客户信息,包括潜在客户的信息。这些信息包括客户基本信息、市场部拜访客户的记录还有客户活动记录、客户的背景资料以及客户公司的商务负责人资料。
|
||||
|
||||
管理客户关系的主要目的是改进和维护客户关系,因此它包括如下主故事:
|
||||
|
||||
|
||||
管理潜在客户信息
|
||||
管理客户信息
|
||||
对客户进行分类
|
||||
查询客户信息
|
||||
客户满意度调查
|
||||
维护拜访记录
|
||||
维护客户活动记录
|
||||
|
||||
|
||||
管理市场需求
|
||||
|
||||
市场人员可以收集市场需求,并与客户接触,明确这些市场需求。在创建市场需求后,需要对市场需求进行评估,进行财务核算,从而确定需求订单和客户需求,确定需求承担者。市场人员应随时跟踪需求订单的状态,并及时向客户反馈。同时,市场人员还可以查询市场需求和需求订单。包括的主故事有:
|
||||
|
||||
|
||||
录入市场需求
|
||||
查询市场需求
|
||||
修改市场需求
|
||||
评估市场需求
|
||||
创建需求订单
|
||||
指定客户需求的承担者
|
||||
跟踪需求订单状态
|
||||
更新需求订单状态
|
||||
|
||||
|
||||
管理商务合同
|
||||
|
||||
签订的合同来自于需求订单,一个需求订单可能会创建多份商务合同。在正式签订合同之前,作为合同的创建者,可以向相关部门的负责人发起合同评审流程,并按照评审后的要求修改合同。为了便于对合同的管理,应提供查询合同与跟踪合同进展状态的功能。合同一旦经过甲方、乙方审批通过并正式签订后,就不允许任何角色和用户修改。如果要修改合同,应该是增加一份附加合同。包括如下主故事:
|
||||
|
||||
|
||||
录入合同
|
||||
添加附加合同
|
||||
指定合同承担者
|
||||
更新合同状态
|
||||
为合同添加评论
|
||||
|
||||
|
||||
管理员工
|
||||
|
||||
人力资源部负责对员工基本信息的管理,包括员工技能列表、语言技能、项目经验等,还要管理每位员工的日常考勤。根据组织结构的定义与授权,每位员工的直接管理者还要检查员工工作日志的填写情况,了解每位员工的工作状况。包括如下主故事:
|
||||
|
||||
|
||||
管理员工基本信息
|
||||
将储备人才转为正式员工
|
||||
管理员工的劳务合同
|
||||
追加员工的项目经历
|
||||
考勤
|
||||
填写工作日志
|
||||
|
||||
|
||||
招聘人才
|
||||
|
||||
根据市场部的需求以及集团自我的发展,作为人力资源部的管理人员,需要制定招聘计划,更新和查询招聘状态。每次应聘人员的面试活动(包括电话面试、笔试、技术面试等)以及测评结果都需要记录下来归档。对于每一位应聘者,需要对投递过来的简历进行归档和录入。人力资源部的工作人员可以根据自己需要对储备人才进行分类,从而对简历进行分类管理。包括如下主故事:
|
||||
|
||||
|
||||
制定招聘计划
|
||||
审核招聘计划
|
||||
修改招聘计划
|
||||
查看招聘计划
|
||||
删除招聘计划
|
||||
输入面试记录与测评结果
|
||||
管理储备人才信息
|
||||
管理储备人才简历
|
||||
|
||||
|
||||
管理项目
|
||||
|
||||
集团的项目有两种类型:承包项目和外派项目。不同项目类型的流程是不相同的。承包项目牵涉到对整个项目进度的跟踪,从需求到设计到开发实现和测试的全生命周期管理;外派项目则是人力资源外包,仅仅需要管理外派人员的工作情况即可。项目管理人员需要创建项目,根据项目类型和管理流程选择计划模板制订项目计划,并通过该模块查询和跟踪项目进展情况,更新项目状态。包括的主故事为:
|
||||
|
||||
|
||||
创建项目
|
||||
制订项目计划
|
||||
创建迭代任务
|
||||
分配任务给项目成员
|
||||
更新项目状态
|
||||
更新任务状态
|
||||
为任务添加评论
|
||||
查询项目情况
|
||||
跟踪项目进度
|
||||
查看指定迭代的所有任务
|
||||
|
||||
|
||||
管理项目成员
|
||||
|
||||
一旦立项后,就可以为项目分配项目成员以及分配项目成员的角色,项目管理人员可以对项目成员的信息进行管理。包括如下主故事:
|
||||
|
||||
|
||||
添加项目成员
|
||||
移除项目成员
|
||||
调整项目成员的角色
|
||||
查看项目成员的任务状态
|
||||
|
||||
|
||||
管理工位与硬件资源
|
||||
|
||||
作为服务中心的工作人员,可以管理公司现有的硬件资源信息以及工位信息。通过系统,可以将硬件资源与工位分配给集团的员工,若未分配,则为闲置硬件与工位。包括如下主故事:
|
||||
|
||||
|
||||
管理硬件资源
|
||||
管理工位
|
||||
|
||||
|
||||
决策分析
|
||||
|
||||
作为集团的管理者,需要查看集团各部门的工作情况,包括需求订单情况、项目进展情况、人员利用率等综合报表,并结合各部门具体情况,定期提供日报表、周报表、月报表、季度报表和年报表。
|
||||
|
||||
运用用例分析方法
|
||||
|
||||
在先启阶段的领域场景分析过程中,我们可以运用用例分析方法对 EAS 进行需求分析。用例的驱动力是业务流程与参与者,参考的内容则为业已识别出来的史诗级故事和主故事。同时,在识别用例的过程中,还应该尽量通过用例表达领域知识,力求获得“统一语言”。典型的用例描述是一个动宾短语,体现了参与者在业务场景需要履行的职责,又或者是满足用例规格的业务行为。
|
||||
|
||||
为了保证用例分析方法的简洁,避免在先启阶段出现“分析瘫痪”,我将传统的用例分析方法分为两个步骤。在先启阶段进行领域场景分析时,只需要使用用例图,而非详尽的用例规格说明。至于用例的流程描述则过于死板和繁琐,我建议使用用户故事对需求进行阐述,并作为第二个步骤放在迭代开始后的领域驱动战术设计阶段。
|
||||
|
||||
在绘制用例图时,可以基于识别出来的史诗级故事来绘制,亦可以按照参与业务流程的参与者(Actor)来绘制。无论采用何种方法,这个过程都需要团队与领域专家通力合作,从业务而非技术实现的角度剖析领域需求,最后推导出真正能表达领域概念的用例图。
|
||||
|
||||
在这里,我展现的用例分析方法以参与者(Actor)为用例分析的起点,分析步骤为:
|
||||
|
||||
|
||||
确定业务流程,通过业务流程识别参与者(Actor);
|
||||
根据每个参与者识别属于该参与者的用例,遵循一个参与者一张用例图的原则,保证用例图的直观与清晰;
|
||||
对识别出来的用例根据语义相关性和功能相关性进行分类,确定用例的主题边界,并对每个主题进行命名。
|
||||
|
||||
|
||||
根据业务流程确定参与者
|
||||
|
||||
如果考虑 EAS 的核心业务流程,可以初步识别出如下参与者:
|
||||
|
||||
|
||||
集团决策者
|
||||
市场人员
|
||||
子公司
|
||||
财务
|
||||
人事专员
|
||||
招聘专员
|
||||
人力资源总监
|
||||
面试官
|
||||
项目管理办公室
|
||||
项目经理
|
||||
项目成员
|
||||
员工
|
||||
部门经理
|
||||
服务中心
|
||||
|
||||
|
||||
在识别参与者(Actor)时,要注意以下问题。
|
||||
|
||||
|
||||
参与者不一定是人,可以是一个系统、服务或模块,也可以是一个部门。例如,定时器可以根据事先设定的规则给相关人员发送通知,此时,定时器作为一个组件成为了参与者;项目管理办公室发起项目的立项,此时,项目管理办公室作为一个部门成为了参与者。
|
||||
当参与者为同一部门的不同角色时,可以考虑参与者的泛化关系,也可以理解为完全不同的参与者。例如,招聘专员参与的用例包括“制定招聘计划”、“修改招聘计划”和“审核招聘计划”,但第三个用例只有人力资源总监才具有操作权限。这时,可以认为人力资源总监是招聘专员的一种特化,但亦可以视为两个完全不同的参与者。当人力资源总监在操作前两个用例时,本质是扮演了招聘专员这个参与者在执行。
|
||||
参与者不同于设计模型中的角色(Role),前者来自领域场景,是真实业务场景的参与对象,后者是对职责的抽象。例如,“评论商品”用例的参与者,可以是买家和卖家,但在设计模型中,可以抽象为评论者角色。
|
||||
|
||||
|
||||
根据参与者识别用例
|
||||
|
||||
在识别参与者时,一些用户体验设计的实践是为参与者建立一个用户画像(Persona),即给出更为具体的用户特征和属性,从而得到一个如身临其境一般的场景参与者,然后设身处地思考他或者她是如何参与到这个领域场景中的。无论是否建立用户画像,这种场景模拟的方式对于用例分析都是有帮助的。
|
||||
|
||||
市场人员的用例图
|
||||
|
||||
让我们首先思考“市场人员”这个参与者。作为一家软件外包的集团公司,它与产品销售公司不同,没有售前和售后人员来负责推销商品和开展售后维护,保持与客户之间的良好关系。市场部作为开拓市场、寻找客户合作机会、维持客户关系、开展需求合作谈判的职能部门,承担了需求管理、客户管理和合同管理的职责,而市场人员作为市场部员工,全程参与了从市场需求、客户洽谈到合同签署的整个市场活动全过程。因此,市场人员参与的用例几乎涵盖了客户关系管理与市场需求两个核心子领域。这些用例关系如下图所示:
|
||||
|
||||
|
||||
|
||||
在绘制这个用例图时,我主要参考了以下内容。
|
||||
|
||||
|
||||
识别的主故事:分析这些主故事的用户目标是什么,进而就可以确定应该是哪个参与者发起该用例。
|
||||
对市场人员的调研:与市场人员进行沟通,了解该角色目前的工作任务。
|
||||
业务流程:从组成业务流程的各个环节判断参与者与功能之间的关系。
|
||||
|
||||
|
||||
子公司的用例图
|
||||
|
||||
子公司在 EAS 中,主要作为需求的承担者。承担需求的工作属于项目管理的范畴,真正的参与者是项目经理和项目成员。在核心业务流程中,当市场人员在创建了市场需求后,要由该需求的承担者即子公司负责评估,签订合同时,也需要子公司确认。此时,子公司会以部门作为整体参与到领域场景中,用例图为:
|
||||
|
||||
|
||||
|
||||
在识别“确认合同履行”用例时,我们仔细分析了业务需求。由于子公司是合同的承担者,因此履行需求合约的乙方是子公司而非集团市场部。市场人员会作为需求的委托者草拟合同,并在 EAS 中负责创建合同及上传合同附件。合同的真正签署者是子公司,但这个签订的过程是线下行为,子公司领导只需要在 EAS 系统中完成合同的确认即可。
|
||||
|
||||
在子公司的用例图中,包含了“为合同添加评论”用例,它同时也是市场人员的用例。不同参与者使用相同的用例,这是完全正常的。但它也给我们传递了一个信号,就是在设计模型中,我们可以考虑为该用例抽象一个角色,如“合同评论者”。在编码实现时,该角色可能会作为一个权限角色,用以控制评论合同的功能权限,也可以考虑为其定义一个角色接口。
|
||||
|
||||
财务的用例图
|
||||
|
||||
财务中心的“财务”参与者也参与了市场需求的核心业务流程:
|
||||
|
||||
|
||||
|
||||
根据前面对系统上下文的定义,EAS 的业务范围并未包含工资结算、财务成本核算等。因此在用例图中,财务仅仅负责市场需求的财务核算。
|
||||
|
||||
人事专员的用例图
|
||||
|
||||
我将人力资源部中负责管理员工信息的参与者定义为“人事专员”,员工的基本信息管理与考勤都由他(她)来负责。其用例图为:
|
||||
|
||||
|
||||
|
||||
对于员工的管理,我最初定义的用例为“管理员工信息”;然而,“管理”这个词语稍显宽泛,无法准确表达领域行为。这种过于抽象的用例描述可能会导致我们忽略一些必要的领域概念,并让领域行为变得模糊化。经过与人事专员的沟通,我们一致认为在员工管理的场景中,对员工的管理其实包括以下内容。
|
||||
|
||||
|
||||
办理员工入职:入职体现了领域概念,要好于新建员工的描述。
|
||||
办理员工离职:离职体现了领域概念,要好于删除员工的描述,何况员工的离职未必一定要删除该员工记录。
|
||||
录入员工信息:录入员工基本信息、项目经历、技能、语言能力等。
|
||||
|
||||
|
||||
在主故事列表中,属于人事专员的职责还包括“追加项目经历”。然而,通过深入分析用例,我们发现该用例其实应该发生项目管理过程中,作为“添加项目成员”的扩展用例,一旦员工被加入到项目,就会被触发。
|
||||
|
||||
招聘专员的用例图
|
||||
|
||||
负责招聘的人力资源部员工被定义为“招聘专员”,用例图如下所示:
|
||||
|
||||
|
||||
|
||||
招聘专员在制定或修改招聘计划之后,需要由人力资源总监对计划进行审核,这是两个不同的参与者,人力资源总监的用例图为:
|
||||
|
||||
|
||||
|
||||
除了招聘专员会参与面试过程之外,人力资源部之外的其他员工可能会作为面试官参与面试。无论是招聘专员,还是面试官,都需要在面试之后输入面试记录与面试结果,故而引入了“面试官”参与者:
|
||||
|
||||
|
||||
|
||||
员工的用例图
|
||||
|
||||
集团的每一名员工都需要考勤和填报工作日志。注意在下图,我没有像人事专员与人力资源总监那样,将员工和部门经理定义为两个完全独立的参与者,而是采用泛化关系表达。仔细体味这之间的微妙差别。人力资源总监可以审核招聘计划,但却不会直接去制定或修改招聘计划,他(她)与人事专员之间有一个比较明显的层级关系,对应的是不同的权限。而部门经理就是一名员工,这种泛化关系是确定无疑的。
|
||||
|
||||
|
||||
|
||||
注意用例图中“查询打卡记录”和“查询出勤记录”之间的差异。打卡记录是考勤机每天留存的信息,出勤记录则是根据集团的考勤制度并结合员工的请假信息和打卡记录生成的记录内容。
|
||||
|
||||
工作日志定时器的用例图
|
||||
|
||||
这里还有一个特殊的参与者,在之前识别参与者时被忽略了,那就是提醒填报工作日志的定时器:
|
||||
|
||||
|
||||
|
||||
项目管理办公室的用例图
|
||||
|
||||
项目管理办公室是以部门作为参与者在项目管理场景中出现,它是整个项目的发起者、评审者,也只有它才有权终止项目或结束项目。项目管理办公室参与了整个项目管理流程的监督,但并不参与项目的具体活动:
|
||||
|
||||
|
||||
|
||||
“立项”与“结项”用例是项目流程中的关键节点,由项目管理办公室发起。当立项完成后,一个新的项目就会被创建;项目结项则意味着项目的状态发生变更。如果我们将用例命名为“创建新项目”、“更改项目信息”就不符合项目管理的统一语言。
|
||||
|
||||
注意,“通知项目经理”既作为了“评审项目计划”的扩展用例,又作为了“指定项目经理”的扩展用例。当然,在业务上,虽然同为通知,但通知的内容并不相同。在项目管理场景中,所有与通知有关的用例都作为扩展用例出现;事实上,在所有核心领域场景中,通知用例都不是主用例,毕竟它并不参与核心业务。
|
||||
|
||||
项目成员的用例图
|
||||
|
||||
项目成员与项目经理之间存在泛化关系,因为当项目经理在创建(编辑)一个问题(Issue)时,就是作为一名项目成员执行的操作;二者的差异还是角色不同导致的权限差异。项目成员的用例图如下所示:
|
||||
|
||||
|
||||
|
||||
我们在识别史诗级故事和主故事时,使用了“任务(Task)”来表达项目管理过程中分配给项目成员的工作;而在用例图中,我们却改为了“问题(Issue)”。“问题”是对任务、史诗故事、用户故事、缺陷的一个抽象,这是在项目管理领域中得到公认的领域概念。任务这个词语其实是与用户故事(User Story)、史诗故事(Epic)、缺陷(Defect)属于同一等级的概念,根据“单一抽象层次原则”,使用“任务”进行抽象显然不再合适。
|
||||
|
||||
当我们创建一个问题时,需要指定问题的基本属性,如问题的标题、描述、问题类型等。那么,问题所属的迭代、承担人(Owner)、报告人(Reporter)是否也作为问题的属性呢?我们在设计用例图时,确实困惑不已,甚至考虑过将上图中“指定问题所属的迭代”与“分配问题给项目成员”用例作为“创建问题”、“编辑问题”的包含用例。经过思索再三,最终还是认为这两个用例是有用户目标的,即提供了明显的业务价值,应该将其作为主用例,与项目成员之间存在“使用(Use)”关系。同样的,“更新问题状态”也没有出现在最初的用例图中,但实际上它与“编辑问题信息”有着完全不同的用户目标,有必要成为项目成员的主用例。
|
||||
|
||||
项目经理的用例图
|
||||
|
||||
在项目经理用例图中,“指定问题报告人”用例也是出于同样的考虑因素:
|
||||
|
||||
|
||||
|
||||
在项目经理的用例图中,最初我并没有识别出“跟踪问题进度”用例。后来,我发现我将“查询问题”与“跟踪问题进度”二者混为一谈了,这其实是不正确的。“查询问题”用例是查询符合各种搜索条件的问题,例如查询当前迭代的所有问题,查询当前迭代所有未完成的问题,查询项目成员的所有问题等;“跟踪问题进度”的着眼点是了解当前问题的完成情况,是对进度的跟踪;二者有着不同的用户目标。
|
||||
|
||||
服务中心的用例图
|
||||
|
||||
“服务中心”也是一个部门作为领域场景的参与者,该参与者的用例非常清晰,就是针对工位和硬件资源的管理:
|
||||
|
||||
|
||||
|
||||
集团决策者的用例图
|
||||
|
||||
最后是“集团决策者”,该参与者的用例主要是查看表达供需关系的统计报表:
|
||||
|
||||
|
||||
|
||||
就像写真时,为求画面的真实准确,必须寻找一个唯一的坐标一样,绘制用例图的唯一参考坐标就是参与者(Actor)。每个参与者的用例图或许大小不一,粒度不均,但自身是完全独立的,参与者之间(除了存在泛化关系的参与者)的用例图互不干扰,清晰地勾勒出各自观察视角得到的领域行为。
|
||||
|
||||
|
||||
|
||||
|
132
专栏/领域驱动设计实践(完)/032实践先启阶段的领域场景分析(下).md
Normal file
132
专栏/领域驱动设计实践(完)/032实践先启阶段的领域场景分析(下).md
Normal file
@ -0,0 +1,132 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
032 实践 先启阶段的领域场景分析(下)
|
||||
如何有效地识别参与者的用例
|
||||
|
||||
前述内容通过用例形式将所有的主故事都转换成了与参与者有关的用例,那么,在识别用例时,是否有什么经验可循呢?
|
||||
|
||||
用例关系的确定
|
||||
|
||||
一个用例图,往往体现了参与者与用例之间的使用关系,用例与用例之间的包含或扩展关系,有时候还存在用例之间的泛化关系,确定用例之间的关系很重要。在识别用例时,思考参与者与用例之间的关系会成为一个不错的设计起点。尤其在先启阶段,我们识别的用例体现了 Cockburn 提出的用例层次中的用户目标层,这恰好对应用例与参与者的“使用(Use)”关系。从领域场景分析的角度看,这个使用关系代表了业务价值。在确定了参与者后,你就可以结合主故事与领域场景,询问自己:“在这个领域场景下,该参与者的用户目标是什么?”由此,可以帮助我们确定该用例是否主用例。
|
||||
|
||||
正如对项目用例图中的分析,如果考虑编码实现的本质,则问题所属迭代、承担人以及问题状态都是问题(Issue)的属性;然而在用例图中,我却以“指定问题所属的迭代”、“分配问题给项目成员”、“更新问题状态”此三个主用例与“编辑问题信息”平级,因为它们在项目管理中都具有不可替代的业务价值。
|
||||
|
||||
与之相反,包含用例与扩展用例是为具有业务价值的主用例提供支持和服务的,识别它们既可以丰富和完善业务逻辑,又可以在后续的用例边界找到属于通用子领域或支撑子领域的业务内容。这些不直接提供业务价值的用例恰好可能组成单独的限界上下文。例如,在前面给出的诸多用例图中,诸如“上传附件”、“通知评估人”等用例主要以扩展用例的形式呈现,这些扩展用例体现了各自内聚的关注点,即文件共享与消息通知。
|
||||
|
||||
包含用例与扩展用例之间的区别在于两个用例之间的“粘性”。包含用例为主用例不可缺少之业务环节,如“指定项目经理”包含用例之于“立项”主用例,如果缺少了指定项目经理操作,立项就是不完整的。扩展用例为主用例功能之补充,如“通知立项”扩展用例之于“立项”主用例,即使没有通知立项的相关干系人,也不妨碍立项工作的完成。作为包含用例或扩展用例本身,又可以有属于自己的包含用例或扩展用例,例如“通知项目经理”对“指定项目经理”的扩展:
|
||||
|
||||
|
||||
|
||||
从功能相关性看,“立项”与“指定项目经理”用例是强相关的,“通知立项”与“立项”用例是弱相关的。因此,对包含和扩展用例的识别往往会影响到后续对限界上下文的识别。
|
||||
|
||||
在识别用例图时,还要注意避免错误的用例关系识别。例如,在项目管理用例图中,团队最初为项目成员参与者识别出“接收问题分配”用例。结合业务场景对此进行检验:当项目经理将问题分配给项目成员后,在业务上确乎存在接受问题的行为;但该行为其实是一个线下行为,属于项目成员之间的一个口头表达;当问题分配给项目成员之后,就已经意味着该问题已经被项目成员接受。因此,这个用例是不合理的。
|
||||
|
||||
用例名应字斟句酌
|
||||
|
||||
在领域场景分析过程中,如果我们只满足于用例图的获得,无异于买椟还珠。用例图仅仅是我们获得的分析结果,但更重要的是我们获得用例图的过程,这其中的关键在于团队与领域专家的交流与合作。作为UML(Unified Modeling Language,统一建模语言)组成部分的用例图,已经得到行业的认可。无论是没有技术背景的领域专家,还是没有业务背景的技术专家,都能很好理解用例图这种可视化的建模语言。
|
||||
|
||||
用例名是领域知识的呈现,更是统一语言的有效输入。用例名应采用动宾短语,描述时须字斟句酌,把握每一个动词和名词的精确,动词是领域行为的体现,名词是领域概念的象征,进而这些行为与概念就能再借助领域模型传递给设计模型,最终通过可读性好的代码来体现。当然,在给出中文用例的同时,还应提倡以英文来表述,毕竟在最终的代码层面,还是用英文来“说话”。
|
||||
|
||||
在项目管理用例图中,我们最初给出的用例为“查看问题完成情况”,但在项目管理领域,所谓“问题完成情况”仅仅体现了问题的状态,却没有清晰地表达问题在迭代周期内的过程。准确的术语是“进度(Progress)”,命名为“跟踪问题进度(Tracking Issue Progress)”更加符合该领域的统一语言。在最初识别用例时,对于“创建问题”的包含用例而言,最初命名为“问题检查”。这个描述未遵循动宾短语的形式,而“检查”一词也容易带来歧义,会错以为是项目成员检查问题的完成情况,实则是对创建的问题进行合规性验证,更名为“验证问题有效性”更为合理。
|
||||
|
||||
再以员工管理用例图中的“提交工作日志”为例。企业的内部术语为“日志报工”,若以统一语言的角度讲,似以“日志报工”用例名为佳。然而在英文中,并无“报工”的恰当翻译,更为人接受的英文名为“Submit Work Log”,因而用例还是应命名为“提交工作日志”。
|
||||
|
||||
显然,通过对用例的不断打磨,对存有疑惑的用例,通过可视化的用例图与领域专家不断沟通,借助用例规格的设计指导,可以帮助我们发现问题,并进一步挖掘出准确的领域术语,建立系统的统一语言,并为后续识别限界上下文以及领域建模奠定基础。
|
||||
|
||||
识别用例的主题边界
|
||||
|
||||
在绘制用例图时,除了参与者、用例以及用例之间的关系外,还有一个非常重要的要素:主题边界(Subject Boundary)。主题边界包含了一组高内聚的用例,并需要设计者为这个边界确定一个主题(Subject)。显然,主题的确定恰好就是对用例的归类,至于归类的原则,正是[第 3-4 课 识别限界上下文]中提及的两个方面:
|
||||
|
||||
|
||||
语义相关性
|
||||
功能相关性
|
||||
|
||||
|
||||
语义相关性
|
||||
|
||||
通过语义相关性来判别用例是否存在高内聚,是一种业务分析手段。就好像我们整理房间一般,相同类别的物品会整理放在一处,例如衣服类,鞋子类,书籍类……每个类别其实就是所谓的“主题(Subject)”。在前面识别用例时,我就要求针对用例名要字斟句酌。用例名通常为动宾短语,宾语往往体现了领域概念。显然,在用例名中如果包含了相同领域概念,就可以认为是语义相关的,就可能归类到同一个主题中。
|
||||
|
||||
在识别用例的主题边界时,我抛开了用例图的约束,选择将用例图直接以主题边界进行划分,不再继续保留参与者与用例、以及用例之间的关系。如下图是对合同(Contract)主题的识别:
|
||||
|
||||
|
||||
|
||||
这种对用例的可视化方式可以认为是用例图的另一种视图,即“主题视图”,主要表现用例的分类和相关性,属于领域场景分析中的 Where 要素;而之前给出的用例图则为参与者视图,表现了参与者、用例之间的协作关系,属于领域场景分析中的 Who、Why 与 What 要素。两种不同的用例视图可以提供不同的参考价值,同时又保障了用例可视化的清晰度。
|
||||
|
||||
仔细分析合同的主题视图,我们发现在这个主题边界中的所有用例,用例名都包含了“合同(Contract)”这个领域概念,这就是所谓的“语义相关性”。有时候,这种语义相关性并没有这么直接,需要就领域概念的共同特征进行归纳,例如,市场(Marketing)主题:
|
||||
|
||||
|
||||
|
||||
在这个主题中,包括了市场需求、需求订单、客户需求等领域概念,我们却不能分别为其建立主题,毕竟这样建立的主题太过散乱而细碎。这时,就需要针对领域概念,建立抽象,即寻找这些领域概念的共同特征。显然,无论是市场需求,还是经过评估后形成的订单及客户需求,都是为一种更高的抽象层次“市场”服务的。
|
||||
|
||||
通过语义相关性判断用例的归属时,一个用例有可能包含两个语义,这时就需要判断语义与主题相关性的强弱。例如,“从储备人才转为正式员工”用例,究竟属于储备人才主题,还是员工主题?判断语义的相关性强弱时,可以依据用例的业务价值或用户目标,应优先考虑满足用户目标的语义。显然,“从储备人才转为正式员工”用例的用户目标是生成员工记录,储备人才的信息仅仅作为该领域行为的输入,答案不言而喻。
|
||||
|
||||
功能相关性
|
||||
|
||||
领域概念是名词,而用例则是动词,表达了一种领域行为。在确定用例的主题边界时,如果我们发现一些用例虽然在领域概念上没有明显的语义相关性,但它们却服务于一个共同的用户目标或业务价值,则说明它们是功能相关的。例如,考勤(Attendance)主题:
|
||||
|
||||
|
||||
|
||||
功能相关性还体现于用例之间的关联与依赖,在用例图中,主要以用例关系的包含、扩展与泛化来体现。例如,人事专员用例图:
|
||||
|
||||
|
||||
|
||||
与员工管理功能相关的子用例包括:
|
||||
|
||||
|
||||
上传员工劳务合同
|
||||
从储备人才转为正式员工
|
||||
录入项目经历
|
||||
录入技能信息
|
||||
录入语言能力
|
||||
|
||||
|
||||
那么,员工主题就应该包含以上功能相关的用例:
|
||||
|
||||
|
||||
|
||||
在确定用例之间关系时,我提到了包含与扩展不同的“粘度”。在确定功能相关性时,尤其要特别关注主用例的扩展用例。根据我的经验,大多数扩展用例提供了不同于主业务视角的关注点,而这些关注点往往在支撑子领域场景中提供了共同的业务价值,可以对它们做进一步抽象。在 EAS 中,这样的扩展用例主要体现在两个方面:文件上传与下载、消息通知,故而可以为其分别建立主题边界:文件共享(File Sharing)与通知(Notification)。
|
||||
|
||||
|
||||
|
||||
注意文件共享与通知主题中的用例名,描述了与具体领域场景无关的通用业务。例如,在招聘专员用例图中,定义了“上传储备人才简历”和“通知招聘计划审核人”两个用例,它们实际上分别对应了“上传 Word 文件”与“发送通知电子邮件”、“发送站内信息”用例。之所以这样描述用例名,是因为这两个主题可能会为通过其他主题提供业务支撑,一旦具体化,就无法满足通用要求。
|
||||
|
||||
文件共享和通知主题中的用例并没有出现在之前识别的用例图中。通过参与者识别用例图时,我们是根据先启阶段识别出来的核心业务流程、史诗级故事与主故事,并通过设想参与者参与的领域场景,进而驱动得出这些用例。文件共享和通知主题中的用例则是通过寻找所有的扩展用例,进而归纳出它们的共同特征。这是两种迥然不同的用例分析方法。
|
||||
|
||||
设计的决策
|
||||
|
||||
无论是寻找领域概念的共同特征,还是识别用例行为的用户目标,都需要一种抽象能力。在进行抽象时,可能出现“向左走还是向右走”的困惑。这是因为抽象的层次可能不同,抽象的方向或依据亦有所不同,这时就需要做出设计上的决策。例如针对用例中识别出来的“员工”与“储备人才”领域概念,我们可以抽象出“人才”的共同特征,从而得到人才(Talent)主题:
|
||||
|
||||
|
||||
|
||||
然而从共同的用户目标考虑,储备人才又是服务于招聘和面试的,似乎归入招聘(Recruiting)主题才是合理的选择:
|
||||
|
||||
|
||||
|
||||
实际上还有第三种选择,就是将储备人才单独抽离出来,形成自己的“储备人才(Candidate)”主题:
|
||||
|
||||
|
||||
|
||||
该如何抉择呢?我认为须得思考为什么要识别主题边界?显然,这里识别的主题边界仅仅是设计过程中的中间产物,并非我们最终的设计目标。主题边界是对用例的分类,在用例图中体现了用例的边界,而这种边界恰好可以对应领域模型的限界上下文,并为设计模型的包、模块提供设计指导。因此,究竟为人才主题,还是招聘主题,或者单独的储备人才主题,完全可以从限界上下文或者领域建模的角度去思考。
|
||||
|
||||
主题边界体现用例的内聚性
|
||||
|
||||
主题边界并不以边界内用例的多寡为设计准则。至少在进行领域场景分析时,不要因为一个主题边界包含了太多的用例,就人为地对其进行更细粒度的拆分,关键还是要考察用例的内聚性。例如与项目管理有关的主题,包含的用例数量就非常不均匀。项目(Project)主题包含的用例为:
|
||||
|
||||
|
||||
|
||||
问题(Issue)主题包含的用例为:
|
||||
|
||||
|
||||
|
||||
项目成员(Project Member)主题包含的用例最少:
|
||||
|
||||
|
||||
|
||||
识别主题边界不是求平衡,更不是为了让设计的模型更加好看,它的设计质量可能会直接影响到后续的限界上下文识别。或许内聚性的识别需要较强的分析能力和抽象能力,但只要我们遵循领域场景分析的设计思想,按部就班地通过业务流程识别参与者,再根据参与者驱动出清晰表达的用例图,最后再根据语义相关性和功能相关性识别主题边界,就能获得一个相对不错的场景分析结果。毕竟,这个分析过程是有章可循的,在知识的积累上也是层层递进的。整个过程不需要任何与技术实现有关的知识,非常利于领域专家与团队的共同协作和交流。
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user