云计算百科
云计算领域专业知识百科平台

设计模式 01 · 开篇:七大设计原则

这是「设计模式拆解」系列的第一篇。很多人学设计模式,是抱着一本 GoF 二十三式,一个一个背名字、抄 UML,背完发现工作里既想不起来用、也看不懂别人为什么这么写。问题出在顺序反了:设计模式不是凭空发明的招式,而是一群人反复踩坑之后,对"怎么写代码才不容易烂"这件事总结出的套路。而这些套路背后,是更底层的一层东西——设计原则。原则是"为什么",模式是"怎么做"。不先把原则的地基打牢,后面二十四篇模式看着就全是零散的技巧。

所以这一篇不讲任何一个具体模式,只做一件事:把公认的七大设计原则(SOLID 五条 + 迪米特法则 + 合成复用原则)一条条讲透。而且不是干巴巴地念定义,而是拿一段"能跑但很烂"的订单代码,让它在每一条原则的拷问下暴露出一个具体的毛病,再重构掉。整个设计模式系列都会围绕这个订单场景展开,正好从这里开个头。

这篇文章按这条线索展开:先说清楚设计模式到底在跟什么东西作斗争(答案是"变化"和"耦合");再摆出那段烂代码;然后逐条上原则,每条都对应烂代码里的一处具体病灶,讲清它是什么、违反了会怎样、怎么改;最后把七条原则收拢成一张全景图,并且泼一盆冷水——原则是用来权衡的,不是用来无脑堆砌的,过度设计比不设计还糟。

