最近在学习 Kratos 框架并且尝试使用这个框架将一些服务微服务化,在学习的过程中遇到了一些跟传统的编程思维有一些出入的地方,记录并形成博客,以便帮助更多人快速入门云原生。
一、什么是依赖注入?什么是 Wire?
依赖注入解决的核心问题
分层架构里,biz 是业务层,里面的逻辑需要查数据库。最直觉的写法是在 biz 里直接 import data 层,自己把对象 new 出来。这样写代码能跑,但 biz 和 data 就绑死了。
一旦 data 层的实现发生变更,比如更换数据库、调整 ORM 框架或重构数据访问逻辑,biz 层的代码必须同步修改。而且 biz 内部硬编码了 data 层的具体实现,单元测试时没法 Mock,测试成本极高。
依赖注入的做法是:biz 层只声明接口,不 care 谁来实现;data 层去实现这个接口,但 biz 对此无感知。 依赖的具体实例由外部在初始化时传入,而不是由 biz 在内部实例化。
通过这种方式,高层与低层之间仅通过接口契约交互。更换底层实现时,biz 代码不用改;进行单元测试时,直接注入 Mock 对象即可。
Wire 的定位与作用
上面说的”外部传入”具体怎么操作?手写 main.go 里的组装逻辑当然可以,但项目大了之后,几十个 NewXxx 的嵌套调用很容易写错顺序,维护起来也头疼。
Wire 是 Google 开源的编译期依赖注入代码生成工具。你通过 Provider 函数声明依赖关系,Wire 在编译阶段扫描依赖图,自动生成完整的初始化代码,确保所有依赖被正确实例化且无循环依赖。
注意:Wire 不是运行时框架。 它不靠反射,不搞容器,没有启动开销。它只是在编译前帮你生成一个 wire_gen.go 文件,里面就是纯 Go 代码,和你手写的一模一样。生成完之后,Wire 就退场了。
https://github.com/google/wire
Wire 里有三个核心概念,后面代码都会用到,先过一遍:
- Provider:就是”我会造某种东西的工厂函数”。你项目里那些
NewXxx函数,只要返回一个对象,它就是 Provider。 - Injector:告诉 Wire”请帮我组装出这个对象”的蓝图文件。里面写
wire.Build(...),列出所有原材料,Wire 自动算顺序。 - ProviderSet:把一堆 Provider 打包成一个集合,省得在 Injector 里一个个列。
二、代码示例
1. 传统写法:biz 直接 import data
这是最常见的直觉写法。业务层要查用户,就直接把数据层 import 进来,在构造函数里自己 new 一个 UserMySQLStore。
package biz
import ( "context" "project/internal/data" // ← 高层直接依赖低层)
type UserLogic struct { store *data.UserMySQLStore // ← 依赖的是具体实现}
func NewUserLogic() *UserLogic { db := data.NewMySQLDatabase("root:password@tcp(localhost:3306)/app") return &UserLogic{ store: data.NewUserMySQLStore(db), // ← 自己创建依赖 }}
func (l *UserLogic) GetUserName(ctx context.Context, id int64) (string, error) { user, err := l.store.GetByID(ctx, id) if err != nil { return "", err } return user.Name, nil}package data
import "context"
type MySQLDatabase struct { dsn string}
func NewMySQLDatabase(dsn string) *MySQLDatabase { return &MySQLDatabase{dsn: dsn}}
type UserMySQLStore struct { db *MySQLDatabase}
func NewUserMySQLStore(db *MySQLDatabase) *UserMySQLStore { return &UserMySQLStore{db: db}}
func (s *UserMySQLStore) GetByID(ctx context.Context, id int64) (*User, error) { // 真实的 SQL 查询... return &User{ID: id, Name: "Alice"}, nil}
type User struct { ID int64 Name string}这段代码的问题很明显。
biz 包里写了 import "project/internal/data",还直接用了 *data.UserMySQLStore。如果有一天要把 MySQL 换成 MongoDB,biz/user.go 必须改。
NewUserLogic 里硬编码了数据库连接串,连初始化逻辑都耦合在业务层里。调用方只是想拿个业务逻辑对象用,结果它背后默默连了数据库、建了连接池。这在复杂项目里会让初始化逻辑散落得到处都是,main 函数变成一团乱麻。
写单元测试时,你没法在不启动 MySQL 的情况下测 UserLogic,因为 NewUserLogic 内部直接 new 了一个真实的 UserMySQLStore。
我们用一张图来看

2. 依赖倒置:biz 只认接口,data 去实现
正确的做法是把依赖方向反转。biz 层定义一个 UserStore 接口,声明”我需要什么能力”,但完全不 care 谁来实现。data 层去 import biz,然后提供一个满足这个接口的具体实现。
package biz
import "context"
// User 领域模型type User struct { ID int64 Name string}
// UserStore 是 biz 层定义的接口// 它只描述"我需要什么",不描述"怎么实现"type UserStore interface { GetByID(ctx context.Context, id int64) (*User, error) Save(ctx context.Context, user *User) error}
// UserLogic 依赖的是 UserStore 接口,而不是 data 层的任何具体类型type UserLogic struct { store UserStore}
// 依赖从外部传入,biz 层自己不创建 storefunc NewUserLogic(store UserStore) *UserLogic { return &UserLogic{store: store}}
func (l *UserLogic) GetUserName(ctx context.Context, id int64) (string, error) { user, err := l.store.GetByID(ctx, id) if err != nil { return "", err } return user.Name, nil}注意上面这个文件里没有任何 import "project/internal/data"。biz 包完全不认识 data 包。
package data
import ( "context" "project/internal/biz")
// MySQLDatabase 封装真实的数据库连接type MySQLDatabase struct { dsn string}
func NewMySQLDatabase(dsn string) *MySQLDatabase { return &MySQLDatabase{dsn: dsn}}
// userMySQLStore 是 UserStore 接口的一个具体实现// 小写开头,包外不可见,通过 NewUserStore 暴露type userMySQLStore struct { db *MySQLDatabase}
// 隐式实现 biz.UserStore 接口,Go 不需要写 implementsfunc (s *userMySQLStore) GetByID(ctx context.Context, id int64) (*biz.User, error) { // 真实的 SQL 查询逻辑... return &biz.User{ID: id, Name: "Alice"}, nil}
func (s *userMySQLStore) Save(ctx context.Context, user *biz.User) error { // INSERT / UPDATE ... return nil}
// NewUserStore 返回的是 biz.UserStore 接口类型// 调用方拿到的是接口,不是具体类型func NewUserStore(db *MySQLDatabase) biz.UserStore { return &userMySQLStore{db: db}}这里 data 包 import 了 biz 包。箭头方向彻底反转了。

3. 解耦之后,测试变得很简单
因为 UserLogic 依赖的是接口,你可以很轻松地写一个内存版的 Mock 实现来测业务逻辑,完全不需要启动数据库。
package biz
import ( "context" "testing")
// mockUserStore 是一个内存实现的 UserStore,只用于测试type mockUserStore struct { users map[int64]*User}
func newMockUserStore() *mockUserStore { return &mockUserStore{ users: map[int64]*User{ 1: {ID: 1, Name: "MockAlice"}, }, }}
func (m *mockUserStore) GetByID(ctx context.Context, id int64) (*User, error) { if u, ok := m.users[id]; ok { return u, nil } return nil, context.Canceled // 模拟 not found}
func (m *mockUserStore) Save(ctx context.Context, user *User) error { m.users[user.ID] = user return nil}
func TestUserLogic_GetUserName(t *testing.T) { // 用 Mock 创建 UserLogic,完全不涉及 MySQL logic := NewUserLogic(newMockUserStore())
name, err := logic.GetUserName(context.Background(), 1) if err != nil { t.Fatal(err) } if name != "MockAlice" { t.Fatalf("expected MockAlice, got %s", name) }}在传统写法里,你做不到这一点,因为 NewUserLogic 内部直接 new 了 UserMySQLStore,测试时会被迫连真实数据库。
三、Wire 负责最后的组装
biz 和 data 都写好了,但它们之间还缺一个”胶水”。
biz 不认识 data,造不出 userMySQLStore;data 虽然能造,但不知道这个实现该给谁。所以必须有一个同时认识两边的第三方来做组装。
没有 Wire 的时候,你只能在 main.go 里手写:
func main() { db := data.NewMySQLDatabase("root:password@tcp(localhost:3306)/app") store := data.NewUserStore(db) logic := biz.NewUserLogic(store) svc := service.NewUserService(logic) // ...}项目小的时候,手写这段代码没问题。但一旦层数多了、依赖关系复杂了,main.go 里会挤满 NewA(NewB(NewC(...))) 这种嵌套,而且顺序错一个就编译不过。
Wire 就是自动写这段组装代码的工具。
先安装 Wire:
go install github.com/google/wire/cmd/wire@latest然后写 Injector 蓝图:
//go:build wireinject// +build wireinject
package main
import ( "github.com/google/wire" "project/internal/biz" "project/internal/data" "project/internal/service")
// InitializeApp 告诉 Wire:请用下面这些 Provider,组装出一个 *service.UserServicefunc InitializeApp() (*service.UserService, error) { wire.Build( data.NewMySQLDatabase, data.NewUserStore, biz.NewUserLogic, service.NewUserService, ) return nil, nil}这里有个细节:为什么文件顶部要写 //go:build wireinject?
因为 wire.Build(...) 这个函数只在 Wire 工具分析时有用。正常 go run 编译时,如果编译器读到这个文件,会因为 wire 包在运行时不可用而报错。所以用 build tag 做区分:
wire.go带wireinjecttag → 只给wire命令分析用- 生成的
wire_gen.go带!wireinjecttag → 正常go run时编译
执行 wire 命令后,生成的 wire_gen.go 长这样:
// Code generated by Wire. DO NOT EDIT.//这段代码由 Wire 自动生成,不要手动修改
package main
import ( "project/internal/biz" "project/internal/data" "project/internal/service")
func InitializeApp() (*service.UserService, error) { mySQLDatabase := data.NewMySQLDatabase("root:password@tcp(localhost:3306)/app") userStore := data.NewUserStore(mySQLDatabase) userLogic := biz.NewUserLogic(userStore) userService := service.NewUserService(userLogic) return userService, nil}这段代码和你手写 main.go 里的组装逻辑完全一致,但 Wire 自动帮你算好了顺序。项目大了之后,几十个 Provider 的依赖图人工维护很容易出错,Wire 就是用来消除这个心智负担的。
四、各层的 ProviderSet(Kratos 风格)
实际项目里不会把 Provider 一个个列在 wire.Build 里,而是每层打包成一个 Set。
package data
import "github.com/google/wire"
var ProviderSet = wire.NewSet( NewMySQLDatabase, NewUserStore,)package biz
import "github.com/google/wire"
var ProviderSet = wire.NewSet( NewUserLogic,)package service
import "github.com/google/wire"
var ProviderSet = wire.NewSet( NewUserService,)然后 Injector 里简洁地写:
wire.Build(data.ProviderSet, biz.ProviderSet, service.ProviderSet)Kratos 项目里就是这么干的,每一层一个 ProviderSet,最后在最外层的 cmd/xxx/wire.go 里汇总。
五、几个容易踩的坑
改了 Provider 没重新执行 wire
wire_gen.go 是自动生成的,如果你改了某个 Provider 的参数或返回值,必须重新执行 wire 命令,否则 wire_gen.go 里的代码还是旧的,编译会报错。
no provider found for X
这个错误意思是 Wire 找不到某个类型的 Provider。检查两点:一是这个类型有没有对应的 NewXxx 函数;二是这个 NewXxx 有没有被加到 wire.Build 或某个 ProviderSet 里。
循环依赖
如果 biz 依赖 data,data 又依赖 biz,Wire 会报循环依赖错误。这也是分层架构的好处:箭头单向,天然避免循环。
六、总结
依赖注入是一种设计思想,其核心是通过控制反转实现模块解耦。Wire 则是该思想在 Go 语言工程实践中的具体工具实现,负责将手动组装依赖的过程自动化
- 传统写法:对象自己 new 依赖,高层被低层绑死。
- 接口解耦:
biz定义契约,data实现契约,箭头反转。 - 解耦之后需要有人做组装——以前是人手写
main.go,现在是 Wire 自动生成。
Wire 不创造新的设计模式,它只是把”按依赖顺序调用构造函数”这种枯燥、容易出错的体力活,交给了代码生成器。
如果你正在用 Kratos,或者准备搭一个分层比较清晰的 Go 项目,建议把 Wire 接上。它省下的不是几行代码,而是你在改架构时不用去动那坨初始化逻辑的心力。