
【仓颉语言入门 · 第二篇 第 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和他的 Todotodo。关系很简单——一个用户可以有很多 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/1False/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 ObjectDAO层只负责一件事把 SQL 语句翻译成 DAO 方法。一张表对应一个 DAO 类每个方法代表一条可被复用的 SQL。UserDaoclass 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.getInt64(1), rs.getString(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 } }TodoDaoclass 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.getInt64(4) let doneFlag: Bool rawDone ! 0 result Some(Todo(rs.getInt64(1), rs.getInt64(2), rs.getString(3), doneFlag)) } rs.close() stmt.close() return result } // 列出某用户的全部 Todo func listByUser(userId: Int64): ArrayTodo { 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 ArrayListTodo() while (rs.next()) { let rawDone rs.getInt64(4) let doneFlag: Bool rawDone ! 0 list.add(Todo(rs.getInt64(1), rs.getInt64(2), rs.getString(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 层的三个套路所有 SQL 走conn.prepareStatement预编译绝不用字符串拼接。第 12 课讲过原因防 SQL 注入Connection 是 DAO 的唯一依赖通过构造器注入init(conn: Connection)。Service 层传给 DAO 的是同一个连接事务里所有 DAO 调用自然共享同一个事务状态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, ArrayTodo) { 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 { // 先删 todouser 表没有其他表依赖它顺序不敏感 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.deleteByUserIdconn.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返回 NonegetOrThrow()当场崩。七、运行输出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 创建用户成功id3, namezhaoliu 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 才能触发不 importDriverManager 里就没有 mariadb 这个驱动getDriver返回 None。解决在文件顶部补上import mariadb.cdbc。2.error: append is not a member of struct ArrayT原因你把Array当成了动态数组用.append()往里加元素。仓颉 SDK 1.2.0 的Array是固定长度数组没有增删方法。解决用ArrayListTimport 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 argumenterror: get is not a member of enum OptionT原因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.getT(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 课动态集合ArrayListT().add(x).toArray()第 08 课第一篇Option 解包.isSome()/.isNone().getOrThrow()第 09 课十、FAQQ1Service 层和 DAO 层的边界在哪为什么不把跨表逻辑也写在 DAO 里DAO 的设计哲学是**“一张表 一个 DAO”**。跨两张表、需要事务的操作放在 DAO 里会导致一个 DAO 依赖另一个 DAOUserDao里要调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改一行 URLsqlite:///path/to/db.sqlite前提是换用 SQLite 驱动。Service 层和 DAO 层一行都不用改——因为我们用的都是标准 SQL std.database.sql标准接口不依赖 mariadb 的特定扩展。这就是分层解耦的好处。下一课预告第 16 课 反射 —— 标准库std.reflect能让程序在运行时看见自己类有哪些字段方法签名是什么字段叫什么名字反射是 ORM、序列化框架、依赖注入的底层工具也是仓颉类型系统的后门。系列说明本系列基于 Windows 平台 CIDE 仓颉 SDK1.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