Golang 依赖注入设计哲学|12.6K 🌟 的依赖注入库 wire

一、前言

线上项目往往依赖非常多的具备特定能力的资源,如:DB、MQ、各种中间件,以及随着项目业务的复杂化,单一项目内,业务模块也逐渐增多,如何高效、整洁管理各种资源十分重要。

本文从"术"层面,讲述"依赖注入"的实现,带你体会其对于整洁架构 & DDD 等设计思想的落地,起到的支撑作用。

涉及内容:

📺 B站账号:白泽talk,绝大部分博客内容都将会通过视频讲解,不过文章一般是先于视频发布

白泽的开源 Golang 学习仓库:https://github.com/BaiZe1998/go-learning,用于文章归档 & 聚合博客代码案例

公众号【白泽talk】,本期内容的 pdf 版本,可以关注公众号,回复【依赖注入】获得,往期资源的获取,都是类似的方式。

二、What

📒 本文所涉及编写的代码,已收录于 https://github.com/BaiZe1998/go-learning/di 目录

一句话概括:实例 A 的创建,依赖于实例 B 的创建,且在实例 A 的生命周期内,持有对实例 B 的访问权限。

2.1 案例分析

依赖注入(Dependency Injection, DI),以 Golang 为例,左侧为手动完成依赖注入,右侧为不使用依赖注入

🌟 不使用依赖注入风险:

  1. 全局变量十分不安全,存在覆写的可能
  2. 资源散落在各处,可能重复创建,浪费内存,后续维护能力极差
  3. 提高循环依赖的风险
  4. 全局变量的引入提高单元测试的成本
  • 不使用依赖注入 demo
go 复制代码
package main

var (
	mysqlUrl = "mysql://blabla"
	// 全局数据库实例
	db = NewMySQLClient(mysqlUrl)
)

func NewMySQLClient(url string) *MySQLClient {
	return &MySQLClient{url: url}
}

type MySQLClient struct {
	url string
}

func (c *MySQLClient) Exec(query string, args ...interface{}) string {
	return "data"
}

func NewApp() *App {
	return &App{}
}

type App struct {
}

func (a *App) GetData(query string, args ...interface{}) string {
	data := db.Exec(query, args...)
	return data
}

// 不使用依赖注入
func main() {
	app := NewApp()
	rest := app.GetData("select * from table where id = ?", "1")
	println(rest)
}
  • 手动依赖注入 demo
go 复制代码
package main

func NewMySQLClient(url string) *MySQLClient {
	return &MySQLClient{url: url}
}

type MySQLClient struct {
	url string
}

func (c *MySQLClient) Exec(query string, args ...interface{}) string {
	return "data"
}

func NewApp(client *MySQLClient) *App {
	return &App{client: client}
}

type App struct {
	// App 持有唯一的 MySQLClient 实例
	client *MySQLClient
}

func (a *App) GetData(query string, args ...interface{}) string {
	data := a.client.Exec(query, args...)
	return data
}

// 手动依赖注入
func main() {
	client := NewMySQLClient("mysql://blabla")
	app := NewApp(client)
	rest := app.GetData("select * from table where id = ?", "1")
	println(rest)
}

三、Why

依赖注入 (Dependency Injection,缩写为 DI),可以理解为一种代码的构造模式(就是写法),按照这样的方式来写,能够让你的代码更加容易维护。

四、How

4.1 Golang 依赖注入

以 Golang 🌟 最多的开源库 wire 为例讲解:https://github.com/google/wire/blob/main/docs/guide.md

wire是由 google 开源的一个供 Go 语言使用的依赖注入代码生成工具。它能够根据你的代码,生成相应的依赖注入 go 代码。

而与其它依靠反射实现的依赖注入工具不同的是,wire 能在编译期(准确地说是代码生成时)如果依赖注入有问题,在代码生成时即可报出来,不会拖到运行时才报,更便于 debug。

  • Install:
shell 复制代码
go install github.com/google/wire/cmd/wire@latest
  • provider: a function that can produce a value

以上面手动实现依赖注入为基础,wire 做的工作是帮助开发者完成如下组装过程

go 复制代码
client := NewMySQLClient("mysql://blabla")
app := NewApp(client)

而其中用到的 NewMySQLClient、NewApp 在 wire 定义为一个个的 provider,是需要提前由开发者实现的。

go 复制代码
func NewMySQLClient(url string) *MySQLClient {
	return &MySQLClient{url: url}
}

func NewApp(client *MySQLClient) *App {
	return &App{client: client}
}

假设系统中的资源很多,配置很多,出现了如下复杂的初始化流程,人工完成依赖注入则变得复杂:

go 复制代码
a := NewA(xxx, yyy) error
b := NewB(ctx, a) error
c := NewC(zzz, a, b) error
d := NewD(www, kkk, a) error
e := NewD(ctx, b, d) error
  • injector: a function that calls providers in dependency order

如下是名为 wire.go 的依赖注入配置文件,是一个只会被 wire 命令行工具处理的 injector 文件,用于声明依赖注入流程。

wire.go:

go 复制代码
//go:build wireinject
// +build wireinject

