本文记录 protobuf.js 8.8.0 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的 Protocol Buffers 运行时,并在本地终端中通过真实二进制消息验证标量字段、嵌套消息、repeated、packed、map、分隔消息、未知字段和类型校验能力。
项目地址: AtomGit/nutpi_cj/protobuf.js
上游仓库: protobufjs/protobuf.js
开发工具: 华为云码道
一、背景
1.1 为什么适配 protobuf.js
protobuf.js 是 JavaScript 生态中使用广泛的 Protocol Buffers 实现,提供 schema 反射、消息编码解码、对象转换、校验和多种运行时入口。JavaScript 版本可以接收动态对象,按运行时类型写入字段;仓颉强调静态类型、显式集合和编译期接口检查。直接逐行翻译 JavaScript 源码,会把动态对象边界和 JavaScript 的运行时语义带入仓颉,难以保证编码结果可解释。
本项目把 protobuf.js 的核心消息模型拆分为仓颉源码:用 MessageSchema 描述字段,用 ProtoField 表达字段号和 wire kind,用 ProtoMessage 保存类型明确的消息值,再由独立的 reader/writer 完成 Protocol Buffers wire format 编解码。这样熟悉 protobuf.js 的开发者可以继续使用 encode、decode、verify 和 JSON 输出这些核心概念,同时得到仓颉编译器的类型检查。
1.2 跨端三方库的适配边界
当前项目提供的是不绑定 UI 框架或操作系统的仓颉跨端库。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和静态库输出 |
| schema 描述 | MessageSchema、ProtoField 和 FieldKind |
| 消息值 | ProtoMessage、ProtoValue、HashMap 和 Array |
| 二进制传输 | Protocol Buffers varint、zigzag、fixed32/fixed64 和 length-delimited |
| 消息能力 | 标量、bytes、嵌套消息、repeated、packed repeated、map、enum |
| 运行时操作 | encode、decode、encodeDelimited、decodeDelimited、verify 和 toJson |
| 交付形态 | 仓颉静态库和接口文件 |
| 验证方式 | test/main.cj 独立编译测试和 example/example.cj 终端示例 |
这里没有把 .proto 解析器、JavaScript 代码生成器、RPC service、ProtoJSON/Text Format 扩展和浏览器运行时一起迁移。当前实现聚焦消息运行时和 wire format;目标平台只要提供兼容版本的仓颉 SDK,就可以用同一套源码和 cjpm 配置构建并消费这个库。
1.3 适配后的能力
当前导出的仓颉 API 覆盖以下能力:
- 字段描述:FieldKind、ProtoField、MessageSchema;
- 消息值:ProtoValue、ProtoMessage、message 以及标量、bytes、enum、list、map 构造函数;
- 二进制操作:encode、decode、encodeDelimited、decodeDelimited;
- wire format:varint、zigzag、fixed32、fixed64、长度分隔字段和未知字段跳过;
- 复杂消息:嵌套消息、repeated、packed repeated、map 和枚举值;
- 边界检查:字段类型 verify、截断输入检查、wire type 检查和 JSON 友好输出。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 protobuf.js 消息生命周期,确定仓颉类型和运行时边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 enum、class、HashMap 和 Array 替换动态 JavaScript 对象
第 4 阶段:实现 wire 格式 ── 完成 writer、reader、字段编码、字段解码和未知字段处理
第 5 阶段:测试与核对 ── 编译测试入口,执行真实消息往返和行为断言
第 6 阶段:终端演示 ── 输出真实二进制、解码 JSON、分隔消息和校验结果
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到仓颉静态库,验证阶段得到测试通过标记,最终示例则输出一组来自实际 encode/decode 调用的消息结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
protobuf.js 的 JavaScript API 通常接收普通对象,字段值在运行时由生成器或反射类型判断。仓颉没有与 JavaScript plain object 完全等价的无类型边界,因此本适配先筛选可以由静态类型稳定表达的能力,再为这些能力设计清晰签名。
字段类型收敛为 FieldKind;消息字段使用 MessageSchema;字符串、整数、浮点数、布尔值、bytes、枚举、重复值和 map 分别对应 ProtoValue 的枚举成员。MessageSchema 同时负责字段号索引、编码、解码、校验和 JSON 输出,避免把 wire 细节暴露给调用方。
1.2 固定仓颉项目结构
以下目录树按照当前 protobuf.js 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 protobuf.js JavaScript 源码、CLI、测试和 examples/ 目录保持原位置:
text
protobuf.js/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── Protobuf.js 跨端三方库仓颉适配全流程.md
├── protobufjs-cangjie-demo.png
├── src/
│ └── protobuf.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码集中在 src/protobuf.cj,按字段描述、writer、reader、编码、解码、校验和 JSON 输出顺序组织。测试入口位于 test/main.cj,示例工程位于 example/,两者都独立于库源码。示例的 src-dir 是当前目录,但 target-dir 放在 example 外部的 .cjpm-target/protobufjs_example,因此构建输出不会被当作源码再次扫描。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型,并将构建目录放在库项目之外:
toml
[package]
name = "protobufjs_cangjie"
version = "8.8.0"
cjc-version = "1.1.3"
description = "A native Cangjie runtime for protobuf.js-style Protocol Buffers encoding and decoding"
src-dir = "src"
target-dir = "../.cjpm-target/protobufjs_cangjie"
output-type = "static"
示例工程使用本地路径依赖,并把构建目录放在 example 外部:
toml
[package]
name = "protobufjs_example"
version = "8.8.0"
cjc-version = "1.1.3"
description = "Executable protobuf.js Cangjie feature demonstration"
src-dir = "."
target-dir = "../.cjpm-target/protobufjs_example"
output-type = "executable"
[dependencies]
protobufjs_cangjie = { path = ".." }
这里的 target-dir 特别重要:它不在 src-dir 的扫描范围内。这样执行 cjpm run 时不会出现把构建输出目录当作源码目录扫描的 target 警告。构建完成后,静态库和接口文件位于对应 release 目录;验证结束后清理 .cjpm-target,不提交构建产物。
第 3 阶段:把 JavaScript 动态消息映射为仓颉类型
3.1 schema 和消息值
MessageSchema 负责保存字段号和字段名称,ProtoField 负责表达字段类型、repeated、packed 以及嵌套 schema:
cangjie
let address = MessageSchema("Address", [
ProtoField.string("city", 1),
ProtoField.string("country", 2)
])
let schema = MessageSchema("User", [
ProtoField.int32("id", 1),
ProtoField.string("name", 2),
ProtoField.message("address", 3, address),
ProtoField.int32("points", 4, repeated: true, packed: true)
])
消息值不再依赖动态对象,而是显式构造:
cangjie
let input = message([
("id", intValue(42)),
("name", stringValue("仓颉 protobuf")),
("points", listValue([intValue(3), intValue(5), intValue(8)]))
])
let bytes = schema.encode(input)
let decoded = schema.decode(bytes)
println(schema.toJson(decoded))
这种映射让字段类型在编译期可见,也使错误值能够在 verify 阶段给出明确结果。
3.2 用 ProtoValue 表达标量和容器
JavaScript 中同一个普通对象可能同时包含数值、数组、嵌套对象和 map。仓颉版本将它们拆成 ProtoValue 的成员:IntValue、UIntValue、BoolValue、DoubleValue、StringValue、BytesValue、MessageValue、ListValue、MapValue 和 EnumValue。重复字段必须使用 ListValue,map 必须使用 MapValue,从调用边界保证编码器接收到的形状稳定。
3.3 用 verify 表示输入边界
protobuf.js 的 verify 会检查普通对象是否可以按当前类型编码。仓颉版本返回 None 或 Some(error):
cangjie
match (schema.verify(input)) {
case None => println("valid message")
case Some(error) => println(error)
}
当把字符串错误地放入 int32 字段时,示例会打印 id: value has the wrong type。编码器不会静默把错误类型转换成另一种 wire 值。
第 4 阶段:实现源码
4.1 文件 src/protobuf.cj
源码按消息生命周期组织:ProtoWriter 写入字段 key 和 wire value,ProtoReader 读取 varint、fixed32、fixed64 和 length-delimited 数据,MessageSchema 再根据字段号选择对应的 ProtoField。编码路径覆盖正负整数补码、zigzag、浮点位表示、UTF-8 字符串、bytes、嵌套消息、repeated 和 map。
编码和解码都保留 protobuf wire format 的关键规则:字段 key 是 (fieldNumber << 3) | wireType;packed repeated 使用一个长度分隔字段;未知字段按照 wire type 跳过,不会破坏后续已知字段的读取。
4.2 分隔消息和未知字段
encodeDelimited 会在消息前写入消息体长度,decodeDelimited 先读取长度再限制 reader 范围。示例把未声明的 field 30 附加到消息末尾,解码后仍能正常读取 id,并打印:
text
field 30 is skipped; decoded id=42
这对应 protobuf.js reader 默认丢弃未知字段的安全行为,调用方不需要为未知字段维护额外的动态存储。
4.3 JSON 输出边界
MessageSchema.toJson 用于观察解码结果和终端演示,不是完整 ProtoJSON 实现。bytes 以 Base64 字符串展示,嵌套消息、数组和 map 递归输出,未命名的枚举值以数值输出。这样可以直接把二进制往返的结果打印出来,同时避免把 JSON 展示能力误认为完整 JSON 映射规范。
第 5 阶段:建立自动化测试
test/main.cj 是一个直接链接仓颉静态库的可执行测试入口,覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 标量编码 | int32、uint32、uint64、sint32、sint64、double、bool |
| wire 类型 | varint、zigzag、fixed32、sfixed64、length-delimited |
| 复杂字段 | bytes、enum、repeated、packed repeated、嵌套消息和 map |
| 消息操作 | encode/decode、encodeDelimited/decodeDelimited |
| 边界处理 | 未知字段跳过、截断输入和错误字段类型 |
| 输出 | toJson 的字段、数组、map 和 Base64 结果 |
编译并运行后会输出:
text
protobufjs_cangjie tests passed: encode/decode, packed repeated, nested, map, delimited, verify, JSON
示例工程负责展示每个操作的实际输入和输出,测试入口负责断言二进制往返和错误边界,两者共同覆盖编译期 API 和运行时消息行为。
第 6 阶段:核对 protobuf.js 适配 API
当前公开入口按以下几组核对:
text
Field metadata: FieldKind, ProtoField, MessageSchema
Message values: ProtoValue, ProtoMessage, message and value constructors
Runtime operations: encode, decode, encodeDelimited, decodeDelimited, verify, toJson
这些入口都可以从 import protobufjs_cangjie.* 导入。这个核对说明仓颉适配的文档入口和源码入口一致,不代表已经覆盖 protobuf.js 的 CLI、动态 .proto parser、RPC service 或可选文本扩展。
四、完整验证步骤
以下命令均以仓库根目录 protobuf.js/ 为当前目录。先确认本机已经安装仓颉 SDK:
bash
cjc --version
cjpm --version
构建静态库:
bash
cjpm build
构建并运行独立的终端示例工程:
bash
cd example
cjpm check
cjpm run
如果需要直接运行测试入口,可以先执行示例构建,让本地依赖接口文件进入 release 目录,再从仓库根目录链接运行:
bash
cjc test/main.cj \
--import-path .cjpm-target/protobufjs_example/release/protobufjs_cangjie \
--library-path .cjpm-target/protobufjs_example/release/protobufjs_cangjie \
--library protobufjs_cangjie \
--static-libs --set-runtime-rpath \
-o /tmp/protobufjs-cangjie-test
/tmp/protobufjs-cangjie-test
示例会真实调用库并打印输入消息、二进制字节、解码消息、校验结果、分隔消息、未知字段和故意构造的类型错误:
text
== Binary encoding ==
147 bytes
08 2a 12 0f ...
== Verification ==
valid message
== Unknown field handling ==
field 30 is skipped; decoded id=42
== Verification failure ==
id: value has the wrong type
All protobuf.js Cangjie demo operations produced observable results.
验证结束后执行 cjpm clean 或清理 .cjpm-target、target 等构建目录,并确认这些构建产物未加入 Git。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含静态库构建后的 protobuf.js Cangjie 二进制编码、解码、分隔消息、未知字段和类型校验结果。

