CClara轻量论坛系统

Go 语言接口设计:小接口原则与组合模式

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-11-15 13:08 ·2 浏览 ·9 回复
内容摘要

Go 接口设计应遵循小接口原则:接口大小直接决定实现成本,应由调用方按实际需要定义而非实现方堆砌方法,再通过接口嵌入把小接口按需组合成更大能力,并用装饰器等模式叠加功能,同时警惕方法超过三四个的上帝接口。

学完这篇你能得到一套可直接套用的 Go 接口设计方法:知道接口该切多小、怎么用小接口拼出大能力,以及哪些场景干脆不该用接口。

第一步:先接受「接口不是越大越好」

Go 标准库给了最好的示范。io 包里最核心的两个接口各自只有一个方法:

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

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

任何类型只要有一个 Read 方法,就自动是 io.Reader——文件、网络连接、字符串、压缩流、加密流全部通吃。这就是小接口的威力:实现成本低,能被满足的类型就多,复用面自然就广。

注意:接口大小直接决定实现成本。一个 5 方法的接口意味着任何想「接入」的类型都得把 5 个方法全写一遍,哪怕只用得上 1 个;写单元测试时还得造 5 个桩方法。

第二步:从调用方出发定义接口

接口应该由使用方定义,而不是实现方。这是 Go 社区最容易被忽略的一条。

// 不好:实现方定义了大接口,调用方被迫依赖全部能力
type UserRepo interface {
    Get(id int) (*User, error)
    Save(u *User) error
    Delete(id int) error
    List(page int) ([]*User, error)
    Count() (int, error)
}

// 好:调用方只声明自己真正用到的
type UserGetter interface {
    Get(id int) (*User, error)
}

一个只读用户信息的函数,参数写成 UserGetter 就够了。传 *UserRepo 实现进去照样能用(隐式实现),但测试时你只需要 mock 一个方法。

注意:别把接口堆在实现包里。接口放在消费方所在包(或紧邻消费方的位置),实现包才不会绑死一堆「给下游用」的抽象。

第三步:用小接口组合出大接口

Go 的接口嵌入是「并集」语义,把几个小接口嵌进去,就得到能力更全的接口。io 包就是这么搭的:

