《Go 语言高级编程》11.2 依赖注入(wire/fx)与装配

依赖装配的两条路线本机实测:wire 用代码生成把装配变成编译期可见的普通 Go 代码(v0.7.0 在 Go 1.27 可用,v0.5.0 直接 SIGSEGV),fx 用反射在运行期建图并管理生命周期 hook。含缺依赖的报错对比、选型决策表与常见坑。

本节要回答:wire 和 fx 分别在什么时候、用什么机制完成「对象装配」,代价是什么,以及 Go 1.27 下哪个能跑。与卷二的边界:卷二讲的是「把依赖用接口解耦」,本节讲「谁来把一堆 NewXxx 按依赖顺序拼起来」这件装配工作本身。
适用版本:Go 1.27(实测 go1.27.0);github.com/google/wire v0.7.0 可用、v0.5.0 在本机崩溃(见 11.2.2),go.uber.org/fx v1.17.1 可用。

11.2 依赖注入(wire/fx)与装配

依赖注入(DI)在 Go 里被误解得厉害:很多人以为它是「Java 那套框架」。其实 Go 的 DI 只解决一件很朴素的事——按依赖顺序把 NewA、NewB、NewC 串起来。问题在于对象一多,手写装配就变成这样:

func main() {
	cfg := NewConfig()
	db := NewDB(cfg)
	cache := NewCache(cfg)
	repo := NewRepo(db, cache)
	svc := NewService(repo)
	handler := NewHandler(svc)
	// ... 20 个对象后,这段代码有 30 行且每次加参数都要改
}

对象一多,这段「装配代码」既长又易错(顺序写反、漏传参数、循环依赖)。wire 和 fx 是两条把它自动化的路线,机制完全不同。

11.2.1 两条路线:生成 vs 反射

维度wire(Google)fx(Uber)
机制代码生成(go generate)运行期反射(reflect + dig 容器)
错误时机生成时(≈编译期)启动时(运行期)
产物生成 wire_gen.go,是普通 Go 代码无产物,图在内存里
运行期开销零(就是普通函数调用)反射 + 容器查找的开销
生命周期管理不提供提供(OnStart/OnStop)
调试看生成的代码,栈是真实栈看 [Fx] 日志,栈含反射帧
适合服务启动装配、追求零开销需要 hook、模块化、可观测启动

一句话:wire 把装配「编译掉」,fx 把装配「运行起来」。前者适合「对象图在编译期就确定」的服务,后者适合「需要生命周期、需要动态注册」的应用。

11.2.2 wire 实测:v0.7.0 可用,v0.5.0 在 Go 1.27 崩溃

wire 的用法是:写一个带 //go:build wireinject 标签的「注入器」文件,里面用 wire.Build 声明 provider,然后跑 wire 命令生成实现。

//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitService() *Service {
	wire.Build(NewConfig, NewDB, NewRepo, NewService)
	return nil
}

生成命令与结果:

$ GOTOOLCHAIN=go1.27.0 wire ./...
wire: wiredemo: wrote /tmp/gbadv4/ch11/wiredemo/wire_gen.go

$ cat wire_gen.go
// Code generated by Wire. DO NOT EDIT.
...
func InitService() *Service {
	config := NewConfig()
	db := NewDB(config)
	repo := NewRepo(db)
	service := NewService(repo)
	return service
}

生成的代码就是最朴素的手写装配——wire 并没有引入任何运行期容器,它只是把「人写这段代码」变成「工具生成这段代码」。所以运行期开销为零,出错时栈也是普通函数的栈,可读性极好。

版本兼容性是一个真实的坑。本机模块缓存里预热的 wire@v0.5.0 在 Go 1.27 下直接崩溃:

$ GOTOOLCHAIN=go1.27.0 wire ./...     # wire v0.5.0
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x1009345a4]
go/types.(*StdSizes).Sizeof(0x0, {0x100cb0a40, 0x100ccde60})
	.../src/go/types/sizes.go:229 +0x304

