Go 依赖注入系统 - Fx 官方文档

官方文档:https://uber-go.github.io/fx/get-started/


1. 简介

Fx 是用于 Go 的依赖注入系统。使用 Fx 可以:


2. 入门教程

本章将介绍 Fx 的基本用法。

首先,做准备工作。

  1. 创建新项目

  2. 安装最新版本的 Fx

2.1. 创建最小的应用程序

下面构建 Fx 版的 hello-world,该应用程序除打印一堆日志外,不做任何事情。

  1. 编写最小化的 main.go

  2. 运行应用程序:

    将看到类似下面的输出:

    该输出展示提供给 Fx 应用程序的默认对象,但是它不做任何有意义的事情。使用 Ctrl-C 停止应用程序。

我们刚刚做了什么?

通过不带参数地调用 fx.New,构建空 Fx 应用程序。但是应用程序通常会向 fx.New 传递参数,以设置其组件。

然后,使用 App.Run 方法运行该应用程序。该方法将阻塞到接收到停止信号,在退出前,该方法将运行必要的清理操作。

Fx 主要用于常驻的服务端应用程序;这些应用程序在关闭时通常从部署系统接收信号。

2.2. 添加 HTTP 服务

下面向前一节的 Fx 应用程序中添加 HTTP 服务。

  1. 编写构建 HTTP 服务的函数。

    这还不够,我们需要告诉 Fx 如何启动 HTTP 服务。这就是 fx.Lifecycle 参数的作用。

  2. 使用 fx.Lifecycle 对象向应用程序添加 lifecyle 钩子。告诉 Fx 如何启动和停止 HTTP 服务。

  3. 使用 fx.Provide 将其提供给上面的 Fx 应用程序。

  4. 运行应用程序。

    输出中的第一行表明已提供 Server,但是输出不包含“Starting HTTP server”消息。因此服务未运行。

  5. 为解决该问题,添加 fx.Invoke,请求被构造的 Server。

  6. 再次运行应用程序。这次可以在输出中看到“Starting HTTP server”。

  7. 向服务发送请求。

    请求是 404,因为服务还不知道如何处理请求。下一节将修复该问题。

  8. 停止应用程序。

我们刚刚做了什么?

使用 fx.Provide 向应用程序添加 HTTP Server。Server 连接到 Fx 应用程序的生命周期 -- 当调用 App.Run 时,开始服务请求,当应用程序收到停止信号时,停止运行。使用 fx.Invoke,保证始终实例化 HTTP Server,即便在应用程序中没有其它组件直接引用它。

后面将介绍 Fx 生命周期是什么,以及如何使用。

2.3. 注册处理器

前一节构造的 Server 可以接收请求,但是该服务不知道如何处理请求。下面修复该问题。

  1. 定义基础的 HTTP 处理器,该处理器将传入的请求体拷贝到响应。在文件底部添加如下代码。

    将其提供给应用程序:

  2. 下面,编写构造 *http.ServeMux 的函数。*http.ServeMux 将服务接收到的请求路由到不同的处理器。在本例中,它将发送到 /echo 的请求路由到 *EchoHandler,因此其构造器接受 *EchoHandler 作为参数。

    同样地,将其提供给应用程序。

    注意,提供给 fx.Provide 的构造器的顺序无关紧要。

  3. 最后,修改 NewHTTPServer 函数,将 Server 连接到该 *ServeMux

  4. 运行 Server。

  5. 向 Server 发送请求。

我们刚刚做了什么?

使用 fx.Provide 添加更多组件。这些组件通过在构造器中添加参数的方式,声明彼此之间的依赖关系。Fx 将通过参数和函数的返回值,解析组件的依赖关系。

2.4. 添加 Logger

当前的应用程序将“Starting HTTP server”消息打印到标准输出,将错误打印到标准错误输出。两者都是全局状态。我们应该打印到 Logger 对象。

