干净架构概念已经存在了一段时间,并不断出现在一个或另一个地方,但它并没有被广泛采用。 在这篇文章中,我想以一种不太传统的方式介绍这个主题:从客户的需求开始,经过各个阶段,提出一个足够清晰的解决方案,以满足上述博客(或同名书籍)中的概念。
观点
为什么我们需要软件架构?它到底是什么?在敏捷世界有点出乎意料的地方可以找到广泛的定义——来自 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 的概念很有用。感谢您的阅读!
对人们喜爱的产品感兴趣的软件工程师。反馈循环爱好者。在 Allegro,他担任开发团队负责人 (Allegro Biznes)。
原文:https://blog.allegro.tech/2021/12/clean-architecture-story.html