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

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

本文记录 Zod 4.6.5 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的运行时 schema 校验库,并在本地终端中通过真实仓颉程序验证基础类型、对象、数组、组合 schema、默认值、转换、错误路径和 JSON Schema 输出。

项目地址: AtomGit/nutpi_cj/zod

上游仓库: colinhacks/zod

开发工具: 华为云码道

一、背景

1.1 为什么适配 Zod

Zod 是 JavaScript 和 TypeScript 生态中使用广泛的 schema 声明与运行时校验库。原版 Zod 通过链式 builder 描述字符串、数字、对象、数组、联合和转换,再由 parse 或 safeParse 对不可信输入进行验证。JavaScript 版本依赖动态值、异常对象和 TypeScript 的静态推导,而仓颉强调显式类型、集合类型和编译期接口检查。

直接把 TypeScript 源码逐行翻译成仓颉,无法保留 z.infer 这类编译器能力,也会把 JavaScript 的动态值边界隐藏起来。因此本项目重新设计了仓颉侧的 Value 数据模型和 Schema 运行时接口,保留 Zod 熟悉的 builder 风格,同时把缺失值、空值、错误路径和转换结果表达为仓颉可检查的类型。

1.2 跨端三方库的适配边界

当前项目提供的是纯仓颉跨端库,不绑定某一个 UI 框架、网络框架或操作系统。适配边界可以概括为:

边界 仓颉实现
包管理 cjpm.toml、cjpm build 和静态库输出
输入模型 Value 枚举表示 Missing、Null、Boolean、Number、Text、Items 和 Fields
schema Schema 基类及字符串、数字、对象、数组和组合 builder
错误模型 ParseResult、Issue、ZodError,保留嵌套字段和数组索引路径
转换能力 optional、nullable、defaultValue、prefault、catchValue、refine、transform、pipe 和 coerce
输出能力 解析后的 Value、错误详情和 Draft 2020-12 JSON Schema 片段
交付形态 仓颉静态库 libzod_cangjie.a 和接口文件 zod_cangjie.cjo
验证方式 test/main.cj 独立回归程序和 example/example.cj 终端示例

这里没有 TypeScript 类型推导、JavaScript 对象兼容层、异步解析或 HAP/ArkTS 桥接。当前版本面向可以使用仓颉标准库的跨端项目;目标平台只要提供兼容的仓颉 SDK 和 cjpm,就可以按同一套源码和配置构建并消费这个库。

1.3 适配后的能力

当前导出的仓颉 API 覆盖以下能力:

  • 基础 schema:Z.string、Z.number、Z.boolean、Z.nullValue、Z.undefined、Z.unknown、Z.never、Z.literal 和 Z.enumValues;
  • 字符串和数值约束:长度、正则、前后缀、包含、大小写转换、email、URL、UUID、ISO 日期、数值范围、整数、正负数和倍数;
  • 容器和组合:object、array、tuple、record、union、discriminated union、intersection 和 lazy 递归 schema;
  • 处理流程:optional、nullable、nullish、defaultValue、prefault、catchValue、refine、transform、pipe、preprocess 和三种 coerce;
  • 错误和输出:safeParse、parse、错误码、错误路径、联合分支错误、flatten 和 JSON Schema 生成。

二、实现路线图

text 复制代码
第 1 阶段:盘点 API       ── 对照 Zod schema 生命周期,确定仓颉值模型和适配边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射       ── 用 enum、class、Array、HashMap 和显式结果替换动态值
第 4 阶段:实现校验       ── 完成 primitive、容器、组合、转换、错误和 JSON Schema
第 5 阶段:测试与核对     ── 编译独立测试入口,覆盖成功、失败、边界和不可变性
第 6 阶段:终端演示       ── 输出真实输入、解析结果、错误路径、JSON Schema 和效果图

每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 .cjpm-target/zod_cangjie 中的静态库,验证阶段得到 Zod Cangjie: 282 regression checks passed,最终示例则输出 114 项来自真实 schema 调用的结果。

三、逐步实现过程

第 1 阶段:盘点原库 API 和仓颉工程边界

1.1 识别动态 API

Zod 的 JavaScript API 接受任意运行时值,schema builder 通过链式方法添加约束,解析器再返回深拷贝结果或错误对象。仓颉没有与 JavaScript unknown 完全等价的动态值容器,因此本适配先确定一个封闭但可递归的 Value 枚举:Missing 表示字段未提供,Null 表示显式空值,Boolean、Number、Text 表示基础值,Items 和 Fields 表示数组与对象。

所有 schema 都继承 Schema,解析器接收 Value 和 Array<PathPart>,返回 ParseResult。PathPart.Key 和 PathPart.Index 让错误能准确定位到 $["user"]["name"] 或 $[1]。这样动态输入仍然可以进入库,但每个边界都被仓颉类型显式表达。

1.2 固定仓颉项目结构

以下目录树按照当前 zod 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 Zod 的 TypeScript 源码、测试和其他目录保持原位置:

text 复制代码
zod/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│   ├── value.cj
│   ├── schema.cj
│   └── builders.cj
├── test/
│   └── main.cj
└── example/
    ├── cjpm.toml
    ├── cjpm.lock
    └── example.cj

源码按值模型、基础 schema 和 builder 组织。测试入口位于 test/main.cj,示例工程位于 example/,两者都独立于库源码。示例的 src-dir 是当前目录,但 target-dir 放在仓库外的 ../../.cjpm-target/zod_example,因此构建输出不会被当作源码再次扫描。

第 2 阶段:创建 cjpm 工程

根目录 cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:

toml 复制代码
[package]
name = "zod_cangjie"
version = "0.1.0"
cjc-version = "1.1.3"
description = "Native Cangjie runtime schema validation inspired by Zod"
src-dir = "src"
target-dir = "../.cjpm-target/zod_cangjie"
output-type = "static"

示例工程使用本地路径依赖,并把构建目录放在 example 外部:

toml 复制代码
[package]
name = "zod_example"
version = "0.1.0"
cjc-version = "1.1.3"
src-dir = "."
target-dir = "../../.cjpm-target/zod_example"
output-type = "executable"

[dependencies]
zod_cangjie = { path = ".." }

根包构建成功后,重要的库产物是:

text 复制代码
../.cjpm-target/zod_cangjie/release/zod_cangjie/libzod_cangjie.a
../.cjpm-target/zod_cangjie/release/zod_cangjie/zod_cangjie.cjo

其中 .a 用于链接,.cjo 提供编译器需要的接口信息。example 的构建产物写入仓库外的 .cjpm-target/zod_example,验证完成后不提交这些产物。

第 3 阶段:把 JavaScript 动态值映射为仓颉类型

3.1 Value、Schema 和 ParseResult

仓颉调用方使用辅助函数构造输入,schema 返回统一的结果:

cangjie 复制代码
let user = Z.object([
    ("name", Z.string().min(1)),
    ("age", Z.coerceNumber().integer().nonnegative()),
    ("role", Z.enumValues(["reader", "writer"]).defaultValue(text("reader")))
])

let result = user.safeParse(obj([("name", text("Ada")), ("age", text("36"))]))
if (result.success) {
    println(display(result.value()))
}

parse 在失败时抛出 ZodError;safeParse 返回 ParseResult,成功时提供 data,失败时提供 issues。解析结果中的对象和数组会深拷贝,调用方修改返回值不会反向修改输入。

3.2 用 HashMap<String, Value> 表达对象

JavaScript 对象可以同时包含字符串、数字、数组和嵌套对象,仓颉适配使用 HashMap<String, Value> 表达字段:

cangjie 复制代码
let input = obj([
    ("name", text("Ada")),
    ("tags", list([text("cangjie"), text("zod")]))
])
let schema = Z.object([
    ("name", Z.string()),
    ("tags", Z.string().array())
])
let output = schema.parse(input)
println(display(output))

object 默认移除未声明字段,strict() 会把它们报告为 unrecognized_keys,passthrough() 会保留它们,catchall() 则用指定 schema 验证它们。对象字段、数组下标和 record 键都会加入错误路径,便于终端输出直接说明失败位置。

3.3 用 Issue 和 ZodError 表示失败

每个 Issue 包含 code、message、path 和可选的 branches。联合类型失败时,branches 保留每个候选 schema 的错误;ZodError.flatten() 会把错误整理为 formErrors 和 fieldErrors:

cangjie 复制代码
try {
    schema.parse(obj([("name", text("")), ("tags", list([num(1)]))]))
} catch (error: ZodError) {
    println(error.message)
    println(display(error.flatten()))
}

这种错误模型对应 Zod 的使用习惯,同时避免依赖 JavaScript Error 的动态字段。内置 parser 产生结构化 issue,自定义 refine 可以提供业务消息,回调自身抛出的异常则继续向调用方传播。

第 4 阶段:实现源码

4.1 文件 src/value.cj

value.cj 定义 Value、PathPart、Issue、ParseResult 和 ZodError,并提供 display、deepClone、valuesEqual、get、asText、asNumber 等辅助函数。显示函数会对字符串控制字符进行转义,对数字保留足够精度,方便示例同时展示人类可读结果和真实值。

deepClone 递归复制 Items 和 Fields,默认值也会在每次解析时复制。对象字段的哈希顺序不作为行为契约,比较和测试通过结构相等而不是字符串顺序判断。

4.2 文件 src/schema.cj 和 src/builders.cj

schema.cj 提供基础 Schema、StringSchema、NumberSchema 和 ArraySchema,实现基本类型、字符串/数字约束、转换链、JSON Schema 元数据和数组元素错误路径。builders.cj 提供 object、tuple、record、union、discriminated union、intersection、lazy、literal、enum 以及三种 coerce。

builder 方法始终返回新 schema,原 schema 保持可复用。例如 let constrained = base.min(1) 不会改变 base;defaultValue 在缺失输入时直接返回默认值,prefault 则把默认输入继续送进校验和转换流程。递归结构通过 Z.lazy 延迟取得子 schema,避免初始化阶段的循环依赖。

4.3 JSON Schema 和格式边界

toJSONSchema() 为可表达的输入约束生成 Draft 2020-12 片段,覆盖基础类型、对象、数组、元组、record、枚举、联合、交叉、可选/可空字段、默认值和描述。重复约束使用 allOf 保留,strip 对象的额外字段策略输出为 additionalProperties。

任意 refine、transform、pipe、preprocess、coerce、递归 schema 和没有可靠元数据的自定义 schema 不会被静默转换为宽松 JSON Schema,而是明确抛出"cannot be represented faithfully"错误。这样生成结果不会让调用方误以为 JSON Schema 已经表达了运行时转换逻辑。

第 5 阶段:建立自动化测试

test/main.cj 是直接链接仓颉静态库的独立回归测试入口,覆盖以下行为:

测试组 示例
基础 schema string、number、boolean、null、undefined、unknown、never、literal、enum
格式与边界 Unicode 长度、正则、email、URL、UUID、闰年日期、UTC datetime、有限数值和安全整数
容器 object 字段策略、数组、tuple 可选尾项、tuple 默认尾项、record 和嵌套路径
组合 union 分支错误、discriminated union、intersection 和 lazy 递归
处理流程 optional、nullable、default、prefault、catch、refine、transform、pipe、preprocess、coerce
契约 builder 不可变、输入深拷贝、错误聚合、JSON Schema、非法参数和不支持转换

断言失败会抛出异常并以非零状态退出;全部通过时输出:

text 复制代码
Zod Cangjie: 282 regression checks passed

示例工程则负责展示库调用,测试程序负责密集验证,两者都使用同一个 zod_cangjie 包,因此展示结果不是只打印断言通过。

第 6 阶段:核对 Zod 适配 API

除回归测试外,还对示例中的每一类公开入口进行实际调用核对:基础类型、字符串和数字约束、对象策略、容器、组合 schema、缺失/空值处理、转换链、错误输出和 JSON Schema。示例共完成 114 项演示,每一项都比较预期结果;失败案例则检查错误码并打印错误路径。

text 复制代码
Completed 114 demonstrations with verified results.

