设计模式04 - 设计原则 - 理论叙述

  本讲主要介绍常用的经典的设计原则,其中包括,SOLID(单一、开闭、多态、接口隔离、依赖反转)、KISS(不复杂设计)、YAGNI(不过度设计)、DRY(代码复用)、LOD (高类聚,低耦合)等。SOLID 原则并非单纯的 1 个原则,而是由 5 个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则(多态)、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母。

一、单一职责原则(SRP)

  核心是:一个类或者模块只负责完成一个职责(或者功能)

  单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。核心是:一个类或者模块只负责完成一个职责(或者功能),也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。

  单一职责原则,几条判断原则如下:

    1、类中的代码行数、函数或属性过多

      会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;

    2、类依赖的其他类过多,或者依赖类的其他类过多:

      不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;

    3、比较难给类起一个合适名字:

      很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;

  当然,类的职责是否设计得越单一越好,也不是,要有度。比如,一个序列化和反序列化的类,应该放在一起,而不是拆分为两个类。

二、☆开闭原则(OCP)☆:对扩展开放、修改关闭

  这条原则最有用,核心是:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

扩展性是代码质量最重要的衡量标准之一。在 23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。

☆☆关于告警的开闭重构案例 - 重点分析

  举例说明,这是一段 API 接口监控告警的代码。其中,AlertRule类 存储告警规则,可以自由设置。Notification类 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。

复制代码
 1 public class Alert {
 2   //告警规则类
 3   private AlertRule rule;
 4   //通知类
 5   private Notification notification;
 6   public Alert(AlertRule rule, Notification notification) {
 7     this.rule = rule;
 8     this.notification = notification;
 9   }
10    //业务逻辑
11   public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
12     long tps = requestCount / durationOfSeconds;
13     //满足规则1: 如果tps大于某个值,紧急通知
14     if (tps > rule.getMatchedRule(api).getMaxTps()) {
15         //发告警
16       notification.notify(NotificationEmergencyLevel.URGENCY, "...");
17     }
18     //满足规则2  如果错误次数大于某个值,严重通知
19     if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
20       notification.notify(NotificationEmergencyLevel.SEVERE, "...");
21     } 
22   }
23 }
复制代码

  业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。

  现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?主要的改动有两处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;第二处是在 check() 函数中添加新的告警逻辑。改动如下:

复制代码
 1 public class Alert {
 2   // ...省略AlertRule/Notification属性和构造函数...
 3   // 改动一:添加参数timeoutCount
 4   public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
 5    。。。
 6     // 改动二:添加接口超时处理逻辑
 7     long timeoutTps = timeoutCount / durationOfSeconds;
 8     if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
 9       notification.notify(NotificationEmergencyLevel.URGENCY, "...");
10     }
11   }
12 }
复制代码

  这样的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了 check() 函数,相应的单元测试都需要修改。需要重新测试。

  如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。我们先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:

第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;

第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。

代码如下:

复制代码
 1 //check()函数的入参封装
 2 public class ApiStatInfo {
 3   //省略constructor/getter/setter方法
 4   private String api;
 5   private long requestCount;
 6   private long errorCount;
 7   private long durationOfSeconds;
 8 }
 9 /**
10 *抽象handler
11 **/
12 public abstract class AlertHandler {
13   protected AlertRule rule;
14   protected Notification notification;
15   public AlertHandler(AlertRule rule, Notification notification) {
16     this.rule = rule;
17     this.notification = notification;
18   }
19   public abstract void check(ApiStatInfo apiStatInfo);
20 }
21 22 //规则1
23 public class TpsAlertHandler extends AlertHandler {
24   public TpsAlertHandler(AlertRule rule, Notification notification) {
25     super(rule, notification);
26   }
27   @Override
28   public void check(ApiStatInfo apiStatInfo) {
29     long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
30     if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
31       notification.notify(NotificationEmergencyLevel.URGENCY, "...");
32     }
33   }
34 }
35 //规则2
36 public class ErrorAlertHandler extends AlertHandler {
37   public ErrorAlertHandler(AlertRule rule, Notification notification){
38     super(rule, notification);
39   }
40   @Override
41   public void check(ApiStatInfo apiStatInfo) {
42     if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
43       notification.notify(NotificationEmergencyLevel.SEVERE, "...");
44     }
45   }
46 }
47 /**
48 * 工作类
49 **/
50 public class Alert {
51   private List<AlertHandler> alertHandlers = new ArrayList<>();
52   public void addAlertHandler(AlertHandler alertHandler) {
53     this.alertHandlers.add(alertHandler);
54   }
55    //
56   public void check(ApiStatInfo apiStatInfo) {
57     for (AlertHandler handler : alertHandlers) {
58       handler.check(apiStatInfo);
59     }
60   }
61 }
复制代码

  重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。

