摘要生成中...
AI 摘要
Hunyuan-lite
这是本博客公开发表的第 200 篇文章🎉🎉

SOLID 设计原则

SOLID 由 5 个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母。

单一职责原则 Single Responsibility Principle

单一职责原则 SRP

A class or module should have a single responsibility.
一个类或者模块只负责完成一个职责(或者功能)。

不要设计大而全的类,要设计粒度小、功能单一的类。一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。

🍐⚱️:我们应设计适当大小的模块,但到底多大呢?这个问题就如同做菜时的「放盐少许,放水适量」,需要自己度量。

如何判断类的职责是否足够单一?这是一个主观问题见仁见智。不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。除此之外,从不同的业务层面去看待同一个类的设计,对类是否职责单一,也会有不同的认识。

开发的时候也不要未雨绸缪、过度设计,做的事情越多,代价越大。可以先写一个粗粒度的类,满足业务需求。随着业务的发展,再把类细分。

一些简单的判断原则:

  • 代码的行数、函数或属性过多;
  • 类依赖的其他类过多,或依赖类的其他类过多;
  • 私有方法过多;
  • 名字很难起(类的职责不清);
  • 类中的大量的方法都是集中操作类中的某几个属性。

这些情况就要考虑类的拆分。

类的职能并不是越单一越好。代码要低耦合,高内聚。如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

开闭原则 Open Closed Principle

开闭原则 OCP

Software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification.
软件实体(模块、类、方法等)应该「对扩展开放,对修改关闭」。

该原则由勃兰特·梅耶提出,而后 RobertC.Martin 在 C++Report(1996 年 1 月)中的 Engineering NoteBook 专栏中对其进行了总结。

添加一个新的功能应该是在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法、属性等)。

  • 第一点:开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
  • 第二点:同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能被认为“扩展”。

🍐⚱️:if 是把条件写死在代码里了。我们在改的就是改 if。

要做到「对扩展开放,对修改关闭」需要有扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。

唯一不变的只有变化本身。

很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。

里氏替换原则 Liskov Substitution Principle

里氏替换原则 LSP

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program. ——Barbara Liskov, 1986
子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。

这是一个用来指导继承关系中的子类如何设计的原则:凡是使用父类的地方,使用子类总是可以的。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

多态和里氏替换有点类似,但它们关注的角度是不一样的,实际上他们是两回事:

  • 多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。
  • 里氏替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
多态和里氏替换原则

假设某个子类的重写方法中抛出了异常。即使通过「多态」,这个子类可以替换任何父类中出现的位置,但是这个子类特另独行的「抛异常」的行为本身,就需要让原本的代码修改逻辑,从而违反里氏替换原则。

LSP 另一种理解——按照协议来设计(Design By Contract)。子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:

  • 函数声明要实现的功能;
  • 对输入、输出、异常的约定;
  • 甚至包括注释中所罗列的任何特殊说明。

LSP 原则可以让我们毫无顾虑地替换子类。

叛逆的儿子容易挨打

违反里氏替换原则的例子:

  1. 子类违背父类声明要实现的功能。父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那这个子类的设计就违背里氏替换原则。
  2. 子类违背父类对输入、输出、异常的约定。
    • 在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合。而子类重载函数之后,实现变了,运行出错返回异常,获取不到数据返回 null。那子类的设计就违背里氏替换原则。
    • 在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出异常,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里氏替换原则。
    • 在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里氏替换原则。
  3. 子类违背父类注释中所罗列的任何特殊说明。父类中定义的 withdraw() 提现函数的注释是这么写的:「用户的提现金额不得超过账户余额……」,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里氏替换原则的。

验证是否违背里氏替换原则的方法:拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里氏替换原则。

接口隔离原则 Interface Segregation Principle

接口隔离原则 ISP

Clients should not be forced to depend upon interfaces that they do not use. ——Robert Martin
客户端不应被强迫依赖它不需要的接口。

“客户端”可以理解为接口的调用者或者使用者。“接口”可以理解为:

  • 一组 API 的组合。
  • 单个 API 接口。比如 Java 某个函数方法。
  • 函数 OOP 中的接口概念。比如 Java 中的 interface。

