Golang在整洁架构中优雅使用事务
学习资料
- kratos CLI 工具:使用命令 go install github.com/go-kratos/kratos/cmd/kratos/v2@latest安装。
- kratos 微服务框架
- wire 依赖注入库
- 领域驱动设计思想:本文不多涉及,具备相关背景知识更佳。
在开始学习之前,先补充整洁架构与依赖注入的前置知识。
预备知识
整洁架构
Kratos 是 Go 语言的微服务框架,GitHub 星标 23k,地址:kratos。该项目提供 CLI 工具,允许用户通过 kratos new xxxx 新建项目,使用 kratos-layout 仓库的代码结构。
kratos-layout 项目为用户提供了一个典型的 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 进行依赖注入。
Service层
在 service 层,实现 RPC 接口的方法,注入 biz:
type GreeterService struct {
   v1.UnimplementedGreeterServer
   uc *biz.GreeterUsecase
}
func NewGreeterService(uc *biz.GreeterUsecase) *GreeterService {
   return &GreeterService{uc: uc}
}
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层
在 biz 层,定义 repo 接口,注入 data 层:
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)
}
type GreeterUsecase struct {
   repo GreeterRepo
   log  *log.Helper
}
func NewGreeterUsecase(repo GreeterRepo, logger log.Logger) *GreeterUsecase {
   return &GreeterUsecase{repo: repo, log: log.NewHelper(logger)}
}
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
}
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
}
数据库连接
注入 data 作为被操作的对象:
type Data struct {
   // TODO: wrapped database client
}
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/transaction/helloworld
该目录基于 go-kratos CLI 工具生成,并在此基础上修改,实现了事务支持。
运行 demo 需要准备:
- 本地数据库 dev:root:root@tcp(127.0.0.1:3306)/dev?parseTime=True&loc=Local
- 建立表:
CREATE TABLE IF NOT EXISTS greeter (
    hello VARCHAR(20) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
启动服务
运行服务:
go run ./cmd/helloworld/
通过 config.yaml 配置 HTTP 服务监听 localhost:8000,GRPC 则是 localhost:9000。
核心逻辑
helloworld 项目本质是一个打招呼服务。在 internal/biz/greeter.go 文件中,为了测试事务,在 biz 层的 CreateGreeter 方法中,调用了 repo 层的 Save 和 Update 方法,且 Update 方法人为抛出一个异常。
func (uc *GreeterUsecase) CreateGreeter(ctx context.Context, g *Greeter) (*Greeter, error) {
    uc.log.WithContext(ctx).Infof("CreateGreeter: %v", g.Hello)
    var (
        greeter *Greeter
        err     error
    )
    err = uc.db.ExecTx(ctx, func(ctx context.Context) error {
        greeter, err = uc.repo.Save(ctx, g)
        _, err = uc.repo.Update(ctx, g)
        return err
    })
    if err != nil {
        return nil, err
    }
    return greeter, nil
}
Repo层开启事务
为了在 repo 层共用一个事务,在 biz 层使用 db 开启事务,并将事务会话传递给 repo 层的方法。
核心实现
在 biz 层,通过优先执行 ExecTx() 方法,创建事务,并将待执行的两个 repo 方法封装在 fn 参数中,传递给 GORM 实例的 Transaction() 方法。
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 层持有的数据库实例。