0%

go 库学习之 fx

Fx 库是 Uber 开源的一个依赖注入(dependency injection)库,Uber 内部大量服务都使用了 Fx 库。Fx 库的实现依赖了 Uber 的另一个依赖注入库:dig。这篇文章将学习 Fx 库的使用。

Fx 库简介

Fx 是 Go 语言下的一个依赖注入库,通过 Fx,你可以实现如下目标:

  • 减少程序启动时的 boilerplate code(指那些具有固定模式的代码)
  • 减少程序中的全局状态,不再需要 init() 或者全局变量,而是使用 Fx 管理的单例
  • 增加新的组件后,可以立即在程序中使用它
  • 构建通用的、松耦合的、可共享的模块

Get Started

首先通过一个示例程序来快速了解 Fx 库。

建立最小 Fx 程序

  • 首先新建一个 Go 项目,并安装 Fx 库的最新版本
1
2
3
4
# mkdir fxdemo
# cd fxdemo/
# go mod init example.com/fxdemo
# go get go.uber.org/fx@latest
  • 然后编写一个 Fx 库的最小程序,main.go 如下所示:
1
2
3
4
5
6
7
package main

import "go.uber.org/fx"

func main() {
fx.New().Run()
}
  • 运行该程序
1
2
3
4
5
# go run .
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] RUNNING

在这个最小程序中,我们不带任何参数地调用了 fx.New() 来创建一个 Fx App。而实际使用中我们通常会给 fx.New() 传递多个参数来启动程序的多个组件。之后通过调用 App.Run() 方法来运行应用程序,该方法会一直阻塞,直到收到停止信号。最后在它退出之前会运行必要的清理操作。

添加一个 HTTP Server

上个最小 Fx 程序没有实现任何功能,接下来我们会给这个程序增加一个 HTTP Server 的功能。

  • 首先编写一个函数,创建 HTTP server
1
2
3
4
func NewHTTPServer(lc fx.Lifecycle) *http.Server {
srv := &http.Server{Addr: ":8080"}
return srv
}
  • 仅仅创建 HTTP server 是不够的,我们还需要告诉 Fx 如何启动该 HTTP server,这也是为什么 NewHTTPServer 需要一个 fx.Lifecycle。我们可以在 fx.Lifecycle 对象上注册 OnStart hookOnStop hook,通过这两个 hook 我们就可以告诉 Fx 如何启动、停止 HTTP server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func NewHTTPServer(lc fx.Lifecycle) *http.Server {
srv := &http.Server{Addr: ":8088"}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}

fmt.Println("Starting HTTP server at", srv.Addr)
go srv.Serve(ln)
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv
}
  • 接下来通过 fx.Provide 将该构造函数提供给 Fx App
1
2
3
func main() {
fx.New(fx.Provide(NewHTTPServer)).Run()
}
  • 仅仅注册 构造器 是不够的。接下来还要通过 fx.Invoke() 注册 触发器,触发构造 HTTP server。通常这些 触发器 的参数就是使用由 Provide() 注册的构造器来创建的。可以注册多个 触发器,这些触发器总是会按注册顺序执行。
1
2
3
4
5
func main() {
fx.New(fx.Provide(NewHTTPServer),
fx.Invoke(func(*http.Server) {}),
).Run()
}
  • 接下来就可以运行该程序了
1
2
3
4
5
6
7
8
9
10
11
12
# go run .
[Fx] PROVIDE *http.Server <= main.NewHTTPServer()
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE main.main.func1()
[Fx] RUN provide: go.uber.org/fx.New.func1()
[Fx] RUN provide: main.NewHTTPServer()
[Fx] HOOK OnStart main.NewHTTPServer.func1() executing (caller: main.NewHTTPServer)
Starting HTTP server at :8088
[Fx] HOOK OnStart main.NewHTTPServer.func1() called by main.NewHTTPServer ran successfully in 182.506µs
[Fx] RUNNING
1
2
# curl localhost:8088
404 page not found

注册 Handler

接下来给该 HTTP Server 添加 Handler,从而可以真正处理 HTTP 请求。

  • 定义一个 HTTP handler,直接将请求的 body 复制到响应中
1
2
3
4
5
6
7
8
9
10
11
type EchoHandler struct{}

func NewEchoHandler() *EchoHandler {
return &EchoHandler{}
}

