跳至主要內容
蒼時弦也
蒼時弦也
資深軟體工程師
發表於

資料庫抽換 - 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}

因為大部分的實作我們在 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 來測試看看是否能夠順利得到相同的結果。