这张图可以作为文章或项目说明中的验收证据:终端同时展示了解码后的完整消息、valid message、分隔消息解码、未知字段跳过和校验失败信息。输入消息先经过仓颉库编码,再由同一个 schema 解码,因此效果图不是预先写死的文本,而是库真实运行输出。
六、关键决策说明
6.1 为什么使用显式 schema 和消息值
仓颉适配的价值在于让调用方在编译期知道字段名称、字段号和字段值的形状。若所有字段都退化成无类型值,虽然表面上接近 JavaScript,却会失去 repeated、packed、map 和 nested message 的约束。因此项目优先使用 enum、class、泛型集合和显式构造函数,让常见编码调用保持可读、可检查和可预测。
6.2 为什么保留 protobuf.js 的核心方法名称
保留 encode、decode、verify、encodeDelimited、decodeDelimited 和 toJson 等名称可以降低迁移成本。熟悉 protobuf.js 的开发者只需要适应仓颉的参数标签、ProtoValue 构造和显式 schema,不必重新记忆一套完全不同的消息运行时 API。暂时没有迁移的 parser、CLI 和 RPC 能力,通过文档明确边界,而不是伪造兼容行为。
6.3 为什么把测试和示例分开
测试程序负责断言,适合自动化执行;示例工程负责展示,适合在终端中观察真实编码字节和解码结果。两者都使用同一个 protobuf.js 仓颉包,因此效果图展示的是库的实际运行输出,测试结果也不会被展示代码替代。
七、兼容边界和遗留问题
JavaScript protobuf.js 与仓颉的类型系统和运行时扩展不同,以下限制需要在接入时提前了解:
- 当前版本实现消息运行时和 Protocol Buffers wire format,不包含
.proto文本解析器、CLI 代码生成器和动态 root 加载。 - 当前版本支持标量、bytes、enum、嵌套消息、repeated、packed repeated 和 map;oneof、proto2 required/default 语义、group 和 service RPC 尚未映射。
toJson是用于结果观察的 JSON 输出,不是完整 ProtoJSON 规范实现;枚举解码后保留数值,bytes 输出 Base64。- map key 当前由字符串、整数、无符号整数或布尔值编码为字符串键;复杂对象不能作为 map key。
- 解码默认跳过未知字段,不保留未知字段原始字节;应用如果需要透传未知字段,需要在更高层自行保存原始消息。
- schema 需要由调用方显式创建,当前没有从 JavaScript 类型或
.proto文件自动生成仓颉 schema 的工具链。
这些是适配范围说明,不是构建错误。只要 cjpm build 成功、测试入口输出通过标记、示例输出 All protobuf.js Cangjie demo operations produced observable results.,就说明当前版本已经完成本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖或发布包使用。最直接的本地验证方式是先在本仓库根目录构建,再在调用方导入 protobufjs_cangjie:
cangjie
import protobufjs_cangjie.*
main(): Int64 {
let schema = MessageSchema("User", [
ProtoField.int32("id", 1),
ProtoField.string("name", 2)
])
let input = message([
("id", intValue(7)),
("name", stringValue("Ada"))
])
let bytes = schema.encode(input)
println(schema.toJson(schema.decode(bytes)))
return 0
}
如果使用 cjpm 管理依赖,则将仓颉包加入项目依赖配置,再由业务工程统一构建:
toml
[dependencies]
protobufjs_cangjie = { path = "../protobuf.js" }
跨端项目可以复用相同的 schema、消息构造、二进制往返和错误校验思路;具体的 .proto 生成、RPC、未知字段保留和 ProtoJSON 适配由宿主项目按需求扩展。
九、总结
本次适配完成了 protobuf.js 核心消息运行时到仓颉静态类型模型的迁移:字段使用 ProtoField 和 MessageSchema 描述,消息值使用 ProtoValue 和 ProtoMessage 表达,wire format 通过 ProtoWriter 和 ProtoReader 实现,错误输入通过 verify 返回明确结果,二进制消息可以进行普通和分隔编码解码。
验证链路包括 cjpm build、测试入口编译、真实消息二进制往返、packed repeated、嵌套消息、map、未知字段和 JSON 结果输出。最终效果图来自真实的仓颉可执行程序,能够直观看到三方库在复杂消息场景中的编码和解码结果。对于暂时无法一比一映射 JavaScript protobuf.js 的 parser、CLI、RPC 和 ProtoJSON 语义,项目通过静态签名和文档边界保持行为可解释,方便后续继续扩展。
适配过程 FAQ
以下问题来自仓颉工程创建、
cjpm依赖、与 HarmonyOS 设备联调的实际流程。
Q1:cjpm 依赖解析失败或拉取超时?
- 检查
cjpm.toml中的依赖坐标与版本号; - 配置可用的仓颉包仓库镜像或代理;
- 删除
target、cjpm.lock后重新执行cjpm update/cjpm build。
Q2:编译报找不到仓颉 SDK 或 cjc 版本不匹配?
- 设置
CANGJIE_HOME指向仓颉 SDK 根目录; - 确认
cjc --version、cjpm --version与工程要求的版本一致; - 清理构建缓存后重新生成。
Q3:与 HarmonyOS 设备联调时安装失败?
- 用
hdc list targets确认设备在线; - 确认 HAP 已签名,
bundleName与签名 profile 一致; - 设备需加入调试 profile 的
deviceIds。
Q4:动态库或 FFI 符号找不到?
- 检查导出符号名称、ABI 与打包路径;
- 确认
.so已随 HAP 一起打包到libs/<abi>/; - 用
llvm-readelf -d检查依赖库是否齐全。
Q5:测试用例在设备上失败,但本机通过?
- 对比设备与主机的架构、SDK 版本和环境变量;
- 用
hdc hilog抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。