【仓颉语言入门 · 第二篇 第 22 课】收官实战:Todo REST API(stdx + MySQL)
第 21 课结尾说过:这一课要把整篇学过的零件攒成一个真正能跑的后端服务。现在兑现它------用 stdx 的 HTTP 服务起路由、JSON 做出入参(第一篇第 24、23 课)、第 7~15 课的 MySQL 做存储,写一个 Todo REST API:别的程序(浏览器、手机 App、另一个后端)通过 HTTP 就能增删改查待办清单。命令行程序只能给自己用,而 API 是给全世界用的------这是从"写程序"到"写服务"的一步。
本文所有代码、接口输出与报错文案均在仓颉 SDK 1.2.0 + stdx 1.2.0-beta.02.1 + mariadb-driver(master 分支)下逐行实测:服务端逐字编译运行,13 条 curl 命令的响应逐字摘录。
目录(第二篇导航)
第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 22 课、4 个模块:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、补遗篇 | 01~06 | 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理 |
| 二、数据库篇 | 07~15 | 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战 |
| 三、进阶硬核篇 | 16~21 | 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器 |
| 四、实战项目篇 | 22 | 收官实战:Todo REST API |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(std.binary)
- 数据库入门:认识 std.database.sql,连上 MySQL
- 建表:用代码定义表结构
- 查询:SELECT 与结果集遍历
- 插入:INSERT、占位符与自增主键
- 更新与删除:UPDATE / DELETE
- 预编译与防 SQL 注入
- 事务:批量操作的成败一致
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏基础:std.ast 与 quote
- deriving 宏实战
- 与 C 互操作基础:FFI 调用 C 函数
- FFI 进阶:结构体、指针与编译配置
- 并发容器:并发安全的集合
- 收官实战:Todo REST API(stdx + MySQL)(本文)
一、收官战:把零件攒成整机
先看这个服务用了哪些零件、各自来自哪一课:
| 零件 | 干什么的 | 出处 |
|---|---|---|
ServerBuilder + distributor.register |
起 HTTP 服务、注册路由 | 第一篇第 24 课 |
JsonValue / JsonObject |
请求体解析、响应体构造 | 第一篇第 23 课 |
DriverManager + Datasource |
连 MySQL、借还连接 | 第 07 课 |
prepareStatement + ? 占位符 |
全部 SQL 预编译防注入 | 第 12 课 |
TodoDao |
单表 CRUD 封装 | 第 14、15 课 |
Option + match |
查得到/查不到、字段有没有 | 第一篇第 9 课 |
| try-catch | 坏 JSON → 400、意外 → 500 | 第一篇第 10 课 |
成品是四个接口:
| 方法 + 路径 | 作用 | 成功响应 |
|---|---|---|
GET /todos |
列出全部待办 | 200 + JSON 数组 |
POST /todos |
新增一条(body {"title":"..."}) |
201 + 新对象 JSON |
PUT /todos?id=N |
更新完成标记(body {"done":true}) |
200 + 更新后对象 |
DELETE /todos?id=N |
删除一条 | 200 + {"deleted":1} |
为什么是 ?id=N 而不是更常见的 /todos/N?这是本课一个诚实的工程妥协,第五节实测给你看。
和第 15 课的关系也要说清:15 课的 Todo 项目是两张表(user + todo)跑在命令行里;本课把数据模型简化成单表(去掉 user),因为这一课的主角是 HTTP 层------每张表都挂一遍路由只会稀释主题。存储层写法(DAO、预编译、借还连接)原样复用。
二、工程与数据库准备
cjpm init todo_api 建一个可执行工程,把 cjpm.toml 改成(和第 07 课同款的依赖与 stdx 配置):
toml
[package]
cjc-version = "1.2.0"
name = "todo_api"
version = "1.0.0"
output-type = "executable"
[dependencies]
mariadb = {git = "https://gitcode.com/Cangjie-SIG/mariadb-driver.git", branch = "master"}
[target.x86_64-w64-mingw32]
[target.x86_64-w64-mingw32.bin-dependencies]
path-option = ["D:\\software\\cangjie\\cangjie_stdx_libs\\stdx"]
path-option 换成你机器上的 stdx 路径,然后 cjpm update 拉驱动。
数据表建在 todo_tutorial 库(15 课用的那个)里,但起个新名字 todo_api,免得和 15 课的 todo 表打架:
sql
DROP TABLE IF EXISTS todo_api;
CREATE TABLE todo_api (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(256) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0
);
INSERT INTO todo_api (title, done) VALUES ('写教程', 0), ('买菜', 1);
用第 7 课的 MySQL 客户端连上 todo_tutorial 库执行即可。DROP + CREATE 的写法同时是演示重置脚本------自增 id 会归零,第七节的演示序列跑多少遍输出都一样。
三、Model 与 DAO:15 课写法的单表版
Model 只做数据搬运,外加一个 toJson() 负责把自己变成 JSON 对象(第一篇第 23 课的 JsonObject 全家桶):
cangjie
package todo_api
import std.collection.ArrayList
import std.convert.*
import std.database.sql.*
import stdx.encoding.json.*
import stdx.net.http.*
import mariadb.cdbc
// ---------- Model:一行 todo ----------
class Todo {
let id: Int64
let title: String
let done: Bool
init(id: Int64, title: String, done: Bool) {
this.id = id
this.title = title
this.done = done
}
func toJson(): JsonObject {
let obj = JsonObject()
obj.put("id", JsonInt(this.id))
obj.put("title", JsonString(this.title))
obj.put("done", JsonBool(this.done))
return obj
}
}
DAO 和 15 课的 TodoDao 一个模子:全部 prepareStatement 预编译、ResultSet 和 Statement 用完立刻 close、TINYINT(1) 的 0/1 和 Bool 互转:
cangjie
// ---------- DAO:单表 CRUD,全部预编译 ----------
class TodoDao {
let conn: Connection
init(conn: Connection) { this.conn = conn }
func findAll(): Array<Todo> {
let stmt = conn.prepareStatement("SELECT id, title, done FROM todo_api ORDER BY id")
let rs = stmt.query()
let list = ArrayList<Todo>()
while (rs.next()) {
list.add(Todo(rs.get<Int64>(1), rs.get<String>(2), rs.get<Int64>(3) != 0))
}
rs.close()
stmt.close()
return list.toArray()
}
func findById(id: Int64): ?Todo {
let stmt = conn.prepareStatement("SELECT id, title, done FROM todo_api WHERE id = ?")
stmt.set(1, id)
let rs = stmt.query()
var result: ?Todo = None
if (rs.next()) {
result = Some(Todo(rs.get<Int64>(1), rs.get<String>(2), rs.get<Int64>(3) != 0))
}
rs.close()
stmt.close()
return result
}
func insert(title: String): Int64 {
let stmt = conn.prepareStatement("INSERT INTO todo_api (title, done) VALUES (?, 0)")
stmt.set(1, title)
let r = stmt.update()
stmt.close()
return r.lastInsertId
}
func setDone(id: Int64, done: Bool): Int64 {
let stmt = conn.prepareStatement("UPDATE todo_api SET done = ? WHERE id = ?")
var flag: Int64 = 0
if (done) { flag = 1 }
stmt.set(1, flag)
stmt.set(2, id)
let rows = stmt.update().rowCount
stmt.close()
return rows
}
func deleteById(id: Int64): Int64 {
let stmt = conn.prepareStatement("DELETE FROM todo_api WHERE id = ?")
stmt.set(1, id)
let rows = stmt.update().rowCount
stmt.close()
return rows
}
}
四、JSON 出入参:紧凑输出用 toString()
第 23 课讲过 toJsonString() 输出美化多行格式。API 响应习惯用紧凑单行 ------实测 JsonValue 的 toString() 就是紧凑版:
text
toString(): {"id":1,"title":"x","done":false}
toJsonString(): {
"id": 1,
"title": "x",
"done": false
}
所以本课所有响应体都调 .toString()(上面 toJson() 返回 JsonObject,拼好字段后 .toString())。
入参方向(解析客户端发来的 body)用第 23 课的链式取值:JsonValue.fromStr(文本).asObject() 拿到对象,obj.get("title") 返回 Option<JsonValue>(字段不存在是 None,不抛异常),再 .asString().getValue() / .asBool().getValue() 取出具体类型。
坏 JSON 会在 fromStr 处抛 JsonException,实测消息长这样:
text
JsonException: The json data is Non-standard, please check:
Parse Error: [Line]: 1, [Pos]: 2, [Error]: Unexpected character: 't'.
第六节的 handler 用 catch (e: JsonException) 把它兜成 400,把 e.message 塞进错误响应里还给调用方。
五、路由的现实:distributor 只认精确匹配
REST 教程里标准的写法是 PUT /todos/3------id 放在路径里。本课原计划也这么写,但实测发现 stdx 1.2.0-beta.02.1 的 distributor 只支持精确匹配 :注册了 /todos 之后,请求 /todos/3 直接吃 404;试注册 /todos/{id}、/todos/* 同样接不住。
所以本课的设计妥协是:id 走查询参数 ------PUT /todos?id=3。真实框架(Spring、Express)替你做的"路径参数解析",这里自己写几行补上,也正好看看框架平时替你干了什么。
读查询参数前先认清一个类型事实:req.url 不是 String,是 URL 类对象 ------对它调 .split("?") 会编译报错:
text
error: 'split' is not a member of class 'URL'
它的两个可用成员:req.url.path 是 String(/todos,不含查询串);req.url.query 是 Option<String>(无查询串是 None,有时是 Some("id=3") 这样的原始串,需要自己 split)。另外实测路由匹配忽略查询串 :GET /todos?x=1 照样命中 /todos,所以四个接口可以只注册一个路径。
两个小工具函数------errJson 统一错误响应格式,parseIdFromQuery 从原始查询串里挖 id:
cangjie
// ---------- 小工具 ----------
func errJson(msg: String): String {
let obj = JsonObject()
obj.put("error", JsonString(msg))
return obj.toString()
}
// distributor 只支持精确匹配,id 走查询参数 ?id=N
func parseIdFromQuery(query: String): ?Int64 {
for (pair in query.split("&")) {
let kv = pair.split("=")
if (kv.size == 2 && kv[0] == "id") {
return Int64.tryParse(kv[1])
}
}
return None
}
Int64.tryParse(第一篇第 3 课 Int64.parse 的不抛异常版)把 "abc" 这样的坏 id 也变成 None,和"没给 id"走同一条 400。
六、四个 handler:方法分流与错误处理
一个路径 /todos、四种方法,在 handler 里用 if (req.method == ...) 分流。注意连接的用法:每个请求进来现借 ds.connect()、用完 conn.close() 还回池子 (第 07 课讲过 Datasource 内部有连接池)------不共享单条连接,天然避开多线程争抢。
先是 main 函数、注册路由,以及 GET 和 POST 两个 handler:
cangjie
main(): Int64 {
let driver = DriverManager.getDriver("mariadb").getOrThrow()
let ds = driver.open("mariadb://127.0.0.1:3306", [
("username", "root"),
("password", "wangpeng"),
("database", "todo_tutorial")
])
let server = ServerBuilder().addr("127.0.0.1").port(8080).build()
server.distributor.register("/todos", { ctx =>
let req = ctx.request
try {
// ---------- GET /todos:列表 ----------
if (req.method == "GET") {
let conn = ds.connect()
let dao = TodoDao(conn)
let arr = JsonArray()
for (t in dao.findAll()) {
arr.add(t.toJson())
}
conn.close()
ctx.responseBuilder
.status(200)
.header("Content-Type", "application/json; charset=utf-8")
.body(arr.toString())
.build()
return
}
// ---------- POST /todos:新建 ----------
if (req.method == "POST") {
let buf = Array<Byte>(8192, {_ => 0})
let n = req.body.read(buf)
let obj = JsonValue.fromStr(String.fromUtf8(buf[0..n])).asObject()
match (obj.get("title")) {
case Some(v) =>
let title = v.asString().getValue()
let conn = ds.connect()
let dao = TodoDao(conn)
let newId = dao.insert(title)
conn.close()
ctx.responseBuilder
.status(201)
.header("Content-Type", "application/json; charset=utf-8")
.body(Todo(newId, title, false).toJson().toString())
.build()
case None =>
ctx.responseBuilder
.status(400)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("missing field: title"))
.build()
}
return
}
接着是 PUT、DELETE、其他方法的 405 兜底,以及把坏 JSON 和意外异常兜住的两道 catch:
cangjie
// ---------- PUT /todos?id=N:更新 done ----------
if (req.method == "PUT") {
let idOpt: ?Int64 = match (req.url.query) {
case Some(q) => parseIdFromQuery(q)
case None => None
}
match (idOpt) {
case Some(id) =>
let buf = Array<Byte>(8192, {_ => 0})
let n = req.body.read(buf)
let obj = JsonValue.fromStr(String.fromUtf8(buf[0..n])).asObject()
match (obj.get("done")) {
case Some(v) =>
let done = v.asBool().getValue()
let conn = ds.connect()
let dao = TodoDao(conn)
let found = dao.findById(id)
if (found.isNone()) {
conn.close()
ctx.responseBuilder
.status(404)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("todo ${id} not found"))
.build()
return
}
let t = found.getOrThrow()
dao.setDone(id, done)
conn.close()
ctx.responseBuilder
.status(200)
.header("Content-Type", "application/json; charset=utf-8")
.body(Todo(t.id, t.title, done).toJson().toString())
.build()
case None =>
ctx.responseBuilder
.status(400)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("missing field: done"))
.build()
}
case None =>
ctx.responseBuilder
.status(400)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("missing or invalid query: id"))
.build()
}
return
}
// ---------- DELETE /todos?id=N:删除 ----------
if (req.method == "DELETE") {
let idOpt: ?Int64 = match (req.url.query) {
case Some(q) => parseIdFromQuery(q)
case None => None
}
match (idOpt) {
case Some(id) =>
let conn = ds.connect()
let dao = TodoDao(conn)
let rows = dao.deleteById(id)
conn.close()
if (rows == 0) {
ctx.responseBuilder
.status(404)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("todo ${id} not found"))
.build()
} else {
let resp = JsonObject()
resp.put("deleted", JsonInt(rows))
ctx.responseBuilder
.status(200)
.header("Content-Type", "application/json; charset=utf-8")
.body(resp.toString())
.build()
}
case None =>
ctx.responseBuilder
.status(400)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("missing or invalid query: id"))
.build()
}
return
}
// ---------- 其他方法 ----------
ctx.responseBuilder
.status(405)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("method not allowed"))
.build()
} catch (e: JsonException) {
ctx.responseBuilder
.status(400)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("bad json: ${e.message}"))
.build()
} catch (e: Exception) {
ctx.responseBuilder
.status(500)
.header("Content-Type", "application/json; charset=utf-8")
.body(errJson("server error: ${e.message}"))
.build()
}
})
println("Todo API server up: http://127.0.0.1:8080/todos")
server.serve()
return 0
}
三层错误处理的位置值得看清:
| 层次 | 兜什么 | 状态码 |
|---|---|---|
match (obj.get(...)) 的 None 分支 |
body 里没给 title / done / id | 400 |
catch (e: JsonException) |
body 根本不是合法 JSON | 400 |
catch (e: Exception) |
剩下的一切(比如数据库挂了) | 500 |
PUT 分支里 let idOpt: ?Int64 = match (...) 的显式类型标注不能省------case None => None 里的裸 None 是泛型占位符,不标注会报 error: generic type should be used with type argument(第 15 课 FAQ 里的老熟人)。
七、跑起来,用 curl.exe 验收
先按第二节的重置 SQL 把表恢复成两条种子。然后一个终端窗口里 cjpm run 把服务起起来(它会停在 serve() 阻塞),看到:
text
Todo API server up: http://127.0.0.1:8080/todos
另开一个 PowerShell 窗口发请求。Windows 自带 curl.exe(注意必须写全 .exe------PowerShell 里裸 curl 是 Invoke-WebRequest 的别名,参数不兼容)。命令结尾的 -w "\n[HTTP %{http_code}]\n" 让 curl 在响应体后面补一行状态码,方便对照。
主流程六连
① 列出两条种子:
text
PS> curl.exe -s -w "\n[HTTP %{http_code}]\n" http://127.0.0.1:8080/todos
[{"id":1,"title":"写教程","done":false},{"id":2,"title":"买菜","done":true}]
[HTTP 200]
② 新增一条(PowerShell 里双引号要用 \" 转义,原因见 FAQ Q1):
text
PS> curl.exe -s -w "\n[HTTP %{http_code}]\n" -X POST -H "Content-Type: application/json" -d "{\"title\":\"买牛奶\"}" http://127.0.0.1:8080/todos
{"id":3,"title":"买牛奶","done":false}
[HTTP 201]
③ 再列一次,3 条了:
text
PS> curl.exe -s -w "\n[HTTP %{http_code}]\n" http://127.0.0.1:8080/todos
[{"id":1,"title":"写教程","done":false},{"id":2,"title":"买菜","done":true},{"id":3,"title":"买牛奶","done":false}]
[HTTP 200]
④ 把 #3 标为完成(URL 带 ?,PowerShell 里要整体加引号):
text
PS> curl.exe -s -w "\n[HTTP %{http_code}]\n" -X PUT -H "Content-Type: application/json" -d "{\"done\":true}" "http://127.0.0.1:8080/todos?id=3"
{"id":3,"title":"买牛奶","done":true}
[HTTP 200]
⑤ 删掉 #2:
text
PS> curl.exe -s -w "\n[HTTP %{http_code}]\n" -X DELETE "http://127.0.0.1:8080/todos?id=2"
{"deleted":1}
[HTTP 200]
⑥ 终态确认:
text
PS> curl.exe -s -w "\n[HTTP %{http_code}]\n" http://127.0.0.1:8080/todos
[{"id":1,"title":"写教程","done":false},{"id":3,"title":"买牛奶","done":true}]
[HTTP 200]
错误处理七连
服务不是只会干活,还得会拒绝。逐条验证错误分支(每条的命令就是把前面对应命令改掉 id/body/方法,不再重复贴):
text
⑦ PUT /todos?id=999 {"done":true} → {"error":"todo 999 not found"} [HTTP 404]
⑧ POST /todos body 就发 {title → {"error":"bad json: The json data is Non-standard, please check:\nParse Error: [Line]: 1, [Pos]: 2, [Error]: Unexpected character: 't'."} [HTTP 400]
⑨ DELETE /todos?id=999 → {"error":"todo 999 not found"} [HTTP 404]
⑩ GET /abc → 404 Not Found [HTTP 404]
⑪ PATCH /todos → {"error":"method not allowed"} [HTTP 405]
⑫ POST /todos {"name":"x"} → {"error":"missing field: title"} [HTTP 400]
⑬ PUT /todos(不带 ?id=){"done":true} → {"error":"missing or invalid query: id"} [HTTP 400]
⑩ 是纯文本的 404 Not Found,没有 JSON 包装------那是 distributor 的内置兜底,根本没进我们的 handler。⑧ 里 JSON 错误消息中的换行被 JsonString 自动转义成了 \n,客户端收到的仍是合法 JSON------这就是用 JsonObject 拼响应而不用字符串插值的原因。
服务端窗口里每个请求处理完会打印一行 WARN ... ConnectionException: Socket is closed.------客户端断开的正常噪音(第 24 课 FAQ 提过),无视即可。
八、常见问题 FAQ
Q1:照抄 POST 命令,服务端却回 400 bad json ... Unexpected character: 't'?
不是你的 JSON 写错,是 PowerShell 把 -d 参数里的双引号剥掉了------服务端实际收到的是 {title:买牛奶}。两种解法:① 照本课写法,内层引号全部写成 \";② 把 body 存进 UTF-8 文件(如 body.json),用 -d "@body.json" 读文件,最稳。
Q2:不想用 cjpm run,直接双击/启动 target\release\bin\main.exe 却秒退,curl 全连不上?
动态版 stdx 的 DLL 不在搜索路径里。cjpm run 会帮你备好环境,裸跑 exe 不会------先把 stdx 目录加进 PATH 再启动:$env:PATH = "D:\software\cangjie\cangjie_stdx_libs\stdx;" + $env:PATH。
Q3:编译警告 unused import 'mariadb.cdbc' 能删掉这行吗?
不能。这是驱动自注册的触发器(第 07 课的机制),删了 getDriver("mariadb") 返回 None,运行即崩。警告无视。同理 handler 里 try 块的 unused expression 警告也是良性的。
Q4:为什么不写一个 ConnectionPool 对象,而是每请求 ds.connect()?
Datasource 内部本来就是连接池(第 07 课)------connect() 是"借",close() 是"还",开销远小于新建 TCP。每个请求借一条、用完就还,既安全又符合池子的设计意图。自己再包一层池属于重复造轮子。
Q5:这个服务和生产级后端还差什么?
主要四样:路径参数/中间件等路由能力(等 stdx 演进或换框架)、HTTPS(第 24 课提过要配 TLS)、连接与读写的超时配置、以及日志规范。骨架是对的------分层、预编译、错误处理、状态码语义都在,换更厚的框架时这些概念全部平移。
全篇收官
到这里,第二篇 22 课全部走完。回头看这一篇的路线:
- 补遗篇(01~06)把标准库剩下的常用件补齐:正则、时间、命令行、随机数、加密、二进制;
- 数据库篇(07~15)从"连上 MySQL"一路走到事务和手写 DAO,让程序有了像样的存储;
- 进阶硬核篇(16~21)掀开语言底板:反射、宏、FFI、并发容器;
- 实战项目篇(22,本课)把它们和第一篇的 HTTP、JSON 拧成一台整机。
加上第一篇的 30 课,你已经攒齐了写仓颉程序的全套家当:语法、类型、工程化、并发、文件、网络、数据库,还有一个能跑的真实后端。教程能教的路到此为止,接下来的路在自己手里------给这个 Todo API 加用户体系、写个前端页面对接它、或者干脆照这个骨架做一个你自己的小服务。遇到坎了,回来翻对应的课;翻不到了,扫码来找我。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)+ stdx(1.2.0-beta.02.1)编写。本文所有代码、接口输出与报错文案均在上述环境下逐行实测:服务端逐字编译运行,13 条 curl 命令的响应逐字摘录。如遇 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