本教程使用 Zap,但也可以使用任何日志记录系统。

  1. 将 Zap Logger 提供给应用程序。本教程使用 zap.NewExample,但真实的应用程序应该使用 zap.NewProduction,或者构建更加定制化的 Logger。

  2. EchoHandler 上添加持有 Logger 的字段,并且在 NewEchoHandler 中添加设置该字段的参数。

  3. EchoHandler.ServeHTTP 方法中,使用 Logger 代替向标准错误输出打印。

  4. 类似地,更新 NewHTTPServer,接收 Logger,并且将“Starting HTTP server”消息记录到该 Logger。

  5. 可选)也可以使用同一 Zap Logger 记录 Fx 自身的日志。

    这将使用打印到 Logger 的消息替换 [Fx] 消息。

  6. 运行应用程序。

  7. 向 Server POST 请求。

我们刚刚做了什么?

使用 fx.Provide 将另一个组件添加到应用程序,并且将其注入进需要打印日志的其它组件。为实现这一点,只需要在构造器中添加新参数。

在可选步骤中,告诉 Fx 我们希望为 Fx 自身的操作提供自定义 Logger。使用现有的 fxevent.ZapLogger 从注入的 Logger 构建该自定义 Logger,以使所有日志遵循相同的格式。

2.5. 解耦注册

前面的 NewServeMux 明确声明对 EchoHandler 的依赖。这是不必要的紧耦合。ServeMux 是否真得需要知道确切的处理器实现?如果为 ServeMux 编写测试,那么不应该构造 EchoHandler

下面尝试修复该问题。

  1. main.go 中定义 Route 类型。它是 http.Handler 的扩展,该处理器知道其注册路径。

  2. 修改 EchoHandler 实现该接口。

  3. main() 中,对 NewEchoHandler 条目做注解,以声明将该处理器当作 Route 提供给应用程序。

  4. 修改 NewServeMux 接受 Route,并且使用其提供的模式。

  5. 运行服务。

  6. 向其发送请求。

我们刚刚做了什么?

引入接口从使用者解耦实现。然后使用 fx.Annotatefx.As 对前面提供的构造器做注解,以将其结果强制转换为接口。通过这种方式,NewEchoHandler 可以继续返回 *EchoHandler

2.6. 注册另一个处理器

下面添加另一个处理器。

  1. 在同一文件中创建新处理器。

  2. 为该处理器实现 Route 接口。

    该处理器读取其请求体,并且向调用者返回欢迎消息。

  3. 将其作为 Route 提供给应用程序,放在 NewEchoHandler 旁边。

  4. 运行应用程序 - 服务将启动失败。

    输出很多,但在错误信息内部,可以看到:

    失败原因是 Fx 不允许容器中存在相同类型的两个实例,并且未对它们进行注解。NewServeMux 不知道使用哪个 Route。下面进行修复。

  5. main() 中,使用名称对 NewEchoHandlerNewHelloHandler 进行注解。

  6. NewServeMux 添加另一个 Route 参数。

  7. main() 中,对 NewServeMux 进行注解,以使用这两个名称值

  8. 运行程序。

  9. 发送请求。

我们刚刚做了什么?

添加构造器,用于生成与现有类型相同类型的值。使用 fx.ResultTags 对构造器进行注解,生成命名值,使用 fx.ParamTags 对使用者进行注解,使用这些命名值。

2.7. 注册多个处理器

前面的示例有两个处理器,但是在构造 NewServeMux 时,通过名称显式地引用它们。如果添加更多处理器,这种方式将变得不方便。

如果 NewServeMux 无需知道有多少个处理器或者名称,而只接受待注册的处理器列表,那么更可取。

  1. 修改 NewServeMux,使其操作 Route 对象的列表。

  2. main 中,对 NewServeMux 条目进行注解,以说明它接收包含“routes”组内容的切片。

  3. 定义新函数 AsRoute,以构造提供给该组的函数。

  4. main() 中,使用 AsRoute 包装 NewEchoHandlerNewHelloHandler 构造器,以便它们将路由提供给该组。

  5. 最后,运行该应用程序。

  6. 发送请求。

我们刚刚做了什么?

NewServeMux 进行注解,使其将值组当作切片使用,并且对现有处理器构造器进行注解,将其提供给该值组。应用程序中的任何构造函器只要结果符合 Route 接口,都可以向该值组提供值。它们将被收集在一起,并且被传递给 ServeMux 构造器。


3. 概念

3.1. 应用程序生命周期

