本文作者:来自 MoonWebTeam 的 yamachen 腾讯高级工程师
本文编辑:kanedongliu
1. 引言
领域驱动设计(Domain-Driven Design,简称DDD)是一种软件开发方法论,旨在帮助开发团队更好地理解业务领域,并将这种理解反映到软件设计和开发中。在这篇文章中,我们将着重探讨从前端视角看Domain Primitive的概念、使用场景。
2. DP 基本概念
2.1 VO、DTO、PO
在学习DP之前,从其他文章中都将其拿来与VO和DTO做比较,DP是从VO和DTO使用中发展而来,先简单了解下再JAVA中这些概念。
VO(Value Object):
视图对象,用于展示层,通常是不可变的,只有getter方法。
class UserVO {
private username: string;
private age: number;
// getters
}
DTO(Data Transfer Object):
数据传输对象,泛指用于展示层与服务层之间的数据传输对象,通常是可变的。
class UserDTO {
private username: string;
private age: number;
// getters and setters...
}
PO(Persistent Object):
持久化对象,就是和数据库保持一致的对象,基本结构域DTO相同,但使用场景不同。
class UserPO {
private id: number;
private username: string;
private password: string;
// getters and setters...
}
一张图快速了解他们之间的关系
2.2 什么是DP
就好像在学任何语言时首先需要了解的是基础数据类型一样,在DDD中 Domain Primitive 表示领域中最基本的概念和规则的数据类型。
DDD战术设计最与众不同的就是要把聚合(实体,值对象)设计成,对于聚合而言要根据业务语义,放进去对应的业务逻辑&行为,符合业务语义
根据阿里领域驱动设计系列文章的定义:Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。
- DP是一个传统意义上的Value Object,拥有Immutable的特性
- DP是一个完整的概念整体,拥有精准定义
- DP使用业务域中的原生语言
- DP可以是业务域的最小组成部分、也可以构建复杂组合
2.3. DP 与 VO 的区别
Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象.在 VO 的 Immutable 基础上增加了 Validity 和行为.当然同样的要求无副作用(side-effect free)
| VO | DP | |
|---|---|---|
| 功能 | 数据传输属于技术细节 | 业务领域中的概念 |
| 数据的关联 | 只是一堆数据放在一起不一定有关联度 | 数据之间的高相关新 |
| 行为 | 无行为 | 丰富的行为和业务逻辑 |
| 模型 | 贫血模型 | 充血模型 |
class UserVO {
private username: string;
private age: number;
// getters and setters...
}
class UserDP {
private userName: string;
private age: number;
constructor(userName: string, age: number) {
this.userName = userName;
this.age = age;
this.isVerify();
}
// getters
isVerify() {
if (!this.userName) {
throw new Error('姓名不能为空');
}
if (this.age <= 0) {
throw new Error('年龄不能小于0');
}
}
}
2.4. DP和Entity的关系
Entity是基于领域逻辑的实体类,拥有 ID,它的字段和数据库储存不需要有必然的联系,不仅包含数据,还有行为,字段也不仅仅是 String 等基础类型,而应该尽可能用 Domain Primitive 代替,可以避免大量的校验代码。
| DP | Entity | |
|---|---|---|
| 定义 | 领域中的基本类型、值对象 | 领域中的具体对象 |
| 可变性 | 不可变 | 有状态可变 |
| 关注点 | 数据类型和数据值的含义和约束 | 对象的属性和行为 |
3. 通过案例学习DP的基本原则
DP的三原则:
- 将隐性的概念显性化(Make Implicit Concepts Explicit)
- 将隐性的上下文显性化(Make Implicit Context Explicit)
- 封装多对象行为(Encapsulate Multi-Object Behavior)
3.1. 模拟业务场景
经典案例:
一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金
因为经典所以本案例可能是20年前的场景,所使用的电话号码还是座机,但不影响本次产品要你实现该需求。 该需求实现基本流程图:
代码示例:
import { SalesRepEntity } from './';
import { UserEntity } from './';
/** 用户注册实体 */
export class UserRegistrationEntity {
/** 销售代表实体 */
private salesRepEntity: SalesRepEntity;
/** 用户实体 */
private userEntity: UserEntity;
public async register(name: string, phone: string) {
// 创建时候参数校验
this.isVerify(name, phone);
// 获取电话号归属地编号和y营商编号
const areaCode = this.getAreaCode(phone);
const operatorCode = this.getOperatorCode(phone);
// 根据号码归属地和运营商编号获取销售员id
const { repId } = await this.salesRepEntity.findRep(areaCode, operatorCode);
// 执行创建命令
return this.userEntity.save({
name,
phone,
repId,
});
}
/** 校验逻辑 */
private isVerify(name: string, phone: string) {
if (!name) {
throw new Error('姓名不能为空')
}
if (/^[\u4e00-\u9fa5]{2,4}$/.test(name)) {
throw new Error('不能使用特殊符号')
}
if (!phone) {
throw new Error('电话号码不能为空')
}
if (this.isValidPhoneNumber(phone)) {
throw new Error('电话号码格式不正确')
}
return;
}
/** 校验号码格式 */
private isValidPhoneNumber(phone: string): boolean{
return /^0[1-9]{2,3}-?\\d{8}$/.test(phone);
}
/** 获取所属地区号 */
private getAreaCode( phone){
// 获取归属地编号
}
/** 获取运营商 */
private getOperatorCode(phone: string){
// 获取运营商编号
}
}
上面的代码基本符合了我们大部分面向过程开发的代码,经过CR可以发下以下问题:
- 违背单一职责原则
业务代码的不够清晰度,上述代码中UserRegistration类的实现逻辑中,包含了参数校验逻辑、数据库的操作逻辑、异常数据校验逻辑、销售员信息查询、用户创建逻辑。
- 数据验证和错误处理
use-case的使用
try {
const userRegistration = UserRegistration('张三', '28877001');
userRegistration.register();
} catch (e) {
logger.warn('注册错误', e);
}
对于UserRegistration用例而言,调用方的入参是不可信的,需要对入参进行合法性校验,而这些校验出现在代码的最前端,即便当前前端项目基本采用TypeScript能够在编译时发现错误,但TypeScript 不会对数据的类型进行运行时的检验。
再者phone这个参数大概率还会存在于项目中的其他地方,都需要使用phoneNumber的校验逻辑,这时候常规解决方案是封装一个 ValidationUtils 校验工具,当大量的校验逻辑放入到该工具类时又违背了单一性原则,导致代码难以维护,仍需要对ValidationUtils进一步拆解。
- 可测性
为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖,所以在我们这个方法里需要以下的 TC :
假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC。
在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。
再者当参数被层层透传时,下游函数也难免要重复一遍校验逻辑和单测用例覆盖。
3.2. 将隐性的概念显性化
回顾上述用例,手机号作为一个隐性概念以String格式的参数传入,但UserRegistration用例中绝大多数代码作用是phone的校验和解析,这些逻辑并不属于用户注册的主逻辑,也不属于销售代表实体或用户实体。
将手机号显性化出来,那么我们可以构建出下面这个DP对象
class PhoneNumberDp {
private number: string;
constructor(number: string) {
this.isValid(number);
this.number = number;
}
/** 获取手机号 */
public getNumber() {
return this.number;
}
/** 获取所属地区号 */
public getAreaCode() {}
/** 获取运营商编号 */
public getOperatorCode() {}
private isValid(number: string) {
if (!number) {
throw new Error('电话号码不能为空')
}
if (/^0[1-9]{2,3}-?\\d{8}$/.test(number)) {
throw new Error('电话号码格式不正确')
}
return;
}
}
同理,在上述代码中姓名这个概念也同样以string格式作为参数使用,这里将姓名显性化,可以构建出下面这个DP对象;
class NameDp {
private number: string;
(number: string) {
this.isValid(number);
this.number = number;
}
/** 获取用户全名 */
public getName() {
return this.number;
}
/** 获取用户姓氏 */
public getFamilyName() {}
/** 获取用户名 */
public getLastName() {}
private isValid(name: string) {
if (!name) {
throw new Error('姓名不能为空')
}
if (/^[\u4e00-\u9fa5]{2,4}$/.test(name)) {
throw new Error('不能使用特殊符号')
}
return;
}
}
当phone在实例化时执行了校验逻辑,如校验失败则创建失败,所以当以PhoneNumberDp格式传递时候,下游函数内可以不用再对phone的进行参数校验。
/** 用户注册 */
export class UserRegistration {
/** 销售代表实体 */
private salesRepEntity: SalesRepEntity;
/** 用户实体 */
private userEntity: UserEntity;
public async register(name: NameDp, phone: PhoneNumberDp) {
// 获取电话号归属地编号和运营商编号
const areaCode = phone.getAreaCode();
const operatorCode = phone.getOperatorCode();
// 根据号码归属地和运营商编号获取销售员id
const { repId } = await this.salesRepEntity.findRep(areaCode, operatorCode);
// 执行创建命令
return this.userEntity.save({
name,
phone: phone.getNumber(),
repId,
});
}
}
new UserRegistration().register(new NameDp('张三'), new PhoneNumberDp(28888888))
3.3. 将隐性的上下文显性化
当要实现一个功能或进行逻辑判断依赖多个概念时,可以把这些概念封装到一个独立地完整概念。 常见的礼包领取列表功能,礼包有等级、结束时间;
从上图可见,对于礼包信息主要包含礼包名称、内容、可领等级,每个字段单独来看就是一个简单的 number 或string类型,属于隐式概念。但礼包包含了很多相关业务逻辑,比如礼包类型,不同类型领取条件不同。整合礼包信息中的领取等级、可领时间这些隐形上下文信息,形成一个完整的礼包类型。
interface GiftInfoParams {
/** 礼包 id */
id: string;
/** 礼包名称 */
name: string;
/** 礼包描述 */
msg: string;
/** 最低领取等级 */
minLevel: number;
/** 开始时间 */
startTime: number;
/** 结束时间 */
endTime: number;
}
/** 礼包 dp */
class GiftInfoDp {
private giftInfo: GiftInfoParams;
constructor(data: GiftInfoParams) {
this.giftInfo = data;
}
/** 礼包领取时间已开始 */
public isStarted() {
const now = Date.now();
return now > this.giftInfo.startTime && now < this.giftInfo.endTime;
}
/** 礼包领取时间已结束 */
public isEnd() {
const now = Date.now();
return now > this.giftInfo.endTime;
}
/** 可使用的 */
public isAvailable(level: number) {
return this.isStarted() && level >= this.giftInfo.minLevel;
}
/** 礼包领取展示的文案 */
public getStatusMsg() {
if (this.isEnd()) {
reutrn '已结束';
}
if (!this.isStarted()) {
reutrn '未开始';
}
if (this.isAvailable()) {
return '等级不足'
}
return '领取礼包'
}
}
3.4. 封装多对象行为
将更多业务属性整合成一个新的DP,如在创建订单的过程中将需要使用到 itemDp、CouponDp、MoneyDp整合成CreateOrderDp,use-case的代码中省略校验逻辑,逻辑清晰易懂。
class CreateOrderDp {
/** 商品信息 */
private item: ItemDp;
/** 优惠券信息 */
private coupon: CouponDp;
/** 金额 */
private money: MoneyDp;
/** 返回优惠金额 **/
getSaleMoney() {
// 计算该订单优惠金额
}
}
/** use-case 创建订单 */
function createOrder() {
let order: CreateOrderDp;
try {
order = new CreateOrderDp({
item: new ItemDp(itemData),
coupon: new CouponDp(itemData),
money: new MoneyDp(1000, MoneyType.CNY),
})
/** 订单实体发起支付 **/
orderEntity.createOrder(order);
} catch (e) {
logger.error('创建订单失败', e);
}}
}
4.DP的应用场景
常见的 DP 的使用场景包括:
- 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
- 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
- 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
- Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
- 复杂的数据结构:比如 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为
4.1. 举例业务中常见的DP模型
单一值的情况:
- urlDp 域名DP:白名单校验、合法性校验、参数解析、参数添加
- moneyDp 金额DP:合法性校验、精确N位小数
- qqCodeDp qq号DP:合法性校验
- ipDp ip地址Dp:合法性校验
- versionDp 版本号DP:合法性校验、获取测试版本号、获取正式版本号、获取下一版本号、版本号比较
组合:
- couponDp 宝券DP: 合法性校验、使用moneyDp
- loginInfoDp 登录信息Dp:合法性校验、登录态解析、登录态判断
- 接口鉴权签名Dp: 入参合法性校验、签名生成、签名比较
5. DP在业务改造
5.1. 实验平台Node项目
业务场景: 实验平台主要服务于应用定制化的实验参数维护管理,在创建实验的过程中,会将应用特有的sceneId、layerCode、conditionId拼成tabLayerCode给到TAB平台作为实验分流层ID,在与TAB数据交互的过程中作为查询实验的关键字段。
当前实现 - 获取发布层参数
const paramLayerId = await createLayerIfNeed({
// 非自定义层级的排版类实验 tabLayerCode需要在layerCode前加上场景号前缀
tabLayerCode: `${param.sceneId}_${param.layerCode}__${expItem.conditionId}`,
layerCode: param.layerCode,
sceneId: param.sceneId,
layerType: param.layerType || param.layerCode,
conditionId: expItem.conditionId,
owner: expGroup.owner,
modelId: param.modelId,
});
/** 如果需要的情况下,生成实验层 否则返回实验层id **/
async function createLayerIfNeed(options: {
tabLayerCode?: string;
layerCode?: string;
sceneId?: number;
layerType?: string;
owner?: string;
conditionId?: number;
modelId?: number;
}): Promise<number> {
const { tabLayerCode } = options;
const layerRes = await layerDao.selectByTabCode(tabLayerCode);
if (!layerRes) {
const insertLayerId = await this.createLayer(options);
return insertLayerId;
}
return layerRes.id;
}
5.1.1. 第一步 - 创建 Domain Primitive,收集所有 DP 行为
上述案例中tabLayerCode是作为string各种传递,但在实际业务中围绕着tabLayerCode在使用的过程中具有校验、拼接生成、拆解字段独立的逻辑,现有的代码中对部分逻辑是散落在各处重复实现的。将tabLayerCode显性化作为一个具备特定业务意义的type,对tabLayerCode的数据处理逻辑成为 tabLayerCodeDp 自己的行为或属性。
5.1.2. 第二步 - 替换数据校验和无状态逻辑
interface TabLayerCodeDpParams {
tabLayerCode?: string;
sceneId?: string;
layerCode?: string;
conditionId?: string;
}
export class TabLayerCodeDp {
/** tab 层 id */
private tabLayerCode: string;
/** 场景 id */
private sceneId: string;
/** 层 id */
private layerCode: string;
/** 定向 id */
private conditionId: string;
constructor(data: TabLayerCodeDpParams) {
const { tabLayerCode, sceneId, layerCode, conditionId = '0' } = data;
if (!tabLayerCode || !sceneId || !layerCode) {
throw new Error('参数不全');
}
if (tabLayerCode) {
this.resolverTabLayerCode(tabLayerCode);
return;
}
if (!sceneId) {
throw new Error('sceneId不能为空');
}
if (!layerCode) {
throw new Error('layerCode不能为空');
}
this.layerCode = layerCode;
this.sceneId = sceneId;
this.conditionId = conditionId;
}
public getSceneId() {
return this.sceneId;
}
public getLayerCode() {
return this.layerCode;
}
public getConditionId() {
return this.conditionId;
}
/** 生成 tabLayerCode */
public getTabLayerCode() {
if (!this.tabLayerCode) {
this.tabLayerCode = `${this.sceneId}_${this.layerCode}__${this.conditionId}`;
}
return this.tabLayerCode;
}
/** 解析 tabLayerCode 回参数 */
private resolverTabLayerCode(tabLayerCode: string) {
const [res, conditionId] = tabLayerCode.split('__');
const [sceneId, layerCode] = res.split('__');
if (layerCode && conditionId && sceneId) {
this.layerCode = layerCode;
this.sceneId = sceneId;
this.conditionId = conditionId;
return;
}
throw new Error(`tabLayerCode 格式错误: ${tabLayerCode}`);
}
}
5.1.3. 第三步 - 创建新接口
创建新接口,将DP的代码提升到接口参数层:
async function createLayerIfNeed(options: {
tabLayerCode?: TabLayerCodeDp;
layerCode?: string;
sceneId?: number;
layerType?: string;
owner?: string;
conditionId?: number;
modelId?: number;
}): Promise<number> {
const { tabLayerCode } = options;
const layerRes = await layerDao.selectByTabCode(tabLayerCode.getTabLayerCode());
if (!layerRes) {
const insertLayerId = await this.createLayer(options);
return insertLayerId;
}
return layerRes.id;
}
5.1.4. 第四步 - 修改外部调用
使用DP的方式改动后,tabLayerCode在项目中以TabLayerCodeDp类型传递,可以保证tabLayerCode的数据校验合法。
const tabLayerCode = new TabLayerCodeDp({
sceneId: param.sceneId,
layerCode: param.layerCode,
conditionId: expItem.conditionId,
});
createLayerIfNeed({
// 非自定义层级的排版类实验 tabLayerCode需要在layerCode前加上场景号前缀
tabLayerCode,
layerCode: param.layerCode,
sceneId: param.sceneId,
layerType: param.layerType || param.layerCode,
conditionId: expItem.conditionId,
owner: expGroup.owner,
modelId: param.modelId,
});
5.2. H5云游项目
案例说明:短云游有游玩次数限制,当用户游玩次数不足时需要给用户提示次数不足,触发提示的场景有初始化时可玩条件判断、挑战结束再玩一次增加次数后;而短云游玩法有王者残局、通用残局、互动玩法三种类型。
再玩一次前次数校验判断
云游戏初始化次数判断
以DP方式改造,收集在游戏次数的
/** 残局游戏次数 **/
export class PlayCountDp {
/** 当前次数 */
private playCount: number;
/** 最大次数 */
private maxPlayCount: number;
/** 活动类型 */
private activityType: ActivityType;
constructor(playCount: number, maxPlayCount: number, activityType?: ActivityType) {
this.playCount = playCount;
this.maxPlayCount = maxPlayCount;
this.activityType = activityType;
this.isValid();
}
public canPlay() {
return this.playCount <= this.maxPlayCount;
}
/** 获取游戏挑战结束时次数反馈文案 */
public getGameEngMsg() {
if (this.canPlay()) {
return `再玩一次(${this.getCountText()})`;
}
if (this.activityType === ActivityType.SouGou) {
return '活动剩余挑战次数不足,请回到活动页完成任务';
}
if (this.activityType === ActivityType.Wzry) {
return '剩余挑战次数不足,打开王者增加次数';
}
return '剩余挑战次数不足,打开更多挑战';
}
/** 获取游戏次数展示的文案 */
public getCountText() {
return `${Math.min(this.playCount, this.maxPlayCount)}/${{this.maxPlayCount}`;
}
private isValid() {
if (this.maxPlayCount <= 0) {
throw new Error('最大可玩次数不能小于0');
}
if (this.playCount < 0) {
throw new Error('可玩次数不能小于等于0');
}
}
}
6. DP学习总结
DP(Domain Primitive)是领域驱动设计中的一个重要概念,它指的是领域模型中最基本的元素,是构成领域模型的基石。 在使用DP时,需要注意以下几点:
- 避免使用过于抽象的概念,保持领域模型的实用性和可理解性;
- 基于业务需求来定义DP,而不是基于技术实现来定义;
- 在DDD战术设计的过程中,应该优先设计业务DP类型,而不是按照技术层面的函数传参定义类型;
- Entity尽量使用DP数据类型,减少数据校验代码,保持逻辑清晰,提高可测性;
参考文章:
阿里技术专家详解 DDD 系列- Domain Primitive
一篇文章讲清楚VO,BO,PO,DO,DTO的区别 - 知乎
最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。
7. 关于我们
MoonWebTeam目前成员均来自于腾讯,我们致力于分享有深度的前端技术,有价值的人生思考。