# 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 条规则先引一套构建插件。 |