Fx 应用程序的生命周期有两个阶段:初始化执行。这两个阶段依次由多个步骤组成。

初始化期间,Fx 将:

执行期间,Fx 将:

go-fx-1.jpg

3.1.1. 生命周期钩子

当应用程序启动或关闭时,生命周期钩子提供调度 Fx 执行的工作的能力。Fx 提供两种钩子:

通常,提供启动钩子的组件也提供相应的关闭钩子,以释放启动时申请的资源。

Fx 使用硬超时强制运行这两种钩子。因此,钩子只有在需要调度工作时才可以阻塞。换言之,

3.2. 模块

Fx 模块是可共享的 Go 库或包,向 Fx 应用程序提供自包含(Self-Contained)的功能。

3.2.1. 编写模块

为编写 Fx 模块:

  1. 定义顶级的 Module 变量(通过 fx.Module 调用构建)。给模块起一个简短的、容易记住的日志名称。

  2. 使用 fx.Provide 添加模块的组件。

  3. 如果模块拥有必须运行的函数,那么为其添加 fx.Invoke

  4. 如果模块在使用其依赖之前需要装饰它们,那么为其添加 fx.Decorate 调用。

  5. 最后,如果希望将构造器的输出保留在模块(以及模块包含的模块)中,那么在提供时添加 fx.Private

    在这种情况下,parseConfig 是“server”模块私有的。任何包含“server”的模块都不能使用结果 Config 类型,因为它仅对“server”模块可见。

以上是关于编写模块的全部内容。本节的其余部分涵盖 Uber 为编写 Fx 模块建立的标准和惯例。

3.2.1.1. 命名

3.2.1.1.1. 包

独立的 Fx 模块,即那些作为独立库分发的模块,或者那些在库中有独立 Go 包的模块,应该以它们包装的库或者提供的功能命名,并且添加“fx”后缀。

BadGood
package mylibpackage mylibfx
package httputilpackage httputilfx

如果 Fx 模块是另一个 Go 包的一部分,或者是为特定应用程序编写的单服务模块,那么可以省略该后缀。

3.2.1.1.2. 参数和结果对象

参数和结果对象类型应该以它们对应的函数命名,命名方式是在函数名后添加 ParamsResult 后缀。

例外:如果函数名以 New 开头,那么在添加 ParamsResult 后缀前,去掉 New 前缀。

函数参数对象结果对象
NewParamsResult
RunRunParamsRunResult
NewFooFooParamsFooResult

3.2.1.2. 导出边界函数

如果功能无法通过其它方式访问,那么导出模块通过 fx.Providefx.Invoke 使用的函数。

在本例中,不导出 parseConfig,因为它只是简单的 yaml.Decode,无需暴露,但仍然导出 Config,以便用户可以自己解码。

基本原则:应该可以在不使用 Fx 的情况下,使用 Fx 模块。用户应该可以直接调用构造器,获取与使用 Fx 时模块提供的相同功能。这对于紧急情况和部分迁移是必要的。

Bad case:不使用 Fx,无法构建 server

3.2.1.3. 使用参数对象

由模块暴露的函数不应该直接接受依赖项作为参数。而应该使用参数对象。

基本原则:模块不可避免地需要声明新依赖。通过使用参数对象,可以以向后兼容的方式添加新的可选依赖,并且无需更改函数签名。

Bad case:无法在不破坏的情况下,添加新参数

3.2.1.4. 使用结果对象

由模块暴露的函数不应该将其结果声明为常规返回值。而应该使用结果对象。

基本原则:模块将不可避免地需要返回新结果。通过使用结果对象,可以以向后兼容的方式生成新结果,并且无需更改函数签名。

Bad case:无法在不破坏的情况下,添加新结果

3.2.1.5. 不要提供你不拥有的东西

Fx 模块应该只向应用程序提供在其权限范围内的类型。模块不应该向应用程序提供它们碰巧使用的值。模块也不应该大量捆绑其它模块。

基本原则:这让使用者可以自由地选择依赖的来源和方式。他们可以使用你推荐的方法(比如,“include zapfix.module”),或者构建该依赖的变体。

Bad case:提供依赖

Bad case:绑定其它模块