下面将针对「接口」的不同理解,探讨 ISP 的应用。

把「接口」理解为一组 API 的组合

所谓一组 API 的组合:某个微服务接口,或某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

假设有一个微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}

public class UserServiceImpl implements UserService {
//...
}

如果现在要增加一个删除接口,应当怎么做?也许我们会直接在 interface UserService 中添加 deleteUserByCellphone()deleteUserById() 接口。但是这会埋下一些安全隐患。删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。

当然,最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,我们还可以从代码设计的层面,尽量避免接口被误用。我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}

public interface RestrictedUserService {
boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
// ...省略实现代码...
}

把「接口」理解为单个 API 接口或函数

在这种情景下,ISP 可以这么理解:接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。

下面是一个统计函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Statistics {
private Long max;
private Long min;
private Long average;
private Long sum;
private Long percentile99;
private Long percentile999;
//...省略constructor/getter/setter等方法...
}

public Statistics count(Collection<Long> dataSet) {
Statistics statistics = new Statistics();
//...省略计算逻辑...
return statistics;
}

上面的 count() 函数的功能不够单一,它将求最大值、最小值、平均值等等都涵盖了进来。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:

1
2
3
4
5
public Long max(Collection<Long> dataSet) { /*...*/ }
public Long min(Collection<Long> dataSet) { /*...*/ }
public Long average(Colletion<Long> dataSet) { /*...*/ }
// ...省略其他统计函数...

在某种意义上讲,count() 函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。

把「接口」理解为函数 OOP 中的 interface 概念

假设 A、B 类需要实现 x 功能,C 需要实现 y 功能,需要实现 Y 接口。

设计方式:

  • 符合 ISP 的设计:A、B 类实现 X 接口,C 类实现 Y 接口。不让接口的实现类和调用者,依赖不需要的接口函数。
  • 违反 ISP 的设计:设计一个大而全的接口 Z,包含 x,y 功能。A、B、C 都实现 Z 接口。这样的设计为 Z 接口增加新功能将会出现困难。

小接口的设计更加灵活、容易扩展、易复用。大而全的接口需要所有的实现类实现所有的方法,且添加新接口时,所有的类都需要改动。

ISP 和 SRP

单一职责原则针对的是模块、类、接口的设计。

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

依赖反转原则 Dependency Inversion Principle

关于「控制反转」、「依赖注入」等概念详见:站内文章SpringBoot 中的 IoC & DI 入门

依赖反转原则 DIP

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.
高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。除此之外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。

在调用链上,调用者属于高层,被调用者属于低层。这条原则主要用于指导框架的设计。

依赖于抽象——以 Tomcat 为例

Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照 DIP 的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个「抽象」,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。

KISS 和 YAGNI

KISS 关注的是「如何做」的问题,保持代码的简单;YANGI 关注的是「要不要做」的问题,当前不需要的就不要做。

KISS:写简单的代码

KISS

不同英文版本:

  • Keep It Simple and Stupid.
  • Keep It Short and Simple.
  • Keep It Simple and Straightforward.

尽量保持简单。

例子:检查输入的字符串 ipAddress 是否是合法的 IP 地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 第一种实现方式: 使用正则表达式
public boolean isValidIpAddressV1(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}

// 第二种实现方式: 使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}

