将订单保存到 SQLite
我们已经把对象接好了,但内存里的订单会随进程消失。这次只换存储实现,用相同的 HTTP 请求验证数据真的留在了磁盘上。
本章内容
先观察内存丢失的究竟是什么
停止现有订单服务,在工程根目录重新运行内存版。在另一个终端创建一笔新订单,记下它的完整 ID。Windows 使用 curl.exe;不要把 ORDER_ID 原样发送出去。
go run ./cmd/orders -memorycurl -i -X POST http://127.0.0.1:8080/orders -H "Content-Type: application/json" --data-binary "@requests/create.json"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 是本章练习文件;若你已有同名重要文件,请换一个未使用的名字,并同步替换后续命令。不要删除已有数据库。
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) 把路径交到这里:
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,让同进程数据库操作排队使用它。这便于解释状态更新,但不是高吞吐数据库设计,也不保证多个进程之间不会发生竞争。
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 值变成一行记录
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
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))
}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,也不只是查询同一个尚未关闭的对象。
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 查询这里的订单。