【仓颉语言入门 · 第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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程、Channel 通道与同步原语、并发实战
七、项目实战29~30命令行小工具、GeoJSON 数据处理实战
  1. 环境搭建与第一个仓颉程序
  2. 变量与常量:let / var 与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构:if 与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO
  23. JSON 处理(结合 stdx 扩展库)(本文)
  24. 网络编程入门
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. Channel 通道与同步原语
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具
  30. 实战二:GeoJSON 数据处理程序

一、为什么是 JSON

先看一段你眼熟的文本(第 22 课的订单文件):

NO.1001|1250|已支付

它有三个绕不开的麻烦:

  1. 字段一多就数不清——要是订单再带"买家、商品列表、收货地址",光看 a|b|c|d|e 谁知道每个位置是什么;
  2. 没法表达层级和列表——"一个订单含多个商品"这种嵌套关系,纯文本只能再发明一套约定;
  3. 只在本程序内有效——把它发给网页前端或别的语言写的服务,对方根本不懂。

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"]

注意三点:

  1. TOML 字符串里反斜杠要写两个(\\),也可以直接写正斜杠 D:/cangjie-libs/stdx;
  2. 这是二进制依赖(直接用编译好的库),不需要在 [dependencies] 里再写包名,也不用执行 cjpm update;
  3. 路径换成你自己的实际解压位置。

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 写法仓颉类构造方式打印结果
nullJsonNullJsonNull()null
true / falseJsonBoolJsonBool(true)true
123(整数)JsonIntJsonInt(123)123
3.14(小数)JsonFloatJsonFloat(3.14)3.140000
"hi"(字符串)JsonStringJsonString("hi")"hi"(带引号)
{...}(对象)JsonObjectJsonObject(){...}
[...](数组)JsonArrayJsonArray()[...]

把五种标量值都打出来看看:

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
nullasNull()得 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
下标取不存在的 keyobj["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, [学生, 会员]

逐块解释:

  1. class Student <: Serializable<Student>:实现序列化接口,泛型参数填自己(第 19 课讲过泛型,先照写);
  2. serialize():把对象摊平成一个 DataModelStruct,基础类型字段用 field<T>("键", 值) 逐个 .add(...);
  3. 数组要手工处理:1.2.0 实测直接写 field<Array<String>>("tags", tags) 能编译通过,但运行时崩溃(extensionData is nullptr ... Serializable<Array<String>>)——Array 没有内置序列化实现。正确做法是建一个 DataModelSeq,把元素逐个 DataModelString(t) 加进去,再用 Field("tags", seq) 放进结构体;
  4. 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 转 JSONdm.toJson()来自 stdx.encoding.json 的 ToJson
JSON 转 DataModelDataModel.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,可以混用。


十一、课后练习

  1. 用 JsonObject / JsonArray 手工构建一本图书的 JSON:书名 "仓颉入门"、作者 "小明"、价格 59.9、是否在售 true、章节数组 ["环境", "语法", "实战"]。分别用 toString() 和 toJsonString(0, indent: " ") 打印紧凑版与 4 空格缩进版。
  2. 给定一段订单 JSON(文本自拟,要求含嵌套:buyer 对象和 items 商品数组,每个商品有 name 和 qty)。用 fromStr 解析后,以 match Option 的安全方式打印买家名和每件商品名;再故意读取一个不存在的键,确认走到 None 分支而不崩溃。
  3. 为第 21 课的订单类(或自拟的 Book 类:id、name、author 三个字段之外再加一个 Array<String> 分类字段)实现 Serializable<T>,完成"对象 → JSON 文本 → 对象"一次往返,打印反序列化后的全部字段与数组。
  4. 把第 22 课的管道符文件 orders.txt 改造成 JSON 持久化:仿照本课 roster,写 saveOrders / loadOrders,首次运行造种子数据,第二次运行验证从 data/orders.json 完整加载(包括数组型字段)。
  5. 错误实验:分别触发并记录三种异常的真实文案——① 把字符串节点调 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
Logo

一站式 AI 云服务平台

更多推荐