// 第三种实现方式: 不使用任何工具类
public boolean isValidIpAddressV3(String ipAddress) {
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for (int i = 0; i < length; ++i) {
char c = ipChars[i];
if (c == '.') {
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (isFirstUnit && ipUnitIntValue == 0) return false;
if (isFirstUnit) isFirstUnit = false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if (c < '0' || c > '9') {
return false;
}
if (ipUnitIntValue == -1) ipUnitIntValue = 0;
ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (unitsCount != 3) return false;
return true;
}

第一种实现方式使用了正则,代码行数少,看似简单,实际复杂,这种实现方式并不符合 KISS 原则。第二种使用了现成工具函数,实现方式逻辑清晰,容易理解,除非代码中存在性能瓶颈,不然这种实现方式是符合 KISS 原则的。第三种实现方式相较于第二种更难,容易写出 Bug,但是性能更高。

站内文章KMP 字符串匹配算法 逻辑复杂、实现难度大、可读性差,但是并不违反 KISS。因为 KMP 十分高效,当我们需要处理长文本字符串匹配问题(几百 MB 大小文本内容的匹配),或者字符串匹配是某个产品的核心功能(比如 Vim、Word 等文本编辑器),又或者字符串匹配算法是系统性能瓶颈的时候,我们就应该选择尽可能高效的 KMP 算法。本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。

不过,平时的项目开发中涉及的字符串匹配问题,大部分都是针对比较小的文本。在这种情况下,直接调用编程语言提供的现成的字符串匹配函数就足够了。如果非得用 KMP 算法、BM 算法来实现字符串匹配,那就真的违背 KISS 原则了。也就是说,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。

通过以上案例,我们可以知道:

  • 代码行数少,并不意味着简单
  • 逻辑复杂,并不意味着不符合 KISS,具体情况具体分析

符合 KISS 原则代码的特点:

  • 谨慎使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。当然这里不是指不要使用新技术,新技术的引入可以在代码检视时触发团队思考讨论,让技术团队成长。关键点在于:新技术是否值得引入并推广。
  • 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
  • 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。

一个验证自己的代码是否足够简单的办法:和同事一起 Code Review。当然,这里 Code Review 的前提是团队成员的技术要有一定的水平,不然 Code Review 只会流于形式,没有意义。

YAGNI:不要过度设计

YAGNI

You Ain’t Gonna Need It. 你不会需要它。

不要去设计当前用不到的功能;不要去编写当前用不到的代码。

比如,我们的系统暂时只用 Redis 存储配置信息,以后可能会用到 ZooKeeper。根据 YAGNI 原则,在未用到 ZooKeeper 之前,我们没必要提前编写这部分代码。当然,这并不是说我们就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现 ZooKeeper 存储配置信息这部分代码。

再比如,我们不要在项目中提前引入不需要依赖的开发包。对于 Java 程序员来说,我们经常使用 Maven 或者 Gradle 来管理依赖的类库。我发现,有些同事为了避免开发中 Library 包缺失而频繁地修改 Maven 或者 Gradle 配置文件,提前往项目里引入大量常用的 library 包。实际上,这样的做法也是违背 YAGNI 原则的。

DRY:不要写重复的代码

DRY

Don’t Repeat Yourself. 不要重复自己。

重复的代码不一定违反 DRY 原则,而且有些看似不重复的代码也有可能违反 DRY 原则。

实现逻辑的重复

下面是一段逻辑验证代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class UserAuthenticator {
public void authenticate(String username, String password) {
if (!isValidUsername(username)) {
// ...throw InvalidUsernameException...
}
if (!isValidPassword(password)) {
// ...throw InvalidPasswordException...
}
//...省略其他代码...
}

private boolean isValidUsername(String username) {
// check not null, not empty
if (StringUtils.isBlank(username)) {
return false;
}
// check length: 4~64
int length = username.length();
if (length < 4 || length > 64) {
return false;
}
// contains only lowcase characters
if (!StringUtils.isAllLowerCase(username)) {
return false;
}
// contains only a~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = username.charAt(i);
if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
return false;
}
}
return true;
}

private boolean isValidPassword(String password) {
// check not null, not empty
if (StringUtils.isBlank(password)) {
return false;
}
// check length: 4~64
int length = password.length();
if (length < 4 || length > 64) {
return false;
}
// contains only lowcase characters
if (!StringUtils.isAllLowerCase(password)) {
return false;
}
// contains only a~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = password.charAt(i);
if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
return false;
}
}
return true;
}
}

