利用应用层用例进行更好的软件设计|企业Node.js + TypeScript

207 阅读15分钟

用例这个词是用来描述我们的软件可以被使用的潜在方式之一。

另一个有时被使用的词是功能,尽管其概念几乎是相同的。

构建软件的全部目的是为了解决一个或多个用例。

在大型项目中,有时很难确定系统的能力是什么。

在以API为先设计的CRUD应用中,这一点令人难以置信。

如果有一个特殊的结构 出现在我们的代码中,描述我们应用程序的所有不同的能力,会怎么样呢?

这将有助于封装、组织和跟踪我们的应用程序所能做到的所有事情。

嗯,它确实存在,它被正确地称为:你猜对了用例


Clean Architecture中,用例是一个应用层的关注点,它封装了在我们的应用中执行功能所涉及的业务逻辑。

在这篇文章中,我们将介绍以下主题,即在应用层中使用Use Case来构建Node.js/TypeScript应用程序。

  • 如何发现用例
  • 应用层的作用
  • 如何确定用例属于哪个子域?
  • 用例如何使大型项目更具可读性(尖叫架构)。
  • 如何用TypeScript实现Use Cases

要查看本文中使用的代码,请查看。

github.com/stemmlerjs/…


发现用例

有几种不同的方法来计划构建一个应用程序。在很长一段时间里,我只是简单地通过设计API来计划我将如何实际构建一些东西。

后来,我更多地转向线框设计,并首先从界面开始,因为前端往往决定了什么是真正需要的,什么是YAGNI

直到我开始工作的项目变得如此复杂,我才意识到我需要采取一种更传统的方法来进行软件规划。用例设计

传统方法

有一种传统的方法是使用用例图来识别和绘制用例。

在用例图中,棍子人代表我们系统的actor (使用该系统的人),而圆圈代表我们想让他们用我们的软件执行的所有实际用例。

我认为知道如何创建这些文件是件好事,但我很少在开始设计一个系统时只为了生成这些文件。

相反,我试图通过对话来确定基本的组成部分,并使用一种自由的方法来画出东西。

用例基础知识

关于识别用例,最重要的事情是要理解。

    1. 谁是系统的actors (谁在执行这些用例)
    1. 用例是命令查询
    1. 用例属于一个特定的子域,它可能被部署在独立的边界环境中

1.行为者

User很容易把每个actor ,我们可以这样做,但这并不能说明领域本身的情况。

有一个时间和地点可以把用户称为User ,比如在身份和访问管理子域中,如Amazon IAM或Auth0,但我们应该尽量通过思考他们在域中的角色来识别我们系统中的实际actors

下面是一些替代User ,取决于域的情况。

  • 一个计费系统。Customer,Subscriber,Accountant,TreasurerEmployee
  • 一个博客系统。Editor,Reviewer,GuestAuthor
  • 一个招聘平台。JobSeeker,Employer,InterviewerRecruiter
  • 我们的乙烯交易应用程序。Trader,Admin
  • 一家电子邮件营销公司。Contact,Recipient,SenderListOwner

明白了吗?角色很重要。

2.用例是命令和查询

一个用例将是一个命令或一个查询

有一个完整的面向对象的设计原则专门针对这种现象,叫做CQS(命令-查询隔离)。当你遵循它时,它有助于使应用程序的副作用更容易推理,并有助于减少错误和提高可读性。

例如,在我们的乙烯交易应用程序"白标 "中,一个特定命令的例子是将乙烯添加到我们的愿望清单。这可能会在我们的代码中显示为一个叫做AddVinylToWishlist 的类。

一个查询的例子是获取我们的愿望清单,它可能显示为GetWishlist

命令-查询隔离中,COMMANDS对系统进行改变**,但不返回任何值**,而QUERIES从系统中提取数据,但不产生任何副作用

3.用例属于一个特定的子域

一般来说,大多数应用程序是由几个子域组成的。

如果你不记得领域驱动设计中的子域是什么,它是整个问题域的一个逻辑分离。

一个子域是整个问题域的逻辑分离。

什么是问题域?

例如,白标,我正在建立的黑胶交易应用程序,是关于黑胶交易的。

但问题域并不仅仅是让交易者能够交易黑胶唱片。还有很多东西需要考虑。

除了交易方面(Trading),企业还必须考虑其他几个子域:身份和访问管理(Users)、项目编目(Catalog)、计费(Billing)、通知等等。

这就是康威法则的精髓,该法则指出。

"设计系统的组织,受制于产生的设计是这些组织的通信结构的副本"。

康威定律实际上有助于回答很多问题,比如。

  • 我们如何决定我们的子域?
  • 我们如何决定一个用例应该属于哪个子域?
  • 我们如何使将来改变用例变得更容易?

