一、前言

线上项目往往依赖非常多的具备特定能力的资源,如: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
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

各个目录的关系如图:

  • 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

Golang 依赖注入设计哲学|12.6K 🌟 的依赖注入库 wire的更多相关文章

  1. 深入浅出理解依赖注入这种由外部负责其依赖需求的行为,我们可以称其为 “控制反转(IoC)”

    原文地址: http://www.insp.top/learn-laravel-container ,转载务必保留来源,谢谢了! 这个组件现在可以很简单的获取到它所需要的服务,服务采用延迟加载的方式, ...

  2. 基于.NET平台的分层架构实战(六)——依赖注入机制及IoC的设计与实现[转]

    原文:http://www.cnblogs.com/leoo2sk/archive/2008/06/19/1225223.html 我们设计的分层架构,层与层之间应该是松散耦合的.因为是单向单一调用, ...

  3. [.net 面向对象程序设计深入](26)实战设计模式——使用Ioc模式(控制反转或依赖注入)实现松散耦合设计(1)

    [.net 面向对象程序设计深入](26)实战设计模式——使用IoC模式(控制反转或依赖注入)实现松散耦合设计(1) 1,关于IOC模式 先看一些名词含义: IOC: Inversion of con ...

  4. 开涛spring3(12.2) - 零配置 之 12.2 注解实现Bean依赖注入

    12.2  注解实现Bean依赖注入 12.2.1  概述 注解实现Bean配置主要用来进行如依赖注入.生命周期回调方法定义等,不能消除XML文件中的Bean元数据定义,且基于XML配置中的依赖注入的 ...

  5. [.net 面向对象程序设计深入](31)实战设计模式——使用Ioc模式(控制反转或依赖注入)实现松散耦合设计(1)

    [.net 面向对象程序设计深入](31)实战设计模式——使用IoC模式(控制反转或依赖注入)实现松散耦合设计(1) 1,关于IOC模式 先看一些名词含义: IOC: Inversion of con ...

  6. golang常用库包:Go依赖注入(DI)工具-wire使用

    google 出品的依赖注入库 wire:https://github.com/google/wire 什么是依赖注入 依赖注入 ,英文全名是 dependency injection,简写为 DI. ...

  7. 轻松学,浅析依赖倒置(DIP)、控制反转(IOC)和依赖注入(DI) 依赖注入和控制反转的理解,写的太好了。

    轻松学,浅析依赖倒置(DIP).控制反转(IOC)和依赖注入(DI) 2017年07月13日 22:04:39 frank909 阅读数:14269更多 所属专栏: Java 反射基础知识与实战   ...

  8. Compile-time Dependency Injection With Go Cloud's Wire 编译时依赖注入 运行时依赖注入

    Compile-time Dependency Injection With Go Cloud's Wire - The Go Blog https://blog.golang.org/wire Co ...

  9. PHP 依赖注入和控制反转再谈(二)

    今天有个朋友看到yii2中介绍的依赖注入一头雾水,之前我写过类似的文章发给他看了,可能还没深入理解吧,这里我再通俗点描述下依赖注入的原理吧,尽可能滴说通俗易懂一点吧:先还是扯下概念性滴问题(概念问题我 ...

  10. 依赖注入(DI)和控制反转(IOC)的理解,写的太好了。

    学习过spring框架的人一定都会听过Spring的IoC(控制反转) .DI(依赖注入)这两个概念,对于初学Spring的人来说,总觉得IoC .DI这两个概念是模糊不清的,是很难理解的,今天和大家 ...

随机推荐

  1. 【爬虫数据集】李子柒YouTube频道TOP10热门视频的TOP2000热门评论,共计2W条

    目录 一.背景 二.爬取目标 三.结果展示 四.演示视频 五.附完整数据 一.背景 这段时间,有超多小伙伴找我要YouTube数据,做数据分析.情感分析之类的研究工作,但很多人并不是计算机软件相关专业 ...

  2. linux-centos7.6 硬盘挂载

    目录 一 .功能 二.VM中设置硬盘 2.1 系统关机状态下 2.2 添加硬盘 三.系统中挂载硬盘 3.1 查看硬盘信息 3.2 硬盘分区 3.3 格式化硬盘 3.4 临时挂载硬盘 3.4 开机自动挂 ...

  3. linux的统计实现

    场景: 将下面的数据里category里的分类统计计数 数据源 es_ip10000.json {"_index":"order","_type&qu ...

  4. log4j的配置详解

    参考文章:https://www.jianshu.com/p/ccafda45bcea 引入log4j: 在项目中单独使用log4j进行日志的输出: maven依赖: <dependency&g ...

  5. C语言:输入一串字符串,统计字符串中有多少个数字

    gets函数会在输入完字符后自动补上一个\0,所以用这个特性可以计算出字符串是否结束. 因为数字在字符中对应的ascii码就是0~9,只要遇到小于9的字符就是数字,所以计数器加一 #include&l ...

  6. 让智慧物联赋能高效生产, AIRIOT助力数字化油田转型升级

    ​ 近年来,中国石油行业为了推进工业化和信息化深度融合,充分结合勘探开发.生产科研和经营管理的实际需求,积极通过信息化建设促进油田业务转型升级.在勘探开发与管理的领域中,油气生产物联网系统是一个极其重 ...

  7. AIRIOT物联网低代码平台如何配置交通部JT-808协议?

    属性配置可配置终端ID和SIM卡号,数据点配置决定设备传上来的哪些数据可以存储展示. 驱动配置可配置采集周期和通讯超时时间. AIRIOT物联网低代码平台如何配置交通部JT-808协议?具体操作如下: ...

  8. AIRIOT物联网低代码平台如何配置OPC DA驱动?

    AIRIOT物联网低代码平台提供了丰富的驱动,兼容了市面上95%以上的传感器.控制器及数据采集设备等,并且在持续增加中,能够快速.便捷地实现数据采集与控制功能. AIRIOT物联网低代码平台如何配置O ...

  9. MegaCli64查看磁盘损坏,错误个数统计情况

    如下,两个命令,是磁盘濒临崩坏,比如存在扇区损坏之类的事情发生.咨询的浪潮热线,报sn.他们的临界值是500,我们监控脚本是200告警.Predictive Failure Count 这个的数字比M ...

  10. Python闭包和装饰器原理

    # Python闭包和装饰器 ############# 闭包 ############## ''' 1. 一个外层函数,内嵌一个内层函数: 2. 内层函数使用外层函数的参数: 3. 外层函数将内层函 ...