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

时间:2022-06-17 18:21:34

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

观点

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

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

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

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

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

挑战

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

错误的开始

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

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

用例驱动的方法

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

用例

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

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

并选择第一个必需的交互:商店进行预订。 预订需要什么? 嗯,我想先得到当前的时间表会很好。 为什么我使用“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)模型本身实现的——所以我们是安全的,任何其他交互,任何其他用例,都不会违反这些规则。 这种方法还简化了测试,因为业务规则可以与用例交互逻辑分离进行验证。

“干净架构”在哪呢?

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

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

在她看来,这很像,不是吗?一种预订系统!它还不是另一种具有某些方法的领域服务,这些方法与可能的用途没有明确的联系——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)

抽象

用例

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

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

那么,我们为什么不对此进行一些抽象呢? 听起来像个疯狂的主意? 让我们检查一下! 根据我们的代码和上面的图表,我们可以识别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端点。

高层概述

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

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

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

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

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

作者:Michał Kowalcze

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

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