领域驱动设计之Domain Primitive

2,031 阅读14分钟

本文作者:来自 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...
}

一张图快速了解他们之间的关系

image.png

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)

VODP
功能数据传输属于技术细节业务领域中的概念
数据的关联只是一堆数据放在一起不一定有关联度数据之间的高相关新
行为无行为丰富的行为和业务逻辑
模型贫血模型充血模型
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 代替,可以避免大量的校验代码。

DPEntity
定义领域中的基本类型、值对象领域中的具体对象
可变性不可变有状态可变
关注点数据类型和数据值的含义和约束对象的属性和行为

3. 通过案例学习DP的基本原则

DP的三原则:

  1. 将隐性的概念显性化(Make Implicit Concepts Explicit)
  2. 将隐性的上下文显性化(Make Implicit Context Explicit)
  3. 封装多对象行为(Encapsulate Multi-Object Behavior)

3.1. 模拟业务场景

经典案例:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金

因为经典所以本案例可能是20年前的场景,所使用的电话号码还是座机,但不影响本次产品要你实现该需求。 该需求实现基本流程图:

image.png

代码示例:

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可以发下以下问题:

  1. 违背单一职责原则

业务代码的不够清晰度,上述代码中UserRegistration类的实现逻辑中,包含了参数校验逻辑、数据库的操作逻辑、异常数据校验逻辑、销售员信息查询、用户创建逻辑。

  1. 数据验证和错误处理

use-case的使用

try {
    const userRegistration = UserRegistration('张三', '28877001');
    userRegistration.register();
} catch (e) {
    logger.warn('注册错误', e);
}

对于UserRegistration用例而言,调用方的入参是不可信的,需要对入参进行合法性校验,而这些校验出现在代码的最前端,即便当前前端项目基本采用TypeScript能够在编译时发现错误,但TypeScript 不会对数据的类型进行运行时的检验。

再者phone这个参数大概率还会存在于项目中的其他地方,都需要使用phoneNumber的校验逻辑,这时候常规解决方案是封装一个 ValidationUtils 校验工具,当大量的校验逻辑放入到该工具类时又违背了单一性原则,导致代码难以维护,仍需要对ValidationUtils进一步拆解。

  1. 可测性

为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖,所以在我们这个方法里需要以下的 TC :

image.png假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC。

在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。

再者当参数被层层透传时,下游函数也难免要重复一遍校验逻辑和单测用例覆盖。

3.2. 将隐性的概念显性化

回顾上述用例,手机号作为一个隐性概念以String格式的参数传入,但UserRegistration用例中绝大多数代码作用是phone的校验和解析,这些逻辑并不属于用户注册的主逻辑,也不属于销售代表实体或用户实体。

img

将手机号显性化出来,那么我们可以构建出下面这个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. 将隐性的上下文显性化

当要实现一个功能或进行逻辑判断依赖多个概念时,可以把这些概念封装到一个独立地完整概念。 常见的礼包领取列表功能,礼包有等级、结束时间;

image.png

从上图可见,对于礼包信息主要包含礼包名称、内容、可领等级,每个字段单独来看就是一个简单的 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云游项目

案例说明:短云游有游玩次数限制,当用户游玩次数不足时需要给用户提示次数不足,触发提示的场景有初始化时可玩条件判断、挑战结束再玩一次增加次数后;而短云游玩法有王者残局、通用残局、互动玩法三种类型。

再玩一次前次数校验判断

image.png

image.png

云游戏初始化次数判断

image.png

以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时,需要注意以下几点:

  1. 避免使用过于抽象的概念,保持领域模型的实用性和可理解性;
  2. 基于业务需求来定义DP,而不是基于技术实现来定义;
  3. 在DDD战术设计的过程中,应该优先设计业务DP类型,而不是按照技术层面的函数传参定义类型;
  4. Entity尽量使用DP数据类型,减少数据校验代码,保持逻辑清晰,提高可测性;

参考文章:

阿里技术专家详解 DDD 系列- Domain Primitive

一篇文章讲清楚VO,BO,PO,DO,DTO的区别 - 知乎

最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。

点赞

7. 关于我们

MoonWebTeam目前成员均来自于腾讯,我们致力于分享有深度的前端技术,有价值的人生思考。

8. 往期推荐

低码编辑器中的“拖拽”是如何实现的

一文带你了解AI程序员DEVIKA

前端依赖注入的探索和实践

MoonWebTeam前端技术月刊第1期

2024年前端技术演进&优秀实践技术扫描