在
《在 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(255) NOT NULL,
`password` VARCHAR(255) NOT 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 nil, nil, 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 nil, nil, 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 nil, nil, nil
}
有了前文的讲解,其实这里无需我多言,你都能够看懂,因为并没有新的知识。
不过我们还是简单分析下这里都用到了 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,它们使用的都是运行时反射机制,来实现依赖注入功能。
这会带来最直观的两个问题:
-
-
我们需要根据工具的要求编写代码,而这份代码正确与否,只有在运行期间才能确定。也就是说,代码是“黑盒”的,通过 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
支持的所有子命令,嗯,仅此而已。