专栏名称: GoCN
最具规模和生命力的 Go 开发者社区
目录
相关文章推荐
51好读  ›  专栏  ›  GoCN

在 Go 中如何优雅的使用 wire 依赖注入工具提高开发效率?下篇

GoCN  · 公众号  ·  · 2024-06-17 11:37

正文

《在 Go 中如何优雅的使用 wire 依赖注入工具提高开发效率?上篇》 ,我讲解了 Go 语言中依赖注入工具 wire 的基本使用及高级用法。本篇就来介绍下 wire 的生产实践。

Wire 生产实践

这里以一个 user 服务作为示例,演示下一个生产项目中是如何使用 wire 依赖注入工具的。

user 项目目录结构如下:

$ tree user
user
├── assets
│   ├── curl.sh
│   └── schema.sql
├── cmd
│   └── main.go
├── go.mod
├── go.sum
├── internal
│   ├── biz
│   │   └── user.go
│   ├── config
│   │   └── config.go
│   ├── controller
│   │   └── user.go
│   ├── model
│   │   └── user.go
│   ├── router.go
│   ├── store
│   │   └── user.go
│   ├── user.go
│   ├── wire.go
│   └── wire_gen.go
└── pkg
    ├── api
    │   └── user.go
    └── db
        └── db.go

12 directories, 16 files

NOTE: user 项目源码在此(https://github.com/jianghushinian/blog-go-example/tree/main/wire/user),你可以点击查看,建议下载下来执行启动下程序,加深理解。

这是一个典型的 Web 应用,用来对用户进行 CRUD。不过为了保持代码简洁清晰,方便理解, user 项目仅实现了创建用户的功能。

我先简单介绍下各个目录的功能。

assets 努目录用于存放项目资源。 schema.sql 中是建表语句, curl.sh 保存了一个 curl 请求命令,用于测试创建用户功能。

cmd 中当然是程序入口文件。

internal 下保存了项目业务逻辑。

pkg 目录存放可导出的公共库。 api 用于存放请求对象; db 用于构造数据库对象。

项目设计了 4 层架构, controller 即对应 MVC 经典模式中的 Controller, biz 是业务层, store 层用于跟数据库交互,还有一个 model 层定义模型,用于映射数据库表。

router.go 用于注册路由。

user.go 用于定义创建和启动 user 服务的应用对象。

wire.go wire_gen.go 两个文件就无需我过多讲解了。

NOTE: 本项目目录结构遵循最佳实践,可以参考我的另一篇文章 《如何设计一个优秀的 Go Web 项目目录结构》

简单介绍完了目录结构,再来梳理下我们所设计的 4 层架构依赖关系:首先 controller 层依赖 biz 层,然后 biz 层又依赖 store 层,接着 store 层又依赖了数据库(即依赖 pkg/db/ ),而 controller biz store 这三者又都依赖 model 层。

现在看了我的讲解,你可能有些发懵,没关系,下面我将主要代码逻辑都贴出来,加深你的理解。

assets/schema.sql 中的建表语句如下:

CREATE TABLE `user`
(
    `id`        BIGINT       NOT NULL AUTO_INCREMENT,
    `email`     VARCHAR(255),
    `nickname`  VARCHAR(255),
    `username`  VARCHAR(255NOT NULL,
    `password`  VARCHAR(255NOT NULL,
    `createdAt` DATETIME,
    `updatedAt` DATETIME,
    PRIMARY KEY (`id`)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

user 项目仅有一张表。

cmd/main.go 代码如下:

package main

import (
 user "github.com/jianghushinian/blog-go-example/wire/user/internal"
 "github.com/jianghushinian/blog-go-example/wire/user/internal/config"
 "github.com/jianghushinian/blog-go-example/wire/user/pkg/db"
)

func main() {
 cfg := &config.Config{
  MySQL: db.MySQLOptions{
   Address:  "127.0.0.1:3306",
   Database: "user",
   Username: "root",
   Password: "123456",
  },
 }

 app, cleanup, err := user.NewApp(cfg)
 if err != nil {
  panic(err)
 }

 defer cleanup()
 app.Run()
}

入口函数 main 中先创建了配置对象 cfg ,接着实例化 app 对象,最后调用 app.Run() 启动 user 服务。

这也是一个典型的 Web 应用启动步骤。

Config 定义如下:

type Config struct {
 MySQL db.MySQLOptions `json:"mysql" yaml:"mysql"`
}

type MySQLOptions struct {
 Address  string
 Database string
 Username string
 Password string
}

user.go 中的 App 定义如下:

// App 代表一个 Web 应用
type App struct {
 *config.Config

 g  *gin.Engine
 uc *controller.UserController
}

// NewApp Web 应用构造函数
func NewApp(cfg *config.Config) (*App, func()error ) {
 gormDB, cleanup, err := db.NewMySQL(&cfg.MySQL)
 if err != nil {
  return nilnil, err
 }
 
 userStore := store.New(gormDB)
 userBiz := biz.New(userStore)
 userController := controller.New(userBiz)
 
 engine := gin.Default()
 app := &App{
  Config: cfg,
  g:      engine,
  uc:     userController,
 }

 return app, cleanup, err
}

// Run 启动 Web 应用
func (a *App) Run() {
 // 注册路由
 InitRouter(a)

 if err := a.g.Run(":8000"); err != nil {
  panic(err)
 }
}

App 代表一个 Web 应用,它嵌入了配置、 gin 框架的 *Engine 对象,以及 controller

NewApp App 的构造函数,通过 Config 来创建一个 *App 对象。

根据其内部代码逻辑,也能看出项目的 4 层架构依赖关系:创建 App 对象依赖 Config Config 是通过参数传递进来的; *Engine 对象可以通过 gin.Default() 得到;而 userController 则通过 controller.New 创建, controller 依赖 biz biz 依赖 store store 依赖 *gorm.DB

可以发现,依赖关系非常清晰,并且我们使用了依赖注入思想编写代码,那么此时,正是 wire 的用武之地。

不过,我们先不急着讲解如何在这里使用 wire。我先将项目剩余主要代码贴出来,便于你理解这个 Web 应用。

我们可以通过 pkg/db/db.go 中的 NewMySQL 创建出 *gorm.DB 对象:

// NewMySQL 根据选项构造 *gorm.DB
func NewMySQL(opts *MySQLOptions) (*gorm.DB, func()error) {
 // 可以用来释放资源,这里仅作为示例使用,没有释放任何资源,因为 gorm 内部已经帮我们做了
 cleanFunc := func() {}

 db, err := gorm.Open(mysql.Open(opts.DSN()), &gorm.Config{
  Logger: logger.Default.LogMode(logger.Silent),
 })
 return db, cleanFunc, err
}

有了 *gorm.DB 就可以创建 store 对象了, internal/store/user.go 主要代码如下:

package




    
 store

...

// ProviderSet 一个 Wire provider sets,用来初始化 store 实例对象,并将 UserStore 接口绑定到 *userStore 类型实现上
var ProviderSet = wire.NewSet(New, wire.Bind(new(UserStore), new(*userStore)))

// UserStore 定义 user 暴露的 CRUD 方法
type UserStore interface {
 Create(ctx context.Context, user *model.UserM) error
}

// UserStore 接口实现
type userStore struct {
 db *gorm.DB
}

// 确保 userStore 实现了 UserStore 接口
var _ UserStore = (*userStore)(nil)

// New userStore 构造函数
func New(db *gorm.DB) *userStore {
 return &userStore{db}
}

// Create 插入一条 user 记录
func (u *userStore) Create(ctx context.Context, user *model.UserM) error {
 return u.db.Create(&user).Error
}

有了 store 就可以创建 biz 对象了, internal/biz/user.go 主要代码如下:

package biz

...

// ProviderSet 一个 Wire provider sets,用来初始化 biz 实例对象,并将 UserBiz 接口绑定到 *userBiz 类型实现上
var ProviderSet = wire.NewSet(New, wire.Bind(new(UserBiz), new(*userBiz)))

// UserBiz 定义 user 业务逻辑操作方法
type UserBiz interface {
 Create(ctx context.Context, r *api.CreateUserRequest) error
}

// UserBiz 接口的实现
type userBiz struct {
 s store.UserStore
}

// 确保 userBiz 实现了 UserBiz 接口
var _ UserBiz = (*userBiz)(nil)

// New userBiz 构造函数
func New(s store.UserStore) *userBiz {
 return &userBiz{s: s}
}

// Create 创建用户
func (b *userBiz) Create(ctx context.Context, r *api.CreateUserRequest) error {
 var userM model.UserM
 _ = copier.Copy(&userM, r)

 return b.s.Create(ctx, &userM)
}

接着,有了 biz 就可以创建 controller 对象了, internal/controller/user.go 主要代码如下:

package controller

...

// UserController 用来处理用户请求
type UserController struct {
 b biz.UserBiz
}

// New controller 构造函数
func New(b biz.UserBiz) *UserController {
 return &UserController{b: b}
}

// Create 创建用户
func (ctrl *UserController) Create(c *gin.Context) {
 var r api.CreateUserRequest
 if err := c.ShouldBindJSON(&r); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{
   "err": err.Error(),
  })
  return
 }

 if err := ctrl.b.Create(c, &r); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{
   "err": err.Error(),
  })
  return
 }

 c.JSON(http.StatusOK, gin.H{})
}