// The build tag makes sure the stub is not built in the final build.

package main

import "github.com/google/wire"

// wireApp init application.
func wireApp(url string) *App {
	wire.Build(NewMySQLClient, NewApp)
	return nil
}

执行 wire 命令,则在当前目录下生成 wire_gen.go 文件,此时的 wireApp 函数,就等价于最初手动编写的依赖注入流程,可以在真正需要初始化的引入。

wire_gen.go:

go 复制代码
// Code generated by Wire. DO NOT EDIT.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

// Injectors from wire.go:

// wireApp init application.
func wireApp(url string) *App {
   mySQLClient := NewMySQLClient(url)
   app := NewApp(mySQLClient)
   return app
}

4.2 针对复杂项目的依赖注入设计哲学

这里以 go-kratos 的模版项目为例讲解,是一个 helloworld 服务,我们着重分析其借助 wire 进行依赖注入的部分。

以下 helloworld 模板服务的 interanl 目录的内容:

shell 复制代码
.
├── biz
│   ├── README.md
│   ├── biz.go
│   └── greeter.go
├── conf
│   ├── conf.pb.go
│   └── conf.proto
├── data
│   ├── README.md
│   ├── data.go
│   └── greeter.go
├── server
│   ├── grpc.go
│   ├── http.go
│   └── server.go
└── service
    ├── README.md
    ├── greeter.go
    └── service.go

各个目录的关系如图:

  • data:业务数据访问,包含 cache、db 等封装,实现了 biz 的 repo 接口,data 偏重业务的含义,它所要做的是将领域对象重新拿出来。

  • biz:业务逻辑的组装层,类似 DDD 的 domain 层,data 类似 DDD 的 repo,repo 接口在这里定义,使用依赖倒置的原则。

  • service:实现了 api 定义的服务层,类似 DDD 的 application 层,处理 DTO 到 biz 领域实体的转换(DTO -> DO),同时协同各类 biz 交互,但是不应处理复杂逻辑。

  • server:为http和grpc实例的创建和配置,以及注册对应的 service 。

🌟上图右侧部分,表示了模块之间的依赖关系,可以看到,依赖的注入是逆向的,资源往往被业务模块持有,业务模块则被负责编排业务的应用持有,应用则被负责对外通信的模块持有。

此时在服务启动前的实例化阶段,provider 的定义和注入,本质是这样一种状态:

go 复制代码
func main() {
    dbClient := NewDBClient()
    dataN := NewDataN(dbClient)
    dataM := NewDataM(dbClient)
    bizA := NewBizA(dataN)
    bizB := NewBizB(dataM)
    bizC := NewBizC(dataN, dataM)
    serviceX := NewService(bizA, bizB, bizC)
    server := NewServer(serviceX)
    server.httpXXX // 提供 http 服务
    server.grpcXXX // 提供 grpc 服务
}

在 helloworld 这个 demo 当中,则是这样定义 provider 的:

go 复制代码
// biz 目录
var ProviderSet = wire.NewSet(NewGreeterUsecase)

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 目录
var ProviderSet = wire.NewSet(NewData, NewGreeterRepo)

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
}

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),
	}
}
// service 目录
var ProviderSet = wire.NewSet(NewGreeterService)

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
}

// server 目录
var ProviderSet = wire.NewSet(NewGRPCServer, NewHTTPServer)

func NewGRPCServer(c *conf.Server, greeter *service.GreeterService, logger log.Logger) *grpc.Server {
	var opts = []grpc.ServerOption{
		grpc.Middleware(
			recovery.Recovery(),
		),
	}
	if c.Grpc.Network != "" {
		opts = append(opts, grpc.Network(c.Grpc.Network))
	}
	if c.Grpc.Addr != "" {
		opts = append(opts, grpc.Address(c.Grpc.Addr))
	}
	if c.Grpc.Timeout != nil {
		opts = append(opts, grpc.Timeout(c.Grpc.Timeout.AsDuration()))
	}
	srv := grpc.NewServer(opts...)
	v1.RegisterGreeterServer(srv, greeter)
	return srv
}

在 helloworld 这个 demo 当中,则是这样定义 injector 的:

go 复制代码
// wire.go
func wireApp(*conf.Server, *conf.Data, log.Logger) (*kratos.App, func(), error) {
   panic(wire.Build(server.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp))
}

最后运行 wire 的到的完成注入的文件如下:

go 复制代码
// wire_gen.go
func wireApp(confServer *conf.Server, confData *conf.Data, logger log.Logger) (*kratos.App, func(), error) {
	dataData, cleanup, err := data.NewData(confData, logger)
	if err != nil {
		return nil, nil, err
	}
	greeterRepo := data.NewGreeterRepo(dataData, logger)
	greeterUsecase := biz.NewGreeterUsecase(greeterRepo, logger)
	greeterService := service.NewGreeterService(greeterUsecase)
	grpcServer := server.NewGRPCServer(confServer, greeterService, logger)
	httpServer := server.NewHTTPServer(confServer, greeterService, logger)
	app := newApp(logger, grpcServer, httpServer)
	return app, func() {
		cleanup()
	}, nil
}

