【微服务】基于DDD的商城系统实战(四)-- 微服务代码模型

Posted by jintang on 2021-03-15

前言

上篇看了软件架构的演进,从两层架构到DDD的分层架构,并且使用结合代码理解了不同分层架构的思想。接下来我们看看DDD 的分层架构是如何知道我们落地微服务代码模型。

基于DDD分层架构的微服务代码模型

回顾一下DDD 的分层架构,包括:用户接口层、应用层、领域层和基础层

csr

DDD 分层架构对应的微服务代码模型,其实DDD没有给出明确的定义,也就是说每个实践者都可能会有不一样。那么我们还是吸收前人的经验来确定我们的代码模型,具体如下:

微服务代码模型定义

微服务代码结构

整合Yii2框架

实现以上代码结构,我们基于熟悉的yii框架来搭建微服务代码模型。

一级目录的定义

为了方便devops部署和日志输出,我把一级目录结构划分为:devops,runtime,src

一级目录

devops 用于存放运维相关的文件,包括云擎配置,用于部署类的文件。
runtime 运行时的文件,包括日志,cache和debug
src 存放代码目录,作为微服务代码结构的根目录

通过composer引入yii2

首先是引入yii2框架,放在哪里合适呢?基础层 可以存放第三方包,所以我们把vendor放到infrastructure目录下

1
2
3
cd infrastructure
composer init # 初始化composer
composer require yiisoft/yii2

入口文件改造

入口文件index.php我们放在src下的public目录,注意修改vendor的autoload.php和yii2/Yii.php文件的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

// 此处定义了两个常量,分别是站点的根目录,和代码目录,方便后序配置
define('SRC_PATH', dirname(__DIR__));
define('ROOT_PATH', dirname(SRC_PATH));

// comment out the following two lines when deployed to production
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');

require __DIR__ . '/../infrastructure/vendor/autoload.php';
require __DIR__ . '/../infrastructure/vendor/yiisoft/yii2/Yii.php';

$config = require __DIR__ . '/../infrastructure/config/web.php';

(new yii\web\Application($config))->run();

此处定义了两个常量ROOT_PATHSRC_ROOT,分别是站点的根目录和代码目录,方便后序配置

提醒一下:有了入口文件,nginx配置指定root的时候自然需要改到src/public下,nginx具体怎么配置此处就不多说了。

配置文件

根据基础层 的职责,配置文件同样是放在基础层,我们确定放在src/infrastructure/config下

对web.php配置文件进行改造,改造的目的主要由以下三个:

  1. 根据一级目录的定义,runtime目录需要改到相应的位置。
  2. 根据用户接口层的职责,我们对外的接口放在用户接口层 interfaces下,通过facade来提供较粗粒度的调用接口,将用户请求委派给一个或多个应用服务进行处理。也就是说,我们要把controller放在interfaces/facade下
  3. 我需要有一个显性的路由定义,只有定义了路由才能访问,有利于对接口的把握和提高灵活性。

于是,对于web.php做了如下改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php
$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/db.php';

$config = [
'id' => 'basic',
'basePath' => dirname(dirname(__DIR__)),
'bootstrap' => ['log'],
'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
'@runtime' => ROOT_PATH . '/runtime',

// 定义用户接口层的别名
'@interfaces' => SRC_PATH . '/interfaces',
],

// 修改控制器的命名空间,改变默认的目录结构
'controllerNamespace' => '@interfaces\facade\controllers',
// 定义product控制器类
'controllerMap' => [
'product' => 'interfaces\facade\ProductApi'
],
'components' => [
'request' => [
// !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
'cookieValidationKey' => 'e2ndO0Mv5t_-UUfou2od0uBRJHCfe4Qy',
],
'db' => $db,

// ...

'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
'targets' => [
[
'class' => 'yii\log\FileTarget',
'levels' => ['error', 'warning'],
'logFile' => ROOT_PATH . '/runtime/logs/app.log'
],
],
],
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => [
// 配合路由规则达到目的3
],
],
],
'params' => $params,
];

return $config;

再看看ProductApi.php文件

1
2
3
4
5
6
7
8
9
10
11
<?php

namespace interfaces\facade;

class ProductApi extends \yii\web\Controller
{
public function actionIndex()
{
// coding...
}
}

脚本程序

除了接口外,还有脚本程序也是放到基础层中,具体目录:src/interfaces/commands,并且把yii指令文件:yii和yii.bat放到commands下。

到此,我们完成yii2框架的整合,整体看一下代码结构:

一级目录

领域对象的代码设计示例

上面我们根据DDD 的分层架构定义出具体的代码模型和目录结构,下面结合我对DDD思想的理解,给出一些领域对象的代码示例

值对象

值对象的特征包括:

  • 度量或者描述了领域中的一件东西
  • 可作为不变量
  • 将不同的相关的属性组合成一个概念整体(Conceptual Whole)
  • 当度量和描述改变时,可以用另一个值对象予以替换
  • 可以和其他值对象进行相等性比较
  • 不会对协作对象造成副作用

通过特征来辨别实体与对象, 值对象可以是数值,文本字符,日期,时间,一个人的全名,货币,颜色,电话号码,邮箱地址,收货地址

下面是收货地址示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace domain\product\entity\valueobejct;

class Address
{
protected $province;
protected $city;
protected $area;
protected $detail;

public function __construct($province, $city, $area, $detail)
{
$this->province = $province;
$this->city = $city;
$this->area = $area;
$this->detail = $detail;
}
/**
* @return mixed
*/
public function getProvince()
{
return $this->province;
}
/**
* @param mixed $province
*/
public function setProvince($province)
{
$this->province = $province;
}

// 省略...
// more setters and getters
}

实体

