本文记录 qs 6.15.3 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的跨端三方库,并在终端中用真实示例验证查询字符串解析、嵌套对象、数组参数、URL 编解码和查询字符串序列化能力。
项目地址: AtomGit/nutpi_cj/qs
上游仓库: ljharb/qs.git
开发工具: 华为云码道
一、背景
1.1 为什么适配 qs
qs 是 JavaScript 生态中常用的查询字符串解析和序列化库,支持 a[b]=c 这样的嵌套对象、tags[]=one 这样的数组参数、重复键、点号路径和 URL 编码。它比浏览器原生的简单 querystring 工具覆盖更多服务端请求场景,也提供参数数量、嵌套深度和数组索引限制来降低不可信输入带来的风险。
JavaScript 版本依赖动态对象、异构数组和运行时属性判断,而仓颉强调静态类型、泛型和显式的空值处理。直接把 JavaScript 源码逐行改写成仓颉,难点不在语法替换,而在于重新确定查询参数树的类型边界、数组索引规则、重复键合并方式和编码行为。
本项目将 qs 的核心解析与序列化能力收敛为可维护的仓颉源码,并保留 parse、stringify、encode 和 decode 等熟悉的函数名称,方便熟悉 qs 的开发者迁移调用代码。
1.2 跨端三方库的适配边界
当前项目提供的是仓颉跨端库,不绑定某一个 UI 框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和 target/release/qs_cangjie |
| 查询树 | QsValue 枚举、HashMap<String, QsValue> 和 ArrayList<QsValue> |
| 空值 | NullValue 分支和 strictNullHandling 选项 |
| URL 编解码 | encode、decode 使用 UTF-8 字节和 RFC 3986 百分号编码 |
| 解析限制 | parameterLimit、depth 和 arrayLimit |
| 交付形态 | 仓颉静态库 libqs_cangjie.a 和接口文件 qs_cangjie.cjo |
| 验证方式 | test/main.cj 编译检查和 example/example.cj 终端示例 |
1.3 适配后的能力
当前适配导出以下仓颉入口:
- 值模型:
QsValue、NullValue、BoolValue、IntValue、FloatValue、StringValue、ObjectValue、ArrayValue; - 构造函数:
object、array; - 编解码:
encode、decode; - 解析:
parse,支持方括号嵌套键、重复键、数组空下标、可选点号路径、查询前缀忽略和三个输入限制; - 序列化:
stringify,支持indices、brackets、repeat三种数组格式,以及严格空值、仅编码值和查询前缀选项。
解析得到的叶子值保留为 StringValue,因为 URL 查询参数本身是文本。需要传输布尔值或数值时,调用方可以直接构造 BoolValue、IntValue 或 FloatValue 后再序列化。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 qs 的 parse/stringify 选项,确定仓颉函数和目录边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 QsValue、HashMap、ArrayList 和 Option 表达动态查询树
第 4 阶段:实现核心逻辑 ── 完成 URL 编解码、路径拆分、嵌套插入和数组序列化
第 5 阶段:测试与核对 ── 编译 test/main.cj,验证嵌套键、数组、点号路径和编码结果
第 6 阶段:终端演示 ── 构建 example,直接输出真实解析和序列化结果并保存效果图
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 target/release/qs_cangjie,验证阶段得到成功的 cjpm check 与 cjpm test --no-run,最终示例则输出一组可直接放入文章的运行结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
qs 的 JavaScript API 接收任意查询字符串,并返回可能同时包含字符串、数组和对象的动态值。例如 foo[bar]=baz&tags[]=one 会被解析成嵌套对象和数组。仓颉没有与 JavaScript Any 完全等价的无类型容器,因此本适配先确定一个显式值树,再让所有解析与序列化逻辑围绕这个值树工作。
解析边界也需要提前固定:重复键合并成数组;空方括号追加数组元素;数字方括号在 arrayLimit 范围内作为数组下标;__proto__、constructor 和 prototype 键被忽略;超过 parameterLimit 的参数和超过 depth 的路径不再写入结果。
1.2 固定仓颉项目结构
以下目录树按照当前 qs 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 JavaScript 源码、测试和构建目录保持原位置,不在此处重复列出:
text
qs/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ └── qs.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
src/qs.cj 同时包含值模型、编解码、查询路径拆分、解析和序列化实现,保持当前适配范围内的单一公共包。测试入口位于 test/main.cj,示例工程位于 example/,两者都独立于库源码。
第 2 阶段:创建 cjpm 工程
cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:
toml
[package]
cjc-version = "1.1.3"
name = "qs_cangjie"
version = "6.15.3"
output-type = "static"
src-dir = "src"
target-dir = "target"
包版本沿用当前 qs 适配版本,包名使用 qs_cangjie,避免与 JavaScript 包名在仓颉模块命名空间中混淆。构建成功后,最重要的两个产物是:
text
target/release/qs_cangjie/libqs_cangjie.a
target/release/qs_cangjie/qs_cangjie.cjo
其中 .a 用于链接,.cjo 提供编译器需要的接口信息。示例工程通过本地路径依赖引用根项目:
toml
[dependencies]
qs_cangjie = { path = ".." }
第 3 阶段:把 JavaScript 动态值映射为仓颉类型
3.1 用 QsValue 表示查询树
QsValue 是解析和序列化共用的值模型:
cangjie
public enum QsValue {
| NullValue
| BoolValue(Bool)
| IntValue(Int64)
| FloatValue(Float64)
| StringValue(String)
| ObjectValue(HashMap<String, QsValue>)
| ArrayValue(ArrayList<QsValue>)
}
对象使用 HashMap<String, QsValue>,数组使用 ArrayList<QsValue>。这种表示允许 parse 构造嵌套对象,也允许 stringify 序列化由调用方手动构造的布尔值和数字。
3.2 用字符串和显式空值对应 URL 参数
查询字符串没有可靠的原生数字和布尔类型,因此 parse 将每个参数值解码后包装为 StringValue。strictNullHandling 只影响序列化:开启时 NullValue 输出没有等号的键,关闭时输出空值。
cangjie
let value = object([
("active", BoolValue(true)),
("count", IntValue(3)),
("empty", NullValue)
])
let query = stringify(value, strictNullHandling: true)
3.3 用参数标签表达 qs 选项
仓颉的参数标签让选项保持可读,并在编译期检查拼写和类型:
cangjie
let value = parse(
"user.name=Ada&tags[]=cangjie",
parameterLimit: 1000,
depth: 5,
arrayLimit: 20,
allowDots: true
)
stringify 的 arrayFormat 只接受 indices、brackets 和 repeat。其他值会抛出 IllegalArgumentException,避免悄悄产生与调用方预期不同的查询格式。
第 4 阶段:分层实现源码
4.1 核心文件 src/qs.cj
核心文件先将键和值拆开,再按方括号和可选点号拆分路径,最后递归写入对象或数组。以下调用会生成嵌套对象和数组:
cangjie
let parsed = parse("foo[bar]=baz&tags[]=one&tags[]=two")
foo[bar] 被拆为 foo、bar 两段;tags[] 的空段表示向数组末尾追加。重复的普通键通过 combine 变为数组,避免后一个值覆盖前一个值。
4.2 URL 编解码和输入限制
decode 处理 %HH 十六进制字节和表单中的 + 空格;encode 保留 RFC 3986 的非保留字符,其余 UTF-8 字节转换为大写百分号编码。解析时按 & 分割参数,并执行三层限制:
cangjie
let parsed = parse(
query,
parameterLimit: 1000,
depth: 5,
arrayLimit: 20
)
参数超过 parameterLimit 后停止读取;路径段超过 depth + 1 时跳过;数字下标达到 arrayLimit 后不再作为数组下标。危险原型键在写入前过滤,避免把 JavaScript 原型污染语义带入仓颉值树。
4.3 序列化入口和静态边界
stringify 递归遍历对象和数组,在数组节点根据 arrayFormat 生成下一个键:
cangjie
let query = stringify(
object([("tags", array([StringValue("one"), StringValue("two")]))]),
arrayFormat: "brackets"
)
输出为 tags%5B%5D=one&tags%5B%5D=two。仓颉版本没有复刻 JavaScript 任意对象、函数值和原型链;公共边界固定为 QsValue,让解析和序列化的行为可以由编译器和模式匹配检查。
第 5 阶段:建立自动化测试
test/main.cj 是项目约定的仓颉测试入口,覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 嵌套对象 | foo[bar]=baz 解析为两层对象 |
| 数组参数 | tags[]=one&tags[]=two 合并为两个数组元素 |
| 点号路径 | user.name=Ada 在 allowDots 下生成嵌套对象 |
| 编码 | 空格编码为 %20,方括号编码为 %5B 和 %5D |
| 查询前缀 | ?a=b 在 ignoreQueryPrefix 下正常解析 |
测试入口中的断言失败会抛出异常并以非零状态退出;所有断言通过时输出固定标记:
text
ALL QS CANGJIE TESTS PASS
当前 cjpm test --no-run 会编译该测试入口。由于它是带 main 的项目入口而不是 @Test 注册用例,直接执行 cjpm test 时不会自动统计这个入口中的断言;需要执行入口程序时,应使用 cjc 按下文命令编译并运行。
第 6 阶段:核对 qs 仓颉导出名称
当前 src/qs.cj 的公共入口核对结果为:
text
QsValue 枚举:1 个
值构造函数:2 个
编解码函数:2 个
解析与序列化函数:2 个
这些入口覆盖当前适配明确承诺的 qs 能力范围。它们不是对 JavaScript qs 所有历史选项的名称承诺;尚未实现的 comma、charsetSentinel、自定义 decoder 和自定义 filter 等动态扩展没有伪装成仓颉接口。
四、完整验证步骤
以下命令均以仓库根目录 qs/ 为当前目录。先加载已经安装在本机的仓颉 SDK:
bash
source ~/.zshrc
echo "$CANGJIE_HOME"
cjc --version
cjpm --version
检查源码和依赖关系:
bash
cjpm check
构建静态库:
bash
cjpm build
编译测试入口但不依赖 cjpm 的 unittest 自动发现:
bash
cjpm test --no-run
构建并运行独立的终端示例工程:
bash
cd example
cjpm update
cjpm build
cjpm run
示例工程直接调用 parse 和 stringify,输出查询文本和序列化后的结果:
text
query: user[name]=Ada&tags[]=cangjie&tags[]=query
round trip: user%5Bname%5D=Ada&tags%5B%5D=cangjie&tags%5B%5D=query
序列化的对象字段顺序取决于 HashMap 的遍历顺序;字段顺序可能变化,但键和值及数组元素保持一致。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含 cjpm update、静态库构建、示例运行和 qs 查询字符串往返结果。

