【仓颉语言入门 · 第二篇 第 14 课】

【仓颉语言入门 · 第二篇 第 14 课】手写 DAO 数据访问层

上一课事务把"转账要么全成要么全不算"解决了,可回看那段代码:连接、重置余额、prepareStatement、set、update、事务三件套……全挤在 main 里。业务一多——开户、查余额、转账、销户——main 就成了一锅粥,每加一个功能都得在业务流程和 SQL 细节之间来回穿针。这一课把这些散落的 SQL 收进一个专门的类 AccountDao:业务代码只管说"我要转账""我要查 zhangsan 的余额",至于 SQL 怎么写、占位符怎么填、事务怎么包,全是 DAO 内部的事。这正是第一篇第 29 课"仓库层"思想在数据库上的回响。


目录(第二篇导航)

第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~15 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战
三、进阶硬核篇 16~22 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池
四、实战项目篇 23~26 HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫
  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. 对象池与资源复用
  23. 实战三:HTTP 服务端基础
  24. 实战四:HTTP 进阶与 JSON 接口
  25. 实战五:多线程日志分析器
  26. 实战六:简易爬虫

一、问题现场:main 函数越来越胖

把第 13 课事务版转账和第 9 课的查询拼在一起,main 就长这样:

main(): Int64 {
    let driver = DriverManager.getDriver("mariadb").getOrThrow()
    let ds = driver.open("mariadb://127.0.0.1:3306", [
        ("username", "root"),
        ("password", "wangpeng"),
        ("database", "tumo_governance")
    ])
    let conn = ds.connect()

    // 查 zhangsan 的余额
    let stmt1 = conn.prepareStatement("SELECT balance FROM account WHERE name = ?")
    stmt1.set(1, "zhangsan")
    let rs1 = stmt1.query()
    if (rs1.next()) {
        println("zhangsan 余额:${rs1.get<Int64>(1)}")
    }
    rs1.close()
    stmt1.close()

    // 转账:zhangsan → lisi 100
    let tx = conn.createTransaction()
    tx.begin()
    try {
        let stmt2 = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE name = ?")
        stmt2.set(1, 100)
        stmt2.set(2, "zhangsan")
        stmt2.update()
        stmt2.close()

        let stmt3 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE name = ?")
        stmt3.set(1, 100)
        stmt3.set(2, "lisi")
        stmt3.update()
        stmt3.close()

        tx.commit()
        println("转账成功")
    } catch (e: SqlException) {
        tx.rollback()
        println("转账失败:${e.message}")
    }

    conn.close()
    0
}

才两个业务动作,main 就塞了三段 SQL、两个 Statement、一个 ResultSet、一个事务。想象再加"开户""销户""查所有人"——每条业务逻辑都要在"连接怎么拿、SQL 怎么拼、结果怎么读、异常怎么接"里趟一遍,业务流程和数据库细节缠死在一起。

问题根源:SQL 是数据访问细节,却和业务逻辑混在同一层。解法是把它们分开——业务层只描述"做什么",数据访问层负责"怎么存/怎么取"。这个负责存取的层,就是 DAO。


二、DAO 是什么:一张表一个类,增删改查收进去

DAO(Data Access Object,数据访问对象)的模式很简单:为每张表写一个类,把这张表的所有 SQL 操作收进这个类的方法里。外部只调方法,看不见 SQL。

业务代码                AccountDao(DAO)              数据库
  │                        │                            │
  │  dao.transfer(...)     │  prepareStatement + 事务   │
  │ ─────────────────────→ │ ─────────────────────────→ │
  │                        │ ←───────────────────────── │
  │ ←───────────────────── │  返回结果/抛异常            │

对 account 表,我们要这些操作:按名查、查全部、开户、改余额、转账、销户。把它们收进 AccountDao,业务代码就只剩一行 dao.transfer("zhangsan", "lisi", 100)。

动手前先建一个承载数据的数据模型类。


三、先建数据模型:Account 类

数据库里一行 account 记录,在程序里用一个对象承载最自然——这就是第一篇第 15 课的 class:

class Account {
    let id: Int64
    let name: String
    var balance: Int64

    init(id: Int64, name: String, balance: Int64) {
        this.id = id
        this.name = name
        this.balance = balance
    }
}

id、name 创建后不变,用 let;balance 后续可能改,用 var。这个类不碰数据库,纯粹是"一行数据的搬运工"。


四、AccountDao:把增删改查收进来

DAO 类持有一个连接,每个方法封装一次"准备语句 → 填值 → 执行 → 收结果 → 关闭":

class AccountDao {
    let conn: Connection

    init(conn: Connection) {
        this.conn = conn
    }

    // 按姓名查账户,查不到返回 None
    func findByName(name: String): ?Account {
        let stmt = conn.prepareStatement("SELECT id, name, balance FROM account WHERE name = ?")
        stmt.set(1, name)
        let rs = stmt.query()
        var result: ?Account = None
        if (rs.next()) {
            result = Some(Account(
                rs.get<Int64>(1),
                rs.get<String>(2),
                rs.get<Int64>(3)
            ))
        }
        rs.close()
        stmt.close()
        return result
    }

