我与DDD第一次邂逅

1,401 阅读17分钟

wallhaven-eyl6jw.png

喜欢的人看你怎么都喜欢,恨你的人看你怎么都讨厌

在我刚来公司的时候,赖老师就说了:我们没有开发文档,你想熟悉业务,从产品那里拿到需求在去对比代码!!!
当我开始浏览项目的时候

537d38f746c94b51a0f6f5aeee46f853_tplv-k3u1fbpfcp-watermark_看图王.png

业务开发的痛点

  • 现在写的软件怎么样?

    • 现有的代码我不满意,我知道它乱,但不知如何入手解决
    • 听说过业务架构整体优雅,允许局部腐化,你见过吗?我没见过
    • 复杂度高,越来越不可控
    • 新的产品需求文档(PRD)的不断涌入
  • 如何让你的代码可以成为领域?

    • 系统能否支持以不同视角从不同维度梳理业务?
    • 代码可以反应PRD的内容吗?
    • 如何让产品和技术融合在一起?
    • 代码的结构化,是什么意思?
  • 业务代码和技术代码能解耦?

    • 业务与技术二者相互独立
    • 我想使用外包的东西,作为代码库的我,不太相信其质量
    • 在我们这个技术团队的大家庭里,有的人明显适合业务领域开发,有的适合技术性系统开发,如何人员分层管理?
    • 想从代码里捋业务思路,醒醒吧!你看代码只想问问“晚上吃什么?”,得到的确是“我吃了一个朴朴(非广告)买的鸡蛋与米饭用铁锅做出来的蛋炒饭”
  • 业务不确定

    • 如何优雅地解决:业务逻辑的扩展,业务模型的扩展,业务流程的扩展
    • 你曾经的if语句你敢碰吗?它的逻辑遍布世界
    • 经常有新需求要我加字段,甚至加表,加得研发自己都不认识了
    • 又要响应千奇百怪的个性化需求,又要保持自身不腐化
  • 研发痛点

    • 如何让研发拿到需求立刻就知道代码写在哪里,不各显神通地造轮子造概念
    • 不要跟我讲各种方法论,架构思想,我只想知道这个PRD怎么好地实现

业务开发的复杂性来源

  • 根本来源
    • 业务场景多,差异大
    • 个性化需求多
    • 业务术语多,每个术语可能都对应一大堆字段、逻辑和流程
    • 业务流程长,任何一个节点错误都会造成整体bug
    • 自己公司是B2B项目,每个行业每个企业都有不同的业务诉求
  • 附属来源
    • 缺乏顶层设计,造成的代码随意
      • 千人千面的代码风格和设计
      • 没有顶层逻辑,那个站出来的男人(女人)是谁
    • 业务和技术的耦合,代码本身无法反映业务本质
    • 代码自身的质量差与可解释性差
    • 环境因素(团队规模、人员流动、项目流动)

DDD

  • 你知道DDD吗? DDD(领域驱动设计),不要因为其高大上的名称而觉得遥不可及(不过真的是隐晦难懂!)。DDD概念来源于2004年著名建模专家eric evans发表的他最具影响力的书籍:《domain-driven design –tackling complexity in the heart of software》(中文译名:领域驱动设计—软件核心复杂性应对之道)一书。,书中提出了“领域驱动设计(简称 ddd)”的概念。

    • 领域驱动设计一般分为两个阶段:

      1.以一种领域专家、设计人员、开发人员都能理解的“通用语言”作为相互交流的工具,在不断交流的过程中发现和挖出一些主要的领域概念,然后将这些概念设计成一个领域模型;
      2.由领域模型驱动软件设计,用代码来表现该领域模型。领域需求的最初细节,在功能层面通过领域专家的讨论得出。

    请参考这里为自己补充弹药:DDD(领域驱动设计)总结

  • DDD是真正解决业务问题的架构思想:

    • 把业务设计和业务开发统一,产品和研发统一
      • 统一在domain层,DDD的精华在这一层
      • 业务专家角色,在互联网领域大部分是缺失的,实际上是谁更懂谁就专家
    • 业务和技术解耦
      • 本质上,DDD是把技术从业务中剥离:让业务成为中心,技术成为附属品
        • 技术是为业务服务的
        • domain层,是业务核心:不要把业务模型和规则逃逸、泄露到其他层
        • 以领域为核心的分层架构,技术手段通过倒置依赖进行隔离
      • 是面向业务的设计和编程,不是面向数据库的编程,也不是面向技术实现的编程(做好一个业务,不是技术越高大上也不是数据库使劲增加字段)
      • 把业务逻辑集中到domain一层,使得产品和研发能有一个共同的代码交流场所
    • 改变过去Service + 数据库技术驱动开发模式
      • 从业务出发,让代码来解释业务(提高代码业务表达能力)
      • 从核心概念的模型出发

