database — 数据库
pkg/core/db 在 database/sql 上做一层薄封装:SQL 还是你写的 SQL,
但一份方言能跑三个驱动。QueryOne[Article] 这样的泛型辅助函数把行扫进结构体,
WithTx 管提交与回滚。这个示例默认用临时 SQLite 文件跑完所有步骤,零外部依赖。
运行
# 零外部依赖:临时 SQLite(纯 Go,无 cgo)
go run ./examples/database
# 真库(先 make dev)
go run ./examples/database -driver postgres \
-dsn 'postgres://matex:matex@localhost:5432/matex?sslmode=disable'
go run ./examples/database -driver mysql \
-dsn 'matex:matex@tcp(localhost:3306)/matex?parseTime=true'
不监听端口,不是 HTTP 服务。两个标志是 -driver(默认 sqlite)和
-dsn(默认空)。不传 -dsn 时,程序在临时目录建一个一次性
SQLite 文件,退出时删掉;此时 -driver 只能是 sqlite,
否则报 -dsn is required for driver "…" 并以状态码 2 退出。
它演示了什么
- 三个驱动、一份 SQL:SQL 里一律写
?,postgres 会由包自动改写成$1..$n。 - 泛型扫描:
QueryAll[T]/QueryOne[T]/QueryScalar[T],用dbtag 匹配列名。 - 扫描的几条宽容规则:多余的列被忽略(
SELECT *在表加列后照样能用)、NULL→nil指针、RFC3339 字符串 →time.Time。 - 未命中就是
db.ErrNoRow:用errors.Is判断,而不是比较字符串。 WithTx的提交与回滚:返回 nil 提交,返回 error(或 panic)自动回滚。db.Querier让 DAO 进出事务:同一组 DAO 方法既能在池上调用,也能在事务里调用,且事务内的读能看到本事务自己的写。
可移植的建表语句
下面这段来自 examples/database/main.go。它刻意避开三种方言的差异点:
主键用 VARCHAR(64) 而不是 TEXT(MySQL 不能索引裸 TEXT 主键),
没有 AUTO_INCREMENT/SERIAL/IDENTITY,
时间戳用 VARCHAR 并由 Go 侧填充。
const schema = `CREATE TABLE IF NOT EXISTS articles (
slug VARCHAR(64) NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
published BOOLEAN NOT NULL DEFAULT FALSE,
note VARCHAR(255) NULL,
updated_at VARCHAR(64) NOT NULL
)`
type Article struct {
Slug string `db:"slug" json:"slug"`
Title string `db:"title" json:"title"`
Views int64 `db:"views" json:"views"`
Published bool `db:"published" json:"published"`
Note *string `db:"note" json:"note"` // NULL → nil
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
DAO 收 db.Querier,不收 *db.DB
这是整个示例里最值得照抄的一条。同样来自 examples/database/main.go:
方法收一个 db.Querier 参数,*db.DB 和 *db.Tx 都实现它。
注意 QueryOne[T] 必须是包级函数——接口的方法不能带类型参数,
所以泛型扫描函数与 Querier 接口是一对,缺一不可。
// articleDAO is written against db.Querier rather than *db.DB, which is what
// lets the same methods serve the pool and a transaction.
type articleDAO struct{}
func (articleDAO) bySlug(ctx context.Context, q db.Querier, slug string) (*Article, error) {
return db.QueryOne[Article](ctx, q, `SELECT * FROM articles WHERE slug = ?`, slug)
}
func (articleDAO) insert(ctx context.Context, q db.Querier, a Article, now string) error {
_, err := db.Exec(ctx, q, `INSERT INTO articles (slug, title, views, published, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`, a.Slug, a.Title, a.Views, a.Published, a.Note, now)
return err
}
func (articleDAO) count(ctx context.Context, q db.Querier) (int64, error) {
return db.QueryScalar[int64](ctx, q, `SELECT COUNT(*) FROM articles`)
}
验证
看标准输出。每行左边是步骤标签,右边是真实数据:
go run ./examples/database
driver = sqlite
schema OK (driver=sqlite)
scalar count=1
query one slug=hello-matex views=1 published=false note=<nil> updated_at=2026-…Z
no row errors.Is(err, db.ErrNoRow) = true
tx commit count=3
tx rollback count=3 (unchanged)
query all 3 row(s), first=hello-matex
dao + tx count=4 (same DAO methods, tx and pool)
几步之间的数字是对得上的:插入 1 行 → scalar count=1;
事务里插 tx-a/tx-b 两行并提交 → count=3;
第二个事务写入 tx-c 后返回一个哨兵 error → 回滚,
count 仍是 3;最后 DAO 在事务里写入 tx-d 并提交 → count=4。
flowchart TD
A["db.Open(ctx, Config{Driver, DSN})"] --> B["d.Exec / QueryOne / QueryScalar
(走连接池)"]
B --> C["d.WithTx(ctx, fn)"]
C --> D["fn(ctx, *db.Tx)"]
D -- "返回 nil" --> E["Commit"]
D -- "返回 error / panic" --> F["Rollback"]
G["DAO 方法
收 db.Querier"] --> B
G --> D
E --> H["同一 DAO 方法
两种调用点都可用"]
classDef ok fill:#f0f7f0,stroke:#3a7a3a,color:#2b2722
classDef bad fill:#fdf0f0,stroke:#a33,color:#2b2722
class E,H ok
class F bad
db.Querier 这个共同接口,
因此不需要再写一套 …Tx 孪生方法。
测试
main_test.go 用 dbtest.Start(t) 拿到一个已迁移的数据库
(默认纯 Go SQLite,可离线跑),然后断言 run 返回的
result 结构体里的具体数值:
InitialCount == 1、AfterCommit == 3、AfterRollback == 3(tx-c不能落盘)、AfterDAO == 4。- 第一行是
slug == "hello-matex"、views == 1、published == false。 Note == nil——SQL 里的 NULL 扫成了 nil 指针。UpdatedAt非零——VARCHAR 里的 RFC3339 字符串扫成了time.Time。len(All) == 3且第一条是hello-matex(按 slug 排序)。
设置 DB_TEST_DRIVER / DB_TEST_DSN 可以让同一个测试跑在真库上。
go test ./examples/database
相关
相关示例
- 参考:命令:
make migrate与cmd/migrate。 - examples/lifecycle:进程退出时基础设施按什么顺序关闭。
- examples/observability:慢查询与请求如何共用同一个相关 id。