了解领域实体[附例]--DDD w/TypeScript

348 阅读9分钟

这是领域驱动设计 w/ TypeScript & Node.js课程的一部分。如果你喜欢这篇文章,请查看它。

公司走向领域驱动设计的最大原因是他们的业务已经有了必要的复杂性。

想想我们今天最常用的一些工具,如GitLabNetlify中的业务逻辑复杂性。他们确实不是在处理基本的CRUD应用的建模

为了管理业务逻辑的复杂性,方法是使用面向对象的编程概念来模拟对象之间的复杂行为;复制在特定领域中可能 发生的不可能 发生的情况。

领域驱动引入了一套人工制品,我们可以用它来为领域建模。

让我们来谈谈另一个主要的人工制品:实体


实体在DDD中的作用

实体几乎是领域建模的面包和黄油。

这些是实体的一些主要特征。

放置业务逻辑的第一个地方(如果它有意义的话)

实体应该是我们首先想到的放置领域逻辑的地方。

当我们想表达一个特定的模型。

  • 可以做什么
  • 什么时候可以做
  • 什么条件决定了它什么时候可以做那件事

我们的目标是将该逻辑放在最接近其所属模型的地方。

例如:在一个招聘网站的应用中,雇主可以在申请工作时留下问题让申请人回答,我们可以执行一些规则。

规则一:你不能给一个已经有申请人的工作添加问题。

规则二:你不能为一个职位添加超过最大数量的问题。

这里有一个简单的例子。

class Job extends Entity<IJobProps> {
  // ... constructor
  // ... private factory method

  get questions (): QuestionsCollection {
    return this.props.questions;
  }

  public hasApplicants (): boolean {
    return this.props.applicants.length !== 0;
  }

  public addQuestion (question: Question) {
    if (this.hasApplicants()) {
      throw new Error("Can't add a question when there are already applicants to this job.")
    }
    
    if (this.props.questions.length === MAX_QUESTIONS_PER_JOB) {
      throw new Error("This job already has the max amount of questions.")
    }

    this.props.questions.push(question);
  }
}

有时候,把某些领域的逻辑放在一个实体里面,感觉不自然,也没有意义。

这种情况发生在我们试图找出的逻辑放置位置并不涉及某个实体的时候。有些情况下是可以的(比如我们的Job ,利用Question 实体的例子),但也有其他情况,所涉及的两个实体不一定要知道对方的情况(看看聚合设计)1

例如,如果我们要建立一个电影租赁应用的模型,有一个Customer 实体和一个Movie 实体,我们应该把purchaseMovie() 方法放在哪里?

一个Customer 可以购买一部电影,但是Customer 实体不应该需要知道任何关于Movies 的信息。

相反,一个Movie 可以被一个Customer 购买。但我们不想在Movie 模型中引用一个Customer ,因为最终,一个Customer 与一个Movie 没有关系。

这就是我们放在域服务中的逻辑类型,而不是2

强制执行模型不变性

在我之前的一篇文章中,我说领域驱动设计是声明性的

用DDD构建一个应用程序,就像为你的问题领域创建一个特定的领域语言。

为了做到这一点,我们需要确保我们只暴露出对领域有意义和有效的操作。我们还需要确保类的不变性得到满足。

对象创建时的验证逻辑通常被委托给价值对象,但什么可以发生(以及什么时候)由实体决定。


我在领域建模中犯的最早的错误之一是为所有的东西暴露了getters和setters。

因此,让我们明确指出,这并不是最好的做法。

不要为所有的东西添加获取器和设置器。

在这里,我做了我的工作。

之所以说它不好,是因为我们需要控制我们的对象如何变化。我们不希望我们的对象最终处于无效的状态。

再以招聘会的例子为例(特别是关于QuestionsCollection 的部分)。

class Job extends Entity<IJobProps> {
  // ... constructor
  // ... private factory method

  get questions (): QuestionsCollection {
    return this.props.questions;
  }

  public addQuestion (question: Question) {
    // ...
    if (this.props.questions.length === MAX_QUESTIONS_PER_JOB) {
      throw new Error("This job already has the max amount of questions.")
    }

    this.props.questions.push(question);
  }
}

你是否注意到questions 数组没有为它定义一个setter?

我们的领域逻辑规定,某人不应该为每项工作添加超过最大数量的问题。

如果我们为questions ,有一个公共的setter,那么就没有什么可以阻止有人完全规避领域逻辑的做法了。

job.questions = [{}, {}, {}, {}, {}, ...] // question objects

这就是封装:面向对象编程的4个原则之一。封装是一种数据完整性的行为;而这在领域建模中尤其重要。

