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

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

观点

为什么我们需要软件架构?它到底是什么?在敏捷世界有点出乎意料的地方可以找到广泛的定义——来自 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

版权声明:著作权归作者所有。

相关推荐

微前端架构【译】

作者:Muhammad Anser来自🇵🇰的软件工程师,作家,演讲者和喜欢编写技术的技术极客。 在深入研究微前端技术之前,我们必须了解什么是微服务架构,因为微前端的概念隐约受到微服务的启发并以微服务命名。根据官方文档,微服务(也称为微服务架构)是一种架构风格,它将应用程序构建为一组服务,这些服务是:高度可维护和可测试松散耦合可独立部署围绕业务能力进行组织小团队微服务架构支持快速、频繁

CKEditor5事件系统(代理事件)

emitter接口提供了事件代理机制。也就是说指定选择的事件能够被其他的emitter触发。 1、代理指定的事件到另一个emitterlet anyClass = new AnyClass(); let anotherClass = new AnyClass(); let oneClass = new AnyClass(); anotherClass.on('bar',(evt,data

JPA架构(JPA - Architecture)

今天我们来学习一下JPA的架构,首先问一个问题,大家学习理解一个架构有什么好的方法呢?对于我自己来说,我觉得架构图是理解架构最好的方式以上就是JPA的架构图,我们可以知道,JPA的主要部分包括五个类: 第一个类EntityManagerFactory,我们可以称为实体管理器工厂类,很显然,这个类的作用是创建和管理多个实体管理器类,因此,我们可以大胆猜测,实体管理器工厂类和实体管理器是一对

GPU架构学习资源

这里收藏了gpu学习的网站,pdf等等资源:http://courses.cms.caltech.edu/cs179/http://www.amd.com/Documents/GCN_Architecture_whitepaper.pdfhttps://community.arm.com/graphics/b/bloghttp://cdn.imgtec.com/sdk-documentation/

[译]iOS开发可复用框架入门(实例)

当你编写一个iOS应用程序时,你通常会什么都不想就导入Foundation或UIKit框架。 如果要使用字符串,日期,文件系统或线程,可以导入Foundation。如果要使用UITableViewController或UIAlertController,则可以导入UIKit。 如果你导入了UIKit,那么可以完全忽略Foundation,因为UIKit在后台会导入它。关键是这些

[译]Angular构造函数与ngOnInit的本质区别

在Stackoverflow上最受欢迎的其中一个Angular问题是构造函数与ngOnInit的区别,这个问题已经超过了100k的浏览量。在那我回答了此问题,但还是决定在这篇文章展开说明。这个问题的大部分回答以及网络里的文章都是集中在两者在使用上的不同,这里我想给出一个比较全面的比较,挖掘组件初始化的过程。JS/TS语言相关的区别我们先从一个与语言本身有关的最明显的区别开始。ngOnInit只是一