Go 组合式接口设计

用小接口与接口嵌入组合能力:标准库 io 模式、接口隔离、以及何时拆分/合并接口。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 组合式接口设计

这个概念为什么会出现

大而全的接口会同时伤害三方:

  1. 实现者:必须实现用不到的方法,或塞空实现/panic
  2. 调用者:依赖面变宽,被迫感知无关能力
  3. 测试:替身臃肿,mock 生成物爆炸

Go 标准库示范了另一条路——以 io 包为经典:

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}

type ReadWriter interface {
	Reader
	Writer
}

type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

小接口 + 接口嵌入组合让能力像乐高:需要读就依赖 Reader,需要读写关就依赖 ReadWriteCloser。实现类型只要方法集满足,即可隐式满足接口(structural typing)。

[!abstract] 一句话理解 先定义最小行为面,再用接口嵌入组合成更大契约;调用方依赖尽可能小的接口,实现方靠方法集自动满足。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:计量上传包体大小,只依赖“能读”

网关在把请求体转存对象存储前,想先数一数 body 有多少字节
(限额、计费、拒绝超大上传)。
真实来源可能是 *http.Request.Body、文件、或测试里的字符串。

若函数签名写成依赖“又能读又能写又能关”的大接口,
测试替身和调用方都会被迫实现用不到的方法。
正确做法:只依赖 io.Reader——最小行为面

package main

import (
	"fmt"
	"io"
	"strings"
)

// countUploadBytes 模拟“上传限额前先量 body 大小”。
//
// 业务意图:只需要顺序读字节,不需要 Write,也不在这里 Close
//(Close 归属调用方 / 外层 defer,避免双重关闭)。
//
// 教学点:参数类型是小接口 io.Reader;任何带 Read 的值都能传入。
func countUploadBytes(r io.Reader) (int, error) {
	buf := make([]byte, 32)
	total := 0
	for {
		n, err := r.Read(buf)
		// 先累加 n:Read 在返回 err 时仍可能读到了部分字节。
		total += n
		if err == io.EOF {
			// 读完:EOF 对“计量”是正常结束,不是失败。
			return total, nil
		}
		if err != nil {
			return total, err
		}
	}
}

func main() {
	// 测试/演示:用内存里的包体代替真实 HTTP Body。
	body := strings.NewReader("hello")
	n, err := countUploadBytes(body)
	// 期望:5 <nil> —— "hello" 五个字节,无错误。
	fmt.Println(n, err)
}

建议运行:

go run .

期望输出:

5 <nil>

结合场景再看三个关注点

  1. 调用方依赖尽可能小的接口
    计量只需 Reader,不要写成 ReadWriteCloser

  2. 实现方靠方法集自动满足
    strings.Reader*os.File、网络 conn 都能当上传源,无需 implements

  3. 组合发生在接口定义侧
    标准库用嵌入拼出 ReadCloser 等;业务函数按需挑选契约,而不是反过来强迫大接口。

核心概念与准确模型

1. 结构类型 vs 名义类型

Go 接口满足是结构化的:只要方法集包含接口所需方法(名称与签名匹配),无需 implements 关键字。

这使组合成为自然结果:

  • 一个 *os.File 同时满足 ReaderWriterCloser
  • 调用方按需选择看到的面

方法集规则(值/指针接收者)见 go-method-sets-and-receivers

2. 接口嵌入 = 组合契约

type ReadCloser interface {
	Reader
	Closer
}

嵌入后的接口方法集是并集。这与结构体嵌入相关但不同:

接口嵌入结构体嵌入
目的组合要求组合实现/提升
运行时字段有内嵌字段
典型包io业务 struct

go-embedding-and-composition

3. 接口隔离原则在 Go 中的落点

“不应强迫依赖不使用的方法”直接翻译为:

// 只要读
func ingest(r io.Reader) error

// 要读写
func pipe(rw io.ReadWriter) error

// 要负责关闭
func consume(rc io.ReadCloser) error

而不是:

// 反例:一切都要 File 全能接口
func ingest(f FileAPI) error

4. 标准库组合图谱(io)

Reader ──┐
         ├── ReadWriter ──┐
Writer ──┘                ├── ReadWriteCloser
Closer ───────────────────┘

还有:

  • ReaderAt / WriterAt:随机访问
  • Seeker:位移
  • ByteReaderRuneReader:更窄面
  • ReadFrom / WriteTo:可选优化方法(io.Copy 会探测)

