ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

软件设计七大原则:从反例到正例,详解可维护代码的实践之道

软件设计七大原则:从反例到正例,详解可维护代码的实践之道 在我看过的项目里烂代码很少是“写”出来的大多数是“改”出来的。一个新系统立项时大家都挺兴奋架构也说得过去真正开始难受是在第三个版本之后——产品要加一个会员等级运营要改计费规则客户要求对接新渠道于是你发现每加一个需求都要在老代码里翻出三四个文件来动改完A功能B功能又莫名其妙挂了。软件设计7大原则说白了就是七种应对这种“改代码之痛”的姿势目标只有一个让程序的修改成本可控让它真正可维护。这篇文章我会把每个原则都配一组反例和正例代码从“为什么非要这样”的角度拆开来讲适合刚工作不久、想提升代码质量的开发也适合正在准备软考中级软件设计师的朋友拿来对着学。1. 先想清楚软件设计七原则到底在解决什么问题1.1 代码的“可维护”到底是什么一提可维护性很多人第一反应是“代码写得清楚、注释写得多”。但干过几年的人都知道注释清楚只是表面功夫。真正的可维护指的是当你面对一个需求变更时改动能不能被限制在一个很小的范围内。我拿我自己踩过的坑举个例子。之前维护过一个订单导出功能最初需求很简单从库里查订单生成Excel存到指定目录。第一版代码确实“能跑”一个类里塞了三个方法——查数据、转格式、写文件。第三个月需求来了Excel要换成PDF同时文件要传到OSS而不是本地目录。我改的时候发现调格式的地方牵扯到查询逻辑写文件的代码又和格式化的代码混在一起。改了两天还改出一个空指针。这就是没有设计原则的代价需求每变一次代码的脆弱性就指数级上升。所以我把“可维护”拆成四条标准加一个新功能尽量只新增代码少改老代码改一个具体需求能精准定位到一个类或一个方法不波及其他功能类的职责清晰看类名就能猜到里面大概有什么即使换掉底层实现上层业务不用跟着动。七条设计原则全部是在为这四条标准服务。1.2 七原则的一张速查表先给出一张总览表后面每一节再展开。这张表建议收藏写代码前扫一眼基本能规避大部分设计坏味道。原则一句话核心典型应用场景单一职责原则一个类只该有一个修改理由把报表的取数、格式化、输出拆成不同类开闭原则对扩展开放对修改关闭新增会员等级时只加策略类不改原方法里氏替换原则子类继承父类后不能破坏父类的行为契约正方形不要继承长方形接口隔离原则客户端不需要的接口方法就不该出现机器人不必实现 eat() 方法依赖倒置原则高层不依赖低层两者都依赖抽象Service 依赖 Repository 接口而非 MySQL 实现迪米特法则一个对象尽量少知道其他对象的内部结构收银台不要直接操作钱包的内部卡合成复用原则优先使用组合而非继承来复用行为DAO 持有 DatabaseHelper 而不是继承它这七个原则从来不是孤立的。开闭是目标抽象和多态是手段里氏替换是继承体系的安全阀依赖倒置和合成复用其实都在说同一件事——面向抽象编程少用继承传家宝。接下来我按“职责边界、继承体系、依赖管理”三条线来逐个拆解这也是我认为最好记、最好对照代码自查的顺序。2. 职责边界三原则单一职责、接口隔离、最少知道2.1 单一职责原则一个类只该有一个修改理由先看一段我经常在业务系统里见到的代码// 反例一个类承担三种职责任意一个改动都会波及另外两个 public class ReportService { public ListOrder queryOrders(Date start, Date end) { // 从数据库查询订单 } public String toHtml(ListOrder orders) { // 把订单列表格式化为 HTML } public void writeHtmlToFile(String html, String path) { // 把 HTML 写入文件 } }这三个方法各自负责一件事查数据、拼格式、写文件。表面上看每个方法都挺单一但这个类作为整体它有三个不同的修改理由数据库表结构变了要改它页面样式变了要改它存储路径规范变了还要改它。三个改动原因搅在同一个类里就会出现我前面说的连锁反应——改样式的时候不小心动了查询逻辑改存储的时候又影响了格式化。正例是拆开再用一个组装者去编排// 正例每个类只有一个修改理由 public class OrderQuery { public ListOrder queryByDate(Date start, Date end) { ... } } public class HtmlReportFormatter { public String format(ListOrder orders) { ... } } public class ReportFileWriter { public void write(String content, String path) { ... } } // 组装者只需要编排流程不关心细节 public class ReportService { private final OrderQuery query; private final HtmlReportFormatter formatter; private final ReportFileWriter writer; public ReportService(OrderQuery query, HtmlReportFormatter formatter, ReportFileWriter writer) { this.query query; this.formatter formatter; this.writer writer; } public void generateReport(Date start, Date end, String path) { ListOrder orders query.queryByDate(start, end); String html formatter.format(orders); writer.write(html, path); } }拆分之后每个类的改动原因都只有一个改动范围被锁死在单个类里。判断一个类是否违反单一职责有个很实用的土办法把这个类的方法名列出来看它们是不是服务于同一类需求提出者。比如“运营要改报表样式”和“开发要改数据库字段”这两个角色就是不同的修改来源那么对应的代码就不该长在同一个类里。要注意单一职责不是“一个类只写十行代码”。如果拆得过于零碎会出现满天飞的工具类反而增加理解成本。核心是看变更方向职责划分的粒度应当以“需求变更通常落在哪个边界上”为准。一个经常一起变化的职责就应该放在一起两个几乎不会同时变化、却会被不同角色不同节奏触发的职责就该分开。2.2 接口隔离原则不要强迫实现类干它不干的事接口隔离和单一职责是亲兄弟。单一职责管类接口隔离管接口的拆分。看典型反例很多人定义接口时喜欢“一把梭”// 反例一个接口塞入所有员工能力 public interface Worker { void work(); void eat(); } // 机器人被迫实现一个根本不存在的行为 public class Robot implements Worker { Override public void work() { ... } Override public void eat() { throw new UnsupportedOperationException(机器人不需要吃饭); } }代码里出现空方法体、直接抛 UnsupportedOperationException基本就是在交接口隔离的罚款。接口的本质是契约实现类就是签合同的乙方。你让乙方签一份包含它做不到条款的合同乙方就只能撒谎——这会造成两个问题调用方以为机器人也会吃饭于是写出需要所有 Worker 都实现 eat 的逻辑实现类又被迫吞掉异常运行时才炸出来。正例是把接口拆成粒度更小的能力声明// 正例接口按能力拆分客户端按需依赖 public interface Workable { void work(); } public interface Eatable { void eat(); } public class Robot implements Workable { Override public void work() { ... } } public class Human implements Workable, Eatable { Override public void work() { ... } Override public void eat() { ... } }这样写调度系统只需要依赖 Workable食堂系统只需要依赖 Eatable谁都不会被迫关心跟自己无关的能力。判断接口胖不胖可以看两件事实现类里有没有用不到的方法调用方有没有因为注入某个类型而带上了它根本不需要的依赖。有就说明接口该拆了。2.3 迪米特法则别隔着中间人拿东西迪米特法则还有个更直白的名字——最少知道原则。意思是一个对象对其他对象的结构知道得越少越好。反例代码我经常在业务里看到通常长这样// 反例收银逻辑穿透了 Customer 和 Wallet 两个中间对象 public class Cashier { public void charge(Customer customer, double amount) { Wallet wallet customer.getWallet(); Card card wallet.getMainCard(); if (card.getBalance() amount) { card.deduct(amount); } } }这个方法为了一次收银摸清了顾客有钱包、钱包里有主卡、卡里有余额三件事。一旦顾客以后改用脸部识别支付、或者钱包里没有主卡而是多张卡按策略选卡这里全部要改而且调用它的所有地方的代码全部要跟着改。更麻烦的是这条访问链路上任何一环的内部结构变化都会像地震一样传导到每一处“穿透式”调用。正例是让 Cashier 只和最直接的协作对象打交道// 正例顾客自己负责处理支付细节收银员只发起请求 public class Customer { private final Wallet wallet; public void pay(double amount) { wallet.deduct(amount); } } public class Cashier { public void charge(Customer customer, double amount) { customer.pay(amount); } }收银员依然收银但他不再关心顾客的钱放在哪张卡里。Wallet 怎么换实现都不影响 Cashier。迪米特法则不是在教你禁止使用 getter而是在提醒你当一个方法里出现连续两个以上 getXxx().getYyy().getZzz() 的调用链时就该考虑把业务行为上移到真正拥有数据的对象里让它自己开口说话。3. 继承体系的两个闸门开闭原则与里氏替换原则3.1 开闭原则加功能不动老代码开闭原则的表述很优雅对扩展开放对修改关闭。落到代码层面就是当你需要新增能力时优先写一个新类而不是去改一个已经验证过的老方法。来看最经典的折扣计算反例// 反例每来一个会员等级就要往这个方法里塞一个 if public double getDiscount(String userType, double price) { if (vip.equals(userType)) { return price * 0.8; } else if (superVip.equals(userType)) { return price * 0.6; } else { return price; } }这个方法本身短小精悍问题在于它的变化方向。下周产品说“加一个企业会员打七折”你就得打开这个被十个地方调用的方法往里面再插一个 else if。每多插一次就等于在一个稳定的算法里增加一个分支而分支是 bug 最容易藏身的地方——少一个括号、判断顺序错了、老用户命中了新分支这些全是实打实的线上事故。正例是把用户类型变成一类扩展点用接口和多态把“变化”挡在外面// 正例策略接口 独立实现类 public interface DiscountStrategy { double calculate(double price); } public class VipDiscountStrategy implements DiscountStrategy { Override public double calculate(double price) { return price * 0.8; } } public class SuperVipDiscountStrategy implements DiscountStrategy { Override public double calculate(double price) { return price * 0.6; } } // 以后新增企业会员只需再写一个实现类原代码零改动 public class EnterpriseDiscountStrategy implements DiscountStrategy { Override public double calculate(double price) { return price * 0.7; } }把这段代码放到上下文里调用方依赖的是 DiscountStrategy 接口运行时传入不同的实现即可。这就是“开闭”的含义新增一种折扣老代码一行不改只加一个新类。开闭原则是所有设计模式的终极目标你在学策略模式、模板方法、装饰器模式的时候会发现它们内部藏着的都是这条原则。这里要澄清一个常见误解开闭原则不是说你写出来的代码永远不能改。如果一个类本身就还在高频变化、需求还没定型强行抽象只会拖慢节奏。开闭的适用前提是“这块逻辑已经稳定但未来可能出现新变体”。3.2 里氏替换原则子类别拆父类的台里氏替换原则是继承体系里最容易背会、也最容易写错的一条。它说所有能使用父类对象的地方都应该能无缝替换成子类对象。教科书级的反例就是正方形继承长方形// 反例正方形通过重写方法维持“长宽相等”却破坏了父类行为契约 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 getArea() { return width * 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 void resize(Rectangle rectangle) { rectangle.setWidth(5); rectangle.setHeight(4); // 调用者按长方形逻辑推断面积是 20 } Rectangle rectangle new Square(); resize(rectangle); // 实际面积变成了 16Rectangle 这个父类隐含了一个契约setWidth 只修改宽setHeight 只修改高二者互不影响。Square 为了维持自身约束悄悄把这个契约改掉了。调用方基于父类契约写下的业务逻辑在子类身上全线崩溃于是你只能被迫去写 if (obj instanceof Square) 这样的分叉判断。这一写开闭原则也被顺带破坏了——每次新增子类调用方都要加一个判断分支。正例是抽象一个真正的“几何形状”接口让每个形状自己负责自己的面积计算// 正例继承关系只在真正存在is-a语义时使用 public interface Shape { double getArea(); } public class Rectangle implements Shape { private double width; private double height; public Rectangle(double width, double height) { this.width width; this.height height; } Override public double getArea() { return width * height; } } public class Square implements Shape { private double side; public Square(double side) { this.side side; } Override public double getArea() { return side * side; } }Square 和 Rectangle 确实都有“形状”的能力但它们没有谁该继承谁的关系。在设计继承结构时我问自己最多的一句话是这个子类替换掉父类之后调用方已有的行为假设还会成立吗如果答案是“不会”那它就不是继承关系最多是一个相似实现。很多时候你发现自己想从父类那里复用的只是几个方法这种情况我不建议用继承这也正好引出后面要讲的合成复用原则。4. 依赖管理两原则面向抽象编程组合优于继承4.1 依赖倒置原则高层不依赖低层都依赖抽象依赖倒置原则听起来有点绕但它解决的是我见过最多的架构病业务层代码里直接 new 了一个具体实现然后整条业务被这一个实现绑架。经典反例// 反例业务类直接依赖 MySQL 的具体 DAO public class OrderService { private MySQLOrderDao dao new MySQLOrderDao(); public void saveOrder(Order order) { dao.save(order); } }这个写法在项目初期没问题但你要换数据库、要引入缓存、要做读写分离的时候就会很痛苦。OrderService 构造时直接 new 死了 MySQLOrderDao换 MongoDB 就要改 OrderService 的代码再加一层 Redis 缓存还要再改一次。问题不在于换数据库本身而在于“高层的订单流程”不该依赖“低层的存储细节”。订单流程是稳定业务存储方式是可替换细节稳定的一方被不稳定的一方绑架这就是依赖倒置违反的典型症状。正例是让上层依赖抽象接口具体实现在运行时注入// 正例定义仓储接口让业务依赖抽象 public interface OrderRepository { void save(Order order); } public class MySQLOrderRepository implements OrderRepository { Override public void save(Order order) { ... } } public class MongoDBOrderRepository implements OrderRepository { Override public void save(Order order) { ... } } public class OrderService { private final OrderRepository repository; // 构造器注入运行时决定具体实现 public OrderService(OrderRepository repository) { this.repository repository; } public void saveOrder(Order order) { repository.save(order); } }以后加缓存实现也好换数据库也好OrderService 一行不用动只需要在启动配置层面把新的实现类传进来。这正是依赖倒置的核心含义高层策略和低层细节都要面向抽象编程而抽象不依赖细节。顺便说明很多人学 Spring 时候把 Autowired 当成“省去 new 的语法糖”其实它服务的正是依赖倒置原则——让对象在运行时获得抽象实现而不是在编译期写死具体类。判断自己有没有违反 DIP有个成本很低的检查方式看你的 Service 类里 import 语句中依赖的是业务包的接口还是具体框架的类。4.2 合成复用原则能组合就不继承合成复用原则是我在代码评审里提醒最多的原则“你这个继承是为了复用方法还是为了表达 is-a 关系多半是前者那就改成组合。”反例很常见。项目里有个 DatabaseHelper 负责拿连接有人希望 DAO 能用上图省事直接继承// 反例为了复用连接逻辑而继承 Helper 类 public class DatabaseHelper { public Connection getConnection() { ... } } public class UserDao extends DatabaseHelper { public void saveUser(User user) { Connection conn getConnection(); ... } }继承的方式先把 UserDao 和 DatabaseHelper 牢牢焊死。今天 UserDao 想换个连接池实现没门因为继承关系在编译期就固定了。更麻烦的是 Java 只能单继承UserDao 有了这个爹就不能再从其他基类拿能力。等 OrderDao、ProductDao 一个个复制这种继承写法时DatabaseHelper 的任何改动都会沿着继承树传播到所有 DAO风险完全不可控。正例是把 DatabaseHelper 变成 UserDao 的一个成员用组合的方式复用能力// 正例把依赖作为成员变量持有按需调用 public class UserDao { private final DatabaseHelper helper; public UserDao(DatabaseHelper helper) { this.helper helper; } public void saveUser(User user) { Connection conn helper.getConnection(); ... } }组合同样也能复用方法但耦合比继承低得多DatabaseHelper 换实现不影响 UserDao 的类层次UserDao 需要其它能力随时可以再注入其他组件不受单继承限制。“组合优先于继承”不是一条禁止继承的禁令继承仍然适合真正的 is-a 建模比如鸭子继承鸟类、正方形实现形状接口。但凡是你能说清“不是亲父子关系只是想让这个类蹭点现成方法”请首选组合。依赖倒置和合成复用放在一起看会发现它们方向一致——都在逼你把代码从“面向具体实现”扭转到“面向接口、按需组装”。这也是为什么优秀的框架代码里很少有深不见底的继承链倒是一堆接口配合构造器注入在互相协作。5. 把七个原则放进同一个真实项目里5.1 一个订单系统的多原则混用示例学设计原则最怕的事就是背得滚瓜烂熟真写代码时一条都用不上。我拿一个最小可运行的订单结账场景把七个原则同时塞进去让你感受一下它们是怎么合体的。public class OrderService { private final OrderRepository repository; private final DiscountStrategy discountStrategy; private final Notifier notifier; public OrderService(OrderRepository repository, DiscountStrategy discountStrategy, Notifier notifier) { this.repository repository; this.discountStrategy discountStrategy; this.notifier notifier; } public void checkout(Cart cart) { // 只做流程编排不掺和计算和存储细节 Order order new Order(cart.getItems()); order.applyDiscount(discountStrategy.calculate(order.getAmount())); repository.save(order); notifier.send(订单已创建, order); } }就这么一个小方法里面同时体现了至少六条原则OrderService 只负责编排流程不碰折扣算法细节、不碰存储细节、不碰通知细节——单一职责repository、discountStrategy、notifier 都是接口类型具体实现通过构造器注入——依赖倒置以后新增“满减”“限时折扣”只需要新增 DiscountStrategy 实现类OrderService 和已有策略一行不改——开闭只要策略实现遵守“输入金额、返回优惠后金额”的契约替换任意策略都不会破坏订单流程——里氏替换OrderService 不需要知道订单最终是存进 MySQL 还是 Kafka、也不需要知道通知走短信还是邮件它只调用接口方法——接口隔离和迪米特法则repository 是注入进来的组件而非继承父类拿来的能力——合成复用。这套设计带来的实际收益是订单流程稳定后之后三个月里几乎没再动过 OrderService 本体所有新需求都在新增策略和新增实现类里完成。这个思路不只适用后端。就算写前端组件比如 tablist 这类标签页组件同样的原则照样管用。反例是渲染函数里写一堆 switch(tab.type) 来分发不同 tab 内容每新增一种 tab 类型就要改渲染函数正例是让每个 tab 实现同一个渲染接口外层循环调用新增 tab 就是新增一个实现组件。设计原则从来不绑定技术栈它绑定的是变化。5.2 原则“打架”时的判断标准七个原则在理想情况下配合默契但落到真实项目里经常会出现两条原则互相拉扯的局面。这里列几个我实际遇过的抉择点供你参考。单一职责和接口隔离拆得太细会让类数量暴涨。我见过一个项目把一个支付流程拆成了二十多个类和接口结果是没人看得懂整体链路。我的判断标准是看“变更频率是否一致”一组方法在可预见的三个月内总是因为同一个原因一起改动就不要拆如果它们各自由不同角色、不同节奏触发变更就必须拆。迪米特法则要求少知道别人的内部结构但为了少知道而加一层门面对象可能会带来额外的方法调用损耗。放在高性能场景里分布式框架底层的字节码和热路径代码不可能全部拿设计原则套一遍。我的取舍依据是那一层的对象结构是否稳定。Customer 的钱包结构大概率会变值得隔离底层 IO 结构相对稳定那就不必为了“最少知道”硬包一层。开闭原则和写抽象的成本天然冲突。每个接口、每个抽象类都是现在多写一层而它的收益只有未来某个需求来临时才会兑现。过度设计项目的通病就是为永远不会来的变化预埋抽象。我在判断“要不要抽象”时看重需求方向是否明确已经确认“未来确实要扩展”时才动手仅仅“觉得以后可能变”我建议先按最简单的方式写等第二个真实需求出现时再重构。5.3 软考中级视角高频考点与易混淆点如果你在准备软考中级软件设计师设计原则这部分是上午选择题的常客下午案例分析里也经常让你指出某段设计违反了哪条原则。考点本身不难难在四个字措辞严谨。我从历年的易错选项里挑了这几组常见混淆列成表格给你常见错误说法正确理解开闭原则就是软件发布后不能修改开闭原则是“对扩展开放、对修改关闭”指的是已有的稳定逻辑尽量通过扩展实现新需求不是禁止修 bug依赖倒置就是依赖接口所以 dao 改成接口就够了依赖倒置还要求细节同样依赖抽象且业务层不直接创建具体实现运行时注入才算完整落地接口隔离原则和单一职责原则完全一样单一职责针对类和模块接口隔离针对接口的方法集合大小两者是不同层面的约束里氏替换原则要求子类方法必须和父类实现完全相同子类可以有自己的实现细节但必须保持父类的行为契约不破裂合成复用原则就是尽量用继承来复用代码正相反优先使用组合只有真正符合 is-a 关系才用继承备考时建议把每个原则的“定义关键词”标出来比如开闭里的“扩展/修改”、里氏里的“替换/契约”、依赖倒置里的“抽象/细节”选择题里很多陷阱就是把关键词换掉、或者反过来表述。理解设计原则不是为了答题但答好这类题的前提是你确实能在代码里识别出这些坏味道。最后说一点我自己的落地习惯。我不太建议在项目里搞“设计原则大扫除”一次性把所有代码推倒重写那样风险极高。更稳妥的做法是把七条原则变成代码审查时的一个检查清单每次提交前问自己三个问题——这次修改会不会扩散到不止一个类新需求到来时是老代码里加分支实现还是新增一个实现类调用方对内部结构的了解是不是太多了只要这三个问题的答案出现“扩散”“分支”“穿透”就说明这里该停下来做一次小重构。我这些年的体会是设计原则不是背出来的信仰而是在一次次“改代码太疼了”的教训里慢慢长进手感的。
返回列表