三、 里式替换(LSP) -> 多态

  核心理解:子类对象能够替换父类对象出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

  这个设计原则是比较简单、容易理解和掌握的。将这条原则用中文描述:子类对象能够替换父类对象出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

  哪些代码明显违背了 里式替换

    实际上,里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。进一步解读一下。子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。为了更好地理解这句话,我举几个违反里式替换原则的例子来解释一下。

  1、子类违背父类声明要实现的功能

    父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

  2、子类违背父类对输入、输出、异常的约定

    在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。

    在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。

    在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。

  3、子类违背父类注释中所罗列的任何特殊说明

    父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

  以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。实际上,里式替换这个原则是非常宽松的。一般情况下,我们写的代码都不怎么会违背它。

四、接口隔离原则(ISP)

  “接口隔离原则”是指:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者

  接口隔离原则与单一职责原则的区别

  核心区别:接口隔离原则主要是针对接口层面的功能要单一。单一职责理解为包括接口隔离的一个层面

  单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

五、依赖反转原则

  核心逻辑:1、控制反转:本来主逻辑控制的,现在控制权不在主逻辑手上了,失去了控制;

       2、依赖注入:不要自己实现依赖,需要让依赖注入进来,自己不控制依赖的创建

   这个原则用起来比较简单,但概念理解起来比较难。在讲解依赖反转的时候,都会吧控制反转、依赖注入、依赖反转三个一起分析

5.1 控制反转

  核心逻辑:本来逻辑是自己写的,现在控制权不在主逻辑手上了

  控制反转的英文翻译是 Inversion Of Control,缩写为 IOC。强调一下,如果你是 Java 工程师的话,暂时别把这个“IOC”跟 Spring 框架的 IOC 联系在一起。

复制代码
 1 public class UserServiceTest {
 2   public static boolean doTest() {
 3     // ... 
 4   }
 5   
 6   public static void main(String[] args) {//这部分逻辑可以放到框架中
 7       //doTest() 核心方法;
 8     if (doTest()) {
 9       System.out.println("Test succeed.");
10     } else {
11       System.out.println("Test failed.");
12     }
13   }
14 }
复制代码

  在上面的代码中,所有的流程都由程序员来控制。如果我们抽象出一个下面这样一个框架,我们再来看,如何利用框架来实现同样的功能。具体的代码实现如下所示:

复制代码
 1 public abstract class TestCase {
 2   public void run() {
 3     if (doTest()) {
 4       System.out.println("Test succeed.");
 5     } else {
 6       System.out.println("Test failed.");
 7     }
 8   }
 9   
10   public abstract boolean doTest();
11 }
12 13 public class JunitApplication {
14   public static final void main(String[] args) {
15       new TestCase().run();
16       /**注释调的代码
run()方法里边的逻辑本来是自己写的,现在被封装成run()方法去写了,控制权不在主逻辑手上了 17 if (doTest()) { 18 System.out.println("Test succeed."); 19 } else { 20 System.out.println("Test failed."); 21 } 22 * 23 **/ 24 } 25 }
复制代码

​       这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。

