Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

KotlinConf回顾:Verifone架构师谈Kotlin在关键支付系统中的可靠性实践

#kotlin#支付系统#类型安全#编译器#金融科技

尤里·格罗尼穆斯四岁的儿子曾经问他,爸爸是做什么工作的。尤里告诉他,自己写书:写那些描述他想在物理世界中改变什么的书。儿子没有完全听懂。

大多数工程师也不会完全懂:如果你理解应用代码里的词,你就能构建好的系统。如果你不理解,你构建出来的就是 bug。

这正是尤里·格罗尼穆斯在 KotlinConf 2026 演讲的主线——“Kotlin 作为安全网:金融行业中的类型驱动可靠性”。尤里是 Verifone 的架构师,工作涉及支付终端、网关,以及将它们与 EMEA 地区的银行和监管机构连接起来的系统。他的方法用一句话说就是:用监管机构或卡组织规范中的确切措辞来写代码,并让编译器来强制执行。不是靠纪律来执行,而是靠编译器来执行。

观看 KotlinConf 2026 演讲

观看尤里·格罗尼穆斯的 KotlinConf 2026 演讲,了解 Kotlin 如何帮助构建可靠的支付系统。

终端背后的世界

在讲模式之前,尤里先说明了赌注有多大。“支付”不是某一个应用。它是移动销售应用、销售点应用、运行在锁定操作系统上的支付终端、支付网关、终端管理系统、商户平台,而在这所有一切之下,还有处理机构、银行和监管机构。每个国家都有数千页规范,以及大约 1,000 个场景,任何东西要进入生产环境,都必须先满足这些要求。

按照监管要求,其中一些终端必须能够离线持续工作最多 40 天,因为在它们运营的国家,支付终端是与加油站或药房同一级别的关键基础设施。支付团队还必须满足不同监管机构对同一套共享基础设施提出的相互矛盾的规则:一个国家要求某种行为,另一个国家禁止它,而两者都必须在同一个代码库中强制执行。这就是类型系统必须支撑的环境。不是演示环境。而是生产环境,遍布全球,持续数十年,而不是一年。

一个词搞垮支付系统的六种方式

尤里逐一讲述了他在支付和银行业 15 年里听说的真实事故模式,每一起都可以追溯到某个缺失或被误用的词:

  • 一个不正确的字段导致了撤销,结果别人的钱落到了错误账户。
  • 一个必填字段被改成可空,“以防万一”。当一次固件更新不再发送它时,代码回退到默认值,部分终端机群停止处理支付。
  • 一个卡号出现在了日志行和堆栈跟踪中:这构成披露义务,也是实实在在的合规问题。
  • 一笔交易在已经被撤销之后又被退款,商户被扣了两次钱。
  • 一笔授权在超时触发的同一刻成功,资金被导向盗取。

这些事故都可以追溯到同一个根因:在某处,代码对某个词的含义与规范不一致,而在到达生产环境之前,没有任何机制迫使这种不一致暴露出来。

方法:写下规范,然后让编译器检查你的工作

尤里把这描述为一组模式,而不是一个框架。共有十个模式,它们都在做同一件事:让非法状态无法被构造出来,而不只是事后捕获无效状态。

1. 为规范中的名词使用值类

欺诈和风险模型会携带几十个形状相似的 ID:商户 ID、终端 ID、收单机构 ID 等等,其中好几个由于监管规定,格式实际上完全相同。如果把它们当作普通字符串,重构或复制粘贴时没有什么能阻止两个 ID 被调换,比如交易会悄无声息地路由到错误的收单机构。尤里的解决办法是为每个 ID 使用一个值类。

$ kotlin
@JvmInline value class Stan(val value: String)
@JvmInline value class TerminalId(val value: String)
@JvmInline value class AmountMinor(val value: Long)
 
data class AuthRequest(val stan: Stan, val terminalId: TerminalId, val amount: AmountMinor)
AuthRequest(terminalId, stan, amount)

现在交换参数,代码就无法编译。过去会一路到达生产环境的错误,变成了工程师自己机器上的构建错误。

2. 校验工厂

每个外部字段,无论来自银行 API 还是终端固件,都是以无类型字符串的形式到达,而它可能并不符合规范所假设的含义。尤里的模式是在边界处立即将其解析为一个密封结果,而不是在下游信任它。

$ kotlin
sealed interface ParsedStan {
    data class Valid(val stan: Stan) : ParsedStan
    sealed interface Invalid : ParsedStan {
        val rawStan: RawStan
        data class WrongLength(override val rawStan: RawStan) : Invalid
        data class NotNumeric(override val rawStan: RawStan) : Invalid 
    }
}