目录

  • 设计模式到底在跟什么作斗争
  • 先看一段"能跑但很烂"的订单代码
  • 单一职责原则:一个类只干一件事
  • 开闭原则:对扩展开放,对修改关闭
  • 里氏替换原则:子类要能顶替父类
  • 依赖倒置原则:都依赖抽象,别依赖细节
  • 接口隔离原则:别让人依赖用不到的方法
  • 迪米特法则:只和直接朋友说话
  • 合成复用原则:多用组合,少用继承
  • 七条原则串成一张图,以及一句泼冷水
  • 一、设计模式到底在跟什么作斗争

    先想清楚一件事:如果需求永远不变,代码写完再也不改,那世界上根本不需要设计模式。你把所有逻辑一路写到底、全塞在一个 main 方法里,只要能跑出正确结果,就是完美的代码。

    设计模式的全部价值,都建立在"需求一定会变"这个前提上。 今天只支持微信支付,明天要加支付宝;这个月订单只有"实物商品",下个月要卖虚拟卡券;这个季度是单机,下个季度要拆微服务。代码的生命周期里,写的时间是一次性的,改的时间是无限的。真正让项目烂掉的,从来不是第一次没写好,而是每次改动都像拆炸弹——动一个地方,三个不相干的功能跟着崩。

    所以设计的核心目标可以浓缩成两个词:

    • 应对变化:把"容易变的部分"和"相对稳定的部分"隔开,让变化被控制在一个小范围里,而不是四处扩散。
    • 降低耦合:模块之间的连接越少、越松,一处改动波及的范围就越小。

    七大原则,本质上全是围绕这两个目标展开的具体手段。你会发现它们彼此高度相关,甚至有点互相重叠——因为它们本来就是从不同角度描述同一件事:怎么让代码在面对变化时,改得更少、崩得更少。 记住这条主线,下面每条原则你都能自己推出来,而不用背。

    二、先看一段"能跑但很烂"的订单代码

    空谈原则最容易变成正确的废话。我们先立一个靶子。假设我们在做一个订单模块,现在要实现"创建订单"这个功能:根据商品算出金额,按支付方式发起支付,然后把订单存进数据库,再发一条通知短信。一个刚上手的写法可能是这样的:

    public class OrderService {

    public void createOrder(long userId, long skuId, int count, String payType) {
    // 1. 算钱:直接连数据库查价格
    Connection conn = DriverManager.getConnection("jdbc:mysql://…", "root", "123456");
    // … 查 sku 单价,乘以数量,这里省略
    double amount = queryPrice(conn, skuId) * count;

    // 2. 发起支付:一大坨 if-else
    if (payType.equals("wechat")) {
    // 调微信 SDK 的一堆代码
    System.out.println("调微信支付 " + amount);
    } else if (payType.equals("alipay")) {
    // 调支付宝 SDK 的一堆代码
    System.out.println("调支付宝支付 " + amount);
    } else if (payType.equals("unionpay")) {
    // 调银联 SDK 的一堆代码
    System.out.println("调银联支付 " + amount);
    }

    // 3. 存库:又是直接拼 SQL
    String sql = "INSERT INTO orders …";
    // … 执行

    // 4. 发通知:短信内容也在这拼
    String content = "您的订单已创建,金额 " + amount + " 元";
    System.out.println("发短信:" + content);
    }
    }

    这段代码完全能跑,逻辑也对。但凡是有点经验的人看到都会皱眉。为什么?因为它把互不相干的四件事——算钱、支付、存库、通知——连同它们的所有实现细节,一股脑焊死在了一个方法里。它踩中了七大原则里的每一条。下面我们就拿它当活体标本,一刀一刀解剖。

    三、单一职责原则:一个类只干一件事

    单一职责原则(Single Responsibility Principle,SRP):一个类(或方法)应该只有一个引起它变化的原因。换句话说,它只负责一件事。

    回头看那个 createOrder 方法,它至少有四个"变化的原因":支付渠道调整了要改它、数据库换了要改它、短信文案改了也要改它、订单金额规则变了还得改它。四拨完全不同的人(支付组、DBA、运营、产品)因为完全不同的理由,都会来动同一个方法。这就意味着这四件事被死死耦合在了一起:改支付的人一不小心就可能碰坏发短信的逻辑,而且这个方法会随着功能增加越来越长,最后长到没人敢动。

    怎么判断一个类是不是职责太多?有个朴素的办法:试着用一句话描述它是干嘛的,如果这句话里出现了"并且"“同时”“然后”,那大概率就该拆了。 “OrderService 负责计算金额并且发起支付然后存库同时发通知”——四个连接词,四个职责。

    按 SRP 重构,把每件事拆给专门的角色:

    class PriceCalculator { /* 只负责算钱 */ }
    class PaymentService { /* 只负责支付 */ }
    class OrderRepository { /* 只负责存取订单 */ }
    class NotifyService { /* 只负责发通知 */ }

    public class OrderService {
    // OrderService 只负责"编排"这几步,不亲自干活
    public void createOrder(...) {
    double amount = priceCalculator.calculate(skuId, count);
    paymentService.pay(payType, amount);
    orderRepository.save(order);
    notifyService.notify(userId, amount);
    }
    }

    现在支付组改支付,只会动 PaymentService,碰不到别人。这就是"把变化关进小房间"最基础的一步。 需要提醒的是,“职责"的粒度是相对的,不是越细越好——把一个类拆成二十个只有一个方法的类,同样是灾难。粒度的判断依据永远是"它们会不会因为不同的原因一起变”,而不是行数。

    四、开闭原则:对扩展开放,对修改关闭

    开闭原则(Open-Closed Principle,OCP):软件实体应该对扩展开放,对修改关闭。意思是,当需求变化、要加新功能时,应该通过新增代码来实现,而不是去改动已有的、已经测试过、正在稳定运行的代码。

    这条是七大原则里的总纲,是"应对变化"这个目标最直接的表达。其余六条原则,某种意义上都是在为实现开闭原则服务。

    看上一节拆出来的 PaymentService,如果它内部还是那坨 if-else:

    if (payType.equals("wechat")) { ... }
    else if (payType.equals("alipay")) { ... }
    else if (payType.equals("unionpay")) { ... }

    那么每接入一个新渠道(比如"数字人民币"),你都必须回去撬开这个方法,加一个 else if。这就违反了开闭原则——你在修改一段已经稳定的代码。改一次可能没事,改十次,这段方法就变成一个谁都不敢碰的雷区,而且每改一次,原有渠道都要重新回归测试一遍。

    开闭原则的实现套路几乎是固定的:抽象出一个稳定的接口,把每种变化做成接口的一个实现。

    // 稳定不变的抽象
    public interface Payment {
    void pay(double amount);
    }

    // 每个渠道是一个独立实现,新增渠道 = 新增一个类,不碰旧代码
    public class WechatPayment implements Payment { public void pay(double a){ /*…*/ } }
    public class AlipayPayment implements Payment { public void pay(double a){ /*…*/ } }
    public class UnionPayment implements Payment { public void pay(double a){ /*…*/ } }

    以后加"数字人民币",只需 new 一个 DcepPayment implements Payment,PaymentService 一个字都不用改。注意:此刻我们其实已经在不知不觉中用到了"策略模式"的雏形了——这正说明模式就是原则的具体落地。这条 Payment 接口会作为贯穿全系列的例子反复出现。

    一个诚实的提醒:开闭原则不可能 100% 做到,你不可能预判所有未来的变化。它的正确姿势是——在你已经识别出"这里大概率会扩展"的地方(比如支付渠道),提前留好抽象;对那些没看出扩展苗头的地方,不必强行开闭。过早地为想象中的变化做抽象,就是下面第十节要批的"过度设计"。

    五、里氏替换原则:子类要能顶替父类

    里氏替换原则(Liskov Substitution Principle,LSP):所有引用父类的地方,都必须能透明地替换成它的子类,而程序的行为不发生异常。一句话:子类可以扩展父类的能力,但不能改变父类原有的行为契约。

    这条原则是给"继承"划红线的。继承在语法上很简单,一个 extends 就完事,但它其实是一种非常强的承诺:你声明 子类 is-a 父类,就等于对所有使用父类的代码保证"我完全可以当父类用"。一旦这个承诺被打破,继承就会变成 bug 的温床。

    举个订单场景里的经典反例。假设我们有个普通订单 Order,有一个"设置折扣"的行为;然后来了个"预售订单" PreSaleOrder,图省事直接继承 Order:

    class Order {
    // 普通订单:折扣必须在 0~1 之间
    public void setDiscount(double d) {
    if (d < 0 || d > 1) throw new IllegalArgumentException("折扣非法");
    this.discount = d;
    }
    }

    class PreSaleOrder extends Order {
    @Override
    public void setDiscount(double d) {
    // 预售不许打折,直接抛异常 —— ✗ 改变了父类的行为契约
    throw new UnsupportedOperationException("预售订单不能打折");
    }
    }

    问题来了:某段代码原本拿着 Order 好好地跑着,order.setDiscount(0.8) 一切正常;有一天这个 order 被换成了 PreSaleOrder,同样一行代码,突然抛异常炸了。调用方什么都没改,却因为"子类偷偷改了规矩"而崩溃——这就是违反 LSP 的典型症状。它的危害在于隐蔽:编译器不会报错,问题要到运行时、在你意想不到的地方才爆出来。

    违反 LSP 常见的几种表现,可以当自查清单:

    • 子类重写父类方法后,抛出父类不会抛的异常(如上例);
    • 子类重写方法后,缩小了输入范围或放宽了输出范围(要求比父类更苛刻、或返回比父类更宽松);
    • 子类干脆把某个继承来的方法重写成空实现或抛异常,因为它"根本不需要这个方法"——这几乎是在明示:它俩其实不是 is-a 的关系。

    正确的做法是回头审视继承关系:预售订单和普通订单也许根本不该是父子,而应该抽出共同的 Order 抽象,让两者作为平级的实现,各自持有自己的折扣规则。当你发现"这个子类没法完全替代父类"时,答案通常不是想办法绕过去,而是这里压根不该用继承——这就自然引到了本篇最后一条,合成复用原则。

    六、依赖倒置原则:都依赖抽象,别依赖细节

    依赖倒置原则(Dependency Inversion Principle,DIP):高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。

    名字里的"倒置"是相对于直觉而言的。按最自然的想法,高层的业务(OrderService)当然是直接调用低层的实现(WechatPayment),依赖箭头从上指向下。但这样一来,高层就被死死钉在了某个具体实现上:

    public class OrderService {
    // ✗ 直接 new 出具体实现,OrderService 从此和微信支付焊死
    private WechatPayment payment = new WechatPayment();
    }

    这段代码里,OrderService 这个"高层业务"直接依赖了 WechatPayment 这个"低层细节"。后果是:想换成支付宝,得改 OrderService;想在单元测试里用一个假的支付(mock),没法换;WechatPayment 的任何改动,都可能波及到高层。业务逻辑本该是最稳定、最核心的部分,现在却被最容易变的支付细节牵着鼻子走。

    "倒置"就是把这个箭头掰过来:让高层和低层都去依赖中间那层抽象接口,谁也不直接依赖谁的实现。

    public class OrderService {
    private final Payment payment; // ✓ 只依赖抽象接口 Payment

    // 具体用哪个实现,由外部传进来(这就是"依赖注入")
    public OrderService(Payment payment) {
    this.payment = payment;
    }
    }

    // 用的时候由外部决定注入谁
    new OrderService(new WechatPayment());
    new OrderService(new AlipayPayment());
    new OrderService(new MockPayment()); // 测试时轻松替换

    现在依赖关系变成了:OrderService → Payment ← WechatPayment,箭头都指向中间的抽象。高层不再认识任何具体实现,想换实现,从外面注入就行。Spring 的核心 IoC(控制反转)/ DI(依赖注入),整套体系就是这条原则的工业级落地——你 @Autowired 一个接口,由容器决定注入哪个实现,正是"依赖抽象 + 从外部注入"的实践。这也是为什么面向接口编程被反复强调:接口就是那个让高层和低层解耦的"抽象中间层"。

    七、接口隔离原则:别让人依赖用不到的方法

    接口隔离原则(Interface Segregation Principle,ISP):客户端不应该被迫依赖它用不到的方法。接口要小而专,而不是大而全。

    如果说单一职责是从"类"的角度谈拆分,接口隔离就是从"接口"的角度谈同一件事:一个胖接口,会把不相干的能力捆在一起,强迫实现者去实现自己根本不需要的方法。

    举例。假设我们图省事,定义了一个"万能订单操作"接口:

    public interface OrderOperation {
    void create(); // 创建
    void pay(); // 支付
    void refund(); // 退款
    void ship(); // 发货
    void comment(); // 评价
    }

    现在来了个"虚拟卡券订单",它没有物流,不需要发货。但因为实现了这个胖接口,它被迫也得写一个 ship() 方法——写啥呢?只能空着,或者抛个异常:

    public class VirtualOrder implements OrderOperation {
    public void ship() {
    throw new UnsupportedOperationException("虚拟订单不发货"); // ✗ 被迫实现用不到的方法
    }
    // …
    }

    看出来了吗?这里的空实现 / 抛异常,和第五节 LSP 那个反例是同一种味道——胖接口逼着实现类去违反里氏替换。两条原则在这里交汇了。而且危害不止于此:哪天 OrderOperation 接口里加了个新方法 invoice()(开发票),所有实现类,包括那些根本不开发票的,全都得跟着改,一改一大片。

    按接口隔离原则,把大接口拆成几个小接口,谁需要哪种能力就实现哪个:

    interface Payable { void pay(); void refund(); }
    interface Shippable { void ship(); }
    interface Commentable { void comment(); }

    // 实物订单:该有的都有
    class PhysicalOrder implements Payable, Shippable, Commentable { /*…*/ }

    // 虚拟订单:只实现自己需要的,不再被迫写 ship()
    class VirtualOrder implements Payable, Commentable { /*…*/ }

    接口小了,依赖它的人才不会被牵连。 这也和单一职责遥相呼应:单一职责管的是实现类别太杂,接口隔离管的是抽象契约别太胖,一个从实现侧、一个从抽象侧,共同把系统的耦合面积压小。

    八、迪米特法则:只和直接朋友说话

    迪米特法则(Law of Demeter,LoD),又叫最少知识原则:一个对象应该对其他对象保持最少的了解。通俗讲——只和你的"直接朋友"打交道,别去碰朋友的朋友。

    什么叫"直接朋友"?一个对象的直接朋友,通常指:它的成员变量、它方法的参数、它方法的返回值、它自己创建的对象。而"朋友的朋友"——通过一长串点号 a.getB().getC().doSomething() 摸进去够到的那些对象,就属于陌生人了。

    看个订单里常见的坏味道。要给订单的收货人发个通知,需要拿到收货人的手机号:

    // ✗ 一长串 getter 链,OrderService 被迫认识了 Order 内部的 Address、User 结构
    String phone = order.getAddress().getUser().getPhone();
    notify(phone);

    这行代码的问题在于:OrderService 本来只该认识 order,现在却顺着链条,把 Address、User 的内部结构全摸了个遍。它对 order 的内部实现知道得太多了。 后果是:哪天 Order 里 Address 的结构调整了(比如手机号不再挂在 User 上,而是直接挂在 Address 上),这行八竿子打不着的通知代码也得跟着改。一个类知道别人的内部结构越多,别人重构时就越束手束脚。

    改法是让 Order 把"怎么找到手机号"这件事自己封装掉,只对外暴露一个直接朋友能调的方法:

    public class Order {
    // Order 自己知道去哪找收货手机号,外面不用管内部结构
    public String getReceiverPhone() {
    return address.getUser().getPhone();
    }
    }

    // 调用方只和直接朋友 order 说话
    notify(order.getReceiverPhone()); // ✓

    迪米特法则的核心是"信息隐藏":把类的内部结构藏起来,只暴露必要的行为。这样各个类才能独立演化,改内部实现时不会牵一发而动全身。

    别矫枉过正:迪米特法则不是让你消灭所有点号。流式 API(如 StringBuilder.append().append(),或 Stream 的链式调用)返回的是同一个对象或同类对象,并不违反它。它反对的是"穿透对象的封装、去操作深层内部对象",而不是链式调用本身。

    九、合成复用原则:多用组合,少用继承

    合成复用原则(Composite Reuse Principle,CARP):要复用一段代码时,优先使用组合 / 聚合(把别的对象当成员持有),而不是继承。

    我们在第五节里已经埋下了伏笔:继承是一种很强的承诺,用不好就违反里氏替换。这一条原则更进一步地告诉你:大多数你以为需要继承的场景,其实用组合更好。

    先看为什么继承是把双刃剑。假设我们想复用一个 Order 的基础能力,做一个"带日志的订单":

    // ✗ 用继承来复用
    class LoggingOrder extends Order {
    @Override
    public void pay(double amount) {
    System.out.println("[日志] 准备支付");
    super.pay(amount);
    System.out.println("[日志] 支付完成");
    }
    }

    这么写有几个隐患:

    • 打破封装:子类继承了父类的全部实现细节,父类的任何内部改动都可能悄悄影响子类,两者被绑死了。这叫"白箱复用"——父类对子类是透明的。
    • 静态、僵硬:继承关系在编译期就定死了,运行时没法换。而且 Java 是单继承,一个类只能有一个爹,你想同时复用两个类的能力,继承直接就做不到。
    • 容易类爆炸:要"带日志的订单"“带缓存的订单”“既带日志又带缓存的订单”……用继承排列组合,类的数量会指数级膨胀。

    再看组合怎么做——把要复用的对象当成员持有,而不是继承:

    // ✓ 用组合来复用
    class LoggingOrder {
    private final Order order; // 持有一个 Order,而不是继承它
    public LoggingOrder(Order order) { this.order = order; }

    public void pay(double amount) {
    System.out.println("[日志] 准备支付");
    order.pay(amount); // 委托给持有的对象
    System.out.println("[日志] 支付完成");
    }
    }

    组合的好处正好对上继承的短处:它是"黑箱复用"——只依赖 Order 的公开方法,碰不到内部细节,封装性好;它可以在运行时动态替换持有的对象;想叠加多种能力就多持有几个,不会类爆炸。你会发现,后面的装饰器、代理、桥接、策略等一大票模式,骨子里都是"组合优于继承"的具体演绎。

    这不是说继承一无是处。当两个类确实是纯粹的 is-a 关系、且子类能完全替代父类(满足里氏替换)时,继承依然是最自然的表达。 原则的意思是"优先考虑组合",在两者都能解决问题时倾向组合,而不是"禁止继承"。

    十、七条原则串成一张图,以及一句泼冷水

    到这里,七条原则都过完了。回头看,它们并不是七个孤立的教条,而是从不同角度指向同两个目标——应对变化、降低耦合。用一张表收拢一下:

    原则一句话主要对付的病
    单一职责 SRP 一个类只干一件事 职责耦合、类太胖
    开闭原则 OCP 对扩展开放,对修改关闭 改动波及已稳定的代码(总纲)
    里氏替换 LSP 子类要能顶替父类 继承用错、行为契约被破坏
    依赖倒置 DIP 依赖抽象,不依赖细节 高层被低层实现绑死
    接口隔离 ISP 接口要小而专 胖接口逼人实现无用方法
    迪米特 LoD 只和直接朋友说话 过度耦合别人的内部结构
    合成复用 CARP 组合优于继承 继承滥用、类爆炸

    它们彼此还高度联动:接口隔离拆出的小接口,是依赖倒置要依赖的那个"抽象";胖接口的空实现,同时踩了里氏替换;合成复用是里氏替换那条红线的自然延续;而所有这些,最终都是为了服务开闭原则——让系统面对变化时,尽量只增不改。它们共同的抓手,其实就一个字:抽象。找准那个"会变的点",给它一层稳定的抽象,把变化挡在抽象后面。

    在这里插入图片描述

    最后,必须泼一盆冷水,这盆水比上面所有内容都重要:

    原则是用来权衡的,不是用来无脑执行的。 这七条原则,每一条推到极端都会走向反面:

    • 单一职责推到极端,你会得到几百个只有一个方法的类,调用链长到没法读;
    • 开闭原则用力过猛,你会为了应对根本不会发生的变化,预先造出一堆多余的抽象层,美其名曰"扩展性",实则是给后人挖坑;
    • 处处依赖倒置、面向接口,每个类都配一个只有一个实现的接口,除了让人多点两下"跳转到实现",没有任何收益。

    这种"为了用原则而用原则、为了显得有设计感而堆砌抽象"的毛病,有个专门的名字——过度设计(over-engineering)。它的危害不亚于完全不设计:后者是当下就烂,前者是用"未来可能有用"的名义,给现在平添一堆复杂度和理解成本,而那个"未来"往往永远不来。

    真正的功力,不在于会背这七条,而在于判断力:面对一段具体的代码,你能看出"这里的变化点在哪、值不值得为它引入抽象、引入哪种抽象最划算"。设计模式就是前人总结好的、针对常见变化点的"标准答案"。但答案是死的,场景是活的——先有值得解决的问题,才谈得上用模式;没有问题硬套模式,就是过度设计。这句话,请带着它读完后面二十四篇的每一个模式。


    小结。这一篇没讲任何一个具体模式,却是整个系列的地基:设计的目标是应对变化、降低耦合,手段是抽象,而七大设计原则就是运用抽象的七条准则——SRP 拆职责、OCP 立总纲、LSP 管继承、DIP 靠注入、ISP 瘦接口、LoD 降耦合、CARP 推组合。它们不是要背的教条,而是一套帮你识别"变化点该不该抽、怎么抽"的思维工具。记住三句话就够了:把会变的和不变的分开;优先组合、面向接口;以及——别过度设计。下一篇开始进入正题,我们从最有名、也最容易写错的单例模式讲起,看看"保证一个类只有一个实例"这么简单的一句话,背后藏着多少和类加载、并发有关的门道。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 设计模式 01 · 开篇:七大设计原则
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!