Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

签名要真实:Kotlin 中的领域错误与函数式错误处理

#kotlin#错误处理#领域驱动设计#类型安全#函数式编程

[LOADING...]

Sergey Chernov

Sergey Chernov 是 Salmon 的资深软件工程师,专攻函数式 Kotlin 与类型安全系统设计。Salmon 是一家技术驱动的金融公司,在东南亚构建银行和信贷产品;Sergey 负责认证与验证系统,这一平台层确保用户访问跨产品保持安全、可靠和一致。他拥有 10 年以上设计和构建可扩展后端系统的经验。

下面是一个签署文档的函数:

$ kotlin
fun signDocument(
    documentId: UUID,
    code: String,
): Unit

在 Kotlin 中,Unit 表示函数正常完成但不返回有意义的值——大致相当于 Java 中的 void

明白了吗?现在请告诉我可能会出什么问题。你做不到

然而,验证码可能无效。签署窗口可能已经关闭。数据库可能宕机。文档可能已被签署、已过期,或者请求来自一个有缺陷的客户端而乱序到达。

以上每一种都是这个函数必须面对的真实结果。但它们在函数签名里一行都看不到。

要想发现可能的失败以及如何处理它们,你可能得打开实现,再看它调用的服务,然后看异常处理器、路由映射、测试、OpenAPI 规范,以及消费它的客户端代码。

你可以读完所有东西,却唯独漏掉最初就应该告诉你的那一样:函数签名

在 Salmon,我负责认证与验证工作。处理不当的失败很少只是表面问题;两个错误案例之间的差别,可能就是放对的人通过和放错的人通过之间的差别。我花了不少时间思考这个问题:如何让一个函数的预期失败成为它向你传达的信息的一部分,而不是需要你去翻找的东西?

这篇文章就是我的回答。示例使用 Kotlin,但这个概念适用于任何具有密封类型的语言。

不必害怕“函数式错误处理”

“函数式错误处理”。这个短语会吓跑很多人。他们以为要面对单子(monad)、范畴论和一场说教。但并非如此。目标很简单:函数签名应当足以让你知道如何调用它,以及如何处理每一个预期结果。函数体内不隐藏任何东西。

如果某个失败属于业务逻辑的一部分,它就应该出现在函数签名、API 契约和客户端的处理代码中,而不是埋在实现里。

Salmon 的工程文化建立在几条承诺之上:从第一天起就真正拥有责任、公开坚持高标准、拒绝发布实际不能工作的东西。一个隐藏失败的函数与这三条都相悖。

因此,对于上面的例子,我真正想要的签名应该长这样:

$ kotlin
fun signDocument(
    documentId: UUID,
    code: String,
): Either<DocumentSignError, Unit>

现在,函数左侧是输入,右侧是预期失败类型和成功类型。

在讨论 Either 是什么之前,我们需要先明确 DocumentSignError 里应该放什么,因为这个系统的很多价值正来自于此。

三种失败,但只有一种应该出现在签名中

并非所有坏事都同一种坏法。我把失败分成三组,每组用不同的方式处理。

01 · API 客户端错误

调用方错误地使用了 API:包括格式错误的 JSON、缺少请求头、不支持的操作、乱序到达的请求、未被允许的访问。

一个健康的客户端几乎不会遇到这些错误,也不会有专门设计的界面来展示它们,因为正常工作的应用不会产生这些错误。因此,你可以把整个类别压缩为粗略的 HTTP 响应:400、403、404。不要在领域模型中逐一枚举它们。

02 · 非预期异常

数据库不可用、依赖超时、网络中断、某个 null 溜进来导致 NullPointerException,或者某个不变量被破坏使系统进入非法状态。这些不是业务结果。

没有人会为“Postgres 挂了”设计用户流程。你不应把它们建模为领域错误。相反,它们应该成为运维信号:给客户端返回 500,在日志中留下完整堆栈,错误率指标出现尖峰,并呼叫当值人员。

03 · 领域错误

在这里,客户端行为正确,但操作仍然无法成功。

签署码错误、窗口已关闭、文档已被签署、缺少审批、策略拒绝。这些是真实用户在做对一切的情况下仍然会遇到的失败,而设计师为每一种失败都设计了专门的界面。

这才是必须可见的类别。如果健康的客户端需要以不同方式处理两种结果,那么这两种结果就必须在类型上可区分。这个组别才应该出现在契约中。

我经常看到有人错误地把第二组拖进另外两组。例如,有人把 DatabaseUnavailable 加进错误联合类型,仿佛它是业务失败。它不是。让它抛出,让全局处理器捕获它,保持领域模型的诚实。

