Go 接口:小巧、隐式和正确使用
1. 概览
Go 中的接口与 Java 或 C# 的根本区别是:没有 implements 关键字。一个类型只要拥有足够的 method,就自动满足接口。这个特点导致了一种独特的设计方式:接口小巧,声明在使用方,而不是在实现方。
读完这篇文章,你将了解如何以 Go 风格编写接口、如何利用它们进行测试而无需 Mock 库,以及如何避免“nil 但不是 nil”这样的常见错误。
2. 接口隐式满足
让我们从一个向客户发送通知的接口开始:
package main
import "fmt"
type Notifier interface {
Notify(customer, message string) error
}
type EmailNotifier struct {
From string
}
func (e EmailNotifier) Notify(customer, message string) error {
fmt.Printf("[来自 %s 的电子邮件] 给 %s: %s\n", e.From, customer, message)
return nil
}
type SMSNotifier struct{}
func (SMSNotifier) Notify(customer, message string) error {
fmt.Printf("[短信] 给 %s: %s\n", customer, message)
return nil
}
func main() {
notifiers := []Notifier{
EmailNotifier{From: "shop@nonla.dev"},
SMSNotifier{},
}
for _, n := range notifiers {
_ = n.Notify("An", "订单 #42 已确认")
}
}
输出:
[来自 shop@nonla.dev 的电子邮件] 给 An: 订单 #42 已确认
[短信] 给 An: 订单 #42 已确认
在 EmailNotifier 或 SMSNotifier 中的任何地方都不提及 Notifier。编译器自动检查:任何拥有 Notify(string, string) error 方法的类型都可以赋值给 Notifier 类型的变量。
重要的结论是实现方不需要知道接口的存在。你可以为标准库或第三方库中现有的类型声明新接口,而无需修改它们的代码。
3. 保持接口小巧
Go 标准库是最好的例子。io.Reader 和 io.Writer 接口各自只有一个方法:
type Writer interface {
Write(p []byte) (n int, err error)
}
正因为这种简洁性,很多类型满足它:*os.File、*bytes.Buffer、*strings.Builder、http.ResponseWriter、*gzip.Writer……接收 io.Writer 的函数可以用于所有这些类型:
package main
import (
"fmt"
"io"
"os"
"strings"
)
type Order struct {
ID int
Customer string
Total int64
}
func WriteReceipt(w io.Writer, o Order) error {
_, err := fmt.Fprintf(w, "订单 #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
return err
}
func main() {
order := Order{ID: 42, Customer: "An", Total: 350000}
_ = WriteReceipt(os.Stdout, order) // 输出到终端
var sb strings.Builder
_ = WriteReceipt(&sb, order) // 写入内存
fmt.Printf("收据长度:%d 字节\n", sb.Len())
}
订单 #42 - An - 350000 VND
收据长度:26 字节
WriteReceipt 只需要“写字节”的能力,所以它只要求那个。如果要写到文件、HTTP 响应或压缩文件,你无需修改函数。
当需要多个行为时,应该组合小接口而不是从一开始就写一个大接口:
type OrderFinder interface {
FindOrder(id int) (Order, error)
}
type OrderSaver interface {
SaveOrder(o Order) error
}
type OrderStore interface {
OrderFinder
OrderSaver
}
显示订单的函数只接收 OrderFinder。更新状态的函数接收 OrderStore。每个函数都清楚地声明它需要什么,编写伪实现进行测试也简单得多。
4. 在使用方声明接口
在 Java 中,你通常写下 PaymentGateway,然后 StripePaymentGateway implements PaymentGateway,两者并排放在一起。在 Go 中,常见做法相反:实现 package 返回具体的 struct,使用方 package 在真正需要替换时自己声明接口。
假设 payment package 提供一个有很多方法的 client:
// package payment
type Client struct {
apiKey string
}
func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }
func (c *Client) Charge(customer string, amount int64) (string, error) {
return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}
func (c *Client) Refund(txnID string) error { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }
checkout package 只需要扣款,所以声明一个单方法接口:
// package checkout
var ErrInvalidTotal = errors.New("总金额必须大于 0")
type PaymentGateway interface {
Charge(customer string, amount int64) (string, error)
}
type Service struct {
gateway PaymentGateway
}
func NewService(gw PaymentGateway) *Service {
return &Service{gateway: gw}
}
func (s *Service) Checkout(o Order) (string, error) {
if o.Total <= 0 {
return "", ErrInvalidTotal
}
txnID, err := s.gateway.Charge(o.Customer, o.Total)
if err != nil {
return "", fmt.Errorf("订单 #%d 支付:%w", o.ID, err)
}
return txnID, nil
}
在 main 中初始化时:
svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>
*payment.Client 隐式满足 checkout.PaymentGateway。payment package 不会 import checkout,甚至不知道它存在。
这个原则通常总结为**“接收接口,返回 struct”**:函数接收接口以获得灵活性,但返回具体类型,这样调用方可以使用所有方法和字段。
5. 使用伪实现进行测试,无需 Mock 库
使用方的小接口使得测试非常简洁。你只需要编写一个拥有一个方法的 struct:
package checkout
import (
"errors"
"testing"
)
type fakeGateway struct {
charged []int64
err error
}
func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
if f.err != nil {
return "", f.err
}
f.charged = append(f.charged, amount)
return "txn-test", nil
}
func TestCheckout_ChargesOrderTotal(t *testing.T) {
gw := &fakeGateway{}
svc := NewService(gw)
txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})
if err != nil {
t.Fatalf("意外错误:%v", err)
}
if txn != "txn-test" {
t.Errorf("txn = %q,期望 %q", txn, "txn-test")
}
if len(gw.charged) != 1 || gw.charged[0] != 200000 {
t.Errorf("charged = %v,期望 [200000]", gw.charged)
}
}
func TestCheckout_RejectsZeroTotal(t *testing.T) {
gw := &fakeGateway{}
svc := NewService(gw)
_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})
if !errors.Is(err, ErrInvalidTotal) {
t.Fatalf("err = %v,期望 ErrInvalidTotal", err)
}
if len(gw.charged) != 0 {
t.Errorf("不应该调用 Charge,但 charged = %v", gw.charged)
}
}
func TestCheckout_WrapsGatewayError(t *testing.T) {
gwErr := errors.New("卡被拒绝")
svc := NewService(&fakeGateway{err: gwErr})
_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})
if !errors.Is(err, gwErr) {
t.Fatalf("err = %v,期望包装 %v", err, gwErr)
}
}
运行测试:
go test ./checkout -v
=== RUN TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS
如果 PaymentGateway 有十个方法,伪实现也必须实现全部十个。这是最清晰的信号,说明接口太大了。
6. 类型断言、类型开关和编译期检查
6.1. 通过类型断言实现可选接口
有时你想使用额外的功能,如果具体类型支持的话。Go 使用两值类型断言:
type Refunder interface {
Refund(txnID string) error
}
func (s *Service) Cancel(txnID string) error {
r, ok := s.gateway.(Refunder)
if !ok {
return errors.New("支付网关不支持退款")
}
return r.Refund(txnID)
}
使用 *payment.Client 时,ok 为 true,因为它有 Refund 方法。使用 fakeGateway 时,ok 为 false,函数返回错误而不是 panic。标准库频繁使用这种技术,例如 io.Copy 检查参数是否为 io.WriterTo 来选择更快的路径。
注意: 单值形式
r := s.gateway.(Refunder)会在类型不匹配时 panic。除非你绝对确定,否则始终使用r, ok := …形式。
6.2. 类型开关
当需要处理多个不同的类型时,使用类型开关:
func describe(n Notifier) string {
switch v := n.(type) {
case EmailNotifier:
return "来自 " + v.From
case SMSNotifier:
return "短信"
default:
return fmt.Sprintf("未知:%T", v)
}
}
如果你发现在许多地方对业务类型编写类型开关,可能说明接口中缺少一个方法。
6.3. 在编译期确保满足接口
由于满足是隐式的,如果有人改变了方法签名,错误只会在赋值处显示。为了尽早在实现文件中捕获错误,添加以下行:
var _ checkout.PaymentGateway = (*payment.Client)(nil)
这行在运行时不会产生任何值。它只是强制编译器检查 *payment.Client 是否满足 PaymentGateway。仅当实现 package 真正想承诺该接口时才使用。否则,它会创建一个不必要的反向导入。
7. 常见错误
7.1. 包含 nil 指针的接口不是 nil
这是 Go 最经典的错误:
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string {
return "无效字段:" + e.Field
}
func validate(o Order) error {
var verr *ValidationError
if o.Total <= 0 {
verr = &ValidationError{Field: "Total"}
}
return verr // 错误
}
func main() {
err := validate(Order{Total: 100})
fmt.Println(err == nil)
}
false
接口值由两部分组成:类型和值。接口仅在两者都为空时才等于 nil。这里类型是 *ValidationError,而值是 nil 指针,所以 err != nil。修复方法是显式返回 nil:
func validate(o Order) error {
if o.Total <= 0 {
return &ValidationError{Field: "Total"}
}
return nil
}
7.2. 指针接收者和方法集
type AuditNotifier struct{ count int }
func (a *AuditNotifier) Notify(customer, message string) error {
a.count++
return nil
}
var n Notifier = AuditNotifier{} // 编译错误
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)
具有指针接收者的方法只属于 *AuditNotifier 的方法集。你需要赋值 &AuditNotifier{}。相反,具有值接收者的方法对值和指针都有效。
7.3. 在需要之前编写接口
从 Java 习惯来的代码往往看起来像这样:
type OrderService interface {
Create(o Order) error
Cancel(id int) error
Find(id int) (Order, error)
// … 再加 10 个方法
}
type orderServiceImpl struct{ /* … */ }
func NewOrderService() OrderService { return &orderServiceImpl{} }
这种方式有三个问题。接口只有一个实现。它在实现方而不是使用方。Constructor 返回接口,所以调用方失去了访问具体类型的机会。应该返回 *OrderService struct,让每个使用方 package 在真正需要替换时自己声明小接口,比如为了测试。
7.4. 滥用 any
any(interface{} 的别名)接受所有类型,所以编译器无法帮助。从 Go 1.18 起,如果需要函数在多种类型上工作,应该先考虑泛型:
func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
var sum int64
for _, it := range items {
sum += it.GetTotal()
}
return sum
}
仅在数据类型真正提前不知道时使用 any,例如解析任意 JSON。
8. 总结
Go 中的接口强大之处正在于它的简洁。要记住的关键点:
- 类型隐式满足接口,无需
implements。 - 接口应该小巧,理想情况下一个或两个方法。当需要更多时,组合小接口。
- 在使用方声明接口。函数接收接口,构造函数返回 struct。
- 小接口使得用手写的伪实现进行测试,无需 Mock 库。
- 小心包含 nil 指针的接口,以及指针接收者的方法集。
下次定义接口时,问问自己:“谁使用它,他们真正需要哪些方法?“答案通常比你想象的要少。