将订单保存到 SQLite

我们已经把对象接好了,但内存里的订单会随进程消失。这次只换存储实现,用相同的 HTTP 请求验证数据真的留在了磁盘上。

本章内容

先观察内存丢失的究竟是什么

停止现有订单服务,在工程根目录重新运行内存版。在另一个终端创建一笔新订单,记下它的完整 ID。Windows 使用 curl.exe;不要把 ORDER_ID 原样发送出去。

终端 1 · 内存版
go run ./cmd/orders -memory
终端 2 · 使用工程自带的请求文件
curl -i -X POST http://127.0.0.1:8080/orders -H "Content-Type: application/json" --data-binary "@requests/create.json"
用本次创建的 ID 查询
curl -i http://127.0.0.1:8080/orders/ORDER_ID

现在应为 200。Ctrl+C 停止终端 1,再用相同的 -memory 命令启动;不要重新创建,直接查询刚才的 ID。

重启后的关键响应
HTTP/1.1 404 Not Found

{"error":"order_not_found"}

代码并没有删除某笔订单,而是新进程重新执行 memory.New(),得到一个空 map。原进程的 map 已经不存在。ID 仍然有效地标识那次创建,只是当前存储里已经没有对应记录。

把同一个实验换成 SQLite

停止内存版,在同一个工作目录执行下面的命令。lesson.db 是本章练习文件;若你已有同名重要文件,请换一个未使用的名字,并同步替换后续命令。不要删除已有数据库。

终端 1 · 省略 -memory,显式指定文件
go run ./cmd/orders -db lesson.db

等到 listeners ready,再执行上一节的创建命令。这次要记下新响应的 ID,以及 SKU、quantity、status、created_at;不要继续用内存版的旧 ID。先查询一次,确认返回 200。

然后停止服务,再执行完全相同的 go run ./cmd/orders -db lesson.db,查询这次的新 ID。

本次应得到的对照结果
内存版:创建 → 201;重启前查询 → 200;重启后查询 → 404
SQLite:创建 → 201;重启前查询 → 200;重启后查询 → 200
SQLite 的两次查询:ID、SKU、数量、状态、创建时间均相同
不以“文件出现了”作为完成标准

要确认保存成功,必须用重启前记录的同一个 ID 查回同一笔订单。重启后再创建一笔只能证明服务能工作,不能证明旧数据保留了。

从 -db 参数找到真实文件

上一章 main 中的 sqlite.Open(ctx, *dbPath) 把路径交到这里:

internal/sqlite/store.go节选 · L22
func Open(ctx context.Context, path string) (*Store, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, err
	}
	// This teaching service uses one connection and one process. Operations
	// queue here; this is NOT a multi-instance/high-throughput database design.
	db.SetMaxOpenConns(1)
	if _, err = db.ExecContext(ctx, schema); err != nil {
		_ = db.Close()
		return nil, err
	}
	return &Store{db: db}, nil
}

lesson.db 是相对路径,相对于你启动程序时的工作目录,不是相对于 store.go。换目录启动,即使参数文字一样,也可能打开另一个文件。查不到旧数据时,先检查这一点和是否误加了 -memory。

sql.Open 得到数据库连接池对象,后续执行 schema 才实际触发数据库操作;因此不能仅凭 Open 的名字就宣布数据库已准备好。此处任何一步失败,错误都返回给 main。初始化失败还会主动关闭已创建的 db。

示例把连接数限制为 1,让同进程数据库操作排队使用它。这便于解释状态更新,但不是高吞吐数据库设计,也不保证多个进程之间不会发生竞争。

internal/sqlite/schema.sqlL1
CREATE TABLE IF NOT EXISTS orders (
    id TEXT PRIMARY KEY,
    sku TEXT NOT NULL,
    quantity INTEGER NOT NULL CHECK (quantity BETWEEN 1 AND 20),
    status TEXT NOT NULL CHECK (status IN ('pending', 'cancelled')),
    created_at TEXT NOT NULL
);

