给任务存取接口套一层“审计包装”,直接调用包装器的 put 会留下日志;改为调用包装器的 putIfAbsent,数据存进去了,日志却没增加。两次写入都是通过同一个 AuditedTaskStore 变量发起的,差异在于后者的实现代码究竟运行在哪个接收对象上。类委托能省转发代码,不会自动改写被委托对象内部的 this。

沿用 10 的任务值对象与别名风险,本篇只做一个可替换的内存文本存取点。虽然接口叫 TaskStore,目前没有关联真实数据库,也没有把 09 的 Task 或 10 的可变标签对象保存进去;原有 submit CLI 仍只打印输入提示,不经过此接口。版本为 Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14。

为什么先定义接口,而不先堆一套容器

这里的替换点是保存任务文本、按合成 ID 查询;内存实现足够让调用者和接口分离:

1
2
3
4
5
6
7
8
9
10
interface TaskStore {
fun put(id: String, text: String)
fun find(id: String): String?

fun putIfAbsent(id: String, text: String): Boolean {
if (find(id) != null) return false
put(id, text)
return true
}
}

InMemoryTaskStore 用 MutableMap<String, String> 存放合成 ID 与文本,put 检查 ID 和文本都不空白;find 查不到返回 null。通过接口类型先用 putIfAbsent("task-1", "alpha") 得真,再对同一 ID 调用得假,原文本仍为 "alpha"。另造一个内存实例查不到 "task-1",证明当前工厂调用没有让所有仓库偷偷共享同一张 Map。这些行为由 TaskStoreTest 验证,不需要提前创建组件扫描或依赖注入框架。官方接口文档说明接口成员可以提供默认实现。

不要从方法名推断并发原子性。 这个 putIfAbsent 是 find 再 put 的两步代码,没有锁或数据库唯一约束;多个线程或多进程同时调用时,本章并未验证“不重复提交”。内存数据不能抵抗重启;查询到字符串也不代表任务已执行。12 才定义业务状态与允许转移。

对象与伴生对象只管理各自的职责

object TaskTextRules 包含一个不持有数据的 valid(text),空白返回假;InMemoryTaskStore 的 companion object 提供 empty() 工厂,每次创建新的内存仓库。前者是本章全局共用的规则对象,后者是类的创建入口;不能因为两者语法都带有 object,就以为两个 empty() 调用共享一份任务数据。Kotlin object/companion 官方资料给出声明与调用的界限,两个实例的隔离由测试确认。

目前规则只保证非空白字符串。ID 格式、重复提交策略和跨请求所有权都尚未定义,不能拿测试用的 "task-1" 当已冻结的公共 ID 契约。

类委托转发了方法,没有替包装器拦截内部调用

包装器实际代码很短:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AuditedTaskStore(
private val delegate: TaskStore,
private val events: MutableList<String>,
) : TaskStore by delegate {
var lastAttemptedId: String? by Delegates.observable(null) { _, _, next ->
events.add("attempt:$next")
}

override fun put(id: String, text: String) {
lastAttemptedId = id
delegate.put(id, text)
}
}

TaskStore by delegate 把没有显式覆盖的接口方法转给底层对象;put 则由包装器自己实现。直接执行 audited.put("direct", "alpha"),事件为 "attempt:direct"。类委托文档关于覆盖方法的说明特别提醒:委托对象自己内部调用同名成员时,调用的仍是它自身的实现,不会自动进入外层类覆盖。

本章对这一点做了可复现实验:audited.putIfAbsent("delegated", "beta") 实际写入并可从 audited.find 查到,但 events 仍只有 "attempt:direct"。putIfAbsent 被转发到内存仓库,在底层执行默认方法时,它内部的 put 以底层仓库为接收者。不能因外层也覆盖了 put,就假设经过外层审计。如果所有入口都必须审计,应明确地覆盖那些入口或把审计放在真正的底层写入边界,并用测试覆盖每条路径;不要靠类委托替自己证明安全策略已经生效。

lastAttemptedId 由属性委托 Delegates.observable记录变化。随后试图直接 audited.put("failed", " "),底层因空白文本抛异常,日志仍出现 "attempt:failed",而 find("failed") 返回 null。因此这个属性名是最后一次尝试,不是“最后成功写入”。日志顺序、外部副作用和存储完成是三份证据,不能拿其中一份替代另外两份。

复核与尚未保证的事情

在仓库根目录执行 cd examples/kotlin-language && bash verification/11.sh。脚本记录 UTC 环境、JDK/Gradle 和原始命令,离线 clean test 退出 0,00–11 十二组各 2/2。TaskStoreTest 的两组行为分别验证独立内存仓库、校验失败不入库,以及包装器直接写/委托内部写/失败尝试的日志区别;旧 submit alpha 退出 0、缺输入的 submit 退出 1,但 CLI 尚未使用 Store。日志、XML 和断言见 writing-plans/kotlin/verification/11/。

下载含Wrapper、完整累计工程与11证据的快照(SHA-256校验)。素材目录给出解包复跑命令;没有多线程原子性、重启恢复、真实 HTTP/数据库或业务任务完成证据。

练习与答案

练习一:audited.putIfAbsent("delegated", "beta") 返回真,为什么 attempt 列表没增加?答:包装器把未覆盖的 putIfAbsent 转给底层仓库;默认方法在底层对象上运行,内部 put 使用底层接收者,不会回到包装器覆盖的 put。

练习二:lastAttemptedId == "failed",能否认定任务文本已经存入 Map?答:不能。属性在调用 delegate.put 之前更新;非法空白文本会让底层抛异常,find("failed") 为 null。尝试事件不是持久化成功确认。

练习三:两个 InMemoryTaskStore.empty() 会共享对象单例的数据吗?答:不会。empty() 位于伴生对象,但每次调用都会执行新的构造;测试在一个实例中存入 task-1,另一个仍查不到。共享的是规则对象 TaskTextRules 的无状态校验逻辑。

下一篇把现在散落的“待保存文本”升级成明确的排队、运行、完成、失败和取消状态,才有基础讨论合法转移与完成竞争。