如何避免代码里的霰弹式修改
Posted on in Tech
| Abstract | 如何避免代码里的霰弹式修改 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Version | v1.0 |
| Updated | 2026-10-27 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- 一句话:霰弹式修改的根因不是代码多,而是"一个概念的知识被复制到了很多地方"。治理目标不是让代码只出现一次,而是让一个变化原因只有一个主要修改点
- 什么是霰弹式修改:改一条权限规则,要同时动十几个文件,还经常 A 改了、C 忘了、D 改错了,同一条规则在不同地方表现不一致
- 把"变化"集中起来:把到处出现的
if role == ...收进一个CanManageCall(user),从 N 个修改点变成 1 个 - 别看到重复就抽象:判断标准不是"代码是不是重复",而是"它们是不是因为同一个业务原因而变化"。这比 DRY 更重要
- 用"变化原因"划分代码:规则散落在 handler / service / DAO / job / CLI,改一次全得动,这才是霰弹;把规则收进领域服务,外围只负责调用
- 霰弹式修改通常是知识泄漏:问题不是重复,是系统里有好几个地方"知道"同一条业务规则。重构要问的是"这个知识该归谁拥有"
- 先做 Change Map 再动手:接手大项目别一上来就重构,先搜出一条需求要改哪些地方,判断这些修改是合理分层还是霰弹
- 重构目标量化成 N→1:改一个业务规则从要动 12 个文件,变成改 1 个核心实现、测试覆盖其余调用者
- 改很多层不一定是坏事:真正的分层需求穿透各层是正常的,霰弹指的是"同一层的很多无关地方重复出现同一个判断"
- 建立变化边界:频繁变化的业务规则应该待在稳定的领域边界之内,而不是散落在 Interface 和 Infrastructure
- 用测试保护重构:遗留系统先固定行为(characterization test),再改结构,别一次性重写二十处然后指望测试告诉你哪儿坏了
- Code Review 五问:改一个规则要动几处?它们为什么一起变?这知识归谁?有没有复制业务判断?新需求是不是都在加新的 if?
- 当霰弹跨越服务边界:跨服务没有原子提交、没有编译器帮你找调用点,硬套单体办法会出事。思路从"收成一个修改点"转为"建权威来源 + 兼容滚动":共享契约库治数据结构散落,策略服务/配置中心治业务规则散落,改契约只能向后兼容分阶段切,而且要承认——有时候各服务各管各的才是对的,硬抽共享库会制造分布式单体
一条权限规则,admin 和 operator 能管理通话。某天产品说:supervisor 也要能管。
听起来是五分钟的活。结果你打开项目一搜,这个判断散落在十几个文件里:service_a.go、handler_x.go、middleware.go……改到第八个的时候你已经分不清哪些改过、哪些没改。上线之后用户报障:有的入口 supervisor 能管通话,有的不能——因为你漏了一处。
这就是霰弹式修改(Shotgun Surgery):改动一个概念,子弹却打得满屏都是。它是一种非常典型的代码异味(Code Smell,指结构上有问题、但当下还没报错的代码)。
它的根因不是"代码写得多"这么简单,而是:
一个概念的知识,被分散地复制到了很多个地方。
这篇讲清楚三件事:它长什么样、为什么会这样、怎么治。核心结论先放这儿——治理的目标不是"让代码只出现一次",而是"让一个变化原因只有一个主要修改点"。 这两句听起来像,其实差得很远,后面会讲为什么。
现象:改一处规则,动十几个文件
回到开头那个例子。项目里到处是这样的判断:
if user.Role == "admin" || user.Role == "operator" {
...
}
现在要加上 supervisor,你必须打开:
service_a.go
service_b.go
service_c.go
handler_x.go
handler_y.go
middleware.go
...
更糟的是人改东西会累、会分神,于是很容易出现:
A 改了
B 改了
C 忘了
D 改错了
结果系统里同一条业务规则,在不同地方表现不一致。这种 bug 最难查——因为它不是"错了",是"有的地方对、有的地方错",复现要靠运气。
记住这个判断信号:一个需求下来,你得靠 grep 找出所有要改的地方,再逐个改、最后祈祷没漏——只要进入"祈祷"这一步,基本就是霰弹式修改了。
第一步:把"变化"集中起来
Martin Fowler 对这类问题的核心思想其实非常朴素:
把经常一起变化的东西放在一起。
不要让这个判断到处出现:
if user.Role == "admin" || user.Role == "operator" {
...
}
而是收进一个有名字的函数:
func CanManageCall(user User) bool {
return user.Role == "admin" ||
user.Role == "operator"
}
业务代码只负责问它一句话:
if CanManageCall(user) {
...
}
以后要加 supervisor,只改一个地方:
func CanManageCall(user User) bool {
return user.Role == "admin" ||
user.Role == "operator" ||
user.Role == "supervisor"
}
修改点从 N 个变成 1 个。这就是最基本的变化局部性(Change Locality):让一个变化尽量被关在一小块地方。
到这儿你可能觉得问题解决了。但接下来才是真正容易踩坑的地方。
别看到重复就抽象
这一点我见过太多人栽跟头,包括我自己。
有两段代码,长得一模一样:
// A
if user.Role == "admin" || user.Role == "operator" {
...
}
// B
if user.Role == "admin" || user.Role == "operator" {
...
}
手一痒就想抽成一个函数。但假如:
A 是"管理会议"
B 是"查看会议"
那它们恰恰不应该抽象成一个函数。因为它们现在碰巧一样,但未来会往不同方向变——哪天"查看会议"要放开给更多角色,而"管理会议"不变,你就得把它们再拆回来,而且拆的时候八成会改出 bug。
所以判断标准不是:
"代码是不是重复?"
而应该问:
"这些代码是不是因为同一个业务原因而变化?"
这其实比 DRY(Don't Repeat Yourself,不要重复自己)更重要。DRY 盯的是"文本上的重复",而真正该盯的是"变化原因上的重复"。两段代码长得一样但会因为不同原因而变,它们是偶然重复,强行合并反而制造了耦合;两段代码因为同一条规则而存在,哪怕长得不太一样,也该收到一起。
用"变化原因"划分代码
一条业务规则,经常会从入口一路穿到存储,在每一层都留下一个判断:
REST handler
↓
service
↓
DAO
↓
background job
↓
CLI
规则一变,这几处全得动:改 handler、改 service、改 DAO、改 job、改 CLI。这就是典型的霰弹式修改——同一条规则被每一层各自"知道"了一遍。
更好的结构是让规则只有一个拥有者,各层去调用它:
Business Rule
│
Domain Service
/ | \
API Job CLI
规则集中在领域服务里:
type CallPolicy struct {
}
func (p CallPolicy) CanTransfer(call Call, user User) bool {
...
}
外围的 API、Job、CLI 都只负责把请求翻译过来、调用 CanTransfer,自己不碰规则本身。
本质:霰弹式修改是"知识泄漏"
这是我认为最值得停下来想的一点。
一个判断:
if status == "ACTIVE" && retryCount < 3 {
...
}
它可能出现在 A.go、B.go、C.go、D.go 四个文件里。
表面问题是"重复"。但真正的问题是:
系统里有 4 个地方"知道"这条重试规则。
retry policy
│
┌──────┼──────┐
↓ ↓ ↓
A B C
这几个地方各自握着一部分业务知识。理想情况应该是只有一个地方拥有它,其余的去问它:
retry policy
│
RetryPolicy
┌───────┴───────┐
↓ ↓
A B
所以重构的时候,别只想着"怎么减少代码",而要想:
"这个知识应该由谁拥有?"
这个视角一换,重构质量会高很多。减代码是结果,明确归属才是目的。
动手之前,先画一张 Change Map
接手一个大型项目,我不建议一上来就重构——你还没摸清哪些修改是合理的、哪些是病。先做一张 Change Map(变化地图):挑一条典型需求,把它要改的地方全找出来,再判断。
比如需求是:
"改一下 SIP REFER 的处理逻辑。"(REFER 是 SIP 协议里发起呼叫转接的一种请求)
先搜相关关键词:
REFER
Refer
Transfer
transfer
blindTransfer
attendedTransfer
然后把命中的文件按职责归拢:
SIP Handler
├── refer_handler.go
├── transfer.go
└── dialog.go
Business
├── call_service.go
├── transfer_service.go
└── permission.go
State
├── call_state.go
└── dialog_state.go
Tests
├── refer_test.go
├── transfer_test.go
└── call_test.go
然后问一句:为什么一个 REFER 需求要改这么多地方?
如果命中的是这样——
refer_handler.go ← SIP 协议层
transfer_service.go ← 业务层
call_state.go ← 状态
permission.go ← 权限策略
那这些修改很可能是合理的,因为它们分属不同职责,一个转接需求本来就要动协议、业务、状态、权限。
但如果命中的是这样——
refer_handler.go
foo.go
bar.go
baz.go
util.go
helper.go
another_helper.go
而它们都在判断同一个 if x == ...,那大概率就是霰弹式修改了。Change Map 的作用,就是帮你把"合理的分层穿透"和"病态的散弹"区分开。
把重构目标量化成 N → 1
重构这事最怕"凭感觉"。你可以把目标量化:
现在改一个业务规则要动 12 个文件;目标是改 1 个核心实现,测试覆盖其余调用者的行为。
改之前
Feature X
├── handler.go
├── service.go
├── worker.go
├── scheduler.go
├── api.go
├── util.go
└── config.go
要改 7 个地方
改之后
Feature X
├── handler.go ─────┐
├── worker.go ──────┤
├── scheduler.go ───┤
│ ↓
└────────────── FeaturePolicy
│
↓
Rule Engine
改业务规则,只碰 FeaturePolicy 一处。从 7 到 1,这就是可以写进 commit message、可以在评审里讲清楚的收益。
改很多层,不一定是坏事
这一点尤其容易误判,矫枉过正反而坏事。
一个真实的需求:
"增加 SIP REFER 支持,要求有权限控制、事务管理、状态机和审计。"
那么修改穿透各层:
SIP Layer
↓
Application Layer
↓
Domain Layer
↓
Persistence
↓
Audit
这完全正常。一个功能本来就涉及协议、应用、领域、存储、审计五个关注点,每层各改一处,是合理的分层协作,不是霰弹。
真正的问题是另一种形态:
一个业务概念
↓
在同一层的很多无关地方重复出现
比如:
10 个 handler
20 个 service
5 个 worker
都自己在判断:
if role == ...
if status == ...
if featureFlag == ...
判据很简单:穿透不同职责的层是健康的,在同一职责里被复制 N 份才是病。 别把前者当后者去"治",否则你会为了消灭重复而把清晰的分层搅成一团。
给业务规则划一条"变化边界"
对大型项目,我习惯把代码分成四层,并给变化划一条边界:
┌─────────────────────────────┐
│ Interface │
│ HTTP / SIP / CLI / MQ │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Application │
│ use case / orchestration │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Domain │
│ policy / state / rule │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Infrastructure │
│ DB / Redis / HTTP / Kafka │
└─────────────────────────────┘
核心原则:
变化频繁的业务规则,应该尽量待在稳定的领域边界之内,而不是散落在 Interface 或 Infrastructure 里。
反例是让 SIP handler 自己决定业务规则——协议层一边解析报文,一边判断权限、状态和开关:
func HandleRefer(req SIPRequest) {
if role != "admin" &&
call.State != "CONNECTED" &&
featureFlag != true {
...
}
}
应该让协议层只做翻译,把判断交给领域服务:
func HandleRefer(req SIPRequest) {
cmd := ParseRefer(req)
result := transferService.Execute(cmd)
return ToSIPResponse(result)
}
规则待在它该待的地方:
func (s *TransferService) Execute(cmd TransferCommand) Result {
if !s.policy.CanTransfer(...) {
...
}
...
}
这样 SIP 层就不会"拥有"转接策略。协议会换(SIP 换成别的信令),但转接规则不会跟着协议变——把规则放在稳定的那一层,它就不会被每个新入口再抄一遍。
用测试给重构兜底
霰弹式代码最怕的,就是直接上手大规模重构。相对安全的步骤是:
发现重复规则
↓
先补 characterization test(固化现有行为的测试)
↓
锁住"当前到底是什么行为"
↓
提取核心规则
↓
让旧代码改成调用核心规则
↓
删掉重复逻辑
↓
跑完整测试
对遗留系统,一句话:
先固定行为,再改变结构。
characterization test(表征测试)不关心"行为对不对",只关心"行为是什么"——哪怕现在的逻辑是错的,也先把它原样固化下来,这样你重构时才知道有没有把行为改变了。
千万别反过来:
发现 20 个地方重复
↓
全部重写
↓
指望测试告诉你哪里坏了
遗留系统往往测试本就不全,这么干等于蒙眼拆弹。
一份能照着念的 Code Review 五问
做设计评审或代码评审时,这五个问题很好用,照着念就行。
① 改一个业务规则,要改几个地方?
1 👍
2~3 可以接受
10+ ⚠️
不是绝对标准,但非常好用——一超过阈值,就该停下来问为什么。
② 这些地方为什么一起变?
如果答案是"因为它们都实现了 XXX 规则",考虑抽象;如果答案是"碰巧一样但原因不同",那就别合并(见前面"别看到重复就抽象")。
③ 这个业务知识归谁拥有?
如果答不上来"这条规则到底属于谁",通常就是设计问题——没有拥有者的规则,迟早会被复制。
④ 有没有在复制业务判断?
同一个 if status == ... 在好几个文件里出现,尤其要警惕。
⑤ 新需求是不是在不断增加新的 if?
这是最强的信号。如果每个新需求都是:
+ if xxx {
+ }
而且分布在 A、B、C、D 几个地方,那系统很可能已经在往霰弹式修改上滑了。
当霰弹跨越了服务边界
前面那套办法——抽函数、收进领域服务、画 Change Map、把目标量化成 N→1——有一个默默成立的前提:这些修改点在同一个代码库里,你能一次提交、一次编译、一次上线把它们全改完。
可现实里更难受的情况是:同一条规则散落在多个服务、多个仓库之间。订单服务、风控服务、通知服务,各自硬编码了一份"VIP 用户的判定标准";或者同一个消息体的字段约定,被五个服务各自解析、各自假设。这时候你再想"收成一个修改点",会发现根本收不动。
因为跨服务意味着你丢掉了三样东西:
| 单组件内 | 跨多个服务 / 仓库 | |
|---|---|---|
| 原子提交 | 有,一次改完 | 没有,各服务各自发布 |
| 编译器找调用点 | 有,改错了编译不过 | 没有,靠 grep + 文档 + 运气 |
| 同时生效 | 能保证 | 不能,必然有一段过渡期 A 新 B 旧 |
硬把单体内的办法套过来,最典型的翻车是:你改了共享规则,先上线了服务 A,服务 B 还没发——这段时间里 A 按新规则、B 按旧规则,线上就是不一致。单体里编译器会逼你一次改全,分布式里没人逼你,全靠纪律。
所以跨服务的治理换了个目标。单体内追求"收成一个修改点",跨服务做不到也不该强求——你没法把三个团队的三个仓库合并成一个函数。跨服务追求的是两条:
第一,让这份共享知识有一个权威来源(single source of truth),其他服务去消费它,而不是各自抄一份。 第二,改它的时候必须能兼容地、分步地滚动,而不是指望所有服务同时切换。
落到具体手段,大致是这几类。
1. 共享契约库:治"数据结构 / 枚举"的散落
如果散落的是数据结构或协议约定——消息体字段、状态枚举、错误码——别让每个服务各自定义一份。把它抽成一个版本化的共享契约:
- 用 Protobuf / OpenAPI 之类的 schema 定义一次,各服务从同一份 schema 生成代码。schema 改了,各服务重新生成,字段对不上的地方编译期就暴露——这等于把跨服务丢掉的"编译器找调用点"部分地找回来了。
- 或者抽成一个版本化的内部库(
common-types、xxx-contract),各服务用包管理器依赖它。
要点是版本化:共享库必须能多版本共存,因为服务 A 升到 v2 的时候,服务 B 可能还在 v1。没有版本管理的共享库,会从"治散落"退化成"所有服务被同一个库绑死,一改全崩"——那是另一种灾难。
2. 策略服务 / 配置中心:治"业务规则"的散落
如果散落的是业务规则本身——鉴权策略、风控阈值、功能开关(feature flag)——共享库还不够,因为规则会频繁变,你不想每改一次阈值就让五个服务重新发版。这时候把规则从代码里挪到运行时:
- 策略服务(Policy Service):把"谁能转接通话""VIP 怎么判定"做成一个服务,其他服务调用它来问,而不是自己判断。好处是规则真正只有一个拥有者;代价是多一跳网络调用、多一个要高可用的依赖。
- 配置中心:阈值、开关这类放进配置中心,各服务订阅同一份配置。改一次,所有服务生效,不用发版。
这其实就是前面"把规则收进领域服务"在分布式下的延伸——只不过领域服务从一个进程内的对象,变成了一个独立的服务或一份集中配置。
3. 扩展优于修改:跨服务改契约的唯一安全姿势
单体里我们用 characterization test 兜底,跨服务的兜底则是另一条铁律:向后兼容,分阶段滚动,永远不要指望原子切换。
具体说,改一个跨服务契约时:
- 加字段,别改字段。 加一个新字段是兼容的(老服务忽略它),改一个旧字段的含义是要命的(老服务会误解)。
- 双写双读过渡。 要替换一个字段,先让生产方同时写新旧两份,等所有消费方都切到读新字段,再撤掉旧字段。中间可能要过好几周,别嫌慢。
- 消费方先行。 发布顺序上,通常让"能同时接受新旧两种格式"的消费方先上线,生产方再切——这样任何时刻都不会出现"生产方发了新格式、消费方还不认"的窗口。
一句话:跨服务没有"改完"这个瞬间,只有"滚动完"这个过程。 把每一次契约变更都当成一次灰度发布来设计。
4. 承认:有时候"散落"恰恰是对的
这一条最反直觉,但对微服务尤其重要。
微服务的本意,就是让每个服务拥有自己的业务逻辑。如果你看到两个服务里有相似的判断,就手痒抽一个共享库强制它们统一——你可能正在亲手制造一个分布式单体(distributed monolith):表面上是一堆独立服务,实际上被共享库死死绑在一起,改一行共享代码要协调五个团队同时发版,拿到了微服务的所有运维成本,却丢掉了独立演进的唯一好处。
所以跨服务抽象之前,判断标准比单体内还要严一档:
- 是不是真正的同一个概念? "订单服务的 VIP 判定"和"营销服务的 VIP 判定",名字一样,但可能是两个 bounded context(领域边界)里的两个概念,未来会各自演化。这种情况下,各自拥有一份反而是对的。
- 它属于哪个服务的核心域? 如果这条规则明显是某个服务的职责,那就让它拥有这条规则,其他服务通过 API 问它,而不是抽到一个谁都不拥有的共享库里。共享库最怕的就是"三不管地带"——没有明确 owner 的共享代码,最后会烂得比散落更快。
一句话收这一节: 单体内,治霰弹是"把知识收拢到一个拥有者";跨服务,治霰弹是"给知识指定一个拥有的服务,其余的去问它,并且用兼容滚动代替原子修改"。而在动手之前,先确认这真是"一个概念被抄了多份",而不是"多个服务各自拥有一个长得像的概念"——后者不该被统一。
总结:不求代码只出现一次,只求一个变化只有一个修改点
把全文浓缩成一句工程原则:
不要追求"代码只出现一次",而要追求"一个变化原因只有一个主要修改点"。
这三层是递进的:
- DRY:减少重复代码——这是最表层,也最容易用错(偶然重复不该合并)。
- Change Locality:减少一个需求的修改范围——盯的是"变化",不是"文本"。
- Single Source of Business Knowledge:让每条业务规则只有一个真正的拥有者——盯的是"知识归属"。
最终想达到的是这样的改动体验:
需求变化 → 找到一个明确的业务概念 → 改一个核心位置 → 自动化测试验证所有调用者
而不是这样:
需求变化 → grep → grep → grep → 猜 → 改 → 改 → 改 → 祈祷没漏
单组件内也好,跨服务也好,治理霰弹最后都收敛到同一个词——归属。单体内是"把知识收拢给一个拥有者",跨服务是"给知识指定一个拥有的服务,其余的去问它"。差别只在于:进程内你能靠编译器和原子提交一次改完,跨进程你得靠共享契约、权威来源和兼容滚动,一步步把子弹收回枪膛。
最后留一句给做大型后端和通信系统(SIP、FreeSWITCH、Redis、K8s 这类)的同行:很多被叫作"霰弹式修改"的东西,往回刨一层,其实是领域逻辑泄漏到了 Handler / Adapter / Utility 层。所以治理它,往往不是去消灭重复,而是回答三个归属问题——业务规则归谁拥有?状态机归谁拥有?协议层和业务层怎么隔离? 把这三个问题答清楚,无论规则是散在一个服务里,还是散在十个服务之间,你都知道该往哪儿收。
全文思维导图
@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* 霰弹式修改
** 现象
*** 改一处规则动十几个文件
*** A改B改C忘D错
*** 同规则不同地方不一致
** 根因
*** 不是代码多
*** 一个概念的知识被复制到多处
*** 本质是知识泄漏
** 判断
*** 不问"是否重复"
*** 问"是否因同一业务原因而变"
*** 偶然重复不要合并
** 解决
*** 把变化集中(Change Locality)
*** 规则收进领域服务
*** 划变化边界:规则留在稳定的Domain层
*** 测试兜底:先固定行为再改结构
** 工具
*** Change Map 排查
*** 重构目标 N→1
*** Code Review 五问
** 跨服务
*** 丢了原子提交/编译器/同时生效
*** 目标:权威来源+兼容滚动
*** 共享契约库治数据结构散落
*** 策略服务/配置中心治规则散落
*** 扩展优于修改:加字段/双写双读
*** 别硬抽成分布式单体
** 易错
*** 看到重复就抽象
*** 把合理分层穿透当霰弹
@endmindmap

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。