资讯中心

SOLID五原则实战:从代码坏味道到可维护架构的重构指南

📅 2026/9/29 7:24:56
SOLID五原则实战:从代码坏味道到可维护架构的重构指南
1. 先把 SOLID 这张地图摊开讲清楚1.1 一次改需求改到凌晨的经历我印象最深的一次加班不是因为需求有多难而是因为一个三百多行的OrderService类。产品要加一种新的会员折扣规则我打开这个类发现里面塞了参数校验、库存扣减、支付调用、短信通知、日志埋点、优惠计算六件事。改折扣逻辑的时候手一抖碰到校验分支隔天测试反馈库存扣减偶尔会重复执行。那一刻我才真正理解 SOLID 五大原则存在的意义——它不是面试背的概念而是用来回答一个非常现实的问题当需求变化时我希望只改一处而不是牵一发动全身。SOLID 是五个面向对象设计原则的首字母缩写最早由 Robert C. Martin 系统整理后来 Michael Feathers 把五个首字母拼成了这个好记的词。它包含单一职责原则SRP、开闭原则OCP、里氏替换原则LSP、接口隔离原则ISP、依赖倒置原则DIP。这五个原则解决的不是代码写得漂不漂亮而是代码在时间维度上的抗腐蚀能力。写完就能跑的代码到处都是能在两年里被十几个不同的人反复修改还不出事的代码才是这套原则真正的战场。这篇内容适合三类人刚学完面向对象语法、感觉会用类了但又好像没用对的初学者负责维护老项目、被改一处崩三处折磨过的中级开发以及需要做代码评审、想给团队一套统一评判标准的技术负责人。我会尽量用图解式的拆解、反面案例和可复制的重构手法把这五条讲成能直接上手用的东西而不是五句正确的废话。1.2 五个字母分别在管什么先用一张文字化的结构图把全局摆出来后面每一章再逐个展开需求变化 | ---------------------------- | | | 变化的原因 扩展的方式 替换的安全性 | | | SRP OCP LSP (谁来负责) (怎么加功能) (能不能换掉) | | | ---------------------------- | 接口与依赖的边界 | ISP DIP (接口多细) (指向谁)对应到每个原则的核心疑问和典型症状可以看下面这张对照表原则一句话核心违反时的典型症状主要收益SRP 单一职责一个模块只有一个被修改的理由一个类几百行改A功能影响B功能改动影响范围可控OCP 开闭原则对扩展开放对修改关闭每加一种类型就要动老代码的 if-else新增功能不动老逻辑LSP 里氏替换子类必须能替换父类而不破坏契约子类重写方法后抛异常或改语义多态调用可放心使用ISP 接口隔离客户端不该依赖它用不到的方法接口方法一大堆实现类大量空实现接口小而专耦合低DIP 依赖倒置高层和低层都依赖抽象业务层直接 new 具体数据库实现可替换、可测试看这张表你会发现五条原则其实是在回答同一个问题的五个侧面如何把变化关进笼子里。SRP 管的是变化的归属OCP 管的是变化的入口LSP 管的是变化的兼容性ISP 管的是变化的传播面DIP 管的是变化的传导方向。理解了这条主线后面所有的技巧都是它的注脚。1.3 三个常见的误解先摆出来在展开讲之前有三个误解必须先纠正否则后面越学越拧巴。第一个误解是SOLID 是必须全部满足的硬性标准。实际不是。它是权衡工具不是法律条文。在一个人维护的小脚本里强行套五条原则最后得到的是一堆只被调用一次的接口和工厂类复杂度比业务本身还高。我的一般经验是代码的预期寿命越长、修改它的人越多SOLID 的收益才越明显。第二个误解是遵循 SOLID 就意味着类会变得很多、文件会变得很碎。类变多是表象不是目的。目的是让每个变化点有独立的落脚处。如果拆完之后你发现要跳五六个文件才能看懂一次下单流程那说明拆分维度选错了——大概率是按代码行数拆而不是按变化原因拆。第三个误解是把这五条当成孤立的技巧背。它们之间有强联动DIP 用得好OCP 自然容易实现ISP 做得好LSP 的违反概率会明显下降而 SRP 是所有原则的地基。后面我会在综合案例里把这种联动关系具体演示一遍。2. 单一职责原则SRP先搞清楚职责到底指什么2.1 职责不是功能数量而是变化的来源很多人第一次看 SRP 的原始定义一个类应该只有一个引起它变化的原因会觉得这句话说了等于没说。因为在直觉里功能和变化原因是两码事。我举个具体的例子你就明白了一个UserService里有register()和sendWelcomeEmail()两个方法看起来是两个功能。但如果它们的变化来源不同——注册逻辑跟着业务规则变邮件模板跟着市场和运营变——那这个类就有两个职责应该拆开。判断标准不是这个类有几个方法而是问一句哪一类人、因为什么原因会要求我修改这段代码。财务改税率、运营改文案、运维改日志格式、安全团队改加密算法这些不同的诉求如果都指向同一个类那这个类就是职责过载的。功能多但变化来源单一其实不算违反 SRP功能少但变化来源有三四个照样违反。我在实际项目里用过一个很土但很准的自检方法把类的每个方法列出来在旁边标注谁最可能要求改这个方法。如果标注出来的角色超过两个基本可以确定要拆。这个方法不需要任何工具白板上三五分钟就能做一次比事后读几百行代码判断快得多。2.2 现场拆解一个典型的上帝类看一个被我简化过的真实案例。这是改造前的版本用 Java 写public class OrderService { public void placeOrder(Order order) { // 1. 参数校验 if (order.getItems() null || order.getItems().isEmpty()) { throw new IllegalArgumentException(订单不能为空); } if (order.getAmount() 0) { throw new IllegalArgumentException(金额不合法); } // 2. 库存扣减 for (Item item : order.getItems()) { jdbcTemplate.update(update stock set qty qty - ? where sku ?, item.getQty(), item.getSku()); } // 3. 调用支付 String result httpClient.post(https://pay.internal/charge, toJson(order)); if (!SUCCESS.equals(parse(result).get(status))) { throw new RuntimeException(支付失败); } // 4. 发通知 smsClient.send(order.getUserPhone(), 您的订单已支付成功); // 5. 记录日志 logger.info(order placed: {}, order.getId()); } }这个类的问题表面看是太长实质是它同时暴露给了四类变化业务规则的变化校验条件、库存策略的变化扣减方式、支付渠道的变化接口协议、通知方式的变化短信改推送。产品说支持货到付款要改它运维说支付网关换了要改它运营说短信成本太高改成站内信还要改它。每次改动都在同一个文件里叠加冲突和回归风险自然指数级上升。2.3 拆分粒度怎么定三个自检问题拆的方向通常是按变化来源切分落到代码上就是抽出协作对象。改造后的形态大致是这样public class OrderService { private final OrderValidator validator; private final InventoryService inventory; private final PaymentGateway payment; private final Notifier notifier; public OrderService(OrderValidator validator, InventoryService inventory, PaymentGateway payment, Notifier notifier) { this.validator validator; this.inventory inventory; this.payment payment; this.notifier notifier; } public void placeOrder(Order order) { validator.validate(order); inventory.deduct(order.getItems()); payment.charge(order); notifier.notifyPaid(order); } }改造之后OrderService的职责收敛成一句话编排下单流程。库存规则变了只动InventoryService支付渠道换了只动PaymentGateway的实现类。这个类本身反而变得很稳定一年可能都不需要改。粒度控制上我通常用三个问题来卡这个类里的方法是否都在操作同一组核心数据或同一个业务概念如果把其中一部分拆出去拆出去的部分是否自己就能形成一个完整的、可独立测试的单元拆完之后调用方是不是需要频繁地在两个类之间来回传数据如果是说明拆错了维度。第三条尤其重要。我见过有人把Order实体拆成OrderBase、OrderAmount、OrderAddress结果每次算个总价要跨三个对象取字段调用方苦不堪言。这不是 SRP这是拆碎。2.4 注意事项别让 SRP 变成过度设计提示SRP 判断的是变化的原因不是方法数量。一个类有二十个方法只要它们都服务于同一个业务概念、同一个变化来源就不算违反。第一个坑是按代码行数拆分。500 行不是罪如果这 500 行讲的是同一件事硬拆反而增加阅读成本。我的经验阈值是当一个文件里出现两个以上为什么这段代码会变的答案时才考虑拆。第二个坑是service 层无限膨胀。很多团队把所有业务逻辑都堆在XxxService里最后变成十几个上千行的类。真正的做法是让领域对象承担一部分行为Service 只负责编排和事务边界。第三个坑是拆完之后忘了收敛调用方。拆出十个类但调用方还是自己 new 一堆等于把复杂度从一个文件转移到了十个文件。拆必须配合依赖注入一起做这也是后面 DIP 那一章要讲的。3. 开闭原则OCP让新增功能只加代码不改老代码3.1 对扩展开放到底开放的是什么OCP 的完整表述是软件实体应该对扩展开放对修改关闭。这话听起来很别扭——功能总是要加的怎么可能不修改代码关键在修改这个词的指向OCP 要保护的是那些已经被大量调用、已经稳定运行的核心代码而不是禁止一切代码变更。换句话说新增一个业务能力时理想状态是你写一个新的类、注册进去老代码一行不动。这里的扩展点就是抽象。用生活中的类比插座是抽象电器是扩展。你要加一台新设备不需要拆开墙重铺电线插上就行。如果每加一个电器都要重新装修那就是典型的违反 OCP。判断是否违反 OCP 有个非常实用的信号老代码里出现了按类型分支的 if-else 或 switch而且这个分支随着新需求在持续增长。每来一种新类型就加一个 case这就是在逼迫你修改已经测试通过的代码。3.2 从 if-else 到策略模式的演进现场先看违反 OCP 的写法。假设做会员折扣public BigDecimal discount(User user, BigDecimal amount) { if (user.getLevel() Level.NORMAL) { return amount; } else if (user.getLevel() Level.SILVER) { return amount.multiply(new BigDecimal(0.95)); } else if (user.getLevel() Level.GOLD) { return amount.multiply(new BigDecimal(0.88)); } else if (user.getLevel() Level.DIAMOND) { return amount.multiply(new BigDecimal(0.8)); } throw new IllegalArgumentException(未知等级); }加一个黑卡等级就要回来改这个方法同时要保证不碰坏前四个分支。测试同学也得把所有等级重跑一遍。这就是对修改开放的典型代价。改造思路是抽出策略接口public interface DiscountPolicy { Level supportedLevel(); BigDecimal apply(BigDecimal amount); } public class GoldDiscount implements DiscountPolicy { public Level supportedLevel() { return Level.GOLD; } public BigDecimal apply(BigDecimal amount) { return amount.multiply(new BigDecimal(0.88)); } }然后维护一张映射表用构造函数或静态块把实现注册进去public class DiscountCalculator { private final MapLevel, DiscountPolicy policies new EnumMap(Level.class); public DiscountCalculator(ListDiscountPolicy list) { for (DiscountPolicy p : list) { policies.put(p.supportedLevel(), p); } } public BigDecimal calculate(Level level, BigDecimal amount) { DiscountPolicy policy policies.get(level); if (policy null) throw new IllegalArgumentException(未知等级: level); return policy.apply(amount); } }现在加黑卡只需要新增一个BlackCardDiscount类Spring 之类的容器会自动把它注入到 list 里DiscountCalculator完全不用改。老代码零改动新功能零风险这就是 OCP 的落地形态。3.3 注册表与插件式设计OCP 的进阶形态策略模式是入门级 OCP往上一层是注册表模式。当扩展点不止按类型分发而是需要动态加载、按优先级排序、甚至运行期热插拔时注册表会更合适。核心思路是扩展点用接口定义实现类通过某种机制自我注册核心流程只面向注册表编程。一个典型的注册表长这样public class PolicyRegistry { private static final MapString, DiscountPolicy REGISTRY new ConcurrentHashMap(); public static void register(String key, DiscountPolicy policy) { REGISTRY.put(key, policy); } public static DiscountPolicy get(String key) { DiscountPolicy p REGISTRY.get(key); if (p null) throw new IllegalStateException(策略未注册: key); return p; } }配合 SPI 机制或者配置驱动的加载器可以在启动时扫描所有实现类并注册。这种设计在规则引擎、支付渠道对接、多协议网关这类场景非常常见。它的好处不只是 OCP还顺带解决了 ISP 和 DIP 的问题——后面讲综合案例时会看到这一点。3.4 注意事项抽象不是免费的注意OCP 的价值在于未来会变化的维度。如果你非常确定某个分支永远不会再有新类型用 if-else 反而更清晰。为不存在的扩展做抽象是纯粹的负债。第一个坑是抽象层级选错。比如业务变化的是折扣算法你却把抽象点设在用户等级上结果新需求是同一个等级在不同活动下折扣不同抽象立刻失效。抽象要建在变化的那个维度上而不是建在表面的分类上。第二个坑是过早抽象。在只有两种类型时就抽出三层接口读代码的人要在三个文件之间跳转才能看明白。我的经验是同类型分支超过三个、或者你明确知道近期还会加才值得抽。第三个坑是忘了兜底。策略模式下如果新增类型没注册运行期会直接空指针或抛异常。所以要么在启动时做一次完整性校验要么提供默认策略别把错误留到线上。4. 里氏替换原则LSP子类能不能换掉父类4.1 契约式设计前置、后置与不变式LSP 的正式定义是子类型必须能够替换掉它们的基类型。这句话真正约束的是契约而不是语法。一个类继承另一个类语法上通过了行为上未必能替换。契约包含三部分前置条件调用方法前必须满足的条件子类不能加强它。父类说参数允许负数子类就不能改成只允许正数。后置条件方法返回后必须保证的状态子类不能削弱它。父类承诺返回非空列表子类就不能返回 null。不变式对象在生命周期内必须始终为真的约束子类不能破坏。比如账户余额永不为负。只要子类在这些条款上打了折扣多态调用就会出问题。而多态恰恰是面向对象最依赖的机制所以 LSP 事实上是多态能不能被信任的前提。4.2 经典案例正方形与长方形这个例子被讲烂了但它确实精准。设Rectangle有setWidth和setHeight并且有一个约定面积等于宽乘高且宽高互相独立。class Rectangle { protected int width, height; public void setWidth(int w) { this.width w; } public void setHeight(int h) { this.height h; } public int area() { return width * height; } } class Square extends Rectangle { Override public void setWidth(int w) { this.width w; this.height w; } Override public void setHeight(int h) { this.width h; this.height h; } }看语法完全没问题。但下面这段代码会挂void resize(Rectangle r) { r.setWidth(5); r.setHeight(4); assert r.area() 20; // 传 Square 进来结果是 16 }问题的根源在于几何上正方形是长方形但代码契约上并不成立因为Rectangle的隐含约定是宽高独立可变而Square强制宽高一致。前者被削弱了LSP 就被破坏了。修复方式不是硬改子类而是承认两者不是继承关系。可以用一个共同的抽象Shape各自实现area()interface Shape { int area(); } class Rectangle implements Shape { private final int w, h; Rectangle(int w, int h) { this.w w; this.h h; } public int area() { return w * h; } } class Square implements Shape { private final int side; Square(int s) { this.side s; } public int area() { return side * side; } }不可变对象 各自独立实现问题从根上消失。这个改造顺带也符合 SRP——每个类只对自己的形状规则负责。4.3 更贴近业务的例子账户与定期账户来看一个我项目里真实踩过的坑。有Account基类提供withdraw(amount)方法。后来加了FixedDepositAccount定期账户继承Account并重写withdrawclass Account { protected BigDecimal balance; public void withdraw(BigDecimal amount) { if (amount.compareTo(balance) 0) throw new InsufficientException(); balance balance.subtract(amount); } } class FixedDepositAccount extends Account { Override public void withdraw(BigDecimal amount) { throw new UnsupportedOperationException(定期账户不支持取现); } }调用方写了一段通用逻辑遍历所有账户做提现结果遇到定期账户直接炸了。这就是典型的 LSP 违反父类承诺所有账户都能取现子类直接把这个能力砍掉了。正确的做法是不要在Account上定义withdraw而是把它放到WithdrawableAccount接口上定期账户不实现这个接口。这就是 LSP 和 ISP 的联动——很多 LSP 问题根子都在接口设计太胖。4.4 注意事项继承是最强的耦合提示写子类时问自己三个问题——有没有加强前置条件有没有削弱后置条件有没有破坏不变式任何一个答案是有,就不要用继承。第一个坑是为复用而继承。看到父类有方法能省几行代码就继承过来这是最常见的 LSP 违反来源。复用应该优先用组合继承只用于确实是同一种东西的场景。第二个坑是重写方法抛异常。父类能做的事子类做不了用抛异常来回避本质是能力契约被破坏。碰到这种情况先反思抽象层级是不是错了。第三个坑是用 instanceof 判断子类型。如果你在多态调用里写了if (x instanceof Square)说明父类的抽象没覆盖真实需求多态已经名存实亡。这是一个非常灵敏的代码坏味道信号。5. 接口隔离原则ISP别让实现类为用不到的方法买单5.1 胖接口的真实代价ISP 说的是客户端不应该被强迫依赖它不使用的方法。这句话对两种人有害一是实现类被迫写大量空实现或抛异常的假方法二是调用方因为接口太大改动一个方法可能影响到不相关的调用者。最直观的例子是打印机。很多教材用Device接口演示interface Device { void print(String doc); void scan(String doc); void fax(String doc); }然后一台只能打印的低端设备要实现三个方法class SimplePrinter implements Device { public void print(String doc) { /* 真实实现 */ } public void scan(String doc) { throw new UnsupportedOperationException(); } public void fax(String doc) { throw new UnsupportedOperationException(); } }这两个抛异常的方法不是小问题。它们意味着调用方拿到一个Device引用后并不能安全地调用scan只能靠文档、靠约定、靠运行期试错。系统里的不确定性就是这样一点点累积起来的。5.2 拆分思路按客户端角色切不按实现类切拆接口最容易犯的错是按实现类拆那样拆出来的接口只对某一个实现有意义换个实现又要重拆。正确的做法是按调用方的角色拆。谁在用这个接口用它做什么就把这部分能力单独抽出来。以上面的设备为例三种能力对应三种调用者打印任务队列用Printer扫描服务用Scanner传真服务用Fax。interface Printer { void print(String doc); } interface Scanner { void scan(String doc); } interface Fax { void fax(String doc); } class SimplePrinter implements Printer { public void print(String doc) { /* ... */ } } class AllInOneMachine implements Printer, Scanner, Fax { public void print(String doc) { /* ... */ } public void scan(String doc) { /* ... */ } public void fax(String doc) { /* ... */ } }这样低端设备只实现Printer调用方也只依赖Printer谁都不需要为不存在的能力操心。而且以后加云打印设备只要实现Printer就能接入打印队列。5.3 用默认方法会更省事吗Java 8 之后有接口默认方法有人会想那我在胖接口里给scan一个默认实现抛UnsupportedOperationException不就行了我的看法是能用但只适合绝大多数实现都支持、极少数不支持的场景。如果一半以上的实现都不支持某个方法那这个方法就不该待在这个接口里。默认方法只是省了写代码的力气没有解决契约模糊的问题。判断标准可以简化成一句话如果一个接口的方法集合里存在一种合理的实现方式只用其中一部分那这个接口就该拆了。5.4 注意事项拆过头比不拆更麻烦注意ISP 的目标是接口刚好覆盖调用者的需求不是一个方法一个接口。后者会让系统里充满毫无意义的单方法接口反而增加认知负担。第一个坑是接口碎片化。我见过一个项目UserReader、UserWriter、UserNameReader各是一个接口最后类型声明长得像绕口令。合理的做法是按读写、按角色、按聚合粒度拆而不是按方法拆。第二个坑是公共接口被当成万能入口。很多团队会有一个CommonService或者BaseRepository什么方法都往里塞。这个接口的每次变更都会影响所有实现类是编译冲突的高发地带。碰到这种接口我一般会主动提出拆分收益非常明显。第三个坑是忽略调用方视角。同样是数据库访问后台管理页面需要分页、统计、导出前台业务只需要按 ID 查询。这两个角色就应该看到不同的接口哪怕底层是同一套实现。这属于同一份实现多个角色接口的常规做法用类实现多个接口就能做到。6. 依赖倒置原则DIP高层不依赖低层两者都依赖抽象6.1 先把三个容易混的词分清DIP、IoC、DI 这三个词经常被混着用实际是三个层级的概念。DIP依赖倒置原则一种设计原则。高层模块不依赖低层模块两者都依赖抽象抽象不依赖细节细节依赖抽象。它讲的是依赖的方向。IoC控制反转一种设计思想。把程序的控制权从代码内部交给外部框架或容器。它讲的是谁说了算。DI依赖注入IoC 的一种具体实现方式。通过构造函数、Setter 或字段把依赖传进来而不是自己 new。它讲的是依赖怎么进来。三者的关系是DIP 是目标IoC 是思路DI 是最常用的手段。实际项目里只要做到业务类依赖接口、接口通过构造函数传入基本就同时满足了 DIP 和 DI。6.2 从直接 new 到依赖注入的改造违反 DIP 的典型写法public class OrderService { private final MySQLOrderRepository repo new MySQLOrderRepository(); private final AliyunSmsSender sms new AliyunSmsSender(); public void place(Order order) { repo.save(order); sms.send(order.getPhone(), 下单成功); } }这段代码的问题在测试时暴露得最明显想跑一个单元测试必须先起数据库、配短信密钥否则类根本构造不出来。而且以后换数据库、换短信服务商都要回来改这个类。改造后public interface OrderRepository { void save(Order order); } public interface Notifier { void send(String phone, String msg); } public class OrderService { private final OrderRepository repo; private final Notifier notifier; public OrderService(OrderRepository repo, Notifier notifier) { this.repo repo; this.notifier notifier; } public void place(Order order) { repo.save(order); notifier.send(order.getPhone(), 下单成功); } }现在测试时传一个内存实现就行换数据库也不用动OrderService。依赖方向从业务层指向具体实现倒转成业务层和具体实现都指向接口这就是倒置两个字的由来。6.3 手工注入和容器注入什么时候用哪个依赖注入不必上框架。小项目里手工组装就够了public class App { public static void main(String[] args) { OrderRepository repo new MySQLOrderRepository(dataSource); Notifier notifier new AliyunSmsSender(smsConfig); OrderService service new OrderService(repo, notifier); service.place(new Order(/* ... */)); } }这种写法的好处是依赖关系一目了然任何一个类要什么看构造函数就知道。等对象图变复杂了再上容器。我一般建议依赖数量少于二三十个、生命周期简单时手工装配超过这个规模再引入容器。提前上容器调试时你会很难追踪某个对象到底是谁注入的。容器注入的典型形态就是给实现类加上注解让框架扫描并装配。注意容器只是工具它不会自动帮你设计出好的抽象。我见过不少项目用着容器业务类里照样new一堆东西DIP 一点没落地。6.4 注意事项抽象该归属谁提示接口应该定义在使用方所在的模块里而不是实现方。谁调用谁定契约这样依赖方向才是倒置的。第一个坑是接口跟着实现走。把OrderRepository接口放在数据库模块里结果业务模块依赖数据库模块方向还是没倒过来。正确做法是把接口放在业务模块或独立的领域模块里。第二个坑是抽象泄漏具体细节。接口方法签名里出现ResultSet、HttpServletRequest这类具体技术类型等于换汤不换药。抽象层要描述业务语义不是描述技术细节。第三个坑是构造函数参数过多。一个类需要注入七八个依赖通常说明它职责太重该拆了。这是 SRP 和 DIP 的联动信号我一般把五六个依赖当成一个需要警惕的阈值。7. 五条原则放在一起一个下单系统的改造实录7.1 改造前的状态用一个完整的小需求把五条串起来。需求是下单后要扣库存、走支付、发通知折扣规则随会员等级变化支付渠道将来可能增加通知方式将来可能增加。改造前的代码就是第 2 章那个OrderService一个类包办所有事折扣用 if-else支付和通知直接 new 具体实现。这个结构违反的情况是SRP职责过载、OCP加折扣要改老代码、DIP直接依赖具体实现、LSP 和 ISP 因为还没有继承和接口暂时不涉及。7.2 分四步改造第一步做 SRP 拆分。把校验、库存、支付、通知分别抽成独立组件OrderService只保留流程编排。这一步做完改动的影响范围从整体收敛到局部。第二步做 OCP 扩展点。折扣抽取DiscountPolicy接口支付抽取PaymentGateway接口通知抽取Notifier接口每种实现各自独立成类。以后加一种新折扣、新支付渠道只写新类。第三步做 DIP 注入。OrderService的构造函数接收接口不再自己 new。这样业务层对具体实现零依赖测试可以用假实现。第四步检查 LSP 和 ISP。检查所有实现类是否都能替换接口而不破坏契约比如PaymentGateway的charge方法所有实现都必须真正完成扣款不能有实现抛UnsupportedOperationException检查接口是否足够小Notifier只保留send不要塞进查发送记录重发这些只有部分实现需要的方法。改造后的结构用文字图表示------------------- | OrderService | 高层只懂流程 ------------------ | 依赖 ----------------------------------------- | | | | | OrderValidator Inventory PaymentGateway Notifier DiscountPolicy | | | | | 具体校验类 具体库存类 微信/支付宝 短信/推送 各等级折扣 (可扩展) (可扩展) (可扩展) ^ | 都实现同一组抽象7.3 改造前后对比维度改造前改造后新增折扣类型修改老 if-else全部回归新增一个类零改动切换支付渠道修改业务类新增实现并注册单元测试需要数据库和外部服务传假实现即可单次改动影响范围整个下单流程单个组件新人上手需读几百行主线代码按组件逐个理解这张表不是用来炫耀改造得多漂亮的它对应的是非常具体的成本回归测试工作量、上线风险、排障时间。SOLID 的收益最终都会折算成这三样东西。8. 常见问题与排查技巧实录8.1 常见问题速查表现象可能的违反原则排查动作一个类超过 500 行且方法主题分散SRP列出每个方法的变化来源同源才留每加一种类型就要改老代码OCP找出按类型分支的 if-else抽策略子类重写方法后抛异常LSP检查前置、后置条件是否被改变实现类里大量空方法ISP按调用方角色拆接口业务层直接 new 数据库对象DIP抽接口构造函数注入单测必须起数据库DIP同上把外部依赖替换为内存实现改一个功能连带改五六个文件SRP DIP先查职责是否过载再查依赖是否过紧这张表的使用方式是遇到具体症状再查不要教条式地全量重构。我见过团队喊着全面提升代码质量一次性重构两万行结果三个月没上线最后回滚。原则是用来指导局部改造的不是用来搞运动的。8.2 踩过的坑和实际技巧第一个坑是在错误的抽象层级上拆分。有一次我把订单金额计算拆成了五个类结果每次算一次总价要跨五个对象性能没提升可读性还下降了。后来回退成两个类只是把优惠计算单独抽出。教训是拆分的粒度要跟变化的频率匹配变化频繁的拆细一点几乎不变的可以合并。第二个坑是用接口包装但没有真正解耦。接口方法签名里带着具体框架的类型或者接口只有一个实现且永远不会变这种抽象是摆设。判断标准很简单如果这个接口的存在不能让你在测试里替换掉真实依赖那它的价值就很有限。第三个坑是忽略了数据层面的一致性。把库存扣减和支付拆成两个组件之后中间失败会导致状态不一致。这时候需要引入事务边界或者补偿机制。原则只解决结构问题不自动解决一致性问题。这部分必须单独设计别指望 SOLID 能包治百病。再分享几个实用技巧。做代码评审时我会重点看三处构造函数参数超过五个的类、方法名里带 And 的方法、参数里带布尔开关的方法。这三处几乎必然藏着 SRP 违反。重构老代码时我会先写测试再动结构因为 SOLID 改造本质是行为不变的结构调整没有测试兜底就是盲改。最后说一个我自己的经验。刚工作那几年我把 SOLID 当成必须遵守的规定写代码时总想着这里符不符合 OCP结果经常为了抽象而抽象代码写得很绕。后来慢慢想明白这五条原则真正的作用是在变化发生的时候帮你找到最小的改动面。所以判断该不该用某条原则最实在的标准就是问一句如果明天需求变了我现在这个写法要改几个地方如果一个地方就够那这个设计对你当下的项目就是合适的不用管它是否符合教科书上的哪一条。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案