Golang在整洁架构基础上实现事务

前言

大家好,这里是白泽,这篇文章在 go-kratos 官方的 layout 项目的整洁架构基础上,实现优雅的数据库事务操作。

视频讲解 📺:B站:白泽talk

本期涉及的学习资料:

  • 我的开源Golang学习仓库:github.com/BaiZe1998/g...,这期的所有内容汇聚成一个可运行的 demo, kit/transaction 路径下。
  • kratos CLI 工具:go install github.com/go-kratos/kratos/cmd/kratos/v2@latest
  • kratos 微服务框架:github.com/go-kratos/k...
  • wire 依赖注入库:github.com/google/wire
  • 领域驱动设计思想:本文不多涉及,具备相关背景知识食用本文更佳。

在开始学习之前,先补齐一下整洁架构 & 依赖注入的前置知识。

预备知识

整洁架构

kratos 是 Go 语言的一个微服务框架,github 🌟 23k,github.com/go-kratos/k...

该项目提供了 CLI 工具,允许用户通过 kratos new xxxx,新建一个 xxxx 项目,这个项目将使用 kratos-layout 仓库的代码结构。

仓库地址:github.com/go-kratos/k...

kratos-layout 项目为用户提供的,配合 CLI 工具生成的一个典型的 Go 项目布局看起来像这样:

markdown 复制代码
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。

go 复制代码
// 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 层。

go 复制代码
// 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 作为数据访问的实现层,实现了上游接口,注入了数据库实例资源。

go 复制代码
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,作为被操作的对象。

go 复制代码
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 优雅事务

准备

🌟 项目获取:强烈建议克隆仓库后实机操作。

bash 复制代码
git clone [email protected]: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. 建立表:
sql 复制代码
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 也是一个开源的高 🌟 项目,推荐学习)

shell 复制代码
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。
  1. 发起一个 get 请求

核心逻辑

helloworld 项目本质是一个打招呼服务,由于 kit/transcation/helloworld 已经是魔改后的版本,为了与默认项目做对比,你可以自行生成一个 helloworld 项目,在同级目录下,对照学习。

internal/biz/greeter.go 文件中,是我更改的内容,为了测试事务,我在 biz 层的 CreateGreeter 方法中,调用了 repo 层的 SaveUpdate 两个方法,且这两个方法都会成功,但是 Update 方法人为抛出一个异常。

go 复制代码
// 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 的数据库操作都是独立的。

go 复制代码
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 记录。

go 复制代码
// 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 层的事务会话。

go 复制代码
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,进行数据访问操作。

go 复制代码
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
}

参考文献

相关推荐
LanLance14 分钟前
ES101系列09 | 运维、监控与性能优化
java·运维·后端·elasticsearch·云原生·性能优化·golang
Piper蛋窝18 分钟前
我所理解的 Go 的 `panic` / `defer` / `recover` 异常处理机制
后端·go
这儿有一堆花35 分钟前
打造你的 Android 图像编辑器:深入解析 PhotoEditor 开源库
android·开源
clk66071 小时前
Spring Boot
java·spring boot·后端
皮皮高2 小时前
itvbox绿豆影视tvbox手机版影视APP源码分享搭建教程
android·前端·后端·开源·tv
弱冠少年2 小时前
golang入门
开发语言·后端·golang
Humbunklung2 小时前
Rust 函数
开发语言·后端·rust
喜欢踢足球的老罗2 小时前
在Spring Boot 3.3中使用Druid数据源及其监控功能
java·spring boot·后端·druid
叹一曲当时只道是寻常2 小时前
AI书签管理工具开发全记录(十三):TUI基本框架搭建
ui·go
jakeswang2 小时前
StarRocks
后端·架构