作为刚接触企业级项目的 Go 新手,我最近发现一个很有意思的现象:同事写代码时,总爱先在 Proto 里定义一堆接口,再用结构体去实现具体逻辑。调用的时候也不直接用结构体对象,而是通过接口方法调用 —— 刚开始看代码时满脑子疑惑:这不是多此一举吗?直接写实现不好吗?为啥要绕这么一圈?
直到后来遇到一个实际需求,再加上和 GPT 梳理思路,我才恍然大悟:原来 “先定义接口再实现” 的核心,藏着解耦和灵活扩展的大玄机。今天就结合 Go 项目实战和 Wire 依赖注入,跟大家聊聊这个新手必懂的分层设计思路。
一、先吐槽后真香:为什么 “多此一举” 的接口很重要?
先说说我最初的困惑:比如要做一个用户数据查询功能,直接写个UserRepo结构体,实现GetUser方法不就完事了?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
type UserRepo struct {
db *sql.DB
}
func NewUserRepo(db *sql.DB) *UserRepo {
return &UserRepo{db: db}
}
func (r *UserRepo) GetUser(id int64) (*User, error) {
var user User
err := r.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
return &user, err
}
type UserUsecase struct {
repo *UserRepo
}
func NewUserUsecase(repo *UserRepo) *UserUsecase {
return &UserUsecase{repo: repo}
}
|
这种写法看似简洁,但问题很快就暴露了:如果后续需要把数据库从 MySQL 换成 PostgreSQL,或者要加缓存层,就得修改UserUsecase的代码 —— 因为它直接依赖了*UserRepo这个具体实现。
而 “先定义接口” 的写法,能完美解决这个问题:
第一步:在 Proto 层定义抽象接口(核心)
首先在 Proto 里定义数据访问的接口(不管具体用什么数据库):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
syntax = "proto3";
package conf;
option go_package = "internal/conf;conf";
service UserRepo {
rpc GetUser(int64) returns (*User);
}
message User {
int64 id = 1;
string name = 2;
}
|
通过protoc编译后,会生成对应的 Go 接口(核心是UserRepo接口)。
第二步:Data 层实现接口(MySQL 版本)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
type UserRepoMysql struct {
db *sql.DB
}
func (r *UserRepoMysql) GetUser(id int64) (*conf.User, error) {
var user conf.User
err := r.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id).Scan(&user.Id, &user.Name)
return &user, err
}
func NewUserRepoMysql(db *sql.DB) conf.UserRepo {
return &UserRepoMysql{db: db}
}
|
第三步:Biz 层依赖接口,不依赖具体实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
type UserUsecase struct {
repo conf.UserRepo
}
func NewUserUsecase(repo conf.UserRepo) *UserUsecase {
return &UserUsecase{repo: repo}
}
func (u *UserUsecase) GetUserInfo(id int64) (*conf.User, error) {
return u.repo.GetUser(id)
}
|
关键差异:换数据库时不用改业务代码!
如果后续要迁移到 PostgreSQL,只需要新增一个实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
type UserRepoPg struct {
db *pg.DB
}
func (r *UserRepoPg) GetUser(id int64) (*conf.User, error) {
var user conf.User
err := r.db.QueryRow("SELECT id, name FROM users WHERE id = $1", id).Scan(&user.Id, &user.Name)
return &user, err
}
func NewUserRepoPg(db *pg.DB) conf.UserRepo {
return &UserRepoPg{db: db}
}
|
此时,Biz 层的UserUsecase完全不用改 —— 因为它依赖的是UserRepo接口,只要新的实现类满足接口契约,就能无缝替换。
这就是 “接口解耦” 的核心价值:将 “依赖具体实现” 改成 “依赖抽象接口”,让业务层和数据层彻底解耦,后续扩展或替换组件时,不用动核心业务代码。
二、接口 + 依赖注入:Go 中用 Wire 让代码更优雅
光有接口还不够,怎么让 Biz 层 “拿到” 接口的具体实现?这就需要依赖注入(DI)—— 简单说就是 “不用手动New对象,让框架帮你组装依赖”。
在 Go 项目中,最常用的依赖注入工具就是 Wire。它的核心作用是:帮你自动管理对象的创建和依赖关系,不用手动写一堆New代码。
1. Wire 的基础使用:注册构造函数
首先,我们需要把所有接口实现的构造函数,用 Wire 的NewSet分组管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
import "github.com/google/wire"
var DataProviderSet = wire.NewSet(
NewUserRepoMysql,
NewDB,
)
|
2. 编写 Wire 注入脚本
在项目入口的wire.go中,声明需要注入的对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
package main
import (
"github.com/google/wire"
"your-project/internal/biz/usecase"
"your-project/internal/provider"
)
func InitUserUsecase() (*usecase.UserUsecase, error) {
wire.Build(
provider.DataProviderSet,
usecase.NewUserUsecase,
)
return nil, nil
}
|
3. 生成代码并使用
运行go generate ./...,Wire 会自动生成wire_gen.go文件,里面包含了对象组装的具体逻辑。之后在业务代码中,直接调用InitUserUsecase即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
func main() {
userUsecase, err := InitUserUsecase()
if err != nil {
log.Fatal(err)
}
user, _ := userUsecase.GetUserInfo(123)
fmt.Println(user.Name)
}
|
Wire 的核心逻辑:编译期生成代码
Wire 和 Java 的 Spring 依赖注入不同,它是编译期生成代码,而不是运行时反射。生成的wire_gen.go里,会清晰地展示对象的创建顺序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
func InitUserUsecase() (*usecase.UserUsecase, error) {
db, err := provider.NewDB()
if err != nil {
return nil, err
}
userRepo, err := provider.NewUserRepoMysql(db)
if err != nil {
return nil, err
}
userUsecase := usecase.NewUserUsecase(userRepo)
return userUsecase, nil
}
|
相当于 Wire 帮你自动写了手动New对象的代码,既保证了性能,又简化了开发。
三、总结:新手该如何理解 “接口 + 依赖注入”?
接口的作用:定义 “契约”,隔离具体实现和核心业务,实现解耦 —— 就像电源插座,不管是 U 盘还是移动硬盘,只要符合接口标准就能用。
依赖注入的作用:自动组装对象依赖,不用手动New,让代码更简洁,同时配合接口实现 “无缝替换组件”。
实战价值:后续换数据库、加缓存、写单元测试时,不用动核心业务代码,只需要新增实现类 + 修改 Wire 的构造函数集合,大大降低维护成本。
作为新手,我之前觉得 “先定义接口” 是多此一举,现在才明白这是企业级项目 “可维护性” 的关键。而 Wire 则让这种设计落地更简单,不用纠结于对象的创建顺序和依赖关系。
如果你的项目还在手动New对象、强依赖具体实现,不妨试试 “接口 + Wire” 的组合 —— 相信我,后续扩展时你会感谢现在的设计!