你能写出DDD吗

DDD是一个架构思想不是一个现有的框架。代码层面缺乏了足够的约束,导致DDD在实际应用中上手门槛很高,理解上容易产生偏差。

  • 框架易学,思想难学(就像当初面向过程转面向对象的时候,满满的疑惑)
  • DDD的最佳实践太少,没有标准
    • 从上面看下来,DDD只给出了结果,并没给我过程指导,最开始的时候总是那么多的可能性延伸出更多的未知性
  • DDD核心诉求是业务架构和系统架构形成绑定关系,但它缺乏面向领域的架构体系:不足以支撑复杂项目需求
  • DDD落实到代码考验的是研发的面向对象思维
    • 但长期的面向数据库编程思维,造成书本里学到的面向对象思维能力退化严重
    • 研发落地,很容易走回到老路
  • DDD的概念一个个都要去理解,生硬且困惑
  • DDD的建模,拜托你拿过奖吗?

DDD实践

赖老师给我布置了一个任务,想把业务中那些权重低且不影响核心流程的事件提取出来(如Sms、数据统计、文件转换等需要异步处理等事件)并处理,而你需要写个服务或者工具去处理这些任务。从我接到这个任务时,一切都是0。DDD的学习在过程中资料更是应接不暇,有可以支持复杂项目的框架也有为踏进DDD大门的实践项目,我们要取其精华,去其糟粕! image.png

  • 主观与客观的碰撞
    • 以DDD架构思想为本,面向复杂业务场景架构设计
      • 通过代码框架提供足够约束,提供一个好的DDD实现环境
      • 降低DDD上手门槛,为研发减负,防止落地偏差
      • 降低复杂度,让业务资产可复用
    • 帮助解决业务的不确定性
      • 业务逻辑、流程、逻辑模型、数据模型的扩展、多态体系
      • 框架本身可扩展
      • 扩展业务包(热部署),框架本身ClassLoader机制的业务隔离