身份和查询

实体与价值对象不同,主要是因为实体有一个身份,而价值对象没有。

实体:想想User,Job,Organization,Message,Conversation

价值对象:想想Name,MessageText,JobTitle,ConversationName

通常情况下,一个单一的实体将是一个引用其他价值对象和实体的模型。

下面是一个基本的User 实体可能是什么样子。

interface IUserProps {
  name: Username;
  email: Email;
  active: boolean;
}

class User extends Entity<IUserProps> {
  get name (): Username {
    return this.props.name;
  }

  get email (): Email {
    return this.props.email;
  }

  private constructor (props: IUserProps, id?: UniqueEntityId) {
    super(props, id);
  }
  
  public isActive (): boolean {
    return this.props.active;
  }

  public static createUser (props: IUserProps, id?: UniqueEntityId) : Result<User> {
    const userPropsResult: Result = Guard.againstNullOrUndefined([
      { propName: 'name', value: props.name },
      { propName: 'email', value: props.email },
      { propName: 'active', value: props.active }
    ]);

    if (userPropsResult.isSuccess) {
      return Result.ok<User>(new User(props, id))
    } else {
      return Result.fail<User>(userPropsResult.error);
    }
  }
}

在一个实体的生命周期中,它可能需要被Stored 到数据库,ReconstitutedModified ,然后再被删除或归档。

我使用UUID而不是自动递增的ID来创建实体。原因见这篇文章

实体的生命周期

这就是一个实体的生命周期,一般来说是这样的。

创建

为了创建实体,就像Value Objects一样,我们使用某种类型的工厂

本网站上的大多数例子都使用基本的工厂方法

什么是工厂方法

还记得前一个例子中的这一段吗?

class User {
  // ...

  private constructor (props: IUserProps, id?: UniqueEntityId) {
    super(props, id);
  }

  public static createUser (props: IUserProps, id?: UniqueEntityId) : Result<User> {
    const userPropsResult: Result = Guard.againstNullOrUndefined([
      { propName: 'name', value: props.name },
      { propName: 'email', value: props.email },
      { propName: 'active', value: props.active }
    ]);

    if (userPropsResult.isSuccess) {
      return Result.ok<User>(new User(props, id))
    } else {
      return Result.fail<User>(userPropsResult.error);
    }
  }
}

createUser 方法是一个工厂方法,处理创建User 实体。

注意,我们不能使用new 关键字和做。

const user: User = new User(); // <= constructor is private

还是那句话,封装数据完整性。我们想控制Users 的实例如何进入我们领域层代码的执行。

如果我们有数百种不同类型的Users ,我们希望能够创建,我们可以写更多的工厂方法,或者我们可以尝试使用抽象工厂

实体基类

请注意,你不应该完全复制别人的实体或价值对象类。对于对你的领域如此重要的东西(这基本上是你的家传之宝),你应该推出自己的。你可能有不同的需求,但请随意从这里开始,并根据需要进行修改

import { UniqueEntityID } from './types';

const isEntity = (v: any): v is Entity<any> => {
  return v instanceof Entity;
};

export abstract class Entity<T> {
  protected readonly _id: UniqueEntityID;
  protected props: T;

  // Take note of this particular nuance here:
  // Why is "id" optional?
  constructor (props: T, id?: UniqueEntityID) {
    this._id = id ? id : new UniqueEntityID();
    this.props = props;
  }

  // Entities are compared based on their referential
  // equality.
  public equals (object?: Entity<T>) : boolean {

    if (object == null || object == undefined) {
      return false;
    }

    if (this === object) {
      return true;
    }

    if (!isEntity(object)) {
      return false;
    }

    return this._id.equals(object._id);
  }
}

下面是关于Entity<T> 基类的重要说明。

  1. Entity<T> 是一个抽象的类。这意味着我们不能直接实例化它。然而,我们可以对它进行子类化。这是一个合乎逻辑的设计决定。一个实体只有在它有一个特定的类型时才有存在的意义,比如 。Car extends Entity<ICarProps>
  2. 这个类的idreadonly 。所以它一旦被实例化就不应该被改变。如果你问我,这也是一个相当合理的设计决定。
  3. 我们正在使用equals(object?: Entity<T>) 方法来确定一个实体是否与另一个实体有指代相等。如果指代相等不能确定它们是相同的,我们就将这个实体的id 与我们重新比较的那个实体进行比较。
  4. 类的道具被存储在this.props 。这样做的原因是我们想让子类来决定应该定义哪些属性的getters和setters

可选的id字段

