本文记录 form-data 4.0.6 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的跨端三方库,并在终端中用真实示例验证文本字段、二进制字段、文件字段、multipart 边界、请求头和请求体序列化等能力。
这类适配与 OpenHarmony 原生三方库不同:仓颉版本不依赖 ArkTS、HAP、设备传感器或系统权限,而是通过
cjpm组织源码、通过静态类型表达 multipart/form-data 的常用语义,再以静态库形式供其他仓颉项目复用。
项目地址: AtomGit/nutpi_cj/form-data
上游仓库: form-data/form-data.git
开发工具: 华为云码道
一、背景
1.1 为什么适配 form-data
form-data 是 Node.js 生态中常用的 multipart/form-data 构造库,用于组织表单字段、文件字段和上传请求体。JavaScript 版本依赖 Buffer、Readable Stream、文件流和 HTTP 请求对象,而仓颉强调静态类型、明确的字节数组和显式的跨平台边界。直接把 JavaScript 源码改成仓颉,难点不在逐行翻译语法,而在于确定二进制内容、文件元数据、边界字符串和请求头之间的稳定类型关系。
本项目将 form-data 的核心内存构造能力拆分为可维护的仓颉源码,保留 append、getHeaders、getBoundary、getBuffer 和 getLength 等熟悉的调用方向,方便熟悉 Node.js form-data 的开发者迁移表单组装代码。
1.2 跨端三方库的适配边界
当前项目提供的是仓颉跨端库,不绑定某一个 UI 框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和 target/release/form_data_cangjie |
| 文本字段 | String 转换为 UTF-8 Array<UInt8> |
| 二进制内容 | Array<UInt8>,序列化时保持原始字节 |
| 文件字段 | filename、contentType 和字节数组元数据 |
| multipart 边界 | 可设置的 String,默认使用稳定边界 |
| 交付形态 | 仓颉静态库 libform_data_cangjie.a 和接口文件 form_data_cangjie.cjo |
| 验证方式 | test/main.cj 直接链接测试和 example/example.cj 终端示例 |
这里没有 Node.js Readable Stream、HTTP ClientRequest、文件系统流和 HAP 构建。只要目标平台提供兼容版本的仓颉 SDK,就可以按同一套源码和 cjpm 配置构建并消费这个库;HTTP 传输和文件读取由宿主应用负责。
1.3 适配后的能力
当前仓颉实现围绕 form-data 的请求体构造流程提供以下能力:
- 字符串字段:
append(name, value)将文本按 UTF-8 字节写入表单; - 二进制字段:
appendBytes(name, value)接收任意Array<UInt8>; - 文件字段:
appendFile(name, filename, value, contentType:)输出文件名和内容类型; - 边界和请求头:
getBoundary、setBoundary、getHeaders; - 请求体信息:
getBuffer、getLength、toString和size; - 字段头部转义:文件名和字段名中的双引号、反斜杠会被转义,避免破坏
Content-Disposition头部格式。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 form-data 的字段、边界、请求头和序列化能力
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 String、Array<UInt8> 和明确的文件元数据替换动态值
第 4 阶段:分模块实现 ── 完成 FormPart、FormData 和 multipart 字节序列化
第 5 阶段:测试与核对 ── 编译测试入口,执行字段、文件、边界和长度断言
第 6 阶段:终端演示 ── 构建 example,直接输出真实请求头和请求体效果图
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 target/release/form_data_cangjie,验证阶段得到 ALL FORM-DATA CANGJIE TESTS PASS,最终示例则输出一段真实的 multipart/form-data 请求体。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
form-data 的 JavaScript API 可以接收字符串、Buffer、文件流、HTTP 响应流和自定义可读流。仓颉没有与 JavaScript Any、Node.js Stream 完全等价的跨端容器,因此本适配先筛选可以稳定表达的内存能力:字符串字段使用 String,二进制字段使用 Array<UInt8>,文件字段将文件名和 MIME 类型作为显式字符串元数据。
1.2 固定仓颉项目结构
以下目录树按照本地 form-data 项目的仓颉适配文件整理,只展示仓颉相关的文件;原始 form-data JavaScript 源码、测试和构建目录保持原位置,不在此处重复列出:
text
form-data/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ └── form_data.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码文件 src/form_data.cj 放置 FormPart 和 FormData 的实现。测试入口位于 test/main.cj,示例工程位于 example/,两者都独立于库源码。示例使用 form_data_cangjie = { path = ".." } 引用本地包,避免依赖尚未同步仓颉目录的远程旧提交。这个目录树与当前本地项目中的仓颉文件保持一致。
第 2 阶段:创建 cjpm 工程
cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:
toml
[package]
cjc-version = "1.1.3"
name = "form_data_cangjie"
version = "4.0.6"
output-type = "static"
src-dir = "src"
target-dir = "target"
包版本沿用当前适配版本,包名使用合法的仓颉标识符 form_data_cangjie,因此业务工程可以通过 cjpm 依赖或本地路径引用这个包。构建成功后,最重要的两个产物是:
text
target/release/form_data_cangjie/libform_data_cangjie.a
target/release/form_data_cangjie/form_data_cangjie.cjo
其中 .a 用于链接,.cjo 提供编译器需要的接口信息。示例工程使用本地路径依赖时,cjpm update 会读取根目录的 cjpm.toml,不需要从远程仓库猜测仓颉包是否存在。
第 3 阶段:把 JavaScript 动态值映射为仓颉类型
3.1 用 Array<UInt8> 表示请求体
JavaScript 版本中的 Buffer 和流最终都会写入二进制请求体。仓颉实现将这个核心边界固定为 Array<UInt8>,文本字段通过 String.toArray() 转换为 UTF-8 字节,二进制字段则原样追加:
cangjie
let form = FormData()
form.append("message", "hello")
form.appendBytes("payload", [0u8, 1u8, 255u8])
let body: Array<UInt8> = form.getBuffer()
这样序列化过程不会把文件内容误当作普通字符串,也不要求调用方引入某一个平台专属的文件或网络类型。
3.2 用 FormPart 表示字段元数据
每个字段由 FormPart 保存,包含字段名、主体字节、文件名和内容类型。普通字段的文件名和内容类型为空字符串;文件字段通过 appendFile 显式设置它们:
cangjie
form.appendFile(
"document",
"hello.txt",
"Cangjie multipart example".toArray(),
contentType: "text/plain"
)
这种表示方式把 JavaScript 中通过 options 对象传入的动态元数据收敛为固定字段,调用方可以在编译期检查参数类型。
3.3 用请求头和长度方法替换动态流状态
JavaScript form-data 可以在流消费过程中计算长度。当前仓颉版本在内存中保留全部字段,因此 getHeaders 可以直接返回 Content-Type 和 Content-Length,getLength 与 getBuffer().size 保持一致:
cangjie
let headers = form.getHeaders()
println(headers["Content-Type"])
println(headers["Content-Length"])
这种设计适合需要先准备请求体再交给 HTTP 客户端的跨端场景,具体的发送动作仍由宿主应用的网络 API 完成。
第 4 阶段:分层实现源码
4.1 核心类型 FormPart
FormPart 是不可变的结构体,负责保存一个 multipart 部分的最小信息。字段主体使用 Array<UInt8>,因此可以同时承载文本、图片、压缩包或其他二进制内容。构造器的可选命名参数用于表达文件名和内容类型,不需要引入无类型 options 对象。
4.2 核心类 FormData
FormData 管理字段列表和边界字符串。默认构造器使用稳定的 ----CangjieFormDataBoundary,调用方也可以传入自己的边界或通过 setBoundary 修改边界。append、appendBytes 和 appendFile 分别覆盖文本、二进制和文件字段:
cangjie
let form = FormData(boundary: "demo-boundary")
form.append("message", "hello from Cangjie")
form.appendFile("document", "hello.txt", [72u8, 105u8], contentType: "text/plain")
4.3 multipart 序列化
getBuffer 按 multipart 规范依次写入边界、Content-Disposition、可选的 Content-Type、空行、字段字节和结束边界。所有头部文本使用 CRLF,最后追加 --boundary--\r\n。toString 只适合调试或文本字段展示;对于任意二进制内容,应优先使用 getBuffer。
第 5 阶段:建立自动化测试
test/main.cj 是一个直接链接仓颉静态库的可执行程序。测试覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 字段管理 | append、appendBytes、appendFile 和 size |
| 边界 | 自定义边界、起始边界和关闭边界 |
| 文件元数据 | filename 和 Content-Type 头部 |
| 字节内容 | UTF-8 文本和 [72u8, 105u8] 二进制字段 |
| 请求头 | Content-Type 和 Content-Length |
| 序列化 | CRLF、字段正文和结尾边界 |
断言失败会抛出异常并以非零状态退出;全部通过时输出固定标记,便于脚本或 CI 判断:
text
ALL FORM-DATA CANGJIE TESTS PASS
第 6 阶段:核对示例工程
示例工程位于 example/,通过本地路径依赖引用根包。example/example.cj 真实创建 FormData,添加文本和文件字段,打印请求头、请求长度和完整请求体。它不把输出写死为一组演示文本,因此终端结果可以直接作为适配验收依据。
四、完整验证步骤
以下命令均以仓库根目录 form-data/ 为当前目录。先加载已经安装在本机的仓颉 SDK:
bash
source ~/.zshrc
echo "$CANGJIE_HOME"
cjc --version
cjpm --version
然后检查并构建静态库:
bash
cjpm check
cjpm build
编译并运行测试入口:
bash
cjc test/main.cj \
--import-path target/release/form_data_cangjie \
-L target/release/form_data_cangjie \
-lform_data_cangjie \
-o /tmp/form-data-cangjie-test
DYLD_LIBRARY_PATH="$CANGJIE_HOME/runtime/lib/darwin_aarch64_cjnative" /tmp/form-data-cangjie-test
再更新、构建并运行独立的终端示例工程:
bash
cd example
cjpm update
cjpm build
cjpm run
示例使用本地 path = ".." 依赖,因此不会访问未包含仓颉目录的旧远程提交。程序会直接调用库并打印结果:
text
Content-Type: multipart/form-data; boundary=demo-boundary
Content-Length: 249
--demo-boundary
Content-Disposition: form-data; name="message"
hello from Cangjie
--demo-boundary
Content-Disposition: form-data; name="document"; filename="hello.txt"
Content-Type: text/plain
Cangjie multipart example
--demo-boundary--
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含 cjpm update、静态库构建、示例运行、请求头、请求长度和完整 multipart 请求体。