根因是 v0.5.0 依赖 2019 年的 golang.org/x/tools,它调用的 go/types 内部接口在 Go 1.27 已经变了(StdSizes 为 nil 时不再兜底),于是空指针崩溃。升级到 v0.7.0 后正常(它依赖 golang.org/x/tools v0.24.1):

GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
  go install github.com/google/wire/cmd/wire@v0.7.0

教训:wire 是代码生成工具,它自己也是用 go/types 分析源码的,所以对 Go 版本敏感。升级 Go 大版本时,wire、stringer、mockgen 这类生成器都要跟着升级,否则可能在生成阶段崩溃。

wire 的错误在生成阶段就暴露。少写一个 provider:

$ GOTOOLCHAIN=go1.27.0 wire ./...
wire: /tmp/gbadv4/ch11/wirefail/wire.go:13:1: inject InitB: no provider found for *wirefail.A
	needed by *wirefail.B in provider "NewB" (/tmp/gbadv4/ch11/wirefail/wire.go:11:6)
wire: wirefail: generate failed
wire: at least one generate failure

「no provider found for *wirefail.A,needed by *wirefail.B」——它精确指出了「谁需要谁、缺了谁」。这个错误发生在 wire 命令阶段,也就是你提交代码之前,而不是运行起来才发现。

11.2.3 wire 的接口绑定与 provider set

wire 真正的价值不在「省几行装配代码」,而在把依赖关系声明出来后可组合。两个核心工具:wire.NewSet 打包一组 provider,wire.Bind 把接口绑定到具体实现。

var storeSet = wire.NewSet(NewMemStore, wire.Bind(new(Store), new(*memStore)))

func InitRepo() *Repo {
	wire.Build(storeSet, NewRepo) // NewRepo 要 Store,Bind 告诉 wire 用 *memStore 满足
	return nil
}

生成的代码(实测 v0.7.0):

$ GOTOOLCHAIN=go1.27.0 wire ./...
wire: wirebind: wrote /tmp/gbadv4/ch11/wirebind/wire_gen.go

$ go run .
bind demo: 1

wire.Bind(new(Store), new(*memStore)) 的意思是「当某处需要 Store 接口时,用 *memStore 来满足」。这让「面向接口编程」和「自动装配」不再矛盾:NewRepo 声明它要 Store 接口(解耦),wire 在生成时把具体实现填进去(装配)。没有 Bind,wire 会因「Store 没有 provider」而报错——因为 NewMemStore 返回的是 *memStore,类型不匹配。

wire.NewSet 则是把「一组相关 provider + 绑定」打包成可复用的单元,多个注入器可以共享同一个 set。这是 wire 在大型项目里真正的规模化能力:依赖声明可以分层、可以组合,而不是散落在每个 main 里。

11.2.4 fx 实测:运行期建图 + 生命周期

fx 的用法是把 provider 和 invoke 交给 fx.New:

app := fx.New(
	fx.Provide(NewLogger, NewServer),
	fx.Invoke(registerHooks),
)
app.Run() // 阻塞直到收到 SIGINT/SIGTERM

生命周期用 fx.Lifecycle 注册 OnStart/OnStop:

func registerHooks(lc fx.Lifecycle, s *Server) {
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error { return nil },
		OnStop:  func(ctx context.Context) error { return nil },
	})
}

实测启动与优雅关闭(运行后发 SIGTERM):

$ GOTOOLCHAIN=go1.27.0 ./fxdemo
[Fx] PROVIDE	*main.Logger <= main.NewLogger()
[Fx] PROVIDE	*main.Server <= main.NewServer()
[Fx] INVOKE		main.registerHooks()
[Fx] HOOK OnStart		main.registerHooks.func1() executing (caller: main.registerHooks)
[app] OnStart: 启动 HTTP 服务
[Fx] RUNNING
[Fx] TERMINATED
[Fx] HOOK OnStop		main.registerHooks.func2() executing (caller: main.registerHooks)
[app] OnStop: 优雅关闭

fx 的 [Fx] 日志把整个装配过程摊开:PROVIDE 列出每个类型由哪个构造函数提供,INVOKE 列出被调用的函数,HOOK 记录每个生命周期钩子的执行与耗时。Run() 会捕获 SIGINT/SIGTERM,先跑 OnStop 再退出——这就是 fx 相对 wire 最大的增值:它把「优雅关闭」做成了框架能力,不用自己写 signal 处理。

