---
title: "資料庫抽換 - SQLite（二） - Clean Architecture in Go"
date: 2025-05-09T00:00:00+08:00
publishDate: 2025-05-09T00:00:00+08:00
lastmod: 2024-10-07T20:05:43+08:00
tags: ["Golang","Clean Architecture","架構","經驗"]
series: "clean-architecture-in-go"
toc: true
permalink: "https://blog.aotoki.me/posts/2025/05/09/clean-architecture-in-go-add-sqlite-2/"
language: "zh-tw"
---


使用 sqlc 的前置準備已經完成，我們現在可以透過 sqlc 產生的程式碼直接跟資料庫互動，然而生成的實作和我們在 UseCase 期待的介面不同，因此需要實作 Repository 來將介面統一。

<!--more-->

## TokenRepository

跟 BoltDB 的處理類似，我們這次要將實作轉換成使用 SQL 的版本，因此在 `SQLiteTokenRepository` 的實作會有一些不同，打開 `internal/repository/sqlite_tokens.go` 這個新檔案，加入以下內容。

```go
type SQLiteTokenRepository struct {
	cipher  cipher.Block
	queries *sqlite.Queries
}

func NewSQLiteTokenRepository(queries *sqlite.Queries) (*SQLiteTokenRepository, error) {
	cipher, err := aes.NewCipher([]byte(tokenEncryptionKey))
	if err != nil {
		return nil, err
	}

	return &SQLiteTokenRepository{
		cipher:  cipher,
		queries: queries,
	}, nil
}

func (r *SQLiteTokenRepository) Find(ctx context.Context, tokenStr string) (*tokens.Token, error) {
	segments := strings.SplitN(tokenStr, ":", 2)
	if len(segments) != 2 {
		return nil, tokens.ErrTokenNotFound
	}

	id := segments[1]
	token, err := r.queries.FindToken(ctx, id)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, tokens.ErrTokenNotFound
		}

		return nil, err
	}

	rawData, err := decrypt(r.cipher, token.Data)
	if err != nil {
		return nil, tokens.ErrUnableToDecrypt
	}

	return tokens.New(
		token.ID,
		tokens.WithData(rawData),
		tokens.WithVersion(token.Version),
	), nil
}

func (r *SQLiteTokenRepository) Save(ctx context.Context, token *tokens.Token) error {
	encryptedData, err := encrypt(r.cipher, token.Data())
	if err != nil {
		return tokens.ErrUnableToEncrypt
	}

	_, err = r.queries.CreateToken(ctx, sqlite.CreateTokenParams{
		ID:      token.Id(),
		Data:    encryptedData,
		Version: token.Version(),
	})

	return err
}
```

因為大部分的實作我們在 `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` 分成幾個階段實作這個功能，可以更好的觀察差異。

```go
type SQLiteOrderRepository struct {
	db      *sql.DB
	queries *sqlite.Queries
}

func NewSQLiteOrderRepository(db *sql.DB, queries *sqlite.Queries) *SQLiteOrderRepository {
	return &SQLiteOrderRepository{
		db:      db,
		queries: queries,
	}
}
```

跟 `SQLiteTokenRepository` 不同的地方是，這次我們需要 `sql.DB` 作為依賴，原因是 `sqlc` 產生的只有查詢相關的實作，並不包含 Transaction 的處理，這個處理還是要依賴 Golang 本身的 SQL 標準函式庫。

```go
func (r *SQLiteOrderRepository) Find(ctx context.Context, id string) (*orders.Order, error) {
	order, err := r.queries.FindOrder(ctx, id)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, orders.ErrOrderNotFound
		}

		return nil, err
	}

	orderEntity := orders.New(order.ID, order.CustomerName)

	orderItems, err := r.queries.ListOrderItems(ctx, order.ID)
	if err != nil {
		return nil, err
	}

	for _, item := range orderItems {
		err := orderEntity.AddItem(item.Name, int(item.Quantity), int(item.UnitPrice))
		if err != nil {
			return nil, err
		}
	}

	return orderEntity, nil
}
```

讀取資料的部分並不複雜，因為我們只需要在兩張資料表個別取出所需的資料即可，除了進行兩次的查詢之外並沒有什麼特別的地方。

```go
func (r *SQLiteOrderRepository) Save(ctx context.Context, order *orders.Order) (err error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() {
		err = tx.Rollback()
	}()

	qtx := r.queries.WithTx(tx)

	createdOrder, err := qtx.CreateOrder(ctx, sqlite.CreateOrderParams{
		ID:           order.Id(),
		CustomerName: order.CustomerName(),
	})
	if err != nil {
		return err
	}

	for _, item := range order.Items() {
		_, err := qtx.CreateOrderItem(ctx, sqlite.CreateOrderItemParams{
			ID:        uuid.NewString(),
			OrderID:   createdOrder.ID,
			Name:      item.Name(),
			Quantity:  int64(item.Quantity()),
			UnitPrice: int64(item.UnitPrice()),
		})
		if err != nil {
			return err
		}
	}

	return tx.Commit()
}
```

儲存的部分為了確保 `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 角色。
## 切換資料庫{#switch-database}

延續 BoltDB 的實作，我們可以繼續加入新的分支，然而在依賴注入上會有一些小細節的變化。

根據 wire 的使用，我們在 `internal/repository/repository.go` 會有類似這樣的定義。

```go
var SQLiteSet = wire.NewSet(
	sqlite.New,
	NewSQLiteOrderRepository,
	wire.Bind(new(usecase.OrderRepository), new(*SQLiteOrderRepository)),
	NewSQLiteTokenRepository,
	wire.Bind(new(usecase.TokenRepository), new(*SQLiteTokenRepository)),
)
```

先前的版本都是直接使用資料庫，因此不會特別描述，但是在 SQLite 的版本中我們需要額外的 `sqlite.Queries` 因此可以在這個依賴組合中定義，讓他跟著我們的需求跑。

因為我們的 Schema 需要先進行建立，因此可以在初始化的時候一起處理，以 `cmd/grpc/main.go` 為例子，我們會增加 `provideSQLiteDb` 這個方法。

```go
func provideSQLiteDb() (*sql.DB, func(), error) {
	db, err := sql.Open("sqlite3", "./sqlite3.db")
	if err != nil {
		return nil, nil, err
	}

	if _, err := db.Exec(appDb.Schema); err != nil {
		return nil, nil, err
	}

	return db, func() { db.Close() }, nil
}
```

除了開啟連線之外，也會將我們內嵌在 `db` 中的 `Schema` 拉出來運行，如果是使用 Migration 套件，則需要判斷一個適合的時機點呼叫。

同時，因為 sqlc 不是直接以 `*sql.DB` 作為依賴，而是定義了一個叫做 `DBTX` 的介面，允許任何具備相同介面的套件作為依賴提供進去，因此我們還需要額外對這個介面綁定。

```go
func initializeSQLite() (*grpc.Server, func(), error) {
	wire.Build(
		provideSQLiteDb,
		wire.Bind(new(sqlite.DBTX), new(*sql.DB)),
		repository.SQLiteSet,
		usecase.DefaultSet,
		validator.DefaultSet,
		grpc.DefaultSet,
	)

	return nil, nil, nil
}
```

在這裡，我們選擇使用 `sql.DB` 做為資料庫連線的手段，就要額外定義 `wire.Bind(new(sqlite.DBTX), new(*sql.DB))` 來綁定兩者，確保 wire 能理解正確的關係。

最後，可以用 `curl` 或者 `grpcurl` 來測試看看是否能夠順利得到相同的結果。

