前言
大家好,這裡是白澤,這篇文章在 go-kratos 官方的 layout 專案的整潔架構基礎上,實現優雅的資料庫事務操作。
影片講解 📺:B站:白澤talk,公眾號【白澤talk】
本期涉及的學習資料:
- 我的開源Golang學習倉庫:https://github.com/BaiZe1998/go-learning,這期的所有內容匯聚成一個可執行的 demo,
kit/transaction
路徑下。 - kratos CLI 工具:
go install github.com/go-kratos/kratos/cmd/kratos/v2@latest
。 - kratos 微服務框架:https://github.com/go-kratos/kratos
- wire 依賴注入庫:https://github.com/google/wire
- 領域驅動設計思想:本文不多涉及,具備相關背景知識食用本文更佳。
在開始學習之前,先補齊一下整潔架構 & 依賴注入的前置知識。
預備知識
整潔架構
kratos 是 Go 語言的一個微服務框架,github 🌟 23k,https://github.com/go-kratos/kratos
該專案提供了 CLI 工具,允許使用者透過 kratos new xxxx
,新建一個 xxxx 專案,這個專案將使用 kratos-layout 倉庫的程式碼結構。
倉庫地址:https://github.com/go-kratos/kratos-layout
kratos-layout 專案為使用者提供的,配合 CLI 工具生成的一個典型的 Go 專案佈局看起來像這樣:
application
|____api
| |____helloworld
| | |____v1
| | |____errors
|____cmd
| |____helloworld
|____configs
|____internal
| |____conf
| |____data
| |____biz
| |____service
| |____server
|____test
|____pkg
|____go.mod
|____go.sum
|____LICENSE
|____README.md
依賴注入
🌟 透過依賴注入,實現了資源的使用和隔離,同時避免了重複建立資源物件,是實現整潔架構的重要一環。
kratos 的官方文件中提到,十分建議使用者嘗試使用 wire 進行依賴注入,整個 layout 專案,也是基於 wire,完成了整潔架構的搭建。
service 層,實現 rpc 介面定義的方法,實現對外互動,注入了 biz。
// GreeterService is a greeter service.
type GreeterService struct {
v1.UnimplementedGreeterServer
uc *biz.GreeterUsecase
}
// NewGreeterService new a greeter service.
func NewGreeterService(uc *biz.GreeterUsecase) *GreeterService {
return &GreeterService{uc: uc}
}
// SayHello implements helloworld.GreeterServer.
func (s *GreeterService) SayHello(ctx context.Context, in *v1.HelloRequest) (*v1.HelloReply, error) {
g, err := s.uc.CreateGreeter(ctx, &biz.Greeter{Hello: in.Name})
if err != nil {
return nil, err
}
return &v1.HelloReply{Message: "Hello " + g.Hello}, nil
}
biz 層:定義 repo 介面,注入 data 層。
// GreeterRepo is a Greater repo.
type GreeterRepo interface {
Save(context.Context, *Greeter) (*Greeter, error)
Update(context.Context, *Greeter) (*Greeter, error)
FindByID(context.Context, int64) (*Greeter, error)
ListByHello(context.Context, string) ([]*Greeter, error)
ListAll(context.Context) ([]*Greeter, error)
}
// GreeterUsecase is a Greeter usecase.
type GreeterUsecase struct {
repo GreeterRepo
log *log.Helper
}
// NewGreeterUsecase new a Greeter usecase.
func NewGreeterUsecase(repo GreeterRepo, logger log.Logger) *GreeterUsecase {
return &GreeterUsecase{repo: repo, log: log.NewHelper(logger)}
}
// CreateGreeter creates a Greeter, and returns the new Greeter.
func (uc *GreeterUsecase) CreateGreeter(ctx context.Context, g *Greeter) (*Greeter, error) {
uc.log.WithContext(ctx).Infof("CreateGreeter: %v", g.Hello)
return uc.repo.Save(ctx, g)
}
data 作為資料訪問的實現層,實現了上游介面,注入了資料庫例項資源。
type greeterRepo struct {
data *Data
log *log.Helper
}
// NewGreeterRepo .
func NewGreeterRepo(data *Data, logger log.Logger) biz.GreeterRepo {
return &greeterRepo{
data: data,
log: log.NewHelper(logger),
}
}
func (r *greeterRepo) Save(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
return g, nil
}
func (r *greeterRepo) Update(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
return g, nil
}
func (r *greeterRepo) FindByID(context.Context, int64) (*biz.Greeter, error) {
return nil, nil
}
func (r *greeterRepo) ListByHello(context.Context, string) ([]*biz.Greeter, error) {
return nil, nil
}
func (r *greeterRepo) ListAll(context.Context) ([]*biz.Greeter, error) {
return nil, nil
}
db:注入 data,作為被操作的物件。
type Data struct {
// TODO wrapped database client
}
// NewData .
func NewData(c *conf.Data, logger log.Logger) (*Data, func(), error) {
cleanup := func() {
log.NewHelper(logger).Info("closing the data resources")
}
return &Data{}, cleanup, nil
}
Golang 優雅事務
準備
🌟 專案獲取:強烈建議克隆倉庫後實機操作。
git clone git@github.com:BaiZe1998/go-learning.git
cd kit/transcation/helloworld
這個目錄基於 go-kratos CLI 工具使用 kratos new helloworld
生成,並在此基礎上修改,實現了事務支援。
執行 demo 需要準備:
- 本地資料庫 dev:
root:root@tcp(127.0.0.1:3306)/dev?parseTime=True&loc=Local
- 建立表:
CREATE TABLE IF NOT EXISTS greater (
hello VARCHAR(20) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
ps:Makefile 中提供了使用 goose 進行資料庫變更管理的能力(goose 也是一個開源的高 🌟 專案,推薦學習)
up:
goose mysql "root:root@tcp(localhost:3306)/dev?parseTime=true" up
down:
goose mysql "root:root@tcp(localhost:3306)/dev?parseTime=true" down
create:
goose mysql "root:root@tcp(localhost:3306)/dev?parseTime=true" create ${name} sql
-
啟動服務:
go run ./cmd/helloworld/
,透過config.yaml
配置了 HTTP 服務監聽 localhost:8000,GRPC 則是 localhost:9000。 -
發起一個 get 請求
核心邏輯
helloworld
專案本質是一個打招呼服務,由於 kit/transcation/helloworld
已經是魔改後的版本,為了與預設專案做對比,你可以自行生成一個 helloworld
專案,在同級目錄下,對照學習。
在 internal/biz/greeter.go
檔案中,是我更改的內容,為了測試事務,我在 biz 層的 CreateGreeter
方法中,呼叫了 repo 層的 Save
和 Update
兩個方法,且這兩個方法都會成功,但是 Update
方法人為丟擲一個異常。
// CreateGreeter creates a Greeter, and returns the new Greeter.
func (uc *GreeterUsecase) CreateGreeter(ctx context.Context, g *Greeter) (*Greeter, error) {
uc.log.WithContext(ctx).Infof("CreateGreeter: %v", g.Hello)
var (
greater *Greeter
err error
)
//err = uc.db.ExecTx(ctx, func(ctx context.Context) error {
// // 更新所有 hello 為 hello + "updated",且插入新的 hello
// greater, err = uc.repo.Save(ctx, g)
// _, err = uc.repo.Update(ctx, g)
// return err
//})
greater, err = uc.repo.Save(ctx, g)
_, err = uc.repo.Update(ctx, g)
if err != nil {
return nil, err
}
return greater, nil
}
// Update 人為丟擲異常
func (r *greeterRepo) Update(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
result := r.data.db.DB(ctx).Model(&biz.Greeter{}).Where("hello = ?", g.Hello).Update("hello", g.Hello+"updated")
if result.RowsAffected == 0 {
return nil, fmt.Errorf("greeter %s not found", g.Hello)
}
return nil, fmt.Errorf("custom error")
//return g, nil
}
repo 層開啟事務
如果忽略上文註釋中的內容,因為兩個 repo 的資料庫操作都是獨立的。
func (r *greeterRepo) Save(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
result := r.data.db.DB(ctx).Create(g)
return g, result.Error
}
func (r *greeterRepo) Update(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
result := r.data.db.DB(ctx).Model(&biz.Greeter{}).Where("hello = ?", g.Hello).Update("hello", g.Hello+"updated")
if result.RowsAffected == 0 {
return nil, fmt.Errorf("greeter %s not found", g.Hello)
}
return nil, fmt.Errorf("custom error")
//return g, nil
}
即使最後丟擲 Update 的異常,但是 save 和 update 都已經成功了,且彼此不強關聯,資料庫中會多增加一條資料。
biz 層開啟事務
因此為了 repo 層的兩個方法能夠共用一個事務,應該在 biz 層就使用 db 開啟事務,且將這個事務的會話傳遞給 repo 層的方法。
🌟 如何傳遞:使用 context 便成了順理成章的方案。
接下來將 internal/biz/greeter.go
檔案中註釋的部分釋放,且註釋掉分開使用事務的兩行,此時重新執行專案請求介面,則由於 Update 方法丟擲 err,導致事務回滾,未出現新增的 xiaomingupdated
記錄。
// CreateGreeter creates a Greeter, and returns the new Greeter.
func (uc *GreeterUsecase) CreateGreeter(ctx context.Context, g *Greeter) (*Greeter, error) {
uc.log.WithContext(ctx).Infof("CreateGreeter: %v", g.Hello)
var (
greater *Greeter
err error
)
err = uc.db.ExecTx(ctx, func(ctx context.Context) error {
// 更新所有 hello 為 hello + "updated",且插入新的 hello
greater, err = uc.repo.Save(ctx, g)
_, err = uc.repo.Update(ctx, g)
return err
})
//greater, err = uc.repo.Save(ctx, g)
//_, err = uc.repo.Update(ctx, g)
if err != nil {
return nil, err
}
return greater, nil
}
核心實現
由於 biz 層的 Usecase 例項持有 *DBClient
,repo 層也持有 *DBClient
,且二者在依賴注入的時候,代表同一個資料庫連線池例項。
在 pkg/db/db.go
中,為 *DBClient
提供瞭如下兩個方法: ExecTx()
& DB()
。
在 biz 層,透過優先執行 ExecTx()
方法,建立事務,以及將待執行的兩個 repo 方法封裝在 fn 引數中,傳遞給 gorm 例項的 Transaction()
方法待執行。
同時在 Transcation 內部,觸發 fn() 函式,也就是聚合的兩個 repo 操作,需要注意的是,此時將攜帶 contextTxKey 事務 tx 的 ctx 作為引數傳遞給了 fn 函式,因此下游的兩個 repo 可以獲取到 biz 層的事務會話。
type contextTxKey struct{}
// ExecTx gorm Transaction
func (c *DBClient) ExecTx(ctx context.Context, fn func(ctx context.Context) error) error {
return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctx = context.WithValue(ctx, contextTxKey{}, tx)
return fn(ctx)
})
}
func (c *DBClient) DB(ctx context.Context) *gorm.DB {
tx, ok := ctx.Value(contextTxKey{}).(*gorm.DB)
if ok {
return tx
}
return c.db
}
在 repo 層執行資料庫操作的時候,嘗試透過 DB()
方法,從 ctx 中獲取到上游傳遞下來的事務會話,如果有則使用,如果沒有,則使用 repo 層自己持有的 *DBClient
,進行資料訪問操作。
func (r *greeterRepo) Save(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
result := r.data.db.DB(ctx).Create(g)
return g, result.Error
}
func (r *greeterRepo) Update(ctx context.Context, g *biz.Greeter) (*biz.Greeter, error) {
result := r.data.db.DB(ctx).Model(&biz.Greeter{}).Where("hello = ?", g.Hello).Update("hello", g.Hello+"updated")
if result.RowsAffected == 0 {
return nil, fmt.Errorf("greeter %s not found", g.Hello)
}
return nil, fmt.Errorf("custom error")
//return g, nil
}
參考文獻
-
https://lailin.xyz/post/clean-arch-transaction.html
-
https://github.com/pressly/goose
-
https://github.com/go-kratos/kratos
-
https://go-kratos.dev/docs/getting-started/usage
-
https://gorm.io/zh_CN/docs/update.html
-
https://www.cnblogs.com/zhanchenjin/p/17855944.html