例外:组织或团队级别的“kitchen sink”模块专门用于捆绑其它模块,可以忽略该规则。比如,Uber 的 uberfx.Module 模块捆绑若干其它独立模块。该模块中的所有东西都被所有服务所需要。

3.2.1.6. 保持独立模块精简

独立的 Fx 模块 -- 名称以“fx”结尾的模块很少包含重要的业务逻辑。如果 Fx 模块位于包含重要业务逻辑的包中,那么其名称中不应该有“fx”后缀。

基本原则:在不重写业务逻辑的情况下,使用方应该可以迁移到或者离开 Fx。

Good case:业务逻辑使用 net/http.Client

Bad case:Fx 模块实现 Logger

3.2.1.7. 谨慎地使用 Invoke

当选择在模块中使用 fx.Invoke 时要谨慎。根据设计,Fx 仅在应用程序通过另一个模块、构造器或 Invoke 直接或间接地使用构造器的结果时,才执行通过 fx.Provide 添加的构造器。另一方面,Fx 无条件地运行使用 fx.Invoke 添加的函数,并且在这样做时,实例化其依赖的每个直接值和传递值。


4. 特性

4.1. 参数对象

参数对象是仅用于携带特定函数或方法的参数的对象。

通常专门为函数定义参数对象,并且不与其它函数共享。参数对象不是像“user”那样的通用对象,而是专门构建的对象,比如“GetUser 函数的参数”。

在 Fx 中,参数对象只包含导出字段,并且始终附带 fx.In 标签。

4.1.1. 使用参数对象

为在 Fx 中使用参数对象,执行以下步骤:

  1. 定义新结构体类型,命名为构造器名称加上 Params 后缀。如果构造器名称为 NewClient,那么将结构体命名为 ClientParams。如果构造器名称为 New,那么将结构体命名为 Params。该命名不是必须的,但是是很好的约定。

  2. fx.In 嵌进结构体。

  3. 按值将该新类型当作参数添加到构造器中。

  4. 将构造器的依赖项添加为该结构体的导出字段。

  5. 在构造器中使用这些字段。

4.1.2. 添加新参数

可以通过向参数对象添加新字段的方式,为构造器添加新参数。为向后兼容,新字段必须是可选的

  1. 接受现有的参数对象。

  2. 为新依赖,向参数对象中添加新字段,并且将其标记为可选的,以保证此改动向后兼容。

  3. 在构造器中,使用该字段。确保处理缺少该字段的情况 - 在该情况下,将取其类型的零值。

4.2. 结果对象

结果对象是仅用于携带特定函数或方法的结果的对象。

与参数对象一样,专门为单个函数定义结果对象,并且不与其它函数共享。

在 Fx 中,结果对象只包含导出字段,并且始终附带 fx.Out 标签。

4.2.1. 使用结果对象

为在 Fx 中使用结果对象,执行以下步骤:

  1. 定义新结构体类型,命名为构造器名称加上 Result 后缀。如果构造器名称为 NewClient,那么将结构体命名为 ClientResult。如果构造器名称为 New,那么将结构体命名为 Result。该命名不是必须的,但是是很好的约定。

  2. fx.Out 嵌进结构体。

  3. 按值将该新类型用作构造器的返回值。

  4. 将构造器生成的值添加为该结构体上的导出字段。

  5. 在构造器中,设置这些字段,并且返回该结构体的实例。

4.2.2. 添加新结果

可以以完全向后兼容的方式,向现有结果对象添加新值。

  1. 接受现有结果对象。

  2. 为新结果,向结果对象中添加新字段。

  3. 在构造器中,设置该字段。

4.3. 注解(Annotation)

可以在将函数和值传递给 fx.Providefx.Supplyfx.Invokefx.Decoratefx.Replace 之前,使用 fx.Annotate 函数对其进行注解。

4.3.1. 对函数进行注解

先决条件

函数需要满足:

步骤

  1. 给定传递给 fx.Providefx.Invokefx.Decorate 的函数。

  2. 使用 fx.Annotate 包装函数。

  3. fx.Annotate 内部,传入注解。

    该注解使用名称标记函数的结果。

4.3.2. 将结构体强制转换为接口

