用途
该技能针对多平台 Kotlin 模块中的 protobuf 协议层问题。schema 编译器通常只为某一个平台生成代码;在跨平台模块中,生成类型只能在 JVM 上编译,其余目标会失效,于是整个协议层不得不退回 expect/actual 或复制实现。这里给出的替代方案是:手写消息,用注解携带字段编号,让序列化库产生相同字节。
主要能力
文中示例用 @ProtoNumber 注解普通 data class 的属性,并用 ProtoBuf { encodeDefaults = false } 构建序列化器。这样就不再需要生成层所需的适配代码(在手工 Kotlin 模型与生成 builder 之间转换的 toProto / fromProto),因为只剩一套模型。示例中的消息名、字段名和编号为占位内容。
使用要点与陷阱
- encodeDefaults = false 不是偏好设置,而是 wire format 的语义:持有默认值的字段在 protobuf 帧中不存在,生成编码器就是这么输出的,对端解码器也按此期待。若保持默认值 true,字节将不匹配,且编码任何带 null 嵌套字段的消息会直接报错 “null is not supported for optional properties”。没有附带条目的传输命令是常见形态,因此可能一条命令都发不出去。
- 字节数组属性带来的是引用相等:data class 的 equals 按属性生成,数组属性按引用比较,导致两个内容相同的帧被判为不等,正确的编解码器也会让 assertEquals(frame, decode(encode(frame))) 失败。应针对该属性用 contentEquals / contentHashCode 覆盖 equals/hashCode。更隐蔽的反向错误是:测试只比较非数组字段,载荷被丢弃也照样通过。
- 用一致性测试而非往返测试固定字节级等价性。往返测试只能证明自己的编码器与解码器彼此一致,即使两者都错也会通过。应直接断言标签字节:字段 1 为字符串时帧以 0x0A 开头,字段 2 为 bytes 时为 0x12,字段 3 为 bool 时为 0x18。编号重排或字段类型改变会因此让测试失败,而不是以“无法连接”的形式失败。
- 测试中应有一侧按对端的方式构建。例如直接用序列化器编码应答帧(先信封、载荷嵌套其中),而不是走自己的 encode()。如果两侧都经过自己的编解码器,缺失的解码路径就可能一直是绿的:测试从不需要它。客户端只接收的消息类型恰好没有编码路径,也正是这种测试能发现的问题。
- 低于阈值的压缩会让帧变大。gzip 头本身就有 10 字节,而多数协议消息只有少量字段,全量压缩会在多数流量上耗费字节和 CPU。应按大小阈值启用并协商;解压失败应回退到原始载荷而不是抛异常,因为压缩标志由发送方设置,无法解压的帧更可能是标记错误而非致命问题。
- 注解与格式属于实验性 API,@OptIn(ExperimentalSerializationApi::class) 不是样板代码:该方案依赖的字节级行为不在库的兼容性承诺范围内。上面的一致性测试能把未来库变更从静默的 wire 断裂变成构建失败,这也是该取舍安全的前提。
- 在写第一个 schema 类型前,先确定这些类型允许传播到哪里。它们是普通 Kotlin 类,没有生成代码的“气味”来警示他人,因此比生成类型更容易扩散,甚至直接进入 view model。应把它们限制在模块边界内,在数据层做映射,采用 response-to-domain-flow 形态;借用 wire 协议的纪律意味着之后不能随意重命名它们。
验证方式
文中给出三条静态检查命令,可在每次 schema 变更时运行:
grep -rl 'ProtoNumber' --include='.kt' . | grep -v '/build/' grep -rn 'encodeDefaults' --include='.kt' . | grep -v '/build/' | grep -i proto grep -rl 'ProtoNumber' --include='*.kt' . | grep -v '/build/' | xargs -r grep -l 'ProtoNumber.*ByteArray' | xargs -r grep -L 'contentEquals'
第一条是 schema 清点,可能列出多个文件(别处响应模型可能用同一注解表示无关 wire format),需确认哪个文件是协议。第二条把 protobuf 编码器配置与树中所有 JSON 配置区分开(后者合法地设置相反值),生产编码器必须读到 false;测试编码器设为 true 的唯一正当理由是让解码器面对刻意携带显式默认值的帧,且应在旁边加注释说明。如果它用于构建上文所需的“对端形态”帧,则该帧并不忠实于对端发送的任何字节,不构成一致性,测试却仍显示通过。第三条在每 个带字节数组字段的 schema 类都覆盖了相等性时不输出内容;输出中的文件名就是会在往返测试中说谎的类。保留两个 -r 标志:否则在尚无字节数组字段的树中,最后一个 grep 没有文件操作数,会打印字面量 (standard input),按上述规则会被误读为缺陷并阻塞。
之后检查测试文件中的两类断言:一个原始标签字节,以及一个不经过自己编码器构建的帧。两者都没有的测试套件就是往返测试套件,而往返测试套件无法因该技能所针对的任何原因而失败。
适用场景
适用于 Kotlin Multiplatform 项目中需要手写 protobuf 消息、希望避免生成代码带来的平台限制,并愿意承担实验性 API 风险、通过一致性测试保障字节兼容的团队。
限制
依赖实验性序列化 API,其字节级行为不在兼容性承诺内;需要自行维护字节级一致性测试与静态检查;文中未提供其他平台支持、性能数据或版本要求。