尧图精选

软件设计七大原则深度解析:从可维护性到工程实践

🕒 发布时间:2026/10/1 3:50:23 📁 来源:尧图网络
三年前我接了一个订单模块的维护当时只是要加一种新的支付方式结果改了五个类还把另一个渠道的成功回调带出了问题。事后复盘问题根本不在支付方式本身而在于整个模块的扩展方式就是往上堆 if-else。类似经历踩多了我才真正把软件设计七大原则当成一回事。它们不是用来应付软考中级软件设计师的条条框框而是在你每次“加一个功能”的时候帮你少踩一次坑。这篇文章结合代码实例把这七条原则完整梳理一遍。不管是准备考试还是日常写业务代码都能对照着看看。核心目的只有一个让程序改得动、测得了、敢上线。1. 先看清楚可维护性差到底差在哪1.1 三个典型的“坏味道”很多项目刚上线时跑得挺好半年之后就成了谁都不敢碰的代码。可维护性差通常不是某个功能写错了而是代码结构在一点点腐烂。我总结下来最典型的三个征兆第一个是“改一行崩三处”。比如全局有个订单状态字段某天为了新需求给它加了一个枚举值结果支付回调、对账报表、用户消息推送全都跟着出了状态判断问题。原因是这些逻辑都直接依赖一个可变的状态字段而不是依赖稳定的行为接口。第二个是“新功能无处安放”。想加一个会员折扣翻半天代码才发现价格计算逻辑分散在 Service、工具类、甚至 SQL 里都有真正动手的时候只能凭感觉到处补丁。第三个是“单元测试无处下笔”。方法里面又读数据库又发短信又扣库存测试想 mock 其中一个依赖必须把整个环境都搭起来。这种代码不是不能跑而是没人敢重构最后只能继续往上叠补丁。1.2 七大原则不是规则是观察视角软件设计七大原则包括单一职责原则、开闭原则、里氏替换原则、依赖倒置原则、接口隔离原则、迪米特法则和合成复用原则。很多人觉得它们是一个清单写代码时逐条检查其实不是。它们更像一组“观察视角”帮助你从不同维度发现代码里的问题单一职责原则看的是“类内部是否太杂”开闭原则看的是“扩展时要不要改老代码”里氏替换原则看的是“继承关系是否安全”依赖倒置原则看的是“依赖方向是否合理”接口隔离原则看的是“接口大小是否合适”迪米特法则看的是“对象之间的社交范围”合成复用原则看的是“复用手段是否选对”。这七条彼此有联动。开闭原则是目标单一职责、依赖倒置、接口隔离是手段里氏替换是继承的安全底线迪米特法则管好耦合边界合成复用则提醒你别动不动就继承。实际写代码时不需要每一条都生搬硬套但一旦出现“改不动、测不了、不敢上线”的苗头就该用这些视角去检查代码。2. 单一职责原则 接口隔离原则先把“职责”划清楚2.1 单一职责一个类真的只有一个改变理由吗单一职责原则是指一个类应该只有一个引起它变化的原因。听起来抽象落到实处就是当你需要修改一个类时应该只有一个理由去改它。举个最常见的反面例子订单类public class Order { private double price; private int count; public double calculateTotal() { // 计算商品总价 return price * count; } public void saveToDb() { // SQL 插入订单表 // jdbcTemplate.update(insert into orders ...); } public void sendNotification() { // 发短信提醒用户 // smsClient.send(您的订单已生成); } }这个类里面有三个职责价格计算、持久化、消息通知。以后改打折规则要改这里改表结构要改这里换短信服务商还要改这里。三个修改理由挤在一个类里任何一个改动都可能影响另外两个功能。重构思路是拆分public class Order { private double price; private int count; // 只保留订单自身的数据和行为 } public class OrderCalculator { public double calculateTotal(Order order) { return order.getPrice() * order.getCount(); } } public class OrderRepository { public void save(Order order) { // 只负责持久化 } } public class OrderNotifier { public void sendNotification(Order order) { // 只负责通知 } }这样每个类只有一个修改理由。以后价格规则变了只改 OrderCalculator数据库变了只改 OrderRepository。拆完之后每个类也更容易测试。这里要提醒一句单一职责不是“越细越好”。我曾经见过有人把User类拆成UserBasicInfo、UserDetailInfo、UserLoginInfo结果反而让代码碎片化。判断标准很简单问自己一句“这个类有几个引起变化的原因”回答是一个就够了。如果两个原因总是同进同退拆开反而制造麻烦。2.2 接口隔离不要给实现类塞一堆用不上的方法接口隔离原则说的是客户端不应该被迫依赖它不需要的接口方法。翻译成大白话就是你设计接口时别把所有人都用不上的方法也塞进去。看一个典型例子把“工人”抽象成一个接口public interface Worker { void work(); void eat(); void sleep(); }人实现这个接口没问题因为人需要吃和睡。但假如系统里还有机器人工人public class RobotWorker implements Worker { Override public void work() { System.out.println(机器人工作); } Override public void eat() { throw new UnsupportedOperationException(机器人不需要吃饭); } Override public void sleep() { throw new UnsupportedOperationException(机器人不需要睡觉); } }这就是典型的胖接口。调用RobotWorker的人用不上eat和sleep但仍然必须实现它们甚至还要抛异常。以后如果有人遍历所有 Worker 调用eat()机器人就崩了。按接口隔离原则应该拆开public interface Workable { void work(); } public interface Restable { void eat(); void sleep(); }人实现两个接口机器人只实现 Workable。这样一来每个客户端只依赖它真正需要的方法接口的变化不会波及无辜的实现类。需要注意接口隔离和单一职责容易混淆。单一职责关注的是“一个类该不该做太多事”接口隔离关注的是“调用方需要什么能力”。实践中我通常先想清楚调用方视角谁会调用这个接口他需要哪些能力把不同客户端需要的不同能力拆开而不是把所有可能的操作都写进一个通用接口。3. 开闭原则 依赖倒置原则让扩展不靠“改老代码”3.1 开闭原则加功能尽量不动已稳定的代码开闭原则是七大原则里最出名的对扩展开放对修改关闭。意思是当系统需要增加新功能时应该通过新增代码来实现而不是修改已经测试通过的旧代码。支付场景是最常见的反例。很多项目一开始只有支付宝public class PaymentService { public void pay(String type, Order order) { if (alipay.equals(type)) { // 调用支付宝 SDK } else if (wechat.equals(type)) { // 调用微信支付 SDK } } }第一次加微信支付你在这段代码里加了一个 else if。第二次加银联又加一个。每加一种支付方式都要重新测试整个方法。而且随着渠道增多这个方法的循环复杂度会越来越高最终没人敢动。用开闭原则改造把支付行为抽象出来public interface Payment { void pay(Order order); } public class AlipayPayment implements Payment { Override public void pay(Order order) { // 支付宝支付逻辑 } } public class WechatPayment implements Payment { Override public void pay(Order order) { // 微信支付逻辑 } }支付入口不再关心具体类型public class PaymentService { private Payment payment; public PaymentService(Payment payment) { this.payment payment; } public void checkout(Order order) { payment.pay(order); } }以后新增一种支付方式只需要新增一个类实现 Payment 接口然后在组装处换一个实现即可。老的支付类和 PaymentService 都不需要改这就是“对修改关闭对扩展开放”。但开闭原则不是免费的。提前抽象需要成本而且你不可能预判到所有变化点。我个人的判断标准是同一个功能点已经出现第二次不同形态时再抽象也不迟。第一次写死第二次开始抽象第三次扩展收益就很明显。3.2 依赖倒置高层模块不要依赖低层模块依赖倒置原则的核心是高层模块不应该依赖低层模块两者都应该依赖抽象抽象不应该依赖细节细节应该依赖抽象。这个“倒置”不好理解。举一个老式代码public class OrderService { private JdbcOrderRepository jdbcOrderRepository new JdbcOrderRepository(); public void saveOrder(Order order) { jdbcOrderRepository.save(order); } }问题很明显OrderService 是高层业务逻辑直接依赖了底层数据库实现 JdbcOrderRepository。以后想换成 Redis 缓存库、或者换成内存存储做测试就得改 OrderService 里的 new。更麻烦的是订单数据来自 MySQL 还是文件这个决策不该由高层业务逻辑来做。引入抽象public interface OrderRepository { void save(Order order); } public class JdbcOrderRepository implements OrderRepository { Override public void save(Order order) { // 数据库保存 } }OrderService 不再依赖具体实现而是依赖接口public class OrderService { private final OrderRepository orderRepository; public OrderService(OrderRepository orderRepository) { this.orderRepository orderRepository; } public void saveOrder(Order order) { orderRepository.save(order); } }“倒置”倒在哪原来 OrderService 直接指挥 JdbcOrderRepository依赖方向是高层指向低层现在低层 JdbcOrderRepository 反而要去实现 OrderService 依赖的接口依赖方向反过来了。这是在写代码时最应该培养的直觉不要在高层业务逻辑里去 new 一个具体低层组件把具体选择权交出去。依赖倒置原则带来的直接好处就是可测试性。测试时我可以传一个内存版的 OrderRepository不需要启动数据库也不用 mock 一堆静态方法。真实项目里 Spring 的依赖注入只是实现手段原则本身和框架无关。3.3 这两个原则组合起来的威力开闭和依赖倒置经常一起出现。因为要做到“扩展开放”通常就得让上层依赖抽象接口而不是依赖具体类。比如一个订单处理流程既有支付又有通知public interface OrderNotifier { void notify(Order order); }支付成功后OrderService 既不直接 new SmsNotifier也不直接 new EmailNotifier而是构造时传入一个 OrderNotifier。这样一来通知方式的变化不会污染订单主逻辑主逻辑也不会被具体通知渠道绑架。两者配合才能让系统像插卡一样换实现。4. 里氏替换原则 迪米特法则继承与“朋友”之间都要有边界4.1 里氏替换原则子类必须能替换父类而不出错里氏替换原则说所有引用父类的地方必须能透明地使用子类对象。替换后程序行为不能变坏。如果替换后行为异常说明继承关系设计有问题。最经典的例子是矩形和正方形。public class Rectangle { private int width; private int height; public void setWidth(int width) { this.width width; } public void setHeight(int height) { this.height height; } public int getWidth() { return width; } public int getHeight() { return height; } } public class Square extends Rectangle { Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); } Override public void setHeight(int height) { super.setWidth(height); super.setHeight(height); } }数学上正方形是特殊的矩形但继承之后就出事。看这段“修改高度并重新计算面积”的逻辑public class AreaCalculator { public int calculateArea(Rectangle rectangle) { rectangle.setWidth(5); rectangle.setHeight(10); return rectangle.getWidth() * rectangle.getHeight(); } }如果传入 Rectangle结果是 50如果传入 Square因为 setHeight 把宽度也改成 10结果是 100。出现了明显错误。问题就出在 Square 继承 Rectangle 时改变了父类对 setWidth、setHeight 的行为约束破坏了父类的不变量。正确的建模方式是用组合而不是继承public class Square { private int side; public void setSide(int side) { this.side side; } public int getSide() { return side; } }不要用继承去表达“正方形是一种矩形”除非父类的所有行为约束都能安全适用于子类。里氏替换原则不只是理论实践里有一个特别有效的检查方式把父类写好的单元测试原封不动地拿到子类上跑一遍如果挂了或者需要改测试才能过那就说明继承关系有问题。4.2 迪米特法则只和直接朋友说话迪米特法则又叫最少知识原则。核心意思是一个对象应该尽量少了解其他对象只和自己的直接朋友通信。直接朋友包括当前对象本身、通过参数传入的对象、成员变量指向的对象、方法返回的对象。看一个反例订单处理里直接掏用户钱包public class Wallet { private double balance; public void subtract(double amount) { balance - amount; } } public class Customer { private Wallet wallet; public Wallet getWallet() { return wallet; } } public class OrderProcessor { public void process(Customer customer, double amount) { // 反例越过 Customer直接操纵它的内部钱包 customer.getWallet().subtract(amount); } }OrderProcessor 知道得太多了。它知道 Customer 内部有一个 Wallet还知道 Wallet 有个 subtract 方法。一旦 Customer 内部存储改为支付宝账户或者钱包换成了信用卡OrderProcessor 就得跟着改。修正方式是让 Customer 提供一个行为public class Customer { private Wallet wallet; public void pay(double amount) { wallet.subtract(amount); } }OrderProcessor 只需要调用public class OrderProcessor { public void process(Customer customer, double amount) { customer.pay(amount); } }这样 OrderProcessor 不知道 Customer 内部是钱包还是信用卡耦合度大幅降低。迪米特法则应用时特别容易走极端。我见过有人为了避免链式调用给每一层都写了转发方法结果类数量翻倍代码绕来绕去更难读。实际落地时我的经验是不禁止点号但警惕“一对多”的链式访问比如a.getB().getC().getD().pay()。超过两层就要问一下这个中间对象是不是被我当成了传话筒是不是该由更靠近数据的对象提供行为4.3 继承误用与过度中介化里氏替换和迪米特法则都容易误用。前者容易让人“为了继承而继承”把父子之间的关系只停留在概念层面后者容易让人“为了解耦而解耦”加一堆中介类。两个问题的根源是一样的没有想清楚对象之间的真实关系。继承只能用在真正的 is-a 关系上同时要保证父类的行为契约能被遵守通信边界则要按职责来划分不是按层级来划分。实际开发里我会把继承当成一种重活每写一个 extends 都问自己如果明天父类改一个方法签名哪些子类会遭殃如果答案是“很多且互不相同”那大概率应该把公共行为抽取成接口或者组合对象。反过来迪米特法则也不是让你完全不调用 getter而是不要让外部对象根据 getter 结果去编排内部逻辑。5. 合成复用原则优先组合而不是继承5.1 为什么“继承复用”有坑合成复用原则说尽量使用对象组合而不是类继承来达到复用的目的。因为继承是静态的子类在编译期就绑定了父类行为而组合是动态的可以在运行时替换行为。继承复用的坑用鸭子例子上最直观。假设一个游戏里有一堆鸭子。有的会飞有的不会飞有的会叫有的不会叫。如果纯用继承先定义一个鸭子基类public class Duck { public void fly() { System.out.println(飞); } public void quack() { System.out.println(叫); } }不会飞的鸭子就得重写 fly 的方法public class RubberDuck extends Duck { Override public void fly() { // 什么都不做 } Override public void quack() { System.out.println(橡皮鸭吱吱叫); } }如果再来一种“会飞但不会叫”的鸭子又要创建一个子类去重写 quack。当行为维度变多类数量会爆炸。而且父类一旦加了新行为所有子类都被迫响应哪怕它们根本不需要。5.2 用组合解决“类爆炸”组合的思路是把变化的行为抽成接口在鸭子里持有接口对象public interface FlyBehavior { void fly(); } public class FlyWithWings implements FlyBehavior { Override public void fly() { System.out.println(用翅膀飞); } } public class FlyNoWay implements FlyBehavior { Override public void fly() { System.out.println(不会飞); } }鸭子类不再自己实现飞行逻辑而是把飞行行为“组合”进来public abstract class Duck { protected FlyBehavior flyBehavior; public void setFlyBehavior(FlyBehavior flyBehavior) { this.flyBehavior flyBehavior; } public void performFly() { flyBehavior.fly(); } }以后要造一只“会飞但不会叫”的鸭子不需要新建类只需要在创建实例时给它传入 FlyWithWings再配上合适的叫声行为即可。这样类数量不会爆炸行为也可以运行时替换。合成复用原则的关键是区分“有一个”和“是一个”。一只鸭子“有一个”飞行行为所以该用组合一个订单“是一个”支付请求才适合用继承。大多数业务代码里组合都比继承更灵活因为组合不破坏封装也不依赖父类内部实现。5.3 什么时候仍然要用继承组合优于继承不是“永远不要继承”。框架编程里继承仍然很重要。比如 Spring 的抽象类、模板方法模式中的基类这些场景父类定义了稳定的算法骨架子类只需要填充少量步骤。这种继承是安全的因为父类行为契约稳定且子类不会改变核心流程。我的判断标准是三条一是确确实实存在 is-a 关系二是父类行为足够稳定不需要频繁变化三是子类不会暴力和父类对着干。如果三条都满足继承可以省不少代码。如果有一条不满足优先考虑组合。6. 综合实战用七大原则重构一个“订单通知模块”6.1 原始代码的问题分析把前面几条原则串起来看一段典型的“能跑但难维护”的订单服务代码public class OrderService { public void checkout(Order order, String payType) { double total order.getPrice() * order.getCount(); if (total 100) { total total * 0.9; } // 扣库存 // 保存订单到数据库 // 发短信 // 记日志 if (alipay.equals(payType)) { // 支付宝 } else if (wechat.equals(payType)) { // 微信 } } }这段代码至少违反五条原则。第一一个方法里混了价格计算、库存、持久化、通知、日志、支付节点太多违反单一职责。第二新加支付方式要改这个老方法违反开闭原则。第三OrderService 直接依赖具体数据库和短信客户端违反依赖倒置。第四如果以后有人在继承 OrderService 时改变了价格计算方式替换后行为可能不一致违反里氏替换原则的潜在风险。第五通知逻辑直接写死无法动态组合违反合成复用原则。6.2 重构后的设计结构把职责拆开之后整体设计可以是这样DiscountCalculator接口负责价格计算比如满减、会员折扣Payment接口负责支付渠道OrderRepository接口负责持久化Notifier接口负责用户通知。订单主流程只编排流程不关心细节public interface DiscountCalculator { double calculate(Order order); } public interface Payment { void pay(Order order); } public interface OrderRepository { void save(Order order); } public interface Notifier { void notify(Order order); }实现类按需要补充public class DefaultDiscountCalculator implements DiscountCalculator { Override public double calculate(Order order) { double total order.getPrice() * order.getCount(); if (total 100) { total total * 0.9; } return total; } } public class SmsNotifier implements Notifier { Override public void notify(Order order) { // 发短信 } } public class EmailNotifier implements Notifier { Override public void notify(Order order) { // 发邮件 } }最终订单服务变成public class OrderService { private final DiscountCalculator discountCalculator; private final Payment payment; private final OrderRepository orderRepository; private final Notifier notifier; public OrderService(DiscountCalculator discountCalculator, Payment payment, OrderRepository orderRepository, Notifier notifier) { this.discountCalculator discountCalculator; this.payment payment; this.orderRepository orderRepository; this.notifier notifier; } public void checkout(Order order) { double total discountCalculator.calculate(order); order.setFinalPrice(total); orderRepository.save(order); payment.pay(order); notifier.notify(order); } }6.3 重构后如何扩展现在加一种新的通知方式比如站内信只需要新增一个InAppNotifier实现 Notifier然后在组装 OrderService 时换掉或组合进去一点也不碰 OrderService。加一种新的支付方式也只需要新增一个 Payment 实现类。加新的折扣策略额外加一个 DiscountCalculator 实现就可以。如果某个实现想复用已有的通知能力可以把多个 Notifier 组合成一个 CompositeNotifierpublic class CompositeNotifier implements Notifier { private final ListNotifier notifiers; public CompositeNotifier(ListNotifier notifiers) { this.notifiers notifiers; } Override public void notify(Order order) { for (Notifier notifier : notifiers) { notifier.notify(order); } } }这种组合方式比“在 SmsNotifier 里再调用 EmailNotifier”要干净得多。OrderService 自始至终只知道一个 Notifier不需要知道里面有短信还是邮件还是站内信。这让单元测试也变得非常简单测订单流程时传一个不做事的内存 Notifier测短信时单独测 SmsNotifier 即可。6.4 设计原则带来的可维护性变化重构之后再看最初的问题。加支付方式时不需要再改老的checkout方法改短信渠道时不影响订单计算想换数据库只动 OrderRepository 实现。每个类都只跟一个稳定的抽象协作继承和组合边界也清楚。这个例子基本可以把七大原则全部落地一遍单一职责负责拆类开闭负责扩展路径依赖倒置负责高层和低层解耦接口隔离保证 Notifier 和 Payment 各自独立里氏替换通过接口实现而不是继承来规避行为破坏迪米特法则让 OrderService 只跟四个抽象朋友协作合成复用则体现在 CompositeNotifier 和策略组合上。说到底这些原则不是互相孤立的它们最后都在为一个目标服务让代码在需求变来变去时保持稳定和可预测。7. 常见问题与排查技巧实录7.1 考试和面试里常问的几个点软考中级软件设计师和不少公司面试都喜欢拿设计原则做文章。常见问法无非几种。一种是给你一段代码问违反了哪些原则。比如一个类里有业务逻辑又有数据库访问那么答案通常是单一职责原则一个方法里用 if-else 判断支付类型那开闭原则大概率被违反高层 Service 直接 new 低层 DAO那就是依赖倒置原则的问题。这类题目本质上是考验你能否识别坏味道而不是背原则定义。另一种是对比题比如“依赖倒置和开闭原则有什么区别”或者“里氏替换原则在实际中怎么落地”。我的回答思路是开闭关注扩展方式依赖倒置关注依赖方向里氏替换关注继承安全。各说一个例子比单纯背定义有说服力得多。还有一种情况我看到有些备考者把“单一职责”和“接口隔离”弄混急着背定义。其实只要记住单一职责是类层面的接口隔离是调用方视角的。这两个在面试里被单独拎出来的概率挺高最好准备一个能同时说明两者的例子比如前面那个 Worker 例子就可以两处使用。7.2 实际工作里的高频坑原则听着好用起来容易出问题。我踩过的坑和观察到的坑有这几个第一个坑是接口隔离过头。一个业务对象拆了十几个接口每个接口只有一个方法实现类 implements 一大堆。接口数量多到没人记得住反而增加理解成本。正确做法是只对变化点拆稳定的公共能力没必要拆太碎。第二个坑是单一职责过度拆分。有的团队把“用户注册”拆成五个类每个类只有一个方法方法之间还得互相依赖。代码是听话了但人看着崩溃。单一职责拆的是修改理由不是把每个操作都拆成单独类。第三个坑是盲目追求开闭。新项目刚开始需求还不稳定就急着为所有逻辑加接口、加工厂。等需求一变化发现抽象层自己也要大改抽象反而成了负担。我的习惯是让代码先简单跑起来第二次出现相同变化点时再抽象。第四个坑是里氏替换“能跑就当没事”。很多继承问题不是运行时马上报错而是某个边界条件下行为不一致。比如子类重写父类方法时悄悄忽略了参数校验。排查这种问题最好的办法是把父类的测试用例直接套到子类上跑。第五个坑是迪米特法则变成了“层层转发”。每次要调一个方法就加一个中间方法最后形成一条很长的调用链。这没有降低耦合只是把耦合藏得更深。迪米特法则关注的是“不要让外部对象操作内部细节”不是“禁止一切跨层访问”。第六个坑是合成复用变成了“凡是继承都反对”。框架里的模板方法、抽象基类其实还是在用继承只是用得克制。不要为了“组合优于继承”这句话把所有抽象基类都改成接口加组合那样往往会丢失算法骨架复用的好处。7.3 如何让七大原则平稳落地想让这些原则真正改善代码而不是变成口头禅有四个实操建议。第一先写测试再重构。没有测试兜底任何设计原则都是空谈。哪怕只是抽取一个方法也要先确认现有行为被测试锁住。第二从坏味道出发找重构点。模型理不清时先别急着套原则。看看有哪些类既算价格又发消息哪些方法有超过三层以上的 getter 链路哪些 if-else 分支还要继续加。找到了坏味道再决定用哪条原则去修。第三把原则当成 code review 的检查项。Review 时看到 new 一个具体实现、看到子类重写父类后改变父类约束、看到接口里有无人使用的方法都可以当场指出来。慢慢地团队就会形成共同的设计语言。第四结合设计模式一起理解。策略模式的核心就是开闭原则和依赖倒置模板方法模式是继承复用的典范装饰器模式是组合复用和开闭原则结合的例子适配器模式又常和接口隔离有关。模式是原则的具体招式原则是模式背后的心法两者一起学理解会快很多。我在实际项目里最深的一个体会是设计原则不是写代码时束缚手脚的枷锁而是重构时照亮方向的灯。没有灯的时候新功能总是不知道往哪里放有了灯你至少知道哪条路更稳。每当你面对“又要加需求了老代码却无从下手”的时候想想这七条绝大多数卡点都能找到出口。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →