3. 接口定义规范:使用 Go interface 进行 API 契约设计
在微服务与分层架构中,接口(Interface)是连接调用方与实现方的唯一契约。Go 语言的 interface{} 机制天然支持隐式实现,这为 API 契约设计提供了极大的灵活性,但也带来了滥用风险。本章将围绕“如何用 Go interface 定义稳定、可 Mock、可治理的 API 契约”展开,给出工程级规范与案例。
3.1 为什么需要接口契约?
没有显式接口的代码,通常表现为:
- 直接依赖具体类型:业务逻辑层直接调用
mysql.UserRepo,导致单元测试必须连接数据库。 - 隐式耦合:修改底层实现时,上层代码被迫重新编译甚至修改。
- 无法 Mock:无法在测试中替换为内存实现或桩对象。
引入接口契约后,上述问题得到系统性解决:
| 维度 | 无接口契约 | 有接口契约 |
|---|---|---|
| 依赖方向 | 上层依赖下层具体实现 | 上下层均依赖抽象接口 |
| 测试能力 | 需启动真实依赖(DB/RPC) | 可注入 Mock 实现,单测秒级 |
| 变更影响 | 修改实现波及所有调用方 | 仅需替换实现,调用方无感 |
| 复用性 | 实现与业务逻辑强绑定 | 同一接口可对接多种实现 |
核心原则:依赖倒置(DIP)——高层模块不应依赖低层模块,二者都应依赖抽象。Go 的 interface 正是实现 DIP 的最佳语法载体。
3.2 接口定义规范:从命名到粒度
一个可治理的接口契约,必须满足以下规范:
3.2.1 命名规范
- 接口名:以 “er” 结尾(如
UserRepository、Notifier),或直接描述行为(如Storage、Cache)。 - 方法名:动词 + 名词,清晰表达业务意图(如
SaveUser、GetOrderByID)。 - 包名:接口定义在消费方(调用方)所在的包,而非实现方。例如
service包定义UserRepository,repository/mysql包实现它。
3.2.2 粒度规范
- 单一职责:一个接口只描述一组高度相关的操作。避免“万能接口”(如
UserService包含 CRUD + 发邮件 + 生成报表)。 - 方法数量:建议不超过 5 个方法。超过时考虑拆分为多个小接口。
- 参数与返回值:使用具名结构体作为参数/返回值,避免超过 3 个的平铺参数。
反例(胖接口):
type UserService interface {
Create(ctx context.Context, u *User) error
Get(ctx context.Context, id int64) (*User, error)
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id int64) error
SendWelcomeEmail(ctx context.Context, userID int64) error
GenerateReport(ctx context.Context, start, end time.Time) ([]byte, error)
}
正例(拆分后):
type UserRepository interface {
Create(ctx context.Context, u *User) error
Get(ctx context.Context, id int64) (*User, error)
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id int64) error
}
type WelcomeEmailSender interface {
Send(ctx context.Context, userID int64) error
}
type ReportGenerator interface {
Generate(ctx context.Context, start, end time.Time) ([]byte, error)
}
3.3 接口契约的层级结构
在典型的三层架构(Handler → Service → Repository)中,接口契约位于 Service 层和 Repository 层的边界:
关键规则:
- 接口定义在消费方:Service 层定义它需要的 Repository 接口,而不是由 Repository 层定义。
- 实现方隐式实现:MySQL、Mock、Cache 等 Repository 实现只需拥有对应方法签名,无需显式声明
implements。 - 依赖注入:在 main 函数或 wire 中,将具体实现注入到消费方。
3.4 接口与 Mock 的协同
接口契约的最大工程收益之一,是让 Mock 依赖隔离成为可能。以下是一个典型的 Mock 实现模式:
// service/user_repository.go —— 接口定义(消费方)
package service
import "context"
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, u *User) error
}
// service/user_service.go —— 业务逻辑
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
return s.repo.GetByID(ctx, id)
}
// service/mock_user_repository.go —— Mock 实现(测试用)
package service
import "context"
type MockUserRepository struct {
GetByIDFunc func(ctx context.Context, id int64) (*User, error)
SaveFunc func(ctx context.Context, u *User) error
}
func (m *MockUserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
return m.GetByIDFunc(ctx, id)
}
func (m *MockUserRepository) Save(ctx context.Context, u *User) error {
return m.SaveFunc(ctx, u)
}
// service/user_service_test.go —— 单元测试
func TestGetUser(t *testing.T) {
mock := &MockUserRepository{
GetByIDFunc: func(ctx context.Context, id int64) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
svc := NewUserService(mock)
user, err := svc.GetUser(context.Background(), 1)
// 断言 user.Name == "Alice" 且 err == nil
}
工程要点:Mock 实现与接口定义放在同一个包(service),避免循环导入。Mock 结构体使用函数字段(
GetByIDFunc),让每个测试用例可以自定义行为,无需定义多个 Mock 子类。
3.5 接口治理清单
在 API 设计治理中,接口契约需要纳入以下检查项:
| 检查项 | 标准 | 违规示例 |
|---|---|---|
| 接口位置 | 定义在消费方包内 | 定义在实现方包(如 repository/mysql) |
| 方法数量 | ≤ 5 个 | 一个接口包含 10+ 方法 |
| 参数结构 | 使用结构体参数(参数 ≥ 3 时) | func Create(name, email, phone string, age int) error |
| 返回值 | 优先返回 error,业务数据通过返回值 | 通过 out 参数或全局变量传递结果 |
| Mock 可测性 | 每个接口至少有一个 Mock 实现 | 接口无 Mock,测试依赖真实 DB |
| 文档注释 | 每个方法必须有注释说明行为 | 无注释,或注释与实现不符 |
3.6 常见陷阱与避坑
- 陷阱1:接口定义在实现方。导致消费方被迫 import 实现包,破坏依赖方向。
- 陷阱2:接口过于抽象。如
type Repository interface { Execute(ctx context.Context, query string, args ...interface{}) (interface{}, error) },丧失类型安全。 - 陷阱3:在接口中使用具体类型。如
GetByID(ctx context.Context, id int64) (*sql.Row, error),将 SQL 细节泄露到契约中。 - 陷阱4:为每个结构体都定义接口。只有存在多个实现或需要 Mock 时,才引入接口。否则过度设计。
治理红线:禁止在接口方法中使用
interface{} 作为参数或返回值,除非是序列化/反序列化等通用场景。否则将导致运行时类型断言泛滥,丧失编译期类型检查。
3.7 小结
Go interface 是 API 契约设计的核心语法工具。通过将接口定义在消费方、控制粒度、配合 Mock 实现,可以实现:
- 依赖倒置:高层不依赖低层,两者依赖抽象。
- 测试隔离:任意依赖均可替换为 Mock,单测秒级运行。
- 架构演进:替换底层实现(如 MySQL → Redis + MySQL)时,业务层零改动。