思想与实践-顶层设计的打造

  • DomianModel:领域,DDD的核心

  • DomianService:领域服务。一个完整的业务活动的完成,比如数据统计。 数据统计在任务中算是一个步骤挺繁多的过程,包括:过滤、数据校验、来源分类等步骤,在此对应DomainStep。

  • DomainStep:步骤,一个业务由多个步骤组成。 步骤,将业务细节隐藏而把业务活动拆分出来的抽象。(Divide-and-Conquer思想)

    科普小课堂:分治法(Divide-and-Conquer),字面意思是“分而治之”,就是把一个复杂的1问题分成两个或多个相同或相似的子问题,再把子问题分成更小的子问题直到最后子问题可以简单地直接求解,原问题的解即子问题的解的合并,这个思想是很多高效算法的基础,例如排序算法(快速排序,归并排序),傅里叶变换(快速傅里叶变换)等。
    分治法的基本思想:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。

    有步骤肯定需要进行步骤的编排来保证其执行顺序.在不同业务场景下,步骤顺序的不同,步骤的分类不同.例如:询价行为,在B2B中由步骤(A, B, C, D)完成的;而B2C场景,接单是由步骤(C, D, E, F, H)组成的。所以DomainStep是一个可编排的服务。

    既然有了步骤也需要进行步骤编排,有些是可以预先计算的,有些是动态计算的,例如:在C处需要调用预分拣API,根据返回结果才能决定后续步骤,动态编排例子将由后续给出(这里大致思路是使用拓扑排序进行步骤编排)。

    当步骤编排完成后需要StepExecute(步骤执行器)去执行步骤。在步骤执行过程中是有可能由异常抛出来的,而之前执行的步骤是需要回滚的,则可拓展DomianStep其子类RevokableDomainStep实现业务回滚操作。这些机制是StepExecute实现的一种Saga Pattern。

  • DomainExtension:拓展点,实现业务的多态。

    如何实现不同场景下对步骤的不同编排?在同一个业务范畴内,不同场景的执行逻辑可能不同,例如:发起询价,有的采购员要求0期货报价,有的要求价格范围内报价等等?

    以上的需求,其实本质就是业务的多态。这就刚好引出DomainExtension(拓展点):业务范畴确定,但需在不同场景执行逻辑不同的业务功能点,即业务的多态。扩展点的使用收敛在DomainAbility,通过其tags进行多级分类管理。

    • 根据整体设计,扩展机制也需要分层:
      • DomainExtension:最底层的扩展点,解决业务执行逻辑的不确定性
      • LayoutStepExt:步骤编排扩展点,解决业务流程的不确定性
      • ModelAttachmentExt:解决业务模型的不确定性,可以简单理解为如何解决多业务场景下数据库字段的问题
  • IdentityResolver:业务身份匹配器,扩展点机制,实现了业务多态,但运行一项业务需要根据DomainModel判断该业务是否属于自己,同时与某一个扩展点实现进行绑定,从而完成扩展点的定位机制。
    本质上,IdentityResolver相当于把之前散落在各处的某个业务逻辑的if判断条件进行收敛,使得这些业务判断显式化,有形化,并有了个名字。DomainExtension相当于把if后面的code block显式化,有形化,并可以进行组织分工。

    科普小课堂:列举业务场景的维度:某个商家的订单、出库时是否越库、订单项是否包含违禁品等

  • Pattern:业务模式,根据上面IdentityResolver+DomainExtension组成的有效内容,可以任意维度叠加的水平业务。 每个Pattern+DomianExtension可以有独立的Spring上下文,通过上下文的隔离,让不同模块之间的 Bean 的引用无法直接进行,达到模块在运行时的隔离。

  • DomainAbility:能力,DomainStep细粒度的存在。DomainStep与DomainExtension之间连接的介质,从而为Step提供管理调用DomainExtension的功能。

image.png

抽象顶层设计

  • 接下来,是你需要做的工作:
    • 梳理业务
    • 业务抽象
      • 提炼扩展点DomainExtension
      • 步骤 DomainStep
      • 模型扩展 ModelAttachmentExt
    • 实现自己的DomainModel,建立自己业务的领域模型
      • 异常机制
      • 错误码规范
      • 核心域和支撑域及交互

领域与Domain Primitive