func (*EchoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if _, err := io.Copy(w, r.Body); err != nil {
fmt.Fprintln(os.Stderr, "Failed to handle request:", err)
}
}
  • 定义一个构造函数来构造 *http.ServeMux*http.ServeMux 用于将 HTTP 请求路由到不同的 handler,这里我们直接将 /echo 的请求路由到 *EchoHandler,所以这个构造函数接收 *EchoHandler 作为参数
1
2
3
4
5
func NewServeMux(echo *EchoHandler) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/echo", echo)
return mux
}
  • 同样,通过 Fx.Provide 将上述构造函数注册到 Fx App
1
2
3
4
5
func main() {
fx.New(fx.Provide(NewHTTPServer, NewEchoHandler, NewServeMux),
fx.Invoke(func(*http.Server) {}),
).Run()
}
  • 最后,修改 NewHTTPServer,让其接受 *http.ServeMux 作为参数
1
2
3
4
func NewHTTPServer(lc fx.Lifecycle, mux *http.ServeMux) *http.Server {
srv := &http.Server{Addr: ":8088", Handler: mux}
......
}
  • 运行该程序并测试
1
2
# curl localhost:8088/echo -X POST -d "hello"
hello

这里我们通过 fx.Provide 提供了更多的组件。Fx 会根据构造函数的参数以及返回值来决定组件之间的依赖关系。

添加 logger

目前程序是在 stdoutstderr 输出日志和错误信息,从某种程度上说,stdoutstderr 都是全局状态,我们应该将日志信息输出到日志对象。这里使用 Zap 日志库。

  • 首先为 Fx App 提供 Zap logger 的构造函数
1
2
3
4
5
6
func main() {
fx.New(
fx.Provide(NewHTTPServer, NewEchoHandler, NewServeMux, zap.NewExample),
......
).Run()
}
  • NewEchoHandler 接受 logger 对象作为参数,并将出错信息输出到 logger
1
2
3
4
5
6
7
8
9
10
11
12
13
type EchoHandler struct {
log *zap.Logger
}

func NewEchoHandler(log *zap.Logger) *EchoHandler {
return &EchoHandler{log: log}
}

func (h *EchoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if _, err := io.Copy(w, r.Body); err != nil {
h.log.Warn("Failed to handle request:", zap.Error(err))
}
}
  • NewHTTPServer 也做类似处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func NewHTTPServer(lc fx.Lifecycle, mux *http.ServeMux, log *zap.Logger) *http.Server {
srv := &http.Server{Addr: ":8088", Handler: mux}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}

log.Info("Starting HTTP server", zap.String("addr", srv.Addr))
go srv.Serve(ln)
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv
}
  • 也可以使用 logger 对象来记录 Fx 自己的日志
1
2
3
4
5
6
7
8
9
func main() {
fx.New(
fx.Provide(NewHTTPServer, NewEchoHandler, NewServeMux, zap.NewExample),
fx.Invoke(func(*http.Server) {}),
fx.WithLogger(func(log *zap.Logger) fxevent.Logger {
return &fxevent.ZapLogger{Logger: log}
}),
).Run()
}
  • 运行该程序,可以看到日志输出
1
2
3
4
# go run .
{"level":"info","msg":"provided","constructor":"main.NewHTTPServer()","stacktrace":["main.main (/root/code/private/go/fx/fxdemo/main.go:56)","runtime.main (/usr/local/go/src/runtime/proc.go:250)"],"moduletrace":["main.main (/root/code/private/go/fx/fxdemo/main.go:56)","main.main (/root/code/private/go/fx/fxdemo/main.go:55)"],"type":"*http.Server"}
{"level":"info","msg":"provided","constructor":"main.NewEchoHandler()","stacktrace":["main.main (/root/code/private/go/fx/fxdemo/main.go:56)","runtime.main (/usr/local/go/src/runtime/proc.go:250)"],"moduletrace":["main.main (/root/code/private/go/fx/fxdemo/main.go:56)","main.main (/root/code/private/go/fx/fxdemo/main.go:55)"],"type":"*main.EchoHandler"}
......

解耦注册

在上面的例子上,NewServeMux 显式将 EchoHandler 声明为依赖,这带来不必要的耦合性,因为 NewServeMux 并不需要知道某个具体的 handler 实现。接下来将修复这个问题。

  • 定义 Route 接口类型
1
2
3
4
type Route interface {
http.Handler
Pattern() string
}
  • EchoHandler 类型实现了 Route 接口
