命令查询分离|面向对象的设计原则与TypeScript

338 阅读10分钟

我最近发现了一个编程术语,Heisenbug。

不,我不是在说某个化学老师变成了犯罪的毒枭。

HeisenBUG

它是对某位德国物理学家Werner KarlHeisenberg名字的双关语。

海森堡在粒子物理学中的发现是不确定性原理。它指出,"观察[粒子]的行为本身改变了被观察粒子的位置,使得(在理论上)无法准确预测其行为 "1

是我的错觉,还是这让你想起了你最糟糕的调试噩梦?

HeisenBUG是根据不确定性原理设计的,指的是极难发现一个错误的情况。它特别指的是,当我们试图仔细观察时,错误似乎消失了或有不同的表现。

在编程中,出乎意料的副作用在很大程度上是这种bug的原因。

如果问一个问题改变了答案,我们很可能会遇到问题。


命令-查询分离(CQS)

命令-查询分离(CQS)是一个设计原则(虽然不是严格意义上的面向对象),它指出:。

一个方法要么是一个执行动作的COMMAND ,要么是一个向调用者返回数据的QUERY ,但决不是两者都是。

更简单地说,"问一个问题不应该改变答案"。

为什么这很重要?

我们的想法是 改变系统的操作与简单请求系统数据的操作的代码路径分开

通过强制执行这种分离,代码变得更容易理解。这是在改变什么,还是只是在获取什么?当一个方法同时这两件事时**(改变应用程序的状态 获取数据),要理解它的真正目的**就会变得非常困难。

这可能会导致对应用程序状态的推理变得非常困难。


混乱的工作建议 API设计

想象一下,我们正在建立一个工作推荐服务

假设我们有两条API路线。

GET /jobs         - Returns jobs for me to view
GET /jobs/:jobId  - Returns a particular job

考虑一下,如果每次我做GET/jobs/:jobId ,它就会改变系统的状态,当我做GET/jobs ,它就会改变回来的东西。

最终,当我用/jobs/:jobId 查看更多的工作时,我就会在调用/jobs 时看到更多的相关工作。

理论上,这是我们系统中建立工作推荐的一种方式。

但这种设计使/jobs API变得非常不一致

考虑到测试和验证它是否正常工作会有多难。

另外,考虑到这个系统中的角色是如下的。

  • JobSeekers:求职者是那些真正在找工作的人
  • Recruiters:招聘人员为公司工作,试图让求职者申请工作。
  • Employers:雇主是发布工作的人
  • Public:匿名用户也可以在没有账户的情况下查看招聘网站上的工作。

假设每个行为者都能使用/jobs/:jobId API调用来检索职位信息。

对于每一个使用/jobs/:jobId 检索工作职位的行为者,系统应该改变吗?

当然不是,JobSeekers 可能是唯一应该适用的群体。

感觉真的很复杂,因为几个用户组都依赖同一个资源,但我们却要为其中一个用户组应用副作用,特别是。

你可以肯定,使用这样的设计,在某个地方会有一个肮脏的if 语句。

getJobByJobId.ts

interface Request {
  userId?: string;
  jobId: JobId;
}

class GetJobByJobIdUseCase implements UseCase<Request, Promise<Job>> {
  ...
  execute (request: Request): Promise<Job> {
    const { userId, jobId } = request;
    
    if (!!userId) {
      const user = await this.userRepo.findById(userId);

      const isJobSeeker = !!user.roles.find((r) => r === 'JobSeeker');
      if (isJobSeeker) {
        // dirty, log job view
      }
    }

    const job = await this.jobRepo.getJobByJobId(jobId);
    ...
  }
}

一个更好的设计

让我们明确一下COMMANDSQUERIES ,什么改变了系统,什么检索了数据。

我们可以改进设计,副作用提取到一个类似COMMAND 的POST调用中,以便与检索作业视图分开记录

GET /jobs                 - Returns all jobs 
GET /jobs/:jobId          - Returns a particular job
POST /jobs/:jobId/view    - 🔥 Logs a job view 

这极大地简化了记录工作视图检索特定工作的代码路径。