领域模型清晰与否决定设计的好坏,好的领域内部结构清晰,演化成本低。

  • 设计领域模型的一般步骤如下:

    • 从需求中划分领域界限上下文、上下文之间的关系
    • 实体、值对象的划分,关联的关系形成聚合
    • repository的设计,提供实体与值对象的创建方式(需经过防腐层)
    • 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
  • 说到领域就不得不提一下Domain Primitive,从上面的InquiryMain中看出,其不单有属性也同样存在行为且所有抽离出来的方法要做到无状态,如:验证,创建,过滤等。(贫血模型 to 充血模型)

    • DP是一个传统意义上的Value Object,拥有Immutable的特性
    • DP是一个完整的概念整体,拥有精准定义
    • DP使用业务域中的原生语言
    • DP可以是业务域的最小组成部分、也可以构建复杂组合

    请参考这里为自己补充弹药:阿里技术专家详解 DDD 系列- Domain Primitive

    思考一下:相信很多人接触面向对象之前都接触过面向过程,重看面向过程,它的编程思想围绕着事件,分析出解决问题的方案步骤再利用函数实现步骤。在我们日常的代码中,早已习惯了贫血模型,它作为技术细节给我们带来更多的简洁,代码编写过程中也无需过多思考复用与行为,只需要一股脑的交由调用者去处理。如果将一个贫血模型从维度上将其看成与int,char等,那纵观整贫血模型整个调用过程已经是半面向过程半面向对象的结果。当然并不是所有的对象都有无状态行为,所以在对象的设计中,我们尽量对其进行深入思考来发掘行为与动作而不是想一时的轻松。

在领域的设计上肯定不会一帆风顺,需在业务上不断磨合才能打造出圆润光滑的实现。以询价为例子:InquiryMain作为一个实体

@Domain(code = InquiryDomain.ID, name = "询价核心域")
public class InquiryDomain {
    public static final String ID = "core";
}
@Getter
@Log4j
public class InquiryMain implements InquiryDomainModel {
    private Long id;
    private String inquiryNo;
    // ...

    @Setter
    private String activity;

    @Setter
    private String step;
    
    private OfferIntegrin offerIntegrin; // 实体
    private InquiryItemIntegrin inquiryItemIntegrin;  // 作为值对象

    private InquiryMain validate() throws Exception {
        // 模型本身的基础校验
        return this;
    }

    public IDomainModel createWith(Object objectBean) {
        // 验证
        // 转换
        return null;
    }
    
    public List<String> filterOffer(Predicate predicate) {
        return new ArrayList<>();
    } 
    // ...
}
  • 实体 当可用标识(不是属性)去区分一个对象,那这个对象可以成为实体。实体具备可持久化,存在业务逻辑。 在对实体的设计中不应该有太多的属性,而是去寻找关联

  • 值对象 在我们习惯面向数据库实现代码后对于建模很容易将所有对象看成实体。然而作为值对象它具备不变性、等同性与可替换性,值对象的存在可以更好地精简设计,所以我们要理清楚实体与值对象的附属关系。从DDD的值对象定义可看出,值对象在模型概念上是可公用的,作为一个模型,它不允许外界修改它已创建好的值。如询价单里,询价明细价格、数量等固定后在后面的一系列操作后都不能从外界更改其值,那么询价明细的价格与数量可以视为值对象中的属性。

实体与值对象还是相对好理解,且直接与业务挂钩,我们可以根据业务的划分来进行实体与值对象的建模,但是聚合就需要我们对业务规则作为参考帮助我们技术设计。

  • 聚合与聚合根 每个聚合既然有一个标识一个边界,那就表明在技术设计中以聚合为基本单位去处理事务,故聚合就是一个大家庭,它们共同承担共进退的义务。 外界聚合访问本聚合由于边界的隔离只能从聚合根获取。

领域服务与步骤

@DomainService(domain = InquiryDomain.ID)
public class SubmitInquiryService implements BaseService<InquiryMain, ServiceException> {
    @Override
    public boolean handler(InquiryMain objectBean) throws ServiceException {
        return false;
    }
}
  • 由此可看出Service
    • 它既不是实体,也不是值对象的范畴,他拥有重要的领域行为或操作
    • 服务是无状态的
    • 服务的操作设计领域 领域服务存在的意义就是协助领域对象完成某个操作,而操作过程中的所有状态都会在领域对象里,对于为服务的开发人员创建领域服务应该是件很简单的事情。
@Step
public class InquiryPersistStep implements IDomainStep<InquiryMain, ServiceException> {
    @Autowired
    private InquiryRepository inquiryRepository;
    
    @Override
    public void execute(InquiryMain model) throws ServiceException {
    }

    @Override
    public String activityCode() {
        return Steps.Inquiry.InquiryPersist;
    }

