本文记录 JSON5 2.2.3 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的跨端三方库,并在终端中用真实示例验证 JSON5 注释、字符串、对象、数组、数字、特殊数值和序列化能力。
项目地址: AtomGit/nutpi_cj/json5
上游仓库: json5/json5.git
开发工具: 华为云码道
一、背景
1.1 为什么适配 JSON5
JSON5 是 JSON 的可读扩展,适合手写配置文件。它在 JSON 的基础上增加了单行和多行注释、单引号字符串、无引号对象键、尾逗号、十六进制数字、正负号数字、前导或尾随小数点,以及 Infinity 和 NaN 等语法。
原始 JSON5 JavaScript 库通过动态对象和 JavaScript number 表达解析结果,parse 和 stringify 还可以接收 reviver、replacer 等动态回调。仓颉强调静态类型、显式枚举和编译期检查,直接逐行翻译 JavaScript 不能保留清晰的值边界。因此本适配先建立 Json5Value 值树,再围绕这个值树实现解析、序列化和构造辅助函数。
本项目保留 parse、stringify 等熟悉名称,并提供 object、array、number、text、boolean 和 display 辅助函数,方便熟悉 JSON5 的开发者在仓颉项目中迁移配置读取和生成代码。
1.2 跨端三方库的适配边界
当前项目提供的是仓颉跨端库,不绑定某一个 UI 框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和 target/release/json5_cangjie |
| 值模型 | Json5Value 枚举、HashMap<String, Json5Value> 和 ArrayList |
| 解析能力 | 注释、单/双引号、无引号键、尾逗号和 JSON5 数字 |
| 序列化能力 | 紧凑输出、缩进输出和 quoteChar 引号选择 |
| 错误模型 | IllegalArgumentException,包含字节位置和错误原因 |
| 交付形态 | 仓颉静态库 libjson5_cangjie.a 和接口文件 json5_cangjie.cjo |
| 验证方式 | test/main.cj 编译测试和 example/example.cj 终端示例 |
这里没有 HAP 构建、ArkTS 桥接和真机系统能力验收。只要目标平台提供兼容版本的仓颉 SDK,就可以按同一套源码和 cjpm 配置构建并消费这个库。示例包的 src-dir 是当前目录,但 target-dir 放在 example 外部的 .cjpm-target/json5_example,避免构建输出被源码扫描。
1.3 适配后的能力
当前导出的仓颉 API 包括一个值枚举和八个顶层函数:
- Json5Value:用 NullValue、BooleanValue、NumberValue、StringValue、ObjectValue 和 ArrayValue 表达 JSON5 值;
- parse:将 JSON5 文本解析为 Json5Value;
- stringify:将 Json5Value 序列化为紧凑或格式化的 JSON5 文本;
- object 和 array:从数组和键值对构造对象或数组;
- number、text 和 boolean:构造数字、字符串和布尔值;
- display:为终端示例提供简洁的标量或容器显示文本。
解析器支持 JSON5 的主要手写配置场景,包括 // 和 /* */ 注释、单引号、双引号、转义字符、UTF-8 字符、无引号键、尾逗号、十六进制、小数点省略、正负号以及 Infinity 和 NaN。序列化器支持缩进字符串和单引号/双引号选择。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 JSON5 parse/stringify 能力,确定仓颉值模型和目录边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 Json5Value、HashMap 和 ArrayList 替换动态 JavaScript 值
第 4 阶段:实现解析逻辑 ── 完成注释、字符串、对象、数组、数字和错误位置处理
第 5 阶段:测试与核对 ── 编译 test/main.cj,覆盖 JSON5 语法、序列化和非法输入
第 6 阶段:终端演示 ── 构建 example,直接输出真实解析和序列化结果并保存效果图
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 target/release/json5_cangjie,验证阶段得到 ALL JSON5 CANGJIE TESTS PASS,最终示例则输出 JSON5 CANGJIE EXAMPLE PASS 并打印紧凑和格式化结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
JSON5 的 JavaScript API 接收任意文本并返回任意 JavaScript 值。对象的键可以是带引号的字符串,也可以是 ECMAScript IdentifierName;数组和对象可以嵌套,数字既可能是普通十进制值,也可能是十六进制、Infinity 或 NaN。
仓颉没有与 JavaScript 任意对象完全等价的公共无类型容器,因此本适配先确定一个显式值树。对象统一使用 HashMap<String, Json5Value>,数组统一使用 ArrayList,标量通过枚举分支区分。这样 parse 和 stringify 可以共享同一个类型边界,调用方也可以通过 match 在编译期处理每一种值。
原始库的 reviver 和 replacer 回调依赖 JavaScript 的动态 holder、属性删除和函数对象,本次仓颉适配没有伪造这些接口,而是在文档中明确列为兼容边界。
1.2 固定仓颉项目结构
以下目录树按照当前 json5 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 JSON5 JavaScript 源码、测试和 lib/ 目录保持原位置:
text
json5/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ └── json5.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码集中在 src/json5.cj,包含值模型、词法扫描、递归解析、序列化和构造辅助函数。测试入口位于 test/main.cj,示例工程位于 example/。示例的 src-dir 是当前目录,但 target-dir 配置为 .../.cjpm-target/json5_example,不会把构建输出放入 src-dir 的扫描范围。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:
toml
[package]
cjc-version = "1.1.3"
name = "json5_cangjie"
version = "2.2.3"
output-type = "static"
src-dir = "src"
target-dir = "target"
包版本沿用当前 JSON5 版本,包名使用 json5_cangjie。根包的 src-dir 是 src,target-dir 是同级的 target,不在源码目录扫描范围内。构建成功后,最重要的两个产物是:
text
target/release/json5_cangjie/libjson5_cangjie.a
target/release/json5_cangjie/json5_cangjie.cjo
其中 .a 用于链接,.cjo 提供编译器需要的接口信息。示例工程通过本地路径依赖引用根项目,并单独配置外部构建目录:
toml
[package]
cjc-version = "1.1.3"
name = "json5_example"
version = "1.0.0"
output-type = "executable"
src-dir = "."
target-dir = "../.cjpm-target/json5_example"
[dependencies]
json5_cangjie = { path = ".." }
这样运行 cjpm run 时不会扫描 example/target,也不会出现 there is no '.cj' file ... target 警告。验证完成后清理 target 和 .cjpm-target,构建产物不提交到仓库。
第 3 阶段:把 JavaScript 动态值映射为仓颉类型
3.1 用 Json5Value 表示 JSON5 值树
Json5Value 是解析和序列化共用的值模型:
cangjie
public enum Json5Value {
| NullValue
| BooleanValue(Bool)
| NumberValue(Float64)
| StringValue(String)
| ObjectValue(HashMap<String, Json5Value>)
| ArrayValue(ArrayList<Json5Value>)
}
对象使用 HashMap<String, Json5Value>,数组使用 ArrayList。这种表示允许 parse 构造任意层级的对象和数组,也允许调用方使用 object 和 array 手动构造待序列化的值。
cangjie
let value = object([
("name", text("json5")),
("enabled", boolean(true)),
("items", array([text("comments"), NullValue]))
])
let encoded = stringify(value)
3.2 用 Float64 表达 JSON5 数字
JavaScript JSON5 使用 number 表示普通数字、十六进制数字、Infinity 和 NaN。仓颉版本将所有 NumberValue 映射为 Float64,十六进制整数在解析后转换为 Float64,避免在同一个值分支中混合多个数值类型。
cangjie
let parsed = parse("{hexadecimal: 0x2a, fraction: .5, special: NaN}")
let explicit = number(42)
字符串仍然使用 String,布尔值使用 Bool,空值使用 NullValue。这样调用方可以用 match 明确处理数值精度和空值分支,而不依赖 JavaScript 的隐式类型转换。
3.3 用 IllegalArgumentException 表示解析失败
JavaScript JSON5 会在输入错误时抛出带位置的异常。仓颉版本对空输入、未闭合注释、未闭合字符串、无效数字、对象缺少冒号和多余尾随文本抛出 IllegalArgumentException,错误文本带有字节位置:
cangjie
try {
parse("{name: 'missing'} trailing")
} catch (error: IllegalArgumentException) {
println(error.message)
}
示例程序将非法对象和有效文档后多出的文本都放入断言,确保失败路径真的经过解析器,而不是只展示成功输出。
第 4 阶段:实现源码
4.1 文件 src/json5.cj
源码按 JSON5 文档生命周期组织。Json5Parser 保存 source 和当前位置,先跳过 JSON5 空白与注释,再根据当前字节进入对象、数组、字符串、字面量或数字解析。对象键支持引号形式和无引号标识符,数组和对象在结束符前都接受一个尾逗号。
数字解析先识别正负号和 0x/0X 前缀,再处理十进制小数和指数。Infinity、-Infinity、NaN、+NaN 和 -NaN 映射为 Float64 特殊值。字符串解析覆盖 b、f、n、r、t、v、0、x 和 u 转义,并按 UTF-8 片段保留非 ASCII 文本。
4.2 JSON5 解析和序列化
parse 的核心入口是:
cangjie
let document = parse("""
{
// JSON5 comment
unquoted: 'value',
values: [true, null,],
}
""")
stringify 递归处理 Json5Value。对象键满足标识符规则时可以输出为无引号键,否则使用 quoteChar 指定的引号包裹;数组和对象在 indent 非空时输出多行缩进。Infinity、-Infinity 和 NaN 使用 JSON5 兼容的文本写回。
cangjie
let compact = stringify(document)
let pretty = stringify(document, indent: " ")
let doubleQuoted = stringify(text("single ' and double \""), quoteChar: "\"")
HashMap 不保证字段遍历顺序,因此序列化文本的对象键顺序可能变化,但键和值保持一致。示例通过关键片段和重新 parse 验证语义,不依赖对象字段排序。
4.3 JSON5 语法边界
本次实现覆盖适合配置文件的主要 JSON5 语法,包括 ASCII 空白、行注释、块注释、单/双引号字符串、转义字符、UTF-8 文本、ASCII 和非 ASCII 标识符键、尾逗号、十六进制、十进制小数、正负号、Infinity 和 NaN。
为了保持仓颉实现清晰,当前没有实现 JavaScript reviver、replacer、toJSON5/toJSON 方法、稀疏数组和 JavaScript 原型对象语义。解析结果始终是 Json5Value,序列化输入也必须先构造成 Json5Value。
第 5 阶段:建立自动化测试
test/main.cj 是一个直接链接仓颉静态库的可执行测试入口。测试覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 值模型 | NullValue、BooleanValue、NumberValue、StringValue、ObjectValue 和 ArrayValue |
| JSON5 语法 | 注释、单/双引号、无引号键和尾逗号 |
| 数字 | 十六进制、前导小数点、Infinity、-Infinity 和 NaN |
| 字符串 | 转义换行、UTF-8 字符和 Unicode 标识符键 |
| 序列化 | 紧凑输出、缩进输出、特殊数字和指定引号 |
| 错误路径 | 非法对象和序列化文本后的多余输入 |
断言失败会抛出异常并以非零状态退出;全部通过时输出固定标记:
text
ALL JSON5 CANGJIE TESTS PASS
当前 test/main.cj 是带 main 的项目入口,不是带 @Test 注册的 unittest 用例。直接执行 cjpm test 时,仓颉工具链可能显示 0 个自动发现测试;本文使用 cjc 显式链接静态库并执行入口,确保每一条断言真的运行。
第 6 阶段:核对 JSON5 仓颉 API
当前 src/json5.cj 的公共入口核对结果为:
text
Json5Value 枚举:1 个
解析函数:1 个
序列化函数:1 个
构造函数:5 个
显示函数:1 个
这些入口覆盖当前适配明确承诺的 JSON5 能力范围。它们不是对 JavaScript JSON5 所有动态扩展的名称承诺;reviver、replacer 和 toJSON5 等依赖 JavaScript 函数对象的接口没有伪装成仓颉函数。
四、完整验证步骤
以下命令均以仓库根目录 json5/ 为当前目录。先确认本机已经安装仓颉 SDK:
bash
source ~/.zshrc
echo "$CANGJIE_HOME"
cjc --version
cjpm --version
然后构建静态库:
bash
cjpm build
编译并运行测试入口:
bash
cjc test/main.cj \
--import-path target/release/json5_cangjie \
-L target/release/json5_cangjie \
-l json5_cangjie \
-o /tmp/json5-cangjie-test
DYLD_LIBRARY_PATH="$CANGJIE_HOME/runtime/lib/darwin_aarch64_cjnative" /tmp/json5-cangjie-test
再构建并运行独立的终端示例工程:
bash
cd example
cjpm check
cjpm build
cjpm run
示例使用当前静态库真实调用 parse 和 stringify,并在每一项检查失败时以非零状态退出。成功时输出:
text
JSON5 CANGJIE EXAMPLE PASS
compact: {unquoted:'value',doubleQuoted:'double',...}
pretty:
{
unquoted: 'value',
...
}
示例的 target-dir 是 .../.cjpm-target/json5_example,运行 cjpm run 时不会出现把构建输出目录当作源码扫描目录的警告。验证结束后执行 cjpm clean,并确认 target 和 .cjpm-target 没有被加入 Git。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含静态库构建后的 JSON5 Cangjie 解析和序列化结果,以及最终完成标记。

