都 2024 年最后一周了,不会有人还在为Go项目的依赖注入而发愁吧🙂‍↔️🙂‍↔️🙂‍↔️

820 阅读7分钟

依赖注入(Dependency Injection, DI)是一种软件设计模式,用于管理代码中各个组件之间的依赖关系,从而实现代码的松耦合、可维护性和可测试性,其核心思想便是将对象的创建和依赖关系的管理从对象内部转移到外部进行统一管理,从而减少对象之间的直接耦合,提高系统的灵活性和可扩展性

这里涉及到了一点点控制反转的理念,我们在这里稍微理解一下 IoC 的概念

IoC 是一种对依赖关系进行抽象化的设计思想,其核心作用是降低代码耦合度,而其本质是将对象的创建和依赖管理从业务中抽离出来,从而提高代码的灵活性、可维护性和可扩展性

为什么需要依赖注入

  • 当我们没有使用依赖注入时,我们在 Go 中一般会通过全局变量的方式来共享资源,而这样的方式可能会导致非常多的问题,如并发安全问题、难以测试、作用范围不可控、可维护性差

下面是在 Go 中比较常见的一段示例:

var db *sql.DB
​
func init() {
  db, _ = sql.Open(...)
}

在上述代码中,我们通过全局变量 db 来保存对数据库的连接,但这种方式在程序中可能会引发以下问题:

  1. 并发安全问题: 多个并发执行的程序可能会同时访问与修改变量,导致资源竞态,从而导致难以预料的行为
  2. 难以测试:全局变量在测试过程中难以控制其状态,可能导致测试覆盖不全或者产生更多的样例来覆盖全局变量的不同状态
  3. 可维护性差: 全局变量被多处依赖,增加了代码耦合度,导致代码难以重构和维护,从而降低了代码的可维护性
  • 当我们引入依赖注入后,依赖关系不再作用于对象内部,而是通过构造函数、Setter 方法或字段注入等方式进行注入,从而形成统一的切面便于管理
type Infra struct {
  db *sql.DB
}
​
func NewInfra(db *sql.DB) *Infra {
  return &Infra{db}
}
​
func (i *Infra) InfraMethod() {
  i.db.Method()
}
​
func main() {
  db, _ := sql.Open(...)
  
  infra := NewInfra(db)
  
  infra.InfraMethod()
}

在当前示例中,我们在 NewInfra 中将 db 作为参数传入,从而实现依赖注入,完成了 Infra 和数据库连接的解耦,同时易于测试与代码维护

依赖注入的优势

  1. 降低耦合度:依赖注入通过将对象的创建和组合工作交给外部容器来实现软件组件之间的解耦,从而提高代码的可测试性、可维护性和灵活性。
  2. 提高代码的可测试性:依赖关系被转移到外部,单元测试变得更加容易,允许开发者专注于测试当前类的逻辑,提升测试覆盖率和效率。
  3. 增强代码的可重用性:依赖注入使得一个类的功能不再受限于具体实现,只要遵循相同的接口或继承自相同的抽象类,就可以轻松替换旧的实现,增强了代码的可重用性。
  4. 便于代码管理和扩展:依赖注入使得新增功能或修改现有功能变得更加简单,因为依赖关系的管理是集中的,通常在一个配置文件或注入容器中进行。
  5. 促进架构的整洁性:依赖注入鼓励采用依赖倒置原则,即高层模块不应该依赖于低层模块,两者都应该依赖于抽象,提高了整体的设计质量。
  6. 支持不同环境的配置:依赖注入容器可以在运行时根据不同的环境提供不同的配置,增加了应用的适应性和健壮性。
  7. 提高开发效率:通过解耦和模块化使得性能优化、代码维护等工作变得更容易、高效。

实际应用

  • Spring

    Spring 框架通过IoC 容器自动管理 Bean 之间的依赖关系,支持多种注入方式

  • Kratos

    Kratos 默认采用 wire 作为依赖注入方式

Go 依赖注入详解

Golang 生态中的依赖注入通常通过 AST 生成对应代码实现的注入流程,也有部分通过反射机制等在程序运行中实现动态注入。常见的依赖注入框架有 WireDigInject等,而个人常用的依赖注入框架是由 Google Go Cloud 团队开发的 Wire。