fun of(rawStan: RawStan): ParsedStan = when {
    rawStan.value.length != 6         -> ParsedStan.WrongLength(rawStan)
    !rawStan.value.all(Char::isDigit) -> ParsedStan.NotNumeric(rawStan)
    else                              -> ParsedStan.Valid(Stan(rawStan.value))
}


fun authorize(rawStan: RawStan): AuthResponse =
    when (val parsed = ParsedStan.of(rawStan)) {
        is ParsedStan.Valid       -> gateway.authorize(parsed.stan)        // happy path
        is ParsedStan.Invalid     -> reject(parsed)                        // structured rejection
    }   // no else, no try/catch, no Result<T> to unwrap — exhaustiveness is enforced

尤里认为这种做法是非常好的实践:它不抛出异常;它有助于避免使用 Result<T> 这种不太好的做法,因为 Result<T> 会隐藏业务领域,并且还能避免协程中 runCatching 的陷阱。

3. 在边界处使用非空类型

很多时候,必填字段会被标记为可空,“以防万一”,这需要摆弄那些静默回退逻辑,而它们往往会掩盖真正的元凶。尤里建议利用 Kotlin 的空安全,在一个地方大声失败:

$ kotlin
data class KernelResult(
    val tvr: TerminalVerificationResults, // ← not Optional, not nullable
)

fun fromFirmware(raw: FirmwareResponse) = KernelResult(
    tvr = requireNotNull(raw.tvr) { "TVR null - firmware contract violated" }
)
// One loud failure at the boundary. Never a silent fallback downstream

这就是针对终端机群停摆事故的修复:那个被“以防万一”改成可空的字段,再也不会得到静默默认值。它会在唯一可预测的解析位置大声失败一次,而那里可以被监控,而不是在下游某个没人盯着的地方悄悄失败。

4. 默认掩蔽敏感数据

卡号会以普通字符串的形式流经日志和堆栈跟踪,而这就是一项迟早会发生的披露义务。尤里将值包装进一个带有私有构造函数的类,并重写 toString(),因此安全行为成为默认行为。

$ kotlin
@JvmInline
value class Pan private constructor(private val value: String) {
    override fun toString() = "PAN[****]"                     // ← always safe
    fun masked() = /* masking according spec */
    fun toNetwork() = value                                  // deliberate, grep-able
}
logger.info("Processing card $card")   // → "PAN[****]". Safe by default

一次意外的 log.info("processing $pan") 现在只会打印出四位被掩蔽的数字。要取出真实值——也就是与网络通信时真正需要的那一样东西——需要一次显式的、可审计的调用。这条路径上再也没有什么是不可见的了。尤里附注:在真实系统里,它隐藏得要深得多。

5. 让每个状态都有自己的类,而不只是自己的名字

一笔已经退款的交易,绝不应该还能再次调用退款,但一个带有 status 字段的可变 Transaction 类,依赖于每个人都记得在所有地方检查该状态。尤里更进一步的版本为每个状态提供自己的数据类,因此方法无法从错误的数据类改变交易状态;这确保了不该发生的状态转换(例如,对已撤销交易进行退款)会被编译器阻止。

$ kotlin
sealed interface TransactionStatus {
    data class Captured(val amount: AmountMinor)             : TransactionStatus
    data class FullyRefunded(val at: Instant)                : TransactionStatus
    data class Reversed(val reason: ReversalReason)          : TransactionStatus
}

fun TransactionStatus.fullRefund(refundReason: RefundReason) = when (this) {
    is Captured.                  -> Ok(/* ... */)
    is FullyRefunded, is Revesed  -> Err("not refundable from $this")
}   // exhaustive. the compiler refuses to forget.

6. 不可变性与具名转换

这延续了上一个模式。让承载业务数据的类不可变,并给每一次转换起一个名字。这个名字是规范中的一个动词。任何不应该被调用的方法,干脆就不存在:

$ kotlin
@ConsistentCopyVisibility  // Kotlin 2.0.20+
data class PurchasedAuthorization private constructor( /* ... */ ): Authorization {
    fun fullRefund(refundReason: RefundReason): FullRefundOutcome = /* ... */
}

sealed class FullRefundOutcome {
    data class Succeeded(val refundAuthorization: RefundAuthorization) : FullRefundOutcome 
    // error data classes
}