我们可以通过从它自己的/jobs/recommendations API中提取查看推荐工作的功能来进一步改进设计。

GET /jobs                     - Returns all jobs 
GET /jobs/:jobId              - Returns a particular job
POST /jobs/:jobId/view        - Logs a job view
GET /jobs/recommendations     - 🔥Returns my personal job recommendations 
                                  (used by JobSeekers only)

从用户界面上使用这个API意味着每次我们执行GetJobById QUERY ,我们就会伴随着LogJobView COMMAND

这样,我们就有两个独立的代码路径:一个用于改变系统,一个用于从系统中提取数据。我们可以放心地知道,如果我们改变了关于QUERY-ing的任何东西,它就不会破坏我们执行COMMAND的方式,反之亦然。

在代码层面上违反了原则

考虑到你在一个Comment Moderation System中写了以下postComment 方法。

commentRepo.ts

interface Comment {
  name: string;
  url: string;
  content: string;
  id: string;
}

class CommentRepo {
  ...
  postComment (name: string, url: string, content: string): Promise<Comment> {
    const CommentSequelizeModel = this.models.Comment;

    // Post comment
    const comment = await CommentSequelizeModel.create({ name, url, content });
    return CommentMap.toDomain(comment); // with commentId on it
  }
}

首先,看一下方法的签名。

postComment (name: string, url: string, content: string): Promise<Comment>

这个名字暗示了操作将是一个COMMAND ,但它也返回一个值,违反了原则。

也许返回创建的Comment ,可以说是合理的。假设存在一个CommentService ,为了制作一个Slack频道的消息来通知我们一个新的评论,我们需要从CommentRepo'spostComment 方法中返回新创建的CommentcommentId

commentService.ts

class CommentService {
  ...
  async postCommentAndPostToSlack (name: string, url: string, content: string) {
    const comment = await this.commentRepo.postComment(name, url, content);

    // Needs comment.commentId in order to craft the message.

    this.slackService.sendMessage(`
      New comment posted:
        => Name: ${name}
        => Url: ${url}/commentId/${comment.id}
        => Content: ${content}
    `)
  }
}

所以,然后...

这段代码有什么问题?

  • 虽然CommentRepo 方法被欺骗性地命名为postComment ,但它不仅负责发布评论,而且还负责检索被发布的评论。开发人员在阅读该方法的签名时可能会对该方法的单一职责感到困惑。QUERY 的能力应该被委托给一个新的方法,也许是getComment(commentId: string)
  • 不在域层内生成Comment 的id,而将其留给持久化层(Sequelize)是有问题的,就像这里所示。这可能导致公然违反原则*,以了解刚刚保存到数据库中的实体的标识符。这种糟糕的设计迫使执行COMMAND 的调用代码不仅*要知道COMMAND 是成功还是失败,而且还要迫使COMMAND 在成功时返回该值。

修复它

除了改成从领域层内创建整个Comment 领域模型(使用包含UUID标识符Value ObjectsEntity),我们还可以通过引入一个新的方法getComment ,将COMMANDQUERY 方面的postComment 方法隔离。

commentRepo.ts

class CommentRepo {
  ...
  postComment (comment: Comment): Promise<void> {
    const CommentSequelizeModel = this.models.Comment;
    // Post comment
    await CommentSequelizeModel.create(comment);
    return;
  }

  getComment (commentId: CommentId): Promse<Comment> {
    const CommentSequelizeModel = this.models.Comment;
    const createQuery = this.createQuery();
    createQuery.where['comment_id'] = commentId.id.tostring();
    const comment = await CommentSequelizeModel.findOne();
    return CommentMap.toDomain(comment)
  }
}

而现在,从CommentService ,我们应该能够发送Slack消息,而不依赖于COMMAND 的返回值。

commentService.ts

class CommentService {
  ...
  async postCommentAndPostToSlack (name: string, url: string, content: string) {
    const comment: Comment = Comment.create(name, url, content);
    await this.commentRepo.postComment(comment);

    // Needs comment.commentId in order to craft the message.

    this.slackService.sendMessage(`
      New comment posted:
        => Name: ${name}
        => Url: ${url}/commentId/${comment.id}
        => Content: ${content}
    `)
  }
}