关于康威法则以及它如何帮助做出这些决定的更多信息,请查看这篇文章

在任何领域驱动的项目中,我们通常能够将整个问题域分解成独立的子域;其中一些是我们自己开发的必要条件(如Trading ,可能还有Catalog 子域),而其中一些我们可能只需要使用供应商(如Users & Identity 的Auth0 或Billing 的Stripe)。

大型单体应用的子域之间的耦合度最小,据说在逻辑上是独立的。

一个好的开始和经验法则是将单片机项目的子域按文件夹分开。这也可以提高可读性(见"尖叫的架构")。

如果我们虚构的企业应用需要扩展,这种逻辑上的分离将是必不可少的,以便我们能够将我们的问题域物理上分离成几个可独立部署的单元。

换句话说:微服务。

DDD的原话来说就是:独立的有边界的上下文

我们通过对话发现用例

关于软件开发的一个巨大的误解是,开发人员只是在角落里编码,而不必与人交谈。

不正确的。软件开发(尤其是DDD)中的很多内容都是在不断尝试找出正确的语言,以便有效地从现实生活中创建持久的软件实现模型。


下面是一个对话的例子,以发现白色标签中的一些用例。

"所以,我有这个想法,要做一个应用程序。这是一个你可以和其他人交换黑胶的应用程序。"

"好的,很好。那么,像Discogs?"

"是的,差不多。但它只针对黑胶唱片。真正的潮人。"

"太棒了,我喜欢它。如果我不想交易我的黑胶唱片呢?我可以直接东西吗?"

"是的,这么说吧:你可以用自己的黑胶换别人的黑胶,也可以用钱代替。"

"哦,那这个呢?用户可以提出复杂的报价,比如,嘿,我给你这2张Devo专辑,一张Sex Pistols专辑,还有60块钱买那张限量版的Birthday Party黑胶"。

"啊,所以有offerstrades 。而一个offer ,可以包括几张唱片和/或金钱来换取一张或多张唱片。而收到报价的人可以接受它,也可以拒绝报价。"

"是的,这听起来很准确。如果他们想拒绝,他们可以拒绝'有意见'或其他东西,说他们为什么拒绝,并给他们另一个理由来提出另一个提议。"

"是的,好极了。"

"那么,到目前为止我们发现的用例是什么?"

"

  • MakeOffer(offer: Offer)
  • DeclineOffer(offerId: OfferId, comments?: string)
  • AcceptOffer(offerId: OfferId)

"

"可能还有获取所有报价的能力,以及通过ID获取报价。我们也必须考虑用户界面。它将需要一些用例。"

"啊,是的。所以还有GetAllOffers(userId: User)GetOfferById(offerId: OfferId)"。

"嗯,User 是从何而来的?"

"这就是我们一直在谈论的东西,不是吗?Users 能做的事情。"

"是的,但不是真的。让我们考虑一下这里的泛在语言。关于这个Trading 子域和它们的作用,把它们称为Traders ,或RecordCollectors ,会更有意义。

"啊,我明白了,User 这个词可能更属于Users & Identity 子域,而不是......Trading 子域,这就是我们迄今为止讨论的内容,对吗?"

"是的,这就对了。"

"好吧,我们就用Traders 。"

"棒极了。到目前为止,在Trading 子域中,我们的用例是。

  • MakeOffer(offer: Offer)
  • DeclineOffer(offerId: OfferId, comments?: string)
  • AcceptOffer(offerId: OfferId)
  • GetAllOffers(traderId: TraderId)
  • GetOfferById(offerId: offerId)

我还想不出其他的东西。"

"我也是,我们暂时就用这个吧。"

"而且看起来我们也确定了一些实体。OfferTrader ,对吗?"

"是的,Offer 可能也要成为所有OfferItems 的聚合根(金钱+黑胶的集合)。我们可以稍后弄清楚。到目前为止听起来不错。

"哦,既然我们提出了Users & Identity 子域,我们是否也应该解决这个问题?"

"嗯,是的--我们可以。它可能会和其他的应用程序一样。"

"你是什么意思?"

"嗯,用例是很常见的。通常有像这样的东西。

  • login(userEmail: UserEmail, password: UserPassword)
  • logout(authToken: JWTToken)
  • verifyEmail(emailVerificationToken: EmailVerificationToken)
  • changePassword(passwordResetToken: Token, password: UserPassword)

你知道我的意思吗?你可能已经做过很多次了。"

"啊,这不是我们可以外包的事情吗?"

"是的,我们也许可以试试Auth0的做法。"

"他们在DDD-lingo中怎么称呼这个?子域的类型..."

"通用子域。"

"什么意思?"

"意思是,虽然它可能是业务的一个关键部分,但它不是业务的核心。核心可能是Trading 子域"。

