查看原文
其他

如何在 Go 中做依赖注入?

字符串拼接工程师 Go开发大全 2021-01-31

英文: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 方法需要的这些参数都是从哪里传过来的?当然是来自前面逻辑里的其他调用者,ProvideLoggerhttp.NewServeMux

Fx 框架做了这些把实例调度给对应的方法的杂事。

注册生命周期钩子函数

最后的最后我们实现OnStartOnStopOnstart是在所有服务都配置好后,把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 -




如果觉得本文不错,欢迎转发推荐给更多人。



分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存