1 背景

我们需要重构一个旧系统,新系统采用微服务架构,其中有几个关键服务逻辑复杂,对性能要求很高,因此经决策后计划采用 C++ 实现。

此前有耳闻领域驱动设计(DDD)可以从设计上解耦,低成本变更,在复杂业务逻辑系统中有较好的应用前景,因此我们计划在新系统的关键服务中应用 DDD。

不过目前接触到的 DDD 案例基本上都是 JAVA 的,他们或多或少利用了 Spring 相关的特性,这对我们在 C++ 应用十分不友好,毕竟 C++ 并没有一个成熟的 DDD 框架。于是,在我们了解了 DDD 的核心思想后,我们根据自身理解,在 C++ 服务中应用了 DDD,并形成了一套较为稳定的标准模板。

有关 DDD 相关概念的说明可以参考 领域驱动设计-DDD 一文,在这里,我主要介绍洋葱架构(Onion Architecture)的实践。

2 实践

为方便理解,本文以 资金测算服务 举例,介绍如何基于 DDD 实现一个简单的服务。

资金测算服务介绍

资金测算服务是目前会员资金风控系统中的一个 微服务,其职能是根据会员账本和会员清算单测算出会员的新账本。

2.1 业务梳理

我们梳理一下 资金测算服务 的主线流程:

graph TD
A(收到测算请求)-->B(从redis获取当前会员账本)-->C(从redis获取当前会员清算单)-->D(测算会员新账本)-->E(上传会员新账本到redis)

可以看得出,这个服务的功能非常简单,我们可以根据 IPO( 输入处理输出) 分类方式,将业务梳理成三个部分:

  1. 输入 Input
    1. 接受测算请求
    2. 获取当前会员账本
    3. 获取当前会员清算单
  2. 处理 Process
    1. 测算会员新账本
  3. 输出 Output
    1. 上传会员新账本

这三个部分我们就可以认为是该服务的核心业务逻辑了。

我们接下来,就将:

  1. 业务流程放到 application
  2. 业务实现放到 domain
  3. 与外部交互实现放到 adapter

形成以下目录结构:

2.2 目录结构

.
├── adapters # 放各类适配器
├── app # 放程序入口、程序流程
├── domain
   ├── impl # 放业务实现、业务流程
   └── interface # 放业务接口、交互接口
       └── entity # 放公共类型、结构体定义等
└── third_party # 放三方库

2.3 domain 设计实现

简化代码

为了简化代码,下面对部分逻辑进行了省略:

  • CHECK_RET [XXXXX]:如果返回值为 false,则 return false
  • DECLEAR [XXXXX]:声明一个变量 XXXXX,这个变量的类型需要结合上下文判断。

按照业务梳理结果,我们可以先设计 IPO 每个业务逻辑的接口:

  • 接受测算请求 domain/interface/settlement_request.h
using OnSettlementRequestCallBackFuncType = std::function<bool()>;
class ISettlementRequest{
public:
	virtual void setOnSettlementRequestCallBack(OnSettlementRequestCallBackFuncType) = 0;
};
using ISettlementRequestPtr = std::shared_ptr<ISettlementRequest>;
  • 获取当前会员账本 domain/interface/firm_fund_loader.h
class IFirmFundLoader{
public:
	virtual bool getFirmFund(FirmContainer<FirmFundDetail>& firm_fund_map) = 0;
};
using IFirmFundLoaderPtr = std::shared_ptr<IFirmFundLoader>;
  • 获取当前会员清算单 domain/interface/clear_result_loader.h
class IClearResultLoader{
public:
	virtual bool getClearResult(FirmContainer<FirmClearDetail>& firm_clear_map) = 0;
};
using IClearResultLoader = std::shared_ptr<IClearResultLoader>;
  • 测算会员新账本 domain/interface/settlement.h(包含业务逻辑)
class ISettlement{
public:
	virtual bool calc() = 0;
};
  • 上传会员新账本 domain/interface/settlement_result_uploader.h
class ISettlementResultUploader{
public:
	virtual bool upload(const FirmContainer<FirmFundDetail>& firm_fund_map) = 0;
};
using ISettlementResultUploaderPtr = std::shared_ptr<ISettlementResultUploader>;

我们希望在 测算会员新账本 时将整个业务流程串起来,就需要完成 class ISettlement 的实现:

  • domain/impl/settlement_impl.h
class SettlementImpl : public ISettlement{
private:
	ISettlementRequestPtr        m_request{nullptr};
	IFirmFundLoaderPtr           m_fund_loader{nullptr};
	IClearResultLoaderPtr        m_clear_result_loader{nullptr};
	ISettlementResultUploaderPtr m_uploader{nullptr};
 
public:
	bool init(ISettlementRequestPtr        request, 
			  IFirmFundLoaderPtr           fund_loader, 
			  IClearResultLoaderPtr        clear_result_loader,
			  ISettlementResultUploaderPtr uploader){
		m_request = request;
		m_fund_loader = fund_loader;
		m_clear_result_loader = clear_result_loader;
		m_uploader = uploader;
		m_request->setOnSettlementRequestCallBack([&](){
		  return calc();
		});
		return true;
	}
 