这张图可以作为文章或项目说明中的验收证据:终端同时展示了构建成功、查询参数输入和 round trip 序列化输出,说明示例使用的是实际仓颉库调用,而不是预先写死的展示文本。
六、关键决策说明
6.1 为什么不用无类型 Any
qs 的 JavaScript 返回值可以在运行时混合对象、数组和字符串,但仓颉适配如果把所有值退化为无类型容器,就会失去对象字段、数组元素和空值分支的编译期检查。因此项目用 QsValue 明确列出允许的值形状,并用 HashMap 和 ArrayList 承载递归结构。
6.2 为什么保留 qs 函数名
保留 parse、stringify、encode 和 decode 可以降低迁移成本。熟悉 qs 的开发者只需要适应仓颉的参数标签、枚举模式匹配和静态值模型,不必重新记忆一套完全不同的工具 API。尚未覆盖的动态选项会在兼容边界中明确列出,而不是提供行为不完整的同名入口。
6.3 为什么把测试和示例分开
测试入口负责断言嵌套键、数组合并和编码结果,适合编译检查和自动化流程;示例工程负责展示,适合在终端中观察真实结果和截图。两者都使用同一个 qs_cangjie 包,因此效果图是库的实际运行输出。
七、兼容边界和遗留问题
JavaScript qs 与仓颉的运行时模型不同,以下限制需要在接入时提前了解:
- JavaScript 的任意动态值、异构数组和原型链没有直接的仓颉静态等价物;解析结果统一使用
QsValue,叶子值统一为StringValue。 - 当前实现覆盖方括号嵌套、重复键、空数组下标、点号路径和三类输入限制;
comma数组格式、字符集哨兵、自定义 decoder、filter 和排序回调尚未提供。 parameterLimit、depth和arrayLimit超出后采用安全截断或跳过策略;当前没有strictDepth和throwOnLimitExceeded选项。HashMap不保证字段遍历顺序。序列化结果的参数顺序可能与 JavaScript 对象枚举顺序不同,调用方不应依赖字段排序。arrayFormat只接受indices、brackets和repeat。comma等 JavaScript 选项不能直接传给仓颉函数。- URL 编解码面向 UTF-8 字节;不提供 JavaScript qs 的
iso-8859-1和 numeric entity 兼容模式。
这些是明确的适配边界,不是构建错误。只要 cjpm check、cjpm build 和 cjpm test --no-run 成功,库就完成了当前范围内的本地编译验收;示例运行则用于验证面向用户的实际调用路径。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖或发布包使用。最直接的本地验证方式是在调用方的 cjpm.toml 中加入:
toml
[dependencies]
qs_cangjie = { path = "../qs" }
调用代码导入仓颉包并构造查询值:
cangjie
import qs_cangjie.*
main(): Int64 {
let parsed = parse("user[name]=Ada&tags[]=cangjie")
println(stringify(parsed, arrayFormat: "brackets"))
return 0
}
如果使用 cjc 直接链接静态库,则需要将 target/release/qs_cangjie 加入导入路径和库搜索路径。跨端项目可以复用相同的解析、序列化 API 和测试思路,具体的网络请求、文件系统或 UI 适配由宿主平台负责。
九、总结
本次适配完成了 qs 到仓颉静态类型模型的迁移:查询树使用 QsValue 表达,对象使用 HashMap<String, QsValue>,数组使用 ArrayList<QsValue>,URL 编解码使用 UTF-8 字节处理,解析过程提供参数数、嵌套深度和数组索引限制。
验证链路包括 cjpm check、静态库构建、cjpm test --no-run 和独立示例工程。最终效果图来自真实的仓颉终端程序,能够直观看到嵌套对象和数组查询参数的解析与序列化结果。对于尚未一比一映射 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抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。