这里最值得注意的设计决定是:id 字段是可选的

为什么我们要这样做呢?

好吧,当id 是已知的(因为我们已经创建了它),我们可以把它传进去。

当我们不知道id (因为我们还没有创建它),我们创建一个新的(32位UUID)。

这样我们就可以同时解决实体生命周期中的创建重构事件。

储存

当我们在内存中创建了一个实体后,我们需要一种方法将其存储到数据库中。

这需要借助RepositoryMapper来完成。

存储库是一个工件,用于从你喜欢的任何类型的持久化技术(关系型数据库、NoSQL数据库、JSON文件、文本文件)中持久化和检索域对象。

映射器是一个文件,它简单地将域对象映射到数据库中所需要的格式,反之亦然(变成一个域对象)。

下面是一个利用Sequelize ORM的User repo的骨架。

interface IUserRepo {
  exists (userId: string): Promise<boolean>;
  searchUsersByEmail(email: string): Promise<UsersCollection>;
  getUsers (config: IUserSearchConfig): Promise<UsersCollection>;
  getUsersByRole (config: IUserSearchConfig, role: Role): Promise<UsersCollection>;
  getUser(userId: string): Promise<any>;
  save(user: User): Promise<User>;
}

export class SequelizeUserRepo implements IUserRepo {
  private sequelizeModels: any;

  constructor (sequelizeModels: any) {
    this.sequelizeModels = sequelizeModels;
  }

  exists (userId: string): Promise<boolean> {
    // implement specific algorithm using sequelize orm
  }

  searchUsersByEmail(email: string): Promise<UsersCollection> {
    // implement specific algorithm using sequelize orm
  }

  getUsers (config: IUserSearchConfig): Promise<UsersCollection> {
    // implement specific algorithm using sequelize orm
  }

  getUsersByRole (
    config: IUserSearchConfig, 
    role: Role
  ): Promise<UsersCollection> {
    // implement specific algorithm using sequelize orm
  }

  getUser(userId: string): Promise<any> {
    // implement specific algorithm using sequelize orm
  }

  save(user: User): Promise<User> {
    // implement specific algorithm using sequelize orm
  }
}

假设我们想实现getUsers 方法。

我们想用Sequelize特定的语法来检索所有的用户,然后将这些活跃记录映射到User 域对象中。

import { UserMap } from '../mappers'
export class SequelizeUserRepo implements IUserRepo {
  private sequelizeModels: any;

  // ...
  getUsers (config: IUserSearchConfig): Promise<UsersCollection> {
    const UserModel = this.sequelizeModels.BaseUser;
    const queryObject = this.createQueryObject(config);
    const users: any[] = await UserModel.findAll(queryObject);
    return users.map((u) => UserMap.toDomain(u))
  }
}

下面是映射器的样子。

export class UserMap extends Mapper<User> {
  public static toDTO (user: User): UserDTO {
    id: user.id.toString(),
    userName: user.name.value,
    userEmail: user.email.value
  }

  public static toPersistence (user: User): any {
    return {
      user_id: user.id.toString(),
      user_name: user.name.value,
      user_email: user.email.value,
      is_active: user.isActive()
    }
  }

  public static toDomain (raw: any): User {
    const nameOrResult = UserName.create(raw.user_name);
    const emailOrResult = UserEmail.create(raw.user_email);
    const passwordOrResult = UserPassword.create(raw.user_password);

    return User.create({
      name: nameOrResult.getValue(),
      password: passwordOrResult.getValue(),
      email: emailOrResult.getValue()
      active: raw.is_active,
    }, new UniqueEntityID(raw.user_id)).getValue()
  }
}

看到这个类的范围有多窄了吗?这是单一责任原则的一个很好的例子。

重组

在我们创建了一个实体并将其持久化到数据库中后,在某些时候,我们会想把它拉出来并用于操作。

同样,这是一项由存储库映射器类轻松维护的工作。

结论

聚合根领域事件这样的领域对象还有很多,但你可以只用实体值对象就开始对你的领域进行建模。

在接下来的几篇文章中,我们将讨论如何在现实世界的Sequelize + Node + TypeScript应用程序中使用领域事件,以及如何对聚合进行建模。


  1. 这是对聚合设计的分支。某些实体确实属于其他实体的范围。我们把位于树顶的实体称为聚合根(Aggregate Root)。

  2. 这是我们定位领域逻辑的地方,在概念上不属于任何一个对象。不要与应用服务相混淆。领域服务只对领域对象进行操作,而应用服务是对领域不纯的人工制品,它可能从外部资源(API、对象数据库等)中获取数据,诸如此类。