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

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

上一课我们建表又删表,折腾的都是结构;而 sys_user 里躺着的 9 行真实用户数据,一直等着被读。增删改查里,查 是最高频的一个,也是唯一"有去有回"的操作------发一句 SELECT 出去,服务器会回一张结果表回来。这一课就学会三件事:发出第一条 SELECT、用 next() 把数据一行行搬进仓颉程序、处理好"数据库里的 NULL 到了仓颉怎么办"。


目录(第二篇导航)

第一篇《仓颉语言入门》共 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. 实战六:简易爬虫

一、先在客户端里把 SQL 查明白

数据库开发有个好习惯:先把 SQL 在客户端里试对,再搬进代码。打开 MySQL 命令行客户端,三条查询快速过一遍。

第一条,查出所有用户的登录名和昵称------SELECT 后面写列名,FROM 后面写表名:

sql 复制代码
USE tumo_governance;
SELECT user_name, nick_name FROM sys_user;
text 复制代码
+-----------+--------------+
| user_name | nick_name    |
+-----------+--------------+
| admin     | 管理员       |
| zhangsan  | 张三         |
| test      | 测试用户     |
| lisi      | 里斯         |
| wangwu    | 王五         |
| sunqi     | 孙七         |
| liang     | 李昂         |
| hbwh      | 湖北武汉     |
| zhangsan1 | 张三         |
+-----------+--------------+

第二条,加 WHERE 只看男性用户(sex = 1)------9 行变 8 行,test 被过滤掉了:

sql 复制代码
SELECT user_name, sex FROM sys_user WHERE sex = 1;
text 复制代码
+-----------+-----+
| user_name | sex |
+-----------+-----+
| admin     |   1 |
| zhangsan  |   1 |
| lisi      |   1 |
| wangwu    |   1 |
| sunqi     |   1 |
| liang     |   1 |
| hbwh      |   1 |
| zhangsan1 |   1 |
+-----------+-----+

第三条,ORDER BY ... DESC 倒序排 + LIMIT 3 取前三名,看看"最近登录排行榜"的榜头:

sql 复制代码
SELECT user_name, login_time FROM sys_user
WHERE login_time IS NOT NULL
ORDER BY login_time DESC
LIMIT 3;
text 复制代码
+-----------+---------------------+
| user_name | login_time          |
+-----------+---------------------+
| hbwh      | 2026-07-30 22:08:44 |
| zhangsan1 | 2026-07-30 21:59:18 |
| admin     | 2026-07-30 21:36:59 |
+-----------+---------------------+

注意 login_time 允许为空,没登录过的用户这一列是 NULL,所以用 WHERE login_time IS NOT NULL 先把他们请出排行榜(判断"是不是 NULL"要用 IS / IS NOT,不能写 = NULL)。

SELECT 的语法当然远不止这些,但本课只需要这三板斧:挑列、过滤、排序限量。SQL 试对了,下面把它们原样搬进仓颉。


二、仓颉代码:第一条 SELECT

沿用 cj_mysql_demo 工程,把 src/main.cj 改成:

cangjie 复制代码
package cj_mysql_demo

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

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 stmt = conn.prepareStatement("SELECT user_name, nick_name FROM sys_user")
    let result = stmt.query()
    while (result.next()) {
        let name = result.get<String>(1)
        let nick = result.get<String>(2)
        println("${name}(${nick})")
    }

    result.close()
    stmt.close()
    conn.close()
    0
}

运行输出:

text 复制代码
admin(管理员)
zhangsan(张三)
test(测试用户)
lisi(里斯)
wangwu(王五)
sunqi(孙七)
liang(李昂)
hbwh(湖北武汉)
zhangsan1(张三)

和客户端里看到的一模一样,只是现在数据进了我们的程序。四个要点:

  • query() 与 update() 的分工 (第 8 课埋的伏笔):update() 发给"没有结果表"的语句(DDL、INSERT 等),query() 发给 SELECT------它返回一个 QueryResult 结果集,服务器查出来的那张表就在里面。
  • 结果集是单向游标 :result 刚拿到时停在第一行之前 ,next() 让游标前进一行------有行就返回 true,没行了就返回 false。所以 while (result.next()) 是标准遍历写法:循环体每执行一次,游标正好停在一行数据上;最后一行读完,next() 返回 false,循环自然结束。
  • get<T>(序号) 按列取数 :序号是 SELECT 后面列的顺序,从 1 开始 (user_name 是 1,nick_name 是 2);尖括号里的 T 是"这一列在仓颉里算什么类型",下一节细讲。
  • 三个 close 一个不能少:结果集、语句、连接,用完从小到大依次还回去。

