【仓颉语言入门 · 第二篇 第 15 课】综合实战:把 Todo 清单搬上数据库
导航:上一课 第 14 课 手写 DAO 数据访问层 → 本课 → 下一课 第 16 课 反射
本课内容量:正文约 380 行,全篇约 580 行(含导航 / FAQ / 预告 / 二维码骨架)。单课只聚焦 1 个知识点:把前 8 课讲过的 SQL 能力(建表、CRUD、预编译、事务、DAO)串成一个完整的小项目,不引入新语法、新概念、新依赖。本课本身就是综合实战项目,照着跑通、输出和教程对上、自己改改再跑一遍就是最好的练习——不再额外设课后练习题。
代码下载:本课完整项目打包在 lesson15_todo.zip
一、从文件到数据库:为什么又来一次 Todo?
你可能会问:Todo 清单有啥好写的?第一篇第 29 课不就是一个文件版 Todo 清单吗?
那我们回头看看第 29 课的方案:
- 用 std.fs 读写一个 txt 文件,每条 Todo 一行
- 业务逻辑全堆在 main 里,没有分层
- Todo 本身是 struct Todo(title: String, done: Bool) 这样的纯数据
- 读写文件一卡,整个程序就卡
现在换一个版本:存到数据库里。这次我们用数据库做存储层,其他一切——项目骨架、数据模型、业务逻辑——全和第 29 课一模一样。
这就是本课要讲的核心思想:
存储层可以替换,业务逻辑不必跟着换。
文件版、数据库版、未来 Redis 版——对"用户想加一条 Todo"这件事来说,业务逻辑永远是"把这行字写进去 + 记一个已完成标记"。至于写到哪、怎么写,那是存储层的事。
这种分层在中大型项目里随处可见:电商系统换了个新数据库、消息系统换了个新中间件、缓存换了个新厂商——上面的业务代码一行不动。本课通过一个极简的 Todo 项目,让你亲手走一遍这个分层。
二、表设计:先建表再开干
项目涉及两个实体、两张表:用户(user)和他的 Todo(todo)。关系很简单——一个用户可以有很多 Todo(一对多)。
建表 SQL(和第 08 课 DDL 章节讲的语法一致):
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE todo (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(256) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0
);
设计说明:
- user 的 name 上加了 UNIQUE 约束——不允许两个用户重名
- todo.done 用 TINYINT(1) 存 0/1(False/True),和数据库 BOOLEAN 的常见写法对齐
- 没有加外键约束(FOREIGN KEY (user_id) REFERENCES user(id))。教学简化:我们用"应用层"保证 user_id 有效(DAO 里先查 user 是否存在),不搞数据库级联。初学者阶段,能跑通比完美约束重要
如果想跟着本课演示的流程走,先往表里插两条种子数据:
INSERT INTO user (name) VALUES ('zhangsan'), ('lisi');
INSERT INTO todo (user_id, title, done) VALUES
(1, '学第 15 课', 0),
(1, '写教程代码', 1),
(2, '买牛奶', 0);
插完后手动查一下确认:
mysql> SELECT * FROM user;
id name
2 lisi
1 zhangsan
mysql> SELECT * FROM todo;
id user_id title done
1 1 学第 15 课 0
2 1 写教程代码 1
3 2 买牛奶 0
三、Model 类:纯数据搬运工
Model 层只做一件事:把数据库里的一行数据搬到内存里,变成一个可以在程序里自由传递的对象。它不碰数据库、不做业务决策。
class User {
let id: Int64
let name: String
init(id: Int64, name: String) {
this.id = id
this.name = name
}
}
class Todo {
let id: Int64
let userId: Int64
let title: String
var done: Bool
init(id: Int64, userId: Int64, title: String, done: Bool) {
this.id = id
this.userId = userId
this.title = title
this.done = done
}
}
这两个类的写法和第 14 课 Account 类完全一致:let 表示创建后不变(User 的 id、name 创建后不该变;Todo 的 id、userId、title 同理),var 表示可以改(Todo 的 done 可以从未完成变完成)。
Model 层的设计原则:越薄越好。别在里面写任何 SQL、任何业务判断,保持纯粹的数据容器。
四、两个 DAO:单表操作的套路
Data Access Object(DAO)层只负责一件事:把 SQL 语句翻译成 DAO 方法。一张表对应一个 DAO 类,每个方法代表一条可被复用的 SQL。
UserDao
class UserDao {
let conn: Connection
init(conn: Connection) { this.conn = conn }
// 按名字查用户(登录场景常用)
func findByName(name: String): ?User {
let stmt = conn.prepareStatement("SELECT id, name FROM user WHERE name = ?")
stmt.set(1, name)
let rs = stmt.query()
var result: ?User = None
if (rs.next()) {
result = Some(User(rs.get<Int64>(1), rs.get<String>(2)))
}
rs.close()
stmt.close()
return result
}
// 新建用户,返回新用户的 id
func insert(name: String): Int64 {
let stmt = conn.prepareStatement("INSERT INTO user (name) VALUES (?)")
stmt.set(1, name)
let r = stmt.update()
stmt.close()
return r.lastInsertId
}
}
TodoDao
class TodoDao {
let conn: Connection
init(conn: Connection) { this.conn = conn }
// 按 id 查单条 Todo
func findById(id: Int64): ?Todo {
let stmt = conn.prepareStatement(
"SELECT id, user_id, title, done FROM todo WHERE id = ?")
stmt.set(1, id)
let rs = stmt.query()
var result: ?Todo = None
if (rs.next()) {
let rawDone = rs.get<Int64>(4)
let doneFlag: Bool = rawDone != 0
result = Some(Todo(rs.get<Int64>(1), rs.get<Int64>(2),
rs.get<String>(3), doneFlag))
}
rs.close()
stmt.close()
return result
}
// 列出某用户的全部 Todo
func listByUser(userId: Int64): Array<Todo> {
let stmt = conn.prepareStatement(
"SELECT id, user_id, title, done FROM todo WHERE user_id = ? ORDER BY id")
stmt.set(1, userId)
let rs = stmt.query()
let list = ArrayList<Todo>()
while (rs.next()) {
let rawDone = rs.get<Int64>(4)
let doneFlag: Bool = rawDone != 0
list.add(Todo(rs.get<Int64>(1), rs.get<Int64>(2),
rs.get<String>(3), doneFlag))
}
rs.close()
stmt.close()
return list.toArray()
}
// 给某用户加一条 Todo,返回新 Todo 的 id
func insert(userId: Int64, title: String): Int64 {
let stmt = conn.prepareStatement(
"INSERT INTO todo (user_id, title, done) VALUES (?, ?, 0)")
stmt.set(1, userId)
stmt.set(2, title)
let r = stmt.update()
stmt.close()
return r.lastInsertId
}
// 把某条 Todo 标记为已完成 / 未完成
func setDone(id: Int64, done: Bool): Int64 {
let stmt = conn.prepareStatement("UPDATE todo 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
}
// 删除某条 Todo
func deleteById(id: Int64): Int64 {
let stmt = conn.prepareStatement("DELETE FROM todo WHERE id = ?")
stmt.set(1, id)
let rows = stmt.update().rowCount
stmt.close()
return rows
}
// 删除某用户的全部 Todo(**只在事务里用**)
func deleteByUserId(userId: Int64): Int64 {
let stmt = conn.prepareStatement("DELETE FROM todo WHERE user_id = ?")
stmt.set(1, userId)
let rows = stmt.update().rowCount
stmt.close()
return rows
}
}
DAO 层的三个套路
五、Service 层:把 DAO 串成业务
DAO 只管单表操作,但真实业务经常跨多张表。比如"删除用户"这件事:删 user 表之前必须先删光他所有的 todo,否则会出现"孤儿 todo"。这种跨表编排——事务—— 就是 Service 层的职责。
class TodoService {
let userDao: UserDao
let todoDao: TodoDao
let conn: Connection
init(conn: Connection) {
this.conn = conn
this.userDao = UserDao(conn)
this.todoDao = TodoDao(conn)
}
// 创建用户;同名已存在则直接返回,不重复插
func createUser(name: String): User {
let found = userDao.findByName(name)
if (found.isSome()) {
return found.getOrThrow()
}
let id = userDao.insert(name)
return User(id, name)
}
// 给用户加一条 Todo;用户不存在返回 None
func addTodo(userName: String, title: String): ?Int64 {
let found = userDao.findByName(userName)
if (found.isNone()) {
println("用户 ${userName} 不存在")
return None
}
let userId = found.getOrThrow().id
return Some(todoDao.insert(userId, title))
}
// 把某条 Todo 标为已完成
func completeTodo(todoId: Int64): Bool {
let found = todoDao.findById(todoId)
if (found.isNone()) {
println("todo ${todoId} 不存在")
return false
}
return todoDao.setDone(todoId, true) > 0
}
// 删除一条 Todo
func deleteTodo(todoId: Int64): Bool {
let found = todoDao.findById(todoId)
if (found.isNone()) {
println("todo ${todoId} 不存在")
return false
}
return todoDao.deleteById(todoId) > 0
}
// 列出某用户全部 Todo
func listUserTodos(userName: String): ?(User, Array<Todo>) {
let found = userDao.findByName(userName)
if (found.isNone()) {
return None
}
let u = found.getOrThrow()
return Some((u, todoDao.listByUser(u.id)))
}
// 事务:删用户 + 删光他的 Todo
// 两条 SQL 要么一起成,要么一起不算
func deleteUserAndTodos(userName: String): Bool {
let found = userDao.findByName(userName)
if (found.isNone()) {
println("用户 ${userName} 不存在")
return false
}
let u = found.getOrThrow()
let tx = conn.createTransaction()
tx.begin()
try {
// 先删 todo(user 表没有其他表依赖它,顺序不敏感)
todoDao.deleteByUserId(u.id)
let stmt = conn.prepareStatement("DELETE FROM user WHERE id = ?")
stmt.set(1, u.id)
stmt.update()
stmt.close()
tx.commit()
return true
} catch (e: SqlException) {
tx.rollback()
println("删除用户回滚:${e.message}")
return false
}
}
}
事务版 deleteUserAndTodos 拆解
这是第 13 课事务三件套的标准用法:
| 领事务 | let tx = conn.createTransaction() | 从连接领一个事务对象(它只是一层状态标记,没开新连接) |
| 开始 | tx.begin() | 之后这个连接上执行的所有改动都进草稿区 |
| 干活 | todoDao.deleteByUserId + conn.prepareStatement("DELETE FROM user…") | 两条 SQL;注意:仍然走 conn.prepareStatement,不是 tx.prepareStatement(第 13 课实测结论) |
| 提交 | tx.commit() | 草稿全部落库,事务结束 |
| 异常回滚 | catch (e: SqlException) { tx.rollback() } | 草稿全部撕掉,事务结束 |
小提示:先删 todo 还是先删 user?两种顺序都行——我们没加外键约束,所以不用怕"删了父表再删子表"的问题。但真实生产里加了外键约束后必须先删子表(todo)。养成先删依赖方的习惯不吃亏。
六、main:一条完整的业务流程
main 函数就是一个舞台——它把前面准备好的一切演员(Connection、Service)拉上台,按顺序走一遍剧情。同时还有一个辅助函数 printUserTodos 专门负责"格式化打印某用户的 Todo 列表":
func printUserTodos(svc: TodoService, userName: String): Unit {
let found = svc.listUserTodos(userName)
if (found.isNone()) {
println("${userName} 不存在")
return
}
let (user, todos) = found.getOrThrow()
println("用户 ${user.name}(共 ${todos.size} 条 Todo):")
for (t in todos) {
var mark: String = " "
if (t.done) {
mark = "✓"
}
println(" [${mark}] #${t.id} ${t.title}")
}
}
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 conn = ds.connect()
let svc = TodoService(conn)
println("=== 1. 列出 zhangsan 当前的 Todo ===")
printUserTodos(svc, "zhangsan")
println("")
println("=== 2. 把 zhangsan 的 #1(学第 15 课)标为已完成 ===")
if (svc.completeTodo(1)) {
println("#1 已完成")
} else {
println("#1 不存在")
}
println("")
println("=== 3. 再列 zhangsan 的 Todo,#1 应该带 ✓ ===")
printUserTodos(svc, "zhangsan")
println("")
println("=== 4. 新建用户 zhaoliu 并加 3 条 Todo ===")
let u = svc.createUser("zhaoliu")
println("创建用户成功:id=${u.id}, name=${u.name}")
svc.addTodo("zhaoliu", "跑步 3 公里")
svc.addTodo("zhaoliu", "背单词 50 个")
svc.addTodo("zhaoliu", "读一章小说")
println("")
println("=== 5. 列出全部 zhaoliu Todo ===")
printUserTodos(svc, "zhaoliu")
println("")
println("=== 6. 删除 lisi + 他的全部 Todo(事务) ===")
if (svc.deleteUserAndTodos("lisi")) {
println("lisi 连同 Todo 已全部删除")
} else {
println("删除失败")
}
if (svc.listUserTodos("lisi").isNone()) {
println("确认:lisi 已不存在")
} else {
println("警告:lisi 还存在!")
}
conn.close()
0
}
注意:import mariadb.cdbc 虽然我们没在代码里显式调用它的类,但必须 import——这会触发驱动包的自注册(第 07 课核心机制),让 DriverManager.getDriver("mariadb") 能找到 mariadb 驱动。漏写了 getDriver 返回 None,getOrThrow() 当场崩。
七、运行输出(cjpm run 逐字比对)
跑 cjpm run:
=== 1. 列出 zhangsan 当前的 Todo ===
用户 zhangsan(共 2 条 Todo):
[ ] #1 学第 15 课
[✓] #2 写教程代码
=== 2. 把 zhangsan 的 #1(学第 15 课)标为已完成 ===
#1 已完成
=== 3. 再列 zhangsan 的 Todo,#1 应该带 ✓ ===
用户 zhangsan(共 2 条 Todo):
[✓] #1 学第 15 课
[✓] #2 写教程代码
=== 4. 新建用户 zhaoliu 并加 3 条 Todo ===
创建用户成功:id=3, name=zhaoliu
=== 5. 列出全部 zhaoliu Todo ===
用户 zhaoliu(共 3 条 Todo):
[ ] #4 跑步 3 公里
[ ] #5 背单词 50 个
[ ] #6 读一章小说
=== 6. 删除 lisi + 他的全部 Todo(事务) ===
lisi 连同 Todo 已全部删除
确认:lisi 已不存在
cjpm run finished
为什么每次运行前要 reset 数据库? 因为 step 4 新建用户后不会自动删除;step 6 删了 lisi;第二次运行时 seed 数据(zhangsan、lisi、#1 #2 #3 todo)已经不存在了,listUserTodos("zhangsan") 会报"zhangsan 不存在"。生产程序不会每次都重置——这是教学演示的便利,让你能反复看同一段输出。
八、常见报错速览(我实测踩的坑)
1. NoneValueException 在 DriverManager.getDriver("mariadb").getOrThrow()
原因:漏写 import mariadb.cdbc。mariadb 驱动包的自注册机制需要 import 才能触发;不 import,DriverManager 里就没有 mariadb 这个驱动,getDriver 返回 None。
解决:在文件顶部补上 import mariadb.cdbc。
2. error: 'append' is not a member of struct 'Array<T>'
原因:你把 Array 当成了动态数组,用 .append() 往里加元素。仓颉 SDK 1.2.0 的 Array 是固定长度数组,没有增删方法。
解决:用 ArrayList<T>(import std.collection.ArrayList),追加用 .add(),动态集合用完可以 .toArray() 转回固定长度数组。
3. error: variable 'xxx' is immutable
原因:let 声明的变量你又赋了新值。比如 let flag = 0 之后 if (done) flag = 1——flag 必须用 var。
解决:需要修改的变量一律 var,不需要改的保持 let(编译器会帮你发现意外修改)。
4. 事务里写 tx.prepareStatement(…) 编译报错
原因:Transaction 接口没有 prepareStatement 方法。事务只是一层"草稿区状态",从 begin() 到 commit() / rollback() 之间,这个连接上执行的所有改动自动进入草稿区。
解决:事务里执行 SQL 仍然走 conn.prepareStatement——和事务外一模一样的代码,只是因为 begin() 已经把连接标记成"事务模式"了。第 13 课有实测结论。
5. error: generic type should be used with type argument + error: 'get' is not a member of enum 'Option<T>'
原因:Option 的判断和解包你可能用了错误方法。
解决:
- 判断有值:.isSome() / .isNone()
- 解包取值:.getOrThrow()(有值返回值,没值抛异常)
- 不能写 found == None(编译器认为 None 是泛型占位符,不是具体值)
6. ResultSet 用完没 close 也能跑,但跑多了 MySQL 报错 Too many connections
原因:驱动在 ResultSet 关闭前不释放底层 socket。程序跑完虽然进程退出,但真实服务端的连接数会累积。
解决:每个 Statement → ResultSet 链用完立刻 close,顺序 rs.close() → stmt.close()。这是数据库编程的基本纪律。
九、前 8 课关键 API 串烧(综合速查)
本课把前 8 课数据库知识全部串起来了,下面这张表帮你快速定位:
| 取驱动 | DriverManager.getDriver("mariadb").getOrThrow() | 第 07 课 |
| 开连接 | driver.open("mariadb://host:port", […]).connect() | 第 07 课 |
| 执行 DDL | conn.prepareStatement("CREATE TABLE …").update() | 第 08 课 |
| 查询 | conn.prepareStatement(sql).query() → while (rs.next()) { rs.get<T>(n) } | 第 09 课 |
| 插入 | conn.prepareStatement("INSERT …").update().lastInsertId | 第 10 课 |
| 更新 / 删除 | conn.prepareStatement("UPDATE …").update().rowCount | 第 11 课 |
| 预编译(全课贯穿) | SQL 里写 ?,执行前 stmt.set(n, value) | 第 12 课 |
| 事务 | let tx = conn.createTransaction(); tx.begin() → try/commit + catch/rollback | 第 13 课 |
| 动态集合 | ArrayList<T>() + .add(x) + .toArray() | 第 08 课(第一篇) |
| Option 解包 | .isSome() / .isNone() + .getOrThrow() | 第 09 课 |
十、FAQ
Q1:Service 层和 DAO 层的边界在哪?为什么不把跨表逻辑也写在 DAO 里?
DAO 的设计哲学是**“一张表 = 一个 DAO”**。跨两张表、需要事务的操作,放在 DAO 里会导致一个 DAO 依赖另一个 DAO(UserDao 里要调 TodoDao.deleteByUserId),职责混乱。Service 层就是 DAO 之上的"编排层"——它知道有哪些 DAO、该按什么顺序调用、哪里需要事务、哪里需要异常兜底。
一个简单判断标准:方法里只出现 SELECT / INSERT / UPDATE / DELETE 单表操作 → DAO;方法里出现多个 DAO 调用,或需要事务 → Service。
Q2:和第 29 课文件版 Todo 对照一下?
| 存储介质 | txt 文件,每行一条 | MySQL 两张表 |
| 读取方式 | 整文件读入内存 + split | SQL 查询 + ResultSet 遍历 |
| 写入方式 | file.write(content) 覆盖写 | stmt.update() 增/改/删 |
| 多用户支持 | 不支持(全堆一个文件) | 天然支持(user 表分区) |
| 分层 | 全堆 main | Model + DAO + Service 三层 |
| 并发安全 | 进程独占(不会被两个进程同时改) | 靠事务锁 + 连接池(真实生产问题) |
你可以看到:Model、DAO、Service 三层在两个版本里一模一样,只有 DAO 里的"怎么存/怎么取"变了。这就是本课想传达的——业务逻辑和存储技术解耦。
Q3:为什么不用 ConnectionPool?
数据库连接建立一次成本很高(TCP 握手 + 认证 + 默认 schema 设置)。真实服务器应该用连接池(应用启动时先建 10 条放着,用的时候拿一条,用完还回去而不是关掉)。但本课和 mariadb-driver 1.2.0 都没内置连接池——我们只做一次连接、用完关掉的最简场景。连接池是"生产级优化",初学者阶段先把"单连接能跑通"吃透再说。
Q4:如果将来想换 SQLite?
改一行 URL:"sqlite:///path/to/db.sqlite"(前提是换用 SQLite 驱动)。Service 层和 DAO 层一行都不用改——因为我们用的都是标准 SQL + std.database.sql 标准接口,不依赖 mariadb 的特定扩展。这就是"分层解耦"的好处。
下一课预告:第 16 课 反射 —— 标准库 std.reflect 能让程序在运行时"看见"自己:类有哪些字段?方法签名是什么?字段叫什么名字?反射是 ORM、序列化框架、依赖注入的底层工具,也是仓颉类型系统的"后门"。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 + MySQL 环境下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 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 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
网硕互联帮助中心





评论前必须登录!
注册