效果图中可以看到示例输出 JSON5 CANGJIE EXAMPLE PASS、紧凑 JSON5 文本和带缩进的对象/数组文本。compact 和 pretty 文本都来自当前仓颉静态库的真实调用,不是预先写死的展示文本;程序还在输出前完成了语法、数值、UTF-8、序列化回读和异常路径断言。
六、关键决策说明
6.1 为什么使用 Json5Value 枚举
JSON5 可以表示六类基本值,仓颉适配如果把它们全部退化成 String 或无类型容器,就会失去布尔、数字、空值、对象和数组的编译期区分。因此项目使用 Json5Value 枚举,让 parse 和 stringify 共享同一个静态值模型,也让调用方可以通过 match 明确处理每一个分支。
6.2 为什么保留 parse 和 stringify 名称
保留 parse、stringify 以及 JSON5Value 的核心概念可以降低迁移成本。熟悉 JSON5 的开发者只需要适应仓颉的枚举模式匹配、HashMap、ArrayList 和命名参数,不必重新记忆一套完全不同的配置解析 API。无法表达的 JavaScript 动态回调则在兼容边界中明确列出。
6.3 为什么把测试和示例分开
测试程序负责断言语法、值模型、序列化和错误路径,适合自动化执行;示例工程负责展示,适合在终端中观察真实 JSON5 文本和截图。两者都使用同一个 json5_cangjie 静态库,因此效果图来自库的实际运行输出,而不是模拟文本。
七、兼容边界和遗留问题
JavaScript JSON5 与仓颉的运行时模型不同,以下限制需要在接入时提前了解:
- JavaScript 的任意动态值、函数对象和原型链没有直接的仓颉静态等价物;解析结果统一使用 Json5Value。
- 当前实现没有 reviver、replacer、toJSON5 和 toJSON 回调;这些接口依赖 JavaScript 的 holder、属性删除和动态方法调用。
- NumberValue 使用 Float64 表示普通数值和特殊数值;超出 Float64 精度的整数不能保证与 JavaScript number 的二进制结果逐位一致。
- HashMap 不保证对象字段遍历顺序。序列化结果的键顺序可能变化,调用方不应依赖对象字段排序。
- 错误位置按 UTF-8 字节位置报告,而不是按用户可见字符数报告;多字节字符附近的报错位置应按字节理解。
- 当前实现面向配置文件常见的 JSON5 语法;更完整的 ECMAScript IdentifierName、特殊空白和所有 JavaScript 对象方法语义不在本次适配范围内。
这些不是构建错误。只要 cjpm build 成功、测试程序输出 ALL JSON5 CANGJIE TESTS PASS、示例输出 JSON5 CANGJIE EXAMPLE PASS,就说明当前版本已经完成本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖或发布包使用。最直接的本地验证方式是先在本仓库根目录生成 target/release/json5_cangjie,再把相同的 --import-path 和 -L 参数传给调用方的 cjc 命令。调用代码只需要导入:
cangjie
import json5_cangjie.*
main(): Int64 {
let config = parse("{name: 'json5', enabled: true,}")
println(stringify(config, indent: " "))
return 0
}
如果使用 cjpm 管理依赖,则在调用方的 cjpm.toml 中加入本地路径:
toml
[dependencies]
json5_cangjie = { path = "../json5" }
跨端项目可以复用相同的解析、序列化和值树测试思路;具体的文件读取、配置热更新、线程和 UI 适配由宿主平台负责。
九、总结
本次适配完成了 JSON5 到仓颉静态类型模型的迁移:值树使用 Json5Value 表达,对象使用 HashMap<String, Json5Value>,数组使用 ArrayList,解析器支持注释、字符串、对象、数组、数字和尾逗号,序列化器支持紧凑输出、缩进输出和引号选择。
验证链路包括 cjpm build、测试入口编译、JSON5 语法断言、序列化回读、非法输入检查和独立终端示例。最终效果图来自真实的仓颉可执行程序,能够直观看到三方库在注释、字符串、UTF-8、数字、特殊数值、对象、数组和格式化输出场景中的结果。对于无法一比一映射 JavaScript 动态回调和原型语义的部分,项目通过静态签名和文档边界保持行为可解释,方便后续继续扩展。
适配过程 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抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。