26.2 依赖倒置
一句话
依赖倒置把「高层 → 低层」的单向依赖翻成双方都依赖抽象:内层定义接口、外层实现、组装点注入——它保护的不是"解耦"这个说法,而是高层策略不因低层细节(数据库、SDK、协议)变更而改写。
它修的是 26.1 留下的那根箭头
26.1 的代码里,orderApp 直接持有 *orderDAO。依赖方向是"外层 → 内层",看着合规,但依赖的东西太具体:
26.1 的现实: app ──▶ orderDAO ──▶ map(换成 MySQL 就断)
依赖倒置之后: app ──▶ UserRepo ◀── mysqlRepo
▲
接口由 app 定义,mysqlRepo 反过来依赖 app 的约定
🔑 依赖倒置的"倒"字,指的是接口的所有权与源码依赖方向同时反转:原来"高层依赖低层",现在"低层依赖高层定义的抽象"。
先分清三个词:DIP / IoC / DI
| 概念 | 一句话 | 解决的问题 | 长什么样 | 典型反例 |
|---|---|---|---|---|
| DIP(依赖倒置原则) | 高层与低层都依赖抽象,抽象不依赖细节 | 依赖的方向 | 应用层定义 UserRepo 接口 | service import 具体 repo 包 |
| IoC(控制反转) | 调用权从你的代码交给外部(框架/容器/事件循环) | 控制流由谁驱动 | 框架回调你的 handler、容器给你注入 | 你在 main 里手动逐层调用 |
| DI(依赖注入) | 依赖从外部传进来,而不是内部 new | 依赖怎么拿到 | 构造函数参数、setter、容器 | func NewSvc() { s.repo = NewMySQLRepo() } |
⚠️ 三者不是同义词。DIP 是原则,DI 是手段,IoC 是更宽的现象:Web 框架调用你的 handler 属于 IoC,但你仍然可能把
gorm.DB写死在 handler 里(DIP 没做)。面试问"DI 和 DIP 的区别",答不上来就是没分清。
判据只有两条
- 抽象不依赖细节:接口签名里不能出现
*sql.DB、*gorm.DB、*http.Request、*redis.Client、云厂商 SDK 的类型。 - 依赖指向更稳定的一方:稳定 = 变更频率低。依赖应该从易变指向稳定,所以"业务 → 端口"合法,"端口 → MySQL"违规。
| 一方 | 例子 | 变更频率 | 应该被依赖吗 |
|---|---|---|---|
| 业务规则 | "满 100 减 10"、"已发货不能取消" | 年 | ✅ 稳定,可以被依赖 |
| 端口/接口 | UserRepo、EventBus | 月 | ✅ 稳定 |
| 传输协议 | HTTP 路由、JSON 字段 | 周 | ❌ 易变 |
| 具体实现 | gorm、Redis 客户端、云 SDK | 跟随社区 | ❌ 最易变,只能依赖别人 |
🔑 依赖倒置不是"让大家都依赖接口",而是让依赖方向与稳定性方向一致。一个被到处依赖的
util包之所以危险,就是它易变却被所有人依赖(26.6 用度量抓这件事)。
分水岭:接口定义在谁那里
同样是"接口 + 实现",接口放的位置决定了依赖有没有真的倒置:
// ❌ 接口定义在实现方包里:源码依赖方向没变,只是绕了一圈
// 目录:adapter/mysqlrepo/user.go
package mysqlrepo
import "context"
type User struct{ ID string }
type UserRepo interface {
FindByPhone(ctx context.Context, phone string) (*User, error)
}
// 目录:app/service.go —— 调用方必须 import 实现包才能拿到接口
// func NewService(r mysqlrepo.UserRepo) *Service
// ✅ 接口定义在调用方(应用层):实现包反过来 import app
// 目录:app/service.go
package app
import "context"
type User struct{ ID string }
type UserRepo interface {
FindByPhone(ctx context.Context, phone string) (*User, error)
}
type Service struct{ repo UserRepo }
func NewService(r UserRepo) *Service { return &Service{repo: r} }
| 判断项 | 接口在实现方 | 接口在调用方 |
|---|---|---|
| 依赖方向 | app → mysqlrepo(实现方仍被依赖) | mysqlrepo → app(真正的倒置) |
| 接口方法由谁决定 | 实现方(会塞进自己方便的方法) | 调用方(只写用得到的) |
| 加一个实现 | 要改实现包或不自然 | 直接新增包,调用方零改动 |
| 单测 | 要 import 实现包才能写 fake | fake 就在测试文件里 |
为什么"调用方定义接口"是对的:接口描述的是一份需求(我需要什么能力),需求天然属于提出需求的一方。实现方定义接口,等于让供应商替你写采购单。
💡 这条规则有代价:如果两个调用方都要"存用户",可能出现两个形状不同的接口。这是好事——各自的 fake 更小;等两边确实需要同一份契约(如对外 SDK),再抽出独立
ports包。
代码:从"new 依赖"到"注入依赖"
先看坏味道(能编译、能上线、不能测):
// ❌ 构造函数里 new 具体实现:注册服务被绑死在 MySQL 和短信厂商上
package app
import "os"
type RegisterService struct {
repo *mysqlUserRepo
sms *aliyunSMSClient
}
func NewRegisterService() *RegisterService {
return &RegisterService{
repo: &mysqlUserRepo{db: mustOpen(os.Getenv("DSN"))},
sms: &aliyunSMSClient{ak: os.Getenv("AK")},
}
}
问题不在"用了 MySQL",而在三件事:单测必须连真库真短信、换供应商要改服务、依赖在运行到一半时才可能 nil。
修完的版本(go run main.go 直接跑):
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// ========== 高层策略(应用层):接口在这里定义 ==========
// 接口由调用方定义:调用方只写自己用得到的方法,实现方无权往接口里加东西。
type User struct {
ID string
Phone string
VIP bool
}
type UserRepo interface {
ExistsByPhone(ctx context.Context, phone string) (bool, error)
Save(ctx context.Context, u *User) error
}
type SMS interface {
Send(ctx context.Context, phone, msg string) error
}
type Clock interface {
Now() time.Time
}
type RegisterService struct {
repo UserRepo // 只认识接口,不认识 MySQL
sms SMS // 只认识接口,不认识阿里云
clock Clock // 时间也是外部依赖,否则测试无法固定时间
}
func NewRegisterService(repo UserRepo, sms SMS, clock Clock) *RegisterService {
return &RegisterService{repo: repo, sms: sms, clock: clock}
}
var ErrPhoneTaken = errors.New("手机号已注册")
func (s *RegisterService) Register(ctx context.Context, phone string) (*User, error) {
if !strings.HasPrefix(phone, "1") || len(phone) != 11 {
return nil, errors.New("手机号格式不对")
}
taken, err := s.repo.ExistsByPhone(ctx, phone)
if err != nil {
return nil, fmt.Errorf("查重失败: %w", err)
}
if taken {
return nil, ErrPhoneTaken
}
u := &User{ID: "U" + s.clock.Now().Format("150405"), Phone: phone}
if err := s.repo.Save(ctx, u); err != nil {
return nil, fmt.Errorf("保存用户失败: %w", err)
}
msg := "欢迎注册"
if u.VIP {
msg = "欢迎注册,VIP 专属客服已就绪"
}
if err := s.sms.Send(ctx, phone, msg); err != nil {
// 短信失败不回滚注册:注册是主流程,通知是附属流程
return u, fmt.Errorf("用户已创建,但欢迎短信失败: %w", err)
}
return u, nil
}
// ========== 低层细节(基础设施层):只有它们认识具体技术 ==========
type memoryUserRepo struct{ data map[string]*User }
func (r *memoryUserRepo) ExistsByPhone(_ context.Context, phone string) (bool, error) {
for _, u := range r.data {
if u.Phone == phone {
return true, nil
}
}
return false, nil
}
func (r *memoryUserRepo) Save(_ context.Context, u *User) error {
r.data[u.ID] = u
return nil
}
// fakeSMS 是测试替身:记录发了什么,不真发
type fakeSMS struct{ sent []string }
func (s *fakeSMS) Send(_ context.Context, phone, msg string) error {
s.sent = append(s.sent, phone+":"+msg)
return nil
}
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
func main() {
repo := &memoryUserRepo{data: map[string]*User{}}
sms := &fakeSMS{}
clock := fixedClock{t: time.Date(2026, 10, 24, 16, 0, 0, 0, time.UTC)}
// 组装点:只有这里知道具体实现是谁
svc := NewRegisterService(repo, sms, clock)
u, err := svc.Register(context.Background(), "13800000001")
fmt.Printf("① 注册成功:user=%+v err=%v\n", u, err)
_, err = svc.Register(context.Background(), "13800000001")
fmt.Printf("② 重复注册:err=%v\n", err)
_, err = svc.Register(context.Background(), "abc")
fmt.Printf("③ 格式错误:err=%v\n", err)
fmt.Printf("④ 发出的短信:%v\n", sms.sent)
}
输出:
① 注册成功:user=&{ID:U160000 Phone:13800000001 VIP:false} err=<nil>
② 重复注册:err=手机号已注册
③ 格式错误:err=手机号格式不对
④ 发出的短信:[13800000001:欢迎注册]
三条注册路径加上一条副作用记录,全程没有真实数据库、没有真实短信——依赖倒置的直接产出就是执行速度与可控性。
换一个依赖,要改几处
| 需求变化 | 无倒置(new 在服务里) | 有倒置(构造注入) |
|---|---|---|
| MySQL → PostgreSQL | 改 RegisterService(可能连业务代码一起改) | 新增 pgUserRepo,改 1 行组装代码 |
| 短信厂商换一家 | 改 RegisterService | 新增 xxxSMS,改 1 行组装代码 |
| 单测跑通注册逻辑 | 起 MySQL + 短信 Mock 服务(秒级、易 flaky) | memoryUserRepo + fakeSMS(微秒级) |
| 加"注册后发优惠券" | 继续往服务里 new | 加一个 Coupon 接口参数,用例里编排 |
| 依赖缺失/配置错误 | 运行到请求时才 panic | 启动时组装失败,立刻发现 |
依赖注入的三种方式
| 方式 | 写法 | 优点 | 代价 |
|---|---|---|---|
| 构造器注入(默认) | NewService(repo, sms, clock) | 依赖显式、字段不可变、启动即校验 | 参数多时构造器长(改用配置 struct) |
| 字段/setter 注入 | svc.SetRepo(repo) | 灵活,适合可选依赖 | 可能半初始化,运行期 nil panic |
| 方法注入 | svc.Do(ctx, repo) | 每次调用可换 | 调用方被迫知道依赖,API 被污染 |
🔑 默认用构造器注入。参数超过 4 个,先怀疑服务职责太多,而不是先上 DI 框架——一个服务需要 8 个依赖,通常意味着它承担了 5 件事。
要不要 DI 框架
| 方案 | 代码量 | 适用 | 不适用 |
|---|---|---|---|
手写 main() 组装 | 与依赖数成正比(几十行) | 绝大多数业务服务 | 依赖数百、插件式架构 |
| 编译期生成(wire) | 声明 provider + 生成代码 | 大仓库、依赖图深但不动态 | 需要运行时按配置决定用哪个实现 |
| 运行时容器(dig / fx) | 注册 + 反射解析 | 框架/插件平台,实现由外部提供 | 业务服务:启动慢、错误延后、栈难读 |
Go 的主流答案是手写组装:可读、可断点、无魔法。框架解决的是"依赖图大到人写不动",不是"架构正确性"。
循环依赖:Go 直接编译失败,这是礼物
包级循环 import 在 Go 里是编译错误(import cycle not allowed),比"运行时才炸"友好一个数量级。常见破法有四种(见下方表格),先看最常用的接口倒置:
order/order.go import "shop/payment" → payment.Charge(o)
payment/charge.go import "shop/order" → 读 o.Amount
两条 import 互相指向对方,Go 编译器直接报:
import cycle not allowed
// ✅ order 定义"我需要支付能力",payment 实现它;payment 不再认识 order
// 目录:order/order.go
package order
import "context"
type Order struct {
ID string
Amount int64
}
type Charger interface {
Charge(ctx context.Context, orderID string, amount int64) error
}
func (o *Order) Pay(ctx context.Context, c Charger) error {
return c.Charge(ctx, o.ID, o.Amount)
}
| 破法 | 做法 | 什么时候用 | 风险 |
|---|---|---|---|
| 接口倒置 | 一方定义接口,另一方实现 | 两个包职责清晰、只是互相需要能力 | 接口如果只是镜像对方类型,等于没解耦 |
| 事件解耦 | 一方发事件,另一方订阅 | 反应式流程(下单后扣库存) | 一致性从同步变最终一致 |
| 下沉共享类型 | 把共用类型抽到第三个包 | 只是共享数据结构 | 容易抽出一个"什么都不是"的 common 包 |
| 合并包 | 承认它们本来就是一个模块 | 循环来自职责切错 | 包变大,需要重新找内聚边界 |
⚠️ 如果为了破循环,接口里塞进了对方一整套数据结构,那只是把
import换成了"类型镜像"——耦合没消失,只是编译器不报错了。这通常是包边界画错的信号(26.6)。
破除四个常见误区
| 误区 | 现实 |
|---|---|
| "依赖倒置 = 每个类型都抽接口" | 只在跨边界(DB/HTTP/MQ/第三方)或需要替身测试时抽。同一层内两个纯函数之间抽接口,收益为负 |
| "依赖倒置 = 用 DI 框架" | 构造器注入就是 DI;框架是规模化工具,不是原则本身 |
| "依赖倒置 = 内层不能调用外层" | 内层可以调用(通过自己定义的接口),不能提及实现。控制流向外、依赖向内,两者不矛盾 |
| "接口放哪都行,能注入就行" | 接口归属决定依赖是否真的倒置;放实现包里,方向没变 |
反模式
| 反模式 | 后果 |
|---|---|
| 只有一个实现、也没有测试需求的内部依赖也抽接口 | 接口爆炸,读代码要跳文件才能看到实现 |
| 接口定义在实现包,调用方 import 实现包 | 假倒置:编译依赖仍然指向外层 |
构造函数里 new 依赖 / 读环境变量 | 隐式依赖,测试无法替换,启动错误被推迟到运行期 |
用 Service Locator(container.Get("repo"))代替注入 | 依赖不可见、编译期无检查、运行期 panic |
接口方法里出现 *gorm.DB、*http.Request、sql.Tx | 抽象依赖细节,换个框架接口就得改 |
把所有依赖塞进一个 Deps 大结构体传给每个服务 | 依赖关系不可见,等于全局容器换了张皮 |
给纯函数注入 Clock/Logger 以示"可测试" | 过度设计;纯函数的时间应由调用方传入参数 |
| 用接口硬绕循环依赖,接口里镜像对方全部字段 | 耦合变成隐式的,问题被编译器藏起来 |
自检
Q1 "我们用了依赖注入:所有 handler 都从 container.Get("userRepo") 取依赖。"这符合依赖倒置吗?
▸ 答案:不符合。依赖倒置要求"高层依赖抽象、且依赖显式",Service Locator 只满足了"依赖不是内部 new 的",但把依赖关系藏进了运行期字符串:编译期看不到、测试要启动容器、缺依赖要跑到请求才 panic。判定方法:把这个 handler 的构造函数贴出来,能不能一眼看出它需要什么? 看不出来就还没倒置。
Q2 接口放在实现包(mysqlrepo.UserRepo)、应用层 import 它,编译能过、测试也能写 fake,为什么仍说这是错的?
▸ 答案:因为依赖方向没有反转:应用层仍然依赖 mysqlrepo 这个包,换存储就要改应用层的 import。更隐蔽的是接口方法会由实现方定义——实现方会加自己方便的方法(BulkInsert、SetDB),调用方被绑定到这些它不需要的能力上。正确做法是接口放调用方;实现包反过来 import 调用方(或者双方依赖一个独立的 ports 包)。
Q3 注册服务要发欢迎短信,但短信失败不能导致注册失败。这个"不影响"应该由谁保证:接口定义方、实现方、还是调用方?
▸ 答案:调用方(应用层的用例)。这是业务策略,不是通信细节:接口定义方只声明"可能会失败"(返回 error),实现方如实返回错误,用例决定"记录并继续"。如果让实现方吞掉错误(永远返回 nil),调用方就失去了"短信失败率"这个可观测信号,也无法将来改策略(比如 VIP 失败必须告警)。错误的语义归实现,错误的处置归用例。
Q4 你用接口打破了 A ↔ B 的循环依赖,具体做法是让 A 的接口里出现 []*B.Item。为什么说这很可能只是把问题藏起来了?
▸ 答案:依赖倒置的前提是"接口是调用方需求的抽象"。如果接口签名里出现对方的实体类型,A 仍然在编译期依赖 B 的数据结构(import 只是从 A 挪到了类型定义处),B 改字段 A 就得改——耦合没减少。真正的解法通常有两个:把共享的数据结构下沉到一个双方都依赖的稳定包,或者重新审视边界(这两个包是不是本来应该是一个,或者应该按用例而非按名词分)。接口只能反转控制流,反转不了数据形状。
Q5 一个 Web 服务里,time.Now() 被硬编码在业务函数里。要不要为了可测试性注入 Clock?
▸ 答案:看时间是否参与业务判断。如果是"注册时间是几点"这类日志/展示用途,直接 time.Now(),注入只是噪声;如果是"优惠券在 23:59:59 后过期"这类规则,时间就是业务输入,必须可注入,否则边界用例只能在特定时刻跑。判据是:这个值变了,业务结果会不会变? 会变就注入,不会变就别抽接口。