【仓颉语言入门 · 第23课】
【仓颉语言入门 · 第23课】JSON 处理:程序与世界交换数据的通用语言(结合 stdx 扩展库)
第 22 课我们把订单存成了
NO.1001|1250|已支付这样的管道符文本——自己用没问题,但只要数据里多个字段、缺一个字段、或者要和网页/别的程序交换,这种自制格式立刻崩溃。JSON 是全世界程序交换数据的事实标准:浏览器、后端接口、配置文件、GeoJSON 地图数据……全是它。本课带你掌握仓颉的 JSON 工具库 stdx:手工构建 JSON、解析读取、以及通过Serializable<T>让你自己的类一键双向转换,最后把学生名册落成 JSON 文件,重启程序还能读回来。本文所有代码与报错文案均在仓颉 SDK 1.2.0 + stdx 1.2.0-beta.02.1 下逐行实测编译运行。
目录(系列导航)
整套路线共 7 个模块、30 课:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、环境与入门 | 01~05 | 环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环 |
| 二、常用类型与数据组织 | 06~10 | 字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理 |
| 三、函数与函数式 | 11~14 | 函数、Lambda 与高阶函数、闭包、迭代器与惰性序列 |
| 四、面向对象与类型系统 | 15~20 | struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展 |
| 五、工程化与标准库 | 21~25 | cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试 |
| 六、并发编程 | 26~28 | 线程、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
- 环境搭建与第一个仓颉程序
- 变量与常量:let / var 与基本数据类型
- 运算符与标准输入输出
- 分支结构:if 与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织
- 文件与目录 IO
- JSON 处理(结合 stdx 扩展库)(本文)
- 网络编程入门
- 单元测试
- 并发基础:线程的创建与等待
- Channel 通道与同步原语
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具
- 实战二:GeoJSON 数据处理程序
一、为什么是 JSON
先看一段你眼熟的文本(第 22 课的订单文件):
NO.1001|1250|已支付
它有三个绕不开的麻烦:
- 字段一多就数不清——要是订单再带"买家、商品列表、收货地址",光看
a|b|c|d|e谁知道每个位置是什么; - 没法表达层级和列表——"一个订单含多个商品"这种嵌套关系,纯文本只能再发明一套约定;
- 只在本程序内有效——把它发给网页前端或别的语言写的服务,对方根本不懂。
JSON 用对象 {}、数组 [] 和少量基本类型,把这些问题一次解决:
{
"id": "NO.1001",
"amount": 1250,
"paid": true,
"items": [
{ "name": "仓颉教程", "qty": 1 },
{ "name": "键盘", "qty": 2 }
]
}
字段名自带说明、数组天然表达"多个"、嵌套想多深多深,而且几乎所有语言和工具都认识它。
仓颉的 JSON 能力不在标准库 std 里,而在官方扩展库 stdx 中,主要用到两个包:
| 包 | 作用 |
|---|---|
stdx.encoding.json | 手动构建/解析 JSON:JsonValue 家族、JsonObject、JsonArray |
stdx.serialization.serialization | 让自定义类与 JSON 自动互转:Serializable<T>、DataModel |
二、准备 stdx 扩展库
std 随 SDK 开箱即用,stdx 则要单独下载一次(HTTP、加密、压缩等也都在 stdx 里,配好一次以后都能用)。
2.1 下载与解压
到仓颉编程语言官网下载页,找到扩展库板块,下载 Windows 版 cangjie-stdx-windows-x64-<版本>.zip(本文实测版本文件名为 cangjie-stdx-windows-x64-1.2.0-beta.02.1.zip)。
解压后的目录里按平台分好了子目录,Windows 原生编译对应 windows_x86_64_cjnative,其中:
dynamic/stdx/:动态链接版本(一堆.cjo+.dll),本课用它;static/、static-static-link-extern/:静态链接版本,发布单文件程序时再研究。
把 dynamic/stdx 这个文件夹复制到一个固定位置,例如:
D:\cangjie-libs\stdx
文件夹内应能直接看到 stdx.encoding.json.cjo、libstdx.encoding.json.dll 等文件。
2.2 在 cjpm.toml 里登记路径
新建工程(CIDE 里"新建项目"或命令行 cjpm init --type executable),在生成的 cjpm.toml 末尾追加目标平台配置:
[target.x86_64-w64-mingw32]
[target.x86_64-w64-mingw32.bin-dependencies]
path-option = ["D:\\cangjie-libs\\stdx"]
注意三点:
- TOML 字符串里反斜杠要写两个(
\\),也可以直接写正斜杠D:/cangjie-libs/stdx; - 这是二进制依赖(直接用编译好的库),不需要在
[dependencies]里再写包名,也不用执行cjpm update; - 路径换成你自己的实际解压位置。
2.3 写第一行 JSON 代码
src/main.cj:
package jsontest
import stdx.encoding.json.*
main(): Int64 {
let v = JsonObject()
v.put("message", JsonString("你好,JSON"))
println(v)
return 0
}
在 CIDE 中运行(或在工程目录执行 cjpm run),输出:
{"message":"你好,JSON"}
看到这行,说明 stdx 配置成功。
⚠️ 动态库的运行方式:用动态版 stdx 时,程序运行需要旁边的 stdx DLL。在 CIDE 里运行或用
cjpm run会自动处理好;但直接双击target目录下编译出的main.exe,可能因找不到 DLL 而启动失败(Windows 退出码0xC0000135)。学习阶段统一用 CIDE /cjpm run启动即可。
三、JSON 的七种值:JsonValue 家族
JSON 规范里的值只有七种,仓颉为每种各准备了一个类,它们有一个共同的父类型 JsonValue:
| JSON 写法 | 仓颉类 | 构造方式 | 打印结果 |
|---|---|---|---|
null | JsonNull | JsonNull() | null |
true / false | JsonBool | JsonBool(true) | true |
123(整数) | JsonInt | JsonInt(123) | 123 |
3.14(小数) | JsonFloat | JsonFloat(3.14) | 3.140000 |
"hi"(字符串) | JsonString | JsonString("hi") | "hi"(带引号) |
{...}(对象) | JsonObject | JsonObject() | {...} |
[...](数组) | JsonArray | JsonArray() | [...] |
把五种标量值都打出来看看:
package jsontest
import stdx.encoding.json.*
main(): Int64 {
let vals: Array<JsonValue> = [
JsonNull(),
JsonBool(true),
JsonInt(123),
JsonFloat(3.14),
JsonString("hello 仓颉")
]
for (v in vals) {
println(v)
}
return 0
}
实测输出:
null
true
123
3.140000
"hello 仓颉"
两个细节:
println能直接打印JsonValue——它实现了ToString,输出的是合法 JSON 文本(紧凑格式,字符串带引号、无多余空格);JsonFloat承载的是Float64,所以按浮点规则打印 6 位小数(3.140000);它写进 JSON 后长什么样、整值小数(如88.0)如何呈现,第七节会专门看到。
对象和数组稍复杂,单独用下一节讲透。
四、动手构建 JSON
4.1 JsonObject:put 进去就行
空对象用 JsonObject() 创建,put(key, value) 逐个加字段:
let student = JsonObject()
student.put("name", JsonString("小明"))
student.put("age", JsonInt(18))
student.put("vip", JsonBool(true))
println(student)
println("字段数 = ${student.size()}")
println("有 name 吗 = ${student.containsKey("name")}")
// put 同名 key 会覆盖旧值
student.put("age", JsonInt(19))
println("改年龄后 = ${student}")
实测输出:
{"name":"小明","age":18,"vip":true}
字段数 = 3
有 name 吗 = true
改年龄后 = {"name":"小明","age":19,"vip":true}
JsonObject 的常用成员:
| 成员 | 返回 | 说明 |
|---|---|---|
put(key, v) | Unit | 加字段;key 已存在则覆盖 |
get(key) | Option<JsonValue> | 取字段,没有这个 key 返回 None |
containsKey(key) | Bool | 判断 key 是否存在 |
size() | Int64 | 字段个数 |
getFields() | HashMap<String, JsonValue> | 拿到底层 HashMap 遍历 |
obj[key] | JsonValue | 下标取值;key 不存在直接抛异常 |
📌
JsonObject底层是HashMap,字段排列顺序不受保证(本节示例的输出恰好与插入顺序一致,但不要在程序里依赖它);需要稳定顺序时用第七节的序列化框架(内部按列表保序)。🚫 1.2.0 实测
JsonObject没有remove/delete方法(编译报'remove' is not a member of class 'JsonObject')。要删字段,可以拿getFields()操作底层 HashMap,或构建一个新对象只放需要的字段。
4.2 JsonArray:add 追加,按下标取
let tags = JsonArray()
tags.add(JsonString("学生"))
tags.add(JsonString("会员"))
println(tags)
println("元素数 = ${tags.size()}")
输出:
["学生","会员"]
元素数 = 2
JsonArray 的常用成员:
| 成员 | 返回 | 说明 |
|---|---|---|
add(v) | Unit | 末尾追加 |
get(i) | Option<JsonValue> | 安全取下标,越界返回 None |
arr[i] | JsonValue | 下标直接取 |
size() | Int64 | 元素个数 |
getItems() | ArrayList<JsonValue> | 取出全部元素,方便 for 遍历 |
4.3 嵌套:对象里放数组、放对象
JsonValue 之间可以任意组合:
let tags = JsonArray()
tags.add(JsonString("学生"))
tags.add(JsonString("会员"))
student.put("tags", tags)
println(student)
输出:
{"name":"小明","age":19,"vip":true,"tags":["学生","会员"]}
构造时也可以直接把数组/对象的构造表达式塞进 put,层级多了照此层层嵌套即可。
4.4 紧凑与美化:toString() 与 toJsonString()
toString()(println用的就是它):紧凑格式,适合存文件、发网络;toJsonString():带缩进换行的美化格式,适合给人看。
println(student.toJsonString())
实测输出(默认每层缩进两个空格):
{
"name": "小明",
"age": 19,
"vip": true,
"tags": [
"学生",
"会员"
]
}
还可以用带命名参数的版本自定义缩进:toJsonString(depth, bracketInNewLine!: Bool = false, indent!: String = " ")。比如只要 4 空格缩进:
println(student.toJsonString(0, indent: " "))
indent 只允许空格和制表符,depth 不能为负,否则抛 IllegalArgumentException。
五、解析 JSON 文本
5.1 三步:fromStr → asObject → 逐个 get
解析的入口是静态方法 JsonValue.fromStr(文本),然后用 asObject() / asArray() 告诉它根节点是什么:
package jsontest
import stdx.encoding.json.*
main(): Int64 {
let text = """
{
"name": "小明",
"age": 18,
"tags": ["学生", "会员"]
}
"""
let root = JsonValue.fromStr(text).asObject()
match (root.get("name")) {
case Some(v) => println("name = ${v.asString().getValue()}")
case None => println("没有 name 字段")
}
match (root.get("phone")) {
case Some(v) => println("phone = ${v.asString().getValue()}")
case None => println("没有 phone 字段")
}
let tags = root.get("tags").getOrThrow().asArray()
for (i in 0..tags.size()) {
println("tags[${i}] = ${tags[i].asString().getValue()}")
}
for (t in tags.getItems()) {
print("${t.asString().getValue()} ")
}
println("")
return 0
}
实测输出:
name = 小明
没有 phone 字段
tags[0] = 学生
tags[1] = 会员
学生 会员
⚠️ 仓颉三引号原始字符串的规则是:开头的
"""后必须直接换行,字符串直到下一行的"""结束,结束前的换行会保留、各行公共缩进由解析处理。单行 JSON 直接用普通字符串转义即可:"{\"name\":\"小明\"}"。
5.2 取值链路长什么样
从 JSON 里取一个内层标量,通常是这条链:
root.get("age") Option<JsonValue>,处理"键可能不存在"
.getOrThrow() JsonValue
.asInt() JsonInt,处理"类型可能不对"
.getValue() Int64
对应的"还原"方法按类型选:
| 目标类型 | 方法 | 再取值 |
|---|---|---|
| 字符串 | asString() | .getValue() 得 String |
| 整数 | asInt() | .getValue() 得 Int64 |
| 小数 | asFloat() | .getValue() 得 Float64 |
| 布尔 | asBool() | .getValue() 得 Bool |
| 对象 | asObject() | 得 JsonObject |
| 数组 | asArray() | 得 JsonArray |
| null | asNull() | 得 JsonNull |
5.3 优先用 match 处理 Option,而不是 getOrThrow
get() 和 JsonArray.get() 返回的都是 Option(第 9 课)。外部来的 JSON 什么字段都可能缺,最稳的写法是 match:
match (root.get("name")) {
case Some(v) => println(v.asString().getValue())
case None => println("字段缺失,给个默认处理")
}
只有在你确信字段必然存在时才用 .getOrThrow()。实测:对 None 调 getOrThrow() 会抛 NoneValueException,而且它的 message 是空字符串,排查时不太友好——所以重要数据入口建议走 match。
六、类型判断、null 与四类常见异常
6.1 用 kind() 判断节点类型
解析外部数据时,某个字段可能这次是字符串、下次是数字。JsonValue 提供 kind() 返回枚举 JsonKind,七个构造器一一对应:
let vals: Array<JsonValue> = [
JsonNull(), JsonBool(true), JsonInt(1), JsonFloat(1.0),
JsonString("a"), JsonArray(), JsonObject()
]
for (v in vals) {
let kn = match (v.kind()) {
case JsNull => "null"
case JsBool => "bool"
case JsInt => "int"
case JsFloat => "float"
case JsString => "string"
case JsArray => "array"
case JsObject => "object"
}
println("${v} -> ${kn}")
}
实测输出:
null -> null
true -> bool
1 -> int
1.000000 -> float
"a" -> string
[] -> array
{} -> object
注意 JsonKind 枚举本身没有实现 ToString,不能直接 "${v.kind()}" 插值,必须像上面这样用 match 转成文字(七个构造器全列上即穷尽,无需 case _)。
判断 null 还有更直接的写法——类型判断 is(第 17 课 6.2 节系统讲过,这里先照用):
let remark = root.get("remark").getOrThrow()
if (remark is JsonNull) {
println("remark 是 null")
}
6.2 三类常见异常(均已实测,建议 try-catch 或提前规避)
| 场景 | 触发方式 | 实测异常 / 文案 |
|---|---|---|
| JSON 文本不合法 | JsonValue.fromStr("{name: 小明}") | JsonException:The json data is Non-standard, please check: |
| 类型取错(字符串当整数) | jsonStr.asInt() | JsonException:Fail to convert to JsonInt |
| 下标取不存在的 key | obj["missing"] | JsonException:The Value of JsonObject does not exist |
| Option 为空还硬取 | root.get("x").getOrThrow() 且 x 不存在 | NoneValueException(message 为空) |
另外实测两个数值转换行为:
- 整数节点可以当小数读:
JsonInt(18).asFloat().getValue()得到18.000000; - 小数节点不能当整数读:
JsonFloat(95.5).asInt()抛Fail to convert to JsonInt(它不帮你截断取整,想要取整自己先取Float64再转)。
字符串中的引号、换行等会自动正确转义与还原,科学计数法也能解析:"1e3" 读为 1000.000000、"2.5E-2" 读为 0.025000。
七、对象 ↔ JSON:Serializable 序列化框架
手工 put/asString 适合结构不定的 JSON;但读写"我们自己定义的类"时,逐字段手写太啰嗦。stdx 提供了 Serializable<T> 接口配合中间类型 DataModel,让类与 JSON 双向自动转换。
转换链路是:
对象 ──serialize()──▶ DataModel ──toJson()──▶ JsonValue ──toString()──▶ JSON 文本
JSON 文本 ──fromStr()──▶ JsonValue ──DataModel.fromJson()──▶ DataModel ──deserialize()──▶ 对象
7.1 让类实现 Serializable
先在一个文件里看完全貌(在前面 jsontest 工程中把 main.cj 整体替换成下面内容即可运行;第八节再把它拆成多文件工程):
package jsontest
import stdx.encoding.json.*
import stdx.serialization.serialization.*
import std.collection.ArrayList
public class Student <: Serializable<Student> {
public let name: String
public let age: Int64
public let score: Float64
public let vip: Bool
public let tags: Array<String>
public init(name: String, age: Int64, score: Float64, vip: Bool, tags: Array<String>) {
this.name = name
this.age = age
this.score = score
this.vip = vip
this.tags = tags
}
public func serialize(): DataModel {
// Array 没有内置 Serializable 实现,手工包成 DataModelSeq
let seq = DataModelSeq()
for (t in this.tags) {
seq.add(DataModelString(t))
}
return DataModelStruct()
.add(field<String>("name", this.name))
.add(field<Int64>("age", this.age))
.add(field<Float64>("score", this.score))
.add(field<Bool>("vip", this.vip))
.add(Field("tags", seq))
}
public static func deserialize(dm: DataModel): Student {
let dms = match (dm) {
case d: DataModelStruct => d
case _ => throw Exception("Student 数据不是 JSON 对象")
}
let tagList = ArrayList<String>()
match (dms.get("tags")) {
case seq: DataModelSeq =>
for (item in seq.getItems()) {
tagList.add(String.deserialize(item))
}
case _ => ()
}
return Student(
String.deserialize(dms.get("name")),
Int64.deserialize(dms.get("age")),
Float64.deserialize(dms.get("score")),
Bool.deserialize(dms.get("vip")),
tagList.toArray()
)
}
}
main(): Int64 {
let s = Student("小明", 18, 95.5, true, ["学生", "会员"])
// 对象 -> JSON 文本
let jv = s.serialize().toJson()
println("紧凑: ${jv.toString()}")
// JSON 文本 -> 对象
let text = jv.toString()
let s2 = Student.deserialize(DataModel.fromJson(JsonValue.fromStr(text)))
println("反序列化: ${s2.name}, ${s2.age}, ${s2.score}, ${s2.vip}, ${s2.tags}")
return 0
}
实测输出:
紧凑: {"name":"小明","age":18,"score":95.5,"vip":true,"tags":["学生","会员"]}
反序列化: 小明, 18, 95.500000, true, [学生, 会员]
逐块解释:
class Student <: Serializable<Student>:实现序列化接口,泛型参数填自己(第 19 课讲过泛型,先照写);serialize():把对象摊平成一个DataModelStruct,基础类型字段用field<T>("键", 值)逐个.add(...);- 数组要手工处理:1.2.0 实测直接写
field<Array<String>>("tags", tags)能编译通过,但运行时崩溃(extensionData is nullptr ... Serializable<Array<String>>)——Array没有内置序列化实现。正确做法是建一个DataModelSeq,把元素逐个DataModelString(t)加进去,再用Field("tags", seq)放进结构体; deserialize()是静态函数:先match确认拿到的是DataModelStruct,再用String.deserialize(...)、Int64.deserialize(...)等逐字段取回;数组同样匹配出DataModelSeq遍历还原。
📌
DataModel的具体子类与 JSON 一一对应:DataModelStruct(对象)、DataModelSeq(数组)、DataModelString、DataModelInt、DataModelFloat、DataModelBool、DataModelNull。DataModelStruct.get(key)找不到键时返回的是DataModelNull(不抛异常),但随后Int64.deserialize(DataModelNull)会抛This data is not DataModelInt.——所以读来源不可靠的 JSON 时,可选字段要先匹配类型再取。
7.2 两个细节:字段顺序与小数呈现
- 字段顺序与
serialize()里add的顺序完全一致(DataModelStruct内部是有序列表),这也是推荐用序列化框架而不是手拼HashMap的原因之一; - 分数在 JSON 文本里是
95.5(不是9.500000那种 6 位格式);读回仓颉后它仍是Float64,插值打印时恢复 6 位小数显示。整值小数同理:88.0写进 JSON 是88,读回时asFloat()会把整数节点自动当浮点读(第六节验证过),数值无损。
美化输出对序列化结果同样适用:把 jv.toString() 换成 jv.asObject().toJsonString() 即可得到缩进版文本(下一节保存文件时就会用到)。
八、CIDE 实操:学生名册 JSON 持久化
把第 22 课"文件 IO"和本课"JSON"合起来:学生列表启动时从 data/roster.json 读取,不存在则造一份种子数据并保存;以后修改数据保存回文件,重启仍在。
8.1 工程结构
新建工程 roster,配好第二节的 stdx 路径,按第 21 课的多文件组织成三个源文件:
roster/
├── cjpm.toml
└── src/
├── main.cj # 主流程
├── models/
│ └── Student.cj # Student + Serializable
└── storage/
└── RosterStore.cj # JSON 文件读写
src/models/Student.cj(与 7.1 逻辑相同,区别只有三处:包名换成子包、tags 改成带默认值的命名参数、不需要 json 包的 import):
package roster.models
import stdx.serialization.serialization.*
import std.collection.ArrayList
public class Student <: Serializable<Student> {
public let name: String
public let age: Int64
public let score: Float64
public let vip: Bool
public let tags: Array<String>
public init(name: String, age: Int64, score: Float64, vip: Bool, tags!: Array<String> = []) {
this.name = name
this.age = age
this.score = score
this.vip = vip
this.tags = tags
}
public func serialize(): DataModel {
let seq = DataModelSeq()
for (t in this.tags) {
seq.add(DataModelString(t))
}
return DataModelStruct()
.add(field<String>("name", this.name))
.add(field<Int64>("age", this.age))
.add(field<Float64>("score", this.score))
.add(field<Bool>("vip", this.vip))
.add(Field("tags", seq))
}
public static func deserialize(dm: DataModel): Student {
let dms = match (dm) {
case d: DataModelStruct => d
case _ => throw Exception("Student 数据不是 JSON 对象")
}
let tagList = ArrayList<String>()
match (dms.get("tags")) {
case seq: DataModelSeq =>
for (item in seq.getItems()) {
tagList.add(String.deserialize(item))
}
case _ => ()
}
return Student(
String.deserialize(dms.get("name")),
Int64.deserialize(dms.get("age")),
Float64.deserialize(dms.get("score")),
Bool.deserialize(dms.get("vip")),
tags: tagList.toArray()
)
}
}
src/storage/RosterStore.cj:
package roster.storage
import std.fs.*
import stdx.encoding.json.*
import stdx.serialization.serialization.*
import std.collection.ArrayList
import roster.models.Student
// 把学生列表写成带缩进的 JSON 文件
public func saveRoster(students: ArrayList<Student>, path: Path): Unit {
let seq = DataModelSeq()
for (s in students) {
seq.add(s.serialize())
}
let text = seq.toJson().toJsonString()
File.writeTo(path, text.toArray())
}
// 从 JSON 文件读出学生列表;文件不存在时返回空列表
public func loadRoster(path: Path): ArrayList<Student> {
let result = ArrayList<Student>()
if (!exists(path)) {
return result
}
let content = String.fromUtf8(File.readFrom(path))
let dm = DataModel.fromJson(JsonValue.fromStr(content))
match (dm) {
case seq: DataModelSeq =>
for (item in seq.getItems()) {
result.add(Student.deserialize(item))
}
case _ => throw Exception("名册文件的根节点不是数组")
}
return result
}
要点:列表整体就是一个 DataModelSeq,写的时候 toJsonString() 直接得到美化文本;读的时候根节点匹配 DataModelSeq,每个元素交给 Student.deserialize。
src/main.cj:
package roster
import std.fs.{Path, Directory, exists}
import std.collection.ArrayList
import roster.models.Student
import roster.storage.{saveRoster, loadRoster}
main(): Int64 {
let dir = Path("data")
if (!exists(dir)) {
Directory.create(dir)
}
let path = Path("data/roster.json")
// 启动先读文件
var students = loadRoster(path)
if (students.size == 0) {
println("首次运行,创建名册数据...")
students.add(Student("小明", 18, 95.5, true, tags: ["学生", "会员"]))
students.add(Student("小红", 20, 88.0, false, tags: ["转学生"]))
students.add(Student("小刚", 21, 72.5, false))
saveRoster(students, path)
}
// 展示名册
println("=== 学生名册(共 ${students.size} 人)===")
for (s in students) {
let mark = if (s.vip) { "★" } else { " " }
println("${mark} ${s.name} ${s.age}岁 ${s.score}分 标签:${s.tags}")
}
println("数据文件:data/roster.json")
return 0
}
8.2 运行与验证
第一次运行(文件还不存在):
首次运行,创建名册数据...
=== 学生名册(共 3 人)===
★ 小明 18岁 95.500000分 标签:[学生, 会员]
小红 20岁 88.000000分 标签:[转学生]
小刚 21岁 72.500000分 标签:[]
数据文件:data/roster.json
同时生成的 data/roster.json(用记事本打开,中文正常、结构清晰):
[
{
"name": "小明",
"age": 18,
"score": 95.5,
"vip": true,
"tags": [
"学生",
"会员"
]
},
{
"name": "小红",
"age": 20,
"score": 88,
"vip": false,
"tags": [
"转学生"
]
},
{
"name": "小刚",
"age": 21,
"score": 72.5,
"vip": false,
"tags": []
}
]
注意看小红那条:88.0 在 JSON 文本里呈现为 88,这是 JSON 数字的正常现象(88 与 88.0 在 JSON 层面没有类型区别),读回 Float64 不受影响。
第二次运行(直接从文件加载,注意不再有"首次运行"那行,三个学生的数据完整回来):
=== 学生名册(共 3 人)===
★ 小明 18岁 95.500000分 标签:[学生, 会员]
小红 20岁 88.000000分 标签:[转学生]
小刚 21岁 72.500000分 标签:[]
数据文件:data/roster.json
可以自己动手改:
- 手动编辑
roster.json,给某个学生加个"city": "北京"字段再运行——旧代码不读它但也不会报错,体会 JSON"多字段不影响旧程序"的兼容性; - 把某个字段的类型改坏(如
"age": "十八"),运行观察序列化框架报DataModelException: This data is not DataModelInt.(注意:这条与第五节手工.asInt()的Fail to convert to JsonInt是两条不同路径上的报错); - 把文件改成
{ ... }(根是对象不是数组),观察名册文件的根节点不是数组; - 删掉文件再运行,验证种子数据重建。
九、常用 API 速查
| 功能 | API | 备注 |
|---|---|---|
| 解析文本 | JsonValue.fromStr(s) | 非法文本抛 JsonException |
| 根转对象 / 数组 | .asObject() / .asArray() | 类型不符抛 JsonException |
| 取标量 | .asString()/.asInt()/.asFloat()/.asBool() 后 .getValue() | 整数节点允许 asFloat |
| 判空 | v is JsonNull / .asNull() | — |
| 判类型 | .kind() 匹配 JsObject/JsArray/JsString/JsInt/JsFloat/JsBool/JsNull | 枚举不能直接插值 |
| 对象取字段 | obj.get(k): Option<JsonValue> | 缺失为 None;obj[k] 缺失抛异常 |
| 对象加/改字段 | obj.put(k, v) | 同名覆盖;无 remove |
| 对象遍历 | obj.getFields() 得 HashMap<String, JsonValue> | 勿依赖解析后的字段顺序 |
| 数组追加 | arr.add(v) | — |
| 数组取下标 | arr.get(i): Option / arr[i] | 越界 get 为 None |
| 数组遍历 | arr.getItems() 得 ArrayList<JsonValue> | 直接 for (x in ...) |
| 紧凑文本 | .toString() | println 默认就是它 |
| 美化文本 | .toJsonString() / .toJsonString(0, indent: " ") | 默认两空格缩进 |
| 类转 DataModel | 实现 Serializable<T> 的 serialize() | 基础类型用 field<T> |
| DataModel 转 JSON | dm.toJson() | 来自 stdx.encoding.json 的 ToJson |
| JSON 转 DataModel | DataModel.fromJson(jv) | 根是对象/数组分别匹配 |
| 数组字段 | 手工 DataModelSeq() + DataModelString(x) 等 | field<Array<T>> 运行时不支持 |
| 写 JSON 文件 | File.writeTo(path, text.toArray()) | 第 22 课 |
| 读 JSON 文件 | String.fromUtf8(File.readFrom(path)) | UTF-8 中文无损 |
十、常见问题 FAQ
Q1:std 和 stdx 是什么关系?为什么 JSON 不在标准库里?
std 是随 SDK 安装的核心标准库(std.fs、std.collection 等),开箱即用;stdx 是官方扩展标准库,更新节奏更快,JSON、HTTP、加密、压缩等都在这里,需要单独下载并在 cjpm.toml 配 path-option。配好之后和标准库用法一样,都是 import + 调用。
Q2:为什么直接写 field<Array<String>>("tags", tags) 运行时报 extensionData is nullptr?
1.2.0 的序列化框架只为 String/Int64/Float64/Bool 等基础类型和实现了 Serializable<T> 的类提供了实现,Array 本身没有。数组字段要手工包成 DataModelSeq(元素再包成 DataModelString 等),反序列化时匹配 DataModelSeq 逐个还原(见 7.1)。
Q3:get() 后面一长串 .getOrThrow().asString().getValue() 能不能少写点?
字段确定存在时可以这么写,但外部 JSON 字段可能缺失、类型可能不对,正式代码建议:先 match (obj.get(k)) 处理 None,再按 kind() 或直接 asXxx() 取值并 try-catch。链路虽长,但每一步都在挡一类坏数据。
Q4:为什么 obj["k"] 直接抛异常,而 obj.get("k") 返回 Option?
下标运算符 [] 的契约是"必然有",缺失时抛 JsonException(The Value of JsonObject does not exist);get() 把"可能没有"显式表达成 Option。读不可信数据优先 get() + match。
Q5:为什么 88.0 存进 JSON 变成了 88?读回来类型会不会错?
JSON 的数字不分整型/浮点,88 和 88.0 合法且等价;仓颉序列化时整值 Float64 就写成不带小数点的形式。读回时 JsonFloat.getValue() 走 asFloat(),整数节点会自动转成 Float64(实测 18 → 18.000000),数值无损;但如果你对这个节点调 asInt() 之外又依赖 JSON 文本本身的样子,就要留意这个差异。
Q6:打印 v.kind() 为什么报 should implement ToString?
JsonKind 枚举没有实现 ToString,不能插值。用 match 把七个构造器(JsNull/JsBool/JsInt/JsFloat/JsString/JsArray/JsObject)映射成字符串即可,顺便完成了穷尽检查。
Q7:编译能过,双击 exe 却报 0xC0000135 启动失败?
动态版 stdx 的程序运行时需要 stdx 的 DLL,双击 exe 时系统在旁边找不到。用 CIDE 的运行按钮或命令行 cjpm run 会自动带上库路径;需要分发单文件程序时再研究 stdx 的静态链接目录(static-static-link-extern)。
Q8:解析的 JSON 对象字段顺序怎么保证?想按顺序遍历怎么办?
JsonObject 底层是 HashMap,不要依赖解析后字段的排列顺序。需要稳定顺序有两个办法:要顺序的是"自己类的序列化"时用 Serializable<T>(DataModelStruct 按写入顺序保存);要顺序的是"数组"时本来就是有序的,用 JsonArray / DataModelSeq。
Q9:JSON 文件里的中文会不会变成 \uXXXX 转义?
实测不会。File.writeTo(path, text.toArray()) 按 UTF-8 直接写字节,记事本打开就是正常中文;读回用 String.fromUtf8(File.readFrom(path)) 完整还原。字符串里的引号、换行则会按 JSON 规则正确转义/还原。
Q10:手拼 JsonValue 和 Serializable 框架该怎么选?
数据结构在你掌控之中(自己的业务类、需要落盘/传输的固定结构),用 Serializable<T>:类型安全、字段有序、改字段只改一处;JSON 结构不固定、来自外部接口(比如第 30 课要处理的 GeoJSON,同一数组里每种要素字段都不同),用 JsonValue + kind()/match 动态解析。两者共享同一套 JsonValue/DataModel,可以混用。
十一、课后练习
- 用
JsonObject/JsonArray手工构建一本图书的 JSON:书名"仓颉入门"、作者"小明"、价格59.9、是否在售true、章节数组["环境", "语法", "实战"]。分别用toString()和toJsonString(0, indent: " ")打印紧凑版与 4 空格缩进版。 - 给定一段订单 JSON(文本自拟,要求含嵌套:
buyer对象和items商品数组,每个商品有name和qty)。用fromStr解析后,以match Option的安全方式打印买家名和每件商品名;再故意读取一个不存在的键,确认走到None分支而不崩溃。 - 为第 21 课的订单类(或自拟的
Book类:id、name、author 三个字段之外再加一个Array<String>分类字段)实现Serializable<T>,完成"对象 → JSON 文本 → 对象"一次往返,打印反序列化后的全部字段与数组。 - 把第 22 课的管道符文件
orders.txt改造成 JSON 持久化:仿照本课 roster,写saveOrders/loadOrders,首次运行造种子数据,第二次运行验证从data/orders.json完整加载(包括数组型字段)。 - 错误实验:分别触发并记录三种异常的真实文案——① 把字符串节点调
asInt();② 给fromStr传非法 JSON;③ 用obj["不存在"]取值。然后写一个func jsonDescribe(v: JsonValue): String,用kind()的 match 把任意 JSON 值描述成"对象(3个字段)"/"数组(2个元素)"/"字符串:xxx"/"整数:18"/"小数:3.14"/"布尔:true"/"null"。
下节预告
JSON 最常见的出场位置其实不是文件,而是网络:浏览器向服务器要数据、服务器把结果以 JSON 返回。第 24 课 网络编程入门 将用 stdx 的 HTTP 库发起一个 GET 请求,拿回一段 JSON,再用本课的 JsonValue.fromStr 解析——你会亲眼看到"发请求 → 收 JSON → 变成仓颉对象"这条所有网络程序的主干。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)+ stdx(1.2.0-beta.02.1)编写,所有代码均已实际编译运行通过。如遇 SDK / stdx 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 与扩展库 stdx 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
更多推荐



所有评论(0)