背景
最近在开发一个边缘设备和应用的监控程序,作为一个长期使用Java开发的工程师,我对Spring生态有着深刻的印象------那种"只要你能想到的,它都可以帮你做到"的完善程度。这也许就是Java生态生生不息的重要原因之一。
转向Go语言开发后,我不由自主地思考:虽然Go生态没有Java那么丰富,但在依赖注入(DI)这样的基础需求上,应该也有优秀的解决方案。刚开始开发监控程序时,我认为这只是个小项目,暂时不需要引入第三方库,于是自己编写了一个简单的模块来手动管理对象的注册、注入和生命周期。
但随着业务功能不断增加,我发现自己编写的模块虽然还能应付,但长期来看项目维护成本会越来越大。要么继续维护和升级自己的模块,要么寻找成熟的第三方解决方案。最终,出于项目进度和代码质量的考虑,我发现了Uber开源的fx框架,并将其应用到了我的监控程序中。
我当前使用到的一些fx的特性
- 模块化设计 :通过
fx.Module
将系统拆分为多个独立的功能模块 - 依赖注入 :使用
fx.Provide
声明依赖关系,自动解决组件间的依赖 - 生命周期管理 :通过
fx.Lifecycle
和fx.Invoke
管理组件的启动和关闭顺序 - 分组注入 :利用
group
标签实现处理器的动态发现和注册 - 接口绑定 :使用
fx.As
将具体实现绑定到接口 - 注解功能 :通过
fx.Annotate
实现更灵活的依赖配置
具体使用过程
库的拉取和引入
go
// 在我们项目中,go.mod同目录下执行
go get go.uber.org/fx
// 拉取完成后,go.mod 就会帮我们在项目中自动引入这个库了
require (
go.uber.org/fx v1.24.0
// ... 其他依赖
)
模块的声明
项目中,我采用了分层模块化设计,所以每个功能域都有独立的module.go文件。比如事件模块:
go:internal/infra/event/module.go
// 这个变量就是使用fx声明一个模块
// 在这里,声明了"event"模块,并且使用fx.Provide函数提供了一个NewNotifier的构造方法
// 这个构造方法产生的就是我们要注入fx的对象
var Module = fx.Module("event",
fx.Provide(NewNotifier),
)
// NewNotifier函数如下:
// 它返回一个EvtNotifier的接口对象
// 函数返回值其实可以是:接口对象、结构体对象(如果是结构体就返回指针类型)等等
// 这里我返回的是EvtNotifier接口对象,函数生产的是实现了这个接口的notifier结构体对象
func NewNotifier() EvtNotifier {
return ¬ifier{
subscribersMap: make(map[string][]*subscriberWrapper),
}
}
这种设计可以让每个模块职责单一,便于后期的测试和维护。目前我的项目中共有十几个这样的模块,涵盖了配置管理、数据库操作、消息通信、任务处理等模块。
生命周期管理
fx的生命周期管理功能可以优雅地处理资源的初始化和清理。比如在数据库模块中,我这样做:
go:internal/infra/db/module.go
// 当我们模块声明后,就可以使用fx.Invoke优雅地处理资源的初始化和清理
// 在数据库这个基础模块中,当数据库连接和对象都初始化之后,就可以做一些我们想做的事情
// 这里我想做的事情就是:进行数据库的Migration,即数据库结构或者数据合并操作(你可以认为是数据库变更操作)
// 所以在fx.Invoke中,通过我已经声明了的MigrationManager接口,进行数据库合并这个操作
// 当项目启动的时候将数据库合并的操作自动化完成,而不需要我们再另外手动修改数据库表结构等内容
// 至于数据库合并应该怎么做,后续我会再写一篇文章详细说明
fx.Invoke(func(lc fx.Lifecycle, migrator MigrationManager) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return migrator.DoMigration(ctx)
},
})
}),
// 完整的数据库模块声明如下:
var Module = fx.Module("db", fx.Provide(
NewService,
NewTransactionManager,
NewMigrationManager,
),
fx.Invoke(func(lc fx.Lifecycle, migrator MigrationManager, dbSvc DB) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return m.DoMigration(ctx)
},
OnStop: func(ctx context.Context) error {
return dbSvc.Close()
},
})
}),
)
这样确保了数据库迁移在应用启动时自动执行,避免了手动调用的繁琐和可能出现的错误。
高级特性
分组注入
在Kubernetes资源采集模块中,我使用分组注入批量注册了26种资源处理器:
go:internal/kube/module.go
// fx.Annotate声明多种资源类型的具体Handler构造方法 、Handler实现的共同接口以及Handler的所属组
// 比如在这里,NewNamespaceHandler就是一个命名空间这个资源的构造函数,
// 它实现了`domain.Handler`这个接口,被标记的组是`resource_handlers`
// 其他资源也是相同的声明方式,实现的效果就是:一个共同的接口,多种资源实现类
var HandlerModule = fx.Provide(
fx.Annotate(
handlers.NewNamespaceHandler,
fx.As(new(domain.Handler)),
fx.ResultTags(`group:"resource_handlers"`),
),
fx.Annotate(
handlers.NewNodeHandler,
fx.As(new(domain.Handler)),
fx.ResultTags(`group:"resource_handlers"`),
),
...... // 其他资源类型的Handler
)
// 实现具体接口的自动注入
var Module = fx.Module("kube",
// 使用上面声明的资源Handler
HandlerModule,
// 将资源组提供出来
fx.Provide(fx.Annotate(
func(handlers ...domain.Handler) []domain.Handler {
return handlers
},
fx.ParamTags(`group:"resource_handlers"`),
)),
// 由于每个资源都实现了同一个接口domain.Handler,所以必须实现接口函数Kind()
// 每种资源类型的Kind()函数都返回了各自定义的类型,如 Namespace、Deployment等
// 这里就可以为app.NewService实现一个自动注入 所有资源Handler 的方式
fx.Provide(func(handlers []domain.Handler, config *config.Config,
probe *probe.Probe, mqttCli mqtt.Client) domain.Service {
handlerMap := make(map[string]domain.Handler)
for _, h := range handlers {
handlerMap[h.Kind()] = h
}
return app.NewService(mqttCli, config, probe, handlerMap)
}),
)
// 当我们要使用Handler的时候,只需要
handler := s.handlerMap[kind]
data := handler.GetData()
// 是不是很优雅,哈哈,跟 Spring 的效果一样了
这种设计让新增资源处理器变得非常简单,只需要添加一个新的Provide声明即可。
接口绑定
在命令处理模块中,我将服务注册为MQTT订阅者:
go:internal/command/module.go
// 这里跟上面的kube模块其实是一样的
fx.Provide(
fx.Annotate(
NewService,
fx.As(new(mqtt.TopicSubscriber)),
fx.ResultTags(`group:"mqtt_subscribers"`),
),
),
这样实现了接口与实现的解耦,提高了代码的灵活性。
任务处理器自动注册
在任务处理模块中,我实现了处理器的自动发现和映射:
go:internal/task/module.go
// 这里跟上面的kube模块其实是一样的
fx.Invoke(func(handlers []ActionHandler, param struct {
fx.In
Handlers []ActionHandler `group:"action_handlers"`
}) {
handlerMap := make(map[string]ActionHandler)
for _, handler := range param.Handlers {
handlerMap[handler.ActionType()] = handler
}
// 注册到全局管理器
}),
在main函数中使用
使用fx.New()创建应用实例:
go:main.go
app := fx.New(
fx.Module("probe",
event.Module, // 事件模块
config.Module, // 配置模块
logger.Module, // 日志模块
db.Module, // 数据库模块
mqtt.Module, // MQTT模块
web.Module, // Web模块
file.Module, // 文件模块
kube.Module, // Kubernetes模块
.... // 引入其他模块
),
// 勾子方法
fx.Invoke(func(lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
// 同样可以在这里进行资源的管理,每个模块中也可以自定义
return nil
},
OnStop: func(ctx context.Context) error {
// 程序退出的时候会调用这里的方法代码,每个模块中也可以自定义
return nil
},
})
}),
)
这种设计让main函数变得非常简洁,所有的模块依赖和生命周期管理都交给了fx框架处理。
总结
通过在实际项目中使用fx框架,我能感受到fx在Go语言依赖注入和生命周期管理方面的强大能力。与之前自己手动实现的(DI)模块相比,fx提供了以下优势:
- 标准化:遵循社区标准,降低了新团队成员的学习成本
- 功能丰富:提供了分组注入、接口绑定、生命周期管理等高级特性
- 可扩展性:模块化设计让系统更容易扩展和维护
- 类型安全:充分利用Go的强类型特性,减少运行时错误
- 测试友好:依赖注入让单元测试和集成测试更加容易(这篇文章中没有涉及到,后续会有)
以上就是我在项目中使用fx
这个库的一些简单总结,希望对要使用和学习它的一些同僚有用。文章中,很多关键信息都写在了代码的注解里面,可以加强对每个方法的印象。