fx 的错误在运行期才暴露。缺依赖时 Start 返回:

$ GOTOOLCHAIN=go1.27.0 go run .
Start 错误: could not build arguments for function "main".main.func1:
failed to build *main.B: missing dependencies for function "main".NewB:
missing type: *main.A

错误信息同样清晰(missing type: *main.A),但它发生在 Start 时——代码能编译、能通过 CI 的 go build,直到启动才炸。这是 wire 与 fx 最本质的差别:前者把装配错误拦在生成阶段,后者放到启动阶段。

那串 [Fx] 日志默认开启,生产环境会刷屏,用 fx.NopLogger 关掉,只在排查装配问题时打开:

app := fx.New(
	fx.NopLogger, // 关掉 [Fx] 日志
	fx.Provide(NewLogger, NewServer),
	fx.Invoke(registerHooks),
)

fx.NopLogger 关掉的只是日志,不影响 Start/Stop 返回的错误——排查「依赖为什么没装配上」时再打开日志,是最有效的定位手段。

11.2.5 选型决策表

你的情况推荐理由
对象图固定、追求零运行期开销wire生成普通代码,无反射
需要 OnStart/OnStop 生命周期fx内建 hook + signal 处理
团队不接受代码生成 / 不想装工具fx纯运行期,无额外工具链
启动错误必须尽早暴露wire生成阶段即报缺依赖
大量可选组件、按配置动态装配fx运行期可条件注册
极简项目(< 10 个对象)手写引入框架的认知成本 > 收益

补充一条:两者可以共存——用 fx 管生命周期、内部用 wire 生成关键子图。但多数项目不需要,选一条走到底更清晰。

11.2.6 常见坑

  • wire 工具版本与 Go 版本不匹配:本机 v0.5.0 在 Go 1.27 SIGSEGV,必须升到 v0.7.0。升级 Go 后记得同步升级生成器。
  • 忘了 //go:build wireinject:注入器文件会被正常编译,InitService 未定义报错。
  • wire_gen.go 被提交但没重新生成:改了 provider 忘了跑 wire,生成代码与源码不一致,行为诡异。
  • fx 的 provider 顺序:fx 不关心顺序(按类型解析),但同类型多个 provider 会冲突,需要用 fx.Annotate + fx.ParamTags/ResultTags 区分。
  • fx.Invoke 里做重活:Invoke 在启动阶段同步执行,阻塞会拖慢启动。
  • fx 的 OnStop 超时:默认关闭超时约 15 秒,长任务要自己控制,否则会被强制中断。
  • 循环依赖:两者都会报错(wire 在生成期、fx 在启动期),但根源是设计问题——用接口或引入中间层打断环。
  • 把 DI 当银弹:DI 只解决「装配顺序」,不解决「依赖太多」。对象图超过 30 个,该先想想是不是分层出了问题。

小结

  • wire 是代码生成:wire.Build 声明 provider,生成普通 Go 装配代码,运行期零开销,缺依赖在生成阶段报错。
  • fx 是运行期反射:fx.Provide/fx.Invoke 建图,内建 OnStart/OnStop 生命周期与 signal 处理,缺依赖在启动阶段报错。
  • 实测:wire v0.7.0 在 Go 1.27 正常,v0.5.0 因 go/types 不兼容直接 SIGSEGV;fx v1.17.1 正常。
  • 选型看「要不要生命周期」和「能不能接受代码生成」,而不是看哪个更「高级」。
  • 无论选哪个,装配代码的复杂度都是对象图的复杂度;DI 只把它自动化,不减少它。

装配解决了「对象怎么被造出来」,但对象造对不代表接口对。下一节看最后一环:怎么用契约测试锁住模块之间的接口,以及怎么把版本信息注入二进制完成发布。

阅读导航:上一节:11.1 monorepo 与 go.work 多模块 · 下一节:11.3 契约测试与发布工程 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练