io.Copy 的设计体现组合智慧:优先看是否实现 WriterTo/ReaderFrom,否则退回 Read/Write 循环。文档:io

5. 消费方定义接口(Accept interfaces)

业务代码更常见的模式:

package checkout

type Inventory interface {
	Stock(ctx context.Context, sku string) (int, error)
}

type Service struct{ inv Inventory }
  • 接口定义在使用方包
  • 实现可以在 postgresmemory、测试 stub
  • 实现包不必 import 使用方(避免环)

这与“提供方导出巨大 IRepository”的 Java 味设计相反。见 go-api-design-and-conventionsgo-test-doubles

6. 何时拆、何时合

拆分信号:

  1. 许多实现只能 panic/空实现部分方法
  2. 测试替身必须填一堆无用方法
  3. 函数其实只用到 1 个方法,却要求 10 个方法的接口
  4. 接口变更原因不唯一(ISP 违反)

合并/保留大接口信号:

  1. 方法总是一起用,拆开反而不真实
  2. 跨包稳定协议(如 driver.Driver)且实现者可控
  3. 拆分后出现大量“参数里塞 3 个小接口”且总是同一实现

经验:先小后大——需要时再嵌入组合,不要先设计上帝接口再删方法。

7. 单方法接口的命名习惯

模式例子
方法名 + erReaderWriterStringer
行为名词InventoryClock
函数类型适配http.HandlerFunc
type Stringer interface {
	String() string
}

fmt.StringerCode Review Comments

8. 适配器:用小函数满足小接口

标准库大量使用适配器,把函数或已有类型贴到接口上:

// http.HandlerFunc 适配 func 到 Handler
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}
// io.Reader 适配
r := strings.NewReader("x")

自定义:

type ReaderFunc func(p []byte) (int, error)

func (f ReaderFunc) Read(p []byte) (int, error) { return f(p) }

这是组合式设计的润滑剂:不必为每个小行为新建类层次。

9. 可选行为与类型断言

有时基本接口小,增强能力可选:

if wt, ok := src.(io.WriterTo); ok {
	return wt.WriteTo(dst)
}

或 type switch。这保持核心接口稳定,同时允许优化路径。代价是调用方复杂度上升——应限于基础设施级(如 io.Copy),业务代码慎用“隐藏能力探测”。

go-type-assertions-and-type-switch

10. 空接口与“假组合”

anyinterface{})不是组合设计,而是放弃静态约定。组合式接口追求的是:

  • 足够小
  • 仍然表达真实行为
  • 编译期检查方法存在

把一切收成 any 再断言,是另一条路(序列化边界、容器),不要与小接口组合混谈。

11. 接口值与组合的运行时图像

接口值有动态类型与动态值。组合接口不改变这一模型:一个 io.ReadCloser 变量仍是一个接口值,只是方法集更大。nil 相关陷阱见 go-interface-values-and-nilgo-nil-forms

12. 与嵌入结构体协作

type loggedReader struct {
	io.Reader // 嵌入字段,提升 Read
	name string
}

func (l loggedReader) Read(p []byte) (int, error) {
	n, err := l.Reader.Read(p)
	// log l.name, n, err
	return n, err
}

装饰器模式在 Go 里非常自然:包一个 Reader 还是 Readerio.LimitReaderbufio.Reader 皆是此路。

边界与限制

  1. 过小接口碎片化:三个参数都是单方法接口,可读性可能下降;适当组合。
  2. 接口在提供方过早导出:会冻结错误抽象,推动巨型 mock。
  3. 方法集不匹配的隐性失败:指针接收者方法不能通过值类型赋给接口——编译期抓住,但初学者困惑。
  4. 接口比较与 nil:组合不解决 typed nil 问题。
  5. 版本演进:往接口加方法是破坏性变更;小接口 + 新接口嵌入更易演进。
  6. 跨模块稳定 API:公开库的接口改动成本极高;比应用内接口更保守。
  7. 不是继承替代品:组合接口不带来多态实现复用;复用靠函数与结构体嵌入。
  8. 标准库也有“较大”接口:如 net/http.ResponseWriter 仍相对聚焦;全面性要看领域。

常见误区

[!warning] 误区 1:先画大接口再让所有服务实现 应先写具体类型与使用代码,抽出真实需要的方法。

[!warning] 误区 2:每个包都导出 IXxx 接口 多余。消费方需要抽象时再定义。

