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

前言

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

视频讲解 📺:B站:白泽talk,公众号【白泽talk】

本期涉及的学习资料:

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

预备知识

整洁架构

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 项目布局看起来像这样:

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

准备

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

shell 复制代码
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. 建立表:
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。

  2. 发起一个 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
}

参考文献

相关推荐
清云随笔10 分钟前
Golang基础
golang
猫头虎16 分钟前
OpenClaw开源汉化发行版:介绍、下载、安装、配置教程
运维·windows·开源·aigc·ai编程·agi·csdn
研究司马懿1 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
wukangjupingbb1 小时前
AI在靶点识别(Target Identification)中的关键作用与开源工具生态
人工智能·开源
一只大侠的侠1 小时前
Flutter开源鸿蒙跨平台训练营 Day19自定义 useFormik 实现高性能表单处理
flutter·开源·harmonyos
牛奔3 小时前
Go 如何避免频繁抢占?
开发语言·后端·golang
一只大侠的侠7 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
不老刘7 小时前
LiveKit 本地部署全流程指南(含 HTTPS/WSS)
golang·实时音视频·livekit
猫头虎11 小时前
如何排查并解决项目启动时报错Error encountered while processing: java.io.IOException: closed 的问题
java·开发语言·jvm·spring boot·python·开源·maven
草梅友仁11 小时前
墨梅博客 1.4.0 发布与开源动态 | 2026 年第 6 周草梅周报
开源·github·ai编程