实施DDD所面临的挑战
在实施DDD的过程中,挑战是不可避免的。那么,有人成功过吗?DDD都有哪些常见的挑战,我们又如何处理它们?我将讨论以下三点最常见的挑战:
• 为创建通用语言腾出时间和精力
• 持续地将领域专家引入项目
• 改变开发者对领域的思考方式
使用DDD最大的挑战之一便是:我们需要花费大量的时间和精力来思考业务领域,研究概念和术语,并且和领域专家交流,以发现、捕捉和改进通用语言。如果你想完全采用DDD来最大化业务价值,你需要做出很多努力,并且花费很多时间。事实就是这样的。
要将领域专家引入你的项目恐怕也不是一件易事。但是不管有多么困难,这是你必须做的。如果你连一个领域专家都找不到,那么你根本无法对一个领域有深入的理解。当你找到领域专家的时候,此时开发者应该表现出主动。开发者应该找领域专家交谈并仔细聆听,然后将你们的谈话转化成软件代码。
如果你所工作的领域和业务相去甚远,领域专家所了解的也只是一些边边角角,那么此时你应该将这种问题暴露出来。在我曾经工作的一个项目里,真正的领域专家很难找到,有时他们还会到处出差,我得等上好几周才能和他们开上一次会。在一些小型的公司里,领域专家通常是CEO或者副总裁,他们的事情太多了,这时你也别指望他们能做好你的领域专家。
牛仔的逻辑
AJ:“如果你逮不到那头公牛,你就得挨饿咯!”
引入领域专家需要创造性……
如何在项目中引入领域专家
咖啡。使用这种通用语言:
“Hi,Sally,我给你泡了一杯泡沫牛奶咖啡,你有时间聊聊……?”
学习C级经理使用的通用语言:“……利润……收入……竞争优势……市场优势。”
多数开发者在采用DDD时都需要转变自己思考问题的方式。作为开发者,我们都是技术思想者,技术实现对于我们来说并不是什么难事。我并不是说技术地思考不好,只是说有时少从技术层面去思考会更好。这么多年来,我们都习惯了单从技术层面完成软件开发,那么现在,是时候考虑一种新的思考方式了。为你的业务领域开发一门通用语言便是一个好的出发点。
牛仔的逻辑
LB:“那家伙的靴子太小了,如果他不换双新的,他的脚指头可能要受罪了。”
AJ:“对,如果他不听的话,就有他好受的了。”
在DDD中,我们会谈及到对概念的命名。对于概念命名而言,我们有更高层面的要求。当我们对一个领域进行建模时,我们需要仔细地考虑什么样的对象做什么样的事情,这是关于对象行为设计的。我们希望对对象行为的命名能够传达准确的业务含义,也即反映通用语言。要达到这样的目的,肯定不是先在类上定义属性,然后向客户端代码暴露getter和setter那么简单。
现在让我们来看看一个更有趣的领域,这个领域比之前那个Customer例子更具挑战性。这里,我刻意重复一下先前所讲的。
如果我们只是对领域模型提供getter和setter会怎么样?答案是,结果我们只是在创建纯数据模型。看看下面的两个例子,自己思考一下,哪一个在设计上是欠妥的,哪一个对客户代码更有益。在这两个例子中是一个Scrum模型,我们需要将一个待定项(Backlog Item)提交到冲刺(Sprint)中去。这样的事情你可能一直在做,因此对这个领域你应该是很熟悉的。
第一个例子,通常的做法,使用属性访问的方式:
客户代码如下::
第二个例子使用了领域对象的行为,这种行为表达出了领域中的通用语言:
此时的客户代码如下:
第一个例子采用的是以数据为中心的方式,此时客户代码必须知道如何正确地将一个待定项提交到冲刺中。这样的模型是不能称为领域模型的。如果客户代码错误地修改了sprintId,而没有修改status会发生什么呢?或者,如果在将来有另外一个属性需要设值时又该怎么办?我们需要认真分析客户代码来完成从客户数据到BacklogItem属性的映射。
这种方式同时也暴露了BacklogItem的数据结构,并且将关注点集中在数据属性上,而不是对象行为。你可能会反驳道:“setSprintId()和setStatus()就是行为啊。”问题在于,这里的“行为”没有真正的业务价值,它并没有表明领域模型中的概念——此处即“将待定项提交到冲刺中”。开发者在开发客户代码时,他并不清楚到底需要为BacklogItem的哪些属性设值,而这样的属性有可能存在很多,因为这是一个以数据为中心的模型。
现在,我们来看看第二个例子。有别于第一个例子,它将行为暴露给客户,行为方法的名字清楚地表明了业务含义。这个领域的专家在建模时讨论了以下需求:
允许将每一个待定项提交到冲刺中。只有在一个待定项位于发布计划(Release)中时才能进行提交。如果一个待定项已经提交到了另外一个冲刺中,那么需要先将其回收 。提交完成时,通知相关客户方。
在第二个例子中,客户代码并不需要知道提交BacklogItem的实现细节。实现代码所表达的逻辑恰好能够描述业务行为。我们很容易地添加了几行代码,以确保在发布计划之外的待定项是不能被提交的。诚然,在第一个例子中,你可以修改setter以达到同样的目的,但此时该setter的职责便不单一了,它需要了解BacklogItem对象的内部状态,而不再只是对sprintId和status属性赋值。
这里还有一个微小的区别。如果一个待定项已经被提交到了另外的冲刺中,那么我们应该先从那个冲刺中回收该待定项。这一点也是重要的,因为当一个待定项从冲刺中回收时,将有领域事件发出以通知客户方:
允许从冲刺中回收任何一个待定项,回收时通知相关客户方。
此时,我们并不需要关心如何发布回收事件,因为uncommitFrom()方法会为我们处理这些。而commitTo()方法甚至都不知道发布回收事件这码事,它只需要知道,在将待定项提交给一个新的冲刺时,必须先将该待定项从它当前所在的冲刺中回收。另外,commitTo()的领域行为还包括:在提交待定项完毕后,以事件形式通知相关客户方。如果不是这个富含行为的BacklogItem,我们得在客户代码中发布领域事件,这显然是一种领域逻辑的泄漏。
很明显,在第二个例子中,我们对BacklogItem有了更多的思考,但同时我们也获得更多的回报。沿着这条路往下走,我们将越走越容易。到后来,我们肯定会需要更多的思考、付出和团队协作,但是这并不会使DDD变得笨重。
白板时间
• 对于你目前正在工作的业务领域,思考一下模型中的通用术语和业务操作。
• 将术语写在白板上。
• 然后,将项目中所用到的短语也写下来。
• 与真正的领域专家交流一下,看看哪些词汇是可以改善的(记得带上咖啡哦)。
为领域建模正名
通常来说,战术建模比战略建模复杂。因此,如果你打算采用DDD的战术模式(聚合、领域服务、值对象和领域事件等)来建立领域模型的话,你需要更仔细的思考和更大的投入。那么,我们有什么理由依然要采用战术建模呢?我们又拿什么标准来衡量在DDD上的投入是值得的呢?
你可能已经在盘算,这将把你带到一个陌生的领地,你发现你得好好研究一下周边的情况。你的团队可能会学着既有的线路图,甚至开辟一条新路来决定自己的战略设计方案。你可能会仔细捉摸这片新的领地,然后试图使其为你所用。然而,不管你事先做了多少准备,这都将是一条荆棘丛生之路。
如果你发现你需要在战略的岩石上攀爬,那么你得找到一套合适的战术工具来辅助你。站在低处往上看,你有可能看到一些特别的挑战和危险地带。然而,如果不爬到那样的高度,你又是看不清楚的。你可能需要在坚硬的岩石上打孔插钉,但是也可以找到那些自然形成的裂缝。你可能还需要带上锁环以保证安全。你可以沿着一条路线顺直而上,也可以打点布阵、步步为营。有时随着岩石形状的走势,你可能需要往回撤,再重新设计路线。有人认为攀岩是种危险的运动,但是那些尝试过的人会告诉你,攀岩实际上比驾驶汽车和飞机还安全。攀岩者需要知道如何使用工具和运用好技能,并且能够根据岩石状况做出相应的反应。
如果说开发一个业务子域(Subdomain,2)就像攀岩一样困难,那么我们需要随身携带DDD的战术模式来武装自己。对于满足核心域标准的业务来说,我们不应该将战术模式拒之门外。半途而废的项目时有发生,而正确的战术模式可以帮助我们减少这种情况的发生。
这里是一些实际的指导意见,我会先讲高层次的,然后讲更具体的:
• 如果一个限界上下文被当成核心域来开发,那么从战略上来说,这个限界上下文对业务的成功是极其重要的。核心模型是不易理解的,需要不断地尝试和重构。通过持续改进,我们可以延长它的效用生命,这样的做法显然是值得的。当然,这个限界上下文不见得始终是你的核心域。即便如此,如果它是复杂的,创新性的,并且需要在不断的变化中持续存在很长时间,我们还是建议在该限界上下文中使用战术模式。这里,我们假设你的核心域是值得配置最好的开发者的。
• 一个领域,对于消费方来说有可能成为通用子域(Generic Subdomain,2)或者支撑子域,但是却有可能成为你自己的核心域。我们并不站在最终消费方的角度来评价一个领域。如果你正在开发的限界上下文是你主要的业务,那么它便是你的核心域,而不管消费方是如何看待的。此时,一定记得使用战术模式。
• 如果你正开发一个支撑子域,但是由于种种原因,该支撑子域不能从第三方的通用子域直接获得,那么此时战术模式将帮上你大忙。在这种情况下,你需要考虑团队成员的技能水平,还有模型是否具有创新性。如果此时的模型增加了特定的业务价值,而且不只是拥有技术上的绚丽,那么该模型就可以认为是创新性的。如果团队有能力实施战术设计,这个支撑子域又是创新性的,并且将持续存在很长时间,那么此时便是采用战术设计的大好时机。尽管如此,这并不能使该子域称为核心域,因为在业务人士眼中,这样的领域只是支撑性的。
对于有丰富DDD经验的开发者来说,上面的指导建议可能就不够了。如果你的团队经验丰富,其中的开发者又确信战术建模是种好的选择,那么此时他们的意见可能就更值得相信。诚实的开发者,不管经验丰富与否,都会在特定的情况下明确地指出领域建模是否为最佳的选择。
业务领域的类型本身并不自动地决定应该选择哪种开发方式。你的团队应该考虑一些重要的问题,然后做出决定。请考虑以下因素,这些因素和上面提到的高层次指导是有对应关系的。
• 团队是否有领域专家,如果有,你如何围绕领域专家组织自己的团队?
• 虽然就目前来说,你的业务领域是简单的,但它将来会变得复杂吗?对于复杂的系统来说,使用事务脚本是存在风险的。当领域变得复杂时,是否有可能将系统重构到富含行为的领域模型?
• DDD的战术模式是否可以简化与其他限界上下文的集成,不管是第三方的还是定制开发的?
• 使用事务脚本是否的确可以减少代码量?(经验表明,不管是对于哪种开发方式,事务脚本都不能减少代码量。这可能是由于在项目计划阶段,领域复杂性并没得到正确的认识所致。因此,我们需要在领域复杂性上下足功夫。)
• 你项目的进度安排是否允许在战术模式上有所投入?
• 在核心域上的战术投入能否消除架构变化所带来的影响?事务脚本是做不到这一点的(和领域模型层相比,其他层更易受到架构变化的影响)。
• 客户是否的确能从这种持续设计和开发的方式中获益,或者有现成的产品就能满足他们的需求?换句话说,我们是否应该一开始就考虑定制化开发?
• 使用DDD的战术开发模式会比其他开发方式更加困难吗,比如事务脚本?(这个问题很大程度上取决于团队成员的技能水平和是否有领域专家。)
• 如果团队已经具备了实施DDD的条件,我们还会刻意地选择另一种开发方式吗?有些开发者已经将模型的持久化变得很实用了,比如使用ORM、全聚合序列化和持久化、事件存储(Event Store)、或者战术DDD框架等。但是我们也不能排除还有热衷于其他开发方式的开发者。
上面的列表项并没有先后顺序,而你自己也可以制定另外的衡量标准。你应该知道哪些开发方法对你来说是最好的,同时还应该全景式地了解你的业务和技术。有一点需要记住:你最终得取悦你的客户,而不是技术开发者,所以你得慎重地做出选择。
DDD并不笨重
在我看来,DDD绝非是充满繁文缛节的笨重开发过程。事实上,DDD能够很好地与敏捷项目框架结合起来,比如Scrum。DDD也倾向于“测试先行,逐步改进”的设计思路。在你开发一个新的领域对象时,比如实体或值对象,你可以采用以下步骤进行:
1. 编写测试代码以模拟客户代码是如何使用该领域对象的。
2. 创建该领域对象以使测试代码能够编译通过。
3. 同时对测试和领域对象进行重构,直到测试代码能够正确地模拟客户代码,同时领域对象拥有能够表明业务行为的方法签名。
4. 实现领域对象的行为,直到测试通过为止,再对实现代码进行重构。
5. 向你的团队成员展示代码,包括领域专家,以保证领域对象能够正确地反映通用语言。
你可能会想:“这和我之前采用的测试驱动开发没什么区别啊。”对,他们可能有细微的区别,但是基本思路是一样的。测试代码并不能保证我们的领域对象就是无懈可击的。之后,我们还会添加另外的测试代码。首先,我们关注的是客户代码如何使用领域对象,此时的测试代码驱动着模型的设计。这种方式和敏捷开发并没有多大区别。因此,即便你并不认为上面的步骤是敏捷,但它们的确表明,DDD采用的是一种“敏捷的”方式进行软件开发的。
在这之后,你会添加更多的测试,从多个角度确保新建领域对象的正确性。此时你关注的是领域对象对于领域概念的表达力,而测试代码本身便是通用语言在程序中的表达。在开发人员的帮助下,领域专家可以通过阅读测试代码来检验领域对象是否满足业务需求。这也意味着测试数据应该是真实的,因为这样可以增加测试代码的业务表达力。否则,领域专家是很难对你的实现做出评判的。
以上的开发步骤将不断重复,直到领域模型满足本次迭代的计划任务为止。这种方法是敏捷的,同时,它也是极限编程(Extreme Programming)所倡导的。因此,使用敏捷并不会消除DDD的模式和实践,而相反,它们可以很好地结合起来。当然,在实施DDD时,你也可以不采用测试驱动开发,而是对既有的模型编写测试。但无论如何,从客户的角度来设计领域模型是大有好处的。