首先,Wire 足够轻量,通过自动生成代码的方式在编译期间完成依赖注入,不需要反射机制,没有额外的运行时开销。

另外,Wire 通过代码生成实现依赖注入,便于依赖关系间的管理,同时相较于 Go 略显蹩脚的 reference,AST 无疑是更加出彩的解决方案

安装 Wire

go get github.com/google/wire/cmd/wire

理解相关概念

Wire 有两个基础概念,分别为 ProviderInjector

  • Proider

    实际上便是构造方法,通常以所依赖的类型作为入参,并返回对应依赖

    e.g. 上述示例中的 NewInfra

    func NewInfra(db *sql.DB) *Infra {
      return &Infra{db}
    }
    
  • Injector

    按照依赖顺序调用 Provider 并返回构建目标,即 wire.Build()

跟着 Nunu 的脚步一起看看,Wire 依赖注入是如何在 Nunu 中一步步落地的吧

.
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── README_zh.md
├── api
│   └── v1
│       ├── errors.go
│       ├── user.go
│       └── v1.go
├── cmd
│   ├── migration
│   │   ├── main.go
│   │   └── wire
│   │       ├── wire.go
│   │       └── wire_gen.go
│   ├── server
│   │   ├── main.go
│   │   └── wire
│   │       ├── wire.go
│   │       └── wire_gen.go
│   └── task
│       ├── main.go
│       └── wire
│           ├── wire.go
│           └── wire_gen.go
├── config
│   ├── local.yml
│   └── prod.yml
├── deploy
│   ├── build
│   │   └── Dockerfile
│   └── docker-compose
│       └── docker-compose.yml
├── docs
│   ├── docs.go
│   ├── swagger.json
│   └── swagger.yaml
├── go.mod
├── go.sum
├── internal
│   ├── handler
│   │   ├── handler.go
│   │   └── user.go
│   ├── middleware
│   │   ├── cors.go
│   │   ├── jwt.go
│   │   ├── log.go
│   │   └── sign.go
│   ├── model
│   │   └── user.go
│   ├── repository
│   │   ├── repository.go
│   │   └── user.go
│   ├── server
│   │   ├── http.go
│   │   ├── job.go
│   │   ├── migration.go
│   │   └── task.go
│   └── service
│       ├── service.go
│       └── user.go
├── pkg
│   ├── app
│   │   └── app.go
│   ├── config
│   │   └── config.go
│   ├── jwt
│   │   └── jwt.go
│   ├── log
│   │   └── log.go
│   ├── server
│   │   ├── grpc
│   │   │   └── grpc.go
│   │   ├── http
│   │   │   └── http.go
│   │   └── server.go
│   ├── sid
│   │   ├── convert.go
│   │   └── sid.go
│   └── zapgorm2
│       └── zapgorm2.go
├── scripts
│   └── README.md
├── storage
│   ├── logs
│   │   └── server.log
│   └── nunu-test.db
├── test
│   ├── mocks
│   │   ├── repository
│   │   │   ├── repository.go
│   │   │   └── user.go
│   │   └── service
│   │       └── user.go
│   └── server
│       ├── handler
│       │   └── user_test.go
│       ├── repository
│       │   └── user_test.go
│       └── service
│           └── user_test.go
└── web
    └── index.html

其中,大致的依赖关系可分为

graph TD
    subgraph App Layer
        A[App]
    end

    subgraph Server Layer
    		A -->|Uses| HTTP[http.Server]
        A -->|Uses| Job[server.Job]
        HTTP -->|From| NewHTTPServer
        Job -->|From| NewJob
    end

    subgraph Handler Layer
        Handler -->|From| NewHandler
        UserHandler -->|From| NewUserHandler
        NewHTTPServer -->|Uses| UserHandler
        NewUserHandler -->|Uses| Handler
    end

    subgraph Service Layer
        Service -->|From| NewService
        UserService -->|From| NewUserService
        NewUserHandler -->|Uses| UserService
        NewHandler -->|Uses| Service
    end

    subgraph Repository Layer
        Repository -->|From| NewRepository
        DB -->|From| NewDB
        Transaction -->|From| NewTransaction
        UserRepository -->|From| NewUserRepository
        NewRepository -->|Uses| DB
        NewUserRepository -->|Uses| Repository
        NewTransaction -->|Uses| Repository
        NewUserService -->|Uses| UserRepository
    end

    
    NewService -->|Uses| Transaction
    sid[NewSid] -->|Direct Dependency| A
    jwt[NewJwt] -->|Direct Dependency| A
    conf[NewConfig] -->|Direct Dependency| A
    logger[NewLogger] -->|Direct Dependency| A

