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

資料庫抽換 - BoltDB - Clean Architecture in Go

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

當 Clean Architecture 的實踐完善後,我們在各種功能的抽換就能獲得許多彈性,接下來我們會以 BoltDB 來示範將原本儲存在記憶體的資訊,轉換為保存在硬碟的持久性版本。

TokenRepository

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

 1// ...
 2
 3const BoltTokensTableName = "tokens"
 4
 5type BoltTokenSchema struct {
 6	Id      string
 7	Data    []byte
 8	Version string
 9}
10
11type BoltTokenRepository struct {
12	cipher    cipher.Block
13	db        *bolt.DB
14	tableName string
15}
16
17func NewBoltTokenRepository(db *bolt.DB) (*BoltTokenRepository, error) {
18	cipher, err := aes.NewCipher([]byte(tokenEncryptionKey))
19	if err != nil {
20		return nil, err
21	}
22
23	return &BoltTokenRepository{
24		cipher:    cipher,
25		db:        db,
26		tableName: BoltTokensTableName,
27	}, nil
28}
29
30func (r *BoltTokenRepository) Find(ctx context.Context, tokenStr string) (*tokens.Token, error) {
31	segments := strings.SplitN(tokenStr, ":", 2)
32	if len(segments) != 2 {
33		return nil, tokens.ErrTokenNotFound
34	}
35
36	id := segments[1]
37	var token BoltTokenSchema
38	err := r.db.View(func(tx *bolt.Tx) error {
39		bucket := tx.Bucket([]byte(r.tableName))
40		if bucket == nil {
41			return tokens.ErrTokenNotFound
42		}
43
44		boltValue := bucket.Get([]byte(id))
45		if boltValue == nil {
46			return tokens.ErrTokenNotFound
47		}
48
49		return json.Unmarshal(boltValue, &token)
50	})
51
52	if err != nil {
53		return nil, err
54	}
55
56	rawData, err := decrypt(r.cipher, token.Data)
57	if err != nil {
58		return nil, tokens.ErrUnableToDecrypt
59	}
60
61	return tokens.New(
62		token.Id,
63		tokens.WithVersion(token.Version),
64		tokens.WithData(rawData),
65	), nil
66}
67
68func (r *BoltTokenRepository) Save(ctx context.Context, token *tokens.Token) error {
69	encryptedData, err := encrypt(r.cipher, token.Data())
70	if err != nil {
71		return tokens.ErrUnableToEncrypt
72	}
73
74	return r.db.Update(func(tx *bolt.Tx) error {
75		bucket, err := tx.CreateBucketIfNotExists([]byte(r.tableName))
76		if err != nil {
77			return err
78		}
79
80		tokenSchema := BoltTokenSchema{
81			Id:      token.Id(),
82			Data:    encryptedData,
83			Version: token.Version(),
84		}
85
86		data, err := json.Marshal(tokenSchema)
87		if err != nil {
88			return err
89		}
90
91		return bucket.Put([]byte(token.Id()), data)
92	})
93}

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 的範例程式碼。

 1const BoltOrdersTableName = "orders"
 2
 3type BoltOrderItemSchema struct {
 4	Name      string
 5	Quantity  int
 6	UnitPrice int
 7}
 8
 9type BoltOrderSchema struct {
10	Id           string
11	CustomerName string
12	Items        []BoltOrderItemSchema
13}
14
15type BoltOrderRepository struct {
16	db        *bolt.DB
17	tableName string
18}
19
20func NewBoltOrderRepository(db *bolt.DB) *BoltOrderRepository {
21	return &BoltOrderRepository{
22		db:        db,
23		tableName: BoltOrdersTableName,
24	}
25}
26
27func (r *BoltOrderRepository) Find(ctx context.Context, id string) (*orders.Order, error) {
28	var order BoltOrderSchema
29	err := r.db.View(func(tx *bolt.Tx) error {
30		bucket := tx.Bucket([]byte(r.tableName))
31		if bucket == nil {
32			return orders.ErrOrderNotFound
33		}
34
35		boltValue := bucket.Get([]byte(id))
36		if boltValue == nil {
37			return orders.ErrOrderNotFound
38		}
39
40		return json.Unmarshal(boltValue, &order)
41	})
42
43	if err != nil {
44		return nil, err
45	}
46
47	orderEntity := orders.New(order.Id, order.CustomerName)
48	for _, item := range order.Items {
49		err := orderEntity.AddItem(item.Name, item.Quantity, item.UnitPrice)
50		if err != nil {
51			return nil, err
52		}
53	}
54
55	return orderEntity, nil
56}
57
58func (r *BoltOrderRepository) Save(ctx context.Context, order *orders.Order) error {
59	boltOrder := BoltOrderSchema{
60		Id:           order.Id(),
61		CustomerName: order.CustomerName(),
62		Items:        []BoltOrderItemSchema{},
63	}
64
65	for _, item := range order.Items() {
66		boltOrder.Items = append(boltOrder.Items, BoltOrderItemSchema{
67			Name:      item.Name(),
68			Quantity:  item.Quantity(),
69			UnitPrice: item.UnitPrice(),
70		})
71	}
72
73	return r.db.Update(func(tx *bolt.Tx) error {
74		bucket, err := tx.CreateBucketIfNotExists([]byte(r.tableName))
75		if err != nil {
76			return err
77		}
78
79		boltValue, err := json.Marshal(boltOrder)
80		if err != nil {
81			return err
82		}
83
84		return bucket.Put([]byte(order.Id()), boltValue)
85	})
86}

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

切換資料庫

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

 1// ...
 2func main() {
 3	databaseType := flag.String("database", "in-memory", "Database type to use")
 4	flag.Parse()
 5
 6	server, cleanup, err := initialize(*databaseType)
 7	if err != nil {
 8		log.Fatalf("Error initializing handler: %v", err)
 9	}
10	defer cleanup()
11
12	if err := server.Serve(); err != nil {
13		log.Fatalf("Error starting server: %v", err)
14	}
15}

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

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

 1// ...
 2func provideBoltDb() (*bolt.DB, func(), error) {
 3	db, err := bolt.Open("bolt.db", 0600, nil)
 4	if err != nil {
 5		return nil, nil, err
 6	}
 7
 8	return db, func() { db.Close() }, nil
 9}
10// ...

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

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

 1// ...
 2
 3func initialize(databaseType string) (*grpc.Server, func(), error) {
 4	switch databaseType {
 5	case "in-memory":
 6		return initializeInMemory()
 7	case "bolt":
 8		return initializeBolt()
 9	default:
10		return nil, func() {}, errors.New("unsupported database type")
11	}
12}
13
14func initializeInMemory() (*grpc.Server, func(), error) {
15	wire.Build(
16		repository.DefaultSet,
17		usecase.DefaultSet,
18		validator.DefaultSet,
19		grpc.DefaultSet,
20	)
21
22	return nil, nil, nil
23}
24
25func initializeBolt() (*grpc.Server, func(), error) {
26	wire.Build(
27		provideBoltDb,
28		repository.BoltSet,
29		usecase.DefaultSet,
30		validator.DefaultSet,
31		grpc.DefaultSet,
32	)
33
34	return nil, nil, nil
35}

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

這個方法跟 wire範例是相同的做法,然而理論上我們也可以單獨針對 Repository 的部分做切換,但可能會有不少額外的複雜度,可以根據狀況評估使用。

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