5.2 依赖注入DI

  接下来,我们再来看依赖注入。依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是 Dependency Injection,缩写为 DI。听起来高大上,用起来so easy;

  依赖注入一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。

  我们还是通过一个例子来解释一下。在这个例子中,Notification 类负责消息推送,依赖 MessageSender 类实现推送商品促销、验证码等消息给用户。我们分别用依赖注入和非依赖注入两种方式来实现一下。具体的实现代码如下所示:

复制代码
 1 // 非依赖注入实现方式
 2 public class Notification {
 3   private MessageSender messageSender;
 4   public Notification() { 
 5       //在构造函数里边进行对messageSender构造了,没有依赖注入
 6     this.messageSender = new MessageSender(); 
 7   }
 8   public void sendMessage(String cellphone, String message) {
 9     //...省略校验逻辑等...
10     this.messageSender.send(cellphone, message);
11   }
12 }
13 //内部类
14 public class MessageSender {
15   public void send(String cellphone, String message) {
16     //....
17   }
18 }
19 
20 // 后边使用Notification
21 Notification notification = new Notification();
22 ----------------------------------------------------------
23 // 依赖注入的实现方式
24 public class Notification {
25   private MessageSender messageSender;
26   
27   // 通过构造函数将messageSender传递进来,依赖注入了
28   public Notification(MessageSender messageSender) {
29     this.messageSender = messageSender;
30   }
31   
32   public void sendMessage(String cellphone, String message) {
33     //...省略校验逻辑等...
34     this.messageSender.send(cellphone, message);
35   }
36 }
37 
38 //使用Notification
39 MessageSender messageSender = new MessageSender();
40 Notification notification = new Notification(messageSender);
复制代码
  通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。这一点在我们之前讲“开闭原则”的时候也提到过。

六、KISS原则和YAGNI原则 -万金油

  核心:不过度设计(YAGNI原则) 和不奇淫巧技(KISS原则

  KISS原则(一种版本是:Keep It Simple and Stupid.),万金油,是尽量保持简单原则,做到以下几点:

    1、不要使用同事可能不懂的技术来实现代码。比如复杂的正则表达式,还有一些编程语言中过于高级的语法等。

    2、不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高

    3、不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else等)来优化代码,牺牲代码的可读性。

  YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。比如,我们的系统暂时只用 Redis 存储配置信息,以后可能会用到 ZooKeeper。根据 YAGNI 原则,在未用到 ZooKeeper 之前,我们没必要提前编写这部分代码。当然,这并不是说我们就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现 ZooKeeper 存储配置信息这部分代码。

七、DRY原则 -> 代码复用性

  DRY 原则。它的英文描述为:Don’t Repeat Yourself。中文直译为:不要重复自己(功能语义不要重复)。将它应用在编程中,可以理解为:不要写重复的代码。关于重复,大概有实现逻辑重复、功能语义重复、代码执行重复。

  实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则。

如果两段代码的语义不重复,但实现逻辑重复,也不违反DRY原则。比如,校验用户姓名和验证码,现在规定只能数字开头,后边有可能姓名就不需要数字开头的。所以即便现在重复,也不违反DRY原则。

  实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。

7.1 怎么提高代码复用性?

总结了 7 条,具体如下。

  1、减少代码耦合

    对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。

  2、满足单一职责原则

    我们前面讲过,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。

  3、模块化

    这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。

  4、业务与非业务逻辑分离

    越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。

  5、通用代码下沉

    从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。

  6、继承、多态、抽象、封装

    在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

  7、模板模式等设计模式

    一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。关于应用设计模式提高代码复用性这一部分,我们留在后面慢慢来讲解。

  八、迪米特法则(LOD) -> “高内聚、松耦合”

    迪米特法则,核心思想:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口,迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。

  “高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。

  所谓高内聚,用来指导类本身的设计。

    就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。实际上,我们前面讲过的单一职责原则是实现代码高内聚非常有效的设计原则。

  所谓松耦合,“松耦合”用来指导类与类之间依赖关系的设计

    在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。实际上,我们前面讲的依赖注入、接口隔离、基于接口而非实现编程。

posted @   云执  阅读(107)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示