【仓颉语言入门 · 第二篇 第 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
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:认识 std.database.sql,连上 MySQL
  8. 建表:用代码定义表结构
  9. 查询:SELECT 与结果集遍历
  10. 插入:INSERT、占位符与自增主键
  11. 更新与删除:UPDATE / DELETE
  12. 预编译与防 SQL 注入
  13. 事务:批量操作的成败一致
  14. 手写 DAO 数据访问层
  15. 数据库综合小实战
  16. 反射:在运行时打量类型
  17. 宏基础:std.ast 与 quote
  18. deriving 宏实战
  19. 与 C 互操作基础:FFI 调用 C 函数
  20. FFI 进阶:结构体、指针与编译配置
  21. 并发容器:并发安全的集合
  22. 收官实战: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
相关推荐
Warren2Lynch2 小时前
C4 模型详解:利用 Visual Paradigm 可视化软件架构的综合指南
开发语言·uml
Knight_AL3 小时前
Java 文件下载时文件名中的 `#` 被转换成 `%23`?一次 URL 编码与解码问题排查
java·开发语言
FRing033 小时前
vue2干货总结2(v-bind、v-for、v-model、指令修饰符、v-bind操作class和style)
开发语言·前端·vue.js·vue2
by209994 小时前
C++ 智能指针的所有权与使用
开发语言·c++·经验分享
砚底藏山河4 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
冬奇Lab4 小时前
一天一个开源项目(第252篇):Lightpanda —— 用 Zig 从零写的 AI 专用无头浏览器,内存省 16 倍、速度快 9 倍
人工智能·开源·资讯
IT召唤狮4 小时前
开源企业级零信任项目Pangolin,零信任能挡住PHP核弹漏洞吗?
开发语言·开源·php
知已难寻4 小时前
03 · OpenStack的计算管理 Nova
运维·服务器·开发语言·云原生·云计算·openstack·开源软件
Rover Ramble4 小时前
C++ 用互斥锁实现读写锁
开发语言·c++