领域驱动是⼀种思想,不仅可以应⽤于软件开发,也没有绝对的开发规范,适合⾃⼰的业务和团队背景就好,我们不是为了应⽤⽽应⽤,⽽是为了解决问题。
DDD 这个词⼉,来⾃ Evans Eric 在 2003 年的⼀本书《Domain-Driven Design: Tackling Complexity in the Heart of Software》[1]。在这本书中,Evans 提出了他对软件的复杂性来源的⼀个关键洞察——软件模型跟领域模型的不匹配,并提出他的解决⽅案(即 DDD)。软件模型即我们开发对业务理解之后划分的代码组织的模型,领域模型则来源于领域专家(或产品同学),⼀般来说,后端同学更加注重开发模型的划分,但也会存在与领域专家划分的模型出现出⼊的情况。领域具体指⼀种特定的范围或区域,也就是业务范围。对于如对⼀个图研发平台来说,业务范围就是图业务相关的图数据、图模型、图计算等。那领域模型如何划分呢,这就涉及到领域驱动的核⼼知识体系了。具体包括:领域、⼦域、核⼼域、通⽤域、⽀撑域、限界上下⽂、实体、值对象、聚合和聚合根等概念。下⾯这张图可以很好地体现他们的关系:其实我们不必过多纠结这些概念到底是做什么意思,除了⽀撑域、 通⽤域和核⼼域,其余的概念可以⽤⼀个关键词来概括:边界,只是不同边界的业务范围⼤⼩不同。
决定产品和公司核⼼竞争⼒的⼦域是核⼼域,它是业务成功的主要因素和公司的核⼼竞争⼒。例如在图研发平台中,图项⽬、图计算、图数据、图配置、图运维等是图研发过程的核⼼内容,应该划分到核⼼领域。
例如在图研发平台中,收藏中⼼的功能是⽤来收藏图模型、图查询语句等的,这种功能对于图平台的⽤户⽽⾔,很明显不是核⼼的功能,类似这样的功能就可以划分到⽀撑域。
没有太多个性化的诉求,同时被多个⼦域使⽤的通⽤功能⼦域是通⽤域。例如在图研发平台中,权限系统、⽇志系统、⼯单系统等是所有⼦域都可能需要的基础通⽤能⼒。以上的边界范围和⼦域相同,⼦域并不是固定的,可以根据具体的业务情况进⾏划分。
确定好核⼼域、⽀撑域和通⽤域之后,便可以对⼦域下的内容进⾏进⼀步的划分,即界限上下⽂、聚合和实体。
每个⼦域下可以有多个界限线上下⽂,限界上下⽂定义了⼀定业务范围的边界,确保每个上下⽂含义在它特定的边界内都具有唯⼀的含义,例如图研发平台的图项⽬是核⼼领域下的⼀个界限上下⽂,那就意味着所有跟图项⽬相关的业务内容,例如图项⽬信息、图项⽬列表、图项⽬的创建等,都可以且仅可以在图项⽬内找到。
每个界限上下⽂下可以有多个聚合,聚合由多个实体组成,实体是多个属性、操作或⾏为的载体,例如图研发平台的图项⽬下图项⽬信息确定为⼀个聚合,项⽬的信息就可以作为这个聚合的实体,⾥⾯可以包含项⽬信息的属性定义、获取项⽬信息的⽅法等。
通过领域的划分,我们将得到⼀个领域模型,这个模型即业务的知识体系,有了这个模型我们可以获得什么好处呢?
有了领域模型已经可以获得⼀定的收益,但这并不是我们的Y终⽬的,我们希望可以通过领域模型来驱动我们进⾏软件的开发,其实开发模式并不固定,适合就好。接下来会详细讲解在图研发平台中我们是如何借鉴领域驱动思想进⾏前端开发的。
为什么要⽤借鉴领域驱动的思想,⾸先看下图研发平台前端⾯临的问题:
有些业务场景确实本身就很复杂,⾯对复杂的业务,会有如下问题:1. 如何合理地将业务进⾏拆分,从⽽降低代码实现的复杂度,且保障后续的易维护性。
2. 新⼈⾯对复杂地业务如何快速了解,如何快速适应开发且能保证开发质量。
我们常常会按⻚⾯或者模块分配任务,短⽽快的迭代节奏,使得被分配到任务的同学很难对其他同学所做的模块有较深⼊的了解,后续可能也不会去看其他同学的代码,每个⼈接触的业务都是被切分的,这种形式不利于组内同学对业务的理解。
这⾥的规范是实现业务逻辑的位置、⽅式的规范,在不加以约束的情况下,我们很容易看到业务数据的处理遍布视图层,并且实现⽅式⼜很多样,如dva,hooks等,这会导致视图层变得厚重,UI交互等逻辑代码耦合这⼤量的业务数据处理的代码,牵⼀发⽽动全身,我们很难看清业务数据处理的整个过程,不仅不易迭代,⽽且这样的代码迭代起来很容易出问题。
业务逻辑的复杂性,拆分的不合理,代码不规范等⼀系列问题,导致我们CR效率和质量都会打折扣,单测也变得⽆从下⼿,很难起到质量把控的作⽤。
⻚⾯维度即产品⻚⾯或者UI设计⻚⾯,对于业务系统的开发,通常我们习惯以⻚⾯维度组织代码,将⻚⾯⾥的组件进⾏拆分,这样开发起来很直接,但是会引发如下问题:1、Component1⼀开始被划分到Page1,但是后来发现Page2也需要,Component1继续划分在Page1就不合理了,可能需要重新进⾏⽬录划分,或者组件的提取。
2、PageX是⼀个新的⻚⾯,新的⻚⾯也会⽤到Component1,但是开发者并未参与过Page1和Page2的开发,开发者对Component1的复⽤变得不可控。
如果仓库是多版本,⽐如存在主站版本和商业化版本,版本之间的核⼼功能基本是⼀致的,但是⼀定会存在差异,⼀⽅⾯要对相同功能代码的同步,⼀⽅⾯⼜要保持彼此之间的差异,以上的⼀系列问题,使得这种场景变得很困难。透过现象看本质,以上问题其实很⼤程度都跟“边界”有关,前端代码的边界确实让⼈难以把控,⽽领域驱动⼗分擅⻓解决“边界”的问题。
components为公共组件,可以被所有聚合引⼊,pages为⻚⾯组件,domains即代表⼦域,如 domains-core代表核⼼域,核⼼域下graph-project、graoh-config等为界限上下⽂,代表图项⽬、图配置等,graph-project下的project-info、project-list等则对应聚合,聚合下的内容:该聚合下的组件,该⽬录下的组件,除了公共只能引⼊该聚合⽬录下的内容,也就是说组件在聚合内是⾃闭环的。entities下可以定义多个实体,每个实体内都声明了该实体的属性和⽅法,如project-info.ts我们看到ProjectInfoEntity⾥定义了该实体的属性,以及获取属性的⽅法和更新属性的⽅法,为了保证代码语义updateProjectInfoEntity⽅法是不允许暴露出去的。entity所定义的属性,是在视图层直接进⾏消费的,不需要做数据转换的。该聚合下⽤到的常量,这⾥可以统⼀书写规范,例如只能⼤写字⺟加下划线。该聚合下⽤到的后端接⼝服务,定义为⼀个类,如:service类起到如下作⽤:1、声明了该聚合下⽤到哪些服务,这些接⼝服务的⼊参和出参都是明确的,将来如果涉及到接⼝变更或替换,可以直接在这⾥做变更,尽可能减少视图层的变更。
上⾯提到,我们要求entity的数据是视图层直接消费的,后端的数据很多情况下是要做转换的,这就需要 translator,将后端接⼝数据转换为视图层可以直接消费的数据。还有⼀类数据是前端提交到后端的,典型的如表单场景,可能也会涉及到数据的转换,将前端提交的数据转换成后端接⼝接收的数据。通过transformer和translater,可以减少在视图层进⾏的业务数据处理,理想的状态是视图层只有展示和交互逻辑的代码书写。*这⾥的视图层包括pages和components。可以理解为聚合根,外部只能通过聚合根来访问该聚合下的内容,也就是说,index内需要定义projectinfo这个聚合根下,哪些内容可以被外部访问到,这⾥我们就可以指定⼀些规则,来限制聚合可以被访问的内容,这样做可以⼀定程度保证代码的安全性,例如有些组件和⽅法只是聚合为聚合内部服务的,并不希望被外部访问到,就可以不对外暴露,避免后续在维护聚合内部业务的时候,引发外部问题。*以上内容在聚合⽬录下的引⼊都是⾃闭环的,也就是说,除了全局的公共组件和公共⽅法可以引⼊,不能依赖其他聚合的内容。理想情况下,每个聚合之间都是完全⾃闭环的,但是对于复杂的前端业务系统⽽⾔,⼀个聚合内的组件很难做到完全独⽴,我们不是为了应⽤领域驱动⽽应⽤,⽽是希望可以通过领域驱动解决我们的问题,要适合⾃⼰的业务和团队,对于components,我们允许引⼊其他聚合的内容,但是必须要在contextService内引⼊,也就是说,实体内的其他组件只能通过contextService获取到,这样可以很清楚地看到当前聚合的依赖,在改动聚合内容的时候,需要充分check对其他聚合的影响,我们也可以添加规则,来限制依赖的内容,如只能依赖聚合的components等。当然聚合⽬录下还可以有其他的⽬录,如hooks、utils,可以按需添加。完成领域代码就可以“拼装⻚⾯了”,只需要看当前开发的⻚⾯,⽤到哪些聚合的内容,在⻚⾯引⼊代码组装,此时,⻚⾯内只有⻚⾯视图层的逻辑了,数据的获取通过聚合的entities,组件也都来⾃各个聚合。调整开发思维
前端开发⽐较常规的开发模式,是围绕⻚⾯展开的,以UI的维度将⻚⾯的组件模块进⾏拆分,⽽现在的开发流程:开发前,产品、前端、后端等⻆⾊需要⼀起对当前需求进⾏领域划分,每个⼈都要参与其中,加深对业务的理解。开发⼈员根据⻚⾯中⽤到的领域信息,组装领域代码,完成⻚⾯开发。
新⼈友好
新⼈只需要了解业务的领域模型划分,以及项⽬⽬录和领域模型的对应关系,便可以对项⽬代码有整体了解,即使上⼿开发⼀个新的功能,也可以很容易地复⽤以往实现过的核⼼业务数据、组件、⽅法等,且整体⽅案没有太偏技术的应⽤,新⼈可以很快进⼊开发迭代节奏。这主要得益于transformer和translator。⻚⾯测试的边界清晰
⻚⾯内涉及到的实体⼀⽬了然,且实体内的⽬录职责边界⼗分清晰,对于前端对业务⻚⾯的测试是很友好的。降低CR成本
通过代码⽬录的变更,便可以清楚地知道改动点涉及的业务范围,以及代码变更是否符合该⽬录下代码的规范,业务数据的处理不会分散在各个⻆落了,可以很直观地看到整体数据的处理过程。组件的复⽤变得简单
复⽤业务组件的思路已经不⼀样了,⼀个全新的⻚⾯开发,第⼀时间考虑的是⻚⾯中涉及到领域模型中的哪些⼦域以及实体,开发的时候会先去对应的实体⽬录去找是否有已经实现过得组件、⼯具等,这样即使⼀个对项⽬整体代码不是很熟悉的开发者,同样可以很轻松地对已有的实现进⾏复⽤,并且随着开发量的增加,对每个⼦域和实体都会有所了解,⽽不是只专注于⾃⼰开发的⼏个⻚⾯。代码⻛格变得统⼀
实体下每个⽬录的职责是清晰的,且每个⽬录下代码的⻛格是明确的,即使⼀个新⼈维护,照葫芦画瓢也不会出现与团队⻛格相差较⼤的代码,这样也带来了好的维护性,有利于项⽬的⻓期迭代。开发者对业务的理解更加全⾯深⼊
随着产研每次对领域的讨论实体的划分,开发者会更有参与感,前端同学也会对后端接⼝设计有更多的了解,按照领域驱动思想进⾏开发的过程中,也会更加深⼊地理解各个领域和实体的功能,这种理解的加深会随着开发时间的推移变得范围越来越⼴,直⾄对全局业务都有更加深刻的理解,这种收益是⻓期的。为多版本的维护打下基础
多版本产品之间虽有功能上的差异,但是它们的共同点是:领域模型具有⼀致性,即实体可能存在差异,但⼤的领域划分,尤其是核⼼领域和通⽤领域的划分,基本是不会发⽣变化的,这也意味着,我们在复⽤代码的时候,不需要再将组件作为Y⼩单位进⾏拆分共享,⽽是以实体作为Y⼩单位进⾏复⽤,配合Bit⼯具,进⾏源码级复⽤,可以极⼤提⾼开发效率,并且满⾜不同版本之间的不同需求。
领域驱动是⼀种思想,不仅可以应⽤于软件开发,也没有绝对的开发规范,适合⾃⼰的业务和团队背景就好,我们不是为了应⽤⽽应⽤,⽽是为了解决问题。作为前端开发者,对于领域驱动的理解和应⽤仍是在实践和探索中,如有错误或表达不当之处,欢迎探讨指正。[1] Eric Evans Domain-Driven Design –Tackling Complexity in the Heart of Software
阿里云开发者社区,百万精品技术内容、千节免费系统课程、丰富的体验场景、活跃的社群活动、行业专家分享交流,欢迎点击【阅读原文】加入我们。