领域驱动开发设计 - 具体实践
概述
【领域驱动开发设计(Domain-Driven Design,简称DDD】是一套【理论基础】,它强调的是,编程和架构的思想而非技术细节
我们知道,务随着不断演化迭代, 业务会越来越复杂,系统也越来越冗杂。如果业务一旦需要调整,都是牵一发而动全身
【领域驱动开发设计】就是为了解决【业务不断迭代】的场景中,怎么写出更具【内聚性】【扩展性】【可维护性】的代码
在了解这种开发思想之前,我们先看看,目前我们的架构面临哪些问题
面临问题
目前我们采用的是Action/PageService/DataService/Dao分层架构
使用这种分层架构,会很自然地写出【过程式】代码,类似于一个函数处理一段业务。如果业务简单不会有什么问题,如果业务复杂起来,会有一些意想不到的困哪。
举个例子:
需求:页面上需要展示论文列表(论文信息保存在数据库中)和有效论文的总量,需实现:
1 根据论文status显示不同的文案,status=1显示有效,否则无效
2 列表中的论文id如果匹配当前输入的paperid,则需要放在列表第一位
3 论文中有个字段是price单位是分,需要除100
4 页面中有个位置显示逻辑是:如果论文有name就显示name否则显示description
5 页面只需要显示论文的id,status, price, name或description
6 要统计有效的论文的总量
按照Action/PageService/DataService/Dao分层实现过程:
- Action: 负责处理输入,输出
- PageService: 负责处理业务逻辑
- DataService: 负责获取数据库数据,缓存等
- Dao: 与数据库通信
代码如下:
// PaperageService的实现过程
class PaperPageService {
public function queryPapers()
{
// 先从DataService拿数据
$paperobj = new Service_Data_Paper();
$papers = $paperobj->queryPapers($condition);
// 用于实现 【需求 6】
$count = 0;
// 用户输入的id
$paperId = 'xxxxxxx';
foreach ($papers as &$paper) {
$statusDesc = "无效";
if ($paper["status"] == 1) {
$count++;
$statusDesc = "有效";
}
// 实现了【需求 1】
$paper["status"] = $statusDesc;
// 实现【需求 2】
if ($paper["id"] == $inputId) {
// 打上一个标记到时候用于排序
}
// 实现了【需求 3】
if ($paper["price"] > 0) {
$paper["price"] = $paper["price"] / 100;
}
// 实现了【需求 4】
if (isset($paper["name"]) && $paper["name"]) {
$paper["explain"] = $paper["name"];
} else {
$paper["explain"] = $paper["name"];
}
}
}
// 处理排序
usort($paper)
// 返回
return array(
"data" => $papers,
"count" => $count,
)
}
// DataService的实现过程
class PaperDataService {
public function queryPapers($condition)
{
$dao = new Dao_Paper()
// 处理缓存之类的逻辑,返回dao里面的数据
return $dao->queryPapers($condition)
}
}
// class PaperDaoService {
public function queryPapers()
{
// 写一段sql,查询数据库字段,学术代码很多都是手写sql,不知道其他项目是否是这种情况
}
}
需求瞬息万变,现在问题来了,假如某天PM说:
1, 另外有页面需要展示论文,只需要实现需求【3, 4】
2, 另外有页面需要展示论文列表,论文更新时间还需要格式化
3, 另外有页面需要展示论文的所有字段,需要根据不同的type,分成不同的组
4, 另外有页面需要展示论文列表, 但是还需要展示论文的下载信息(保存在另外的表里面)
5, ......
如果是你,该怎么去优化这段代码,来适应不同的业务场景。
回答这个问题之前,我们需要先搞清楚这样写代码有什么问题:
代码层面上来讲: a. 代码复用性差,函数的实现是为了解决一个具体业务,专业点讲就是为了实现而实现
b. 函数实现复杂,优秀的函数应该是保证【单一职能】但是过程式很容易导致一个函数做很多事情
c. 不利于单元测试,或者说根本没有办法单元测试,验证一个函数好坏的标准就是是否可以简单做单位测试
d. 随意设置临时变量去维护某种状态,比如例子中的count, 标记等
e. 扩展性差,业务迭代后,代码会越来越臃肿,难以维护。
架构上来讲:
a. 一个函数实现一个需求,会导致业务碎片化,难以抽象出业务模型,不利于业务沉淀
b. 不同业务之间的相互调用沟通成本增加 拿上面的例子来说,做其他模块的同学需要调用论文列表的接口,他需要知道论文列表的处理业务,否则很容易出错(比如需要知道论文的price读取出来必须除以100,否则会有大问题)
c. 【过程式】的实现因人而异,可以写的【很随意很自由】,不利于代码管理
d. DateService层, Dao层的贫血症,
e. 【过程式】阅读起来很累,只要搞清楚业务才能懂代码,而且需要看完代码才能知道具体代码内容,可想而知,这种代码的公用性了,我有看你代码的时间还不如自己写一遍,于是越来越多的重复代码
下面, 我们思考优化这段代码的过程中,会一步一步的说明DDD模式为什么可以解决以上的问题
解决方案
可以通过重构【过程式】达到一定程度的代码优化,拿上面的例子来说,可以做的事情是:
把论文数据的通用处理方式(比如:price需要除以100)封装到一个函数里面
但是这样封装的效果还是很有限,从本质上解决不了以上出现的问题,要想更好的解决,我们需要换个角度去思考问题。
回到上面的需求:
需求:页面上需要展示论文(论文保存在数据库中)列表和有效论文的总量,需实现:
1 根据论文status显示不同的文案
2 列表中的论文id如果匹配当前输入的id,则需要放在列表第一位
3 论文中有个字段是price单位是分,需要除以100后显示
4 页面中有个位置显示逻辑是:如果论文有name就显示name否则显示description
5 页面只需要显示论文的id,status, price, name或description
6 要统计有效的论文的总量
之前我们的思考方式是【过程式】: 第一步做什么,第二步做什么,第三步做什么 ...
现在转变思路,用【面向对象的方式】去解读需求
把【论文当做一个对象】,需求解读为:
需求1: 需要获取论文的status, status_name(用来表示不同的文案)属性,status_name需要status计算出来
需求2: 返回的论文列表需要根据输入的id排序,也就是说有【排序】需求
需求3: 需要获取price属性,并且price的值需要除以100
需求4: 论文有name, description,explain(用来最终显示),并且explain是根据name, description 计算出
需求5: 页面需要控制论文属性的输出(排除掉其他不需要的属性)
需求6: 统计有效的论文总量,也就是说有【统计】需求
基于以上的分析,我们在进一步进行业务抽象
1 需要获取论文对象属性,特别需要搞清楚
a. 某些属性是需要【转换】例如 price,
b. 某些属性需要 【追加】 (数据库没有这个字段,要通过其他字段计算出来) 例如status_name
c 某些属性直接输出 例如 id
2 需要对返回的结果进行sort, 统计
完成此业务,我们需要抽象出【论文对象】,或者说是【论文模型】,明确这个对象的属性以及属性的操作方法
class PaperModel {
// 原属性
public function $attributes;
// 对外可见的属性,上面的需求需要如下几个属性
public $visiable = ['id', 'name', 'price', 'description', 'status', 'status_name', 'explain'];
// 需要【转换】的属性
public $mutators = ['price'];
// 需要【追加】的属性
public $appends = ['status_name', 'explain'];
// 初始化
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
// 计算price属性
public function getPriceAttribute()
{
if ($this->attributes["price"] > 0 ) {
return $$this->attributes["price"] / 100;
}
}
// 追加status_name属性
public function getStatusNameAttribute()
{
return ($this->attributes["status"]) ? '有效' : '无效';
}
// 追加explain属性
public function getStatusAttribute()
{
return ($this->attributes["name"]) ?: $this->attributes["description"]
}
}
使用php的面向对象的机制,PaperModel类可以实现这样的效果 :
当访问$papermodel->status_name
的时候, 会自动调用到$papermodel->getStatusNameAttribute()
方法,这个实现细节我们先不讨论
接着往下看,我们怎么使用这个对象?
// 使用这个对象
class PaperageService {
public function queryPapers()
{
// 校验数据
......
$paperobj = new Service_Data_Paper();
// 从DataService拿数据
$papers = $paperobj->queryPapers();
$result = [];
// 集中计算对应的model对应的属性
foreach($papers as $paper) {
$data = [];
$paperModel = new PaperModel($paper);
$data['id'] = $$paperModel->id;
$data['price'] = $paperModel->price;
$data['status_Name'] = $paperModel->status_name;
$data['explain'] = $paperModel->explain;
// 聚合数据
$result[] = $data;
}
// 排序,统计
....
}
代码似乎变得简洁一点了, 具有好处:
1 代码更简洁,
比如,如果要修改price的计算逻辑,只需要修改PaperModel类的getPriceAttribute方法,不需要改PaperageService对象
2 抽象出【论文对象】
随着业务的深入,这个模型会越来越完善,这个对后续开发有巨大帮助
3 增加了代码的复用性
比如其他业务需要获取论文的price,只需要实例化paper对象读取price属性,并不需要关心论文price的计算方法, 代码变得简单可依赖
4 统一【业务处理】代码风格,使用同一种方式去写代码大部分业务
获取【论文对象属性】,只需要实现getAttribute方法即可,大部分web业务都是获取或 修改对象属性,这样能达到【代码即业务】的效果
5 业务对象的单元测试变得简单
除了读取【对象】属性以外,我们还需添加,修改【对象】,比如我们通过PaperModel插入一条论文到数据库, 用户输入paper['type']='1' 是字符串, 需要把type变成整数。所以我们还需要定义一套setXXXAttrbute()
, 在数据插入到数据库之前,会自动调用这个方法,修改用户输入的数据。
于是我们抽象出一个基础的模型如下:
class BaseModel {
// 原属性
public function $attributes;
// 表示对外可见的属性
public $visiable = [];
// 需要'转换'的属性
public $mutators = [];
// 需要添加的属性
public $appends = [];
// 可以被插入到【数据库】的字段
protected $fillable = [];
// 初始化
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
// 读取属性
public function getXxxAttribute()
{
}
// 修改属性
public function setXxxAttribute()
{
}
抽象出模型之后,我们解决了一些问题,特别是【规定读取属,修改属性】的代码书写规范。
请务必要搞清楚PaperModel
是怎么处理业务的。
抽象【论文】模型解决了一些问题,但是并没有解决【过程式】所有的问题,比如统计,排序,页面显示数据的拼装,DataService层的依赖,多个实体之间的依赖等问题。
而且,【论文对象】的实例化也比较复杂,比如,需要在for循环手动实例化对象,如果其他业务需要调用论文列表,也免不了写一些重复的for循环代码。
有没有更好的方法解决这个问题,不急,往下看。这些问题,我们后续会给出答案。
也行你会疑问,不是要讲【领域驱动开发】么,到目前为止还没有太多领域驱动开发的东西,那下面,我们着重介绍领域驱动开发设计。
在这个过程中,我们会一步一步的解决以上出现的问题
领域驱动开发
再次说明,领域驱动编程设计 )不是技术工具集,而是一套思想。
领域驱动编程设计思想集中在核心域里面,其中包含了领域服务,实体、值对象、资源库和聚合等概念。在此基础上,DDD 提出了一套完整的支撑这样的核心领域的基础设施。此时,DDD 已经不再是“面向对象进阶”那么简单了,而是演变成了一个系统工程。
在抽象【实体对象】的过程中,我们已经慢慢接近了领域驱动开发,我们抽象出来的【论文对象】就是领域驱动中的【实体】
下面我们分别介绍领域驱动开发 的各个概念
领域服务
领域并不是多么高深的概念,比如,一个保险公司的领域中包含了保险单、理赔和再保险等概念;一个电商网站的领域包含了产品名录、订单、发票、库存和物流的概念。对于学术来讲,包括论文,下载来源等概念
领域驱动开发的第一步就是领域划分,即限界上下文。
一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部【职责明确】【高度内聚】。
从业务上来讲,界定上下文的就是业务核心部件划分的过程。
所以,领域驱动开发最需要的是 【抽象】能力, 把【业务】抽象出各子领域,每个领域下有对应的实体对象和值对象
并定义实体对象和值对象的之间的逻辑关系
对于论文需求
来讲,其实领域很简单,可以直接理解为处理论文的信息,主要涉及【论文实体】对象等。
实体对象 vs 值对象
实体对象:当一个对象由其标识区分时,这种对象称为实体, 比如,论文有唯一标识id.
实体对象的重点在于【表达业务需求】,而不是在于 【封装公用函数】
一个好的实体抽象可以达到【代码即业务】的效果。阅读【实体对象】即可理解其关联的业务逻辑
实体的生命周期应该是随业务开始而生,业务结束而亡。
值对象:对象没有唯一标识,在具体实践中,我们会把一些【配置信息】作为值对象 来 管理
资源库(Repository)
在上面的例子中,我们曾说一个问题没有解决:
【论文服务】 依赖DataService层,DataService层依赖Dao层,Dao层需要解决,如何更友好的和数据库(shouxwsql)
这里涉及到一个DDD的另一个概念【资源库】
资源库需要承担【资源存储】和【资源获取】的逻辑, 一个典型的Repository应该承担的责任有:
get(key): 获取item
put($key, $value, $minutes): 存储item
has(key): 判断是否存在
pull($key, $default = null): 获取并且删除item
remember($key, $minutes, Closure $callback): 获取或者保存默认值item
实体对象除了依赖【数据库】以外,还可能依赖Cache[redis],RPC[其他服务],CURL[网络],FILE【文件】等各种资源,我们在践行DDD开发模式时,需要封装各类Repository组件
这里我们重点聊一下【数据库】资源库。
我们回顾一下创建【论文对象】的代码:
// 先从DataService拿数据
$paperobj = new Service_Data_Paper();
$papers = $paperobj->queryPapers();
// 计算对应的model对应的属性
foreach($papers as $paper) {
$data = [];
$paperModel = new PaperModel($paper);
$data['id'] = $$paperModel->id;
$data['price'] = $paperModel->price;
$data['status_Name'] = $paperModel->status_name;
$data['explain'] = $paperModel->explain;
// 聚合数据
$result[] = $data;
}
这段代码首先是从数据库读取数据,然后循环把数据【注入】到【论文对象】
这样的方式每次都需要【手动】把数据注入到对象,很麻烦,比如其他组的小伙伴如果需要获取论文列表,他也需要写类似的代码。
好麻烦!
其实我们想想,Dao层无非就是【接受查询条件,拼接sql 执行sql,获取sql数据】任务,PaperModel
可以承担【接受查询条件任务】并通过【委托模式】把【查询条件】委托给【Dao】层,去执行sql,达到如下效果:
# 获取一个对象
$paper = paperModel::get($paperId);
# 获取多个对象 返回Collection对象
$papers = paperModel::mget($paperIds);
# 根据条件查询多个对象 返回是Collection对象
$papers = paperModel::query($condition);
这完全可以做到,想想就激动,再也不需要写Dao代码,也不需要循环实例化【实体对象】,
可以把DataService层,Dao层隐藏在PaperModel背后,实现数据
自动注入到【实体对象】
实现之后,PaperModel的类大概会是这样:
class PaperModel {
// 增加一个新的方法
public function query($condtion)
{
// 输入验证
....
// 收集查询条件,并最终会委托Dao层获取对应的数据,返回collection对象
return $this->where($condition)->fetchAll();
}
}
// 不需要循环处理,不需要外部注入,爽!
class PaperService {
// 实例化论文模型
$paperModel = new PaperModel();
// 获取到模型对象
$papers = $paperModel->query($condition);
// 排序
$papers.sort()
// 拼装前端页面展示需要的属性
....
}
聚合根
聚合在DDD中,也非常重要
聚合,说白了, 就是把把【实体对象】聚合在一起,作为一个整体被外界访问。
在业务中,我们需要考虑两个维度:
1 相同【实体对象】的聚合, 比如查询一个论文列表 2 不同实体对象的聚合,比如需要访问【论文】以及【论文下载信息】,这里就涉及到怎么处理【实体对象】的依赖关系
刚刚上面我们提到一个【Collection对象】这个对象就是用来实现DDD聚合根的概念
首先我们需要清楚 ”实体对象作为一个整体“ 会被怎么处理,又来到了【业务抽象】的乐园,仔细看哦。
在我们的业务中,会需要:
- 论文根据匹配id排序 【排序】
- 统计有效元素 【统计】
- 需要把数据返回给客户端,也就是【序列化】,常用的序列化操作有toArray, toJson toString.
除此之外,可能还需要get【获取】, first【获取第一个】, last【获取最后一个】, groupby【分组】, keys【获取所有的key】, values, diff 等各类操作
所以【collection对象】大概有如下方法:
toArray(): 序列化为array
toJson(): 序列化为json
sort(Closure $callback): 排序
count(Closure $callback): 统计
filter(Closure $callback): 过滤
有了【collection对象】,我们就解决了排序,统计,过滤,分组等逻辑。 (再也不需要设置一堆临时变量)
最后还有个问题,不同【实体对象】之间的依赖。何为依赖,其实就是通过实体A可以查询到实体B, 比如论文(paper表)和论文下载信息(paper_download表),通过paperid可以查询到paper_download信息
# paper信息
{
'id': 1,
'name': 测试,
'type': 1,
"downloads": [
{
'id': 1,
'status': 1,
"url": http://test.com
},
{
'id': 2,
'status': 1,
"url": http://test.com
},
]
}
具体实现细节我们可以不讨论,总之我们可以实现,这样PaperModel里面需要定义一个方法
class PaperModel {
// 定义paper依赖的实体对象
$extras = ['download']
// 定义【Download】对象依赖
public initDownload(Collection $collection) {
$paperids = $collection->lists('paperid');
if (! $paperids) {
return;
}
$download = new DownloadModel();
$downlodas = $ppvSerivce->query($paperids);
foreach ($collection as $paper) {
$data = isset($downlodas[$paper->id]) ? $downlodas[$paper->id]: null;
$paper->setRelation('download',$data);
}
}
}
// 在外部使用,比如需要获取论文的下载源:
$paper = PaperModel::get($id);
// 这样就可以直接获取到对应的下载信息
$downloads = $paper->download;
领域驱动架构
到目前为止,我们主要讲了【领域服务】,【实体对象】,【值对象】,【聚合根】,【实体依赖】等概念
一个完整的领域服务设计架构应该包括:
表现层: 请求应用层以获取用户所需要展现的数据(controller) 应用层: 调用多个领域服务,完成整体的业务逻辑 领域层: 操作领域内的实体对象,值对象,完成领域内的业务逻辑 基础设施: 这一层是一个支持性的库。它提供层之间的信息传递
但是,在实际上使用过程中,我们发现应用层会很薄,大部分的业务只会涉及到一个领域服务, 比如论文,学者等,所以可以根据业务的难易程度,可以考虑去掉应用层。
三层架构也基本上可以满足大部分的业务
领域驱动开发与Restful API设计
细心的你可能会发现,我们还有最后一个问题没有解决
拼装 前端页面 需要显示的 字段
我们不想在傻乎乎的在PaperService
拼装数据,想更优雅的解决这个问题。
换句话,【BaseModel对象】还需要有一个机制可以自动控制【哪些属性可以被外部可见】
有了这样的机制后,有个巨大的好处,比如 页面需要显示论文的id, name, 还有页面需要显示论文id, name, description
在【过程式】开发模型下,我们可能需要些多个方法取分别取不同的属性,并对某些属性做相同的处理。
如果BaseModel能做到自动控制的话,那不要太爽。答案是当然可以。BaseModel
可以实现这几个函数
// 控制前端页面展示
setFields(array $fields): 控制前端页面的展示,序列化时会忽略前端不需要的属性
// 控制读取依赖的资源
setExtras(array $extras): 控制读取哪些依赖的【实体对象】
现在,最终,PaperService的代码就变成了:
class PaperService {
public function queryPapers()
{
// 界面需要显示的数据
$fields = ['id', 'name', 'discription', 'status', 'status_name', 'explain'];
// 依赖的实体对象
$extras = ['download'];
$paperModel = new PaperModel();
$paperModel->setFileds($fields);
$paperModel->setExtras($extras);
// 获取到collection对象
$papers = $paperModel->query($condition);
// 需实现排序的闭包函数
$papers.sort(closure);
// 统计
$count = $papers.count(closure)
return [
"data" => $papers,
"count" => $count
];
}
}
// 在表现层调用PaperService->queryPapers()方法后,序列化后然后返回客户端需要的数据格式, 比如客户端需要的是json的话,流程大概是
$controller->toAarray() -------> $paperService->queryPapers()->toArray() ----> Collection->toArray() ----> BaseModel->toArra() -----BaseModel计算自己的属性
在这里,我们就会发现,领域驱动开发完美适配restapi的api架构,restapi要求把请求的资源当做一个对象,领域驱动开发也是把操作的资源当作对象。
例如restapi有这样的需求: 限制API返回值的域
GET /papers/fields=id,name,description&filter=valid&sort=updated_at&extras=download
这个api定义的意思是: 1 需要id,name,description 2 额外的资源是download 3 过滤filter是有效资源 4 排序是updated_at
那么 PaperService的fields,extras, sort filter都可以做到【根据页面展示需求】灵活控制,完美!再也不需要重复写代码啦
领域驱动开发的要求
如果理解了领域驱动开发,你会更好理解【面向对象编程】,你会越来越关心【怎么职责划分】,你会越来越关心【业务本质】是什么
驱动开发的思想不仅仅是上面提到的东西,还有很多可以探究的事情,最终回答几个问题
1 事务管理怎么处理 事物管理的逻辑代码应该放在领域服务层实现
2 join查询怎么处理 建议禁止使用join连表查询,一方面不好设置缓存,第二是如果后续如果有分表,代码管理会很复杂
下面附上最终论文需求
的各层的代码
# 展现层
class PaperController {
public function queryPapers()
{
$rule = [
"request_filed_1" => "required",
"request_filed_2" => "required|string",
];
// 校验输入
Validator::make(Request::all(), $rule)->validate();
$paperService = new PaperService();
return $paperService->queryPapers(Request::all());
}
}
# 领域层
class PaperService {
$paperModel = new PaperModel();
$papers = $paperModel->query($condition);
$count = $papers.count(closure)
return [
"data" => $papers,
"count" => $count
];
# 为啥不需要手动调用setFields,setExtras()啦,因为在框架可以自动在序列化之前把
请求中的fields, extras, 设置完成,一般情况下不需要手动再次设置, 实现细节可以不用计较
}
# paper实体
class PaperModel {
// 指定对应的数据表
public $table_name='paper';
// 原属性
public function $attributes;
// 对外可见的属性,上面的需求需要如下几个属性
public $visiable = ['id', 'name', 'price', 'description', 'status', 'status_name', 'explain'];
// 需要【转换】的属性
public $mutators = ['price'];
// 需要【追加】的属性
public $appends = ['status_name', 'explain'];
// 初始化
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
// 计算price属性
public function getPriceAttribute()
{
if ($this->attributes["price"] > 0 ) {
return $$this->attributes["price"] / 100;
}
}
// 追加status_name属性
public function getStatusNameAttribute()
{
return ($this->attributes["status"]) ? '有效' : '无效';
}
// 追加explain属性
public function getStatusAttribute()
{
return ($this->attributes["name"]) ?: $this->attributes["description"]
}
// 获取论文列表
public function query($condition)
{
return $this->where($condition)->fetchAll();
}
}
# 基础层对象
【数据库处理组件集】,【collection组件】, 【缓存组件】