蒼時弦也
蒼時弦也
資深軟體工程師
發表於

資料庫抽換 - SQLite(二) - Clean Architecture in Go

這篇文章是 Clean Architecture in Go 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

使用 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}

因為大部分的實作我們在 InMemoryTokenRepositoryBoltTokenRepository 實作過,因此大部分的邏輯都可以直接複製過來使用。

有幾個不同的地方在於,這邊我們使用了 sqlc 產生的 sqlite.Queries 而非 sql.DB 來實作,我們需要的行為已經由 sqlc 實作完畢,就不需要額外的 sql.DB 來輔助。

另一方面,因為 RDBMS 的查詢結果的錯誤有不同情況,因此在 sql.ErrNoRows 的情況需要自己判斷,並且回傳跟 NoSQL 一樣無法找到某個數值的錯誤訊息,除此之外基本上沒有太大變化。

OrderRepository

OrderRepository 的處理就會有比較明顯的差異,因為在 InMemoryOrderRepository 或者 BoltOrderRepository 的儲存處理都只會碰到一張表,在 SQLite 的版本卻需要確保 ordersorder_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}

儲存的部分為了確保 OrderOrderItem 同時被保存進去,因此我們需要用 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 來測試看看是否能夠順利得到相同的結果。