【go语言规范】关于接口设计
抽象应该被发现,而不是被创造。为了避免不必要的复杂性,需要时才创建接口,而不是预见到需要它,或者至少可以证明这种抽象是有价值的。
“The bigger the interface, the weaker the abstraction.
不要用接口进行设计,要发现他们
——rob pike
作为一个常用java的程序员,在创建具体类型之前创建接口是很自然的,但是go不应该这样工作。
创建接口是为了创建抽象。当编程时遇到抽象,主要的注意事项时记住应该发现抽象,而不是创建抽象。
这意味着如果没有直接的理由,不应该在代码中创建抽象。
我们不应该用接口来设计,而应该等待具体的需求。
应该在需要的时候创建一个接口,而不是预见到可能需要他的时候。
如果没有充足的理由添加接口,也不清楚接口如何使代码变得更好,那么我们应该质疑这个接口的目的。为什么不直接调用实现呢?
哲学: 不要试图抽象地解决问题,而适应解决现在必须解决的问题。
如果不清楚接口如何是代码变得更好,我们应该考虑删除它,以使代码更简单。
哲学的哲学:不要抽象,干实事。
Go 语言接口设计原则
-
接口要小而精准
单一职责原则
// 不推荐 - 接口过大
type Storage interface {
Save(data interface{}) error
Delete(id string) error
Update(data interface{}) error
Get(id string) interface{}
List() []interface{}
}
// 推荐 - 拆分成小接口
type Writer interface {
Save(data interface{}) error
}
type Reader interface {
Get(id string) interface{}
} -
以使用者为中心
接口应该在使用处定义
type CustomerService struct {
// 在需要的地方定义接口
store customerStorer
}
// 最小化接口定义
type customerStorer interface {
Store(Customer) error
} -
组合优于继承
通过小接口组合
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// 组合接口
type ReadWriter interface {
Reader
Writer
} -
遵循 Go 的惯例
使用动词+er命名
type Stringer interface {
String() string
}
type Handler interface {
Handle(msg Message) error
} -
接口隔离原则
客户端不应依赖它不使用的方法
// 不推荐
type FileProcessor interface {
Open(name string) error
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
}
// 推荐
type FileReader interface {
Read(b []byte) (n int, err error)
}
type FileWriter interface {
Write(b []byte) (n int, err error)
} -
最佳实践
-
保持接口小巧
// 好的实践
type Logger interface {
Log(message string) error
}
// 而不是
type Logger interface {
Log(message string) error
Logf(format string, args …interface{}) error
Debug(message string)
Error(message string)
// … 更多方法
} -
使用抽象接口
// 具体的实现
type Customer struct {
ID string
Name string
}
// 抽象的接口
type CustomerFinder interface {
FindCustomer(id string) (*Customer, error)
} -
避免接口污染
// 不推荐
type Config interface {
// 过多的方法导致实现困难
Database() string
APIKey() string
Timeout() time.Duration
MaxRetries() int
CacheSize() int
// …更多配置项
}
// 推荐
type DatabaseConfig interface {
Database() string
}
type APIConfig interface {
APIKey() string
} -
注意事项
显式优于隐式
Go 的接口是隐式实现的
但应该在文档中明确说明接口的期望行为
面向接口编程
依赖接口而不是具体实现
便于测试和模拟
避免过度设计
不要为了接口而接口
从实际需求出发设计接口
注意性能影响
接口调用会有少量性能开销
在性能关键路径上要谨慎使用
以上原则可以帮助我们设计出更好的 Go 接口,使代码更加灵活、可维护和可测试。
什么是接口污染?我们如何避免接口污染?
接口污染及其避免方法
接口污染(Interface Pollution)是指创建了不必要的或过大的接口,导致代码复杂性增加和维护困难的情况。
接口污染的表现形式
-
过大的接口
// 不好的示例 - 接口污染
type Storage interface {
CreateCustomer(Customer) error
UpdateCustomer(Customer) error
DeleteCustomer(string) error
GetCustomer(string) (Customer, error)
ListCustomers() ([]Customer, error)
CreateOrder(Order) error
UpdateOrder(Order) error
DeleteOrder(string) error
// … 更多方法
} -
不必要的抽象
// 不好的示例 - 过度抽象
type StringFormatter interface {
Format(string) string
}
// 这种简单操作不需要接口
func ProcessString(formatter StringFormatter, s string) string {
return formatter.Format(s)
}
如何避免接口污染
-
遵循接口隔离原则
// 好的示例 - 小而专注的接口
type CustomerReader interface {
GetCustomer(id string) (Customer, error)
}
type CustomerWriter interface {
CreateCustomer(Customer) error
UpdateCustomer(Customer) error
}
// 如果需要同时读写,可以组合接口
type CustomerStorage interface {
CustomerReader
CustomerWriter
} -
在使用处定义接口
// 好的示例 - 接口在使用处定义
type CustomerService struct {
// 只定义需要的方法
store customerStorer
}
type customerStorer interface {
StoreCustomer(Customer) error
} -
保持接口小巧精简
// 好的示例 - 最小化接口
type Logger interface {
Log(message string) error
}
// 具体实现可以有更多方法
type FileLogger struct {
path string
}
func (f *FileLogger) Log(message string) error {
// 实现日志记录
return nil
}
func (f *FileLogger) Close() error {
// 额外的方法
return nil
} -
根据实际需求设计
// 好的示例 - 基于实际需求的接口
type PaymentProcessor interface {
Process(payment Payment) error
}
// 不同的支付方式实现这个接口
type StripePayment struct{}
type PayPalPayment struct{}
接口设计最佳实践
单一职责
每个接口应该只表达一个清晰的概念
避免创建"万能"接口
显式依赖
明确声明所需的依赖
避免隐式依赖
基于需求驱动
等到真正需要时才创建接口
避免过度设计
持续重构
定期审查接口设计
及时拆分过大的接口
示例:重构污染的接口
// 原始污染的接口
type UserService interface {
CreateUser(User) error
UpdateUser(User) error
DeleteUser(string) error
GetUser(string) (User, error)
AuthenticateUser(string, string) bool
ResetPassword(string) error
}
// 重构后的小接口
type UserCreator interface {
CreateUser(User) error
}
type UserUpdater interface {
UpdateUser(User) error
}
type UserAuthenticator interface {
AuthenticateUser(string, string) bool
}
// 根据实际需求组合接口
type UserManager struct {
creator UserCreator
auth UserAuthenticator
}
通过以上方法,我们可以有效避免接口污染,使代码更加清晰、可维护和可测试。
我们不应该在生产端创建接口,同时创建具体的实现。这是c#和 Java的习惯 。
由于应该发现抽象而不是创建抽象,这意味着生产者不能强制所有客户端使用给定的抽象,相反,有客户来决定他是否需要某种形式的抽象,然后为他的需求确定最佳的抽象级别。
在 Go 中平衡抽象与具体实现
- 遵循 YAGNI 原则(You Ain’t Gonna Need It)
不好的做法
// 过度抽象的接口
type CustomerStorage interface {
StoreCustomer(customer Customer) error
GetCustomer(id string) (Customer, error)
UpdateCustomer(customer Customer) error
DeleteCustomer(id string) error
GetAllCustomers() ([]Customer, error)
GetCustomersWithoutContract() ([]Customer, error)
GetCustomersWithNegativeBalance() ([]Customer, error)
// 可能用不到的方法…
}
好的做法
// 根据实际需求定义小接口
type CustomerReader interface {
GetCustomer(id string) (Customer, error)
}
type CustomerWriter interface {
StoreCustomer(customer Customer) error
}
-
使用组合而不是继承
// 基础接口
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// 按需组合
type ReadWriter interface {
Reader
Writer
} -
在使用处定义接口
type CustomerService struct {
// 只定义服务真正需要的方法
store interface {
GetCustomer(id string) (Customer, error)
StoreCustomer(Customer) error
}
} -
使用适配器模式保持代码整洁
// 具体实现
type PostgresDB struct {
// …
}
// 适配器
type CustomerStorageAdapter struct {
db *PostgresDB
}
func (a *CustomerStorageAdapter) GetCustomer(id string) (Customer, error) {
// 转换调用
return a.db.FindCustomerByID(id)
} -
分层设计
// 领域模型
type Customer struct {
ID string
Name string
Balance float64
}
// 仓储接口
type CustomerRepository interface {
Find(id string) (*Customer, error)
Save(customer *Customer) error
}
// 服务层
type CustomerService struct {
repo CustomerRepository
} -
关键设计原则
按需抽象
先写具体实现
当需要多个实现时再抽象
保持简单
// 简单直接的接口
type Printer interface {
Print(content string) error
}
面向行为
// 描述行为而不是状态
type PaymentProcessor interface {
Process(payment Payment) error
}
- 代码整洁性的保持
明确的职责划分
// 清晰的包结构
project/
├── domain/ // 领域模型
├── repository/ // 数据访问
├── service/ // 业务逻辑
└── api/ // 接口层
统一的错误处理
// 定义领域错误
type NotFoundError struct {
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("customer %s not found", e.ID)
}
良好的命名约定
type CustomerFinder interface {
// 动词+名词的方法命名
FindByID(id string) (*Customer, error)
}
总结:
从具体实现开始
当有多个实现需求时再抽象
保持接口小而精确
使用组合来构建复杂接口
在实际使用处定义接口
通过清晰的项目结构保持代码整洁
这样可以在不过度抽象的同时,保持代码的可读性和可维护性。
在 Go 语言中,这确实是一个值得讨论的设计问题。一般来说,在生产者端定义接口确实不是最佳实践。让我解释原因:
不好的设计(当前代码)
package store
// 在生产者包中定义大而全的接口
type CustomerStorage interface {
StoreCustomer(customer Customer) error
GetCustomer(id string) (Customer, error)
UpdateCustomer(customer Customer) error
GetAllCustomers() ([]Customer, error)
GetCustomersWithoutContract() ([]Customer, error)
GetCustomersWithNegativeBalance() ([]Customer, error)
}
更好的设计方式
在消费者端定义接口
package service
// 只定义服务需要的方法
type customerStore interface {
GetCustomer(id string) (Customer, error)
StoreCustomer(Customer) error
}
type CustomerService struct {
store customerStore
}
生产者端只提供具体实现
package store
type PostgresStore struct {
// … 具体实现细节
}
func (p *PostgresStore) GetCustomer(id string) (Customer, error) {
// 实现获取客户的逻辑
return Customer{}, nil
}
func (p *PostgresStore) StoreCustomer(c Customer) error {
// 实现存储客户的逻辑
return nil
}
为什么这样更好?
遵循接口隔离原则
消费者只需定义它们真正需要的方法
避免依赖不需要的方法
更容易维护
接口变更只影响实际使用该接口的消费者
生产者可以自由添加新方法而不影响现有消费者
更好的解耦
消费者和生产者之间的耦合更松散
更容易进行单元测试和模拟
符合 Go 的设计哲学
接口应该是小而精确的
接口应该由使用者定义
重构建议
删除生产者端的大接口
在各个消费者包中定义所需的小接口
生产者只提供具体实现
例如:
package service
type orderStore interface {
// 只定义订单服务需要的方法
GetCustomer(id string) (Customer, error)
}
// filepath: service/billing.go
package service
type billingStore interface {
// 只定义账单服务需要的方法
GetCustomersWithNegativeBalance() ([]Customer, error)
}
这种方式让代码更灵活、更易于维护,也更符合 Go 语言的设计理念。
找到具有 1 个许可证类型的类似代码
package client
import “github.com/teivah/100-go-mistakes/src/02-code-project-organization/6-interface-producer/store”
type customersGetter interface {
GetAllCustomers() ([]store.Customer, error)
}
当前的依赖结构
graph LR
client --> store[store package]
client --> customersGetter[customersGetter interface]
- 表面的循环依赖
当前代码中:
package client
import "github.com/teivah/100-go-mistakes/src/02-code-project-organization/6-interface-producer/store"
type customersGetter interface {
GetAllCustomers() ([]store.Customer, error)
}
这里看似存在循环依赖:
client 包引用了 store 包来使用 store.Customer 类型
customersGetter 接口定义在 client 包中
似乎 store 包需要实现 client 包中的接口
2. 实际上不存在循环依赖
实际上这并不是真正的循环依赖,因为:
store 包不需要导入 client 包
store 包不需要显式声明它实现了 customersGetter 接口
Go 的接口是隐式实现的
3. 更好的设计方案
package client
// 1. 在 client 包中定义自己的 Customer 类型
type Customer struct {
// 客户端需要的字段
}
type customersGetter interface {
GetAllCustomers() ([]Customer, error)
}
// 2. 使用适配器模式
type storeAdapter struct {
store store.CustomerStorage
}
func (sa *storeAdapter) GetAllCustomers() ([]Customer, error) {
storeCustomers, err := sa.store.GetAllCustomers()
if err != nil {
return nil, err
}
// 转换 store.Customer 到 client.Customer
return convertCustomers(storeCustomers), nil
}
改进建议
定义自己的类型
每个包应该定义自己需要的类型
避免直接依赖其他包的具体类型
使用适配器
在边界处转换类型
减少包之间的直接依赖
保持接口小巧
只定义真正需要的方法
避免不必要的依赖
依赖注入
使用依赖注入来管理依赖关系
提高代码的可测试性
这种方式可以让代码更加清晰和模块化,同时避免包之间的紧耦合。
client 包可以为其需求定义最精准的抽象。它与接口隔离原则(soild)中的i 概念有关。该原则指出,任何客户端都不应该被破译来他不使用的方法。因此,这种情况下,最好的方法是在生产者端公开具体的实现,并让客户端决定如何使用它以及是否需要抽象。
函数返回签名。
为什么很多情况下返回一个接口被认为是一个糟糕的做法?
如果函数返回接口而不是结构体,将会产生依赖关系。
如果考虑两个包:
client包:包含一个store 接口
store包: 包含store 的实现
在store包中,我们定义了一个实现store 接口的 inmemorystore 结构体,同时创建了一个
原则:
返回结构体而不是接口
尽可能地接收接口。
在大多数情况下,我们不应返回接口,而是应返回具体的实现。否则,由于包的依赖关系,它会使我们的设计更加复杂,并且会限制灵活性,因为所有客户端都必须依赖于相同的抽象。
同样,如果知道(而不是预见到)一个抽象将对客户端有帮助,那么可以考虑返回一个接口,否则不应该强制抽象,这些抽象应该由客户端发现。
eg. 打破规则的例子:
io包检查标准库:
LimitReader
Go 1.18 引入的泛型(Type Parameters)确实改变了某些编程模式,但并未颠覆 Rob Pike 提出的接口设计原则(如“接口应小而专注”)。以下从技术角度详细分析这一变化:
一、泛型如何减少对 interface{}
和反射的依赖
1. 替代 interface{}
的场景
泛型前(Go 1.17 及更早):
需用 interface{}
实现通用容器或算法,例如一个通用栈:
type Stack struct {
data []interface{}
}
func (s *Stack) Push(v interface{}) { s.data = append(s.data, v) }
func (s *Stack) Pop() interface{} { /* ... */ }
问题:
- 类型不安全(需运行时类型断言)
- 性能损失(
interface{}
涉及动态内存分配)
泛型后(Go 1.18+):
直接通过类型参数约束:
type Stack[T any] struct {
data []T
}
func (s *Stack[T]) Push(v T) { s.data = append(s.data, v) }
func (s *Stack[T]) Pop() T { /* ... */ }
优势:
- 编译时类型安全(无需类型断言)
- 性能优化(避免
interface{}
的装箱拆箱)
2. 减少反射的使用
泛型前:
需用反射实现通用逻辑,例如一个合并两个 map
的函数:
func MergeMaps(a, b interface{}) interface{} {
va := reflect.ValueOf(a)
vb := reflect.ValueOf(b)
// 反射检查类型、合并键值对...
}
问题:
- 代码复杂度高
- 运行时错误风险
泛型后:
通过类型参数直接约束 map
类型:
func MergeMaps[K comparable, V any](a, b map[K]V) map[K]V {
merged := make(map[K]V)
for k, v := range a { merged[k] = v }
for k, v := range b { merged[k] = v }
return merged
}
优势:
- 代码简洁且类型安全
- 无需反射即可实现通用逻辑
二、为何未颠覆接口设计原则?
1. 接口的核心作用未变
泛型解决的是数据类型的通用性问题,而接口定义的是行为的抽象。二者是互补关系:
- 接口:定义“能做什么”(如
io.Reader
的Read()
方法)。 - 泛型:定义“处理什么类型”(如
Stack[T]
中的T
)。
示例:
// 接口定义行为(未依赖泛型)
type Processor interface {
Process(data []byte) error
}
// 泛型定义数据类型
type Pipeline[T Processor] struct {
processors []T
}
接口仍保持小而专注,泛型仅约束 Pipeline
处理的具体类型。
2. 小接口组合的实践仍被推崇
即使使用泛型,Go 社区仍鼓励通过小接口组合实现功能。例如:
// 小接口定义
type Cloner[T any] interface {
Clone() T
}
// 泛型函数利用接口
func Duplicate[T Cloner[T]](original T) T {
return original.Clone()
}
此处 Cloner
接口仅包含一个方法,符合“接口应小”的原则,泛型仅用于约束 T
必须实现该行为。
3. 泛型无法替代接口的多态性
泛型在编译时确定具体类型,而接口在运行时实现动态分发。二者适用于不同场景:
- 泛型:适用于算法和容器(如排序、链表)。
- 接口:适用于插件化架构或运行时多态(如不同数据库驱动实现同一接口)。
示例:
// 接口实现运行时多态
type Driver interface {
Connect() error
}
var drivers = map[string]Driver{
"mysql": &MySQLDriver{},
"postgres": &PostgresDriver{},
}
// 泛型实现编译时类型安全
func Query[T any](db *DB, sql string) ([]T, error) { /* ... */ }
三、实际场景中的协同作用
1. 标准库的实践
Go 标准库在引入泛型后,依然遵循接口设计原则:
slices
包:泛型函数(如Sort
)操作切片,但依赖元素的Less
方法(通过接口约束)。maps
包:泛型函数处理map
,但键仍需满足comparable
约束(本质是内置接口)。
2. 减少反射的滥用
泛型显著减少了 JSON 解析、ORM 等场景对反射的依赖:
// 泛型替代反射解析 JSON
func ParseJSON[T any](data []byte) (T, error) {
var result T
if err := json.Unmarshal(data, &result); err != nil {
return zero(T), err
}
return result, nil
}
四、未完全替代的场景
1. interface{}
的剩余用途
以下场景仍需 interface{}
:
- 处理未知类型:如
fmt.Println
的参数。 - 动态数据模型:如解析任意结构的 JSON(结合
map[string]interface{}
)。
2. 反射的必要性
反射在以下场景仍不可替代:
- 结构体标签解析:如
json:"field"
。 - 动态调用方法:如根据字符串名称调用函数。
总结
泛型的引入优化了 Go 的类型系统,减少了 interface{}
和反射的滥用,但并未改变接口设计的核心原则:
- 泛型:解决“数据类型通用性”问题,提升类型安全和性能。
- 接口:解决“行为抽象”问题,保持代码灵活性和可扩展性。
二者共同推动 Go 向“静态类型安全 + 动态行为抽象”的平衡发展,而非相互替代。开发者应结合场景选择工具:
- 优先泛型:处理通用算法和容器。
- 优先接口:定义组件交互协议。
- 慎用反射:仅在必要时(如框架开发)使用。