Protobuf.js 跨端三方库仓颉适配全流程

本文记录 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 与仓颉的类型系统和运行时扩展不同,以下限制需要在接入时提前了解:

  1. 当前版本实现消息运行时和 Protocol Buffers wire format,不包含 .proto 文本解析器、CLI 代码生成器和动态 root 加载。
  2. 当前版本支持标量、bytes、enum、嵌套消息、repeated、packed repeated 和 map;oneof、proto2 required/default 语义、group 和 service RPC 尚未映射。
  3. toJson 是用于结果观察的 JSON 输出,不是完整 ProtoJSON 规范实现;枚举解码后保留数值,bytes 输出 Base64。
  4. map key 当前由字符串、整数、无符号整数或布尔值编码为字符串键;复杂对象不能作为 map key。
  5. 解码默认跳过未知字段,不保留未知字段原始字节;应用如果需要透传未知字段,需要在更高层自行保存原始消息。
  6. 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 抓取运行日志;
  • 优先复现最小用例,确认是否为平台差异或时序问题。

十、参考资料

相关推荐
ITxiaobing20232 小时前
中国公司海外广告投放的 IP 地址库选型:从“查归属”到“验真实”的工程视角
网络·网络协议·tcp/ip
KKKlucifer3 小时前
运营商非结构化文档安全管控体系建设实践
大数据·网络·安全
Flynt4 小时前
我把热榜那篇"禁用 axios"逐条实测了一遍:结论大体对,坑在它没讲的地方
前端·javascript·axios
网络小江4 小时前
组网第35课:静态路由——下一跳、出接口、默认路由、浮动静态与黑洞路由
服务器·网络·默认路由·黑洞路由·浮动静态路由
acrel75 小时前
医院用电隐患治理与节能降耗及智慧能源平台应用
网络·数据库
slongzhang_5 小时前
linux 递归删除源码文件|递归删除目录下 *.js,但排除 *.min.js
linux·开发语言·javascript
Escalating_xu6 小时前
【Linux 网络编程】应用层协议 HTTP(下):Header、连接管理与 C++ HTTP 服务器
linux·服务器·网络
明天也要努力学习7 小时前
TCP 协议的相关特性
网络·网络协议·tcp/ip