实体对象应该不用多说了,有一个重要的特征就是具备唯一的ID,我们很容易想到商品这一个实体,因为每一个商品都应该有一个唯一的商品ID,这样一来有利于实体的持久化。

假设我们有这样一个表:

1
2
3
4
5
6
7
8
9
10
11
create table if not exists `product` (
`id` char(36) not null primary key comment '商品唯一ID',
`name` varchar(1000) not null default '' comment '商品名称',
`cate_id` char(36) not null default '' comment '商品分类ID',
`status` tinyint unsigned not null default 0 comment '商品状态:0下架,1上架',
`created_on` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
`created_by` char(36) DEFAULT NULL COMMENT '记录创建者',
`modified_on` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录修改时间',
`modified_by` char(36) DEFAULT NULL COMMENT '记录修改者',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否已删除',
);

通常我们设计完表结构后,我们的思维比较容易陷入数据建模的陷阱,而DDD 对于实体的设计是建议采用充血模型

充血模型 vs 贫血模型

我通过代码描述一下过往我们”贫血模型”的方式:

首先当我们设计完数据表后,接下来是为这个创建一个实体类:

商品实体类

1
2
3
4
5
6
7
8
9
<?php
namespace app\models;
class Product extends \yii\db\ActiveRecord
{
public static function tableName()
{
return "product";
}
}

假设我们需要修改商品的上下架状态,我们会这样:

1
2
3
4
5
6
7
<?php

$product = $productRepository->findOne($productId);
if (!is_null($product)) {
$product->status = 1; // 设置为上架
$product->save();
}

为了解决代码重用问题,按照三层架构模式,自然会设计一个服务层来封装这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php 
class ChangeProductStatusService
{
protected $productRepository;

public function __construct(ProductRepository $productRepository)
{
$this->productRepository = $productRepository;
}

public function putOnSale($productId)
{
$product = $this->productRepository->findOne($productId);
if (!is_null($product)) {
$product->status = 1; // 设置为上架
return $product->save();
}
return false;
}
}

再来就是仓储层

1
2
3
4
5
6
7
8
9
use app\models\Product;

class ProductRepository
{
public function findOne($productId)
{
return Product::findOne($productId);
}
}

这种三层架构的逻辑虽然做到了代码重用,但是有一个很严重的弊端:如果controller直接调用ProductRepository的代码,实现putOnSale通用的逻辑,那么ChangeProductStatusService的putOnSale方法根本不会被执行,甚至忽略。

充血模型的思想是领域模型中的实体的数据应该与实体对应的行为绑定,这样我们得到一个更加丰富的实体类,当我们需要实体完成一些动作时,实体自身就带有相应的方法。

所以上面Product实体类,应该带有putOnSale的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace app\models;
class Product extends \yii\db\ActiveRecord
{
public static function tableName()
{
return "product";
}

public function putOnSale()
{
// ...
}
}

三种服务

虽然说实体和值对象,采用充血模型来设计,能够包含大部分应用程序的业务逻辑,但是你会发现,在领域里有一些重要过程或者转换不是实体或者值对象的职责范围,根据DDD的思想,我们应该把这些操作定义成服务。

这样一来,你将碰到一下三类服务:

应用服务 :位于应用层,用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果拼装,对外提供粗粒度的服务

领域服务 :位于领域层,领域服务封装核心的业务逻辑,实现需要多个实体协作的核心领域逻辑

基础服务 :位于基础层,提供基础资源服务(比如数据库、缓存等),实现各层的解耦,降低外部资源变化对业务应用逻辑的影响,比如仓储服务

应用服务

根据分层架构的调用关系,应用服务应该会被用户接口层调用,比如下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace interfaces\facade;

class ProductApi extends \yii\web\Controller
{
public function actionPutOnSale()
{
// ...
$product = ProductAssembler.toDO(ProductDTO);
$productService = new ChangeProductStatusService(new ProductService());
$productService->putOnSale($product);
// ...
}
// ...
}

与此同时,应用服务的内部可以调用领域层的领域服务,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace application\service;

use domain\product\entity\Product;
use domain\product\service\ProductService;

class ChangeProductStatusService
{
protected $productService;

public function __construct(ProductService $productService)
{
$this->productService = $productService;
}

public function putOnSale(Product $product)
{
if ($this->productService->putOnSale($product)) {
// 触发领域事件:发送商品补货通知给用户
}
}
}

领域服务

对多个实体或方法的业务逻辑进行组合或编排,或者在严格分层架构中对实体方法进行封装,以领域服务的方式供应用层调用

看看上面一个例子中的ProductService代码:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace domain\product\service;

class ProductService
{
public function putOnSale(Product $product)
{
// do something coding..
return $product->putOnSale();
}
}

基础服务

以上例子中,基础服务主要是仓储服务,是通过依赖倒置的方式提供资源服务的,上篇文章已经展示过依赖倒置的实现,此处不再叙述。

总结一下

本篇开头回顾了一下DDD的分层架构,同时直接引入了与DDD的分层架构对应的代码模型,并且对各级代码目录的职责做了明确定义。考虑到目前团队对yii2框架的熟悉程度,我整合了yii2框架到代码模型中,最后通过包括值对象、实体、应用层服务、领域层服务等实例代码,展示了充血模型的意义,各层服务之间的调用关系。

以上是我学习ddd设计思想的总结,代码示例没有在完整的场景中实现,所以业务逻辑可能会不完整,但是不影响理解。希望对你有帮助,同时有理解不正确的,欢迎讨论。

下期预告:”【微服务】基于DDD的商城系统实战(五) – 实现商品上下文”

思考题

你能根据自己的理解,设计出你自己的微服务代码模型,并且与你最熟悉的框架做整合吗?