小提示 :编译时的 warning: unused import 'mariadb.cdbc' 老规矩,忽略即可(驱动自注册,第 7 课 FAQ 讲过)。


三、按列取数:类型要对上

get<T> 的 T 不是随便写的------MySQL 列类型和仓颉类型有对应关系,第 8 课的类型表现在派上用场:

MySQL 类型 仓颉类型(get 的泛型参数)
INT Int32
BIGINT Int64
VARCHAR(n) String
DATETIME / TIMESTAMP DateTime
DECIMAL(m,n) Decimal

对照 sys_user 的结构:id 是 bigint → Int64,user_name 是 varchar → String,sex 是 int → Int32。写个按类型取数的例子(WHERE 子句直接搬第一节试好的):

cangjie 复制代码
let stmt = conn.prepareStatement("SELECT id, user_name, sex FROM sys_user WHERE sex = 1")
let result = stmt.query()
while (result.next()) {
    let id = result.get<Int64>(1)
    let name = result.get<String>(2)
    let sex = result.get<Int32>(3)
    println("id=${id} 姓名=${name} 性别=${sex}")
}
result.close()
stmt.close()
text 复制代码
id=1900010622019957256 姓名=admin 性别=1
id=1900010644348391560 姓名=zhangsan 性别=1
id=1900010653277109512 姓名=lisi 性别=1
id=1900010680132446728 姓名=wangwu 性别=1
id=1900010722168889096 姓名=sunqi 性别=1
id=1900010821105162120 姓名=liang 性别=1
id=1900010933547430664 姓名=hbwh 性别=1
id=1900010933650395016 姓名=zhangsan1 性别=1

19 位的分布式 ID 原样取出,Int64 装得下。取错类型会怎样?两种结局:

  • get<String> 最宽容 :就算列是 bigint,驱动也会帮你转成字符串,get<String>(1) 能拿到 "1900010622019957256"。拿不准时先按 String 取来看看,是排障小技巧。
  • 数值类型量级不够会溢出 :id 用 get<Int32>(1) 取,程序当场抛 OverflowException: The result would be greater than Int32.Max.------19 位的数塞不进 32 位。所以主键 ID 这类大数,老老实实用 Int64。

还有一个高频场景:只想要一个数字,比如用户总数。COUNT(*) 的结果当作 Int64 取即可;聚合查询保证只返回一行,用 if 代替 while 更贴切:

cangjie 复制代码
let stmt = conn.prepareStatement("SELECT COUNT(*) FROM sys_user")
let result = stmt.query()
if (result.next()) {
    println("用户总数:${result.get<Int64>(1)}")
}
result.close()
stmt.close()
text 复制代码
用户总数:9

四、数据库的 NULL:到了仓颉就是 Option

前三节取的列都有值,现在捅一下马蜂窝。avatar(头像)列允许为空,9 个用户里 8 个是 NULL------用 get<String> 直取看看:

cangjie 复制代码
let stmt = conn.prepareStatement("SELECT user_name, avatar FROM sys_user")
let result = stmt.query()
while (result.next()) {
    let name = result.get<String>(1)
    let avatar = result.get<String>(2)
    println("${name} 的头像:${avatar}")
}
result.close()
stmt.close()
text 复制代码
admin 的头像:
An exception has occurred:
NoneValueException
         at mariadb.cdbc::QueryResultImpl::get<...>(Int64)(...)
         at cj_mysql_demo::main()(...)

第一行 admin 打印完(他的头像存的是空字符串 ,所以冒号后面什么都没有,但不算错),游标走到第二行 zhangsan------他的 avatar 是 NULL,get 当场抛 NoneValueException。

这正是第 9 课(第一篇)Option 的现实意义:数据库的 NULL 在仓颉里没有对应的基本值,驱动把它映射成 Option 的 None 。取可空列要用 getOrNull<T>,返回 ?T,再配 ?? 给个兜底值:

cangjie 复制代码
while (result.next()) {
    let name = result.get<String>(1)
    let avatar = result.getOrNull<String>(2) ?? "(未设置)"
    println("${name} 的头像:${avatar}")
}
text 复制代码
admin 的头像:
zhangsan 的头像:(未设置)
test 的头像:(未设置)
lisi 的头像:(未设置)
wangwu 的头像:(未设置)
sunqi 的头像:(未设置)
liang 的头像:(未设置)
hbwh 的头像:(未设置)
zhangsan1 的头像:(未设置)

9 行全须全尾地出来了。注意第一行暴露了一个重要事实:NULL 和空字符串是两回事 ------admin 的头像是空字符串(get 正常取出 ""),其余 8 人才是真正的 NULL(走了 ?? 兜底)。数据库里这两者互不替代,程序里也走的是两条不同的代码路径。