    // 开户,返回插入的行数
    func insert(name: String, balance: Int64): Int64 {
        let stmt = conn.prepareStatement("INSERT INTO account (name, balance) VALUES (?, ?)")
        stmt.set(1, name)
        stmt.set(2, balance)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }

    // 改余额,返回受影响的行数
    func updateBalance(name: String, balance: Int64): Int64 {
        let stmt = conn.prepareStatement("UPDATE account SET balance = ? WHERE name = ?")
        stmt.set(1, balance)
        stmt.set(2, name)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }

    // 按名销户,返回删除的行数
    func deleteByName(name: String): Int64 {
        let stmt = conn.prepareStatement("DELETE FROM account WHERE name = ?")
        stmt.set(1, name)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }
}

几个要点:

  1. 构造函数只接一个 Connection。DAO 不自己建连接——连接由外部(main)建好传进来,DAO 只用它。这样 DAO 可测、可复用,不绑死数据库地址。
  2. 每个方法自己 prepare、自己 close。用完即关,不跨方法持有 Statement,避免泄漏。
  3. 取列用第 9 课的 rs.get<T>(序号)。序号按 SELECT 后面的列顺序从 1 数:id 是 1、name 是 2、balance 是 3。别想当然写成 getInt64("列名")——本课实测 QueryResult 身上没有按列名取值的方法,编译器会报 'getInt64' is not a member of interface 'QueryResult'。
  4. 写操作的影响行数取 update().rowCount。第 10/11 课讲过 update() 返回的是 UpdateResult 对象,不是数字——直接 return stmt.update() 会报 mismatched types ... found 'Interface-UpdateResult',取 .rowCount 才是 Int64。
  5. 占位符 ? + set()。第 12 课的防注入在这里自然落地——业务层传进来的 name 永远只当数据填,不会被当 SQL 解析。
  6. findByName 返回 ?Account。查不到返回 None,查到返回 Some(account),调用方用 match 或 if-let 处理,比抛异常更符合"查询可能没结果"的语义。

五、把事务也收进来:transfer 方法

第 13 课的事务三件套,同样收进 DAO 的一个方法里。业务层调 dao.transfer(...),完全不知道内部有事务:

class AccountDao {
    let conn: Connection

    init(conn: Connection) {
        this.conn = conn
    }

    // ……上面的 findByName / insert / updateBalance / deleteByName 略 ……

    // 转账:from 给 to 转 amount,要么全成要么全不算
    func transfer(from: String, to: String, amount: Int64): Bool {
        let tx = conn.createTransaction()
        tx.begin()
        try {
            let stmt1 = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE name = ?")
            stmt1.set(1, amount)
            stmt1.set(2, from)
            stmt1.update()
            stmt1.close()

            let stmt2 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE name = ?")
            stmt2.set(1, amount)
            stmt2.set(2, to)
            stmt2.update()
            stmt2.close()

            tx.commit()
            return true
        } catch (e: SqlException) {
            tx.rollback()
            println("转账回滚:${e.message}")
            return false
        }
    }
}

事务被封装在方法内部:成功返回 true,失败回滚后返回 false。调 transfer 的业务代码不用碰 tx,也不需要调 begin/commit/rollback——事务的成败一致性成了 DAO 的内部保证,业务层少写一步就不容易出错。


六、业务代码变什么样:清爽的 main

把上面三节合到一起,main 就只剩"建连接 → 建 DAO → 调业务方法 → 关连接":

package cj_mysql_demo

import std.database.sql.*
import mariadb.cdbc

class Account {
    let id: Int64
    let name: String
    var balance: Int64
    init(id: Int64, name: String, balance: Int64) {
        this.id = id
        this.name = name
        this.balance = balance
    }
}

class AccountDao {
    let conn: Connection
    init(conn: Connection) {
        this.conn = conn
    }
    func findByName(name: String): ?Account {
        let stmt = conn.prepareStatement("SELECT id, name, balance FROM account WHERE name = ?")
        stmt.set(1, name)
        let rs = stmt.query()
        var result: ?Account = None
        if (rs.next()) {
            result = Some(Account(rs.get<Int64>(1), rs.get<String>(2), rs.get<Int64>(3)))
        }
        rs.close()
        stmt.close()
        return result
    }
    func insert(name: String, balance: Int64): Int64 {
        let stmt = conn.prepareStatement("INSERT INTO account (name, balance) VALUES (?, ?)")
        stmt.set(1, name)
        stmt.set(2, balance)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }
    // updateBalance、deleteByName 同第四节,main 里用不到,此处从略
    func transfer(from: String, to: String, amount: Int64): Bool {
        let tx = conn.createTransaction()
        tx.begin()
        try {
            let s1 = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE name = ?")
            s1.set(1, amount); s1.set(2, from); s1.update(); s1.close()
            let s2 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE name = ?")
            s2.set(1, amount); s2.set(2, to); s2.update(); s2.close()
            tx.commit()
            return true
        } catch (e: SqlException) {
            tx.rollback()
            println("转账回滚:${e.message}")
            return false
        }
    }
}

