Golang在整潔架構基礎上實現事務

白泽talk發表於2024-08-07

前言

大家好,這裡是白澤,這篇文章在 go-kratos 官方的 layout 專案的整潔架構基礎上,實現優雅的資料庫事務操作。

影片講解 📺:B站:白澤talk,公眾號【白澤talk】

image-20240726234405804

本期涉及的學習資料:

  • 我的開源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

image-20240806235306095

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 需要準備:

  1. 本地資料庫 dev:root:root@tcp(127.0.0.1:3306)/dev?parseTime=True&loc=Local
  2. 建立表:
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
  1. 啟動服務:go run ./cmd/helloworld/,透過 config.yaml 配置了 HTTP 服務監聽 localhost:8000,GRPC 則是 localhost:9000。

  2. 發起一個 get 請求

image-20240807005017171

核心邏輯

helloworld 專案本質是一個打招呼服務,由於 kit/transcation/helloworld 已經是魔改後的版本,為了與預設專案做對比,你可以自行生成一個 helloworld 專案,在同級目錄下,對照學習。

internal/biz/greeter.go 檔案中,是我更改的內容,為了測試事務,我在 biz 層的 CreateGreeter 方法中,呼叫了 repo 層的 SaveUpdate 兩個方法,且這兩個方法都會成功,但是 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 都已經成功了,且彼此不強關聯,資料庫中會多增加一條資料。

image-20240807005400189

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

相關文章