	virtual bool calc() override{
		// 获取当前会员账本
		DECLEAR firm_fund_map;
		CHECK_RET m_fund_loader->getFirmFund(firm_fund_map);
		// 获取当前会员清算单
		DECLEAR firm_clear_map;
		CHECK_RET m_clear_result_loader->getClearResult(firm_clear_map);
		// 测算会员新账本
		DECLEAR result;
		CHECK_RET calcFund(firm_fund_map, firm_clear_map, result);
	    // 上传会员新账本
	    CHECK_RET m_uploader->upload(result);
	    
	    return true;
	}
 
private:
	bool calcFund(const FirmContainer<FirmFundDetail>& in_firm_fund_map,
				  const FirmContainer<FirmClearDetail>& in_firm_clear_map,
				  FirmContainer<FirmFundDetail>& out_firm_fund_map){
		// 业务逻辑,计算出的新账单为out_firm_fund_map
		return true;
	}
 
};

接下来可以对 SettlementImpl 做单元测试,以验证流程的正确性。

2.4 adapters 设计实现

当完成 domain 部分的验证后,就可以着手于 adapters 部分的实现了。

我们可以留意到,domain 中存在一些未实现的接口,这些接口大多与外部交互有关,这也是我们的目的——将与外部交互有关的逻辑放到 adapters 中实现,将与业务相关的逻辑在 domain 中实现,这样业务是业务,技术是技术,二者只要满足接口设计,变动时就不会相互影响。

我们选 ISettlementResultUploader 举例:

  • adapters/settlement_result_uploader/settlement_result_uploader_adapter.h
class SettlementResultUploaderImpl : public ISettlementResultUploader
{
public:
	bool init(const Config& config){
		// 这里根据配置初始化一些信息
		return true;
	}
 
	virtual bool upload(const FirmContainer<FirmFundDetail>& firm_fund_map) override{
		// 这里实现将结果上传到redis中
		return true;
	}
};
using SettlementResultUploaderImplPtr = std::shared_ptr<SettlementResultUploaderImpl>;

在这里,我们可以意识到,如果我们希望将 redis 换成其他的存储中间件,如 OceanbaseOracle 等,我们只需要修改这个实现就行。甚至在前期测试时,我们可以直接将其做成假接口,返回特定的数据,达成测试目的。

2.5 app 设计实现

当我们完成 adapters 的实现后,我们可以将其整合起来了,也就是我们现在需要完成程序流程。

在这个阶段,我们将不再面临抽象的接口,而是将其实现组装起来。

为让代码干净整洁,我专门用一个 SettlementMgr 来干整合的事情:

  • app/settlement_mgr.h
class SettlementMgr{
private:
	SettlementResultUploaderImplPtr m_result_uploader{std::make_shared<SettlementResultUploaderImpl>()};
	// 其他的实现都像示例一样声明
	SettlementRequestImplPtr m_request{....};
	FirmFundLoaderImplPtr m_firm_fund_loader{....};
	ClearResultLoaderImplPtr m_clear_result_loader{....};
	SettlementImpl m_settlement_impl{....};
 
public:
	bool init(const Config& config){
		// 初始化各类adapters
		CHECK_RET m_result_uploader->init(config.get("Uploader"));
		// .....
		// 初始化SettlementImpl
		CHECK_RET m_settlement_impl->init(m_request, m_firm_fund_loader, m_clear_result_loader, m_result_uploader);
		return true;
	}
 
	bool run(){
		m_request->start(); // 开始监听服务
		return true;
	}
 
	// 这里就先不实现stop之类接口了
};

然后就可以在程序入口对 SettlementMgr 进行管理了:

  • app/main.cpp
int main(){
	// 初始化配置
	auto config = loadConfig();
 
	// 初始化日志
	CHECK_RET initLogger(config);
 
	SettlementMgr sm;
	CHECK_RET sm.init(config);
	CHECK_RET sm.run();
 
	return 0;
}

这样,一个符合 DDD 的 C++ 服务就实现出来了。

3 遗留问题

3.1 adapters 单元测试问题

在设计实现 adapters 时,我们发现为了保证接口的稳定性,一切输入输出的数据转换逻辑都需要在 adapters 中实现,同时,数据转换中可能也存在着少量的业务逻辑。

在早期实践中,我们认为应该对 domain 着重测试,adapters 中由于和交互相关,测试相对困难,于是没有做完整的场景覆盖,这导致 domain 逻辑“很稳健“,但涉及到 adapters 经常会出现问题。

起初我们想将 adapters 中剩余的小部分业务逻辑也迁入 domain 中,但很快发现这样不妥:

  • 这部分业务逻辑依赖交互方式,可能换一种交互方式就不存在该逻辑。
  • 一旦放入 domain 中,就会导致 domain 非常臃肿,与使用 DDD 的初衷相悖。

目前只能说又开发人员在编码 adapters 时留意此类逻辑,并单独做单元测试,还没想到有什么合适的办法。

4 感想

从一开始接触 DDD 到开始尝试上手实践,程序的结构改变了不知道多少次,最终才能有一个大家相对满意的结果。

个人认为,DDD 并不是一个框架,它只是设计方式的指导

受益于 单一职责原则依赖倒置原则,我们在技术方案变动、接口改动和业务改动时(全新系统还未稳定),可以非常快速地定位到修改点,并将修改范围缩小到非常精确的位置,修改后单元测试也可以很稳定地覆盖。个人认为它可以延缓一个项目走向屎山的步伐。

当然,这一切都是主观感受,每个项目都有它所面临的主要问题,

希望能给你带来帮助。