"对。这将是核心子域,因为这基本上是我们应用程序的独特之处,不能随便外包或买现成的。"

应用层的作用

如果你一直在关注关于企业Node.js + TypeScript的系列文章,你会记得域层持有所有的实体价值对象,对外层的依赖性为0,并且是我们旨在放置业务逻辑的第一个地方,特别是如果它与一个特定实体有关。

例如,在白色标签中,如果我试图找出放置不变逻辑的位置,以确保一个Vinyl ,最多只能有3个不同的Genre,那么这个逻辑就属于Vinyl 类(这是一个聚合根)。

class Vinyl extends AggregateRoot<VinylProps> {
  ...

  addGenre (genre: Genre): Result<any> {
    if (this.props.genres.length >= MAX_NUMBER_OF_GENRES_PER_VINYL) {
      return Result.fail<any>('Max number of genres reached')
    } 

    if (!this.genreAlreadyExists(genre)) {
      this.props.genres.push(genre)
    }

    return Result.ok<any>();
  }
}

通过将验证逻辑置于模型本身来确保领域模型的完整性。

Vinyl 是来自 子域的Catalog 领域层中的许多领域模型之一。

好了,所以我们大致了解了领域层的作用。而且我们记得基础设施层包含了外部服务和一些我们不想把内层搞得一团糟的东西(控制器、数据库、缓存等)。

那么,应用层的作用是什么?

应用层包含了我们应用程序中某个特定子域用例

用例描述了应用程序功能,它可以独立部署,也可以作为一个单体部署。

这意味着,当我们把用例直接放到一个子域中时,我们就可以马上了解该子域的功能。

在DDD的语言中,用例就是应用服务。它们除了负责检索领域实体外,还负责检索执行一些领域逻辑所需的信息。

例如,在AcceptOffer(offerId: OfferId) 用例中,我所拥有的只是OfferId 。这还不足以让我做接受动作。我需要整个offer 实体,以便保存offer.accept() ,并派遣一个OfferAcceptedEvent 领域事件。为了获得offer ,我需要使用一个资源库来检索和保存它。这就是他们负责检索和启动带有域实体的执行环境的方式。

让我们来看看我们如何围绕用例来构造一个项目。

围绕用例构建项目

鲍勃叔叔确定了一种叫做"尖叫式架构"的模式。它的意思是,只要看看项目结构本身,它就应该形象地对我们喊话:除了系统的能力之外,我们正在进行的项目类型

下面是在白色标签中的一点情况,当我们把它分成Subdomain =>Use Cases +entities

一目了然,这告诉我们除了users 子域是什么和它做什么之外,还有catalog 子域是什么和它做什么。

一个用例接口

用例在原则上是简单的。它们有一个可选的请求和响应。

export interface UseCase<IRequest, IResponse> {
  execute (request?: IRequest) : Promise<IResponse> | IResponse;
}

采用 "总是对接口而不是实现进行编程 "的设计原则,我们可以创建一个接口来表示这样的用例。

很简单,对吗?

实现一个用例

让我们来看看我们如何实现它。让我们从Catalog 子域做AddVinylToCatalogUseCase

首先,我们将创建类并实现接口,使用any 来表示通用DTOs(数据传输对象)

export class AddVinylToCatalogUseCase implements UseCase<any, any> {
  public async execute (request: any): Promise<any> {
    return null;
  }
}

好的,所以为了更新一个Vinyl ,我们需要提供创建它所需要的一切,除了我们要添加的Trader's id之外。

让我们把所有的东西放在请求DTO中。

interface AddVinylToCatalogUseCaseRequestDTO {
  vinylName: string;
  artistNameOrId: string;
  traderId: string;
  genresArray?: string | string[];
}

export class AddVinylToCatalogUseCase 
  implements UseCase<AddVinylToCatalogUseCaseRequestDTO, any> {
    
  async execute (request: AddVinylToCatalogUseCaseRequestDTO) : Promise<any> {
    return null;
  }
}

我们现在已经准备好实际实现用例算法了。

由于我们的Vinyl 聚合根类需要一个Artist 的实际实例,我们将不得不确定是通过id 还是通过artistName 来检索艺术家。

如果请求失败,我们将使用我们的结果类来安全地返回一个错误,否则我们将使用一个VinylRepo ,将Vinyl 保存到持久化。

所以我们需要使用依赖注入来注入一个VinylRepo 和一个ArtistRepo

我们可以在这个类的constructor 中把它作为一个依赖项。

interface AddVinylToCatalogUseCaseRequestDTO {
  vinylName: string;
  artistNameOrId: string;
  traderId: string;
  genresArray?: string | string[];
}

