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( 输入
、处理
、输出
) 分类方式,将业务梳理成三个部分:
输入 Input
接受测算请求
获取当前会员账本
获取当前会员清算单
处理 Process
测算会员新账本
输出 Output
上传会员新账本
这三个部分我们就可以认为是该服务的核心业务逻辑了。
我们接下来,就将:
业务流程放到 application
中
业务实现放到 domain
中
与外部交互实现放到 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
换成其他的存储中间件,如 Oceanbase
、Oracle
等,我们只需要修改这个实现就行。甚至在前期测试时,我们可以直接将其做成假接口,返回特定的数据,达成测试目的。
2.5 app 设计实现
当我们完成 adapters
的实现后,我们可以将其整合起来了,也就是我们现在需要完成程序流程。
在这个阶段,我们将不再面临抽象的接口,而是将其实现组装起来。
为让代码干净整洁,我专门用一个 SettlementMgr
来干整合的事情:
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
进行管理了:
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 并不是一个框架,它只是设计方式的指导 。
受益于 单一职责原则 和 依赖倒置原则 ,我们在技术方案变动、接口改动和业务改动时(全新系统还未稳定),可以非常快速地定位到修改点,并将修改范围缩小到非常精确的位置,修改后单元测试也可以很稳定地覆盖。个人认为它可以延缓一个项目走向屎山的步伐。
当然,这一切都是主观感受,每个项目都有它所面临的主要问题,
希望能给你带来帮助。