
【倉頡語言入門 · 第二篇 第 15 課】綜合實戰(zhàn)把 Todo 清單搬上數(shù)據(jù)庫導(dǎo)航上一課 第 14 課 手寫 DAO 數(shù)據(jù)訪問層 → 本課 → 下一課 第 16 課 反射本課內(nèi)容量正文約 380 行全篇約 580 行含導(dǎo)航 / FAQ / 預(yù)告 / 二維碼骨架。單課只聚焦 1 個知識點把前 8 課講過的 SQL 能力建表、CRUD、預(yù)編譯、事務(wù)、DAO串成一個完整的小項目不引入新語法、新概念、新依賴。本課本身就是綜合實戰(zhàn)項目照著跑通、輸出和教程對上、自己改改再跑一遍就是最好的練習——不再額外設(shè)課后練習題。代碼下載本課完整項目打包在 lesson15_todo.zip一、從文件到數(shù)據(jù)庫為什么又來一次 Todo你可能會問Todo 清單有啥好寫的第一篇第 29 課不就是一個文件版 Todo 清單嗎那我們回頭看看第 29 課的方案用std.fs讀寫一個 txt 文件每條 Todo 一行業(yè)務(wù)邏輯全堆在main里沒有分層Todo 本身是struct Todo(title: String, done: Bool)這樣的純數(shù)據(jù)讀寫文件一卡整個程序就卡現(xiàn)在換一個版本存到數(shù)據(jù)庫里。這次我們用數(shù)據(jù)庫做存儲層其他一切——項目骨架、數(shù)據(jù)模型、業(yè)務(wù)邏輯——全和第 29 課一模一樣。這就是本課要講的核心思想存儲層可以替換業(yè)務(wù)邏輯不必跟著換。文件版、數(shù)據(jù)庫版、未來 Redis 版——對用戶想加一條 Todo這件事來說業(yè)務(wù)邏輯永遠是把這行字寫進去 記一個已完成標記。至于寫到哪、怎么寫那是存儲層的事。這種分層在中大型項目里隨處可見電商系統(tǒng)換了個新數(shù)據(jù)庫、消息系統(tǒng)換了個新中間件、緩存換了個新廠商——上面的業(yè)務(wù)代碼一行不動。本課通過一個極簡的 Todo 項目讓你親手走一遍這個分層。二、表設(shè)計先建表再開干項目涉及兩個實體、兩張表用戶user和他的 Todotodo。關(guān)系很簡單——一個用戶可以有很多 Todo一對多。建表 SQL和第 08 課 DDL 章節(jié)講的語法一致CREATETABLEuser(idBIGINTPRIMARYKEYAUTO_INCREMENT,nameVARCHAR(64)NOTNULLUNIQUE);CREATETABLEtodo(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,titleVARCHAR(256)NOTNULL,doneTINYINT(1)NOTNULLDEFAULT0);設(shè)計說明user的name上加了UNIQUE約束——不允許兩個用戶重名todo.done用TINYINT(1)存 0/1False/True和數(shù)據(jù)庫 BOOLEAN 的常見寫法對齊沒有加外鍵約束FOREIGN KEY (user_id) REFERENCES user(id)。教學簡化我們用應(yīng)用層保證user_id有效DAO 里先查 user 是否存在不搞數(shù)據(jù)庫級聯(lián)。初學者階段能跑通比完美約束重要如果想跟著本課演示的流程走先往表里插兩條種子數(shù)據(jù)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 類純數(shù)據(jù)搬運工Model 層只做一件事把數(shù)據(jù)庫里的一行數(shù)據(jù)搬到內(nèi)存里變成一個可以在程序里自由傳遞的對象。它不碰數(shù)據(jù)庫、不做業(yè)務(wù)決策。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表示創(chuàng)建后不變User 的 id、name 創(chuàng)建后不該變Todo 的 id、userId、title 同理var表示可以改Todo 的 done 可以從未完成變完成。Model 層的設(shè)計原則越薄越好。別在里面寫任何 SQL、任何業(yè)務(wù)判斷保持純粹的數(shù)據(jù)容器。四、兩個 DAO單表操作的套路Data Access ObjectDAO層只負責一件事把 SQL 語句翻譯成 DAO 方法。一張表對應(yīng)一個 DAO 類每個方法代表一條可被復(fù)用的 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**只在事務(wù)里用** 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預(yù)編譯絕不用字符串拼接。第 12 課講過原因防 SQL 注入Connection 是 DAO 的唯一依賴通過構(gòu)造器注入init(conn: Connection)。Service 層傳給 DAO 的是同一個連接事務(wù)里所有 DAO 調(diào)用自然共享同一個事務(wù)狀態(tài)ResultSet 和 Statement 用完立刻 close。不 close 雖然程序不崩但數(shù)據(jù)庫服務(wù)端會保持連接數(shù)不釋放跑久了 MySQL 會滿五、Service 層把 DAO 串成業(yè)務(wù)DAO 只管單表操作但真實業(yè)務(wù)經(jīng)??缍鄰埍怼1热鐒h除用戶這件事刪 user 表之前必須先刪光他所有的 todo否則會出現(xiàn)孤兒 todo。這種跨表編排——事務(wù)——就是 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) } // 創(chuàng)建用戶同名已存在則直接返回不重復(fù)插 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))) } // 事務(wù)刪用戶 刪光他的 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 } } }事務(wù)版deleteUserAndTodos拆解這是第 13 課事務(wù)三件套的標準用法步驟代碼作用領(lǐng)事務(wù)let tx conn.createTransaction()從連接領(lǐng)一個事務(wù)對象它只是一層狀態(tài)標記沒開新連接開始tx.begin()之后這個連接上執(zhí)行的所有改動都進草稿區(qū)干活todoDao.deleteByUserIdconn.prepareStatement(DELETE FROM user...)兩條 SQL注意仍然走conn.prepareStatement不是tx.prepareStatement第 13 課實測結(jié)論提交tx.commit()草稿全部落庫事務(wù)結(jié)束異?;貪Lcatch (e: SqlException) { tx.rollback() }草稿全部撕掉事務(wù)結(jié)束小提示先刪 todo 還是先刪 user兩種順序都行——我們沒加外鍵約束所以不用怕刪了父表再刪子表的問題。但真實生產(chǎn)里加了外鍵約束后必須先刪子表todo。養(yǎng)成先刪依賴方的習慣不吃虧。六、main一條完整的業(yè)務(wù)流程main 函數(shù)就是一個舞臺——它把前面準備好的一切演員Connection、Service拉上臺按順序走一遍劇情。同時還有一個輔助函數(shù)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 應(yīng)該帶 ? ) printUserTodos(svc, zhangsan) println() println( 4. 新建用戶 zhaoliu 并加 3 條 Todo ) let u svc.createUser(zhaoliu) println(創(chuàng)建用戶成功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事務(wù) ) 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雖然我們沒在代碼里顯式調(diào)用它的類但必須 import——這會觸發(fā)驅(qū)動包的自注冊第 07 課核心機制讓DriverManager.getDriver(mariadb)能找到 mariadb 驅(qū)動。漏寫了getDriver返回 NonegetOrThrow()當場崩。七、運行輸出cjpm run 逐字比對跑cjpm run 1. 列出 zhangsan 當前的 Todo 用戶 zhangsan共 2 條 Todo [ ] #1 學第 15 課 [?] #2 寫教程代碼 2. 把 zhangsan 的 #1學第 15 課標為已完成 #1 已完成 3. 再列 zhangsan 的 Todo#1 應(yīng)該帶 ? 用戶 zhangsan共 2 條 Todo [?] #1 學第 15 課 [?] #2 寫教程代碼 4. 新建用戶 zhaoliu 并加 3 條 Todo 創(chuàng)建用戶成功id3, namezhaoliu 5. 列出全部 zhaoliu Todo 用戶 zhaoliu共 3 條 Todo [ ] #4 跑步 3 公里 [ ] #5 背單詞 50 個 [ ] #6 讀一章小說 6. 刪除 lisi 他的全部 Todo事務(wù) lisi 連同 Todo 已全部刪除 確認lisi 已不存在 cjpm run finished為什么每次運行前要 reset 數(shù)據(jù)庫因為 step 4 新建用戶后不會自動刪除step 6 刪了 lisi第二次運行時 seed 數(shù)據(jù)zhangsan、lisi、#1 #2 #3 todo已經(jīng)不存在了listUserTodos(zhangsan)會報zhangsan 不存在。生產(chǎn)程序不會每次都重置——這是教學演示的便利讓你能反復(fù)看同一段輸出。八、常見報錯速覽我實測踩的坑1.NoneValueException在DriverManager.getDriver(mariadb).getOrThrow()原因漏寫import mariadb.cdbc。mariadb 驅(qū)動包的自注冊機制需要 import 才能觸發(fā)不 importDriverManager 里就沒有 mariadb 這個驅(qū)動getDriver返回 None。解決在文件頂部補上import mariadb.cdbc。2.error: append is not a member of struct ArrayT原因你把Array當成了動態(tài)數(shù)組用.append()往里加元素。倉頡 SDK 1.2.0 的Array是固定長度數(shù)組沒有增刪方法。解決用ArrayListTimport std.collection.ArrayList追加用.add()動態(tài)集合用完可以.toArray()轉(zhuǎn)回固定長度數(shù)組。3.error: variable xxx is immutable原因let聲明的變量你又賦了新值。比如let flag 0之后if (done) flag 1——flag 必須用var。解決需要修改的變量一律var不需要改的保持let編譯器會幫你發(fā)現(xiàn)意外修改。4. 事務(wù)里寫tx.prepareStatement(...)編譯報錯原因Transaction接口沒有prepareStatement方法。事務(wù)只是一層草稿區(qū)狀態(tài)從begin()到commit()/rollback()之間這個連接上執(zhí)行的所有改動自動進入草稿區(qū)。解決事務(wù)里執(zhí)行 SQL 仍然走conn.prepareStatement——和事務(wù)外一模一樣的代碼只是因為begin()已經(jīng)把連接標記成事務(wù)模式了。第 13 課有實測結(jié)論。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原因驅(qū)動在ResultSet關(guān)閉前不釋放底層 socket。程序跑完雖然進程退出但真實服務(wù)端的連接數(shù)會累積。解決每個 Statement → ResultSet 鏈用完立刻 close順序rs.close()→stmt.close()。這是數(shù)據(jù)庫編程的基本紀律。九、前 8 課關(guān)鍵 API 串燒綜合速查本課把前 8 課數(shù)據(jù)庫知識全部串起來了下面這張表幫你快速定位場景用法對應(yīng)課程取驅(qū)動DriverManager.getDriver(mariadb).getOrThrow()第 07 課開連接driver.open(mariadb://host:port, [...]).connect()第 07 課執(zhí)行 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 課預(yù)編譯全課貫穿SQL 里寫?執(zhí)行前stmt.set(n, value)第 12 課事務(wù)let tx conn.createTransaction(); tx.begin()→ try/commit catch/rollback第 13 課動態(tài)集合ArrayListT().add(x).toArray()第 08 課第一篇Option 解包.isSome()/.isNone().getOrThrow()第 09 課十、FAQQ1Service 層和 DAO 層的邊界在哪為什么不把跨表邏輯也寫在 DAO 里DAO 的設(shè)計哲學是**“一張表 一個 DAO”**??鐑蓮埍?、需要事務(wù)的操作放在 DAO 里會導(dǎo)致一個 DAO 依賴另一個 DAOUserDao里要調(diào)TodoDao.deleteByUserId職責混亂。Service 層就是 DAO 之上的編排層——它知道有哪些 DAO、該按什么順序調(diào)用、哪里需要事務(wù)、哪里需要異常兜底。一個簡單判斷標準方法里只出現(xiàn)SELECT/INSERT/UPDATE/DELETE單表操作 → DAO方法里出現(xiàn)多個 DAO 調(diào)用或需要事務(wù) → Service。Q2和第 29 課文件版 Todo 對照一下維度第 29 課文件版第 15 課數(shù)據(jù)庫版存儲介質(zhì)txt 文件每行一條MySQL 兩張表讀取方式整文件讀入內(nèi)存 splitSQL 查詢 ResultSet 遍歷寫入方式file.write(content)覆蓋寫stmt.update()增/改/刪多用戶支持不支持全堆一個文件天然支持user 表分區(qū)分層全堆 mainModel DAO Service 三層并發(fā)安全進程獨占不會被兩個進程同時改靠事務(wù)鎖 連接池真實生產(chǎn)問題你可以看到Model、DAO、Service 三層在兩個版本里一模一樣只有 DAO 里的怎么存/怎么取變了。這就是本課想傳達的——業(yè)務(wù)邏輯和存儲技術(shù)解耦。Q3為什么不用ConnectionPool數(shù)據(jù)庫連接建立一次成本很高TCP 握手 認證 默認 schema 設(shè)置。真實服務(wù)器應(yīng)該用連接池應(yīng)用啟動時先建 10 條放著用的時候拿一條用完還回去而不是關(guān)掉。但本課和 mariadb-driver 1.2.0 都沒內(nèi)置連接池——我們只做一次連接、用完關(guān)掉的最簡場景。連接池是生產(chǎn)級優(yōu)化初學者階段先把單連接能跑通吃透再說。Q4如果將來想換 SQLite改一行 URLsqlite:///path/to/db.sqlite前提是換用 SQLite 驅(qū)動。Service 層和 DAO 層一行都不用改——因為我們用的都是標準 SQL std.database.sql標準接口不依賴 mariadb 的特定擴展。這就是分層解耦的好處。下一課預(yù)告第 16 課 反射 —— 標準庫std.reflect能讓程序在運行時看見自己類有哪些字段方法簽名是什么字段叫什么名字反射是 ORM、序列化框架、依賴注入的底層工具也是倉頡類型系統(tǒng)的后門。系列說明本系列基于 Windows 平臺 CIDE 倉頡 SDK1.2.0編寫。本文全部代碼、運行輸出與報錯文案均已在 SDK 1.2.0 MySQL 環(huán)境下逐塊編譯運行、逐字實測如遇 SDK 版本差異導(dǎo)致的細節(jié)出入以你本地版本為準歡迎評論區(qū)交流。 遇到問題掃碼聯(lián)系作者跟著課程練習時如果在 SDK 安裝、環(huán)境變量配置、編譯報錯或調(diào)試上卡住歡迎掃碼加作者企業(yè)微信直接咨詢請備注倉頡課程離線環(huán)境下圖片可能加載不出來也可以在 CIDE 菜單Help ? 聯(lián)系作者 / Contact中查看同一張二維碼應(yīng)用內(nèi)置兜底圖無需聯(lián)網(wǎng)。 工具下載本系列全程使用的倉頡 IDE ——CIDE免費開源、社區(qū)版GitCode 倉庫 / 安裝包下載https://gitcode.com/wp_upala/cide打開頁面后進入發(fā)行版Releases兩種包任選其一安裝版下載CIDE-版本-x64-Setup.exe雙擊安裝適合日常長期使用免安裝版Portable下載CIDE-版本-x64-Portable.zip解壓到任意目錄即用不寫注冊表、不留安裝痕跡拷到 U 盤也能在別的電腦直接運行包內(nèi)附《使用說明.txt》。適合先試用、或在受限電腦上學習本系列課程。倉頡 SDK 請前往倉頡編程語言官網(wǎng)下載https://cangjie-lang.cn