经验法则一句话:DESC 表结构时 Null 列是 YES 的,一律 getOrNull;是 NO 的,才能放心 get。


五、结果集的"说明书":columnInfos

QueryResult 还随身带着每列的元数据,存在 columnInfos 属性里------每列一个 ColumnInfo,能读出列名(name)、仓颉侧类型名(typeName)、是否可空(nullable)等:

cangjie 复制代码
let stmt = conn.prepareStatement("SELECT id, user_name, sex, login_time FROM sys_user")
let result = stmt.query()
for (info in result.columnInfos) {
    println("列 ${info.name}:类型 ${info.typeName},允许空 ${info.nullable}")
}
result.close()
stmt.close()
text 复制代码
列 id:类型 Int64,允许空 false
列 user_name:类型 String,允许空 true
列 sex:类型 Int32,允许空 false
列 login_time:类型 DateTime,允许空 true

这份"说明书"和第三节的对照表完全吻合。眼下它最大的用处是自查 :拿不准某列该用什么类型取,先把 columnInfos 打出来看一眼。等第 14 课手写 DAO 做通用映射时,它会成为主力工具。


六、常见报错速览

查询阶段的报错就这几类,提前混个脸熟:

表名写错 (和客户端报错同款,SqlException):

text 复制代码
An exception has occurred:
SqlException: Table 'tumo_governance.sys_user_zzz' doesn't exist, errorCode: 0

列名写错:

text 复制代码
An exception has occurred:
SqlException: Unknown column 'user_namee' in 'field list', errorCode: 0

列序号从 1 开始,写 0 会被拒:

text 复制代码
An exception has occurred:
IllegalArgumentException: The index must be greater than 0

结果集 close 之后还取数:

text 复制代码
An exception has occurred:
SqlException: The database Connection has been closed, errorCode: 0

前两类是 SQL 本身的问题(回客户端把语句试对),后两类是代码流程的问题(序号、close 顺序)。加上第四节的 NoneValueException 和第三节的 OverflowException,查询的坑基本齐了。


七、常用 API 速查(查询篇)

任务 写法
执行 SELECT conn.prepareStatement(sql).query()
前进到下一行 result.next()(返回 false 表示读完)
取非空列 result.get<T>(列序号),序号从 1 开始
取可空列 result.getOrNull<T>(列序号) ?? 默认值
查列元数据 result.columnInfos(name / typeName / nullable)
关闭结果集 result.close()
类型对照 INT→Int32、BIGINT→Int64、VARCHAR→String、DATETIME/TIMESTAMP→DateTime

八、常见问题 FAQ

Q1:SELECT * 一把梭不行吗?为什么课文坚持写列名? get 靠列序号 取数,而 * 的列序是跟着表结构走的------哪天表里加一列,你程序里所有序号集体错位,还未必报错。写明列名,顺序由你的 SELECT 说了算;再说只取需要的列,网络传输也省。客户端里随手 * 无所谓,进代码的 SQL 都该写列名。

Q2:next() 遍历到一半能倒回去、或者再遍历一遍吗? 不能。结果集是单向游标 ,只进不退。想再用一遍就重新 query() 一次;如果数据要反复用,常见做法是遍历的同时存进 ArrayList。

Q3:getOrNull 取出来的时间直接打印,为什么套着一层 Some(...)? 比如 Some(2026-07-30T21:36:59+08:00)------这是 Option 默认的打印格式,提醒你"这是个可能有也可能没有的值"。业务输出里别直接打印 Option,先 ?? 兜底或 isSome() 分支拆开再打印(第一篇第 9 课的 Option 操作,忘了可以回去翻翻)。里面时间的 T 和 +08:00 是 DateTime 的标准格式(ISO 8601,带时区),想换格式用第 2 课学的 DateTime 格式化。

Q4:查询结果一行都没有时,程序会怎样? 什么都不发生------第一次 next() 就返回 false,循环体一次都不执行,也不抛异常。"查到 0 行"不是错误,是正常结果,需要特殊提示的话自己在循环后判断。


九、课后练习

前 3 题必做,第 4 题选做。都在 tumo_governance 库里操作,只读 sys_user,一个字符都不许改。

第 1 题(必做)查出男性用户的邮箱

在 cj_mysql_demo 工程里查询 sys_user 中 sex = 1 的用户,逐行打印登录名和邮箱。

提示:SQL 为 SELECT user_name, email FROM sys_user WHERE sex = 1;两列都是 varchar,用 get<String>(1)、get<String>(2) 取。

期望输出:

text 复制代码
admin 的邮箱:178421@163.com
zhangsan 的邮箱:178451@163.com
lisi 的邮箱:145165@163.com
wangwu 的邮箱:26151645@163.com
sunqi 的邮箱:178465@163.com
liang 的邮箱:123456@163.com
hbwh 的邮箱:178421@163.com
zhangsan1 的邮箱:145563.@com

第 2 题(必做)统计用户总数

用 SELECT COUNT(*) FROM sys_user 统计并打印用户总数。

提示:聚合查询只有一行,用 if (result.next());COUNT(*) 的结果用 get<Int64>(1) 取。

期望输出:

text 复制代码
用户总数:9

第 3 题(必做)亲手踩一次 NoneValueException,再修好它

分两步:① 查询 user_name 和 login_time,循环里用 get<String>(2) 直取登录时间并打印,运行确认程序打印出 admin、zhangsan 两行后抛 NoneValueException;② 把直取改成下面的分支写法,确认 9 行全部打印。

提示:login_time 是时间类型,可空列用 getOrNull<DateTime>(2) 取(需要 import std.time.*);Option 用 isSome() 判断有值、getOrThrow() 取出。

cangjie 复制代码
let t = result.getOrNull<DateTime>(2)
if (t.isSome()) {
    println("${name} 最近登录:${t.getOrThrow()}")
} else {
    println("${name} 从未登录")
}

期望输出(顺序即表中行的顺序):

text 复制代码
admin 最近登录:2026-07-30T21:36:59+08:00
zhangsan 最近登录:2026-07-05T18:32:05+08:00
test 从未登录
lisi 从未登录
wangwu 最近登录:2026-07-30T18:00:44+08:00
sunqi 最近登录:2026-07-20T17:38:04+08:00
liang 最近登录:2026-07-20T17:39:07+08:00
hbwh 最近登录:2026-07-30T22:08:44+08:00
zhangsan1 最近登录:2026-07-30T21:59:18+08:00

第 4 题(选做)最近登录排行榜

打印"最近登录排行榜":只统计登录过的用户,按 login_time 从近到远排序,逐行输出名次。

提示:SQL 就用第一节客户端里试过的 WHERE login_time IS NOT NULL ORDER BY login_time DESC(去掉 LIMIT 取全部);另开一个 var rank = 1 计数器,每行打印后 rank++;login_time 已被 WHERE 排除空值,可以安全 get<DateTime>(2)。

期望输出:

text 复制代码
第1名 hbwh(2026-07-30T22:08:44+08:00)
第2名 zhangsan1(2026-07-30T21:59:18+08:00)
第3名 admin(2026-07-30T21:36:59+08:00)
第4名 wangwu(2026-07-30T18:00:44+08:00)
第5名 liang(2026-07-20T17:39:07+08:00)
第6名 sunqi(2026-07-20T17:38:04+08:00)
第7名 zhangsan(2026-07-05T18:32:05+08:00)

下节预告

查了半天,sys_user 还是雷打不动的 9 行------我们只敢读,不敢写。下一课 插入:INSERT、占位符与自增主键 ------往自己建的练习表里写入第一行数据,学 set() 占位符参数怎么传、UpdateResult.lastInsertId 怎么把数据库自动编的主键拿回来。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 + MySQL 5.7 环境下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信 直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE ------ CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载 :gitcode.com/wp_upala/ci...
  • 打开页面后进入 发行版(Releases) ,两种包任选其一:
    • 安装版 :下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable) :下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:cangjie-lang.cn
相关推荐
Rain的Java大神之路1 小时前
别再死磕 MySQL 了:向量数据库与 RAG 生产落地实录(Java 工程师专场)
java·后端·面试
雨中飘荡的记忆2 小时前
SDD × Claude Code:先写清楚,再让 AI 写代码
后端
Thneonl2 小时前
主从延迟 0 秒的谎言:SBM 的三个盲区
后端·架构
孙浩君2 小时前
0行手写代码,我用AI做了一个打工人模拟器(含3D霓虹城市漫游)
前端·后端
羑悻2 小时前
超越Bridge:全面掌握Docker网络隔离与通信的底层逻辑!
后端
他日若遂凌云志2 小时前
深入剖析 ET8 框架的 ETTask 机制:await 的挂起、唤醒、异常与零 GC
后端
星轨zb2 小时前
[支付专题4]支付回调幂等性和状态:状态机与终态检查的边界
后端·支付架构学习
会编程的吕洞宾2 小时前
Spring AI 2.0 结构化输出实战:让大模型返回精准 JSON 不再“抽风“,准确率从 78% 拉到 99%
java·后端
用户298698530142 小时前
Python 实现 Word 与 TIFF 双向转换的方案
后端·python·api