这个检查说明当前文档覆盖的是仓颉适配已经实现的公开能力,不代表它覆盖 JavaScript Zod 的所有动态扩展、TypeScript 推导或异步能力。

四、完整验证步骤

以下命令均以仓库根目录 zod/ 为当前目录。先确认本机已经安装仓颉 SDK:

bash 复制代码
echo "$CANGJIE_HOME"
cjc --version
cjpm --version

然后构建静态库:

bash 复制代码
cjpm build

编译并运行独立测试入口:

bash 复制代码
cjc test/main.cj \
  --import-path ../.cjpm-target/zod_cangjie/release/zod_cangjie \
  -L ../.cjpm-target/zod_cangjie/release/zod_cangjie \
  -l zod_cangjie \
  --set-runtime-rpath \
  -o ../.cjpm-target/zod_tests
../.cjpm-target/zod_tests

--set-runtime-rpath 让 macOS 上的测试程序能够找到仓颉 SDK 运行库。最后构建并运行独立示例:

bash 复制代码
cd example
cjpm run

示例会直接调用库并打印实际输入、解析后的输出、错误路径和 JSON Schema,不会把预先写死的文本当成验证结果。结尾输出:

text 复制代码
Completed 114 demonstrations with verified results.
cjpm run finished

示例使用 target-dir = "../../.cjpm-target/zod_example",因此运行 cjpm run 时不会出现把构建输出目录当作源码扫描目录的警告。验证结束后确认 .cjpm-target 没有被 Git 跟踪。

五、运行效果

下面的效果图来自 example/example.cj 的实际终端运行,包含静态库构建后的 Zod Cangjie schema 输入、解析输出、错误路径、JSON Schema 和最终完成标记。

这张图可以作为文章或项目说明中的验收证据:终端同时展示了派生 schema 错误、深拷贝结果、ZodError、JSON Schema 和不可表示转换的明确错误。示例程序真实调用仓颉库并比较结果,因此效果图不是模拟页面或只显示断言通过。

六、关键决策说明

6.1 为什么使用显式 Value 和 Schema

仓颉适配的价值在于让调用方看到输入模型和解析结果的边界。若所有数据都退化成字符串或无结构容器,虽然表面上容易翻译 JavaScript,却会失去 null/missing 区分、嵌套错误路径和编译期 API 检查。因此项目使用 enum 表达值、class 表达 schema、HashMap 表达对象,并把错误拆成可读取的字段。

6.2 为什么保留 Zod builder 名称

保留 Z.object、Z.string、optional、nullable、defaultValue、safeParse 和 ZodError 等名称,可以降低熟悉 Zod 的开发者迁移成本。调用方需要适应的是仓颉的参数标签、Value 构造函数和显式异常,而不是重新学习完全不同的 schema 组织方式。无法表达的 TypeScript 推导和 JavaScript 特殊值,则通过 README 明确列出边界。

6.3 为什么把测试和示例分开

测试程序负责密集断言,适合自动化执行;示例工程负责展示,适合在终端中观察每个输入对应的真实输出和错误。两者都使用同一个 Zod 仓颉包,因此截图展示的是库的实际运行过程,测试则额外覆盖参数边界、深拷贝和 JSON Schema 失败情况。

七、兼容边界和遗留问题

JavaScript Zod 与仓颉的值模型和类型系统不同,以下限制需要在接入时提前了解:

  1. 当前版本没有 TypeScript z.infer、品牌类型、TypeScript 泛型推导和原版 npm 导出兼容;解析结果使用仓颉 Value。
  2. JavaScript 的 bigint、symbol、Date、Map、Set、Promise、函数、类实例、File 等特殊值没有对应的 Value 分支。
  3. 字符串长度按 Unicode 码点计算,不按 JavaScript UTF-16 code unit 计算;trim 和大小写转换采用 ASCII 语义;正则使用仓颉标准库引擎。
  4. 数值必须有限;integer() 限定在 JavaScript 安全整数范围;multipleOf 对浮点商使用 1e-12 误差容忍,不是任意精度十进制运算。
  5. url() 支持受限的 ASCII HTTP/HTTPS 形式,不等同于 JavaScript URL 解析器;ISO datetime 要求 UTC Z 和秒,offset/local 选项未实现。
  6. 数值强制转换采用仓颉浮点解析,空字符串和 null 转为零;字符串 "false" 的布尔强制结果是 true,因为转换采用真假值语义。
  7. 输入值必须无环;Z.lazy 支持递归 schema,但不支持循环引用的数据图。回调自身抛出的异常不会自动包装成校验 issue。
  8. async parse、codec、registry、多语言错误、AOT 编译、mini API 和其他未列入功能表的 Zod 辅助 API 尚未实现。