main(): Int64 {
    let driver = DriverManager.getDriver("mariadb").getOrThrow()
    let ds = driver.open("mariadb://127.0.0.1:3306", [
        ("username", "root"),
        ("password", "wangpeng"),
        ("database", "tumo_governance")
    ])
    let conn = ds.connect()
    let dao = AccountDao(conn)

    // 查 zhangsan
    match (dao.findByName("zhangsan")) {
        case Some(acc) => println("zhangsan 余额:${acc.balance}")
        case None => println("查无此人")
    }

    // 开个新户
    dao.insert("wangwu", 500)
    println("wangwu 开户成功")

    // 转账
    if (dao.transfer("zhangsan", "lisi", 100)) {
        println("转账成功")
    } else {
        println("转账失败")
    }

    // 再查一次
    match (dao.findByName("zhangsan")) {
        case Some(acc) => println("zhangsan 余额:${acc.balance}")
        case None => println("查无此人")
    }

    conn.close()
    0
}

运行输出(假设初始 zhangsan 1000、lisi 1000、无 wangwu):

zhangsan 余额:1000
wangwu 开户成功
转账成功
zhangsan 余额:900

对比第一节的"一锅粥",业务流程现在一目了然:查、开、转、查。SQL 一个字都没出现在 main 里。以后要加"销户",直接用第四节写好的 deleteByName——main 里多一行 dao.deleteByName("wangwu") 就完事,数据库细节不再污染业务逻辑。


七、常见报错速览

忘了关 Statement / ResultSet:

// 偶发:连接资源耗尽,新的 prepareStatement 卡住或抛异常

DAO 里每个方法都要保证 stmt.close()、rs.close() 执行。本课的写法是"用完立刻关",简单可靠;进阶项目里可用 try-finally 确保异常路径也关。

给 DAO 传了已关闭的连接:

SqlException: Connection closed, errorCode: 0

连接的生命周期归 main 管:先 conn.close() 再调 DAO 方法就会报这个(本课实测文案,异常在 prepareStatement 那一行抛出)。记住顺序:所有 DAO 调用完毕,再关连接。

findByName 返回 None 却直接取值:

// 编译期:Option 类型未处理

?Account 必须用 match 或 if-let 拆开,不能直接 .balance。这正是第一篇第 9 课可空类型的安全保证——编译器逼你处理"没查到"的情况。


八、常用 API 速查(DAO 篇)

代码 作用
class XxxDao { let conn: Connection; init(conn) { ... } } DAO 骨架:持有连接,不自己建连
func find(...): ?T 查询返回 Option,查到 Some,查不到 None;取列用 rs.get<T>(序号)
func insert/update/delete(...): Int64 写操作返回影响行数,取 stmt.update().rowCount
func transfer(...) : Bool 事务型操作,内部 begin/commit/rollback,返回成败
match (dao.find(...)) { case Some(x) => ...; case None => ... } 业务层处理查询结果
固定套路 方法内:prepare → set → query/update → close;事务方法多包一层 begin/try-commit/catch-rollback

九、常见问题 FAQ

Q1:DAO 每个方法都 prepareStatement,会不会很慢?

预编译的开销主要在第一次"编译 SQL 模板"。同一个连接上反复 prepare 同一条 SQL,服务器会复用已编译的执行计划,开销远小于首次。对学习项目和中小规模业务,这个开销可以忽略。真到了高并发场景,再上"连接池 + 预编译语句缓存",那是进阶话题。

Q2:一个 DAO 只管一张表,跨表操作怎么办?

跨表操作(比如转账动了 account 表两行、或"下单"要同时写 order 和 order_item)通常有两种做法:① 在其中一个 DAO 里写跨表方法(如本课 transfer 就动了同一张表的两行);② 建一个更高层的 Service,组合多个 DAO + 事务。本课先把"单表 DAO"练熟,第 15 课综合实战会触及 Service 层。

Q3:DAO 里的事务方法返回 Bool,那异常信息不就丢了?

本课 transfer 把异常信息 println 出来后返回 false,业务层只知道成败、不知道细节。真实项目里更常见的做法是:回滚后把异常重新抛出去(throw e),让业务层或最外层统一处理。本课为了演示清晰用了 Bool,你可以试着改成抛异常版本作为练习。

Q4:能不能让 DAO 自己管连接的开关?

可以,但不推荐。让 DAO 自己 open/close 连接,意味着每个方法都要建连、关连——频繁建连很慢,且事务方法跨多条 SQL 时连接不能中途断。连接归外部管、DAO 只借用,是更灵活也更省资源的做法。这叫"依赖注入":连接从外面注入,DAO 不自己 new。


下节预告

DAO 把单表的增删改查收好了,可一个真实小项目不止一张表、不止一个 DAO——还要建连接、关连接、把多个 DAO 串成一个业务流程。下一课 数据库综合小实战,把这几课的全部知识捏成一个完整小例子:建表、灌数据、写 UserDao + AccountDao、在 main 里跑通"开户→查询→转账→销户"全流程,给数据库篇收个尾。


系列说明:本系列基于 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
posted @ 2026-10-24 00:13  upala  阅读(4)  评论(0)    收藏  举报