这些对象都有了,就可以调用 NewApp 构造出 App 了。

App 在启动前,还会调用 InitRouter 进行路由注册:

// InitRouter 初始化路由
func InitRouter(a *App) {
 // 创建 users 路由分组
 u := a.g.Group("/users")
 {
  u.POST("", a.uc.Create)
 }
}

现在 user 项目逻辑已经清晰了,是时候启动应用程序了:

cd user   
$ go run cmd/main.go

程序启动后,会监听 8000 端口,可以使用 assets/curl.sh 中的 curl 命令进行访问:

$ curl --location --request POST 'http://127.0.0.1:8000/users'




    
 \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "[email protected]",
    "nickname": "江湖十年",
    "username": "jianghushinian",
    "password": "pass"
}'

不出意外,你将在数据库中看到新创建的用户。

执行以下 SQL:

USE user;
SELECT * FROM user;

将输出新创建出来的用户。

+----+-------------------------------+----------+----------------+----------+---------------------+---------------------+
| id | email                         | nickname | username       | password | createdAt           | updatedAt           |
+----+-------------------------------+----------+----------------+----------+---------------------+---------------------+
|  1 | [email protected] | 江湖十年  | jianghushinian | pass     | 2024-06-11 00:01:35 | 2024-06-11 00:01:35 |
+----+-------------------------------+----------+----------------+----------+---------------------+---------------------+

现在,是时候讨论如何在 user 项目中使用 wire 来提高开发效率了。

回顾下 NewApp 的定义:

// NewApp Web 应用构造函数
func NewApp(cfg *config.Config) (*App, func()error) {
 gormDB, cleanup, err := db.NewMySQL(&cfg.MySQL)
 if err != nil {
  return nilnil, err
 }
 
 userStore := store.New(gormDB)
 userBiz := biz.New(userStore)
 userController := controller.New(userBiz)
 
 engine := gin.Default()
 app := &App{
  Config: cfg,
  g:      engine,
  uc:     userController,
 }

 return app, cleanup, err
}

其实这里面一层层的依赖注入,都是套路代码,基本上一个 Web 应用都可以按照这个套路来写。

这就涉及到套路代码写多了其实是比较烦的,这还只是一个微型项目,如果是中大项目,可以预见这个 NewApp 代码量会很多,所以是时候让 wire 出场了:

func NewApp(cfg *config.Config) (*App, func()error) {
 engine := gin.Default()
 app, cleanup, err := wireApp(engine, cfg, &cfg.MySQL)

 return app, cleanup, err
}

我们可以将 NewApp 中的主逻辑全部拿走,放在 wireApp 中(在 wire.go 文件中)。

wireApp 定义如下:

func wireApp(engine *gin.Engine, cfg *config.Config, mysqlOptions *db.MySQLOptions) (*App, func()error) {
 wire.Build(
  db.NewMySQL,
  store.ProviderSet,
  biz.ProviderSet,
  controller.New,
  wire.Struct(new(App), "*"),
 )
 return nilnilnil
}

有了前文的讲解,其实这里无需我多言,你都能够看懂,因为并没有新的知识。

