Files
Guangfei.ZhaoandClaude Code b2903d6eeb docs: add backend/13-kotlin-style.md and index it
Kotlin syntax & coding constraints for the backend service: treat Kotlin as Java
syntax sugar, not as a new paradigm. Bans !! / lateinit / coroutines / RxJava /
Reactor, data-class entities, and XxxUtils|Util|Helper|Common classes — each rule
states the concrete failure it prevents.

Also records the Gradle two-sided version constraint in 01-project-structure.md:
the Boot 4.1 plugin needs 8.14+ or 9.x while KGP 2.4.10 tops out at 9.5.0, so
bumping either side means recomputing the intersection.

Skeleton follows the existing convention (## 决策 first, then 使用规则, topic
sections, ending with a 为什么不选另一个方案 comparison table).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 16:09:52 +08:00

162 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 13. Kotlin 语法与编码约束
## 决策
**Kotlin 在本项目里只当 Java 的语法糖用,不当新范式用。**
判断一个新特性该不该用,只问一句话:它对 Java 是**缩短**,还是**替换**?
| 特性 | 性质 | 用不用 |
| --- | --- | --- |
| 空安全(`?` / `?:` / `takeIf`)、`val`/`var`、属性、`data class`、扩展函数、顶层函数 | 把 Java 写得更短,心智模型没变 | ✅ 用 |
| `when` 表达式、`sealed` 层次 | 把 Java 的 switch + 继承写得更短**且更严**(分支穷尽性由编译器保证) | ✅ 用 |
| 协程 / `suspend` / `Flow`、RxJava、Reactor | 换一套并发心智模型,且与本项目的阻塞式 JPA + ThreadLocal 上下文直接冲突 | ❌ 不用 |
| `companion object` 当 static 工具袋、`object XxxUtils` | 换一套"万物皆对象、没有 static"的世界观,收益是零 | ❌ 不用 |
| DSL builder、Monad Transformer、`inline` + `reified` 造框架 | 换一套抽象方式,在 CRUD 业务里只换来调试成本 | ❌ 不用 |
第二条准入标准:**本文档里每条规则都要能指出它挡住的具体故障。** 指不出来的不写——写下来也不会有人遵守,只会在 review 时引发无穷争论。下面 DON'T 清单里每一条都带了「挡住什么」,那就是它的准入证明。
这条标准本身是 [02-layering.md](./02-layering.md) 里那次论证的推广。那份文档解释了为什么规则不是更严的"Entity 永不跨出 infrastructure"——因为更严的版本会逼出无意义的样板代码,却换不来任何隔离收益。**语法规则同理:不能减少实际复杂度的规则,只是把复杂度从代码挪进了文档。**
## 使用规则
### DO
- **`data class`**:DTO / Request / Response / Value Object,以及 `domain` 层的领域模型(如 `WebviewTicket`)。纯数据、无生命周期、无懒加载。
- **`sealed interface` / `sealed class`**:有限且封闭的状态建模,`when` 上不用写 `else`,新增状态时编译期报错。状态不带附加数据时仍用 `enum`(现有 `ErrorCode`、`TicketStatus`、`UserStatus` 都是 enum,这是对的);当每个状态要携带不同的字段时再换 `sealed`。
- **`val` 优先**,`var` 只留给 JPA Entity 的持久化字段——而 Entity 只存在于 `infrastructure` 包([02-layering.md](./02-layering.md))。
- **无状态逻辑用顶层函数**,不要为了"整洁"造一个 `@Component` 的壳。全库目前只有 13 个顶层声明,说明这条没有被滥用:绝大多数逻辑都有它该归属的类。
- **扩展函数**用于同层内的轻量转换:`WebviewTicketEntity.toDomain()`、`UserEntity.toResponse()`。**跨层转换不用扩展函数**,放 `application/mapper/`([06-api-design.md](./06-api-design.md))。
### DON'T
#### ❌ 生产代码禁止 `!!`
**挡住什么**:`!!` 抛的是 `NullPointerException`,消息里只有 `null` 两个字——没有类名、没有字段名、没有业务上下文。线上日志里出现一个光秃秃的 NPE,定位成本远高于写一行 `?: throw`。
```kotlin
// ❌ 违反不变量时,只能得到一个没有上下文的 NPE
accessTokenIssuer.issue(user.id!!, storeId, roles)
// ✅ 不变量有名字,报错带类名
accessTokenIssuer.issue(user.requiredId, storeId, roles)
```
**根因不在语法,在数据来源。** 应用层拿到的 Entity 只有两种状态:`new` 出来的(id 必然是 null)和从 repository 读出来 / `save` 过的(id 一定有)。上面那个 `!!` 想表达的正是后者这条不变量——那就把它写成类型:`platform-persistence` 的 `PersistedEntity.requiredId`。
所以本项目的修法不是"把 `!!` 逐处换成 `?: throw`"(那只是把同样的假设抄了 7 遍,还没解决问题),而是**让不变量有一个名字**。
**`src/test` 里的 `!!` 不禁止**:测试里 null 是立即可见的失败信号,再包一层 `?: throw` 只是噪音。
#### ❌ 生产代码禁止 `lateinit var`
**挡住什么**:`lateinit` 是"我保证在使用前一定赋值"的口头承诺,编译器从此不再管你。它和 `!!` 是同一个洞的两半——`!!` 是**读**的时候不安全,`lateinit` 是**写**的时候不检查。
字段注入时代它还有存在理由;本项目已经禁止字段注入(`ArchitectureRulesTest` 里有规则),依赖一律走构造器参数,`lateinit` 就没有场景了。需要延迟初始化就用 `by lazy`(只读且线程安全)。
#### ❌ 禁止引入协程 / `suspend` / `Flow` / RxJava / Reactor
**挡住什么**:见下方「为什么不用协程,而用虚拟线程」。结论是**同步到底;需要并发就用虚拟线程,不引入任何异步框架**。
2026-09 落地时全库 0 处违规——这是条预防性规则,不是清理性规则。它的价值在半年后有人提出"我们用协程改造一下"时,能直接翻到下面那一节,而不是重新辩论一遍。
#### ❌ 禁止 `XxxUtils` / `XxxUtil` / `XxxHelper` / `XxxCommon` 类
**挡住什么**:这类类名是"我懒得想这东西属于谁"的标记。它们一旦出现就会变成什么都往里塞的垃圾桶,并且彼此依赖,最后没人说得清 `CommonUtils` 里到底有什么、改一行会炸哪里。
**替代**:无状态逻辑用顶层函数,让**文件名**表达归属(`ErrorCode.kt` 里的 `object ErrorCode`);有状态的用 `@Component` 由容器管。
`XxxManager` **不在这条规则里**:Spring 自身就有大量 `*Manager` 类名,一刀切会误伤,交给 review 判断。
#### ❌ JPA Entity 禁止 `data class`,且必须能被 Hibernate 代理
**挡住什么**,三条都是真事故:
1. `data class` 的 `equals`/`hashCode` 基于**可变**的 `id`。实体放进 `Set`/`Map` 后再 flush 拿到 id,哈希值就变了,那个元素再也找不回来。
2. 生成的 `toString` 会打印所有字段——只要有一个懒加载关联,就是一次意料之外的查询,甚至 `LazyInitializationException`。
3. `copy()` 造出的是**游离态新对象**,`save()` 会被当成新增而不是更新。
Entity 一律写成普通 `class` + `var` 持久化字段(现有 6 个 Entity 都是这么写的,保持住)。
**并且 Entity 必须是 `open` 的**,否则 Hibernate 造不出代理,`fetch = LAZY` 会**静默退化**成每次立即查库——测试里看不出来,只有 SQL 条数的差别。
> ⚠️ **已知缺口(2026-09)**:根 `build.gradle` 只 apply 了 `org.jetbrains.kotlin.plugin.spring`,没有 `plugin.jpa`,所以现有 Entity 都是 `final` 的。当前不出问题,是因为全库**一个懒加载关联都没有**(0 处 `@ManyToOne` / `@OneToMany`),Hibernate 压根不需要造代理。
>
> 哪天有人加第一个 `@ManyToOne(fetch = LAZY)`,上面第 3 条就会静默生效。修法是 apply `org.jetbrains.kotlin.plugin.jpa`(它给 `@Entity` / `@MappedSuperclass` 补 `open` 和 no-arg 构造器,见 [03-persistence.md](./03-persistence.md)),**改完必须跑一遍打了 `@Tag("integration")` 的仓储测试**——那是唯一能发现代理行为变化的地方。
## 为什么规则不是"禁止 companion object"
全库 14 处 `companion object`,逐一看过,**没有一处是该删的**:
| 位置 | 用途 | 改成顶层会怎样 |
| --- | --- | --- |
| `F6Exceptions.kt` 的两个异常类 | `private companion object { F6_LOG }` 拿**类级** logger category | 顶层 `private val` 会变成文件级 category(`F6ExceptionsKt`),日志分类直接退化 |
| `Tile.kt` 的 `ok()` / `degraded()` | 类的命名工厂 | 顶层只能叫 `tileOk()`,丢掉类型归属 |
| `AuthAppService.kt` | `private` 常量(失败次数上限、锁定时长、假哈希) | 常量失去"属于这个服务"的语义 |
| `ApiResult.kt` / `ErrorCode.kt` 等 | 同上的构造入口与常量 | 同上 |
所以这条规则被砍掉,理由有两条:
1. **它没有一个能自动检查的形态。** "禁止 companion object" 和 "禁止把 companion object 当 static 工具袋用" 在字节码上长得一模一样,只能靠人判断。而一条只能靠人判断、且现有 14 处全是合法用法的规则,实际效果是让每次 review 多花五分钟争论它算不算例外。
2. **它本来是性能论据,但性能论据是错的。** Kotlin 用 companion object 替代 `static` 是"概念一致性"的取舍,不是性能取舍:companion 是每个类装载器一个单例,`const val` 还会被编译期内联,不存在 per-call 分配。拿性能去反驳一个概念性决定,等于认错了对方的动机,打不到点上。
**真正该禁的是"垃圾桶类"**,那件事已经由上面那条 `XxxUtils` 规则接管了。`companion object` 的合法用途就三种:**类级 logger、类的命名工厂、类私有常量**。超出这三种,review 时问一句"这个方法为什么不属于某个类"就够了。
## 为什么不用协程,而用虚拟线程
**结论:同步到底;需要并发就用虚拟线程。**
先说一个容易被忽略的前提:**本项目的请求上下文全部挂在 ThreadLocal 上。**
| 上下文 | 承载方式 |
| --- | --- |
| 门店上下文 | `StoreContextHolder` / `StoreContextFilter` |
| 事务 | `@Transactional`(连接与事务绑定在 ThreadLocal 上) |
| 认证信息 | Spring Security 的 `SecurityContextHolder` |
| 审计 | `AuditContext` |
| 链路追踪 | Micrometer Tracing 的 current span |
协程一旦在 `Dispatchers.IO` 之类的地方发生**线程切换**,上面这些 ThreadLocal 会**静默丢失**——不报错,只是门店过滤条件没了、事务不在同一个连接上、审计记不到人。这不是"风格不统一",这是越权和数据错乱级别的 bug,而且极难复现。
所以不用协程的第一理由是**正确性**,不是"协程是银弹"这类口感判断。用口感当理由,半年后有人拿"协程写起来更优雅"就能把它推翻。
那并发需求怎么办?——**虚拟线程**,本项目 toolchain 已经是 Java 21([01-project-structure.md](./01-project-structure.md)):
| 做法 | 吞吐 | ThreadLocal / `@Transactional` | 调试 |
| --- | --- | --- | --- |
| 平台线程 + `CompletableFuture` | 受线程池大小限制 | 正常 | 栈完整 |
| **虚拟线程 + `CompletableFuture`** | 阻塞时自动让出载体线程,接近协程的吞吐 | **照旧正常** | **栈完整** |
| 协程 / `suspend` | 高 | **静默丢失** | 栈不可读,异常要靠 CoroutineExceptionHandler |
| WebFlux + Reactor | 高 | 需要额外桥接,且 JPA 是阻塞的 | 栈不可读,需 Reactor Debug Agent |
`workbench` 那个跨 F6 + Mini 的并行 fan-out([11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md))就是目标场景:把 `WorkbenchExecutorConfig` 的线程池换成 `Executors.newVirtualThreadPerTaskExecutor()`,**业务代码一行不用改**,拿到接近响应式的吞吐,而 Hibernate、事务、门店上下文全部照旧工作。
**为什么也不引 RxJava**:RxJava 正是上面那张"糖 vs 范式"表里被否掉的那一类——操作符爆炸、栈不可读、学习曲线陡。而且 Spring 生态的响应式是 Reactor(WebFlux),引 RxJava 等于在阻塞栈里塞进**第二套**并发模型,两套都要维护。真要异步流,答案也是虚拟线程,不是再引一个框架。
## 检查方式
| 规则 | 强制手段 | 状态 |
| --- | --- | --- |
| 禁 `!!` / `lateinit` / `suspend` / `kotlinx.coroutines` / RxJava / Reactor | `KotlinSyntaxScanTest`(源码扫描) | ✅ 自动 |
| Entity 禁 `data class` | `KotlinSyntaxScanTest` | ✅ 自动 |
| 禁 `XxxUtils` / `Util` / `Helper` / `Common` 类 | `ArchitectureRulesTest` | ✅ 自动 |
| 禁字段注入、禁 `java.util.Date` / `Calendar` | `ArchitectureRulesTest`(既有规则) | ✅ 自动 |
| `companion object` 只用于三种合法用途 | 无 | review 判断 |
| Entity 必须 `open` | 无 | ⚠️ 待 apply `plugin.jpa`(见上文已知缺口) |
**为什么 `!!` 用源码扫描而不是 ArchUnit**:`!!` 编译成 `Intrinsics.checkNotNull(...)` 调用,字节码上它和其他 null 检查混在一起,ArchUnit 分不出来;而源码里 `!!` 就是一个确定的 token。
这是 [10-testing.md](./10-testing.md) 那条思路的第二次应用——"编译器和 ArchUnit 拦不住的东西,加一条源码扫描作为第二道防线"。第一次是 `NativeQueryScanTest`(库名是字符串,字节码里看不见)。
**后续**:`.editorconfig` 里已经预留了 `ij_kotlin_*` 段,说明 ktlint 迟早要接;接的时候 `!!` / `lateinit` 这些可以改用 detekt 的 `UnsafeCallOnNullableType` 等现成规则。在那之前,源码扫描是零依赖、零构建改动的做法,先把它换成可执行的。
## 为什么不选另一个方案
| 方案 | 否决理由 |
| --- | --- |
| 全面拥抱协程 + Spring WebFlux | 本项目是阻塞式 JPA + MySQL([03-persistence.md](./03-persistence.md)),WebFlux 的收益要整条链路非阻塞才拿得到;混在阻塞 JDBC 上只会把线程从 Tomcat 挪到 Reactor 的调度器里,同时丢掉 ThreadLocal 上下文。虚拟线程拿到了同样的吞吐,代价是零。 |
| 用 RxJava 做异步流 | 见上一节结尾。而且 Spring 生态的响应式是 Reactor,引 RxJava 是引入第二套并发模型。 |
| 禁止所有 `companion object` | 见「为什么规则不是禁止 companion object」。14 处合法用法会被误伤,且规则无法自动检查。 |
| 把编码规范写成纯文档,靠 review 执行 | 这套文档被定义成"代码的约束来源",但一条不能被机器检查的规范,半年后就会和代码脱节。能上机器检查的一律上机器检查——这不是不信任团队,是省下 review 的注意力去做机器做不了的事。 |
| 现在就接 detekt + ktlint 全套 | 值得做,但不是这次。先零依赖地把规则变成可执行测试,等格式化类规则积累够了再一次性接工具链——避免为了 6 条规则先引一套构建插件。 |