[!warning] 误区 3:接口越多越 OOP、越专业 无必要接口增加间接层与导航成本。

[!warning] 误区 4:用空实现填不满的方法 那是接口过大的味道,应拆分。

[!warning] 误区 5:把接口嵌入当成多重继承解决状态复用 接口嵌入只组合契约;状态复用是结构体嵌入/委托。

[!warning] 误区 6:业务到处 type assert 找可选方法 优化探测留给底层库;业务偏好显式依赖。

[!warning] 误区 7:为测试生成 30 方法 mock 不反思设计 优先切小接口。

[!warning] 误区 8:认为小接口不能表达复杂领域 复杂领域用多个小接口 + 结构体协作,而不是一个上帝接口。

工程实践

设计步骤(务实)

  1. 先写具体实现与调用代码
  2. 找测试痛点或替换点
  3. 抽出 1~3 个方法的接口
  4. 多处需要同一组合时,再定义嵌入型组合接口
  5. 公开库再额外审视稳定性

API 布局示例

// service 使用方
type Mailer interface {
	Send(ctx context.Context, to, body string) error
}

// smtp 实现方
type Client struct{ /* ... */ }
func (c *Client) Send(ctx context.Context, to, body string) error

无需 type Mailer interface 出现在 smtp 包。

标准库阅读作业

精读并画图:

与错误、上下文

现代小接口几乎总是:

type Loader interface {
	Load(ctx context.Context, id string) (Item, error)
}

context 作首参、error 作末返回,是组合之外的正交约定。见 go-contextgo-error-handling

演进策略:加能力不破坏旧接口

type Reader interface { Read([]byte) (int, error) }

// 新增
type ReaderAt interface {
	ReadAt(p []byte, off int64) (n int, err error)
}

// 需要两者时
type ReaderReaderAt interface {
	Reader
	ReaderAt
}

旧代码继续只依赖 Reader

实验

实验 A:依赖面最小化

写两个函数:

  1. Read
  2. 需要 Read+Close

尝试传入 *os.Filestrings.NewReader:后者不能传入需要 Close 的函数——体会编译期隔离。

实验 B:装饰器

实现 type upperWriter struct{ io.Writer },在 Write 里转换大小写再委托。接到 os.Stdout 验证。

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"strings"
)

type upper struct{ w io.Writer }

func (u upper) Write(p []byte) (int, error) {
	return u.w.Write([]byte(strings.ToUpper(string(p))))
}

func main() {
	var buf bytes.Buffer
	_, _ = io.WriteString(upper{&buf}, "go")
	fmt.Println(buf.String())
	_, _ = io.WriteString(upper{os.Stdout}, "ok\n")
}

实验 C:接口过大的痛

定义 8 方法接口,为测试写 stub;再拆成 2 个小接口重写 stub。对比代码量与可读性。

实验 D:io.Copy 优化路径

实现带 WriteTo 的类型,观察 io.Copy 是否走快速路径(可用计数探针)。

总结

问题答案
组合式接口是什么?小契约 + 嵌入拼装大契约
为何有效?隐式满足 + 调用方选最小面
接口写在哪?优先使用方
标准库范本?ioio/fshttp.Handler
拆分信号?空实现、巨型 mock、方法变更原因分裂
与 any?any 放弃约定;小接口保留编译期检查

一句话收束:

依赖你真正调用的方法;用嵌入组合更大的面,而不是先造上帝接口。

自测题

1. 为什么 countBytes(io.Reader)countBytes(*os.File) 更可测?

2. 接口嵌入会生成运行时“父对象”吗?

3. 提供方导出 15 方法 Repository 对测试有何影响?

4. 如何在不破坏旧 Reader 用户的情况下增加随机读能力?

5. http.HandlerFunc 展示了什么技巧?

6. 方法用值接收者 vs 指针接收者,对接口赋值有何影响?

答案
  1. 任何实现 Read 的类型可传入,包括内存与 stub,不绑文件系统。
  2. 不会;只是方法集的编译期组合。
  3. 替身必须实现大量方法,测试噪音与脆性上升。
  4. 新增 ReaderAt 或组合接口,旧代码继续用 Reader
  5. 函数适配器:让普通函数满足接口。
  6. 指针接收者方法不在 T 的方法集中,仅在 *T;值接收者两者都有(规则详见方法集笔记)。

依据与延伸

创建于 2026/7/14 更新于 2026/7/15