如何在 Go 中做依赖注入?
英文:Preslav Mihaylov,
翻译:Go开发大全 / 字符串拼接工程师
如果刚开始写 Go 项目,main
方法里可能会包含初始化逻辑,如:初始化路由、调用中间件、初始化模板引擎、初始化 logger。
这是 Go 的一大优点,即程序没有隐藏操作,没有“魔法”。代码写的什么就是什么,可读性和可调式性好。
开发时间一长,维护成本会逐渐变高,main
方法开始变得越来越复杂。main
方法里各处会出现各种小功能点实现,比如健康检查、数据库初始化、监控、打印跟踪信息(trace)、外部 API 链接等等。
如果应用演化成了微服务架构会怎样?当有 5 个不同微服务需要根据配置,写一堆类似的代码做初始化要怎么做?
本文中介绍的 Fx 是一个 Go 框架,使用依赖注入来解决上述两个问题。
本文所有代码在这个代码库:
https://github.com/preslavmihaylov/fxappexample
用“手动初始化”方式新建个简单的程序
把初始化逻辑做成代码里显式调用(主要在main
方法里做)的方式是“手动初始化”。
举个例子。
如果你已经知道手动初始化是什么,可以直接跳过本段,但是要看下这块的代码,了解我们需要实现的代码逻辑是什么。
这里创建一个网络应用返回 hello world:
package httphandler
import "net/http"
// Handler for http requests
type Handler struct {
mux *http.ServeMux
}
// New http handler
func New(s *http.ServeMux) *Handler {
h := Handler{s}
h.registerRoutes()
return &h
}
// RegisterRoutes for all http endpoints
func (h *Handler) registerRoutes() {
h.mux.HandleFunc("/", h.hello)
}
func (h *Handler) hello(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("Hello World"))
}
这是main
方法:
package main
import (
"net/http"
"github.com/preslavmihaylov/fxappexample/httphandler"
)
func main() {
mux := http.NewServeMux()
httphandler.New(mux)
http.ListenAndServe(":8080", mux)
}
用 Vuber-go/zap 给main
方法加一个打 log 的功能:
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()
slogger := logger.Sugar()
mux := http.NewServeMux()
httphandler.New(mux, slogger)
http.ListenAndServe(":8080", mux)
}
把这个 logger 作为一个参数传递给 http handler。这部分代码省略。
我们来实现用 go-yaml/yaml 配置端口,在config/base.yaml
文件里写配置:
application:
address: :8080
要写更多结构体来解析这个配置:
...
// ApplicationConfig ...
type ApplicationConfig struct {
Address string `yaml:"address"`
}
// Config ..
type Config struct {
ApplicationConfig `yaml:"application"`
}
...
然后解析 yaml,把端口读出来,配置到 web 服务上:
func main() {
conf := &Config{}
data, err := ioutil.ReadFile("config/base.yaml")
// handle error
err = yaml.Unmarshal([]byte(data), &conf)
// handle error
...
http.ListenAndServe(conf.ApplicationConfig.Address, mux)
...
}
到此为止。我们手动实现了这个结构,把依赖写入到各个需要的组件中。
这个小节的代码在 v1-manual-wiring 分支上:
https://github.com/preslavmihaylov/fxappexample/tree/v1-manual-wiring
当前实现方法的局限性
上面这个实现给小型项目用很合适。优点是容易跟踪,代码可读性、可调式性好。
问题是,随着应用不断开发,会有非常多其他组件也需要像上面这样被手动初始化到main
方法里。
时间一长,手动初始化会变得越来越复杂,容易出错而且难以管理。
这时就该引入依赖注入框架了,依赖注入框架可以不再手写这些初始化逻辑、自动完成配置。这样应用程序就可以优雅地应对更多组件接入了。
目前实现方法的第二个大问题是,重复利用公用的代码逻辑很困难。
比如在微服务架构下,可能有很多基础组件可以为不同服务公用。它们和微服务的域名不是直接相关的。基础组件可以有监控、打 log、基础配置、健康检查等等。
如果还是用手动初始化方式,每引入一个微服务,就不得不复制粘贴这些代码的样本、或者要把公共组件放到单独的 package 里达到公用的目的。但是公用组件放单独 package 里依然要手动处理初始化。
有什么解决方案?
用 Fx 辅助程序的扩展
Fx 是一个依赖注入程序框架。
依赖注入意味着它可以处理好组件的初始化代码。程序框架是说这个Fx不仅仅是一个插入即用的代码库,而是一个框架,它管理了整个应用程序的生命周期,和 C# 的 ASP.NET、Java 的 Spring 类似,这些都是程序框架。
尽管这样做类比,Fx 还是要比这两个框架要简单得多的。
什么是依赖注入框架?
首先,依赖注入框架是做什么的?它的核心是链接了“提供者”和“接收者”。
提供者是类似有一个实例,可以应用到任何需要的地方的;接收者是需要x、y、z
组件加起来才能工作的。x、y、z
是接收者需要的依赖。注意接收者也可以是提供者。
依赖注入框架做的是把接收者和提供者自动链接起来的事。收者不能拿到它的依赖时,框架会报 error。
现在我们来用 Fx 框架重构代码。
把服务转换成 Fx 应用程序
执行go get go.uber.org/fx
安装。接下来改造main
方法:
...
func main() {
fx.New().Run()
...
}
其他代码逻辑不变,写成这样直接运行程序,会初得到一个空的Fx程序。
接下来继续重构其他逻辑。为了简洁,所有变动都放在mian
方法里了。
实现基础提供者
下面为配置创建一个提供者方法:
...
// ProvideConfig provides the standard configuration to fx
func ProvideConfig() *Config {
conf := Config{}
data, err := ioutil.ReadFile("config/base.yaml")
// handle error
err = yaml.Unmarshal([]byte(data), &conf)
// handle error
return &conf
}
...
把上面的提供者交给 Fx 使用:
...
func main() {
fx.New(
fx.Provide(ProvideConfig),
).Run()
...
}
重构打 log 的部分:
...
// ProvideLogger to fx
func ProvideLogger() *zap.SugaredLogger {
logger, _ := zap.NewProduction()
slogger := logger.Sugar()
return slogger
}
func main() {
fx.New(
fx.Provide(ProvideConfig),
fx.Provide(ProvideLogger),
).Run()
...
}
为其他依赖也做这样的重构。重构后和之前的实现不同是,去掉了defer logger.Sync()
。这段代码要在下文中,程序退出时一定会调用的程序退出钩子里实现。
有一个新增的必须提供的依赖,是标准的 http ServeMux。ServeMux 是一个 http 请求的"多路处理器",是 http handler 的依赖:
...
func main() {
fx.New(
fx.Provide(ProvideConfig),
fx.Provide(ProvideLogger),
fx.Provide(http.NewServeMux),
).Run()
...
}
http ServeMux 也可以被包在一个提供者方法里,这样就可以把任意的基础配置传给ServerMux
了。这里我们没有其它配置,所以直接用 http 包里的http.NewServeMux
方法。
重构 http handler —— 我们第一个接收者
最后调用的是 httpHandler 的 New 方法,并删掉剩下的 main 方法内的其他逻辑。
这次要用的是 Invoke 而不是 Provide,因为在程序里不会有其他地方需要返回
httphandler.Handler。
Invoke 的逻辑是必须的,如果这个依赖没有被使用,Fx 不会调用这个函数。目的是避免不必要的 CPU 计算和缓存 IO 操作。
这里我们需要用 Invoke 方法调用httpHandler.New
方法,把我们程序里的所有接口都注册进去:
...
func main() {
fx.New(
fx.Provide(ProvideConfig),
fx.Provide(ProvideLogger),
fx.Provide(http.NewServeMux),
fx.Invoke(httphandler.New),
).Run()
}
最后要举一个接收者和提供者的例子。来看下httpHandler.New
的实现:
...
// New http handler
func New(s *http.ServeMux, logger *zap.SugaredLogger) *Handler {
h := Handler{s, logger}
h.registerRoutes()
return &h
}
...
注意下方法的参数。我们目前加的所有其他提供者的方法都没有任何参数,这意味着这些方法只提供一些实例,而没有其他的任何依赖。仅仅是提供者。
这个 New 方法即可以是提供者也是接收者。它可以是提供者是因为它返回了一个新http Handler
实例。它可以是接收者是因为它需要http.ServerMux
和打印 log 的 logger 作为参数,才能正确运行。
New 方法需要的这些参数都是从哪里传过来的?当然是来自前面逻辑里的其他调用者,ProvideLogger
和http.NewServeMux
。
Fx 框架做了这些把实例调度给对应的方法的杂事。
注册生命周期钩子函数
最后的最后我们实现OnStart
和OnStop
。Onstart
是在所有服务都配置好后,把web 服务跑起来、OnStop
是程序退出时把 log 还在缓冲区的内容全部打印。
我们通过实现一个registerHooks
方法,在 Fx 应用程序启动的时候无条件执行它:
...
func main() {
fx.New(
...
fx.Invoke(registerHooks),
).Run()
}
func registerHooks(
lifecycle fx.Lifecycle,
logger *zap.SugaredLogger, cfg *Config, mux *http.ServeMux,
) {
lifecycle.Append(
fx.Hook{
OnStart: func(context.Context) error {
go http.ListenAndServe(cfg.ApplicationConfig.Address, mux)
return nil
},
OnStop: func(context.Context) error {
return logger.Sync()
},
},
)
}
注意registerHooks
也是一个接收者程序,依赖着fx.Lifecycle
、*zap.SugaredLogger
、*Config
、*http.ServeMux
。
重构完毕,我们来跑一下完整版的 Fx 应用。
完整的代码在 v2-fx-example分支上:
https://github.com/preslavmihaylov/fxappexample/tree/v2-fx-example
总结
一些人可能人为第一版代码会更好,因为更简洁而且没有额外“魔法”。这个看法没有错,所以我建议只有真正需要Fx框架的时候再启用它。对于中小型 Go 项目,保持手动初始化是有意义的。
毕竟每个工具,都应该让程序更轻量,使用工具的好处要打过使用成本。使用Fx的成本是复杂性增加和可读性降低。
由于这个工具依赖反射,IDE 是不能判断这个依赖是从何而来的,因此会增加代码追踪和调试的难度。
不过在文章开始时已经说明了,手动初始化代码会让项目变得越来越复杂。这时考虑启用这种 Fx 框架是很有意义的。
如果项目里依赖着很多公共的组件和配置,通过 Fx 可以把程序分隔成可重复使用的模块,这部分在后面的文章里讨论,敬请关注。
- EOF -
如果觉得本文不错,欢迎转发推荐给更多人。
分享、点赞和在看
支持我们分享更多好文章,谢谢!