2.1 Chuyển store sang sqlc
Chuyển store sang sqlc
Giai đoạn 2 viết mọi query của database bằng tay: một chuỗi SQL trong Go, một lời gọi pgx, và một Scan chép từng cột vào field của struct. Giai đoạn 2.1 chuyển các query đó sang file .sql và để một công cụ tên là sqlc viết code Go chạy chúng. Những gì người gọi nhìn thấy không đổi: cùng các interface, cùng các test, cùng quy tắc tenant. Thứ thay đổi là ai viết phần nhàm chán, và lỗi được bắt vào lúc nào.
Bạn sẽ học được gì
- sqlc đọc gì, viết ra gì, và kiểm tra gì trước khi code của bạn chạy.
- Cách giữ quy tắc tenant khi SQL nằm trong file thay vì trong Go.
- Cách viết các filter tùy chọn trong một query mà nội dung không bao giờ đổi.
- sqlc dừng ở đâu, và phải làm gì khi một query không vừa.
- Cách tìm một query trong trace theo tên của nó.
Các từ dùng trong bài này
| Từ | Ý nghĩa |
|---|---|
| Code generation | Một công cụ viết source code giúp bạn từ một đầu vào khác. Bạn commit kết quả và không bao giờ sửa tay. |
| sqlc | Một code generator. Đầu vào: schema và các query SQL của bạn. Đầu ra: các hàm Go có kiểu rõ ràng để chạy những query đó. |
| Schema | Hình dạng của database: bảng, cột, kiểu, ràng buộc. Schema của hango là các goose migration trong migrations/. |
| Parameter | Một giá trị được gửi tới Postgres tách riêng khỏi phần chữ SQL, viết là $1 hoặc @name. Postgres không bao giờ trộn nó vào SQL, nên nó không thể gây SQL injection. |
| NULL | “Không có giá trị” của SQL. NULL = NULL không đúng, nên bạn kiểm tra bằng is null. |
| Row struct | Struct Go mà sqlc sinh ra để chứa một dòng kết quả của một query. |
| Escape hatch | Một query viết tay vì công cụ không diễn đạt được nó, kèm một comment nói lý do. |
| Stale | Code do sqlc sinh không còn khớp với đầu vào của nó, vì ai đó đã đổi một query hoặc một migration mà không generate lại. |
Vì sao đổi thứ đang chạy tốt
Giai đoạn 2 chọn SQL viết tay để mọi query, và predicate tenant bên trong nó, đều nhìn thấy được trong Go. Cách đó đã chạy tốt. Nhưng nó cũng có những cái giá tăng lên theo từng bảng:
- Một query chỉ được kiểm tra khi một test chạy nó. Một cột viết sai chính tả, một kiểu sai, hoặc một
Scancó thứ tự cột sai vẫn compile bình thường và chỉ lỗi lúc chạy. - Mỗi query lặp lại cùng danh sách cột và cùng lời gọi
Scan. Thêm một cột nghĩa là phải đi tìm từng cái. - Các filter tùy chọn được dựng bằng cách nối chuỗi, nên đoạn SQL thật sự chạy phụ thuộc vào request.
Giai đoạn 4 thêm agent, session và message, và sẽ sao chép bất kỳ pattern nào store đang có. Chuyển năm file query bây giờ rẻ hơn chuyển mười lăm file sau này. Đó cũng là lý do giai đoạn này tồn tại: hango là một dự án học tập, và sqlc đáng để học trên một codebase thật.
Phiên bản nhỏ nhất
Bạn viết một query, kèm một comment đặt tên cho nó và nói nó trả về bao nhiêu dòng:
-- name: GetUser :one
select * from users where tenant_id = @tenant_id and id = @id;sqlc đọc bảng users từ các migration, suy ra @tenant_id và @id là uuid và select * trả về sáu cột, rồi viết ra đoạn này:
type GetUserParams struct {
TenantID uuid.UUID `db:"tenant_id"`
ID uuid.UUID `db:"id"`
}
func (q *Queries) GetUser(ctx context.Context, arg GetUserParams) (User, error) {
row := q.db.QueryRow(ctx, getUser, arg.TenantID, arg.ID)
var i User
err := row.Scan(&i.ID, &i.TenantID, &i.Email, &i.Role, &i.Status, &i.CreatedAt)
return i, err
}Đó chính là code mà giai đoạn 2 đã viết bằng tay. Nếu query nhắc tới một cột không tồn tại, sqlc generate thất bại và không có Go nào được viết ra. Nếu một migration về sau bỏ một cột mà query dùng, generate lại cũng thất bại theo cách đó. Lỗi được bắt ngay tại bàn làm việc của bạn, không phải trong một test hay trên production.
Hậu tố sau tên nói hình dạng cần trả về: :one cho một dòng, :many cho một slice, :exec cho không có dòng nào.
Cách hango được nối dây
Các file
migrations/*.sql schema, read by sqlc (only the goose Up section)
sqlc.yaml settings: where to read, where to write, type choices
internal/store/pg/queries/ the queries, one file per table
internal/store/pg/*.sql.go generated, one per query file
internal/store/pg/models.go generated, one struct per table
internal/store/pg/db.go generated, the Queries type and the DBTX interface
internal/store/pg/user.go … hand-written, thin: scope in, store types outChạy make generate sau khi đổi một query hoặc một migration, rồi commit kết quả. make sqlc-check thất bại nếu code đã commit bị stale.
sqlc chạy bằng go run github.com/sqlc-dev/sqlc/cmd/[email protected], được ghim trong Makefile. Nó không nằm trong go.mod. Code do sqlc sinh chỉ import pgx, thứ hango vốn đã dùng, nên sqlc không bao giờ trở thành một phần của binary. Bạn tự kiểm tra được: go list -deps ./cmd/hango | rg sqlc không in ra gì. Lần chạy đầu tiên phải compile sqlc, mất vài phút vì nó chứa parser Postgres thật. Parser đó là code C, nên lần chạy đầu cần cgo và một C compiler (Xcode command line tools trên macOS, build-essential trên Debian). Các lần sau được cache.
Code do sqlc sinh nằm trong package pg
sqlc viết thẳng vào internal/store/pg, không phải một package riêng. Có hai lý do. Plan giới hạn hango ở hai mươi package, và một package pg/db sẽ tốn một suất cho code do sqlc sinh. Quan trọng hơn, pg là package duy nhất được phép import pgx, và một method do sqlc sinh cần một pgx connection (một DBTX), nên code ngoài pg không có cách nào gọi nó. Các method do sqlc sinh là exported, nên điều này dựa vào ranh giới import chứ không phải khả năng hiển thị. Quy tắc tenant từ giai đoạn 2 phụ thuộc vào ranh giới đó, và storeboundary_test.go vẫn bảo vệ nó.
Kiểu dữ liệu
Mặc định, sqlc dùng các kiểu riêng của pgx cho mọi thứ có thể NULL, như pgtype.Timestamptz và pgtype.UUID. Chúng sẽ rò rỉ vào mọi nơi gọi. sqlc.yaml ghi đè chúng:
uuidtrở thànhuuid.UUIDcủa thư viện chuẩn Go 1.27, hoặc*uuid.UUIDkhi cột có thể NULL.timestamptztrở thànhtime.Time, hoặc*time.Timekhi nó có thể NULL.jsonbtrở thànhjson.RawMessage.users.roletrở thànhstore.Rolevàtenants.kindtrở thànhstore.TenantKind, để các tập đóng từ giai đoạn 2 giữ nguyên kiểu của chúng.
Ba phép rename (trace_llm_content thành TraceLLMContent, api_key_id thành APIKeyID và api_key thành APIKey) khớp với cách đặt tên của Go. Với các thiết lập đó, hầu hết row struct do sqlc sinh có đúng các field giống như các type của store, theo cùng thứ tự. Go cho phép chuyển đổi giữa hai kiểu struct có field giống hệt nhau, nên mapper chỉ là một biểu thức:
u, err := us.s.db().GetUser(ctx, GetUserParams{TenantID: us.s.scope.TenantID, ID: id})
return store.User(u), mapErr(err)Nếu ai đó thêm một field vào store.User mà không thêm vào bảng, dòng đó sẽ không compile được nữa. Audit event là ngoại lệ duy nhất: bảng lưu một actor vắng mặt dưới dạng NULL, trong khi store.AuditEvent dùng uuid.Nil(), nên toStoreAudit trong audit.go map từng field một.
Quy tắc tenant giờ nằm ở đâu
Ở giai đoạn 2 quy tắc là: mọi statement trên một bảng thuộc về tenant đều bind tenant_id từ handle. Vẫn là quy tắc đó, giờ viết bằng SQL:
-- name: ListUsers :many
select * from users
where tenant_id = @tenant_id
and (sqlc.narg('only_user')::uuid is null or id = sqlc.narg('only_user'))
order by created_at;@tenant_id là một field bắt buộc của ListUsersParams. Code duy nhất điền nó là một method của sub-store trên scoped, lấy giá trị từ s.scope.TenantID. Không method nào nhận tenant làm đối số. Các test cross-tenant từ giai đoạn 2 chạy nguyên không đổi, và chúng là bằng chứng rằng việc chuyển đổi không mở ra một lỗ hổng.
ResolveAPIKey trong queries/auth.sql vẫn là query duy nhất không có tenant trên một bảng thuộc về tenant, vì việc tìm ra tenant chính là nhiệm vụ của nó. Chỉ store.AuthLookup gọi nó.
Filter tùy chọn mà không dựng chuỗi
Một member chỉ thấy user, key và dòng audit của chính mình. Giai đoạn 2 nối thêm and id = $2 vào chuỗi SQL khi người gọi là member. Nội dung query phụ thuộc vào người gọi.
sqlc cần nội dung cố định, nên filter luôn có mặt và một parameter tắt nó đi:
(sqlc.narg('only_user')::uuid is null or id = sqlc.narg('only_user'))sqlc.narg nghĩa là “nullable argument” (đối số có thể null), nên field Go là *uuid.UUID. Khi nó là nil, Postgres thấy NULL is null, tức là đúng, và filter cho mọi dòng đi qua. Khi nó chứa một id, chỉ dòng đó khớp. scoped.ownUserID() trả về nil cho owner và admin và id của người gọi cho một member, đúng thứ mà parameter cần. Danh sách audit dùng cùng mẹo này cho since, action và target_id.
Phép ép kiểu ::uuid cho sqlc biết kiểu. Thiếu nó, sqlc không biết NULL is null thuộc kiểu gì và sinh ra interface{}.
Limit vẫn được tính trong Go, mặc định 100 và tối đa 500, rồi bind thành @row_limit. Một limit trong SQL cũng là một parameter như mọi parameter khác.
Các query tự bảo vệ mình
Hai query của giai đoạn 2 mang nhiều hơn một filter, và cả hai được chuyển nguyên như cũ:
CreateAPIKeyinsert bằnginsert ... select ... from users where u.tenant_id = @tenant_id and u.id = @user_id. Nếu user không thuộc tenant, phần select không trả dòng nào, không có gì được insert, và method trả vềstore.ErrNotFound. Một foreign key một mình sẽ chấp nhận một user của tenant khác.LockTenantgiữfor no key update. Hai request của platform cùng kiểm tra “tenant này đã có owner chưa?” sẽ lấy lock trước, nên request thứ hai chờ request thứ nhất commit thay vì cả hai cùng tạo owner. Các insert vào bảng tham chiếu tới tenant không bị chặn.
Transaction
Hàm New(db DBTX) do sqlc sinh nhận bất cứ thứ gì có Exec, Query và QueryRow. Interface querier của hango từ giai đoạn 2 có ba method đó cộng thêm Begin, nên nó vừa vặn. InTx vẫn đổi một pool lấy một transaction, và New(s.q) chạy cùng code do sqlc sinh trên cả hai.
Cách xem server đang làm gì
sqlc đặt tên query ở đầu mỗi statement mà nó gửi đi:
-- name: ListUsers :many
select id, tenant_id, email, role, status, created_at from users ...otelpgx chép statement vào attribute db.query.text của mỗi span database. Tên span vẫn là động từ SQL, như SELECT, vì otelpgx bỏ qua comment sqlc ở đầu khi đặt tên span. Trong Grafana, mở Explore, chọn Tempo, và chạy query TraceQL này:
{ span.db.query.text =~ ".*ListAuditEvents.*" }Mọi lần gọi audit list trong khoảng thời gian đó đều hiện ra, kèm thời lượng, mà không phải đoán nội dung SQL.
Cách debug lớp này
sqlc generate thất bại
Đọc thông báo lỗi. Nó nêu tên file, dòng, và cột hoặc kiểu mà nó không phân giải được. Các nguyên nhân thường gặp: một cột không tồn tại trong các migration, một parameter mà sqlc không suy ra được kiểu (thêm phép ép kiểu ::type), hoặc một statement trong migration mà parser của sqlc không hiểu.
make sqlc-check thất bại
Ai đó đã đổi một query hoặc một migration mà không generate lại. Chạy make generate, xem diff, và commit nó cùng với thay đổi gây ra nó.
Một test thất bại sau khi sửa một query
Code do sqlc sinh đúng với SQL bạn đã viết, nên SQL là chỗ cần nhìn. Chạy query lỗi trong psql với các giá trị cụ thể. Với một filter tùy chọn, kiểm tra rằng phía Go gửi nil, không phải một pointer tới giá trị rỗng: &"" không phải NULL, và action = '' không khớp gì cả.
Code không compile sau một migration
Đó là sqlc đang làm đúng việc của nó. Một cột đổi kiểu hoặc đổi tên, struct do sqlc sinh đã đổi, và compiler đang chỉ cho bạn mọi chỗ từng dùng nó.
Testing
Không có gì mới. Các test của giai đoạn 2 trong internal/store/pg, internal/server và internal/app đều qua mà không cần sửa, đó là bằng chứng rằng hành vi không đổi. Một test đã đổi đối tượng kiểm tra: TestAuditStoreNeverUpdatesOrDeletes từng tìm update hoặc delete trong audit.go. SQL giờ nằm trong queries/, và một lệnh purge có thể được thêm vào bất kỳ file query nào, nên test đọc mọi statement trong mọi file query (bỏ qua comment) và thất bại với statement nào sửa hoặc xóa các dòng audit_events. Nó vẫn kiểm tra audit.go phòng khi ai đó thêm một query viết tay ở đó.
Những giới hạn gặp trong thực tế
- Mỗi query một row struct.
GetAPIKey,ListAPIKeys,CreateAPIKeyvàRevokeAPIKeychọn cùng các cột nhưng mỗi cái có một kiểu...Rowriêng, vì sqlc đặt tên kiểu theo query. Chúng đều chuyển đổi được sangstore.APIKey, nên ở đây không tốn gì, nhưng nó là nhiễu trongapi_keys.sql.go. select *trong một query được khai triển lúc generate. Thêm một cột vàouserslàmGetUserđổi theo ở lần generate lại tiếp theo. Thường đó là điều bạn muốn, và đó là lý do các query của key liệt kê rõ các cột: chúng không bao giờ được trả vềkey_hash.sqlc.nargkhông phân biệt được “chưa đặt” với “đặt thành NULL”. Với một filter thì không sao. Với một partial update (“đổi tên, để nguyên email”) thì không ổn, vì bạn không thể nói “đặt email thành NULL”. Giai đoạn 4 sẽ gặp chuyện này; cách thường dùng là một parameter boolean riêng cho mỗi field, hoặc một query cho mỗi dạng update.- sqlc gắn comment phía trên một query vào Go doc của method do sqlc sinh, nên một comment ở đầu file query sẽ nằm trên method đầu tiên. Vì vậy các file query của hango không có comment cấp file; mỗi ghi chú nằm trên query mà nó mô tả.
- Không dùng được SQL động. Một cột sắp xếp chọn lúc chạy, hay một danh sách filter có độ dài không biết trước, không thể là chữ cố định. Đó là việc của escape hatch.
Escape hatch
Nếu một query thật sự không viết được cho sqlc, hãy viết tay bằng pgx trong cùng package, scan vào struct do sqlc sinh (các tag db giúp pgx.RowToStructByName chạy được), và thêm một comment nêu giới hạn. Giai đoạn 2.1 không cần cái nào.
Đánh đổi
- sqlc làm mặc định so với trộn lẫn. Chia query giữa sqlc và code viết tay theo sở thích nghĩa là mọi người đọc phải biết hai phong cách. Một mặc định kèm một ngoại lệ có tên giữ quy tắc đơn giản.
- Code do sqlc sinh trong
pgso với package riêng. Một package riêng sẽ gọn hơn và tốn một trong hai mươi package, và bất cứ thứ gì trong đó có thể bị import bởi nhiều code hơn mức quy tắc tenant cho phép. - Type override so với
pgtype. Override làm các struct do sqlc sinh trông như viết tay. Cái giá là mộtsqlc.yamldài hơn và một pointer cho mỗi cột nullable. go runvới phiên bản được ghim so với một dòngtooltronggo.mod. Một dependencytoolsẽ kéo cây dependency lớn của sqlc vào module graph.go run pkg@versiongiữ nó ở ngoài, với giá là lần chạy đầu chậm.- Commit code do sqlc sinh so với generate trong CI. Code đã commit nghĩa là build và test hango không bao giờ cần sqlc hay cgo.
make sqlc-checkgiữ cho nó trung thực, và job CI của giai đoạn 16 sẽ chạy nó.
goclaw và deepagents làm việc này thế nào
goclaw (~/ws/personal/goclaw) dùng database/sql với driver thư viện chuẩn của pgx, sqlx để scan vào struct, golang-migrate cho migration, và SQL viết tay. Lý do là SQLite: goclaw cũng có một SQLite store (internal/store/sqlitestore), và database/sql là điểm chung. sqlc cũng sinh được cho SQLite, nhưng mỗi engine cần query và đầu ra riêng. hango chỉ dùng Postgres, nên nó dùng được pgx gốc và sinh một bộ code duy nhất.
langchain deepagents không có lớp SQL riêng. Nó lưu trạng thái qua các checkpointer và store của LangGraph, vốn giấu database sau một interface key-value.
Bài tập
- Thêm một cột. Viết một migration thêm
display_name text not null default ''vàousers, chạymake generate, rồigo build ./.... Lần theo các lỗi: dòng nào hỏng đầu tiên, và vì sao? (Gợi ý:store.User(u).) - Cố ý phá quy tắc tenant. Trong
queries/api_keys.sql, đổiwhere tenant_id = @tenant_id and id = @idcủaGetAPIKeythànhwhere @tenant_id::uuid is not null and id = @id, giữ lại parameter nhưng không còn lọc theo nó. Chạymake generatevàgo test ./internal/store/pg/ -run TestCrossTenant -v, rồi xem caseapi_keysthất bại. Sau đó thử xóa hẳntenant_id = @tenant_id and: sqlc bỏ parameterTenantID,GetAPIKeyParamsbiến mất, vàgo buildthất bại trước khi bất kỳ test nào chạy. Khôi phục query và generate lại. - Thử một sắp xếp lúc chạy. Sửa
ListAuditEventsđể người gọi chọn đượcoccurred_athoặcactionlàm cột sắp xếp. Thử bằng một parameter (order by @sort_col) và đọc xem Postgres làm gì. Rồi viết nó như một escape hatch trongaudit.go, scan vàoAuditEvent. - Tìm một query trong Tempo. Khởi động stack bằng
make obs-up, chạy hango trên máy host vớiHANGO_DATABASE_URLtrỏ tới Postgres của compose (HOST_DATABASE_URLtrongMakefile) và mộtHANGO_BOOTSTRAP_ADMIN_KEY, tạo một tenant, một owner và một key như trong phần hướng dẫn ở Persistence và tenancy, gọiGET /v1/audit-eventsvới key đó vài lần, rồi tìm các spanListAuditEventsbằng query TraceQL ở trên.