---
title: "資料庫抽換 - BoltDB - Clean Architecture in Go"
date: 2025-04-25T00:00:00+08:00
publishDate: 2025-04-25T00: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/04/25/clean-architecture-in-go-add-boltdb/"
language: "zh-tw"
---


當 Clean Architecture 的實踐完善後，我們在各種功能的抽換就能獲得許多彈性，接下來我們會以 [BoltDB](https://github.com/etcd-io/bbolt) 來示範將原本儲存在記憶體的資訊，轉換為保存在硬碟的持久性版本。

<!--more-->

## TokenRepository

TokenRepository 相對簡單一些，我們可以在 `internal/repository` 下增加一個 `bolt_tokens.go` 的檔案，用於實作 BoltDB 版本的 TokenRepository。

```go
// ...

const BoltTokensTableName = "tokens"

type BoltTokenSchema struct {
	Id      string
	Data    []byte
	Version string
}

type BoltTokenRepository struct {
	cipher    cipher.Block
	db        *bolt.DB
	tableName string
}

func NewBoltTokenRepository(db *bolt.DB) (*BoltTokenRepository, error) {
	cipher, err := aes.NewCipher([]byte(tokenEncryptionKey))
	if err != nil {
		return nil, err
	}

	return &BoltTokenRepository{
		cipher:    cipher,
		db:        db,
		tableName: BoltTokensTableName,
	}, nil
}

func (r *BoltTokenRepository) 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]
	var token BoltTokenSchema
	err := r.db.View(func(tx *bolt.Tx) error {
		bucket := tx.Bucket([]byte(r.tableName))
		if bucket == nil {
			return tokens.ErrTokenNotFound
		}

		boltValue := bucket.Get([]byte(id))
		if boltValue == nil {
			return tokens.ErrTokenNotFound
		}

		return json.Unmarshal(boltValue, &token)
	})

	if err != nil {
		return nil, err
	}

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

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

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

	return r.db.Update(func(tx *bolt.Tx) error {
		bucket, err := tx.CreateBucketIfNotExists([]byte(r.tableName))
		if err != nil {
			return err
		}

		tokenSchema := BoltTokenSchema{
			Id:      token.Id(),
			Data:    encryptedData,
			Version: token.Version(),
		}

		data, err := json.Marshal(tokenSchema)
		if err != nil {
			return err
		}

		return bucket.Put([]byte(token.Id()), data)
	})
}
```

BoltDB 版本的實作跟 InMemory 版本基本上非常相似，因為都是採用 Key-Value 的機制儲存，唯一不同的地方在於我們將原本的 `map[string]InMemoryTokenSchema` 替換成 `*bolt.DB` 用於呼叫資料庫底層。

除此之外，還增加了 `tableName` 這個欄位，用於記錄 Bucket 的名稱，這是 BoltDB 中用來表示一系列 Key-Value 組合的單位，剛好跟 `map[string]InMemoryTokenSchema` 的意義是接近的。

因為 BoltDB 的 Value 是以 `[]byte` 的方式記錄，我們無法直接用 `struct` 來保存，此時可以選擇像是 `gob` （Golang 內建的序列化格式）`json` 甚至 `protobuf` 這類能轉換成 `[]byte` 資料的方式，這個範例選擇以 `json` 來進行序列化。

> 不延用 `InMemoryTokenSchema` 另外定義 `BoltTokenSchema` 的原因是區分不同資料的保存方式，假設 Schema 都非常一致的話，使用統一的 `TokenSchema` 也是個不錯的做法。

## OrderRepository

有了 TokenRepository 的案例後，OrderRepository 基本上就是用相同的方式實作一遍即可，可以參考以下 `bolt_orders.go` 的範例程式碼。

```go
const BoltOrdersTableName = "orders"

type BoltOrderItemSchema struct {
	Name      string
	Quantity  int
	UnitPrice int
}

type BoltOrderSchema struct {
	Id           string
	CustomerName string
	Items        []BoltOrderItemSchema
}

type BoltOrderRepository struct {
	db        *bolt.DB
	tableName string
}

func NewBoltOrderRepository(db *bolt.DB) *BoltOrderRepository {
	return &BoltOrderRepository{
		db:        db,
		tableName: BoltOrdersTableName,
	}
}

func (r *BoltOrderRepository) Find(ctx context.Context, id string) (*orders.Order, error) {
	var order BoltOrderSchema
	err := r.db.View(func(tx *bolt.Tx) error {
		bucket := tx.Bucket([]byte(r.tableName))
		if bucket == nil {
			return orders.ErrOrderNotFound
		}

		boltValue := bucket.Get([]byte(id))
		if boltValue == nil {
			return orders.ErrOrderNotFound
		}

		return json.Unmarshal(boltValue, &order)
	})

	if err != nil {
		return nil, err
	}

	orderEntity := orders.New(order.Id, order.CustomerName)
	for _, item := range order.Items {
		err := orderEntity.AddItem(item.Name, item.Quantity, item.UnitPrice)
		if err != nil {
			return nil, err
		}
	}

	return orderEntity, nil
}

func (r *BoltOrderRepository) Save(ctx context.Context, order *orders.Order) error {
	boltOrder := BoltOrderSchema{
		Id:           order.Id(),
		CustomerName: order.CustomerName(),
		Items:        []BoltOrderItemSchema{},
	}

	for _, item := range order.Items() {
		boltOrder.Items = append(boltOrder.Items, BoltOrderItemSchema{
			Name:      item.Name(),
			Quantity:  item.Quantity(),
			UnitPrice: item.UnitPrice(),
		})
	}

	return r.db.Update(func(tx *bolt.Tx) error {
		bucket, err := tx.CreateBucketIfNotExists([]byte(r.tableName))
		if err != nil {
			return err
		}

		boltValue, err := json.Marshal(boltOrder)
		if err != nil {
			return err
		}

		return bucket.Put([]byte(order.Id()), boltValue)
	})
}
```

基本上只有 Schema 的不同，以及產生 Entity 方式的差異，其他基本上都是相同的做法。

## 切換資料庫{#switch-database}

因為我們現在允許兩種不同的資料庫存在，因此需要可以進行切換的選項，我們可以利用 Golang 的標準函式庫 `flag` 來做到這點，以下以 gRPC 版本的 `main.go` 作為範例。

```go
// ...
func main() {
	databaseType := flag.String("database", "in-memory", "Database type to use")
	flag.Parse()

	server, cleanup, err := initialize(*databaseType)
	if err != nil {
		log.Fatalf("Error initializing handler: %v", err)
	}
	defer cleanup()

	if err := server.Serve(); err != nil {
		log.Fatalf("Error starting server: %v", err)
	}
}
```

因為我們預期 `wire` 回傳的內容都是相同的，因此只需要使用一個 `initialize()` 來進行判斷即可，除此之外還增加了 `cleanup()` 方法的回傳，用於處理 BoltDB 的關閉。

同樣在 `main.go` 加入 `provideBoltDb()` 方法，用於建立資料庫。

```go
// ...
func provideBoltDb() (*bolt.DB, func(), error) {
	db, err := bolt.Open("bolt.db", 0600, nil)
	if err != nil {
		return nil, nil, err
	}

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

除了放在 `main.go` 之外，也可以考慮放到 `internal/repository` 之類的位置，讓依賴注入可以提供 `*config.Config` 這類物件，用於切換 `bolt.db` 的路徑，作為範例我們先以比較簡單的方式進行。

最後更新 `wire.go` 調整為能跟依照 `databaseType` 提供的類型切換的版本。

```go
// ...

func initialize(databaseType string) (*grpc.Server, func(), error) {
	switch databaseType {
	case "in-memory":
		return initializeInMemory()
	case "bolt":
		return initializeBolt()
	default:
		return nil, func() {}, errors.New("unsupported database type")
	}
}

func initializeInMemory() (*grpc.Server, func(), error) {
	wire.Build(
		repository.DefaultSet,
		usecase.DefaultSet,
		validator.DefaultSet,
		grpc.DefaultSet,
	)

	return nil, nil, nil
}

func initializeBolt() (*grpc.Server, func(), error) {
	wire.Build(
		provideBoltDb,
		repository.BoltSet,
		usecase.DefaultSet,
		validator.DefaultSet,
		grpc.DefaultSet,
	)

	return nil, nil, nil
}
```

基本上只需要根據 `in-memory` 和 `bolt` 選擇不同的 `initialize()` 方法，以及提供不一樣 `wire.Build` 組合來達成。

> 這個方法跟 `wire` 的[範例](https://github.com/google/go-cloud/tree/master/samples/guestbook)是相同的做法，然而理論上我們也可以單獨針對 Repository 的部分做切換，但可能會有不少額外的複雜度，可以根據狀況評估使用。

此時使用 `go run ./cmd/grpc -database bolt` 先做 Place Order 後關閉伺服器，使用拿到的 ID 再次用 Lookup Order 查詢，就不會和 InMemory 版本一樣無法找到。