尤里称这是他最喜欢的模式,因为它会影响工程师阅读规范的方式。这会改变工程师思考和谈论业务领域的方式。

7. 删除 else 分支

如果规范明确列出了每一种情况,并且从未说“否则”,那么代码中的 else 就是在发明一个规范没有描述的情况。他的团队过去只处理全额退款。当部分退款成为真实场景时,旧的 else 分支本会静默地吸收它:编译没问题,测试通过,但在生产环境中却是错的。删掉 else,同一个新情况就会变成一个编译错误,并指向每一个需要更新的地方。

$ kotlin
fun authorize(request: AuthorizationRequest): Authorization = when (request) {
    is PurchaseRequest           -> /* ... */
    is FullRefundRequest         -> /* ... */
    // ← no else. Adding a new state BREAKS compilation here
}

在一个国家,支付业务每隔一个季度或一个月就会不断变化;而对于整个 EMEA 地区——在尤里的规模上,这种变化会变成每天发生。穷尽式的 when 会把每一次变化都变成一份确切说明需要修改什么的清单,而不是一份来自被扣错金额客户的 bug 报告。

8. 选择规范所暗示的协程作用域

尤里的黑色星期五例子:授权、风险管理和欺诈检查过去作为三个独立调用运行,各自处于自己的 try/catch 中。如果欺诈管理在高负载下运行缓慢,交易仍可能被批准,而欺诈检查却静默失败,于是被盗卡可能开始获得批准。Kotlin 提供了两个结构化并发作用域,它们看起来相似,行为却完全不同;应该由规范,而不是便利性,来决定你使用哪一个。

$ kotlin
// coroutineScope = fail-fast.   ← partial success is an incident that you can catch
suspend fun authorize(req: Request) = coroutineScope {
    val auth  = async { acquirer.authorize(req) }
    val risk  = async { riskEngine.score(req) }
    val fraud = async { fraudService.check(req) }
    Outcome.from(auth.await(), risk.await(), fraud.await())
}
// supervisorScope = isolate.    ← partial success is the goal
suspend fun fanOut(event: Event) = supervisorScope {
    listOf(async { sms(event) }, async { analytics(event) }, async { feed(event) }).awaitAll()
}

在这里,coroutineScope 快速失败:如果欺诈检查失败,授权和风险也会被取消,不会有任何东西被静默批准。supervisorScope 则会让同级协程继续独立运行,这对于扇出通知之类的事情是正确的选择,因为其中一个失败不应该取消其余部分。问题不在于哪个作用域更符合惯例,而在于规范规定当某个分支失败时应该发生什么。

9. 将超时建模为领域结果,而不是异常

一次超时的网关调用并不一定是失败。收单机构可能已经处理了它;只是客户端没有及时收到回复。把它当作通用异常捕获然后继续,会抹去“什么都没发生”和“发生了某些事,只是我们不确定是什么”之间的区别。尤里的修复方式是将 Timeout 建模为领域中的一等结果。

$ kotlin
sealed interface AuthorizationOutcome {
    data class Approved(val authCode: AuthCode)    : AuthorizationOutcome
    data class Declined(val reason: DeclineReason) : AuthorizationOutcome
    data object TimedOut                           : AuthorizationOutcome // ← spec verb, named in domain
}

when (val outcome = processAuth(req)) {
    is Approved  -> settleTransaction(outcome.authCode)
    is Declined  -> notifyCardHolder(outcome.reason)
    is TimedOut  -> initiateReversal(req)  // caller decides what to do according to spec
}

TimedOut 不是没有答案。它是一个不同的状态,会路由到对账查询,而不是重试,因为在一次实际上游已成功的超时之后进行天真重试,正是客户被扣两次钱的原因。

10. 让测试成为监管者也能读懂的东西

尤里最出人意料的模式针对的是认证测试,而不是应用代码。一个典型的认证测试套件会运行一千个测试,每个测试大约 200 行,用他的话说,连架构师都读不完。团队用 Kotlin DSL 重写了测试。这样写出来之后,一位从未写过 Kotlin 的认证专家可以读懂测试,对照规范跟进逻辑,并直接签字确认。测试不再只是测试工程师才能验证的产物,而变成了业务人员和认证专家也能审阅的规范。

$ kotlin
val purchaseOverFloorLimit = scenario("Purchase with tap card above floor limit must go online") {
    terminal { floorLimit = 50.EUR }
    posRequest  { purchase(amount = 75.EUR) }
    tap { card = TestCards.visaWithOnlinePIN }

    expect {
        terminalDecision  mustBe  GoOnline
        outcome           mustBe  Approved
    }
}

