Go 接口设计:小接口、组合与依赖倒置
刚从 Java 转 Go 的人,常把接口写成这样:定义一个UserService接口,里面塞十几个方法,然后一个实现类把它们全实现一遍。这在 Java 里天经地义,但在 Go 里是反模式——你会发现 mock 测试要写一大堆空方法,接口和实现改一个动一片。
Go 的接口哲学和 Java 完全不同:接口应该小,由使用方定义,靠组合拼装。这篇我们从一个「大接口」的痛点出发,重构成 Go 风格的小接口设计。
反模式:一个大接口打天下
// 反例:Java 味十足的胖接口typeUserServiceinterface{GetUser(idint)(*User,error)CreateUser(u*User)errorUpdateUser(u*User)errorDeleteUser(idint)errorSendWelcomeEmail(idint)errorExportToCSV()([]byte,error)// ...还能再塞十个}funcNewReportGenerator(svc UserService)*ReportGenerator{return&ReportGenerator{svc:svc}}ReportGenerator其实只需要读用户,但它被迫依赖整个UserService。写单元测试时,你为了 mock 一个GetUser,得把另外七个方法全实现成空壳。接口一旦新增方法,所有实现和所有 mock 都得跟着改。
问题的根源:接口太大,依赖了自己用不到的东西。这违反了接口隔离原则。
第一步:接口拆小,按能力切分
Go 标准库的接口小到极致——io.Reader只有一个Read方法,io.Writer只有一个Write。学它:
// 每个接口只描述一种能力typeUserGetterinterface{GetUser(idint)(*User,error)}typeUserSaverinterface{CreateUser(u*User)errorUpdateUser(u*User)error}现在ReportGenerator只声明它真正需要的:
typeReportGeneratorstruct{getter UserGetter// 只依赖「读」能力}func(r*ReportGenerator)Build(ids[]int)([]byte,error){for_,id:=rangeids{u,err:=r.getter.GetUser(id)iferr!=nil{returnnil,fmt.Errorf("get user %d: %w",id,err)}_=u// 拼报表...}returnnil,nil}测试时 mock 就一个方法的事:
typefakeGetterstruct{}func(fakeGetter)GetUser(idint)(*User,error){return&User{ID:id,Name:"test"},nil}funcTestBuild(t*testing.T){r:=&ReportGenerator{getter:fakeGetter{}}_,err:=r.Build([]int{1,2})iferr!=nil{t.Fatal(err)}}关键原则:接口由「使用方」定义,不由「实现方」定义
这是 Go 和 Java 最大的思维差异。Java 习惯先定义接口再写实现,接口和实现放一起。Go 推荐在使用接口的那个包里定义接口,实现方甚至不需要知道接口的存在——因为 Go 是隐式实现(鸭子类型),只要方法签名对得上就自动满足接口。
// package report —— 消费方,在这里定义它需要什么packagereporttypeUserGetterinterface{GetUser(idint)(*User,error)}// package user —— 实现方,压根没 import report,也没写 implementspackageusertypeServicestruct{db*sql.DB}// 只要签名匹配,Service 自动满足 report.UserGetterfunc(s*Service)GetUser(idint)(*User,error){/* ... */}好处:依赖方向反过来了。不是report依赖user的接口,而是user的实现去满足report定义的抽象——这就是依赖倒置。高层模块(report)定义抽象,低层模块(user)实现抽象,两者都不直接依赖对方的具体类型。
组合:小接口拼成大接口
需要「既能读又能写」时,不是回头写大接口,而是组合小接口:
// 用嵌入把小接口拼起来,标准库 io.ReadWriter 就是这么干的typeUserRepositoryinterface{UserGetter UserSaver}io.ReadWriter的源码就是interface { Reader; Writer }。任何同时实现了Read和Write的类型,自动满足ReadWriteer。组合让你按需拼装,而不是一开始就设计一个无所不包的巨型接口。
一条实用准则:接受接口,返回结构体
Go 社区有句广为流传的话:“Accept interfaces, return structs”。
// 参数用接口:调用方传什么具体类型都行,灵活、好测试funcSync(src UserGetter,dst UserSaver)error{/* ... */}// 返回具体类型:调用方拿到完整能力,不被接口束缚funcNewService(db*sql.DB)*Service{return&Service{db:db}}返回接口往往是过度设计——调用方拿到具体类型能用到全部方法,需要抽象时自己在使用侧定义接口即可。返回接口反而会把调用方能用的能力提前砍掉。
不要「预防性」地为每个 struct 都配一个接口。接口应该在出现第二个实现、或需要 mock 测试时才提炼出来,而不是一上来就为了「解耦」而抽象。过早抽象是 Go 里另一种常见坏味道。
小结
- 别把 Java 的胖接口习惯带到 Go:大接口逼调用方依赖用不到的方法,改一处动一片。
- 接口拆小,一个接口描述一种能力,像
io.Reader那样。 - 接口由使用方定义,靠 Go 的隐式实现达成依赖倒置——高层定义抽象,低层去满足。
- 需要组合能力时嵌入小接口拼装,而不是重新写大接口。
- 准则:接受接口、返回结构体;接口等到「有第二个实现」或「要 mock」时再提炼,别过早抽象。
一句话记忆:Go 的接口不是用来「先规划」的,而是使用方按需「后提炼」的——越小、越晚、越贴近调用点越好。