ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从1200行OrderService到单一职责:破解发散式变化与霰弹式修改

从1200行OrderService到单一职责:破解发散式变化与霰弹式修改 我印象最深的一次加班是因为一个看起来半小时就能搞定的需求给订单加一个是否含税的展示字段。结果我从下单服务改到订单实体再从订单实体改到数据导出模块最后连三个月没碰过的对账报表都得动。改完在工位上坐了很久想不明白为什么加一个字段能牵动这么多地方。这类体验写业务代码超过两年的人基本都撞过。它背后站着的就是软件设计里绕不开的三件事发散式变化、霰弹式修改以及被反复念叨却常年被误读的单一职责。教科书把它们讲成了名词解释但工位现场从来不缺案例——缺的是把这三个词从纸面拽回到你正在改的那段代码上的方法。这篇我就按自己踩坑的顺序把识别信号、判断标准、拆分步骤和粒度权衡摊开讲一遍适合正在维护一坨万能 Service、或准备重构但不敢下手的人看。1. 先把这三个词从纸面拽回到代码里1.1 发散式变化同一个类被好几拨人用不同理由改发散式变化的英文原词是 Divergent Change指的是一个类或模块因为多种互不相干的原因被反复修改。注意重点不是改得频繁而是改的理由来自不同方向。一个类天天被改如果每次都只为一个原因改那它没病反过来一个类一个月只被改两次但一次是因为税务规则调整一次是因为数据库表加了列那它就是发散式变化。举个我真实见过的例子。一个叫OrderService的类包含了这些逻辑参数校验、优惠券计算、库存扣减、订单落库、消息推送、埋点上报。于是它会被至少四拨人盯上财务团队调整税率和发票规则动它运营团队上线新的满减玩法动它DBA 给订单表加字段或者改字段类型动它数据团队要求补一个埋点字段还是动它。这四拨人互相不认识改的频率、时间点、验收标准全都不一样但只要碰订单落脚点都是同一个文件。这就是典型的发散式变化——一个类被多种变化原因同时撬动。怎么识别我常用两个土办法。第一个是翻 Git 历史把某个文件的提交记录拉出来如果提交信息的动词五花八门——调整税率新增促销修复导出补齐埋点——基本可以确诊。第二个是看提交人如果同一个文件长期被前端、后端、数据三个小组的人轮流改那也是信号。这两个办法不需要任何工具git log --format%an %s -- path/to/File.java一条命令就能看个大概。比起静态扫描它更贴近人的视角而职责这件事本来就是围绕人转的。1.2 霰弹式修改改一个需求撒出去十几个文件霰弹式修改的英文是 Shotgun Surgery字面意思是霰弹枪式的修改。它描述的是另一种病你只想做一个改动却发现它碎成了十几处分散在一堆文件里。你改完 A 忘了 B测试环境正常上线之后 C 没跟上线上数据对不上。典型的场景是新增一个字段。比如给用户加昵称你要动的地方可能是User实体、UserDTO、UserVO、UserMapper.xml、缓存的序列化类、导出 Excel 的模板类、同步到检索系统的转换类、对应的单元测试……漏掉任何一个都是一条线上缺陷。这里有个和发散式变化容易混淆的点发散式变化是一个入口多个原因进霰弹式修改是一个原因多个入口出。前者是收口太紧后者是散得太开。它们看起来是相反的病但根子上是同一个问题——职责边界跟变化轴没对齐。所以处理手法也往往是同一套找到那条变化的原因把它收拢到一个地方去。识别霰弹式修改我建议直接看 PR 的 diff 分布。如果一个需求对应的 PR 里改动的文件超过 5 个且都是各加两行的小改动那大概率是霰弹式修改。不用等到线上出事才反应过来做 Code Review 的时候就能看出来。1.3 单一职责不是一个类只做一件事单一职责原则SRPSingle Responsibility Principle被引用最多的表述是一个类应该只有一个引起它变化的原因A class should have only one reason to change。很多人把它简化成一个类只做一件事然后开始灾难式地拆类UserQueryService、UserCreateService、UserUpdateService、UserDeleteService一口气拆出四个。我拆过然后后悔了。因为拆完之后业务上凡是涉及用户的改动——比如给用户加一个状态校验——四个 Service 都得改霰弹式修改反而被我自己造了出来。把一件事理解成一个操作是 SRP 最常见的误读。关键词其实不是 one thing而是 one reason to change也就是一个变化轴。而变化轴在企业系统里很多时候对应的是一个角色或者一个业务方。谁会在什么情况下要求改这段代码这个谁其实就代表了变化的原因。财务要求改是对应一个原因运营要求改是另一个原因。判断一个类有没有违反 SRP我喜欢问一句话我能不能用一句不带和的话说清楚这个类是干什么的这个类负责订单的创建和优惠计算——带了和两个原因大概率有问题。这个类负责把订单对象转换成给前端展示的视图——一个原因可以。当然这句话只是个粗糙的过滤器。真正的判断还要看它是否朝同一个方向演化。两个职能如果总是同步变化强行拆开反而增加成本如果它们各自因为独立的原因变化就必须拆。这就是后面要展开的核心。2. 为什么这两种坏味道总是成对出现2.1 从变化原因倒推代码的边界讲清楚逻辑之前先把一个容易混的概念理一理。发散式变化和霰弹式修改看起来一个聚得太狠、一个散得太开好像是两个极端但它们指向的是同一个缺失——代码的切分维度和业务的变化维度不重合。我用一组正交坐标打个比方。把系统想象成一张表横轴是业务功能下单、支付、发货、对账纵轴是技术切面校验、持久化、计算、通知、埋点。理想情况下每个模块应该是一小块功能 × 切面的正交矩形。但现实里大多数系统是按功能切好然后在每个功能的实现里横向堆技术细节——OrderService里又有校验又有持久化又有通知。这种切法对业务按功能演化是友好的但对技术按切面演化是灾难税务规则一变所有功能模块都要动霰弹式修改一个模块里的技术细节变多了它就变成一个什么都管的巨无霸发散式变化。所以切边界的原则不是功能或技术二选一而是先找到那些独立变化的方向。独立变化的方向就是可以独立演进、独立部署、独立测试的维度。财务规则和数据库表结构各自变化的触发条件完全不同那它们就应该在代码里被物理隔开一个改了不牵动另一个。这就是 SRP 真正想要的东西。判断代码有没有切歪我习惯用一个改动收敛测试假设明天来了一个需求——把满减门槛从 100 调到 200我应该改几个文件理想答案是 1 个。如果答案是 5 个说明这个变化轴被切碎了是霰弹式修改。同一套逻辑反向用假设明天财务说发票规则调整我又要改几个文件如果每次财务调整我都得进OrderService里翻半天说明这个变化原因没有被独立出来是发散式变化。这个测试可以纯靠脑补不需要真的去改适合在 Code Review 或设计评审时快速过一遍。2.2 判断职责边界的四个实用抓手光讲原则容易虚落到代码上我总结过四个比较粗糙但好用的抓手配合使用。抓手一看谁会来要求改。如果某个类的修改需求来自三个不同的业务方或角色那它就承担了至少三个职责。这个抓手在需求文档里就能判断不需要看代码。抓手二看节奏。有些代码变动很频繁——促销规则、运营配置有些代码几乎不变——订单表结构、用户基础字段。变化节奏差一个数量级的东西就不该住在同一个类里。改变慢的被改变快的拖着一起发布本身就是风险。抓手三看修改需要知道什么知识。改税率需要懂税法改 SQL 需要懂表结构改消息格式需要懂下游系统的契约。如果改一个类里的不同方法需要调用三种完全不同的知识库那这个类里其实住着三个人。抓手四一句话描述不能带和。前面提过的过滤器作为快速筛查很方便。这四个抓手不是非此即彼更像是交叉验证。当一个类的四个抓手全都指向多重职责时重构优先级排到最前面一点不用犹豫。当一个类只有一个抓手亮红灯时我通常先放着记录下来等下一个变化来验证。急于求成地拆往往是把发散式变化治成了霰弹式修改。2.3 一个经常被忽略的补充视角变化方向比变化频率更重要最后补一点很多文章不讲的东西。不要只按变化频率拆代码。变化频率高的东西拆出来独立演进这没错但如果两个高频变化的原因是耦合的——比如税率和发票抬头永远一起改——那你把它们拆开等于人为制造霰弹式修改。真正该比的是变化的原因是否独立。频率只是它的一个副产品。我在一个金融项目里见过把利率计算和还款计划生成拆成两个模块的做法理由是利率经常变。结果每次利率调整还款计划模块也必须同步改。因为利率变了还款计划必然变这两个东西共享同一个变化原因拆开纯属自找麻烦。后来还是合回去作为还款策略这一个职责统一管理改动反而收敛了。判断标准其实可以浓缩成一句话如果原因 A 变化时B 大概率不跟着变那 A 和 B 就是两个职责如果 A 变了 B 总要变那它们是一个职责。这话听着像废话但在评审会上拿它反问一句改这个的时候那个是不是一定也要动往往能立刻把设计争论掰回正轨。3. 实战把一坨万能订单服务拆开3.1 案发现场一个 1200 行的 OrderService先看一段简化后的现场代码。这个类来自一个电商系统去掉细节后大概长这样Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private InventoryClient inventoryClient; Autowired private CouponMapper couponMapper; Autowired private MqProducer mqProducer; public OrderVO createOrder(CreateOrderCmd cmd) { // 1. 参数校验 if (cmd.getUserId() null) throw new BizException(用户为空); if (cmd.getItems() null || cmd.getItems().isEmpty()) throw new BizException(商品为空); // 2. 优惠计算 BigDecimal discount BigDecimal.ZERO; if (cmd.getCouponId() ! null) { Coupon coupon couponMapper.selectById(cmd.getCouponId()); if (coupon.getThreshold().compareTo(cmd.getTotalAmount()) 0) { discount coupon.getAmount(); } } // 3. 库存扣减 inventoryClient.deduct(cmd.getItems()); // 4. 落库 Order order new Order(); order.setUserId(cmd.getUserId()); order.setAmount(cmd.getTotalAmount().subtract(discount)); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); // 5. 发送消息 mqProducer.send(order.created, order.getId()); // 6. 埋点 log.info(order_created, userId{}, amount{}, order.getUserId(), order.getAmount()); // 7. 组装 VO 返回 OrderVO vo new OrderVO(); vo.setOrderId(order.getId()); vo.setPayAmount(order.getAmount()); return vo; } }这个类有多条变化原因用上一节列的抓手扫一遍四个全部亮红灯变化原因触发角色修改频率涉及方法变化方向优惠规则调整运营高优惠计算段业务规则库存交互协议变更供应链中库存扣减段外部契约订单表结构变更DBA低落库段持久化下游消息格式变更数据/中台中消息发送段外部契约埋点字段调整数据高埋点段观测返回字段调整前端高VO 组装段接口契约六个变化原因挤在一个方法里且分属六个不同角色。这就是标准的发散式变化。更糟糕的是如果这时候系统里还有另一个OrderQueryService也要读订单、也要组装 VO那么返回字段调整这个需求就得同时改两个地方——霰弹式修改也跟着来了。3.2 第一步列出所有变化原因写成清单重构最忌讳一上来就动代码。我的习惯是先花半小时把变化原因整理成上面那张表标清楚每一行对应代码里的哪一段。这张表本身就是重构路线图——它告诉我要拆成几块哪些块该合并哪些变化原因可以共用。整理的时候有三个原则按角色分不按功能分。运营要改和前端要改对应两个不同的变化原因即使它们改的是同一段代码也要分开考虑。能合并的合并。如果两个变化原因总是同步发生比如新增订单字段和新增导出字段先合并成一行避免拆得过细。标注优先级。高频变化的先拆低频且稳定的可以留到最后甚至不拆。这一步不需要任何工具一张纸或者一个 Markdown 表格就够。它的价值在于拆之前你先想清楚了目标形态而不是边拆边想。3.3 第二步小步抽取每一步都能编译和测试有了清单接下来就是按变化轴抽取。关键原则是小步走每抽一次都能编译、能跑通测试而不是一次改到底。下面按顺序讲我实际的操作步骤。第一步抽 OrderValidator。参数校验是纯入参逻辑不依赖任何外部资源最安全。用 IDE 的 Extract Class把校验段移到新类OrderService里改成调用validator.validate(cmd)。编译、跑测试。第二步抽 OrderPricingCalculator。优惠计算也是纯计算只依赖CouponMapper。抽出来之后OrderService只拿到一个discount结果不再关心优惠怎么算。Component public class OrderPricingCalculator { Autowired private CouponMapper couponMapper; public BigDecimal calcDiscount(CreateOrderCmd cmd) { if (cmd.getCouponId() null) return BigDecimal.ZERO; Coupon coupon couponMapper.selectById(cmd.getCouponId()); return coupon.getThreshold().compareTo(cmd.getTotalAmount()) 0 ? coupon.getAmount() : BigDecimal.ZERO; } }第三步抽 OrderRepository。把OrderMapper的直连改成通过OrderRepository这样以后 DBA 改表结构只需要动 Repository不会透传到业务层。这一步很多团队会省觉得Mapper 已经够了但实际项目里把字段映射和业务规则隔开的那层薄薄封装长期看收益非常明显。第四步抽 OrderNotifier。消息发送和埋点合并成一个通知职责因为它俩的变化原因都是外部需要知道订单创建了。如果消息格式和埋点字段各自独立变化可以再拆成两个但在这个例子里它们经常同步改合在一起反而更好。第五步抽 OrderAssembler。VO 组装独立成一个类前端改字段的时候只动它。抽完之后OrderService变成这样Service public class OrderService { Autowired private OrderValidator validator; Autowired private OrderPricingCalculator calculator; Autowired private OrderRepository repository; Autowired private OrderNotifier notifier; Autowired private OrderAssembler assembler; public OrderVO createOrder(CreateOrderCmd cmd) { validator.validate(cmd); BigDecimal discount calculator.calcDiscount(cmd); Order order repository.save(cmd, discount); notifier.onCreated(order); return assembler.toVO(order); } }主流程从 1200 行变成十几行每一个变化原因都找到了自己的落点。这时候再去改优惠规则动的是OrderPricingCalculator改落库结构动的是OrderRepository前端改字段动的是OrderAssembler。每个变化原因只对应一个文件这才是改动收敛。3.4 第三步定义接口锁定依赖方向抽完类之后还有一步不能省给依赖方抽象出接口尤其是持久化和外部调用。理由不是因为面向接口这个教条而是因为接口能锁定依赖方向防止上游的细节泄露到调用方。具体做法定义OrderRepository接口实现在OrderRepositoryImpl里直接调用 Mapper。OrderService只依赖接口。这样以后想把存库换成分库分表或者加一层缓存都不用动业务代码。同理OrderNotifier定义成接口Mq 实现和埋点实现分开将来要接入第二个消息通道也不用改主流程。同时从整个模块的外部看可以对外暴露一个粗粒度的OrderFacade内部各个小类作为实现细节藏起来。对外粗粒度、对内细粒度是避免霰弹式修改的关键——外部调用者只依赖 Facade 一个入口内部怎么拆它不关心也不需要跟着改。3.5 第四步三维验证确认拆分真的有效拆完之后必须验证不能凭感觉说好多了。我用三个维度验证维度一功能验证。所有已有测试全绿。如果之前没有测试重构的第一步其实是补测试——这一点再怎么强调都不过分。没有测试护栏的重构本质是赌博。维度二变化收敛验证。模拟三个需求加字段、改优惠、改消息格式确认每个需求只改一个文件。这个验证可以走一遍 Diff看改动的文件数是否符合预期。维度三依赖方向验证。检查有没有反向依赖——比如OrderRepository里引用了OrderService的类。有的话说明边界又被打穿。用 IDEA 的 Analyze Dependencies 或者简单的依赖检查工具就能扫出来。三个维度都过了这次重构才算站得住。4. 拆分手法与粒度权衡4.1 常用重构手法对照表真正落地的时候SRP 不是靠喊口号实现的而是靠一串标准手法。这些手法大多来自 Martin Fowler 的《重构》我按实操场景整理成下面这张表手法适用场景操作要点常见坑Extract Class一个类里存在多条变化原因按变化原因分组字段和方法整体搬出去抽得太细反而增加跳转成本Extract Method方法太长内部有清晰的阶段性按语义切段别按行数机械切抽出的方法名太抽象如 doPart1Move Method方法用了太多别的类的数据移到数据所在类消除 Feature Envy移完后出现循环依赖Extract Interface需要锁定依赖方向用例驱动不是给每个类都建接口接口里塞了实现细节方法Replace Conditional with Polymorphism大量按类型分支的条件判断每个分支抽成一个策略类分支少且稳定的场景不值得Split Phase一个方法先处理又决策又执行拆成决策阶段和执行阶段中间数据结构设计不当反而传递复杂Parameter Object一长串参数在同一组里反复出现封装成对象对象名起得太泛如 Param这张表建议打印出来贴在工位上重构的时候照着挑。不要在同一时间用三种以上手法否则出了问题很难定位是哪一步引入的。4.2 拆到什么粒度算够三个经验阈值单一职责没有标准答案我用了几年后总结出三个大致阈值供参考一个类改动时涉及的场景原因不超过 1 个。这是最硬的标准前面反复说。一个类代码规模不超过 500 行方法数不超过 15 个。这不是精确指标超过之后就值得审视一下是不是又长出了新的职责。一个方法不超过 50 行且圈复杂度不超过 10。超过就抽 Extract Method 或者策略。这三个阈值不追求越小越好。我见过有人把每个方法控制在 3 行以内结果代码像跳棋一样跳来跳去读起来比一百行方法还累。拆的目的是降低认知负担不是降低代码行数。一个 400 行但逻辑高度一致的类比四个 100 行、互相调用成一团乱麻的类要好维护得多。一个更实用的判断当你用 IDE 的Find Usages追一个功能的时候如果追了两个类还没找到底说明拆过头了如果在一个 800 行的类里找了半天说明拆得不够。这个感觉比任何指标都好用靠的是每天写代码积累的手感。4.3 别忽视工具让 IDE 和静态扫描替你盯着手工判断职责容易受主观影响善用工具能省一大半力气。IDEA 的重构功能要练熟。Extract ClassCtrlAltShiftT、Move MethodF6、Extract Interface这些是重构的主力用熟了能避免手工搬代码时的各种低级失误。特别推荐Move Method和Pull Up在抽接口、抽父类的过程中非常好用。SonarQube 之类的静态扫描关注类复杂度方法复杂度重复代码。这些指标并不直接检测 SRP但超标的类往往是职责过多的信号。设置一个合理的阈值告警长期看是有价值的。Git 变更热点分析是我最喜欢的工具之一。用几行脚本统计最近半年某个目录下各文件的提交次数次数特别高的文件基本就是变化集中地。用前面说的变化原因表对照一下往往能立刻定位到需要重构的类。工具只是辅助但配合前面讲的方法论效率能提一截。5. 常见问题与踩坑实录5.1 常见问题速查表重构过程中会遇到的问题我整理成下表遇到时可以直接对号入座现象可能原因排查思路处理方式拆完之后改动的地方更多了拆得过细切碎了同一个变化轴检查新类之间是不是总是一起改合并回同一个职责类越来越多调用链越来越长只拆了类没有定义粗粒度入口看外部调用方是否要注入五个以上依赖加 Facade 收口拆完之后出现循环依赖拆错了方向职责边界跨了用依赖分析工具扫一遍重新找变化轴或者引入中间层新类里全是 getter/setter业务逻辑还留在原类只抽了数据没抽行为检查方法是否还在操作别人的数据用 Move Method 把行为搬过去重构完成后 bug 变多没有测试护栏看重构前后的测试覆盖率先补测试再重做一遍时间不够拆到一半一次性拆太多看还有多少未完成回滚到上一个能跑通的状态分批次拆5.2 几个必须讲的坑坑一为了单一职责把 CRUD 拆成四个 Service。我在一个项目里这么干过。用户模块被拆成UserQueryService、UserCreateService、UserUpdateService、UserDeleteService外加三个 DTO 转换类。结果加一个删除用户时校验是否有订单的需求要同时改四个类还得调整它们的依赖关系。CRUD 是操作类型不是变化原因。按操作拆等于把同一个变化轴切碎是典型的把发散式变化治成霰弹式修改。坑二接口拆得太细调用方负担爆炸。有个团队把订单相关的依赖拆成了OrderRepository、OrderCacheRepository、OrderLogRepository三个接口每个方法都很小。结果业务类要注入七个依赖每次启动都担心循环。接口应该按调用方视角拆分而不是按实现方视角。调用方通常想要一个能读能写订单的东西而不是三个分开的东西。坑三领域对象全拆成贫血模型业务逻辑散落到 Service。这是从发散式变化跑向另一个极端。Order类里只有 getter/setter所有业务规则都写在OrderService里。表面上看Order类是单一职责了实际上所有变化原因又都挤回了 Service等于绕了一圈回到原点。领域对象应该承载属于它自己的业务规则Service 负责编排。判断标准是一条规则如果永远只作用于订单这个对象它就应该在 Order 里。坑四重构没有测试护栏越改越乱。这是最致命的。我见过团队为了赶进度在没有测试的情况下重构一个核心模块改完之后线上连出三个 bug最后回滚。重构的前提是行为不变而证明行为不变只能靠测试。没有测试的地方先写特征测试Characterization Test把当前行为锁定再开始拆。坑五把重构和业务需求混在一个 PR 里。这是我最想强调的一条。重构 PR 里混了业务改动Code Review 的时候没人能分清哪些是行为不变的调整哪些是行为变更。一条 PR 只做一件事重构就是重构需求就是需求分开走出问题的时候好定位也好回滚。5.3 一条我用了很多年的自查清单重构之前我会过一遍下面这个清单五条里超过两条回答否就不动手先补功课有测试覆盖这次要改的代码吗没有就先补。能说清楚这次要消除的是哪一条变化原因吗说不清说明还没想透。拆完之后一个典型需求会在几个文件里改动预判应该收敛到 1~2 个。调用方是否会因此注入更多依赖如果会先设计 Facade。这次改动会不会跟业务需求混在一个 PR会就先分拆。写业务代码久了会发现真正拖慢项目的往往不是技术难题而是那些每次都要改一遍的地方。发散式变化和霰弹式修改名字听起来抽象但它们描述的场景你每周都在经历。识别它们不需要什么高级工具一张变化原因表、一次 Git 历史翻看、一个改这个需要动几个文件的自我提问就能把问题看得七七八八。至于要不要拆、拆到什么程度我个人更倾向小步多次而不是一次性大手术——每次只解决一条变化原因让它先跑一阵子看它是否真的收敛了再决定下一步。这样做的好处是即使某一步判断错了回退的成本也就是一次提交而不是一整个季度的重构计划。
返回列表