Qs 跨端三方库仓颉适配全流程

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

  1. JavaScript 的任意动态值、异构数组和原型链没有直接的仓颉静态等价物;解析结果统一使用 QsValue,叶子值统一为 StringValue。
  2. 当前实现覆盖方括号嵌套、重复键、空数组下标、点号路径和三类输入限制;comma 数组格式、字符集哨兵、自定义 decoder、filter 和排序回调尚未提供。
  3. parameterLimit、depth 和 arrayLimit 超出后采用安全截断或跳过策略;当前没有 strictDepth 和 throwOnLimitExceeded 选项。
  4. HashMap 不保证字段遍历顺序。序列化结果的参数顺序可能与 JavaScript 对象枚举顺序不同,调用方不应依赖字段排序。
  5. arrayFormat 只接受 indices、brackets 和 repeat。comma 等 JavaScript 选项不能直接传给仓颉函数。
  6. 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 抓取运行日志;
  • 优先复现最小用例,确认是否为平台差异或时序问题。

十、参考资料

相关推荐
萌动的小火苗1 小时前
MQTT的QoS0、QoS1、QoS2有什么区别?
开发语言·网络·python·php·信息与通信
forest_091 小时前
《大模型原理通俗讲》系列 00|开篇 大模型到底是怎么回事
人工智能·python·prompt·agi
工程师迪菲1 小时前
【基础篇 01】YOLO 到底在做什么?先从一张图片看明白
python
happylifetree2 小时前
Python72:核心语法-类型注解-函数的类型注解
python
liudachu2 小时前
Agent 开发常见名词说明与类比
python·langchain
devilnumber2 小时前
Lambda 表达式(Java)优缺点清晰分析
java·开发语言·python
大泽九章2 小时前
XSS入门:存储型、反射型、DOM型,用Flask全复现
python·网络安全·flask
class心平气和2 小时前
ADB 工具函数 Python 使用教程
开发语言·python·adb
小小张说故事3 小时前
Python 里 is 和 == 到底差在哪?拆开小整数池、字符串驻留与 0ns 身份比较
python