领域驱动开发设计 - 具体实践

概述

【领域驱动开发设计(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分层实现过程:

  1. Action: 负责处理输入,输出
  2. PageService: 负责处理业务逻辑
  3. DataService: 负责获取数据库数据,缓存等
  4. 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, ......

如果是你,该怎么去优化这段代码,来适应不同的业务场景。

回答这个问题之前,我们需要先搞清楚这样写代码有什么问题:

  1. 代码层面上来讲: a. 代码复用性差,函数的实现是为了解决一个具体业务,专业点讲就是为了实现而实现

    b. 函数实现复杂,优秀的函数应该是保证【单一职能】但是过程式很容易导致一个函数做很多事情

    c. 不利于单元测试,或者说根本没有办法单元测试,验证一个函数好坏的标准就是是否可以简单做单位测试

    d. 随意设置临时变量去维护某种状态,比如例子中的count, 标记等

    e. 扩展性差,业务迭代后,代码会越来越臃肿,难以维护。

  2. 架构上来讲:

    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聚合根的概念

首先我们需要清楚 ”实体对象作为一个整体“ 会被怎么处理,又来到了【业务抽象】的乐园,仔细看哦。

在我们的业务中,会需要:

  1. 论文根据匹配id排序 【排序】
  2. 统计有效元素 【统计】
  3. 需要把数据返回给客户端,也就是【序列化】,常用的序列化操作有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组件】, 【缓存组件】

results matching ""

    No results matching ""