export class AddVinylToCatalogUseCase 
  implements UseCase<AddVinylToCatalogUseCaseRequestDTO, Result<Vinyl>> {

  private vinylRepo: IVinylRepo;
  private artistRepo: IArtistRepo;

  constructor (vinylRepo: IVinylRepo, artistRepo: IArtistRepo) {
    this.vinylRepo = vinylRepo;
    this.artistRepo = artistRepo;
  }

  public async execute (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Vinyl>> {
    return null;
  }
}

好了,现在让我们把逻辑挂起来。

export interface AddVinylToCatalogUseCaseRequestDTO {
  vinylName: string;
  artistNameOrId: string;
  traderId: string;
  genresArray?: string | string[];
}

export class AddVinylToCatalogUseCase 
  implements UseCase<AddVinylToCatalogUseCaseRequestDTO, Result<Vinyl>> {

  private vinylRepo: IVinylRepo;
  private artistRepo: IArtistRepo;

  constructor (vinylRepo: IVinylRepo, artistRepo: IArtistRepo) {
    this.vinylRepo = vinylRepo;
    this.artistRepo = artistRepo;
  }

  public async execute (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Vinyl>> {
    const { vinylName, artistNameOrId, traderId, genresArray } = request;
    let artist: Artist;

    const isArtistId = TextUtil.isUUID(artistNameOrId);

    if (isArtistId) {
      artist = await this.artistRepo.findById(artistNameOrId);
    } else {
      artist = await this.artistRepo.findByArtistName(artistNameOrId);
    }

    if (!!artist) {
      artist = Artist.create({ 
        name: ArtistName.create(artistNameOrId).getValue(), genres: [] 
      }).getValue();
    }

    const vinylOrError = Vinyl.create({
      title: vinylName,
      artist: artist,
      traderId: TraderId.create(new UniqueEntityID(traderId)),
      genres: []
    });

    if (vinylOrError.isFailure) {
      return Result.fail<Vinyl>(vinylOrError.error)
    } 

    const vinyl = vinylOrError.getValue()

    await this.vinylRepo.save(vinyl);
    return Result.ok<Vinyl>(vinyl)
  }
}

这就是了!现在,我们如何将其与我们的应用程序连接起来?

用例是与基础设施层的关注无关的

用例对于我们如何连接它们是不可知的。

只要我们能提供输入,它们就能在我们的系统上执行命令查询

这意味着它们可以被Express.jscontrollers 或任何其他来自基础设施层的外部服务挂起。

import { BaseController } from "../../../../../infra/http/BaseController";
import { AddVinylToCatalogUseCase } from "./CreateJobUseCase";
import { DecodedExpressRequest } from "../../../../../domain/types";
import { AddVinylToCatalogUseCaseRequestDTO } from "./AddVinylToCatalogUseCaseRequestDTO";

export class AddVinylToCatalogUseCaseController extends BaseController {
  private useCase: AddVinylToCatalogUseCase; 

  public constructor (useCase: AddVinylToCatalogUseCase) {
    super();
    this.useCase = useCase;
  }

  public async executeImpl (): Promise<any> {
    const req = this.req as DecodedExpressRequest;
    const { traderId } = req.decoded;
    const requestDetails = req.body as AddVinylToCatalogUseCaseRequestDTO;
    const resultOrError = await this.useCase.execute({
      ...requestDetails,
      traderId
    });
    if (resultOrError.isSuccess) {
      return this.ok(this.res, resultOrError.getValue());
    } else {
      return this.fail(resultOrError.error);
    }
  }
}

用例可以被来自应用层其他用例执行(但根据鲍勃叔叔的依赖规则,不能从域层执行)。这真是太酷了。

用例与领域事件的优雅用法

实际上,有一种非常优雅的方式可以将用例链在一起。

当一个事件可能触发另一个用例在某些情况下被执行时,你会想把用例连锁起来。在领域驱动设计中,我们通过一个事件风暴练习来确定这种行为,并使用观察者模式来发射领域事件。

当交易者的愿望清单中的一个项目被添加时通知他们

在白色标签中,交易者可以将artists或特定的vinyl 添加到他们的wishlist 。每当有人将一个新的vinyl 添加到他们的集合中,对该特定vinylartist 感兴趣的交易者将被通知它被发布。这样,他们就可以为他们感兴趣的vinyl ,向所有者发出offer

下图是对各层和用例之间通信的简化。

在规模上,如果我们想把我们的子域部署为微服务,而不是在一个单一的进程中作为一个单体运行,我们可以利用像RabbitMQAmazon MQ这样的消息代理。

我们将在以后的文章中,以观察者模式,以去耦合的方式执行链式用例,来介绍挂接域事件的具体细节。

代码库

本文中的所有代码都来自White Label,这是一个用Node.js和TypeScript构建的、使用领域驱动设计的乙烯-交易企业应用。你可以在GitHub上查看它。

github.com/stemmlerjs/…