不过我们还是简单分析下这里都用到了 wire 的哪些特性。

首先 wireApp 返回值是典型的三件套: (*App, func(), error) ,对象、清理函数和 error

这里使用了两个 wire.ProviderSet 进行分组,定义如下:

var ProviderSet = wire.NewSet(New, wire.Bind(new(UserStore), new(*userStore)))
var ProviderSet = wire.NewSet(New, wire.Bind(new(UserBiz), new(*userBiz)))

并且在构造 wire.ProviderSet 时,还使用了 wire.Bind(new(UserStore), new(*userStore)) 将一个结构体绑定到接口。

最后,我们使用了 struct 作为 provider wire.Struct(new(App), "*") ,通配符 * 用来表示所有字段。

在真实项目中,wire 就这么使用。

如果你觉得 user 项目太小,使用 wire 的价值还不够大。你可以看看 onex 项目,比如 usercenter 中的代码( https://github.com/superproj/onex/tree/master/internal/usercenter ),这个开源项目完全是生产级别。

为什么选择 Wire

通常来说,这部分内容是应该放在文章开头的。我将其放在这里,目的是为了让你熟悉 wire 后,再回过头来对比,wire 有哪些优势,加深你对为什么选择 wire 的理解。

其实 Go 生态中依赖注入工具不止有 Google 的 wire 一家独大,还有 Uber 开源的 dig,以及 Facebook 开源的 inject 比较流行。

但我为什么要选择 wire?

一句话概括:wire 使用 代码生成 ,而非 反射

我们可以分别举例看下 dig 以及 inject 是如何使用的。

dig 的使用示例如下:

package main

import (
 "fmt"
 "log"

 "go.uber.org/dig"
)


type User struct {
 name string
}

// NewUser - Creates a new instance of User
func NewUser(name string) User {
 return User{name: name}
}

// Get - A method with user as dependency
func (u *User) Get(message string) string {
 return fmt.Sprintf("Hello %s - %s", u.name, message)
}

// Run - Depends on user and calls the Get method on User
func Run(user User) {
 result := user.Get("It's nice to meet you!")
 fmt.Println(result)
}

func main() {
 // Initialize a new dig container
 container := dig.New()
 // Provide a name parameter to the container
 container.Provide(func() string { return "jianghushinian" })
 // Provide a new User instance to the container using the name injected above
 if err := container.Provide(NewUser); err != nil {
  log.Fatal(err)
 }
 // Invoke the Run function; Dig automatically injects the User instance provided above
 if err := container.Invoke(Run); err != nil {
  log.Fatal(err)
 }
}

简单解释下示例代码:

dig.New() 实例化一个 dig 容器。

container.Provide(func() string { return "jianghushinian" }) 将一个匿名函数提供给容器。

然后调用 container.Provide(NewUser) ,dig 首先将字符串值 jianghushinian 作为 name 参数提供给 NewUser 函数。之后, NewUser 函数会根据此值创建出来一个 User 结构体的新实例,随后 dig 将其提供给容器。

最后, container.Invoke(Run) 会将容器中保存的 User 结构体传递给 Run 函数并运行。

我们可以类比 wire 来学习 dig:可以把 Provide 看作 providers Invoke 看作 injectors ,这样就好理解了。

以上示例代码可以直接执行,无需像使用 wire 一样需要提前生成代码:

$ go run main.go
Hello jianghushinian - It's nice to meet you!

这就是 dig 的使用。

再来看一个 inject 的使用示例:

package main

import (
 "fmt"
 "log"

 "github.com/facebookgo/inject"
)

type User struct {
 Name string `inject:"name"`
}

// Get - A method with user as dependency
func (u *User) Get(message string) string {
 return fmt.Sprintf("Hello %s - %s", u.Name, message)
}

// Run - Depends on user and calls the Get method on User
func Run(user *User) {
 result := user.Get("It's nice to meet you!")
 fmt.Println(result)
}

func main() {
 // new an inject Graph
 var g inject.Graph

 // inject name
 name := "jianghushinian"

 // provide string value
 err := g.Provide(&inject.Object{Value: name, Name: "name"})
 if err != nil {
  log.Fatal(err)
 }

 // create a User instance and supply it to the dependency graph
 user := &User{}
 err = g.Provide(&inject.Object{Value: user})
 if err != nil {
  log.Fatal(err)
 }

 // resolve all dependencies
 err = g.Populate()
 if err != nil {
  log.Fatal(err)
 }

 Run(user)
}

这个示例代码我就不详细讲解了,学会了 wire 和 dig,这段代码很容易理解。

可以发现的是,无论是 dig 还是 inject,它们使用的都是运行时反射机制,来实现依赖注入功能。

这会带来最直观的两个问题:

  1. 使用反射可能影响性能。
  2. 我们需要根据工具的要求编写代码,而这份代码正确与否,只有在运行期间才能确定。也就是说,代码是“黑盒”的,通过 review 代码,很难一眼看出代码是否存在问题。

而 wire 采用代码生成,它会根据我们编写的 injector 函数签名,生成最终代码。所以在执行代码之前,我们就已经有了 injector 函数的源码。

这既不会影响性能,也不会让代码变成“黑盒”,在执行程序之前我们就知道代码长什么样。而这样做还能带来一个好处,能够大大简化我们排错的过程。

Python 之禅中有一句话叫「显式优于隐式」,wire 做到了。

Wire 命令行工具

文章最后,我再来简单介绍下 wire 命令行工具。

之所以放在最后讲解,是因为 wire 的子命令确实不太常用,如果你去网上搜索,几乎没人介绍。不过为了保证文章的完整性,我还是简单讲解下,作为扩展内容,你好有个印象。

使用 --help 查看使用帮助信息。

$ wire --help
Usage: wire   

Subcommands:
        check            print any Wire errors found
        commands         list all command names
        diff             output a diff between existing wire_gen.go files and what gen would generate
        flags            describe all known top-level flags
        gen              generate the wire_gen.go file for each package
        help             describe subcommands and their syntax
        show             describe all top-level provider sets

可以发现 wire 连最基本的 --version 命令都不存在,即不支持查看版本信息。起初这点我是疑惑的,不过看了官方描述,也就不足为奇了。因为 wire 已经不再加入新功能,所以你可以理解为它就这一个版本。

官方描述说当前项目状态不接受新功能,只接受错误报告和 Bug fix。看来官方也想保持 wire 的简洁。

有人说项目不维护了。但我认为这又何尝不是一件好事情,其实项目还在维护,只是不增加新功能了。这在日新月异的技术行业里,是好事,极大的好事。我们不用投入太多精力学习这个工具,学一次受用很久。这也是我写这篇想着尽量把 wire 功能介绍完全,方便大家学习。

回归正题,首先要讲解的是 gen 子命令。已经是我们的老朋友了,可以根据我们编写的 injector 函数签名,自动生成目标代码。

其实如果直接使用 wire 命令,后面什么也不接, wire 默认会调用 gen 子命令:

$ wire       
wire: github.com/jianghushinian/blog-go-example/wire/getting-started: wrote /Users/jianghushinian/projects/blog-go-example/wire/getting-started/wire_gen.go

check 子命令可以帮我们检查代码错误,比如我们将 Wire 快速开始 部分的示例中的 injector 函数 InitializeEvent 故意写错。

InitializeEvent 代码如下:

func InitializeEvent() Event {
 wire.Build(NewEvent, NewGreeter, NewMessage)
 return Event{}
}

现在修改成错误的,漏写了 NewMessage 方法:

func InitializeEvent() Event {
 wire.Build(NewEvent, NewGreeter)
 return Event{}
}

使用 wire check 检查代码错误:

$ wire check
wire: wire.go:7:1: inject InitializeEvent: no provider found for github.com/jianghushinian/blog-go-example/wire/getting-started.Message
        needed by github.com/jianghushinian/blog-go-example/wire/getting-started.Greeter in provider "NewGreeter" (main.go:15:6)
        needed by github.com/jianghushinian/blog-go-example/wire/getting-started.Event in provider "NewEvent" (main.go:27:6)
wire: error loading packages

但其实我们直接执行 wire 命令生成代码时,也会得到相同的错误。

commands 子命令可以打印 wire 支持的所有子命令,嗯,仅此而已。







请到「今天看啥」查看全文