☰
【仓颉语言入门 · 第二篇 第 15 课】
2026/10/26 2:03:30 网站建设 项目流程

【仓颉语言入门 · 第二篇 第 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 章节讲的语法一致):

CREATETABLEuser(idBIGINTPRIMARYKEYAUTO_INCREMENT,nameVARCHAR(64)NOTNULLUNIQUE);CREATETABLEtodo(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,titleVARCHAR(256)NOTNULL,doneTINYINT(1)NOTNULLDEFAULT0);

设计说明:

  • user的name上加了UNIQUE约束——不允许两个用户重名
  • todo.done用TINYINT(1)存 0/1(False/True),和数据库 BOOLEAN 的常见写法对齐
  • 没有加外键约束(FOREIGN KEY (user_id) REFERENCES user(id))。教学简化:我们用"应用层"保证user_id有效(DAO 里先查 user 是否存在),不搞数据库级联。初学者阶段,能跑通比完美约束重要

如果想跟着本课演示的流程走,先往表里插两条种子数据:

INSERTINTOuser(name)VALUES('zhangsan'),('lisi');INSERTINTOtodo(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 层的三个套路

  1. 所有 SQL 走conn.prepareStatement(预编译),绝不用字符串拼接。第 12 课讲过原因:防 SQL 注入
  2. Connection 是 DAO 的唯一依赖,通过构造器注入(init(conn: Connection))。Service 层传给 DAO 的是同一个连接,事务里所有 DAO 调用自然共享同一个事务状态
  3. ResultSet 和 Statement 用完立刻 close。不 close 虽然程序不崩,但数据库服务端会保持连接数不释放,跑久了 MySQL 会满

五、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 课
执行 DDLconn.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 对照一下?

维度第 29 课文件版第 15 课数据库版
存储介质txt 文件,每行一条MySQL 两张表
读取方式整文件读入内存 + splitSQL 查询 + ResultSet 遍历
写入方式file.write(content)覆盖写stmt.update()增/改/删
多用户支持不支持(全堆一个文件)天然支持(user 表分区)
分层全堆 mainModel + 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询