1
2
3
4

func (h *EchoHandler) Pattern() string {
return "/echo"
}
  • 修改 NewServeMux 接收 Route 类型
1
2
3
4
5
func NewServeMux(route Route) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle(route.Pattern(), route)
return mux
}
  • 修改 main 函数,对 NewEchoHandler 进行注解,fx.As() 用于注解函数的返回类型,这样 Fx 就认为 NewEchoHandler 返回的是 type
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
fx.New(
fx.Provide(
NewHTTPServer,
NewServeMux,
fx.Annotate(NewEchoHandler,
fx.As(new(Route))),
zap.NewExample),
fx.Invoke(func(*http.Server) {}),
fx.WithLogger(func(log *zap.Logger) fxevent.Logger {
return &fxevent.ZapLogger{Logger: log}
}),
).Run()
}

这里我们通过接口来让使用者和具体实现解耦,这时用到了 fx.Annotatefx.As 来对构造器进行注解。

继续注册 handler

接下来在 HTTP server 中继续增加一个 handler。

  • 首先增加一个 Handler,用于输出 Hello XXX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type HelloHandler struct {
log *zap.Logger
}

func NewHelloHandler(log *zap.Logger) *HelloHandler {
return &HelloHandler{log: log}
}

func (h *HelloHandler) Pattern() string {
return "/hello"
}

