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 指针的接口,以及指针接收者的方法集。

下次定义接口时,问问自己:“谁使用它,他们真正需要哪些方法?“答案通常比你想象的要少。

golanginterface设计测试