    @Override
    public String stepCode() {
        return Steps.Inquiry.Activity;
    }
}
  • 领域事件 当领域经过经过服务的操作过程中其所发生的事可以看为领域事件,领域事件是对领域内发生的活动进行的建模。 比如发起询价,是需要通知品牌报价方的,这个“你有一份询价单可以报价”就是一个领域事件。

    • 创建领域事件要保持两个特征
      • 它是不可变的
      • 领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据。比如通知报价方报价后的时候不用将整个聚合的询价单传递,而是将报价数据传递就行。

    对于领域事件的实现,是可以借助事件驱动设计(Event Driven Architecture) 在微服务内实现领域事件,按照“DDD“一个事务只更新一个聚合根”的原则,可以考虑引入消息中间件,通过异步化的方式,对微服务内不同的聚合根采用不同的事务。但由于引入中间件的实现极其复杂,故采用Step进行解释。

  • 步骤是领域服务的更细粒度的存在,将询价这个业务活动进行拆解,抽象成一些业务步骤。 在DDD的介绍中,使用EDA去处理一个事件,二者区别

    • 相同点
      • 都实现了开闭原则
      • 但都面临粒度粗细的设计问题
    • 不同点
      • Step的调试和排障更方便,在StepExecute执行Step保留了调用栈
      • Step更方便实现之间的依赖关系、顺序,而EDA下事件的分发机制会很复杂,可能破坏它带来的收益
      • Step,结合业务身份,很容易实现不同场景下的编排
      • Step支持动态编排 有Step的执行可以异步等,故StepExecute也需要支持多线程操作,也可引入消息中间件来执行。但引入消息中间件对于赖老师分配我的任务存在间隙,故选择弃置,同样支持多线程操作反而适合我。而关于Step的操作方式(顺序还是多线程)与编排可由DomainAbility来管理。为了微服务架构中保证数据一致性同样需要引入事务在StepExecute中,选择的是Saga的策略补偿机制。 请参考这里为自己补充弹药:Pattern: Saga

防腐层

image.png 数据流转

由于外部数据进入,需要防腐层为顶层设计提供保障,对外部上下文的访问进行一次转义(技术选型:MapStruct)

  • 有以下几种情况会考虑引入防腐层:[3]
    • 需要将外部上下文中的模型翻译成本上下文理解的模型。
    • 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
    • 该访问本上下文使用广泛,为了避免改动影响范围过大。
    • 如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。

最后

领域设计的顶层设计相对简单,但建立一个适合业务的领域模型却很困难。在开发的过程中是在不断细化,随着多次测试人员、开发人员、产品经理的磨合才能达到最佳的效果。因此领域模型的创建与业务密不可分这也导致他人的设计不一定适合自己。领域模型的实施促进团队的交流,同样领域模型的概念帮助我们去理解领域驱动的设计,实现一些高内聚、低耦合的代码实现。

DDD只是整个任务设计中的很小一部分。除了领域模型设计之外,要落地一个系统,我们还有非常多的其他设计要做,比如数据库设计、缓存设计、框架选型、高并发解决方案(由于任务的特性,暂不考虑)、一致性选型、性能压测方案、监控报警方案等。上面这些都需要我们平时的大量学习和积累。作为一个合格的开发人员,我觉得除了要会DDD领域驱动设计,还要会上面这么多的技术能力,确实是非常不容易的。当然上面文章由于侧重点不同埋下了许多坑,如任务的具体描述,技术选型,步骤重排,数据一致性等问题还没得到充分的解释。这个等后续肯定填上,但是解决思路我会尽我努力达到最好。

参考文献

[1]《领域驱动设计》
[2]DDD(领域驱动设计)总结
[3]阿里技术专家详解 DDD 系列- Domain Primitive
[4]领域驱动设计在互联网业务开发中的实践
[5]领域驱动设计(DDD)实践之路(二):事件驱动与CQRS
[6]DDDplus