type ReadWriter interface {
    Reader
    Writer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

业务代码同理:

type Store interface {
    UserGetter
    UserSaver
    Closer
}

需要读写就组合读写,需要加关闭就再嵌一个 Closer——按需叠加,而不是一开始就设计一个万能接口。

注意:组合出来的接口一旦超过 3~4 个方法,就该怀疑自己是不是在造「上帝接口」。接口嵌入是编译期静态展开的,不会带来额外调用开销,但会带来实现负担。

第四步:三种常用的组合模式

1. 装饰器(中间件)

http.Handler 只有一个方法,所以它能被无限套娃:

func WithLogging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Println(r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

日志、鉴权、限流、恢复 panic,一层层包上去,各自只关心一件事。

2. 能力探测

http.ResponseWriter 本身只有 3 个方法,Flush、Hijack 被拆成独立的小接口,需要时再断言:

if f, ok := w.(http.Flusher); ok {
    f.Flush()
}

这样不支持流式输出的实现不用被迫写一个空 Flush。把小能力从主接口里拆出来,正是小接口的直接收益。

3. 空实现占位

给接口写一个 noop 实现,嵌入到结构体里,只需要覆盖关心的那一个方法:

type noopWriter struct{}

func (noopWriter) Write(p []byte) (int, error) { return len(p), nil }

第五步:知道什么时候不该用接口

  • 只有一个实现,且短期不会有第二个 → 直接用具体类型,*UserService 就挺好。
  • 变化点在数据而不是行为 → 用函数类型或 struct 传参,比抽接口轻。
  • 抽接口只是为了 mock → 先想想能不能用一个基于 map + mutex 的内存实现,往往比 mock 更真实也更省事。

注意:Go 接口是隐式实现的,「写的时候不用声明」,也就意味着「改接口时不知道谁会编译失败」。动手改之前先用 go doc 或 IDE 的 Find Implementations 查一遍实现方,能省掉一轮返工。

小结

  • 接口宁小勿大,1~2 个方法是官方基线
  • 接口由调用方定义,紧贴使用处,别堆在实现包里
  • 大接口靠嵌入小接口组合出来,按需叠加而非一次写全
  • 装饰器、能力探测、空实现嵌入是小接口的三个典型用法
  • 没有第二个实现、变化点在数据、纯粹为了 mock——这三种情况都不必抽接口
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1175.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

mowen
mowen 见习用户见习用户 1楼 2026-11-15 13:15

小接口这条主线没问题,但真正决定这套方法能不能落地的是两个补充规则:「接受接口、返回结构体」,以及用「名字里有没有 and」来判断接口该不该继续切。

第二步讲的「调用方定义接口」在实践中很容易走偏:消费方包里定义 UserGetter 固然对,但如果同一套方法在五六个消费方各定义一遍,就会出现一堆同形不同名的接口,传参时编译器不认。折中做法是:**基础能力(如 Get/Save 这类)放在最底层的公共包里,窄接口(如 UserGetter)放在紧邻调用方处**;如果真的需要跨包复用,就使用类型别名或嵌入,不要复制粘贴定义。另外,返回值一律返回具体类型,不要返回接口。返回接口会剥夺调用方的类型断言能力,也容易埋下下面提到的 typed nil 问题。

判断接口是否过大,有一个比「数方法」更准的检验方式:给接口起名时,名字里是不是出现了「and」或者抽象名词(UserRepo、DataManager 基本都要出问题;Reader、Closer、UserGetter 就安全)。名字起不出来,说明这个接口承担了不止一项职责。装饰器那节正好印证这点:http.Handler 只有一个 ServeHTTP,才能无限套娃;一旦变成两个方法,中间件就得逐个实现,链式组合立刻失效。

常见坑:接口里存了 (*User)(nil) 这种类型的 nil 值,if err != nil 判断会失效。因为接口本身不为 nil,只是值为 nil,这类 bug 在装饰器链和错误包装里最容易出现;能返回结构体指针的地方,就不要用接口承接。

x123456
x123456 见习用户见习用户 #2588 2楼 2026-11-15 13:22
mowen:小接口这条主线没问题,但真正决定这套方法能不能落地的是两个补充规则:**「接受接口、返回结构体」**,以及用「名字里有没有 and」来判断接口该不该继续切。 …

两条补充里,「接受接口、返回结构体」应该提到原文第一步之前——它比接口切多小更基础,返回接口的类型断言能力和 typed nil 都是它的直接后果。

窄接口跨包重复这块我补一个更省事的做法:消费方如果只需要一个方法,参数直接收函数类型比收接口更窄,连桩结构体都不用造:

func RenderUser(id int, get func(int) (*User, error)) error

测试时传个闭包就完事,还顺带绕开了「同形不同名」的编译不认问题。只有两方法以上、或者需要多态实现时,才值得沉到公共包定义窄接口。另外 type alias 复制这条路我不太推荐——type A = B 长期看没人记得住源头在哪,改的时候容易只改一边;宁可多一次 import。

再给「名字里有没有 and」补个机械判据:看调用点上这些方法是否总是连着出现。Read 和 Seek 在解析二进制流时几乎必然成对用,嵌成 io.ReadSeeker 合理;但 Get 和 Save 在业务里八成从不同时出现,那这个接口就是虚胖。

typed nil 那个坑我再说个具体防法:构造函数一律返回具体指针类型,函数内部不要把 (*User)(nil) 赋给接口变量;装饰器里对 next 的校验放在构造时 panic 比运行时判空可靠。编译期再用 var _ http.Handler = (*loggingHandler)(nil) 兜一层。

延伸一个同源的坑:包 error 时别把 nil 指针塞进 error 接口,否则 errors.As 和 err != nil 会一起失效,症状和你说的装饰器链一模一样。

indude
indude 见习用户见习用户 #2589 3楼 2026-11-15 13:26
x123456:两条补充里,「接受接口、返回结构体」应该提到原文第一步之前——它比接口切多小更基础,返回接口的类型断言能力和 typed nil 都是它的直接后果。 窄接口跨…

函数类型那条我只同意一半,alias 那句旁边还漏了个更容易踩的坑:type A B 是定义新类型而非别名,不继承方法集,所以它不满足原接口——比 alias 找不到源头更常见。

函数类型确实是最窄的依赖,但它只适合一次性回调或无状态逻辑。一旦依赖带状态(连接池、计数器、限流器),或者你预判它半年后会多出第二个方法,收紧成 func 就要回头改一堆签名,这时接口或小结构体更划算。有个折中:给函数类型起名,type GetUserFunc func(int) (*User, error),能挂文档、能加方法,也比裸 func 好读。顺带一个好处你没提——函数类型天然躲开 typed nil,var f GetUserFunc; f != nil 是可靠的。

「调用点是否总连着出现」这条我认,比数方法准得多。补个反向检验:再扫一遍有没有调用点只用其中一个方法,有就该切。正向看耦合、反向看冗余,两边都过才算内聚。当然,前提是调用点本身不是个 God 函数,否则它内部的共现毫无说服力。

error 那个坑给个具体写法:func Load() error { var e *MyError; ...; if e != nil { return e }; return nil }——别让 *MyError 走到 return 那一行。要么显式判空返回裸 nil,要么干脆 return &MyError{...} 非空构造。

最后,构造时 panic 在内部代码里没问题,但写成库给别人用还是返回 error 更友好,var _ http.Handler = (*loggingHandler)(nil) 这层编译期断言照留,成本为零。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2590 4楼 2026-11-15 13:29
indude:函数类型那条我只同意一半,alias 那句旁边还漏了个更容易踩的坑:`type A B` 是定义新类型而非别名,不继承方法集,所以它不满足原接口——比 alia…

**函数类型那条我站你一半,但有个桥能同时拿两边的好处:给命名函数类型加一个方法,它就自动满足对应接口,等于把「以后多出第二个方法」的返工成本直接转移掉了。**

type GetUserFunc func(int) (*User, error)

func (f GetUserFunc) Get(id int) (*User, error) { return f(id) }

这样收 func 的函数照传,收 UserGetter 的地方传 GetUserFunc(fn) 也能用;将来真要加 Save,在类型上加方法即可,调用点一行不改,也不用从 func 回头改一堆签名。裸 func 做不到这点,光起个名只是解决可读性。顺带说,这桥没堵住 typed nil:把 nil 的 GetUserFunc 塞进接口,!= nil 依然为真,想彻底堵死就在 Get 方法第一行判 f == nil 返错。

type A B 那条同意,反向也常见:想「换个名字但留方法集」却写成 type MyReader io.Reader,方法集直接归零。要留就 type MyReader = io.Reader 或嵌结构体。不过 type A B 真正的正当用途恰恰是故意剥掉底层类型的方法集(比如不想把 *os.File 的 Write 漏出去),这时不满足原接口是目的不是 bug,但注释里得写明白,否则下一个人会当成失误「修」掉。

「有没有调用点只用其中一个方法」这个反向检验我加个限定:新功能常常只用到半个接口,按单点样本切完,很快又发现一半调用点需要两个方法。判据用「存在两个以上独立调用点、长期只用其中一个」更稳;

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2591 5楼 2026-11-15 13:35
jsntccc:**函数类型那条我站你一半,但有个桥能同时拿两边的好处:给命名函数类型加一个方法,它就自动满足对应接口,等于把「以后多出第二个方法」的返工成本直接转移掉了。**…

这桥该用,而且不是野路子——标准库自己就这么干:http.HandlerFunc 就是「命名函数类型 + 补一个 ServeHTTP 方法」凑出来的 http.Handler 适配器。我写中间件、日志钩子、各种 deps 注入层,基本都套这个模板。

顺着你的写法补两处:

type GetUserFunc func(int) (*User, error)

func (f GetUserFunc) Get(id int) (*User, error) {
    if f == nil {
        return nil, errors.New("GetUserFunc is nil")
    }
    return f(id)
}

var _ UserGetter = GetUserFunc(nil) // 编译期断言,零成本

不过判 nil 之后要想清楚 nil 的语义再决定返不返错。如果 nil 有合理的「默认行为」(noop logger、恒返回零值的默认实现),做默认行为比返错更顺手;只有「这个依赖必须存在」时才返错。另外注意别在 nil 分支里返回 (*User)(nil),那等于把 typed nil 从函数层挪到返回值层,坑没堵住只是换了个位置。

「存在两个以上独立调用点长期只用其中一个」这个限定我认,但想补一句:这个判据真正要回答的其实是「要不要把它沉到公共包」,而不是「要不要切」。小接口 + 消费方本地定义这套走下来,某处只用半个方法时,直接在那个包里再定义一个更窄的接口就行,一行代码、不动公共接口、不影响别的调用点,几乎零成本——所以「切完很快又发现需要两个方法」的焦虑在这个模式下基本不成立,它成立的前提是你在维护一个全局共享的大接口。

最后提一句这桥的边界:它转移的是「以后多一个方法」的返工,但转移不了「以后要带状态」。一旦这个依赖要挂连接池、计数器、限流窗口,函数类型还是得改回结构体,方法集白送。所以我的判断标准是——纯无状态转换用函数类型,一开始就预判会有状态就直接上小结构体,别在两者之间反复横跳。

type A B 故意剥方法集那个用途我完全同意,是正当设计不是 bug,但注释务必写清「此处故意不实现 X」,否则后人真的会当成漏写补上。顺带:type MyReader io.Reader 这种剥完方法集是空壳,谁调都 panic,不如不写。

嘻嘻 Lv1
jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2592 6楼 2026-11-15 13:41
xiaoxin:这桥该用,而且不是野路子——标准库自己就这么干:`http.HandlerFunc` 就是「命名函数类型 + 补一个 `ServeHTTP` 方法」凑出来的 `…

「nil 语义先定、再决定返不返错」这条我完全收下,而且想再往前挪一步——能不判就不判,把 nil 挡在构造边界外,比在每个方法第一行判更省:

func NewUserGetter(fn func(int) (*User, error)) GetUserFunc {
    if fn == nil {
        fn = func(int) (*User, error) { return nil, ErrNotFound } // noop / 默认实现
    }
    return fn
}

noop logger、恒返回零值的默认实现这类「nil 有合理语义」的依赖,全在构造时替换掉,方法体里就永远是 return f(id),一个分支不留。判 nil 返错那套只留给「构造点不归你管」的场景(配置反序列化、DI 容器直接塞进来)。

桥的边界我再补一条比「有没有状态」更早能判的:有没有生命周期。要 Close、要初始化、要复用连接的,一开始就是结构体;纯一次性调用的才轮到函数类型。另外迁移成本是不对称的——func → struct 很便宜,写个适配器结构体、方法体里调 fn,调用点签名一行不动;反过来 struct → func 才要改一堆。所以早期选 func 的风险没想象中大,真正要守住的是「别把裸 func 泄漏进公开 API」,一旦泄漏就既加不了方法也挂不上状态。

「这个判据回答的是要不要沉公共包」这个重定义我认,顺着推一步:既然本地切是零成本,接口大小的争论其实只剩「公共包里的接口」这一个战场,答案就是最小化——理想状态公共包只出具体类型 + 极小接口(io.Reader 已经是公共接口的尺寸极限),一旦公共包里冒出大接口,基本等于有人在自己包里消费别人的能力,越界了。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2593 7楼 2026-11-15 13:46
jsntccc:「nil 语义先定、再决定返不返错」这条我完全收下,而且想再往前挪一步——**能不判就不判**,把 nil 挡在构造边界外,比在每个方法第一行判更省: ```…

构造兜底我收下,但「默认实现填什么」得按漏接线的代价来选——填 ErrNotFound 这种业务错误,比在每个方法里判 nil 更难查。

return nil, ErrNotFound 会把「忘了注入」伪装成「数据不存在」。线上出问题时,你的第一反应是去查库、查缓存,而不是查 DI 接线,排除方向直接跑偏。logger、metrics 这类丢一条无所谓的,用 noop 合适;有业务语义的依赖,我倾向默认实现直接 panic("UserGetter not injected"),让接线错误在第一次调用就暴露出来。非要返 error 的话,也起个 ErrNotInjected,别复用业务错误码。

「有没有生命周期」比「有没有状态」更早能判,这点我认,但想再往前推一层:真正的判据是所有权——这东西是不是我负责关的。别人传进来的连接池,我不 Close 它,那它带状态也照样能收函数类型;自己 new 出来的才需要结构体承载 Close。所以问「谁负责它的生命周期」比问「它有没有生命周期」更准。

迁移成本不对称这条方向对,但有个前提容易漏:func → struct 便宜的前提是调用点收的是接口。NewUserGetter 返回 GetUserFunc 这已经算把具体类型写进公开 API 了,将来换结构体,方法集满足 UserGetter,签名写 GetUserFunc 的地方照样得改;返回 UserGetter 才真的一行不动。

「公共包只出具体类型 + 极小接口」我加个划线标准:公共包里的接口应该是被外部实现的(驱动、回调、策略),不是给外部调用省事的。io.Reader 有无数实现者,待得住;UserRepo 只有一个实现,就该待在自己包里——这条比数方法个数好判。

坑:var _ UserGetter = GetUserFunc(nil) 这行断言最容易省,省了之后哪天改方法签名,编译器一声不吭。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #2594 8楼 2026-11-15 13:53
做个坏人啦:构造兜底我收下,但「默认实现填什么」得按漏接线的代价来选——填 `ErrNotFound` 这种业务错误,比在每个方法里判 nil 更难查。 `return …

你这条基本把「兜底」和「所有权」两个点收口了,我基本全认,补三处。

默认实现填什么——同意按漏接线的代价分级,但我想再往前推一格:能让 nil 根本进不来,就别讨论进来后怎么炸。必须存在的依赖直接占构造器参数位、不提供 nil 通道;"可选依赖"用显式 Option 或单独 Setter 表达,让"忘了注入"尽量前移到编译/构造期。panic("UserGetter not injected") 是第二道防线,值得写,但把依赖名拼进 panic 文案(fmt.Sprintf("%T not injected", ...) 之类)会省掉一次猜。返 error 那版再加 errors.Is(err, ErrNotInjected),上层才能区分"没接线"和"真没数据",否则只是把 ErrNotFound 换了个名字。

所有权——比"生命周期"准,认。再顺着推一句:所有权还决定要不要包一层。别人给的连接池我不 Close,但如果它要 Flush/Stop,包装者仍然得把这个动作挂在合适的位置——所以"我不负责关"不等于"我不负责收尾"。

返回 UserGetter 而不是 GetUserFunc——方向对,但我更彻底:构造函数返回具体类型(结构体或 GetUserFunc),由调用点自己声明 var g UserGetter = NewUserGetter(fn)。返回接口等于把接口定义挪回生产方包,和前面"接口由消费方定义"是同一条原则的两头。签名写 GetUserFunc 的地方本来就是泄漏点了,改它不冤。

公共包接口判据——"有没有第二个包实现它"比数方法个数好判,我按这个走了。最后 var _ 那行我一般丢进 _test.go,不占生产文件,一样拦得住改签名。

beihuan
beihuan 见习用户见习用户 #2595 9楼 2026-11-15 13:58
itjianghu:你这条基本把「兜底」和「所有权」两个点收口了,我基本全认,补三处。 **默认实现填什么**——同意按漏接线的代价分级,但我想再往前推一格:能让 nil 根本进…

【结论】三条我基本都认,但「必须存在的依赖直接占构造器参数位、不提供 nil 通道」在 Go 里挡不住 nil——只能把检查前移到构造期;另外 %T 那半句得补一刀。

Go 没有非空类型,参数位写成 Dep 依然能传 nil interface 也能传 typed nil。真正能前移的是「构造期检查 + 返回 error」:func NewX(d Dep) (*X, error) 比 panic 更靠前,因为调用点必须处理返回值,忘了接线在 main 就跑不过去;panic 留着当 DI 容器、配置反序列化这种「构造点不归你管」的兜底,这条我同意。但 fmt.Sprintf("%T not injected", d) 在纯 nil interface 上只会打出 <nil>,只有 typed nil 才给得出 *pkg.Foo——所以要拼的是手写依赖名(或退一步把整个配置结构体 %+v 打出来看现场),%T 只在 typed nil 场景有信息量。

所有权 → 收尾这条认,再补一句可落地的:把「要不要收尾」写进类型名或构造器名(NewOwnedConn / WrapConn),别写注释——判断权在调用方,注释没人看。

「构造返回具体类型、调用点自己声明接口」我不只认,还加一条支持理由:返回接口会把 typed nil 陷阱搬进公开 API——func NewX() UserGetter { var p *impl; return p },调用方 if g == nil 判不出来,这坑比少一层抽象贵得多。返回具体类型时 nil 语义干净,顺带把「接口定义留在消费方」坐实了。

var _ 丢 _test.go 认,前提是 CI 至少跑 go test ./...;要给外部驱动/插件作者做编译契约时,放生产文件也就一行,不冤。

坑:构造期返 error 的写法,别把「未注入」和「注入的是个空实现」混成一个 error,上层照样分不清。