最近文章

领域驱动设计——如何发布领域事件

领域事件(Domain Event)是域驱动设计的构建块之一,它通常是一个以过去时命名的不可变数据容器类。如:public class OrderPlaced { private Order order; public OrderPlaced(Order order){ this.order = order; } public Order getOrder()

Angular CLI常用命令——创建模块(ng generate module)

版本:v13基本命令ng generate module <name> [options]简写ng g m <name> [options]选项说明--flat:在当前项目的根目录创建文件,bool,默认false--module:简写-m,和--route一起使用,声明懒加载挂载的路由模块--project:项目名,string--route:懒加载模块的路由路径,这个需
标签:

单一职责原则(Bob大叔)【译】

1972年,David L.Parnas发表了一篇经典论文,题目是关于将系统分解为模块时使用的标准。它出现在12月份的ACM通讯上,第15卷,第12期。文中,Parnas比较了在一个简单算法中分解和分离逻辑的两种不同策略。这篇论文读起来很吸引人,我强烈要求你去研究它。他的结论部分如下:“我们试图通过这些例子证明,根据流程图开始将系统分解为模块几乎总是不正确的。相反,我们建议从一系列困难的设计决策或
标签:

干净架构(Clean Architecture)故事【译】

干净架构概念已经存在了一段时间,并不断出现在一个或另一个地方,但它并没有被广泛采用。 在这篇文章中,我想以一种不太传统的方式介绍这个主题:从客户的需求开始,经过各个阶段,提出一个足够清晰的解决方案,以满足上述博客(或同名书籍)中的概念。观点为什么我们需要软件架构?它到底是什么?在敏捷世界有点出乎意料的地方可以找到广泛的定义——来自 TOGAF 的企业架构定义。系统在其环境中的基本概念或属性体现在其
标签:

领域驱动设计——如何发布领域事件

更新于 2022.07.03 12分钟阅读 0 评论 5 推荐

    领域驱动设计

    作者: CLC
  1. 领域驱动设计——如何发布领域事件 Page 1

领域事件(Domain Event)是域驱动设计的构建块之一,它通常是一个以过去时命名的不可变数据容器类。

如:

public class OrderPlaced
{
   private Order order;
   public OrderPlaced(Order order){
       this.order = order;
   }
   public Order getOrder() {
     this.Order;
   }
}

发布领域事件的三种方式

主要有三种方式发布领域事件。

一、在领域模型内发布领域事件

在领域模型内领域事件通常的做法是,在领域模型内使用静态方法引用事件发布器,如DomainEventPublisher,在领域事件发生的地方立即发布事件。这里需要强调的是立即发布,因为所有领域事件Handler也立即开始处理(甚至聚合方法没有完成处理)。

如在《实现领域驱动设计》里的示例:

public void initiateDiscussion(DiscussionDescriptor aDescriptor) {
    if (aDescriptor == null) {
        throw new IllegalArgumentException("The descriptor must not be null.");
    }

    if (this.discussion().availability().isRequested()) {
        this.setDiscussion(this.discussion().nowReady(aDescriptor));

        DomainEventPublisher
            .instance()
            .publish(new ProductDiscussionInitiated(
                    this.tenantId(),
                    this.productId(),
                    this.discussion()));
    }
}

示例中,当讨论可以用于请求时,立即发送产品讨论已初始化事件ProductDiscussionInitiated

这种方式我认为有两个缺点:

  1. 领域事件是在聚合方法处理过程中立即发布,聚合方法的事务可能还没有完成提交,其他事件监听的处理器便可以开始执行。如果事件处理器和聚合方法需要有顺序执行,这会导致执行乱序的副作用。
  2. 违法单一职责的原则,领域聚合除了要处理聚合自身业务外,还需要考虑事件的发布。这样导致聚合业务和事件发布耦合。

二、领域聚合方法返回领域事件,在应用服务中发布

在领域聚合方法执行过程中,把发生的领域事件存放在集合中,聚合方法执行完成后,返回领域事件集合。返回的领域事件集合交由应用服务决定,什么时候发布以及如何发布领域事件。

如在《微服务架构设计模式》示例1:

聚合:Ticket.java

public List<TicketDomainEvent> confirmCreate() {
  switch (state) {
    case CREATE_PENDING:
      state = TicketState.AWAITING_ACCEPTANCE;
      return singletonList(new TicketCreatedEvent(id, new TicketDetails()));
    default:
      throw new UnsupportedStateTransitionException(state);
  }
}

在应用服务发布事件:KitchenService

public void confirmCreateTicket(Long ticketId) {
  Ticket ro = ticketRepository.findById(ticketId)
          .orElseThrow(() -> new TicketNotFoundException(ticketId));
  List<TicketDomainEvent> events = ro.confirmCreate();
  domainEventPublisher.publish(ro, events);
}

在Ticket聚合中返回的事件列表,然后再KitchenService的应用服务中,调用DomainEventPublisher发布事件。

这种方式避免了领域聚合内发布领域事件上述的两个缺点。

领域聚合方法返回领域事件是否违反CQS的争议

但这种方式也有争议:它是否违反了命令查询分离(CQS,Command-Query-Separation)原则呢?或者是Tell,Don't Ask原则。

参考:How to choose between Tell don't Ask and Command Query Separation?里的一个回答(此回答也是有争议的):

If you are using the result of a method call to make decisions elsewhere in the program, then you are not violating Tell Don’t Ask. If, on the other hand, you’re making decisions for an object based on a method call to that object, then you should move those decisions into the object itself to preserve encapsulation.

大意是分为两种情况考虑:

  1. 返回的结果用于程序的其他地方做决策,则不违反Tell Don't Ask原则。
  2. 如果返回的结果用于调用原对象方法做决策,则违反Tell Don't Ask原则。这种情况建议把决策代码移到对象内部,以提高对象的封装。

参考:Don't publish Domain Events, return them!针对聚合方法返回领域事件的做法,作者也是以上面的回答做依据。认为领域事件是应用的功能,而非领域聚合的业务。如果只返回领域事件给应用服务做决策则不违反CQS。作者也提了如果返回除了领域事件还包含了领域聚合的可变状态,则违反了CQS。

微服务架构设计模式》示例2:

public Ticket createTicket(long restaurantId, Long ticketId, TicketDetails ticketDetails) {
  ResultWithDomainEvents<Ticket, TicketDomainEvent> rwe = Ticket.create(restaurantId, ticketId, ticketDetails);
  ticketRepository.save(rwe.result);
  domainEventPublisher.publish(rwe.result, rwe.events);
  return rwe.result;
}

这个示例中,ResultWithDomainEvents除了返回领域事件外,还包含了Ticket聚合,这种做法是违反了CQS的。

三、聚合内置事件集合,聚合外部获取事件集合发布

这种方式是在每个聚合内创建一个领域事件集合。在聚合方法执行过程中,每个域事件实例都会添加到此集合。执行后,ApplicationService(或其他组件)从所有聚合实体内读取所有事件集合并发布它们。

详情参考:A better domain events pattern

思考:

  1. 应用服务或者其他组件需要能够访问聚合内的事件集合,这是否违反封装
  2. 因为发布事件的组件是管理多个聚合的事件集合,如果聚合方法为执行完,组件就获取事件集合发布,是否也存在乱序的问题呢?

这个方法未看到很多例子,不展开讨论。

参考:

http://www.kamilgrzybek.com/design/how-to-publish-and-handle-domain-events/

 

 

 

 

 

Angular CLI常用命令——创建模块(ng generate module)

发布于 2022.05.11 5分钟阅读 2 评论 5 推荐

    Angular

    作者: CLC
  1. Angular CLI常用命令——创建模块(ng generate module) Page 1

版本:v13

基本命令

ng generate module <name> [options]

简写

ng g m <name> [options]

选项说明

  • --flat:在当前项目的根目录创建文件,bool,默认false
  • --module:简写-m,和--route一起使用,声明懒加载挂载的路由模块
  • --project:项目名,string
  • --route:懒加载模块的路由路径,这个需要和--module一块使用,即在那个,string
  • --routing:创建路由模块,bool,默认false
  • --routing-scop:指定路由的作用域,值:Root和Child,一个应用只有一个Root,默认Child

创建文件路径说明:

  1. 在根目录执行命令,会在src/app下创建模块目录
  2. 在src/app子目录下执行命令,会在当前子目录下创建模块目录

示例

一、创建基本模块

>ng g m test
CREATE src/app/test/test.module.ts (190 bytes)

效果:

  1. 创建test目录,
  2. 在test目录下创建模块声明文件test.module.ts

二、创建带有路由的模块

ng g m test --routing
CREATE src/app/test/test-routing.module.ts (247 bytes)
CREATE src/app/test/test.module.ts (272 bytes)

效果:

  1. 创建test目录
  2. 在test目录下创建路由文件test-routing.module.ts
  3. 在test目录下创建模块文件test.moude.ts

test-routing.module.ts内容:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class TestRoutingModule { }

生成文件的routing-scop是:RouterModule.forChild(routes)

三、创建懒加载模块

ng g m test --routing --route=mytest -m app
CREATE src/app/test/test-routing.module.ts (335 bytes)
CREATE src/app/test/test.module.ts (343 bytes)        
CREATE src/app/test/test.component.html (19 bytes)    
CREATE src/app/test/test.component.spec.ts (612 bytes)
CREATE src/app/test/test.component.ts (264 bytes)     
CREATE src/app/test/test.component.scss (0 bytes)     
UPDATE src/app/app-routing.module.ts (2271 bytes)

在示例二基础上:

  1. 添加test.component.htmltest.component.spec.tstest.component.tstest.component.scss文件,用于指定懒加载模块的第一个组件
  2. 更新-m指定的路由模块文件app-routing.module.ts

app-routing.module.ts更新内容:

const routes: Routes = [
  //其他路由路径
  { path: 'mytest', loadChildren: () => import('./test/test.module').then(m => m.TestModule) },
];

其中path为命令中参数--route的值,即懒加载路径。

四、指定懒加载模块文件

有些情况下,路由文件的命名不是按angular-cli指定,如我们把app-routing.module.ts改为routes.ts,那么执行以上命令就会报错:

Cannot read property 'properties' of undefined

解决办法,就是-m参数直接为懒加载挂载路由所在文件。执行命令改为:

ng g m test --routing --route=mytest -m routes.ts

注意:-m的文件路径是执行命令所在目录的相对路径。

单一职责原则(Bob大叔)【译】

发布于 2021.12.31 8分钟阅读 0 评论 5 推荐

    架构

    作者: CLC
  1. 干净架构(Clean Architecture)故事【译】 Page 1
  2. 单一职责原则(Bob大叔)【译】 Page 29

1972年,David L.Parnas发表了一篇经典论文,题目是关于将系统分解为模块时使用的标准。它出现在12月份的ACM通讯上,第15卷,第12期。

文中,Parnas比较了在一个简单算法中分解和分离逻辑的两种不同策略。这篇论文读起来很吸引人,我强烈要求你去研究它。他的结论部分如下:

“我们试图通过这些例子证明,根据流程图开始将系统分解为模块几乎总是不正确的。相反,我们建议从一系列困难的设计决策或可能发生变化的设计决策开始。然后,每个模块都被设计为隐藏此类决策其他人。”

我在第二句到最后一句增加了强调。Parnas的结论是,模块应该根据它们可能改变的方式分开,至少部分分开。

两年后,Edsger Dijkstra写了另一篇题为科学思想的作用的经典论文。他在书中引入了一个术语:关注点分离。

20世纪70年代和80年代是软件体系结构原则的肥沃时期。结构化编程和设计风靡一时。在此期间,耦合和内聚的概念由Larry Constantine提出,Tom DeMarco、Meilir Page-Jones和其他许多人进一步阐述。

在20世纪90年代末,我试图将这些概念整合成一个原则,我称之为:单一责任原则。(我有一种模糊的感觉,我从Bertrand Meyer那里盗取了这一原则的名称,但我无法证实。)

单一责任原则(SRP)规定,每个软件模块都应该有一个且只有一个更改原因。这听起来不错,似乎符合Parnas的说法。然而,它回避了一个问题:什么定义了改变的理由?

一些人想知道一个bug修复是否可以作为一个改变的理由。其他人怀疑重构是否是改变的原因。这些问题可以通过指出“改变的原因”和“责任”之间的耦合来回答。

当然,代码不负责bug修复或重构。这些事情是程序员的责任,而不是程序的责任。但如果是这样的话,该项目负责什么?或者,也许更好的问题是:该计划对谁负责?更好的是:该计划的设计必须对谁做出反应?

想象一个典型的商业组织。高层有一位首席执行官。向首席执行官汇报的是C级主管:首席财务官、首席运营官和首席技术官等。首席财务官负责控制公司的财务状况。首席运营官负责管理公司的运营。CTO负责公司内部的技术基础设施和开发。

现在考虑一下java代码:

public class Employee {
  public Money calculatePay();
  public void save();
  public String reportHours();
}
  • calculatePay方法实现了基于特定员工的合同、状态、工作时间等确定该员工应支付多少工资的算法。
  • “save”方法将Employee对象管理的数据存储到企业数据库中。
  • reportHours方法返回一个字符串,该字符串附加到报告中,审计员使用该字符串来确保员工工作的小时数适当,并获得适当的报酬。

现在,向首席执行官报告的C级主管中,哪一位负责指定calculatePay方法的行为?如果这种方法被灾难性地错误指定,他们中的哪一位会被CEO解雇?显然,答案是首席财务官。规定员工薪酬是一项财务责任。如果所有员工都因为首席财务官组织中的某个人错误地指定了计算薪酬的规则而获得一年双薪,首席财务官很可能会被解雇。

不同的C级执行人员负责指定reportHours方法返回的字符串的格式和内容。该主管负责管理审计员和审查员,这是运营部的职责。因此,如果该报告出现灾难性的错误说明,首席运营官将被解雇。

最后,如果save方法出现灾难性的错误规范,那么哪些C级主管将被解雇应该是显而易见的。如果企业数据库被如此可怕的错误规范破坏,CTO可能会被解雇。

因此,当在calculatePay方法中对算法进行更改时,这些更改的请求将来自由首席财务官领导的组织,这是理所当然的。同样,COO的组织将请求更改reportHours方法,CTOs组织将请求更改save方法。

这就是单一责任原则的关键所在。这个原则是关于人的。

在编写软件模块时,您希望确保在请求更改时,这些更改只能来自单个人员,或者更确切地说,来自表示单个狭义定义的业务功能的单个紧密耦合的人员组。您希望将您的模块从整个组织的复杂性中分离出来,并设计您的系统,使每个模块只负责(响应)该业务功能的需求。

为什么?因为我们不想让首席运营官被解雇,因为我们做了首席技术官要求的改变。在我们的客户和经理看来,没有什么比发现一个程序以一种与他们要求的更改完全无关的方式出现故障更让他们害怕的了。如果您更改了calculatePay方法,并且无意中中断了reportHours方法;然后首席运营官将开始要求您不再更改calculatePay方法。

想象一下,为了修理一扇坏了的电动车窗,你把车交给了一个机械师。他第二天打电话给你说一切都修好了。当你提车时,你会发现车窗运转良好;但是汽车发动不了。你不太可能回到那个机修工那里,因为他显然是个白痴。

这就是当我们打破了客户和经理所关心的、他们没有要求我们改变的事情时的感受。

这就是我们不将SQL放在JSP中的原因。这就是我们不在计算结果的模块中生成HTML的原因。这就是业务规则不应该知道数据库模式的原因。这就是我们分开关注的原因。

单一责任原则的另一个措辞是:

把因同样原因而发生变化的事物聚集在一起。将因不同原因而改变的事物分开。

如果你仔细想想,你会发现这只是定义内聚和耦合的另一种方式。我们希望增加因相同原因而改变的事物之间的内聚力,我们希望减少因不同原因而改变的事物之间的耦合。

然而,当你思考这个原则时,请记住,改变的原因是人。是人们要求改变。你也不想混淆那些人,或者你自己,把许多不同的人出于不同的原因所关心的代码混在一起。

 

[1] 我在这里有点夸张。C级主管通常不会因为小的错误规格而被解雇。尽管如此,这并不是不可能的,它确实强调了向这些高管报告的组织关注不同的问题。

干净架构(Clean Architecture)故事【译】

更新于 2021.12.30 29分钟阅读 0 评论 5 推荐

    架构

    作者: CLC
  1. 干净架构(Clean Architecture)故事【译】 Page 1
  2. 单一职责原则(Bob大叔)【译】 Page 29

干净架构概念已经存在了一段时间,并不断出现在一个或另一个地方,但它并没有被广泛采用。 在这篇文章中,我想以一种不太传统的方式介绍这个主题:从客户的需求开始,经过各个阶段,提出一个足够清晰的解决方案,以满足上述博客(或同名书籍)中的概念。

观点

为什么我们需要软件架构?它到底是什么?在敏捷世界有点出乎意料的地方可以找到广泛的定义——来自 TOGAF 的企业架构定义。

  • 系统在其环境中的基本概念或属性体现在其元素、关系以及其设计和演化的原则中。 (来源:ISO/IEC/IEEE 42010:2011)
  • 组件的结构、它们的相互关系,以及负责它们的设计和随时间演变的原则和指南。

我们需要这样一个治理结构或形状来做什么?基本上,它允许我们在开发方面做出成本/时间高效的选择。这也体现在部署,运维以及维护上。

它还使我们尽可能多地选择,这样我们未来的选择就不会受到过去承诺过多的限制。

至此 - 我们已经定义了我们的观点。让我们深入研究一个现实世界的问题。

挑战

你是一个年轻有为的程序员,坐在宿舍里,一天下午出现了一个陌生人。 “我经营一家小公司,负责从家具店向客户运送包裹。 我需要一个允许保留插槽的数据库。 你有能力交付吗?” “当然!” ——一个年轻的、有前途的程序员还能回答什么?

错误的开始

客户需要一个数据库,那么我们可以从什么开始呢? 当然是数据库模式! 我们可以轻松识别实体:传输槽(transport slot)、时间表(schedule)、用户(我们需要一些身份验证,对吗?)、一些什么事情? 好吧,也许这不是最简单的方法。 那么我们为什么不从其他事情开始呢?

让我们选择要使用的技术! 让我们使用 React 前端、Java Spring 后端、一些 SQL 作为持久性。 为了向我们的客户展示可点击的版本,我们需要一些热身工作来设置环境、创建可部署的服务版本或 GUI 模型、配置持久性等。 一般而言:要注意技术细节——设置工作所需的代码,非开发人员通常不知道。 它只需要在我们开始讨论业务逻辑的细节之前完成。

用例驱动的方法

如果不是从我们已经知道的开始——如何可视化关系,如何构建web系统——而是从我们不知道的开始呢?很简单——通过提问,例如:系统将如何使用?是谁干的?

用例

换句话说,系统的用例是什么?让我们使用高层参与者和交互再次定义挑战:

并选择第一个必需的交互:商店进行预订。 预订需要什么? 嗯,我想先得到当前的时间表会很好。 为什么我使用“get”而不是“display”? “display”已经暗示了一种传递输出的方式,当我们听到“display”时,我们会想到一个带有 Web 应用程序的计算机屏幕。 当然是单页web应用程序。 “get”更中性,它不会通过特定的呈现方式来限制我们的视野。 坦率地说 ,例如,通过电话提供当前时间表有什么问题吗?

获取时间表:Get schedule

因此,我们可以开始考虑我们的时间表schedule模型——让它成为一个单独的实例,表示一天的预订槽位(slots)。 太好了,我们有我们的实体! 怎么得到一个? 好吧,我们需要检查是否已有存储的时间表schedule,如果有——从存储中检索它。 如果时间表schedule不可用,我们必须创建一个。 基于…? 确切地说 - 我们还不知道,我们所能说的是,它可能是灵活的。 这是我们需要与客户讨论的一些问题 - 但这并不妨碍我们继续我们的第一个用例。 逻辑其实很简单:

fun getSchedule(scheduleDay: LocalDate): DaySchedule {
  val daySchedule = daySchedulerRepository.get(scheduleDay)
  if (daySchedule != null) {
    return daySchedule
  }

  val newSchedule = dayScheduleCreator.create(scheduleDay)
  return daySchedulerRepository.save(newSchedule)
}

(完整提交: GitHub)

即使有了这个简单的逻辑,我们也确定了一个关于时间表定义的隐藏假设:创建每日时间表方法。 更重要的是,我们可以测试时间表schedule的检索——如果需要,可以定义schedule创建者——而不需要任何不相关的细节,如数据库、UI、框架等。 只测试业务规则,没有不必要的细节。

预留槽位Reserving the slot

为了完成预订,我们必须再添加至少一个用例——一个用于预留空闲槽位的用例。假设我们使用现有的逻辑,交互仍然很简单:

fun reserve(slotId: SlotId): DaySchedule {
  val daySchedule = getScheduleUseCase.getSchedule(scheduleDay = slotId.day)

  val modifiedSchedule = daySchedule.reserveSlot(slotId.index)

  return dayScheduleRepository.save(modifiedSchedule)
}

(完整提交: GitHub)

而且,正如我们所看到的——槽位预留业务规则(和约束)是在领域(domain)模型本身实现的——所以我们是安全的,任何其他交互,任何其他用例,都不会违反这些规则。 这种方法还简化了测试,因为业务规则可以与用例交互逻辑分离进行验证。

“干净架构”在哪呢?

让我们暂时停止讨论业务逻辑。 我们确实创建了考虑周全、可扩展的代码,但为什么我们要谈论“干净”的架构? 我们已经使用了领域驱动设计和六边形架构概念。 还有别的吗? 想象一下,另一个人将帮助我们实现。 她还不知道源代码,只是想看看代码库。 她看到:

在她看来,这很像,不是吗?一种预订系统!它还不是另一种具有某些方法的领域服务,这些方法与可能的用途没有明确的联系——class列表本身只描述了系统可以做什么。

第一个假设

我们有一个模拟实现(mocked implementation)作为时间表schedule创建者。可以在单元测试级别测试逻辑,但不足以运行原型。

在与我们的客户简短通话后,我们对每日时间表schedule有了更多了解——有六个时段,每个时段两小时,从上午8:00开始。我们还知道,每日时间表schedule安排的方法非常非常简单,但它将很快就会改变(例如为了适应假期等)。 所有这些问题都将在稍后解决,现在我们处于原型阶段,我们期望的结果是给我们的陌生人提供一个可行的演示。

schedule创建者的这个简单实现放在哪里呢? 到目前为止,领域将使用界面。 我们是否要将此接口的实现放到基础架构包中,并将其视为域外的东西? 当然不是! 它并不复杂,这是领域本身的一部分,我们只需用类规范替换schedule creator的模拟实现。

package eu.kowalcze.michal.arch.clean.example.domain.model

class DayScheduleCreator {
    fun create(scheduleDay: LocalDate): DaySchedule = DaySchedule(
        scheduleDay,
        createStandardSlots()
    )
//...
}

(完整提交: GitHub)

原型

我在这里不会是原创的 - 对于第一个原型版本,RESTAPI听起来很合理。 目前我们是否关心其他基础设施? 持久化? 不! 在以前的提交中,基于Map的持久性层用于单元测试,这个解决方案已经足够好了。 当然,只要系统没有重启。

在这个阶段什么是重要的?我们正在引入一个API—这是一个单独的层,因此确保领域类不会暴露给外界至关重要—并且我们不会在领域中引入对API的依赖。

package eu.kowalcze.michal.arch.clean.example.api

@Controller
class GetScheduleEndpoint(private val getScheduleUseCase: GetScheduleUseCase) {

    @GetMapping("/schedules/{localDate}")
    fun getSchedules(@PathVariable localDate: String): DayScheduleDto {
        val scheduleDay = LocalDate.parse(localDate)
        val daySchedule = getScheduleUseCase.getSchedule(scheduleDay)
        return daySchedule.toApi()
    }

}

(完整提交: GitHub)

抽象

用例

检查端点的实现(请参见代码中的注释),我们可以看到,从概念上讲,每个端点都根据相同的结构执行逻辑:

那么,我们为什么不对此进行一些抽象呢? 听起来像个疯狂的主意? 让我们检查一下! 根据我们的代码和上面的图表,我们可以识别UserCase用例抽象 - 它接受一些输入(准确地说是领域输入)并将其转换为(领域)输出

interface UseCase<INPUT, OUTPUT> {
    fun apply(input: INPUT): OUTPUT
}

(完整提交: GitHub)

用例执行器(Use Case Executor)

太棒了,我们有一些用例,我刚刚意识到,每次抛出异常时,我都希望收件箱中有一封电子邮件——我不想依靠特定于spring的机制来实现这一点。一个通用的UseCaseExecutor将对解决这个非功能性需求有很大的帮助。

class UseCaseExecutor(private val notificationGateway: NotificationGateway) {
    fun <INPUT, OUTPUT> execute(useCase: UseCase<INPUT, OUTPUT>, input: INPUT): OUTPUT {
        try {
            return useCase.apply(input)
        } catch (e: Exception) {
            notificationGateway.notify(useCase, e)
            throw e
        }
    }
}

(完整提交: GitHub)

独立于框架的响应 (Framework-independent response)

为了处理我们计划中的下一个需求,我们必须稍微改变逻辑——增加从执行器本身返回特定于spring的响应实体的可能性。使我们的代码在非spring世界中可重用(ktor,任何人?)我们将普通执行器与特定于spring的decorator分开,这样就可以在其他框架中轻松地使用此代码。

data class UseCaseApiResult<API_OUTPUT>(
    val responseCode: Int,
    val output: API_OUTPUT,
)

class SpringUseCaseExecutor(private val useCaseExecutor: UseCaseExecutor) {
    fun <DOMAIN_INPUT, DOMAIN_OUTPUT, API_OUTPUT> execute(
        useCase: UseCase<DOMAIN_INPUT, DOMAIN_OUTPUT>,
        input: DOMAIN_INPUT,
        toApiConversion: (domainOutput: DOMAIN_OUTPUT) -> UseCaseApiResult<API_OUTPUT>
    ): ResponseEntity<API_OUTPUT> {
        return useCaseExecutor.execute(useCase, input, toApiConversion).toSpringResponse()
    }
}

private fun <API_OUTPUT> UseCaseApiResult<API_OUTPUT>.toSpringResponse(): ResponseEntity<API_OUTPUT> =
    ResponseEntity.status(responseCode).body(output)

(完整提交: GitHub)

处理领域异常

哎呀。我们的原型正在运行,我们观察到导致HTTP 500错误的异常。如果能够以合理的方式将这些代码转换为专用的响应代码,而不需要使用spring基础设施,这样可以简化维护(以及将来可能的更改)。这可以通过向用例执行添加另一个参数来轻松实现,如下所示:

class UseCaseExecutor(private val notificationGateway: NotificationGateway) {
    fun <DOMAIN_INPUT, DOMAIN_OUTPUT> execute(
        useCase: UseCase<DOMAIN_INPUT, DOMAIN_OUTPUT>,
        input: DOMAIN_INPUT,
        toApiConversion: (domainOutput: DOMAIN_OUTPUT) -> UseCaseApiResult<*>,
        handledExceptions: (ExceptionHandler.() -> Any)? = null,
    ): UseCaseApiResult<*> {

        try {
            val domainOutput = useCase.apply(input)
            return toApiConversion(domainOutput)
        } catch (e: Exception) {
            // conceptual logic
            val exceptionHandler = ExceptionHandler(e)
            handledExceptions?.let { exceptionHandler.handledExceptions() }
            return UseCaseApiResult(responseCodeIfExceptionIsHandled, exceptionHandler.message ?: e.message)
        }
    }
}

(完整提交: GitHub)

处理DTO转换异常

通过简单地将输入替换为:

inputProvider: Any.() -> DOMAIN_INPUT,

(完整提交: GitHub)

我们能够以统一的方式处理在创建输入领域对象期间引发的异常,而无需在端点级别进行任何额外的try/catch。

结果

我们跨越一些功能性需求和一些非功能性需求的旅程的结果是什么?通过查看端点的定义,我们可以获得其行为的完整文档,包括异常。我们的代码很容易移植到一些不同的API(例如EJB),我们有完全可审核的修改,并且我们可以非常自由地交换层。此外,还简化了对整个服务的分析,因为明确地说明了可能的用例。

@PutMapping("/schedules/{localDate}/{index}", produces = ["application/json"], consumes = ["application/json"])
fun getSchedules(@PathVariable localDate: String, @PathVariable index: Int): ResponseEntity<*> =
    useCaseExecutor.execute(
        useCase = reserveSlotUseCase,
        inputProvider = { SlotId(LocalDate.parse(localDate), index) },
        toApiConversion = {
            val dayScheduleDto = it.toApi()
            UseCaseApiResult(HttpServletResponse.SC_ACCEPTED, dayScheduleDto)
        },
        handledExceptions = {
            exception(InvalidSlotIndexException::class, UNPROCESSABLE_ENTITY, "INVALID-SLOT-ID")
            exception(SlotAlreadyReservedException::class, CONFLICT, "SLOT-ALREADY-RESERVED")
        },
    )

(仓库: GitHub)

使用开头提到的措施对我们的解决方案进行简单评估:

比较项评估是否有优势
开发UserCase抽象迫使不同团队以比标准服务方法更重要的方式统一方法。
部署在我们的示例中,我们没有考虑部署。它肯定不会与六边形架构不同/更难。 
运行基于用例的方法揭示了系统的运行,从而缩短了开发和维护的学习曲线。
维护与六边形方法相比,进入门槛可能更低,因为服务在水平(分层)和垂直(进入具有公共领域模型的用例中)分离。
保留开放选项类似于六边形架构方法。 

其他

它类似于六边形体系结构,具有一个额外的维度,由用例组成,可以更好地了解系统的操作,并简化开发和维护。在此叙述过程中创建的解决方案允许创建自记录API端点。

高层概述

通过这些阅读,我们可以将我们的观点切换到高层视角:

并描述抽象。从内部开始,我们有:

  • 域模型、服务和网关,负责定义领域业务规则。
  • 用例,它协调业务规则的执行。
  • 用例执行器为所有用例提供通用行为。
  • API,它连接服务与外界。
  • 网关的实现,它与其他服务或持久性提供者连接。
  • 配置,负责将所有元素组合在一起。

我希望你喜欢这个简单的故事,并发现 Clean Architecture 的概念很有用。感谢您的阅读!

作者:Michał Kowalcze

对人们喜爱的产品感兴趣的软件工程师。反馈循环爱好者。在 Allegro,他担任开发团队负责人 (Allegro Biznes)。

原文:https://blog.allegro.tech/2021/12/clean-architecture-story.html