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 的区别",答不上来就是没分清。

判据只有两条

  1. 抽象不依赖细节:接口签名里不能出现 *sql.DB、*gorm.DB、*http.Request、*redis.Client、云厂商 SDK 的类型。
  2. 依赖指向更稳定的一方:稳定 = 变更频率低。依赖应该从易变指向稳定,所以"业务 → 端口"合法,"端口 → 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 后过期"这类规则,时间就是业务输入,必须可注入,否则边界用例只能在特定时刻跑。判据是:这个值变了,业务结果会不会变? 会变就注入,不会变就别抽接口。