测验

让我们看看我解释得如何。

其中哪些是有效的COMMANDs?

  1. getComment (commentId: CommentId): Promise<void>
  2. createJob (job: Job): Promise<Job>
  3. postComment (comment: Comment): Promise<Comment[]>
  4. approveComment (commentId: CommentId): Promise<void>

显示答案

只有#4。


其中哪些是有效的QUERIES

  1. getAllVinyl (): Promise<Vinyl[]>
  2. getVinylById (vinylId: VinylId): Promise<Vinyl[]>
  3. getAllVinyl (): Promise<void>
  4. getAllVinylById (vinylId: VinylId): Promise<Vinyl>

显示答案

#1, #2, and #4, although #2 is confusing to return an array of vinyl in regards to one vinylId.


CQS是在软件开发的若干背景下不断出现的一个原则

CRUD中的CQS

创建读取更新删除(CRUD)通常是我们思考设计琐碎的MVC应用程序的方式。CRUD中的每个操作都完全符合COMMANDQUERY 的定义。

  • CRUD命令。Create,UpdateDelete
  • CRUD查询。READ

RESTful HTTP中的CQS

CQS也与RESTful HTTP的原则相一致。一个HTTP方法也是一个COMMAND ,或者一个QUERY

  • HTTP命令。POST,PUT,DELETEPATCH
  • HTTP查询。GET

在思考某条RESTful API路线的行为可能具有挑战性的时候,可以回想一下CQS原则。

  • 创建用户:POST -/api/users/new
  • 获取一个用户。GET -/api/users/:userId

SQL中的CQS

我们在SQL中做的几乎每一个操作都是COMMAND ,只有一个操作是QUERY 。我相信你能猜到是哪一个(提示:它与 "REFLECT "押韵)。

用例设计中的CQS

超越简单的MVC的项目是以应用程序的用例为中心的。这些是针对个别人群功能/Actors

每个用例严格来说都是一个命令或查询。

在一个Blog 子域中。

  • 命令。CreatePost,UpdatePost,DeletePost,PostCommentUpdateComment
  • 查询。GetPostById,GetAllPosts,GetCommentByIdGetAllCommentsForPost

使用CQRS的领域驱动设计架构中的CQS

在领域驱动设计中,将READ 模型与WRITE 模型分开是有意义的。WRITE通常需要多一点时间来完成执行,因为一个WRITE一个聚合体需要聚合体被完全构成,并包含它的一致性边界内的所有东西,以便执行不变性

正如我们在这篇文章中所探讨的那样,将相同的READ 模型作为WRITE 模型使用会很昂贵

READ 模型用于所有的QUERIES ,让WRITE 模型用于COMMANDs更有意义。

这种分离成两种不同类型的模型是一种叫做命令查询责任分离(CQRS)的架构模式。

如果你在做领域驱动设计,你很可能很难避免它。

在CQRS中执行读取

image.png

在CQRS中执行写操作

image.png

摘要

  • 理论上,我们在编程中写的绝大多数操作只做一个或另一个(改变应用程序的状态,或检索一些数据),但有些时候会不清楚。
  • 意识到这一原则可以清除任何模糊的地方,如果一个方法是COMMAND 还是QUERY ,如果我们期望有任何副作用来改变应用程序的状态。这可以减少错误,提高可读性,并使代码更容易测试。
  • 在简单的CRUD应用中,CQS通常不会被考虑,因为创建 读取 更新删除操作中的每个操作显然都是命令或查询。
  • CQS非常适用于复杂的领域,在那里,领域驱动设计是最有用的。
  • 还有一种技术叫做CQRS,本质上是CQS,但在架构层面上。
  • 在DDD应用程序中,用例有时会让人觉得模糊不清,就像COMMANDQUERY ,就像GetJobById QUERY (工作委员会的例子)。为了提高性能,必须意识到什么是真正的COMMAND ,什么是真正的QUERY ,并使用CQRS来分离读和写模型。

  1. 不确定性原理- 关于该原理的迷人的维基百科条目,有时也被称为海森堡效应