这些不是构建错误。只要 cjpm build 成功、测试程序输出回归通过标记、示例输出 114 项完成标记,就说明当前版本已经完成本地编译和已实现能力的行为验收。需要完整 JavaScript Zod 语义的项目,应继续使用原版 Zod;需要仓颉静态运行时校验的项目可以按当前边界接入。

八、接入方式

在另一个仓颉项目中,可以将本仓库作为本地依赖或发布包使用。最直接的本地验证方式是在调用方 cjpm.toml 中加入:

toml 复制代码
[dependencies]
zod_cangjie = { path = "/path/to/zod" }

调用代码只需要导入:

cangjie 复制代码
import zod_cangjie.*

main(): Int64 {
    let schema = Z.object([
        ("name", Z.string().min(1)),
        ("age", Z.coerceNumber().integer().nonnegative())
    ])
    let result = schema.safeParse(obj([
        ("name", text("Ada")),
        ("age", text("36"))
    ]))
    if (result.success) {
        println(display(result.value()))
    } else {
        for (issue in result.issues) { println(issue.toString()) }
    }
    return 0
}

如果使用 cjpm 管理依赖,则由业务工程统一构建;如果直接使用 cjc,需要把根包构建产物目录传给 --import-path 和 -L,并链接 -l zod_cangjie。跨端项目可以复用同一套 schema、错误处理和测试思路;具体的 UI、线程、网络和系统能力由宿主平台负责。

九、总结

本次适配完成了 Zod schema 模型到仓颉运行时模型的迁移:输入值使用 Value,schema 使用 Schema 及其 builder,字符串、数字、对象、数组和组合能力通过静态仓颉 API 暴露,缺失值和空值通过 optional/nullable 区分,错误通过 Issue/ZodError 表达,解析结果进行深拷贝,JSON Schema 对可表达约束进行明确转换。

验证链路包括 cjpm build、独立测试入口编译、282 项回归检查、114 项终端示例演示、错误路径输出、深拷贝验证和 JSON Schema 边界检查。最终效果图来自真实的仓颉可执行程序,能够直观看到 schema 校验成功值、失败原因、嵌套路径和生成结果。对于暂时无法一比一映射 TypeScript Zod 的类型推导、异步和 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 抓取运行日志;
  • 优先复现最小用例,确认是否为平台差异或时序问题。

十、参考资料

相关推荐
翔云1234561 小时前
Gaea(小米MySQL Proxy)整体架构
运维·数据库·mysql
程序员清风1 小时前
LibreChat 实战:用 Docker 搭建可扩展的多模型对话平台
运维·docker·容器
志栋智能1 小时前
安全超自动化如何提升防御体系可靠性?
运维·安全·自动化
叼烟扛炮1 小时前
Linux 学习笔记(三十二):线程控制——创建、等待与分离
linux·运维·服务器·笔记·学习·1024程序员节
xbzb1 小时前
服务器形态怎么选、BIOS 怎么进?从 1U 机架到固件启动项一次讲透
运维·服务器·bios·启动·固件·启动项
夜雪一千1 小时前
Linux 权限、用户、用户组内容介绍
linux·运维·服务器
代码方舟2 小时前
零信任架构实战:基于天远企业二要素验证构建自动化小微快贷法人资质核验网关
运维·人工智能·架构·自动化
拂拉氏2 小时前
【知识讲解】 Linux信号的认识与使用
linux·信号
Web3&Basketball2 小时前
从0到1落地AI Agent沙箱隔离Docker实战:踩坑全记录
运维·docker·容器·agent·容器安全·沙箱隔离