我写的聚合设计文章绝对是我迄今为止最深入的文章。这是因为它是一个很大的话题。
作为对这篇文章的回应,我被问到一个关于集合性能的非常好的问题。看看这个问题吧。
"我想问一个关于艺术家-流派(1-m)关系的问题。
在你的例子中,你限制了一个艺术家可以拥有的流派的数量,但是如果没有这样的限制,你会怎么做?
当初始化一个新的艺术家实体时,你是否会加载所有相关的流派?
假设有一个Post-Comment(1-m)关系,一个Post可以有成百上千的评论。当你有一个getPost的用例时,你是否也加载所有的评论?"
我们如何处理一个集合会超出范围的情况?"
这真是个好问题,也是一个合理的关切。让我们来讨论这个问题。
让我们把Post 和Comment 类可视化。
interface PostProps {
// WatchedList is a custom utility I made that
// encapsulates a collection/array. It's able to
// tell when items are initial vs. newly added.
comments: WatchedList<Comment>;
}
export class Post extends AggregateRoot<PostProps> {
get comments (): Comment[] {
return this.props.comments.currentItems();
}
private constructor (props: PostProps, id?: UniqueEntityID) {
super(props, id);
}
...
}
因此,通过这种设计,对于一个Post ,实际上有0-to-manyComments ,没有领域逻辑限制的上限。
如果每次我们想对一个帖子进行操作时,我们都要为它检索每一个Comment ,那么我们的系统根本无法扩展。
我们如何解决这个问题呢?
CQS(命令查询隔离)
当我们刚开始学习DDD时,我们经常会遇到CQS、CQRS和事件源等术语。
对于刚刚开始学习DDD的开发者来说,这些话题可能会让他们感到很复杂,所以我将试图为相对简单的DDD项目尽可能地保持实用性(这可能是矛盾的--当我们的项目很复杂时🤪就需要DDD)。
下面是你现在要知道的重要内容。CQS(命令查询隔离)。
Fowler的解释是:"我们应该把一个对象的方法分成两个截然不同的类别:"
- 查询。返回一个结果,不改变系统的可观察状态(没有副作用)。
- 命令。改变系统的状态,但不返回一个值。
让我们先来讨论一下命令。
命令
如果我们考虑一下我们如何设计我们的网络应用,这几乎是我们在做CRUD 。
关于网络开发者所关心的事情,这里有一些类似命令的等价术语。
- CRUD:
Create,Update。Delete - HTTP REST方法。
POST,PUT,DELETE。PATCH - 我们的
Blog子域用例。CreatePost,UpdatePost,DeletePost,PostComment。UpdateComment
这些都是写的。写入会以某种方式对系统进行改变。
为了说明这一点,让我们建立PostComment 用例。
评论后的用例--命令
interface PostCommentRequestDTO {
userId: string;
postId: string;
html: string;
}
export class PostCommentUseCase extends UseCase<PostCommentRequestDTO, Promise<Result<any>>> {
private postRepo: IPostRepo;
constructor (postRepo: IPostRepo) {
this.postRepo = postRepo;
}
public async execute (request: PostCommentRequestDTO): Promise<Result<any>> {
const { userId, postId, html } = request;
try {
// Retrive the post
const post: Post = await this.postRepo.findPostByPostId(postId);
// Create a comment
const commentOrError: Result<Comment> = Comment.create({
postId: post.postId,
userId: UserId.create(userId),
html
});
if (commentOrError.isFailure) {
return Result.fail<any>(commentOrError.error);
}
// Get the comment from the result
const comment: Comment = commentOrError.getValue();
// Add a comment
// => This adds the comment to the post's watched list
post.addComment(comment);
...
} catch (err) {
console.log(err);
return Result.fail<any>(err);
}
}
}
和Post 内的addComment(comment: Comment): void 。
interface PostProps {
comments: WatchedList<Comment>;
}
export class Post extends AggregateRoot<PostProps> {
get comments (): Comment[] {
return this.props.comments.currentItems();
}
public addComment (comment: Comment): void {
// Adds to comments: WatchedList<Comment>.newItems()
this.comments.add(comment);
}
private constructor (props: PostProps, id?: UniqueEntityID) {
super(props, id);
}
...
}
在上面的代码中,我们已经创建了一个PostCommentUseCase ,在这里我们从repo中检索了Post 域实体,并利用Post 域模型,用post.addComment(comment) 发布评论。
让我们在这里停一下......
当我们检索
Post域模型时,我们是否也检索了所有(可能是数百条)评论?
没有。
为什么不呢?
好吧,我们可以在最初从我们的 repo 返回的Comments 的数量上设置一个limit 。
例如,我们在PostRepo 中的baseQuery() 方法可以是这样的。
export class PostRepo implements IPostRepo {
private createBaseQuery (): any {
const models = this;
return {
where: {},
include: [
{
model: models.Comment,
as: 'Comment',
limit: 5,
order: ['date_posted', 'DESC']
}
]
}
}
..
}
这将产生返回5个最新评论的效果。
但我们不是要返回这个Post 中的所有Comments 吗?这不是破坏了我们的Post 领域模型吗?
不,不会的。
我的问题是,对于这个PostCommentUseCase (我们已经确定为一个COMMAND ),我们是否需要有所有的评论才能执行它?
是否有一些不变因素,我们需要在这里对列表中的评论执行,以发布新的评论?
在上一篇文章中,我们看了这样一个事实。
...... "聚合 "是一簇相关的对象,为了数据变化的目的,我们将其视为一个单位"。- 埃文斯。126
而在Vaughn Vernon的书中,他说:。
... "当试图发现聚合体时,我们必须了解模型的真正不变性。只有掌握了这些知识,我们才能确定哪些对象应该被归类到一个特定的聚合体中。不变量是一个必须始终保持一致的业务规则"。- 摘自。Vernon, Vaughn."实现领域驱动设计"。
强调真正的不变量。要明白,我们没有任何理由需要拥有所有的子Posts ,以便执行这个COMMAND 。
除非有规则限制允许发布的评论总数,而且与我在前一篇文章中的Genres 的例子不同,如果上限要高得多(比如6000),那么我们可以考虑在从PostRepo 中检索时,将totalComments: number 作为Post 实体的必要成员。
一个COUNT(*) WHERE post_id = "$" 将比在内存中检索和重新计算6000条评论以发布一个comment 要有效得多。
因此,让我们继续,我刚刚拉入Post ,并做了post.addComment(comment) 。接下来,我们将把它保存到 repo 中。
export class PostCommentUseCase extends UseCase<PostCommentRequestDTO, Promise<Result<any>>> {
...
public async execute (request: PostCommentRequestDTO): Promise<Result<any>> {
const { userId, postId, html } = request;
try {
...
post.addComment(comment);
// save the post, cascading the save the
// any commentsRepos as well for new comments
await this.postRepo.save(post);
return Result.ok<any>()
} catch (err) {
console.log(err);
return Result.fail<any>(err);
}
}
}
当我做postRepo.save(post) ,它将把Post 模型中的任何新的comments 传递给commentRepo ,并像我们上次那样保存它们。
很好。
现在让我们把它翻转到一些READs。
阅读
假设我正在创建API调用来返回Post ,作为一种资源。
通过ID获得一个帖子
这个API调用可能看起来像这样。
- GET
/post/:id
而GetPostByIdUseCase 只是简单地检索该帖子。
interface GetPostByIdRequestDTO {
postId: string;
}
interface GetPostByIdResponseDTO {
post: Post;
}
export class GetPostByIdUseCase extends UseCase<GetPostByIdRequestDTO, Promise<Result<GetPostByIdResponseDTO>>> {
private postRepo: IPostRepo;
constructor (postRepo: IPostRepo) {
this.postRepo = postRepo;
}
public async execute (request: GetPostByIdRequestDTO): Promise<Result<any>> {
const { postId } = request;
try {
// Retrive the post
const post: Post = await this.postRepo.findPostByPostId(postId);
// Return it
return Result.ok<GetPostByIdResponseDTO>(post);
} catch (err) {
console.log(err);
return Result.fail<any>(err);
}
}
}
而PostRepo 默认只返回帖子中的5个最新的Comments 。
export class PostRepo implements IPostRepo {
private createBaseQuery (): any {
const models = this;
return {
where: {},
include: [
{
model: models.Comment,
as: 'Comment',
limit: 5,
order: ['date_posted', 'DESC']
}
]
}
}
public async findPostByPostId (postId: PostId | string): Promise<Post> {
const PostModel = this.models.Post;
const query = this.createBaseQuery();
query.where['post_id'] = (
postId instanceof PostId ? (<PostId>postId).id.toValue() : postId
);
const post = await PostModel.findOne(query);
if (!!post) return PostMap.toDomain(post);
return null;
}
}
这对于第一次调用来说应该是足够的。如果你愿意,你甚至可以对其进行调整。
那么,检索资源的其他部分呢?也就是,Comments 。
通过帖子ID获取帖子评论
假设我正在通过用户界面阅读这篇文章,并开始向下滚动。如果这个帖子有超过1000条评论,会发生什么?我们现在该怎么办呢?
如果我们有一些灵活的fetch-on-scroll功能,我们可以在滚动中进行一些异步的API调用。
为了获取更多的评论,API调用可能看起来像。
- GET /post/:id/comments?offset=5
我们可以创建一个GetCommentsByPostId 用例。
interface GetCommentsByPostIdRequestDTO {
postId: string;
offset: number;
}
interface GetCommentsByIdResponseDTO {
comments: Comment[];
}
export class GetCommentsByPostIdUseCase extends UseCase<GetCommentsByPostIdRequestDTO, Promise<Result<GetCommentsByIdResponseDTO>>> {
private commentsRepo: ICommentsRepo;
constructor (commentsRepo: ICommentsRepo) {
this.commentsRepo = commentsRepo;
}
public async execute (request: GetCommentsByPostIdRequestDTO): Promise<Result<any>> {
const { postId, offset } = request;
try {
// Retrive the comments
const comments: Comment[] = await this.commentsRepo.findCommentsByPostId(postId, offset);
// Return it
return Result.ok<GetPostByIdResponseDTO>({
comments
});
} catch (err) {
console.log(err);
return Result.fail<any>(err);
}
}
}
export class CommentsRepo implements ICommentsRepo {
private createBaseQuery (): any {
const models = this;
return {
where: {},
limit: 5
}
}
public async findCommentsByPostId (postId: PostId | string, offset?: number): Promise<Comment[]> {
const CommentModel = this.models.Comment;
const query = this.createBaseQuery();
query.where['post_id'] = (
postId instanceof PostId ? (<PostId>postId).id.toValue() : postId
);
query.offset = offset ? offset : 0;
const comments = await CommentModel.findAll(query);
return comments.map((c) => CommentMap.toDomain(c));
}
}
虽然我们仍然使用我们的参考post ,通过postId ,我们直接去comments 存储库,以获得我们在这个查询中需要的东西。
论坛中关于在查询时不使用Aggregate的对话
"不要使用你的领域模型和聚合体进行查询。
事实上,你所问的是一个足够常见的问题,以至于已经建立了一套原则和模式来避免这种情况。它被称为CQRS。"
"我无法想象有人会主张在你不需要的时候返回整个信息的集合体。"我想说的是,你的这个说法完全正确。当你不需要的时候,不要检索整个信息的总量。这是CQRS应用于DDD的核心。你不需要一个聚合体来查询。通过不同的机制(repo就很好)来获取数据,然后持续地这样做。"
收获
- 如果有一个不变量/业务规则需要通过返回聚合边界下的相关集合中的所有元素来保护,那么就全部返回(如
Genres)。 - 如果没有底层的不变性/业务规则需要通过返回聚合边界下的关联集合中的所有无界元素来保护,那么就不必为
COMMANDS,将它们全部返回。 - 直接针对资源库执行
QUERY(或者考虑研究如何建立读取模型)。