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


    一、前言

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

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

    涉及内容:

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

    image-20240703002016429

    白泽的开源 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. 全局变量的引入提高单元测试的成本

    image-20240625222009500

    • 不使用依赖注入 demo
    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
    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:
    go install github.com/google/wire/cmd/wire@latest
    
    • provider: a function that can produce a value

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

    client := NewMySQLClient("mysql://blabla")
    app := NewApp(client)
    

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

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

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

    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: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:

    // 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 目录的内容:

    .
    ├── 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
    

    各个目录的关系如图:

    image-20240702235735708

    • 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 的定义和注入,本质是这样一种状态:

    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 的:

    // 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 的:

    // 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 的到的完成注入的文件如下:

    // 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 服务

    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
    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
    var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)
    
    • 接口绑定:
    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

  • 相关阅读:
    dotnet7 aot编译实战
    2010年408大题总结
    智能网联跑出中国「加速度」,26.15%搭载率背后的市场洗牌
    Android --- 英文单引号用'替换报错:does not contain a valid string resource
    【C++智能指针】智能指针的发展和循环引用的原理和解决
    java基于springboot+vue的校园跑腿系统 nodejs前后端分离
    扩散模型(Diffusion Model)简介
    ​软考-高级-信息系统项目管理师教程 第四版【第17章-项目干系人管理-思维导图】​
    语义分割 语义通信 增强模型图片处理
    菜狗杯Misc迅疾响应wp
  • 原文地址:https://www.cnblogs.com/YLTFY1998/p/18280945