到这里相信很多聪明的人已经知道要如何来组织依赖关系了🙈,是的,在 Nunu 中,我们将不同的 Providerconfigloggersidjwt 等组件丢到 Injector 中,就形成了 Nunu 中的这份 wire.go,但是由于涉及到的 Provider 还是非常多的,如果散落一地的话,难免会显得有些杂乱,因此 Wire 贴心的提供了 NewSet 函数,我们只需要将处在不同分层的 Provider 放到对应的 Set 中去,按照项目的分层结构,形成每一层的 Provider Set,再统一注入到 Injector 中,这样不就一下变得清晰明了起来了嘛~

//go:build wireinject
// +build wireinjectpackage wire
​
import (
  "github.com/go-nunu/nunu-layout-advanced/internal/handler"
  "github.com/go-nunu/nunu-layout-advanced/internal/repository"
  "github.com/go-nunu/nunu-layout-advanced/internal/server"
  "github.com/go-nunu/nunu-layout-advanced/internal/service"
  "github.com/go-nunu/nunu-layout-advanced/pkg/app"
  "github.com/go-nunu/nunu-layout-advanced/pkg/jwt"
  "github.com/go-nunu/nunu-layout-advanced/pkg/log"
  "github.com/go-nunu/nunu-layout-advanced/pkg/server/http"
  "github.com/go-nunu/nunu-layout-advanced/pkg/sid"
  "github.com/google/wire"
  "github.com/spf13/viper"
)
​
var repositorySet = wire.NewSet(
  repository.NewDB,
  //repository.NewRedis,
  repository.NewRepository,
  repository.NewTransaction,
  repository.NewUserRepository,
)
​
var serviceSet = wire.NewSet(
  service.NewService,
  service.NewUserService,
)
​
var handlerSet = wire.NewSet(
  handler.NewHandler,
  handler.NewUserHandler,
)
​
var serverSet = wire.NewSet(
  server.NewHTTPServer,
  server.NewJob,
)
​
// build App
func newApp(
  httpServer *http.Server,
  job *server.Job,
  // task *server.Task,
) *app.App {
  return app.NewApp(
    app.WithServer(httpServer, job),
    app.WithName("demo-server"),
  )
}
​
func NewWire(*viper.Viper, *log.Logger) (*app.App, func(), error) {
  panic(wire.Build(
    repositorySet,
    serviceSet,
    handlerSet,
    serverSet,
    sid.NewSid,
    jwt.NewJwt,
    newApp,
  ))
}

欧对了,最后返回的 func() 类型是 Wire 提供的 cleanup(),用于在后续构造失败时,清理掉前面构造的资源,如文件资源和网络连接资源。

注意事项

  1. 不允许有相同类型的注入,在 Google 官方给出的解释中,认为相同类型的注入属于在设计上的不合理(or 错误)

  2. 单例问题

    依赖注入的本质是用单例来绑定接口和实现接口对象间的映射关系。而通常实践中不可避免的有些对象是有状态的,同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。针对这种场景我们通常设计多层的 DI 容器来实现单例隔离,亦或是脱离 DI 容器自行管理对象的生命周期。

The End

Wire 是一个功能强大且丰富的依赖注入工具,与InjectDig 等工具不同的是,Wire 的注入发生在编译阶段而不是在程序运行时通过反射注入,因此不会存在任何性能上的开销。文中所列举的使用方式仅是个人比较喜欢的打开方式,具体还需要根据团队需要、项目结构等进行灵活调整,如在 Kratos 中,关于 WireProvider Set 的组织方式则选择了散落在各层之间。只有灵活运用,才能发挥出 Wire 最为强大的实力,从而协助我们完成对复杂对象的构建组装。