在代码中,有两处非常明显的重复的代码片段:isValidUserName() 函数和 isValidPassword() 函数。重复的代码被敲了两遍,或者简单复制粘贴了一下,看起来明显违反 DRY 原则。为了移除重复的代码,我们对上面的代码做下重构,将 isValidUserName() 函数和 isValidPassword() 函数,合并为一个更通用的函数 isValidUserNameOrPassword()。重构后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserAuthenticatorV2 {

public void authenticate(String userName, String password) {
if (!isValidUsernameOrPassword(userName)) {
// ...throw InvalidUsernameException...
}

if (!isValidUsernameOrPassword(password)) {
// ...throw InvalidPasswordException...
}
}

private boolean isValidUsernameOrPassword(String usernameOrPassword) {
//省略实现逻辑
//跟原来的isValidUsername()或isValidPassword()的实现逻辑一样...
return true;
}
}

重构后的代码看似减少了,但是从新合并方法 isValidUsernameOrPassword() 的名字上看,方法明显违反了「单一职责原则」和「接口隔离原则」。isValidUserName()isValidPassword() 两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。只是在目前的设计中,两个校验逻辑是完全一样的。

如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName()isValidPassword() 的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。

其实,对于包含重复代码的问题,我们可以将校验逻辑抽象为更细粒度的函数来解决。比如将校验只包含 az、09、dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数等等。

功能语义的重复

假设在同一个项目代码中有下面两个函数:isValidIp()checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。之所以在同一个项目中会有两个功能相同的函数,那是因为这两个函数是由两个不同的同事开发的,其中一个同事在不知道已经有了 isValidIp() 的情况下,自己又定义并实现了同样用来校验 IP 地址是否合法的 checkIfIpValid() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public boolean isValidIp(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}

public boolean checkIfIpValid(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}

尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,这还是违反了 DRY 原则。其他同事看到两段代码,以及各处各异的方法调用,需要一定的时间思考出区别。当逻辑需要修改时,需要改动两份代码。

代码执行重复

UserServicelogin() 函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class UserService {
private UserRepo userRepo;//通过依赖注入或者IOC框架注入

public User login(String email, String password) {
boolean existed = userRepo.checkIfUserExisted(email, password);
if (!existed) {
// ... throw AuthenticationFailureException...
}
User user = userRepo.getUserByEmail(email);
return user;
}
}

public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}

if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}

//...query db to check if email&password exists...
}

public User getUserByEmail(String email) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
//...query db to get user by email...
}
}

上面这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码中存在“执行重复”。在 login() 函数中,email 的校验逻辑被执行了两次。一次是在调用 checkIfUserExisted() 函数的时候,另一次是调用 getUserByEmail() 函数的时候。解决方案:将校验逻辑从 UserRepo 中移除,统一放到 UserService 中即可。

还有一处隐蔽的执行重复。实际上,login() 函数并不需要调用 checkIfUserExisted() 函数,只需要调用一次 getUserByEmail() 函数,从数据库中获取到用户的 email、password 等信息,然后跟用户输入的 email、password 信息做对比,依次判断是否登录成功。这样的优化是很有必要的。因为 checkIfUserExisted() 函数和 getUserByEmail() 函数都需要查询数据库,而数据库这类的 I/O 操作是比较耗时的。

重构之后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class UserService {
private UserRepo userRepo;//通过依赖注入或者IOC框架注入

public User login(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}
User user = userRepo.getUserByEmail(email);
if (user == null || !password.equals(user.getPassword()) {
// ... throw AuthenticationFailureException...
}
return user;
}
}

public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
//...query db to check if email&password exists
}

public User getUserByEmail(String email) {
//...query db to get user by email...
}
}

关于代码的可复用性

概念区别:

  • 代码复用(Code Resue):表示一种行为,即开发新功能时尽量复用已经存在的代码。
  • 代码可复用性(Code Reusability):表示一段代码可被复用的特性或能力。
  • DRY 原则:一条指导不写重复代码的原则。

「不重复」并不代表「可复用」。在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY 原则跟代码的可复用性讲的是两回事。

「复用」和「可复用性」关注角度不同。代码「可复用性」是从代码开发者的角度来讲的,「复用」是从代码使用者的角度来讲的。比如,A 同事编写了一个 UrlUtils 类,代码的「可复用性」很好。B 同事在开发新功能的时候,直接「复用」A 同事编写的 UrlUtils 类。

尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,Bug 会比从零重新开发要少。

“复用”这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的产生也都是为了达到复用的目的。比如,Spring 框架、Google Guava 类库、UI 组件等等。

提高的代码可复用性的方法:

  • 减少代码耦合。
  • 满足单一职责原则:越细粒度的代码,代码的通用性会越好,越容易被复用。
  • 模块化:这里的“模块”,可以指一组类构成的模块,还可以理解为单个类、函数。
  • 业务与非业务逻辑分离:越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。
  • 通用代码下沉:从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
  • 继承、多态、抽象、封装。详看:站内文章面向对象编程 OOP
    • 利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。
    • 利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。
    • 利用抽象,越抽象、越不依赖具体的实现,越容易复用。
    • 利用封装,将代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
  • 应用设计模式。比如模板模式等。
  • 利用编程语言特性,如 Java 的泛型编程等。
  • 具备复用意识。

编写可复用的代码并不简单。如果我们在编写代码的时候,已经有复用的需求场景,那就可以根据复用的需求去开发可复用的代码。但是,如果当下并没有复用的需求,我们只是希望现在编写的代码具有可复用的特点,能在未来某个同事开发某个新功能的时候复用得上。在这种没有具体复用需求的情况下,我们就需要去预测将来代码会如何复用,这就比较难。除非有非常明确的复用需求,否则,为了暂时用不到的复用需求,花费太多的时间、精力,投入太多的开发成本,并不是一个值得推荐的做法。这也违反 YAGNI 原则。

有一条应用在很多行业的指导原则叫「Rule of Three」,在代码复用场景下,可以将这条原则理解为:我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。需要注意的是,「Rule of Three」中的「Three」并不是真的就指确切的「三」,在这里就是指「二」。

迪米特法则 Law of Demeter

高内聚、松耦合

「高内聚、松耦合」是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。前面的一些设计原则都以实现代码的「高内聚、松耦合」为目的,比如单一职责原则、基于接口而非实现编程等。

「高内聚、松耦合」可以用来指导不同粒度代码的设计与开发,比如系统、模块、类,甚至是函数,也可以应用到不同的开发场景中,比如微服务、框架、组件、类库等。

下面将围绕「类」作为这个思想的应用对象进行说明。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。两者并非完全独立不相干,高内聚有助于松耦合,松耦合又需要高内聚的支持。

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

「低耦合」是指在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。前面讲的依赖注入、接口隔离、基于接口而非实现编程,以及迪米特法则,都是为了实现代码的松耦合。

感受「高内聚、松耦合」

下图中,左边部分的代码结构是「高内聚、松耦合」;右边部分是「低内聚、紧耦合」。

image.png

图中左边部分的代码设计中,类的粒度比较小,每个类的职责都比较单一。相近的功能都放到了一个类中,不相近的功能被分割到了多个类中。这样类更加独立,代码的内聚性更好。因为职责单一,所以每个类被依赖的类就会比较少,代码低耦合。一个类的修改,只会影响到一个依赖类的代码改动。我们只需要测试这一个依赖类是否还能正常工作就行了。

图中右边部分的代码设计中,类粒度比较大,低内聚,功能大而全,不相近的功能放到了一个类中。这就导致很多其他类都依赖这个类。当我们修改这个类的某一个功能代码的时候,会影响依赖它的多个类。我们需要测试这三个依赖类,是否还能正常工作。这也就是所谓的牵一发而动全身。

除此之外,从图中我们也可以看出,高内聚、低耦合的代码结构更加简单、清晰,相应地,在可维护性和可读性上确实要好很多。

迪米特法则

迪米特法则 LoD(最小知识原则 The Least Knowledge Principle)

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.
每个模块只应该了解那些与它关系密切的模块的有限知识。或者说,每个模块只和自己的朋友“交流”,不和陌生人“交流”。

应用到「类」中,这条原则可以表示为:

  • 不该有直接依赖关系的类之间,不要有依赖;
  • 有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的「有限知识」)。

结合迪米特法则,我们可以总结出一条新的设计原则,那就是「基于最小接口而非最大实现编程」。

本文参考