可以使用函数注解将函数返回的结构体值强制转换为其它函数使用的接口。

先决条件

  1. 生成结构体或指针值的函数。

  2. 使用生产者结果的函数。

  3. 将这两个函数提供给 Fx 应用程序。

步骤

  1. 声明匹配 *http.Client 的 API 的接口。

  2. 修改使用者,使其接受接口,而非结构体。

  3. 最后,使用 fx.As 对生产者进行注解,说明它生成接口值。

使用该改动:

4.4. 值组(Value Group)

值组是相同类型的值的集合。Fx 应用程序中的任何构造器都可以向值组提供值。类似地,任何使用者都可以在不知道生产者完整列表的情况下从值组中读取值。

go-fx-2.jpg

提示

Fx 生成的值以随机顺序提供给值组。不要对值组顺序做任何假设。

4.4.1. 使用值组

4.4.2. 依赖严格性

通过值组形成的依赖关系可以是:

默认情况下,值组的赖是严格的。

4.4.2.1. 严格的值组

无论生产者是否被应用程序使用,值组都使用严格的值组依赖。

假设构造器 NewFoo 生成两个值:AB。将值 A 提供给值组 []A,函数 Run 使用该值组,应用程序使用 fx.Invoke 调用函数 Run

对于严格的值组,Fx 将运行 NewFoo,填充 []A 组,而不管应用程序是否直接或间接地使用其它结果(B)。

go-fx-3.jpg

4.4.2.2. 软值组

仅当生成软值组依赖的构造器被 Fx 调用时,值组才使用软值组依赖 -- 因为应用程序直接或间接地使用它们的其它结果。

下面的组织结构除值组是软的外,与前一节类似。

go-fx-4.jpg

对于软值组,Fx 仅在 AB 被应用程序中的另一个组件直接或间接地使用时,才运行 NewFoo 填充 []A 组。

go-fx-5.jpg

4.4.3. 向值组提供值

为向类型为 T 的值组提供值,必须使用 group:"$name" 标记 T 结果,其中 $name 是值组名称。可以通过如下方式实现:

4.4.3.1. 使用结果对象

可以使用结果对象标记函数的结果,并且将其提供给值组。

先决条件

  1. 生成结果对象的函数。

  2. 将函数提供给 Fx 应用程序。

步骤

  1. 使用想要生成的值的类型向结果对象添加新导出字段,并且用值组的名称标记该字段。

  2. 在函数中,将该字段设置为想要提供给值组的值。

4.4.3.2. 使用带注解的函数

可以使用注解向值组发送函数的结果。

先决条件

  1. 生成值组所需类型的值的函数。

  2. 将函数提供给 Fx 应用程序。

步骤

  1. 使用 fx.Annotate 包装传递给 fx.Provide 的函数。

  2. 对该函数进行注解,以说明将其结果提供给值组。

提示:类型无需必须匹配

如果注解的函数不生成与组相同的类型,那么可以将其强制转换为该类型:

仍然可以使用注解将其提供给值组。

4.4.4. 从值组获取值

为使用类型为 T 的值组,必须使用 group:"$name" 标记 []T 依赖,其中 $name 是值组的名称。可以通过如下方式实现:

  1. 使用参数对象

  2. 使用带注解的函数

4.4.4.1. 使用参数对象

可以使用参数对象将函数的切片参数标记为值组。

先决条件

  1. 使用参数对象的函数。

  2. 将函数提供给 Fx 应用程序。

步骤

  1. 向参数对象添加类型为 []T 的新导出字段,其中 T 是值组中值的种类。使用值组名称标记该字段。

  2. 在接收该参数对象的函数中使用该切片。

警告

不要依赖切片里的值的顺序。因为顺序是随机的。

4.4.4.2. 使用带注解的函数

可以使用注解从函数中使用值组。

先决条件

  1. 接受组中的值类型的切片的函数。

  2. 将函数提供给 Fx 应用程序。

步骤

  1. 使用 fx.Annotate 包装传递给 fx.Provide 的函数。

  2. 对该函数进行注解,说明其切片参数为值组。

  3. 在函数中使用该切片。

提示:函数可以接受变长参数

可以在接受变长参数的函数中使用值组。

对变长参数进行注解。