DDD实战篇:分层架构的代码结构 -- 知识铺
领域驱动设计(Domain Driven Design, DDD)是一种软件设计方法论,它强调从业务领域出发,实现业务逻辑与代码结构的一致性。与传统的架构方法不同,DDD不将分析模型与实现模型分开对待。这意味着,通过合理的命名和代码结构,即便是非技术背景的人员也能够通过阅读代码来理解业务设计。 在领域驱动设计(DDD)的实践过程中,我们主要聚焦于构建领域模型,特别是核心领域模型的确立。我们认为,满足业务需求本质上是对领域模型进行操作,这包括更改核心实体的状态、记录领域事件、调用领域服务等。一个完善的领域模型能够使这些操作变得简单而愉悦。
我参与了多次DDD建模研讨会,经过数日的深入讨论和反复推敲,当看到白板上用不同颜色便签纸展示的领域模型时,团队成员的脸上洋溢着成就感。然而,就在我们认为大功告成时,总有人提出模型如何落地的问题,这使得原本的喜悦转为对细节实现的担忧。尽管如此,细节的实现是不可避免的,而DDD的原始理论提供了分层架构的元模型,但关于如何具体分层则没有给出明确指导。
经过多年的发展,分层架构的实现方式也经历了演变。Martin Fowler后来提出的分层架构实现模型被广泛接受,为DDD方法提供了有效的补充,使得模型落地变得更加可行,同时也对核心领域模型的界限进行了明确划分:包括领域层(Domain)、服务层(Service Layer)以及仓储层(Repositories)。 在Martin Fowler提出的分层架构理念中,我们了解到分层架构的实现方式,特别是’Resources’在RESTful架构中扮演的角色,它是一个针对外界的接口抽象。而’HTTP Client’主要用于互联网通信协议,‘Gateways’则是在信息交换过程中组装信息的逻辑核心。在领域驱动设计(DDD)中,核心实体和值对象应该位于领域层(Domain Layer),领域服务则定义在服务层(Service Layer),而实体和值对象的存储和查询逻辑则应归属于仓储层(Repositories Layer)。避免将实体的属性和行为分离到领域层和业务层,即所谓的贫血模型,因为这种设计会导致维护上的困难。DDD战术建模中的元模型定义在实现过程中应保持不变,实体作为元模型的元素之一,应包含其自身的行为定义。 在讨论更具体的代码结构时,我们应回顾Martin的原始描述,特别是其在微服务架构测试文章中对分层架构的阐述,其中蕴含的深层意义值得我们深思。 当我们讨论代码结构时,我们是在讨论一个经过DDD建模后的子问题域,这是我们明确的组件化边界。是否进一步组件化,例如按照限界上下文(Bounded Context)进行模块化,或采用微服务架构进行服务化,核心实体都是可能的组件化方法。从抽象层面来看,Martin提炼的分层架构适用于面向业务的服务化架构,因此,进一步的组件化也可以遵循这一代码结构。 总体的代码目录结构应如下组织: * 领域层(Domain Layer):包含核心实体和值对象的定义。 * 服务层(Service Layer):包含领域服务的实现。 * 仓储层(Repositories Layer):包含实体和值对象的存储和查询逻辑。 这种分层架构不仅有助于维护和扩展,而且也支持快速响应业务变化,是现代软件开发中不可或缺的一部分。
- DDD-Sample/src/
domain
gateways
interface
repositories
services
在这段描述中,我们讨论了软件架构的分层设计,其中目录结构与分层架构图相匹配。要获取完整的案例代码,需要访问GitHub进行下载。文章指出,虽然我们没有创建外部存储和HTTP客户端的目录,但这并不影响我们验证领域模型的输入输出。领域模型和应用服务不需要关注具体的存储和通信细节,因为这些可以通过依赖注入的方式灵活配置。这种设计策略有助于实现服务的独立部署,同时也便于进行单元测试。 在确立了软件的分层架构之后,我们需首先明晰模型的定义。这涉及到战术建模阶段所提炼出的核心实体与服务。以C++为例,我们通常使用头文件(.h)来定义领域模型。例如,参考领域驱动设计(DDD)的原始著作,我们可以从集装箱运输的案例中汲取灵感。
namespace domain{
struct Entity
{
int getId();
protected:
int id;
};
struct AggregateRoot: Entity
{
};
struct ValueObject
{
};
struct Provider
{
};
struct Delivery: ValueObject
{
Delivery(int);
int AfterDays;
};
struct Cargo: AggregateRoot
{
Cargo(Delivery*, int);
~Cargo();
void Delay(int);
private:
Delivery* delivery;
};
}
在本案例中,首先定义了领域驱动设计(DDD)中的基石概念:实体(Entity)和值对象(ValueObject)。每个实体都拥有一个唯一的标识符id。在实体的基础上,进一步定义了聚合根(AggregateRoot),它作为DDD中的核心组成部分,继承了实体的特性,确保了其自身的完整性和一致性。
以Cargo为例,它被定义为一个实体,同时也是聚合根。Delivery则被设计为一个值对象,虽然在实现上为了提高效率,采用了类似于C++中的结构体(struct)的实现方式,但在概念上,它仍然属于值对象的范畴。
在DDD的实践中,代码目录结构的组织方式对于维护清晰的分层体系至关重要。Domain层作为领域模型的核心,应当保持独立,不依赖于其他任何层。然而,许多团队在实施过程中,未能建立严格的工程纪律,导致代码结构混乱,领域模型受损。
为了遵循分层架构的原则,示例中的代码结构应遵循一定的规则,确保每一层的依赖关系得到妥善管理。
Domain是不依赖于任何的其它对象的。Repositories是依赖于Domain的,实现如下:引用了model.h。
#include "model.h"
#include <vector>
using namespace domain;
namespace repositories {
struct Repository
{
};
...
Services是依赖于Domain和Repositories的,实现如下:引用了model.h和repository.h
#include "model.h"
#include "repository.h"
using namespace domain;
using namespace repositories;
namespace services {
struct CargoProvider : Provider {
virtual void Confirm(Cargo* cargo){};
};
struct CargoService {
... ...
};
...
在软件开发过程中,依赖注入(Dependency Injection)是一种常用的设计模式,它帮助我们降低组件间的耦合度。依赖注入的核心思想是将组件所需的依赖项在外部提供,而不是由组件自身创建。这种方式不仅简化了组件的测试,还提高了代码的灵活性和可维护性。在最近的测试工作中,我们采用了一个控制反转(Inversion of Control, IoC)框架来实现依赖注入。通过这种方式,我们成功地将一个名为CargoService的依赖项注入到了我们的API中。这不仅保持了接口和业务服务之间的清晰界限,还满足了在测试环境中对API进行实例化的需求。
auto provider = std::make_shared< StubCargoProvider >();
api::Api* createApi() {
ContainerBuilder builder;
builder.registerType< CargoRepository >().singleInstance();
builder.registerInstance(provider).as<CargoProvider>();
builder.registerType< CargoService >().singleInstance();
builder.registerType<api::Api>().singleInstance();
auto container = builder.build();
std::shared_ptr<api::Api> api = container->resolve<api::Api>();
return api.get();
}
在软件开发的领域模型确立之后,开发者们往往会思考如何将其转化为实际的业务应用。在这一转化过程中,单元测试的编写成为确保软件质量的关键步骤。尽管单元测试已成为软件开发的标准做法,但编写出真正高质量的单元测试却常常让许多开发团队感到头疼。设计一套优秀的测试用例往往比实现应用本身更具挑战性。
评估单元测试的优劣并没有统一的标准,但有一个基本原则是,测试用例应当针对业务需求进行设计,而非仅仅测试代码的实现方式。我们的目标是满足业务需求,而实现这些需求的方式可以多样化。我们不希望因为代码实现的微小变动,比如函数的重命名,就需要对测试用例进行相应的修改。因此,设计单元测试时,应尽量使其与业务逻辑紧密相关,而与具体的实现细节保持一定的距离。 测试驱动开发(TDD)是一种高效的软件开发实践,当正确实施时,它能够促进团队之间的沟通,确保业务需求的准确实现。TDD与领域驱动设计(DDD)虽然在概念上并不完全相同,但它们在核心理念上却有着相似之处:都是以业务为中心进行分析和设计。DDD通过将整个业务问题域划分为多个子问题域来实现业务的清晰划分;而TDD则是在实现阶段,通过从简单到复杂的测试用例,逐步引导开发过程,从而确保业务需求得到满足。以下测试用例展示了在DDD构建的核心模型上应用TDD的过程。
TEST(bc_demo_test, create_cargo)
{
api::CreateCargoMsg* msg = new api::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对象的标识符与测试信息中提供的一致,并且其发货日期与预期相匹配。通过这一测试,我们成功驱动了接口Api::CreateCargo的实现。接下来,我们关注另一个测试场景,即Cargo的延迟处理。在这个场景中,我们再次观察到接口Api::Delay的实现被驱动出来。
TEST(bc_demo_test, delay_cargo)
{
api::Api* api = createApi();
api::CreateCargoMsg* msg = new api::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);
}
在软件开发领域,测试驱动开发(TDD)作为一种实践,一直伴随着关于架构设计的讨论和疑虑。资深的架构师们担心,如果完全根据业务需求来驱动实现,可能难以构建出有效的技术架构,而且每次重构实现的成本可能非常高。然而,领域驱动设计(DDD)的引入在一定程度上缓解了这些担忧。通过在初期进行战略和战术层面的建模,确立了核心领域的架构。这个架构是经过预先综合讨论和决策形成的,它考虑了更广泛的业务问题,比TDD在业务需求层面的应用更为宏观。在核心模型的基础上,测试用例的设计也变得更容易从应用角度出发,从而降低了测试设计的难度。
关于预先设计的问题,一些读者可能会对DDD作为敏捷开发的一部分,其目标是构建具有响应力的架构模型,而这里似乎一切都预先设计好了,感到疑惑。需要强调的是,我们仍然反对一开始就进行大规模的设计(Big-Design-Up-Front,BDUF)。但我们认可对核心领域模型的前期分析和设计,这有助于我们更快地响应后续的业务变化。这并不意味着核心领域模型是固定不变的,而是其变化频率相对较低。如果核心领域模型变化频繁,我们可能需要考虑业务是否发生了根本性变化,是否需要建立新的模型。
预先定义的模型应该局限在分解出的核心问题域内,我们并不期望一次性建立整个复杂业务领域的所有模型。这种范围的限制在一定程度上也限制了预先设计的范围,促使我们更多地采用迭代的方式来看待建模工作。此外,应该有核心团队来维护核心领域模型,并不是说所有模型的设计和更改都必须由这个团队完成,而是希望通过这个团队促进更广泛的交流和沟通。检验模型是否落地的标准是使用模型的团队是否能够就模型本身达成共识。许多团队通过代码审查等方式持续实践基于核心模型的交流,使模型成为团队的共同责任。
在实践DDD时,我们需要遵循“模型是用来交流的”这一核心原则。本文介绍的方法和模式旨在帮助大家更容易地交流领域模型,是对DDD战略和战术设计的补充。 领域驱动设计(DDD)作为软件架构设计的重要方法,近年来在中国逐渐受到关注。随着数字化时代的到来,企业对架构设计的响应力提出了更高的要求。ThoughtWorks公司作为DDD的倡导者之一,致力于推动DDD在中国的发展,并通过举办领域驱动设计中国峰会,为技术人员提供了一个学习和交流的平台。峰会不仅邀请了国内外的顶级专家分享DDD的最新实践和经典案例,还特别邀请了Event Storming之父Alberto Brandolini进行深度交流。此外,峰会还设有工作坊,通过事件风暴工作坊的方式,帮助参与者快速深入地探索问题域,建立协作渠道,产出清晰具体的模型。峰会的举办,无疑为国内软件架构师提供了宝贵的学习和交流机会,有助于提升企业自身的技术能力和数字化创新形象。
- 原文作者:知识铺
- 原文链接:https://index.zshipu.com/geek001/post/20240723/DDD%E5%AE%9E%E6%88%98%E7%AF%87%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84%E7%9A%84%E4%BB%A3%E7%A0%81%E7%BB%93%E6%9E%84--%E7%9F%A5%E8%AF%86%E9%93%BA/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com