func (h *HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
h.log.Error("Failed to read request", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}

if _, err := fmt.Fprintf(w, "Hello, %s\n", body); err != nil {
h.log.Error("Failed to handle request:", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
  • 修改 NewServeMux,让其可以接受两个 Handler
1
2
3
4
5
6
func NewServeMux(route1, route2 Route) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle(route1.Pattern(), route1)
mux.Handle(route2.Pattern(), route2)
return mux
}
  • 最后修改 fx.Provide,增加 NewHelloHandler 这个构造器
1
2
3
4
5
6
7
8
fx.Provide(
NewHTTPServer,
NewServeMux,
fx.Annotate(NewEchoHandler,
fx.As(new(Route))),
fx.Annotate(NewHelloHandler,
fx.As(new(Route))),
zap.NewExample),
  • 但是直接运行,会报错,其中最关键的原因就是 Route 实例被重复注册。Fx App 内不允许同一个类型的两个实例同时存在,为了解决这个问题,我们仍然需要使用注解以区分两个不同的 Route 实例。这里我们用 fx.ResultTags 来为构造器的结果增加 tag 注解
1
2
3
4
5
6
7
8
9
10
11
fx.Provide(
......
fx.Annotate(
NewEchoHandler,
fx.As(new(Route)),
fx.ResultTags(`name:"echo"`)),
fx.Annotate(
NewHelloHandler,
fx.As(new(Route)),
fx.ResultTags(`name:"hello"`)),
zap.NewExample),
  • 同样的,由于 NewServeMux 接受两个 Route 实例作为参数,为了将它们区分,使用 fx.ParamTags 对构造器的参数增加 tag 注解
1
2
3
4
5
6
fx.Provide(
......
fx.Annotate(
NewServeMux,
fx.ParamTags(`name:"echo"`, `name:"hello"`)),
.....),
  • 该程序正常运行:
1
2
# curl localhost:8088/hello -X POST -d "gopher"
Hello, gopher

所以可以看到 fx.ResultTagsfx.ParamTags 是配套使用的。fx.ResultTags 对构造器结果增加 Tag 注解,fx.ParamTags 则是对构造器参数增加 Tag 注解,从而在 Fx App 中支持同一个类型的多个实例。这些被注解的实例也被称为 Named Values(需要使用 name:".." tag 注解)。

注册多个 Handler

接下来我们继续扩展该程序,让 NewServeMux 可以接受多个 handler。

  • 首先修改 NewServeMux 让其可以接收 Route 的 slice
1
2
3
4
5
6
7
func NewServeMux(routes []Route) *http.ServeMux {
mux := http.NewServeMux()
for _, route := range routes {
mux.Handle(route.Pattern(), route)
}
return mux
}
  • 接下来注解 NewServeMux,表明其接受一个 Value Groups 来作为参数。Fx 使用 group:".." 这个注解 tag 来表明 slice 参数是一个 Value Groups,这会使得 Fx 执行所有的、会往该 group 中产生 Value 的 provider(执行顺序不确定),并将得到的结果保存到该 slice 参数中。除了使用 slice 作为参数,Value Groups 也支持 可变参数
1
2
3
4
5
6
7
fx.Provide(
......
fx.Annotate(
NewServeMux,
fx.ParamTags(`group:"routes"`)),
......
)
  • 然后定义一个 Helper 函数 AsRoute 来让 Route 的 Provider 可以往这个 Value Groups 中产生 Value,这是通过 fx.ResultTags(group:"routes") 实现的:
1
2
3
4
5
6
7
func AsRoute(f any) any {
return fx.Annotate(
f,
fx.As(new(Route)),
fx.ResultTags(`group:"routes"`),
)
}
  • 最后借助 AsRoute,将已有的 Route Provider 注册到 Fx App
1
2
3
4
5
6
7
8
fx.Provide(
NewHTTPServer,
fx.Annotate(
NewServeMux,
fx.ParamTags(`group:"routes"`)),
AsRoute(NewEchoHandler),
AsRoute(NewHelloHandler),
zap.NewExample),

这一步我们主要借助 Value Groups 来让 NewServeMux 可以接收一组 handler 作为参数。需要注意,如果 Value Groups 的构造函数返回类型 T,那么 Value Groups 的消费者其参数类型应该是 []T

Fx 其他特性

上一小节通过一个实例介绍了 Fx 主要特性,接下来继续介绍一些没有覆盖到的 Fx 重要特性。

参数对象

在上面的示例中,构造函数都是通过参数来声明它自己的依赖。如果构造函数有很多依赖,并都通过函数参数的方式来指明这些依赖,代码的可读性就会越来越差,例如:

1
2
3
func NewHandler(users *UserGateway, comments *CommentGateway, posts *PostGateway, votes *VoteGateway, authz *AuthZGateway) *Handler {
// ...
}

参数对象Parameter Objects)就是用来解决这个问题的:定义一个结构体并将函数的所有依赖定义为该结构体的字段,之后函数以 该结构体 作为参数,这样就解决了函数参数过多带来的代码可读性问题。

Fx 中,通过在结构体中嵌入 Fx.In 来定义 参数对象。如下是一个简单示例:

  • 定义一个结构体,例如 ClientParams(通常 Fx 的 参数对象 只用于作为构造器的参数,不要把它用于通用的业务逻辑)
1
2
type ClientParams struct {
}
  • 在该结构体中嵌入 Fx.In
1
2
3
type ClientParams struct {
fx.In
}
  • 让构造函数接收该类型作为参数
1
2
func NewClient(p ClientParams) (*Client, error) {
}
  • 参数对象 结构体中添加真正的依赖类型字段,其需要是结构体的导出字段:
1
2
3
4
5
6
type ClientParams struct {
fx.In

Config ClientConfig
HTTPClient *http.Client
}
  • 在构造函数中使用这些字段
1
2
3
4
5
6
7
func NewClient(p ClientParams) (*Client, error) {
return &Client{
url: p.Config.URL,
http: p.HTTPClient,
// ...
}, nil
}
  • 当我们需要给构造函数增加新的依赖时,只需要在 参数对象 中接续添加新的字段。如果需要实现向后兼容,可以为新增的字段添加 optional:"true"
1
2
3
4
5
6
7
type Params struct {
fx.In

Config ClientConfig
HTTPClient *http.Client
Logger *zap.Logger `optional:"true"`
}
  • 之后在构造函数中继续处理新增的依赖。注意如果没有提供该字段,那么其是对应类型的 零值
1
2
3
4
5
6
7
func New(p Params) (*Client, error) {
log := p.Logger
if log == nil {
log = zap.NewNop()
}
// ...
}
  • 参数对象 通过 name:"..." tag 来支持 named value,通过 group:"..." 来支持 value groups
1
2
3
4
5
6
7
8
9
10
11
12
type GatewayParams struct {
fx.In

WriteToConn *sql.DB `name:"rw"`
ReadFromConn *sql.DB `name:"ro"`
}

type ServerParams struct {
fx.In

Handlers []Handler `group:"server"`
}

结果对象

与参数对象对应的是 结果对象(Result Objects),用于指定函数的返回类型。通过在结构体中新增 fx.Out 字段,这样就可以定义 结果对象

如下是一个示例:

  • 定义一个结构体,例如 ClientResult(Fx 的 结果对象 只用于作为构造器的返回类型,不要把它用于通用的业务逻辑)
1
2
type ClientResult struct {
}
  • 在该结构体中嵌入 fx.Out
1
2
3
type ClientResult struct {
fx.Out
}
  • 结果对象 类型声明为 构造函数 的返回类型
1
2
func NewClient() (ClientResult, error) {
}
  • 结果对象 类型中新增真正的生成字段,需要是导出字段
1
2
3
4
5
type ClientResult struct {
fx.Out

Client *Client
}
  • 在构造函数中增加设置这些字段的逻辑
1
2
3
4
5
6
func NewClient() (ClientResult, error) {
client := &Client{
// ...
}
return ClientResult{Client: client}, nil
}
  • 如果需要继续新增字段,直接在 结果对象 类型中继续新增字段即可

  • 结果对象 通过 name:"..." tag 来支持 named value,通过 group:"..." 来支持 value groups

1
2
3
4
5
6
7
8
9
10
11
12
type ConnectionResult struct {
fx.Out

ReadWrite *sql.DB `name:"rw"`
ReadOnly *sql.DB `name:"ro"`
}

type HandlerResult struct {
fx.Out

Handler Handler `group:"server"`
}

应用程序生命周期

Fx App 的生命周期可以分为两个阶段:initializationexecution。每个阶段包含多个步骤,在 initialization 期间,包含以下步骤:

  • 注册 fx.Provide 提供的所有构造函数
  • 注册 fx.Decorate 提供的所有装饰函数
  • 运行 fx.Invoke 指定的所有函数

execution 期间,Fx 执行如下步骤:

  • 运行所有的 OnStart hooks,这些 hooks 可以是由 providersdecoratorsinvoked 函数 中注册的
  • 等待程序停止运行的信号
  • 运行所有的 OnStop hooks

模块

Fx 模块是一个可共享的 Go 库或包,可以给 Fx App 提供自包含的功能。如下提供一个简单示例:

  • 首先通过 fx.Module() 定义一个 Module 类型的顶层变量
1
var Module = fx.Module("server",
  • 仍然是通过 fx.Provide 为模块添加组件
1
2
3
4
5
6
var Module = fx.Module("server",
fx.Provide(
New,
parseConfig,
),
)
  • 如果模块总是运行某个函数,可以使用 fx.Invoke
1
2
3
4
5
6
7
var Module = fx.Module("server",
fx.Provide(
New,
parseConfig,
),
fx.Invoke(startServer),
)
  • 在使用依赖之前,也支持使用 fx.Decorate 来装饰这些依赖
1
2
3
4
5
6
7
8
var Module = fx.Module("server",
fx.Provide(
New,
parseConfig,
),
fx.Invoke(startServer),
fx.Decorate(wrapLogger),
)
  • 如果你只想让你构造函数的结果只限于本模块使用(还有你模块所包含的子模块),可以在 Provide 的时候使用 fx.Private。如下 parseConfig 只对 server 模块有效,其他模块不能使用这个构造函数
1
2
3
4
5
6
7
8
9
10
11
12
var Module = fx.Module("server",
fx.Provide(
New,
),
fx.Provide(
fx.Private,
parseConfig,
),
fx.Invoke(startServer),
fx.Decorate(wrapLogger),

)

以下列举了一些在 Uber 中使用 Fx 模块的约定。

  • 可以作为独立包发布的 Fx 模块需要以 fx 作为命名后缀,包内的 Fx 模块可以不用这个后缀
  • 参数对象Params 后缀命名、结果对象Result 后缀命名
  • 模块正常工作所依赖的函数(这些函数通常需要通过 fx.Providefx.Invoke 注册)需要被导出,这是为了保证即使没有 Fx,我们的模块也可以被使用
  • 模块暴露的函数需要以 参数对象 作为参数,而不是直接接受多个参数,这样可以保持可扩展性
  • 类似地,模块暴露的函数需要以 结果对象 作为返回值,而不是直接返回多个值
  • 只提供你真正能提供的类型,不要提供本模块的 依赖,也不要捆绑其他模块
  • Fx 模块应该很少包含业务逻辑。如果一个 Fx 模块包含了真正的业务逻辑,它不应该以 fx 后缀命名。这样做的好处是让用户可以自由决定是否使用 Fx
  • 谨慎使用 Invoke,因为 fx.Invoke 注册的函数总是无条件运行,它不像 fx.Provide 注册的构造函数总是在真正需要的时候才运行

Reference