HTTP 400 不是领域概念。“签署窗口已关闭”才是。

无论如何,如果你能正确识别并区分这三类失败,大部分设计工作就已经完成了。剩下的就是选择一种机制,让该可见的那一组保持可见。

为什么异常及其近亲总是落败

大多数 Java 和 Kotlin 代码库的默认做法是先校验,然后抛出异常:

$ kotlin
fun signDocument(documentId: UUID, code: String) {
    if (signingWindowClosed(documentId)) throw SigningWindowClosedException()
    if (!codeMatches(documentId, code)) throw SignatureRejectedException()
    if (alreadySigned(documentId)) throw AlreadySignedException()
    // ... sign it
}

签名说的是“什么都不返回,成功”。但实现却讲着另一个故事,而编译器不会强迫调用方去听。如果下个季度有人添加第四个异常,所有调用点仍然能编译通过,且所有调用点都会静默地不处理新情况。你到生产环境才发现,这不理想。

Java 曾尝试用受检异常来解决这个问题,直觉是对的:强迫调用方处理已声明的失败,或者继续向上传递。但它没有扩展到复杂场景。而且 Stream API 根本不能与受检异常组合,于是你只能偷偷抛出,再把所有东西包装回运行时异常。

事实证明,更好的工具已经存在于语言本身。密封接口告诉编译器完整的子类型集合,这意味着当你(用 Kotlin 的 when 表达式)处理这些错误时,编译器可以可靠地验证你是否漏掉了某个分支:

$ kotlin
sealed interface DocumentSignError {
    data object SignatureRejected   : DocumentSignError
    data object SigningWindowClosed : DocumentSignError
    data object AlreadySigned       : DocumentSignError
}

现在,调用方处理每一个分支,并由编译器强制执行:

$ kotlin
when (error) {
    SignatureRejected   -> showSignatureRejected()
    SigningWindowClosed -> showSigningWindowClosed()
    AlreadySigned       -> showAlreadySigned()
}

向密封接口添加第四个失败类型后,这个 when 就会停止编译,直到你处理它。这就是全部关键:编译器现在知道可能失败的是什么,并且不会让你忘记。

你其实重新发明了 Either

一旦有了密封错误类型,你就需要一种方式来表达“这个函数返回该错误或成功”。你可以手工构建一个包装类型,也确实有很多人这样做,为每种结果类型重复劳动。这很快就会变得冗长。

你真正需要的是同一形状的泛化版本:一个值要么是这一种,要么是那一种,绝不会同时是两者。Left 表示失败,Right 表示成功。这就是 Either。理解它不需要任何库。它是一个密封类型,有两个分支和少量辅助方法(mapflatMapfoldgetOrElse)。如果你用过 Java 的 Optional 或 Kotlin 的可空类型,你已经知道使用它的感受。Optional 大致就是一个左侧不携带任何信息、只有 UnitEither

回报是失败集合进入了公开类型:

$ kotlin
fun signDocument(
    documentId: UUID,
    code: String,
): Either<DocumentSignError, Unit>

失败不再隐藏在函数体中;它是函数一开始就告诉你的信息的一部分。

两个容易用错的联合类型

不幸的是,团队一旦采用这种做法,两个反模式就会频繁出现,而它们都会抵消掉大部分收益。

$ kotlin
fun signDocument(documentId: UUID, code: String):
Either<Throwable, Unit>

虽然这看起来是类型化的,但这个类型只表示“有东西可能失败”。它没有说明调用方必须处理哪些预期失败,因为 Throwable 是开放的,所以对它的 when 总是需要 else。你又回到了什么都不知道的状态。

这本质上和抛出一个错误一样;这也是 Kotlin 自带的 Result<T> 类型不适用、也不被推荐用于领域建模的原因。如果左侧是开放的,你就什么也没得到。

第二种反模式是为了避免重复而让整个类共享一个宽泛的联合类型:

$ kotlin
sealed interface DocumentError {
    data object SignatureRejected   : DocumentError
    data object SigningWindowClosed : DocumentError
    data object AlreadySigned       : DocumentError
    data object TemplateNotFound    : DocumentError
    data object ExportFailed        : DocumentError
}

fun signDocument(...)     : Either<DocumentError, Unit>
fun prepareSigning(...)   : Either<DocumentError, SigningSession>
fun exportDocument(...)   : Either<DocumentError, ExportFile>

编译器满意了,但现在每个方法看起来都会返回所有错误。signDocument 永远不会产生 TemplateNotFound,但每个调用方还是必须处理它。结果就是穷尽式处理里充满了不可能的分支,这不过是披着类型外衣的兜底式编程。

解决办法是为每个公开方法定义一个窄联合类型:

$ kotlin
sealed interface DocumentSignError { /* the three real failures */ }
sealed interface PrepareSigningError { /* its own set */ }
sealed interface ExportError { /* its own set */ }

这样,每个 when 只需处理其方法实际可能返回的结果。没有 else,也没有不可能的分支:

$ kotlin
when (error) {
    SignatureRejected   -> showSignatureRejected()
    SigningWindowClosed -> showSigningWindowClosed()
    AlreadySigned       -> showAlreadySigned()
}

前期多写一点类型,但以后每次阅读这些签名时都会觉得值得。

组合,而不陷入管道细节

真实流程会串联多个步骤,每个步骤都可能失败。如果朴素地使用 flatMap,每一步都会让 lambda 嵌套更深,代码变得很难看。

你有几种解决办法。纯 Kotlin 可以用提前返回来处理:

$ kotlin
val document = findDocument(documentId)
    .getOrElse { return it.left() }

扁平、类型化,而且这个模式本身不需要任何库:如果你手写 Either,就自己实现这些辅助函数。上面的语法恰好用到了 Arrow 的 getOrElseleft,但这里没有任何东西依赖复杂抽象。

如果你希望更干净,Arrow 还提供了 either { } 块,其中 bind() 会解开一个 right 值,并在遇到第一个 left 时短路返回:

$ kotlin
either {
    val document = findDocument(documentId).bind()
    validateStatus(document).bind()
    val signature = validateSignature(document, code).bind()
    markSigned(document, signature).bind()
}

这和 Scala 多年来通过 for-comprehension 内建在语言中的思路是一样的。如果 Arrow 的易用性对你的团队有帮助,就用它;它还带来了非空列表等有用的类型。(但契约思想并不依赖 Arrow,我更希望你采纳这种纪律,而不是依赖这个库。)

契约应该贯穿全程

类型化的失败只有在跨整个技术栈保持类型化时才有用。我遵循的规则是:服务和仓储返回领域错误,并且只在路由边界这一处映射为 HTTP。

$ kotlin
service.signDocument(request)
    .mapLeft { error -> error.toHttpResponse() }

预期的领域失败变成 Either.Left。API 客户端误用被压缩为粗略的 4xx。非预期的基础设施失败和 bug 继续保持异常形式,并变成 500。控制器是唯一知道 HTTP 的层,它下面的层只谈论业务结果。

这里还有一个大多数团队没有意识到的好处:如果你随服务一起发布 API 客户端,请一并发布错误类型。这样,客户端就能用服务端产生的同一个密封联合类型来处理失败,两者无需额外工作就能保持一致。

这对代码审查和 AI 生成的代码有什么影响?

所有这些在日常审查中都会得到回报。当失败存在于签名中时,审查者可以从契约开始,而不必考古式地翻找实现。错误联合类型变了吗?这个 API 客户端误用是不是被包装成了领域错误?新失败要映射到 HTTP 吗?你甚至不用打开函数体,只要阅读接口就能回答这些问题。

在 Salmon 和其他地方,现在很大一部分代码由 agent 起草,这种敏捷性变得更加重要。

当模型编写实现时,显式契约是检查它是否做对了的最廉价方式:你读类型,而不是读底下的 200 行代码。你可以把规则写进 agent 指令文件:“为预期失败返回类型化错误联合,不要抛异常”,模型大多会照做。但你的验证方式是阅读契约,而不是相信文字。

事实上,在 Salmon 的团队里,这与其说是个人的偏好,不如说是共享的默认约定:契约是审查的基本单元,生成的实现不会降低这个标准。决定一个操作实际上可能产生哪些失败,是一种判断;签名就是把这个判断写下来的地方,让下一个人或下一个 agent 必须尊重它。本质上,签名就是责任所在。

诚实的权衡

这需要你付出一些代价:更多类型、更多映射代码、更冗长的签名。我不会假装不是这样。

但复杂性本来就在那里。签署窗口本来就可能关闭,验证码本来就可能错误。这种方法所做的,只是把复杂性从它隐藏的实现中拿出来,放进类型里,在那里它有了名字,可以被测试,也可见。

你只是把工作移到了编译器能帮忙的地方。它把风险暴露给下一个调用方,而不是隐藏起来;它让代码真正做什么变得清晰,并阻止有问题的路径通过编译。让失败成为签名的一部分,是这些价值在最小尺度上的体现:一个函数如实说明它能做什么。这也是我们在 Salmon 实际工作的方式:我们在服务及其客户端之间共享这些类型化契约,并在审查时先读契约,再看实现。

一个返回 Unit 却偷偷抛出异常的签名,正在欺骗你。让你的签名说真话吧!