当前位置: 首页 > 科技观察

DDD实践:分层架构的代码结构

时间:2023-03-16 11:46:49 科技观察

不同于其他架构方式。领域驱动设计DDD(DomainDrivenDesign)提出了从业务设计到代码实现的一致性要求,不再需要分析模型和实现模型。做个区分。也就是说,从代码的结构上,我们可以直接了解业务的设计。如果名字合适,非程序员也能“看懂”代码。但是在整个DDD建模过程中,我们更注重核心领域模型的建立,我们认为业务需求的实现是对领域模型的一系列操作(应用)。这些操作包括更改核心实体的状态、域事件的存储、域服务的调用等。在良好的域模型之上实现这些应用程序应该是轻松愉快的。作者经历过许多DDD建模工作坊。经过几天一轮又一轮的激烈讨论和不懈的考察,大家看着白板上贴着各种颜色贴纸展示的领域模型,取得了圆满成功。每个人的脸上都写满了感情。在成功的这一刻,人们常常会问:我们如何落地这个模式?随后大家脸上的喜悦消失了,取而代之的是细节成魔的焦虑。但这是我们无法回避的实现细节。DDD的原始方法论中虽然给出了“分层架构”(LayeredArchitecture)的元模型,但是并没有明确定义如何分层。分层架构在DDD方法提出后的这些年里,分层架构的具体实现也经历了几代的演进。直到MartinFowler细化了下图所示的分层实现架构,才逐渐被大家所认可。DDD方法也得到了有效的补充,模型实现的问题变得更容易了。核心领域模型的范围也被明确定义:包括Domain、ServiceLayer和Repositories。(MartinFowler总结了提出的分层架构的实现,注意“Resources”是基于RESTful架构的抽象,我们也可以理解为对外界更通用的接口。HTTPClient主要是一种通信协议,用于Internet,而Gateways其实是Internet的一种通信协议,是交换过程中信息组装的逻辑。)我们的核心实体(Entity)和值对象(ValueObject)应该在Domain层,定义域服务(DomainService)应该在Service层,而实体和值对象的存储和查询逻辑应该在Repositories层。值得注意的是,Entity的属性和行为不能分离成Domain和Service两层,也就是所谓的贫血模型。事实证明,这样的实现会带来很多维护问题。DDD战术建模中的元模型定义在实现过程中不应该改变,作为元模型元素之一的实体应该包含自己的行为定义。基于这个模型,我们来谈谈更具体的代码结构。对这种分层架构还有疑问的读者可以仔细阅读Martin的原文。有意思的一点是,这个模型的描述其实在微服务架构的测试文章中,深意值得理解。这里需要明确的是,当我们讲代码结构的时候,我们针对的是一个DDD建模的子问题域(见StrategicDesign),这是我们明确的组件化边界。是否进一步组件化,比如根据限界上下文(BoundedContext)进行模块化,或者使用微服务架构来服务,核心实体是进一步可能采用的组件化方式。从抽象层面来说,老马提炼出来的分层架构适合面向业务的面向服务的架构,所以如果需要进一步组件化,也可以按照这个代码结构来完成。整体代码目录结构如下:-DDD-Sample/src/domaingatewaysinterfacerepositoriesservices这个目录结构和前面的分层架构图一一对应。请从GitHub下载完整的示例代码。可以看出,我们实际上并没有为外部存储(DataMappers/ORM)和外部通信(HTTPClient)建立目录。从领域模型和应用的角度来看,我们不需要关心这两个。足以验证整个领域模型的输入和输出。至于什么样的外部存储和外部通信机制是可以“注入”的。这种隔离是独立可部署服务的基础,也是我们能够测试域模型实现的要求。模型表示按照分层架构建立代码结构后,我们首先需要明确定义我们的模型。如前所述,这里主要涉及的是战术建模过程中得到的核心实体和服务的定义。我们使用C++头文件(.h文件)来显示域模型的定义。案例灵感来源于DDD原著中的集装箱货运示例。namespacedomain{structEntity{intgetId();protected:intid;};structAggregateRoot:Entity{};structValueObject{};structProvider{};structDelivery:ValueObject{Delivery(int);intAfterDays;};structCargo:AggregateRoot{Cargo(Delivery*,int);~Cargo();voidDelay(int);private:Delivery*delivery;};}这个实现首先声明了元模型实体Entity和值对象ValueObject。实体必须有一个标识id。AggregateRoot是DDD中的一个重要元素,它是在实体的基础上声明的。根据定义,聚合根本身应该是一个实体,所以AggregateRoot继承了Entity。在这种情况下,我们定义了一个实体Cargo,它也是一个聚合根。交付是一个值对象。这里虽然为了效率使用了struct,但是可以理解为在C++中定义一个类。Dependencies代码目录结构无法表达层次体系中每一层的依赖关系。例如,领域层不应该依赖于任何其他层。维护每一层的依赖关系是至关重要的。许多团队在实施过程中未能建立这样的工程学科,导致混乱的代码结构和破碎的领域模型。根据分层架构的规则,我们可以看到示例中的代码结构如下图所示。域不依赖于任何其他对象。Repositories依赖于Domain,实现如下:引用model.h。#include"model.h"#includeusingnamespacedomain;namespacerepositories{structRepository{};...服务依赖于Domain和Repositories,实现如下:引用model.h和repository.h#include"model。h"#include"repository.h"usingnamespacedomain;usingnamespacerepositories;namespaceservices{structCargoProvider:Provider{virtualvoidConfirm(Cargo*cargo){};};structCargoService{...};...为了保持合理的依赖,依赖Injection(DepedencyInjection)是一种需要经常使用的实现方式。作为一种解耦的方法,相信大家都不陌生。具体定义见这里。在测试构建时,我们使用了一个IoC框架(依赖注入的实现)来构建一个Api,并在这个Api中注入相关的依赖(比如CargoService)。这样一来,Interface和Service之间的单向依赖没有被打破,并且在测试过程中解决了API的实例化需求。autoprovider=std::make_shared();api::Api*createApi(){ContainerBuilderbuilder;builder.registerType().singleInstance();builder.registerInstance(provider).as();builder.registerType().singleInstance();builder.registerType().singleInstance();autocontainer=builder.build();std::shared_ptrapi=container->resolve();returnapi.get();}测试实现有了领域模型,大家自然会想到如何实现业务应用,而在实现应用的过程中,必须考虑单元测试的设计。在构建高质量软件的过程中,单元测试已经成为标准规范,但是高质量的单元测试是困扰很多团队的通病。通常设计测试比实现应用程序本身更困难。很难有一个固定的标准来判断某个时间点单元测试的好坏,但一个核心原则是让用例尝试去测试业务需求而不是实现本身。满足业务需求是我们的目标,实现它的方法可能有很多种。我们不希望需要不断重构的实现代码影响到我们的测试用例。例如,在实现过程中对某个函数进行输入输出参数的单元测试。当功能稍有变化时(即使改名),我们就需要更改测试。测试驱动开发(TDD)无疑是一种很好的实践。如果应用得当,确实可以实现我们上述的原则,帮助我们沟通业务需求。更有趣的是,在基于DDD构建的核心模型之上应用TDD似乎更符合逻辑。虽然拿DDD和TDD进行比较不妥,但我们会发现两者在原理上是一致的,即都是面向业务进行分解设计的:DDD将整个业务问题域进行分解,形成子问题域;TDD在业务需求的实现过程中对任务进行分解,从简单场景到复杂场景通过测试驱动逐步实现。以下测试用例演示了核心模型上的TDD过程。TEST(bc_demo_test,create_cargo){api::CreateCargoMsg*msg=newapi::CreateCargoMsg();msg->Id=ID;msg->AfterDays=AFTER_DAYS;createCargo(msg);EXPECT_EQ(msg->Id,provider->cargo_id);EXPECT_EQ(msg->AfterDays,provider->after_days);}上面测试了收到创建消息后实例化一个Cargo的简单场景,要求创建的Cargo与消息中的id相同,并输出交货日期相同。该测试驱动出一个接口Api::CreateCargo。下面是测试延迟的另一个场景,我们也看到了驱动Api::Delay的实现。TEST(bc_demo_test,delay_cargo){api::Api*api=createApi();api::CreateCargoMsg*msg=newapi::CreateCargoMsg();msg->Id=ID;msg->AfterDays=AFTER_DAYS;api->CreateCargo(msg);api->Delay(ID,2);EXPECT_EQ(ID,provider->cargo_id);EXPECT_EQ(12,provider->after_days);}很长一段时间,大家对实践的架构设计有疑惑测试驱动开发。很多资深架构师担心,如果完全由业务需求驱动实现,无法形成有效的技术架构,而且每一次实现的重构成本都可能很高。DDD的引入一定程度上解决了这个顾虑。核心域架构是通过早期的战略和战术建模确定的。该架构基于预先综合讨论和决策,同时考虑了更广泛的业务问题。与TDD应用相比,业务需求的层面更加宏观。在现有核心模型的基础上,我们还会发现测试用例的设计更容易从应用的角度入手,从而降低测试设计的难度。关于预设计,不看攻略文章直接看本文的读者肯定会对预设计产生顾虑。毕竟DDD是敏捷开发圈公认的架构方式,其目标应该是构建架构模型的响应性。而我这里给大家更多的是一个模式化的实现过程,好像从建模到代码的一切都是预先设计好的。值得强调的是,我们仍然反对Big-Design-Up-Front(BDUF)。但是我们应该认识到前期对核心领域模型的分析和设计,可以帮助我们更快的响应后续的业务变化(即核心模型之上的应用)。这并不意味着核心领域模型将保持不变或以后不能更改,而是统一模型核心部分的更改频率将远低于外部应用程序。如果核心领域模型也发生了翻天覆地的变化,那我们可能就要考虑业务是否发生了根本性的变化,需要建立新的模型。此外,我们不能忘记,我们预定义的模型也仅限于一个分解的核心问题域,这意味着我们不希望一次性构建整个复杂业务域中的所有模型。这种范围的限制也在一定程度上限制了我们前期设计的范围,促使我们更加迭代地看待建模工作本身。最后,很明显,我们应该有一个核心团队来守卫核心领域模型。这并不意味着任何模型设计和更改都必须由该团队中的人员进行(尽管许多团队确实以这种方式实现DDD)。我们的期望是,对核心模型的任何改变都能够通过这个核心团队促进更大的沟通和交流。检验一个模型是否落地的唯一标准就是应用该模型的团队能否就模型本身达成共识。对此,我们看到很多团队通过代码审查(codereview)的方式,不断实践基于核心模型的线上线下交流,从而起到真正的“守护者”作用,让模型本身成为团队的核心。共同责任。在实践DDD时,仍然需要遵循“模型用于通信”的核心原则。我们希望本文介绍的方法和模式能够帮助您更轻松地交流领域模型,同时也可以将其视为对DDD策略和战术设计的补充。【本文为专栏作者“ThoughtWorks”原创稿件,微信公众号:Thinkworker,转载请联系原作者】点此查看该作者更多好文