这张图可以作为文章或项目说明中的验收证据:终端同时展示了 Content-Type、Content-Length、文本字段、文件名、文件内容类型和结束边界,最后由程序输出 cjpm run finished。
六、关键决策说明
6.1 为什么不用无类型 Any
仓颉适配的价值在于让调用方在编译期知道数据形状。若所有字段都退化成无类型值,虽然表面上接近 JavaScript,却会失去字节数组、文件元数据和请求头值的类型检查。因此项目使用 String 表达文本,使用 Array<UInt8> 表达二进制,并使用 FormPart 固定文件字段形状。
6.2 为什么保留 form-data 的方法方向
保留 append、getHeaders、getBoundary、getBuffer 和 getLength 等名称可以降低迁移成本。熟悉 Node.js form-data 的开发者只需要适应仓颉的参数标签、字节数组和静态对象类型,不必重新记忆一套完全不同的表单构造 API。对无法直接表达的流和 HTTP 方法,则在文档中明确边界,而不是伪造一个不完整的跨端实现。
6.3 为什么把测试和示例分开
测试程序负责断言,适合自动化执行;示例工程负责展示,适合在终端中观察真实请求体和截图。两者都使用同一个 form_data_cangjie 仓颉静态库,因此效果图不是模拟页面,而是库的实际运行输出。
七、兼容边界和遗留问题
JavaScript form-data 与仓颉的运行时模型不同,以下限制需要在接入时提前了解:
- 当前实现只在内存中构造 multipart 请求体,Node.js Readable Stream、HTTP response stream 和自定义流没有直接的仓颉静态等价物;需要流式上传时应由宿主应用提供流适配层。
appendFile接收文件名、内容类型和已读入的字节数组,不负责访问文件系统;调用方需要先用目标平台 API 读取文件内容。toString将完整请求体按 UTF-8 解码,只适合文本字段和调试输出;包含任意二进制数据时应使用getBuffer。getLength依赖所有字段已经保存在内存中,因此没有 Node.js 流场景中的未知长度状态,也没有getLength(callback)异步查询入口。submit、pipe和 Node.jshttp.ClientRequest没有仓颉标准库中的直接等价物,网络请求需要调用方按平台 HTTP 客户端 API 设置请求头并发送getBuffer()。- 默认边界是稳定字符串,适合示例、测试和可重复构建;生产网络请求如需避免边界碰撞,应由调用方生成并传给
FormData(boundary:)。
这些不是构建错误。仓颉版本明确选择了可移植的内存构造边界,构建成功、测试程序输出通过标记、示例程序输出完整 multipart 内容,就说明当前版本已经完成本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖或发布包使用。最直接的本地验证方式是先在本仓库根目录生成 target/release/form_data_cangjie,再把相同的 --import-path 和 -L 参数传给调用方的 cjc 命令。调用代码只需要导入:
cangjie
import form_data_cangjie.*
main(): Int64 {
let form = FormData()
form.append("message", "hello")
form.appendFile("file", "hello.txt", "content".toArray(), contentType: "text/plain")
println(form.getHeaders()["Content-Type"])
println(form.getLength())
return 0
}
如果使用 cjpm 管理依赖,则将仓颉包加入项目依赖配置;示例工程采用 form_data_cangjie = { path = ".." },发布到远程仓库后也可以替换为带版本的 Git 依赖。业务工程负责把 getBuffer() 交给目标平台的 HTTP 客户端,并根据 getHeaders() 设置请求头。跨端项目可以复用相同的字段组装 API,具体的文件、线程、网络和 UI 适配由宿主平台负责。
九、总结
本次适配完成了 form-data 到仓颉静态类型模型的迁移:源码使用 FormPart 保存字段元数据,使用 String 和 Array<UInt8> 表达文本与二进制内容,使用 FormData 统一管理边界、请求头和 multipart 序列化。
验证链路包括 cjpm check、cjpm build、测试入口独立编译运行和本地依赖示例工程运行。最终效果图来自真实的仓颉可执行程序,能够直观看到三方库在文本字段、文件字段、内容类型、请求长度和边界序列化场景中的输出。对于无法一比一映射 Node.js 流、文件系统和 HTTP 请求对象的部分,项目通过静态签名和文档边界保持行为可解释,方便后续继续扩展。
适配过程 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抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。