实际带来了什么改变

尤里对这些结果说得很具体:开发速度提高了 2.7 倍,认证第一次尝试就通过了,而且只有一处缺陷;这非同寻常,以至于监管机构都问到底发生了什么变化。最终代码库缩小了约 30%。代码审查从寻找遗漏的状态检查,转变为将代码与规范逐句对照,因为编译器现在能捕获审查者过去靠手工寻找的那类 bug。过去,新工程师入职后需要两三周,才能足够理解一份 5,000 页的规范和一套遗留代码库,从而发布任何东西。之后,新员工在几天内就能提交生产环境的拉取请求。工程师也可以在设备团队和后端团队之间流动,而此前用两种不同语言拆分的代码库让这变得困难。尤里指出的最后一个效果是,工程师开始足够好地使用业务语言,能够直接与产品经理、销售和监管机构交谈,偶尔还会提出对监管规定本身的修改建议。

无需重写的支付现代化

尤里直言大多数团队会犯的错误:把迁移当成一个项目,用 Kotlin 重写。他的系统运行着已有 25 年历史的 Java 和 C++ 代码,每秒都在处理实时交易,不允许出现可接受的停机时间,因此重写从来不在考虑范围内。如果把它框定为重构还是重写的决策,那么对于正在运行的支付平台,答案几乎总是重构。他的替代方案是四条并行推进、而不是按顺序执行的路线。

  1. 先优化现有代码库,使用 Java 或 C++,不触碰语言本身;现在就现代化代码结构——共享布局、整洁架构、尖叫架构——甚至要在任何 Kotlin 落地之前完成。
  2. 转向验收测试驱动开发,而不是单元测试驱动。工程团队之外没人在乎函数内部是什么;每个人在乎的都是端到端行为。
  3. 构建适应度函数,用来检查合规性、安全性、可扩展性以及其他各种“-ilities”,让它们与迁移并行运行,而不是在最后才进行评估。
  4. 增量迁移:先迁移新适配器,再迁移新领域模型,然后迁移变化最频繁的遗留代码,努力将“共享端口”的数量降到零。Kotlin 与 Java 的互操作正是让这条增量路径成为可能的关键:新适配器可以与未经修改的 Java 代码并存,而不需要双维护期。

尤里认为,同时运行这四条路线,才能使支付现代化工作在受监管环境中既安全又具有成本效益。按顺序运行它们,则会让迁移停滞。## 迁移是投资决策,而非语言偏好

Yuri 指出,在企业中需要被说服的对象并不是工程团队。他提到了同场活动中前一日 Uber 团队的演讲,那场演讲也表达了类似观点:即便是一家以行动迅速著称的公司,一旦进入企业级规模,也会将语言迁移视为一项投资决策。一份可信的商业论证必须考虑到并行运行新旧系统的成本、淘汰被替换系统的成本、团队的学习曲线,以及迁移本身的风险。让迁移获得批准,与迁移真正被采用,并非一回事。如果没有一套说明迁移在日常中如何运作的运营模式,Yuri 直言不讳:这条路走到尽头,等来的不会是什么圆满结局。

针对这一缺口,他指向了一份资源:Kotlin 官网上一份 22 页的实践手册,发布于本次演讲前两天,用他的话说,它覆盖了"所有人都跳过的那部分"。

用一句话概括,Yuri Geronimus 的核心方法是什么?

用规范本身的字面措辞来编写代码,并让编译器去强制执行它们,而不是依赖纪律或文档来捕捉两者之间的差距。

是否必须重写遗留的 Java 或 C++ 系统才能使用这些模式?

不需要。Yuri 明确反对将迁移视为一个重写项目。他的四条路线是并行的:优化现有代码库、转向验收测试、更关注适应度函数,以及增量迁移——从适配器和领域模型入手,然后从变动最频繁的部分开始推进。

为什么纪律或代码审查无法替我们捕捉到这些缺陷?

在 Yuri 所运营的规模下——全球 20 个团队——纪律无法均匀地扩展。而编译器的检查无论何时何地、无论谁在值班、无论某位审查者当天多么细致,都以完全相同的方式适用。

延伸阅读

Geronimus 在支付与金融科技领域的 Kotlin 应用方面写作颇多,可见其在 Medium 上的个人文章。至于他在演讲中提到的商业论证与运营模式,相关资源是 JetBrains 的企业级 Kotlin 采用实践手册,该手册同样由 Geronimus 参与撰写。