
資料庫抽換 - SQLite(二) - Clean Architecture in Go
使用 sqlc 的前置準備已經完成,我們現在可以透過 sqlc 產生的程式碼直接跟資料庫互動,然而生成的實作和我們在 UseCase 期待的介面不同,因此需要實作 Repository 來將介面統一。
TokenRepository
跟 BoltDB 的處理類似,我們這次要將實作轉換成使用 SQL 的版本,因此在 SQLiteTokenRepository
的實作會有一些不同,打開 internal/repository/sqlite_tokens.go
這個新檔案,加入以下內容。
1type SQLiteTokenRepository struct {
2 cipher cipher.Block
3 queries *sqlite.Queries
4}
5
6func NewSQLiteTokenRepository(queries *sqlite.Queries) (*SQLiteTokenRepository, error) {
7 cipher, err := aes.NewCipher([]byte(tokenEncryptionKey))
8 if err != nil {
9 return nil, err
10 }
11
12 return &SQLiteTokenRepository{
13 cipher: cipher,
14 queries: queries,
15 }, nil
16}
17
18func (r *SQLiteTokenRepository) Find(ctx context.Context, tokenStr string) (*tokens.Token, error) {
19 segments := strings.SplitN(tokenStr, ":", 2)
20 if len(segments) != 2 {
21 return nil, tokens.ErrTokenNotFound
22 }
23
24 id := segments[1]
25 token, err := r.queries.FindToken(ctx, id)
26 if err != nil {
27 if err == sql.ErrNoRows {
28 return nil, tokens.ErrTokenNotFound
29 }
30
31 return nil, err
32 }
33
34 rawData, err := decrypt(r.cipher, token.Data)
35 if err != nil {
36 return nil, tokens.ErrUnableToDecrypt
37 }
38
39 return tokens.New(
40 token.ID,
41 tokens.WithData(rawData),
42 tokens.WithVersion(token.Version),
43 ), nil
44}
45
46func (r *SQLiteTokenRepository) Save(ctx context.Context, token *tokens.Token) error {
47 encryptedData, err := encrypt(r.cipher, token.Data())
48 if err != nil {
49 return tokens.ErrUnableToEncrypt
50 }
51
52 _, err = r.queries.CreateToken(ctx, sqlite.CreateTokenParams{
53 ID: token.Id(),
54 Data: encryptedData,
55 Version: token.Version(),
56 })
57
58 return err
59}
因為大部分的實作我們在 InMemoryTokenRepository
和 BoltTokenRepository
實作過,因此大部分的邏輯都可以直接複製過來使用。
有幾個不同的地方在於,這邊我們使用了 sqlc 產生的 sqlite.Queries
而非 sql.DB
來實作,我們需要的行為已經由 sqlc 實作完畢,就不需要額外的 sql.DB
來輔助。
另一方面,因為 RDBMS 的查詢結果的錯誤有不同情況,因此在 sql.ErrNoRows
的情況需要自己判斷,並且回傳跟 NoSQL 一樣無法找到某個數值的錯誤訊息,除此之外基本上沒有太大變化。
OrderRepository
OrderRepository 的處理就會有比較明顯的差異,因為在 InMemoryOrderRepository
或者 BoltOrderRepository
的儲存處理都只會碰到一張表,在 SQLite 的版本卻需要確保 orders
和 order_items
兩個資料表同時寫入,因此需要 Transaction(交易)的機制。
即使如此,在大部分的處理上仍是非常相似的,我們打開 internal/repository/sqlite_orders.go
分成幾個階段實作這個功能,可以更好的觀察差異。
1type SQLiteOrderRepository struct {
2 db *sql.DB
3 queries *sqlite.Queries
4}
5
6func NewSQLiteOrderRepository(db *sql.DB, queries *sqlite.Queries) *SQLiteOrderRepository {
7 return &SQLiteOrderRepository{
8 db: db,
9 queries: queries,
10 }
11}
跟 SQLiteTokenRepository
不同的地方是,這次我們需要 sql.DB
作為依賴,原因是 sqlc
產生的只有查詢相關的實作,並不包含 Transaction 的處理,這個處理還是要依賴 Golang 本身的 SQL 標準函式庫。
1func (r *SQLiteOrderRepository) Find(ctx context.Context, id string) (*orders.Order, error) {
2 order, err := r.queries.FindOrder(ctx, id)
3 if err != nil {
4 if err == sql.ErrNoRows {
5 return nil, orders.ErrOrderNotFound
6 }
7
8 return nil, err
9 }
10
11 orderEntity := orders.New(order.ID, order.CustomerName)
12
13 orderItems, err := r.queries.ListOrderItems(ctx, order.ID)
14 if err != nil {
15 return nil, err
16 }
17
18 for _, item := range orderItems {
19 err := orderEntity.AddItem(item.Name, int(item.Quantity), int(item.UnitPrice))
20 if err != nil {
21 return nil, err
22 }
23 }
24
25 return orderEntity, nil
26}
讀取資料的部分並不複雜,因為我們只需要在兩張資料表個別取出所需的資料即可,除了進行兩次的查詢之外並沒有什麼特別的地方。
1func (r *SQLiteOrderRepository) Save(ctx context.Context, order *orders.Order) (err error) {
2 tx, err := r.db.BeginTx(ctx, nil)
3 if err != nil {
4 return err
5 }
6 defer func() {
7 err = tx.Rollback()
8 }()
9
10 qtx := r.queries.WithTx(tx)
11
12 createdOrder, err := qtx.CreateOrder(ctx, sqlite.CreateOrderParams{
13 ID: order.Id(),
14 CustomerName: order.CustomerName(),
15 })
16 if err != nil {
17 return err
18 }
19
20 for _, item := range order.Items() {
21 _, err := qtx.CreateOrderItem(ctx, sqlite.CreateOrderItemParams{
22 ID: uuid.NewString(),
23 OrderID: createdOrder.ID,
24 Name: item.Name(),
25 Quantity: int64(item.Quantity()),
26 UnitPrice: int64(item.UnitPrice()),
27 })
28 if err != nil {
29 return err
30 }
31 }
32
33 return tx.Commit()
34}
儲存的部分為了確保 Order
和 OrderItem
同時被保存進去,因此我們需要用 sql.DB
來建立一個新的 Transaction 再利用 sqlc 的 WithTx
方法,建立一個具有 Transaction 的查詢,剩下的處理就會被涵蓋在 Transaction 之中,直到我們用 tx.Commit()
提交。
然而,這個還是比較簡單的版本,因為我們沒有考慮「更新」的情境,在 NoSQL 的版本中我們只要製作出新的 Schema 結構後覆蓋上去,基本上就可以視為更新。
在 RDBMS 的版本中,就需要使用 ON DUPLICATE KEY UPDATE
的技巧,以及清除 OrderItem
重新建立等操作,才能在 Save()
中提供新增、更新的行為。
從 InMemory 版本到 BoltDB 到 SQLite 後,Repository 為什麼具備 Adapter 的特性越來越明顯,至少從
sqlite.Queries
轉換成Find()
和Save()
就很容易看出是為了將兩者串連起來所扮演的 Adapter 角色。
切換資料庫
延續 BoltDB 的實作,我們可以繼續加入新的分支,然而在依賴注入上會有一些小細節的變化。
根據 wire 的使用,我們在 internal/repository/repository.go
會有類似這樣的定義。
1var SQLiteSet = wire.NewSet(
2 sqlite.New,
3 NewSQLiteOrderRepository,
4 wire.Bind(new(usecase.OrderRepository), new(*SQLiteOrderRepository)),
5 NewSQLiteTokenRepository,
6 wire.Bind(new(usecase.TokenRepository), new(*SQLiteTokenRepository)),
7)
先前的版本都是直接使用資料庫,因此不會特別描述,但是在 SQLite 的版本中我們需要額外的 sqlite.Queries
因此可以在這個依賴組合中定義,讓他跟著我們的需求跑。
因為我們的 Schema 需要先進行建立,因此可以在初始化的時候一起處理,以 cmd/grpc/main.go
為例子,我們會增加 provideSQLiteDb
這個方法。
1func provideSQLiteDb() (*sql.DB, func(), error) {
2 db, err := sql.Open("sqlite3", "./sqlite3.db")
3 if err != nil {
4 return nil, nil, err
5 }
6
7 if _, err := db.Exec(appDb.Schema); err != nil {
8 return nil, nil, err
9 }
10
11 return db, func() { db.Close() }, nil
12}
除了開啟連線之外,也會將我們內嵌在 db
中的 Schema
拉出來運行,如果是使用 Migration 套件,則需要判斷一個適合的時機點呼叫。
同時,因為 sqlc 不是直接以 *sql.DB
作為依賴,而是定義了一個叫做 DBTX
的介面,允許任何具備相同介面的套件作為依賴提供進去,因此我們還需要額外對這個介面綁定。
1func initializeSQLite() (*grpc.Server, func(), error) {
2 wire.Build(
3 provideSQLiteDb,
4 wire.Bind(new(sqlite.DBTX), new(*sql.DB)),
5 repository.SQLiteSet,
6 usecase.DefaultSet,
7 validator.DefaultSet,
8 grpc.DefaultSet,
9 )
10
11 return nil, nil, nil
12}
在這裡,我們選擇使用 sql.DB
做為資料庫連線的手段,就要額外定義 wire.Bind(new(sqlite.DBTX), new(*sql.DB))
來綁定兩者,確保 wire 能理解正確的關係。
最後,可以用 curl
或者 grpcurl
來測試看看是否能夠順利得到相同的結果。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 連載介紹 - Clean Architecture in Go
- 目標設定 - Clean Architecture in Go
- wire 的依賴注入 - Clean Architecture in Go
- 案例說明 - Clean Architecture in Go
- 操作介面設計 - Clean Architecture in Go
- Place Order 實作 Controller 部分 - Clean Architecture in Go
- Place Order 實作 Entity 部分 - Clean Architecture in Go
- Place Order 實作 Repository 部分 - Clean Architecture in Go
- Lookup Order 功能 - Clean Architecture in Go
- Tokenization 機制設計 - Clean Architecture in Go
- 在 Place Order 實作 Token 機制 - Clean Architecture in Go
- 在 Lookup Order 實作 Token 機制 - Clean Architecture in Go
- Token 內容加密 - Clean Architecture in Go
- gRPC Server 準備 - Clean Architecture in Go
- gRPC Server 實作 - Clean Architecture in Go
- 輸入檢查 - Clean Architecture in Go
- 資料庫抽換 - BoltDB - Clean Architecture in Go
- 資料庫抽換 - SQLite(一) - Clean Architecture in Go
- 資料庫抽換 - SQLite(二) - Clean Architecture in Go