DDD 的目标 -- 设计高质量的软件模型
- 可测试: 所有的业务相关代码可以通过单元测试进行覆盖。
- 易于扩展:可以在代码开发的各个阶段依然保持较好的业务可扩展性,特别是在业务复杂度不断上升的情况下。
- 组织良好:即使在敏捷开发的要求下,依然能适度地保证设计的合理性,即在各个版本迭代中使用一个可接受的成本获取合适的设计质量。
当前的软件开发的问题
case1.png业务专家的思维方式:领域专家更专注于功能点的实现效果,产品级别的体验,缺少于对于内在业务逻辑关系的梳理,或者是这种业务关系梳理的表达。
技术专家的思维方式:技术专家更专注于技术实现的细节,这个表结构怎么设计,需不需要加索引,sql怎么写性能高,但是容易忽略业务所蕴含的本质逻辑。
case2.png结果:修改业务功能越来月困难。
设计问题概括:
-
在领域专家和技术专家之间存在巨大的沟通鸿沟。领域专家 将眼光放在业务的价值上,技术专家将眼光放到技术实现上。
这样软件项目中天生产生了一种映射,需要将业务人员的所想映射到开发人员所想。这类单向的业务知识的传递的结果是巨大的沟通鸿沟 -
业务的无法预测性,即使领域专家,也无法预测业务的变化。业务的概念和目标会随着时间的发展被重新定义。技术专家希望从业务专家处获得所有概念进行建模是不可行的。
-
没有方法平衡软件质量和开发效率。面对业务的复杂度的变化缺少合适的应对方案。 对于技术专家来说需要一致在过度设计和缺乏设计中摇摆。没有适当的方法取得一个平衡点。
-
希望通过敏捷迭代的方式加快对需求变化的适应却同时造成了软件软件质量的下降,或者是功能迭代成本剧烈上升。
综上所述 DDD认为当前的开发方式中的问题或者它希望解决的问题在于:“业务设计和技术设计是割裂的”。这样割裂的结果导致,需求分析的结果无法直接进行设计编程,而能够进行编程运行的代码却扭曲需求,导致客户(领域专家)运行软件后才发现很多功能不是自己想要的,而且软件迭代响应需求变化的成本越来越高。
DDD 如何解决这些问题
DDD希望将领域专家和技术专家整合至同一个交付团队,领域专家可以“准确地传达业务规则”. 同时领域专家是可以参与到业务代码的设计中来。同时将技术专家从繁杂的技术实现中回归到业务实现中,不是面向数据编程面向业务编程,面向领域逻辑编程。DDD通过提出了领域模型概念,统一了分析和设计编程,使得软件能够更灵活快速跟随需求变化。
业务/产品的迭代需要战略设计
整个交付团队在开始工作之前需要进行战略设计, 战略设计流程如下。
case4.png1 划分领域,子域,界限上下文
领域即是业务。表示组织所从事的一切业务。 将领域划分成子域即是划分不同的系统和业务的关注点。
那为什么在设计之初需要划分领域?
往往设计人员会将实现集中在一些‘实体’、‘对象’ 或者 特定的业务流程上面,甚至使用UML模型,但是全领域通用模型的建立是不可行的。随着各个业务点的需求不断迭代,各个领域模型需要有独自演化的能力。大而全的统一模型代表了各个领域模型在全局的高度耦合。
可行的方式是将整个业务领域划分成各个关注点不同的子域。各个子域对应是不同的界限上下文,并且子域会自发地依照子域需求的变化进行演化。
如何划分子域:将子域分为三类
- 核心域:
最核心的业务领域,也是公司最具有价值的业务体现,具有最高的开发优先级以及资源的倾斜,需要投入最好的交付团队。 - 支撑域:
用于对核心的业务领域作出支持,能够实现功能即可,不需要作为核心的业务,投入资源的优先级较低,甚至可以使用第三方(外包)等现有的实现方案,例如短信推送,图片存储等,报表分析。 - 通用域:
对于非核心又能支撑各个业务的通用领域,通过剥离这些领域的功能服务,盘活已沉淀的软件开发资源。例如用户注册系统,基础权限管理系统,基础用户信息系统等。
领域划分的原则:
同时领域的划分应该尽量是内聚的,领域内所包含的概念是紧密相连或者有直接关系的,通常是为同一个共同业务目标服务的相关功能以及概念,同时同一个领域应该尽量被包括在同一个界限上线文中。
什么是界限上下文的定义:
界限上下文是一个语义的边界,同一个概念命名在同一个界限上下文中应该是唯一的,而在不同的界限上下文中可以是区别的。
例如订单这个概念在订单系统中表示一次购物行为的详细信息。
而在库存预测系统中,订单的概念可能只需要有产品,价格,数量并不会包含用户的具体地址,或者订单的收货具体时间点等信息。
对于一个电商系统,假想的领域划分如下:
case5.png
2 确定通用语言
通用语言是一种团队的公用语言,由业务专家和技术专家共同确定。确定通用语言的方式方法包括但不局限于:
-
a 绘制物理模型图和概念模型图,标示以名字和行为(不是真正的设计图,类似于团队一致确认的思维导图)。
-
b 根据概念图细分流程,即所提供的服务用例。
-
c 概念图中所出现的对象建立词汇表,不必一定需要依赖工业级的术语词汇,能达成团队一致即可。
-
d 需要通过团队的方式对术语表和思维模型达成一致,确保业务概念的一致性,即使业务概念发生变化依然需要共同确定概念的语义解释,解决理解上的冲突。
使用思维导图创建概念:
下图是一个简单的电商购物领域的通用语言设计,将领域功能按照“流程” “用例” “行为细则”的概念进行分解
case6.png
建立术语表:
- 地址:地址是国内的一个固定地点的表示,应当包含省市 区县 街道 这几个目标。
- 优惠是指可以选择多种优惠方式:
- 优惠券:.......
- 满减:.......
- 特定时间段的优惠活动:.........
- 支付方式:用户支付订单所产生的费用的方式,可以选择多种方式
- 支付宝支付:........
- 积分支付: ..........
在通用语言建立之后,技术专家可以依据通用语言进行软件建模设计,业务专家可以根据通用语言设计产品页面、交互等产品逻辑。
根据团队确认的通用语言,业务专家可以开始作出产品详细的设计,技术专家可以数据建模,软件设计等。大家共同运用专业能力确保最后的交付。
DDD的战术 -- 如何解耦技术实现和业务逻辑代码
架构风格
分层架构
DDD 可以使用传统的分层架构来实现:
image.png
分层模块的缺点是层与层相互依赖,使用依赖倒置的原则可以解耦各个模块的实现。
依赖倒置原则:高层模块不应该依赖于底层模块,抽象不应该依赖于细节,细节依赖于抽象。
例子中的代码是实现一个简单的用户子域,简单实现了用户获取和注册的功能
-
领域层:
-
领域模型
type User struct { ID int `json:"id"` Name UserName `json:"name"` } func (user *User) FullName() string { return user.Name.Full() } type UserName struct { FirstName string `json:"first_name"` SecondName string `json:"second_name"` } func (un UserName) Full() string { return un.FirstName + un.SecondName } func (user *User) UpdateName(firstName, secondName string) { user.Name = Name{ firstName, secondName } }
-
实体
用于抽象领域模型中的对象并聚合这个对象的各个属性。实体对象具有唯一性,通常使用唯一标示id表示区别。实体id 可以是用户提供唯一标示,也可以是应用程序生成唯一标示。 -
聚合
将领域的实体对象和值对象放在一致性边界之内的概念就是一个聚合。聚合是维护一致性的最小单位,即对于聚合内的数据应当保证一致性,也称为聚合的不变性。
聚合的本质是将具有强约束关系的数据放在一起。正是这种约束的特性保证了数据状态的强一致要求。
这些强一致的约束,同时保证了在一个聚合内的所有实体对象都是具有相同生命周期的。 -
领域事件
将当前领域状态的变更通过事件的方式同志给外部别的领域。领域事件是可以作为通用语言的一部分的。通常在一次
type Event struct { EventName string UserID string }
- 领域仓储(资源库)
- 用于者持久化聚合对象或者实体对象的状态。通常会使用到DAO技术。也可以使用数据库语言进行sql直接操作数据库。
- 用于在不同的上线文界限中实现上下文的映射。
type UserRepository interface { Get(id int) (*User, error) GetAll() ([]*User, error) Save(*User) error GetNewRegisterID() (string, error) }
- 领域服务
抽象一个无状态的操作,实现特定的某个领域的任务,多用以多个实体或者聚合对象的操作。
type UserService struct {} func (us *UserService) RegisterUser(firstName, secondName string, repository UserRepository) (*User, *Event, error) { id ,err := repository.GetNewRegisterID() if err != nil { // TODO fmt err return nil, nil, err } u := &User { ID: id, } u.UpdateName(firstName, secondName) err = repository.Save(u) if err != nil { // TODO fmt err return nil, nil, err } event := &Event { UserID: id, EventName: "user-registered", } return u, event, err }
-
-
-
应用层
- 应用服务:
对应当前服务对外提供的抽象接口,也可以视为是服务用例。主要目的是解耦服务接口和领域模型。服务用例对客户端来说是需要稳定的,但是领域模型可能随着业务迭代快速变化
type UserUseCase interface { GetUser(ctx context.Context, userID string) (*example.User, error) RegisterUser(ctx context.Context, firstName, secondName string) (*example.User, error) } // a imply for MDetailUseCase interface type appServer struct { monitor UsecaseMonitor svc *domain.UserService repository domain.UserRepository logger *zap.Logger } func (appService *appServer) GetUser(ctx context.Context, userID string) (*example.User, error) { var ( startTime = time.Now() err error ) us, err := appService.repository.Get(userID) if err != nil { appService.handleCaseError(CaseNameGetUser, "CreateTeamSummery", startTime, err, "match_id", userID) return nil, err } appService.addMatchRecord(CaseNameGetUser, startTime, true) return appService.toUser(us), nil } func (appService *appServer) RegisterUser(ctx context.Context, firstName, secondName string) (*example.User, error) { var ( startTime = time.Now() err error ) svc := appService.svc us, evt, err := svc.RegisterUser(firstName, secondName, appService.repository) if err != nil { appService.handleCaseError(CaseNameRegisterUser, "CreateTeamSummery", startTime, err, "match_id", userID) return nil, err } appService.publisher.PublishEvent(evt) appService.addMatchRecord(CaseNameRegisterUser, startTime, true) return appService.toUser(us), nil }
- 应用服务:
-
用户接口层:
- 服务对外部服务/接口的具体实现,例如rpc 接口,http 接口 (rest接口/web接口),消息服务接口 kafka/rabbitmq
// grpc server 的实现 var _ example.UserServiceServer = &rpcServer{} type rpcServer struct { ucase usecase.UserUseCase } func NewRpcServer(uc usecase.UserUseCase) *rpcServer { return &rpcServer{ ucase: uc, } } func (srv *rpcServer) GetUser(ctx context.Context, req *example.UserRequest) (*example.UserResponse, error) { user, err := srv.ucase.GetUser(ctx, req.UserId) if err != nil { return nil, err } return &example.UserResponse{ Code: 0, Data: user, }, nil } func (srv *rpcServer) RegisterUser(ctx context.Context, req *example.RegisterUserRequest) (*example.UserResponse, error) { user, err := srv.ucase.RegisterUser(ctx, req.FirstName, req.SecondName) if err != nil { return nil, err } return &example.UserResponse{ Code: 0, Data: user, }, nil }
- 基础设施层
对应repository 和 message 等非业务接口的技术实现。会涉及到很多技术相关实现细节。但是业务逻辑应当尽量剥离出这个层次的代码,比如数据库的sql,表结构,数据索引的实现细节。消息服务中间件调用的细节等。甚至是编解码的方式。
项目目录结构
/app
/config
config.go // 各种配置文件中entry 的结构体
/domain
/league // 模块名称
model.go // 领域模型,包括entity aggregate
repository.go // 领域相关仓储的interface的定义
service.go // 领域服务的相关代码
/usecase // 服务用例相关的代码,或者叫做应用服务相关,这一层的抽象模型可以直接使用protoc 定义的对象
usecase.go // 具体用例的实现
monitor.go
common.go
/interface // 各种接口适配器的实现,既对应的是**用户接口层**
monitor.go // 监控的具体实现
grpc.go // grpc server 的实现
message.go //
/infrastructure // 基础设施
/repository // repository 接口的实现
/message // 领域事件 push / publish 的具体实现
/registry
registry.go // 类似spring 中的application context,主要用于获取依赖注入的接口实例
/proto // gpc protocal files
/cmd // 命令行启动配置
main.go
六边形架构
DDD通常也使用六边形架构来表示,目的是显著区分各个模块的边界
image.png
后记
本文并未涵盖过多的技术实现细节。如果希望深入DDD技术实现细节中需要关注的是
在微服务中使用DDD
+ 如何实现上下文集成并且映射。
+ 如何实现领域事件的异步通讯,以及一致性保证,消息队列如何设计。
+ 如何做服务自治,减少服务之间启动的依赖。
+ 并发情况下以聚合对象为单位的版本控制。
+ 数据缓存应该如何实现。
+ 如何实现长时间,长流程的聚合。
网友评论