schema 通过 //go:embed 编进程序。第一次启动创建 orders 表:id 是主键,quantity 和 status 有约束,时间暂存为文本。IF NOT EXISTS 表示已有表就不再创建,并不表示比较结构后自动升级旧表。

一笔订单怎样从 Go 值变成一行记录

internal/sqlite/store.go节选 · L40
func (s *Store) Create(ctx context.Context, o order.Order) error {
	_, err := s.db.ExecContext(ctx,
		`INSERT INTO orders (id, sku, quantity, status, created_at) VALUES (?, ?, ?, ?, ?)`,
		o.ID, o.SKU, o.Quantity, o.Status, o.CreatedAt.Format(time.RFC3339Nano))
	return err
}

还记得 Service.Create 最后调用 store.Create 吗?现在实际执行的是上面这个方法。Service 已经生成好五个字段;Store 不再另造 ID,而是按 SQL 列顺序把它们作为参数传入。问号是参数位置,不需要把 SKU 拼进 SQL 字符串。

CreatedAt 在写入前格式化成 RFC3339Nano 文本。数据库执行失败,Create 返回 error,Service 随之返回 error,HTTP 入口再映射为失败响应;因此只有这条写入返回成功,上层才会发出 201。

查询怎样把这一行还原成 Order

internal/sqlite/store.go节选 · L47
func (s *Store) Get(ctx context.Context, id string) (order.Order, error) {
	return scan(s.db.QueryRowContext(ctx,
		`SELECT id, sku, quantity, status, created_at FROM orders WHERE id = ?`, id))
}
internal/sqlite/store.go节选 · L78
func scan(row *sql.Row) (order.Order, error) {
	var o order.Order
	var created string
	if err := row.Scan(&o.ID, &o.SKU, &o.Quantity, &o.Status, &created); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return order.Order{}, order.ErrNotFound
		}
		return order.Order{}, err
	}
	t, err := time.Parse(time.RFC3339Nano, created)
	o.CreatedAt = t
	return o, err
}

SELECT 的列顺序与 Scan 的接收顺序一一对应;这不是按字段名自动匹配。前四项填入 Order,时间文本先读到 created,再解析成 time.Time。这样上层得到的仍是 Order,不需要知道数据库如何表示时间。

找不到记录时,数据库返回 sql.ErrNoRows。适配器把它转换成业务的 order.ErrNotFound;HTTP 只认识后者并返回 404,所以更换存储不会要求 HTTP handler 认识每个数据库驱动的错误。

把“关闭再打开”变成回归测试

验证持久化和数据库数量约束
go test ./internal/sqlite -run '^(TestPersistsAfterReopen|TestQuantityConstraint)$' -v -count=1

两项应为 PASS。internal/sqlite/store_test.go 的 TestPersistsAfterReopen 在测试临时目录建库,创建并取消一笔订单,关闭数据库,再打开同一路径比对整笔订单。它不碰你的 lesson.db,也不只是查询同一个尚未关闭的对象。

internal/sqlite/store_test.go节选 · L42
	if err := db.Close(); err != nil {
		t.Fatal(err)
	}
	reopened, err := sqlite.Open(ctx, path)
	if err != nil {
		t.Fatal(err)
	}
	defer reopened.Close()
	got, err := order.New(reopened).Get(ctx, created.ID)
	if err != nil || got != cancelled {
		t.Fatalf("reopened order = %+v, %v; want %+v", got, err, cancelled)
	}
}

TestQuantityConstraint 则刻意绕过 Service,直接向 SQLite Store 传入 0、1、20、21,验证表本身的约束。这不是推荐业务绕过 Service,而是针对存储边界的测试。

换一个文件,解释为什么订单消失又出现

保留刚才的 ID。停止服务,用一个未使用过的文件名启动,例如 go run ./cmd/orders -db lesson-alternate.db。不创建订单,直接查询旧 ID,应为 404。再停止,改回 -db lesson.db,同一个 ID 应重新得到 200。

这次数据没有被删掉或凭空恢复;你只是把同一个查询交给了两个不同的文件。练习后保持 lesson.db 运行,下一章用 TCP 查询这里的订单。

项目教程

构建一个订单服务

将订单保存到 SQLite

本页目录