生成代码之后,则可以像使用普通的 golang 函数一样,使用这个 wire_gen.go 文件内的 wireApp 函数实例化一个 helloworld 服务

go 复制代码
func main() {
	flag.Parse()
	logger := log.With(log.NewStdLogger(os.Stdout),
		// ...
	)
	c := config.New(
        // ...
	)
	defer c.Close()
	// ...

	app, cleanup, err := wireApp(bc.Server, bc.Data, logger)
	if err != nil {
		panic(err)
	}
	defer cleanup()

	// start and wait for stop signal
	if err := app.Run(); err != nil {
		panic(err)
	}
}

4.3 wire 的更多用法

参见 wire 的文档,自己用几遍就明白了,这里举几个例子:

  • 定义携带 error 返回值的 provider
go 复制代码
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}
  • provider 集合:方便组织多个 provider
go 复制代码
var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)
  • 接口绑定:
go 复制代码
type Fooer interface {
    Foo() string
}

type MyFooer string

func (b *MyFooer) Foo() string {
    return string(*b)
}

func provideMyFooer() *MyFooer {
    b := new(MyFooer)
    *b = "Hello, World!"
    return b
}

type Bar string

func provideBar(f Fooer) string {
    // f will be a *MyFooer.
    return f.Foo()
}

var Set = wire.NewSet(
    provideMyFooer,
    wire.Bind(new(Fooer), new(*MyFooer)),
    provideBar)

五、对比 Spring Boot 的依赖注入

Spring Boot的依赖注入(DI)和Golang开源库Wire的依赖注入在设计思路上存在一些相同点和不同点。以下是对这些相同点和不同点的分析:

相同点

  1. 降低耦合度:两者都通过依赖注入的方式实现了代码的松耦合。这意味着,一个对象不需要显式地创建或查找它所依赖的其他对象,这些依赖项会由外部容器(如Spring容器)或工具(如Wire)自动提供。
  2. 提高可测试性:由于依赖关系被解耦,可以更容易地替换依赖项以进行单元测试。无论是Spring Boot还是使用Wire的Golang应用,都可以轻松地为组件提供模拟或存根的依赖项以进行测试。
  3. 灵活性:两者都允许在不修改组件代码的情况下替换依赖项。这使得应用程序在维护和扩展时更加灵活。

不同点

  1. 实现方式
    • Spring Boot的依赖注入是基于Java的反射机制和Spring框架的容器管理功能实现的。Spring容器负责创建和管理Bean的生命周期,并在需要时自动注入依赖项,核心在于运行时
    • Wire是一个Golang的代码生成工具,它通过分析代码中的构造函数和结构体标签,自动生成依赖注入的代码(减少人工工作量),在开发阶段 已经通过工具生成好了依赖注入的代码,程序编译时,资源之间的依赖关系已经固定。
  2. 配置方式
    • Spring Boot的依赖注入通常通过配置文件(如application.properties或application.yml)和注解(如@Autowired)进行配置。开发者可以在配置文件中定义Bean的属性,并通过注解在需要注入的地方指明依赖关系。
    • Wire则通过特殊的Go文件(通常是wire.go文件)来定义类型之间的依赖关系。这些文件包含了用于生成依赖注入代码的指令和元数据。
  3. 运行时开销
    • Spring Boot的依赖注入在运行时需要依赖Spring容器来管理Bean的生命周期和依赖关系。这可能会引入一些额外的运行时开销,特别是在大型应用程序中。
    • Wire在编译时生成依赖注入的代码,因此它在运行时没有额外的开销。这使得使用Wire的Golang应用程序通常具有更好的性能。

六、参考资料

kratos:https://go-kratos.dev/en/docs/getting-started/start/

wire:https://github.com/google/wire/blob/main/_tutorial/README.md

相关推荐
不爱说话郭德纲15 小时前
聚焦 Go 语言框架,探索创新实践过程
go·编程语言
lsjweiyi16 小时前
极简AI工具箱网站开源啦!
opencv·开源·微信支付·支付宝支付·百度ai·极简ai工具箱·ai图像处理
开源社17 小时前
一场开源视角的AI会议即将在南京举办
人工智能·开源
FreeIPCC17 小时前
谈一下开源生态对 AI人工智能大模型的促进作用
大数据·人工智能·机器人·开源
海害嗨17 小时前
阿里巴巴官方「SpringCloudAlibaba全彩学习手册」限时开源!
学习·开源
生命是有光的17 小时前
【开源风云】从若依系列脚手架汲取编程之道(八)
开源
HuggingFace19 小时前
Halo 正式开源: 使用可穿戴设备进行开源健康追踪
开源·健康追踪
时光追逐者1 天前
.NET 9 中 LINQ 新增功能实操
开发语言·开源·c#·.net·.netcore·linq·微软技术
檀越剑指大厂1 天前
Linux本地部署开源项目OpenHands基于AI的软件开发代理平台及公网访问
linux·人工智能·开源
胜天半子_王二_王半仙2 天前
c++源码阅读__ThreadPool__正文阅读
开发语言·c++·开源