【仓颉语言入门 · 第二篇 第 22 课】
【仓颉语言入门 · 第二篇 第 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 配置):
[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表打架:
DROPTABLEIFEXISTStodo_api;CREATETABLEtodo_api(idBIGINTPRIMARYKEYAUTO_INCREMENT,titleVARCHAR(256)NOTNULL,doneTINYINT(1)NOTNULLDEFAULT0);INSERTINTOtodo_api(title,done)VALUES('写教程',0),('买菜',1);用第 7 课的 MySQL 客户端连上todo_tutorial库执行即可。DROP + CREATE的写法同时是演示重置脚本——自增 id 会归零,第七节的演示序列跑多少遍输出都一样。
三、Model 与 DAO:15 课写法的单表版
Model 只做数据搬运,外加一个toJson()负责把自己变成 JSON 对象(第一篇第 23 课的JsonObject全家桶):
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互转:
// ---------- 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()就是紧凑版:
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,实测消息长这样:
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("?")会编译报错:
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:
// ---------- 小工具 ---------- 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:
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:
// ---------- 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()阻塞),看到:
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 在响应体后面补一行状态码,方便对照。
主流程六连
① 列出两条种子:
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):
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 条了:
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 里要整体加引号):
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:
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]⑥ 终态确认:
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/方法,不再重复贴):
⑦ 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 命令,服务端却回 400bad 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