关于 SOLID 原则,已经学过单一职责、开闭、里式替换、接口隔离这四个原则。接下来再学习最后一个原则:依赖反转原则。这个原则用起来比较简单,但概念理解起来比较难。比如,下面这几个问题:
- “依赖反转”这个概念指的是“谁跟谁”的“什么依赖”被反转了?“反转”两个字该如何理解?
- 我们还经常听到另外两个概念:“控制反转”和“依赖注入”。这两个概念跟“依赖反转”有什么区别和联系呢?它们说的是同一个事情吗?
Spring
框架中的 IOC 跟这些概念又有什么关系呢?
控制反转(IOC)
在讲“依赖反转原则”之前,先讲一讲“控制反转”。控制反转的英文翻译是 Inversion Of Control,缩写为 IOC。此处要强调一下,暂时别把这个“IOC”跟 Spring 框架的 IOC 联系在一起。关于 Spring 的 IOC,待会儿还会讲到。先通过一个例子来看一下,什么是控制反转。
public class UserServiceTest {
public static boolean doTest() {
// ...
}
public static void main(String[] args) { // 这部分逻辑可以放到框架中
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
}
在上面的代码中,所有的流程都由程序员来控制。如果抽象出一个下面的框架,如何利用框架来实现同样的功能?具体的代码实现如下所示:
public abstract class TestCase {
public void run() {
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
public abstract boolean doTest();
}
public class JunitApplication {
private static final List<TestCase> testCases = new ArrayList<>();
public static void register(TestCase testCase) {
testCases.add(testCase);
}
public static final void main(String[] args) {
for (TestCase case: testCases) {
case.run();
}
}
}
把这个简化版本的测试框架引入到工程中之后,只需要在框架预留的扩展点,也就是TestCase
类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了。 具体的代码如下所示:
public class UserServiceTest extends TestCase {
@Override
public boolean doTest() {
// ...
}
}
// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用register()
JunitApplication.register(new UserServiceTest();
- 刚刚举的这个例子,就是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。
- 这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。
- 实际上,实现控制反转的方法有很多,除了刚才例子中所示的类似于模板设计模式的方法之外,还有接下来要讲到的依赖注入等方法,所以,控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
依赖注入(DI)
接着再来看依赖注入。依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。那到底什么是依赖注入呢?用一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
下面通过一个例子来解释。在这个例子中,
Notification
类负责消息推送,依赖MessageSender
类实现推送商品促销、验证码等消息给用户。分别用依赖注入和非依赖注入两种方式实现。具体的实现代码如下所示:// 非依赖注入实现方式 public class Notification { private MessageSender messageSender; public Notification() { this.messageSender = new MessageSender(); // 此处有点像hardcode } public void sendMessage(String cellphone, String message) { // ...省略校验逻辑等... this.messageSender.send(cellphone, message); } } public class MessageSender { public void send(String cellphone, String message) { // .... } } // 使用Notification Notification notification = new Notification(); // 依赖注入的实现方式 public class Notification { private MessageSender messageSender; // 通过构造函数将messageSender传递进来 public Notification(MessageSender messageSender) { this.messageSender = messageSender; } public void sendMessage(String cellphone, String message) { // ...省略校验逻辑等... this.messageSender.send(cellphone, message); } } // 使用Notification MessageSender messageSender = new MessageSender(); Notification notification = new Notification(messageSender);
通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,可以灵活地替换依赖的类。这一点在之前讲“开闭原则”的时候也提到过。当然,上面代码还有继续优化的空间,还可以把
MessageSender
定义成接口,基于接口而非实现编程。改造后的代码如下所示:public class Notification { private MessageSender messageSender; public Notification(MessageSender messageSender) { this.messageSender = messageSender; } public void sendMessage(String cellphone, String message) { this.messageSender.send(cellphone, message); } } public interface MessageSender { void send(String cellphone, String message); } // 短信发送类 public class SmsSender implements MessageSender { @Override public void send(String cellphone, String message) { // .... } } // 站内信发送类 public class InboxSender implements MessageSender { @Override public void send(String cellphone, String message) { // .... } } // 使用Notification MessageSender messageSender = new SmsSender(); Notification notification = new Notification(messageSender);
实际上,只需要掌握刚刚举的这个例子,等于完全掌握了依赖注入。尽管依赖注入非常简单,但却非常有用
依赖注入框架(DI Framework)
弄懂了什么是“依赖注入”,再来看一下什么是“依赖注入框架”。还是借用刚刚的例子来解释。在采用依赖注入实现的 Notification
类中,虽然不需要用类似hard code
的方式在类内部通过 new 来创建MessageSender
对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要我们自己来实现。具体代码如下所示:
public class Demo {
public static final void main(String args[]) {
MessageSender sender = new SmsSender(); // 创建对象
Notification notification = new Notification(sender); // 依赖注入
notification.sendMessage("13918942177", "短信验证码:2346");
}
}
- 在实际的软件开发中,一些项目可能会涉及几十、上百、甚至几百个类,类对象的创建和依赖注入会变得非常复杂。如果这部分工作都是靠程序员自己写代码来完成,容易出错且开发成本也比较高。而对象创建和依赖注入的工作,本身跟具体的业务无关,完全可以抽象成框架来自动完成。
- 这个框架就是“依赖注入框架”。只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
- 实际上,现成的依赖注入框架有很多,比如 Google Guice、Java Spring、Pico Container、Butterfly Container 等。不过,如果你熟悉 Java Spring 框架,你可能会说,Spring 框架自己声称是控制反转容器(Inversion Of Control Container)。
- 实际上,这两种说法都没错。只是控制反转容器这种表述是一种非常宽泛的描述,DI 依赖注入框架的表述更具体、更有针对性。因为前面讲到实现控制反转的方式有很多,除了依赖注入,还有模板模式等,而 Spring 框架的控制反转主要是通过依赖注入来实现的。不过这点区分并不是很明显,也不是很重要,稍微了解一下就可以了。
什么是依赖反转原则?
- 前面讲了控制反转、依赖注入、依赖注入框架,现在来讲一讲今天的主角:依赖反转原则。依赖反转原则的英文翻译是 Dependency Inversion Principle,缩写为 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.
- 将它翻译成中文,大概意思就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。
- 所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。拿 Tomcat 这个 Servlet 容器作为例子来解释。
- Tomcat 是运行 Java Web 应用程序的容器。编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
KISS原则和YAGNI原则
接下来介绍两个设计原则:KISS 原则和 YAGNI 原则。其中,KISS 原则比较经典,耳熟能详,但 YAGNI 可能没怎么听过,不过它理解起来也不难。
理解这两个原则时候,经常会有一个共同的问题,那就是,看一眼就感觉懂了,但深究的话,又有很多细节问题不是很清楚。比如,怎么理解 KISS 原则中“简单”两个字?什么样的代码才算“简单”?怎样的代码才算“复杂”?如何才能写出“简单”的代码?YAGNI 原则跟 KISS 原则说的是一回事吗?
KISS 原则的英文描述有好几个版本,比如下面这几个。
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
不过,仔细看就会发现,它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。
代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,
bug
比较难隐藏。即便出现bug
,修复起来也比较简单。不过,这条原则只是告诉我们,要保持代码“Simple and Stupid”,但并没有讲到,什么样的代码才是“Simple and Stupid”的,更没有给出特别明确的方法论,来指导如何开发出“Simple and Stupid”的代码。为了能让这条原则切实地落地,能够指导实际的项目开发,针对刚刚的这些问题来进一步讲解。
代码行数越少就越“简单”吗?
下面这三段代码可以实现同一个功能:检查输入的字符串ipAddress
是否是合法的 IP 地址。一个合法的 IP 地址由四个数字组成,并且通过“.”来进行分割。每组数字的取值范围是 0~255。第一组数字比较特殊,不允许为 0。对比这三段代码,你觉得哪一段代码最符合 KISS 原则呢?如果让你来实现这个功能,你会选择用哪种实现方法呢?
// 第一种实现方式: 使用正则表达式
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 原则呢?答案是否定的。虽然代码行数最少,看似最简单,实际上却很复杂。这正是因为它使用了正则表达式。
- 一方面,正则表达式本身是比较复杂的,写出完全没有
bug
的正则表达本身就比较有挑战;另一方面,并不是每个程序员都精通正则表达式。对于不怎么懂正则表达式的同事来说,看懂并且维护这段正则表达式是比较困难的。这种实现方式会导致代码的可读性和可维护性变差,所以,从 KISS 原则的设计初衷上来讲,这种实现方式并不符合 KISS 原则。 - 第二种实现方式使用了
StringUtils
类、Integer
类提供的一些现成的工具函数,来处理 IP 地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理 IP 地址中的字符,来判断是否合法。从代码行数上来说,这两种方式差不多。但是,第三种要比第二种更加有难度,更容易写出 bug。从可读性上来说,第二种实现方式的代码逻辑更清晰、更好理解。所以,在这两种实现方式中,第二种实现方式更加“简单”,更加符合 KISS 原则。 - 不过,你可能会说,第三种实现方式虽然实现起来稍微有点复杂,但性能要比第二种实现方式高一些。从性能的角度来说,选择第三种实现方式是不是更好些呢?在回答这个问题之前,先解释一下,为什么说第三种实现方式性能会更高一些。一般来说,工具类的功能都比较通用和全面,所以,在代码实现上,需要考虑和处理更多的细节,执行效率就会有所影响。而第三种实现方式,完全是自己操作底层字符,只针对 IP 地址这一种格式的数据输入来做处理,没有太多多余的函数调用和其他不必要的处理逻辑,所以,在执行效率上,这种类似定制化的处理代码方式肯定比通用的工具类要高些。
- 不过,尽管第三种实现方式性能更高些,但我还是更倾向于选择第二种实现方法。那是因为第三种实现方式实际上是一种过度优化。除非
isValidIpAddress()
函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显。
代码逻辑复杂就违背 KISS 原则吗?
并不是代码行数越少就越“简单”,还要考虑逻辑复杂度、实现难度、代码的可读性等。那如果一段代码的逻辑复杂、实现难度大、可读性也不太好,是不是就一定违背 KISS 原则呢?在回答这个问题之前,先来看下面这段代码:
// KMP algorithm: a, b分别是主串和模式串;n, m分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
int[] next = getNexts(b, m);
int j = 0;
for (int i = 0; i < n; ++i) {
while (j > 0 && a[i] != b[j]) { // 一直找到a[i]和b[j]
j = next[j - 1] + 1;
}
if (a[i] == b[j]) {
++j;
}
if (j == m) { // 找到匹配模式串的了
return i - m + 1;
}
}
return -1;
}
// b表示模式串,m表示模式串的长度
private static int[] getNexts(char[] b, int m) {
int[] next = new int[m];
next[0] = -1;
int k = -1;
for (int i = 1; i < m; ++i) {
while (k != -1 && b[k + 1] != b[i]) {
k = next[k];
}
if (b[k + 1] == b[i]) {
++k;
}
next[i] = k;
}
return next;
}
- 这段代码是
KMP
字符串匹配算法的代码实现。这段代码完全符合刚提到的逻辑复杂、实现难度大、可读性差的特点,但它并不违反 KISS 原则。为什么这么说呢? - KMP 算法以快速高效著称。当需要处理长文本字符串匹配问题(几百 MB 大小文本内容的匹配),或者字符串匹配是某个产品的核心功能(比如 Vim、Word 等文本编辑器),又或者字符串匹配算法是系统性能瓶颈的时候,就应该选择尽可能高效的 KMP 算法。而 KMP 算法本身具有逻辑复杂、实现难度大、可读性差的特点。本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。
- 不过,平时的项目开发中涉及的字符串匹配问题,大部分都是针对比较小的文本。在这种情况下,直接调用编程语言提供的现成的字符串匹配函数就足够了。如果非得用 KMP 算法、BM 算法来实现字符串匹配,那就真的违背 KISS 原则了。也就是说,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。
如何写出满足 KISS 原则的代码?
实际上,前面已经讲到了一些方法。这里稍微总结一下。
- 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。
- 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
- 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
- 实际上,代码是否足够简单是一个挺主观的评判。同样的代码,有的人觉得简单,有的人觉得不够简单。而往往自己编写的代码,自己都会觉得够简单。所以,评判代码是否简单,还有一个很有效的间接方法,那就是 code review。如果在 code review 的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够“简单”,需要优化。
YAGNI 跟 KISS 说的是一回事吗?
- YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。
- 比如,系统暂时只用 Redis 存储配置信息,以后可能会用到 ZooKeeper。根据 YAGNI 原则,在未用到 ZooKeeper 之前,没必要提前编写这部分代码。当然,这并不是说不需要考虑代码的扩展性。还是要预留好扩展点,等到需要的时候,再去实现 ZooKeeper 存储配置信息这部分代码。
- 再比如,不要在项目中提前引入不需要依赖的开发包。对于 Java 程序员来说,我们经常使用 Maven 或者 Gradle 来管理依赖的类库(library)。有些同事为了避免开发中 library 包缺失而频繁地修改 Maven 或者 Gradle 配置文件,提前往项目里引入大量常用的 library 包。实际上,这样的做法也是违背 YAGNI 原则的。
- 从刚刚的分析可以看出,YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。
DRY 原则
Don’t Repeat Yourself。中文直译为:不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码。DRY 原则的定义非常简单,不再过度解读。以下列出了三种典型的代码重复情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有的看似违反 DRY,实际上并不违反;有的看似不违反,实际上却违反了。
实现逻辑重复
先看下面这样一段代码是否违反了 DRY 原则。如果违反了,应该如何重构才能让它满足 DRY 原则?如果没有违反,那又是为什么呢?
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()
函数。重复的代码被敲了两遍,或者简单 copy-paste 了一下,看起来明显违反 DRY 原则。为了移除重复的代码,对上面的代码做下重构,将 isValidUserName()
函数和isValidPassword()
函数合并为一个更通用的函数isValidUserNameOrPassword()
。重构后的代码如下所示:
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()
函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。实际上,即便将两个函数合并成isValidUserNameOrPassword()
,代码仍然存在问题。 - 因为
sValidUserName()
和isValidPassword()
两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName()
和isValidPassword()
的实现逻辑就会不相同。就要把合并后的函数,重新拆成合并前的那两个函数。 - 尽管代码的实现逻辑是相同的,但语义不同,因此判定它并不违反 DRY 原则。对于包含重复代码的问题,可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含 a-z、0-9、dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数。
功能语义重复
再来看另一个例子。在同一个项目代码中有下面两个函数:
isValidIp()
和checkIfIpValid()
。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。之所以在同一个项目中会有两个功能相同的函数,是因为这两个函数是由两个不同的同事开发的,其中一个同事在不知道已经有了
isValidIp()
的情况下,自己又定义并实现了同样用来校验 IP 地址是否合法的checkIfIpValid()
函数。那在同一项目代码中,存在如下两个函数,是否违反 DRY 原则呢?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 原则。而在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,认为它违反了 DRY 原则。应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。
假设不统一实现思路,那有些地方调用了
isValidIp()
函数,有些地方又调用了checkIfIpValid()
函数,这就会导致代码看起来很奇怪,相当于给代码“埋坑”,给不熟悉这部分代码的同事增加了阅读的难度。同事有可能研究了半天,觉得功能是一样的,但又有点疑惑,觉得是不是有更高深的考量,才定义了两个功能类似的函数,最终发现居然是代码设计的问题。除此之外,如果哪天项目中 IP 地址是否合法的判定规则改变了,比如:255.255.255.255 不再被判定为合法的了,相应地,对
isValidIp()
的实现逻辑做了相应的修改,但却忘记了修改 checkIfIpValid() 函数。又或者,压根就不知道还存在一个功能相同的checkIfIpValid()
函数,这样就会导致有些代码仍然使用老的 IP 地址判断逻辑,导致出现一些莫名其妙的bug
。
代码执行重复
前两个例子一个是实现逻辑重复,一个是语义重复,再来看第三个例子。其中,UserService
中login()
函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。具体代码如下所示:
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...
}
}
public User getUserByEmail(String email) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
}
}
上面这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码中存在“执行重复”。到底哪些代码被重复执行了?
重复执行最明显的一个地方,就是在 login() 函数中,email 的校验逻辑被执行了两次。一次是在调用
checkIfUserExisted()
函数的时候,另一次是调用getUserByEmail()
函数的时候。这个问题解决起来比较简单,只需要将校验逻辑从 UserRepo 中移除,统一放到 UserService 中就可以了。除此之外,代码中还有一处比较隐蔽的执行重复。实际上,
login()
函数并不需要调用checkIfUserExisted()
函数,只需要调用一次getUserByEmail()
函数,从数据库中获取到用户的 email、password 等信息,然后跟用户输入的 email、password 信息做对比,依次判断是否登录成功。实际上,这样的优化是很有必要的。因为
checkIfUserExisted()
函数和 getUserByEmail() 函数都需要查询数据库,而数据库这类的 I/O 操作是比较耗时的。在写代码的时候,应当尽量减少这类 I/O 操作。按照刚刚的修改思路,把代码重构一下,移除“重复执行”的代码,只校验一次 email 和 password,并且只查询一次数据库。重构之后的代码如下所示:
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 Reusability)
- 首先区分三个概念:代码复用性(Code Reusability)、代码复用(Code Resue)和 DRY 原则。代码复用表示一种行为:在开发新功能时,尽量复用已经存在的代码。代码的可复用性表示一段代码可被复用的特性或能力:在编写代码的时候,让代码尽量可复用。DRY 原则是一条原则:不要写重复的代码。从定义描述上,它们好像有点类似,但深究起来,三者的区别还是蛮大的。
- 首先,“不重复”并不代表“可复用”。在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY 原则跟代码的可复用性讲的是两回事。
- 其次,“复用”和“可复用性”关注角度不同。代码
可复用性
是从代码开发者的角度来讲的,复用
是从代码使用者的角度来讲的。比如,A 同事编写了一个UrlUtils 类
,代码的“可复用性”很好。B 同事在开发新功能的时候,直接“复用”A 同事编写的 UrlUtils 类。尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,bug 会比从零重新开发要少。 - “复用”这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的产生也都是为了达到复用的目的。比如,Spring 框架、Google Guava 类库、UI 组件等等。
怎么提高代码复用性?
实际上,前面已经讲到过很多提高代码可复用性的手段,下面集中总结了 7 条。
对于高度耦合的代码,当希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,要尽量减少代码耦合。 如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合度。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。 这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。 越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。 从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码尽量下沉到更下层。 利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。 一些设计模式也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。此外,还有一些跟编程语言相关的特性,也能提高代码的复用性,比如泛型编程等。实际上,除了上面讲到的这些方法之外,复用意识也非常重要。迪米特法则
最后一个设计原则:迪米特法则。尽管它不像 SOLID、KISS、DRY 原则那样,人尽皆知,但它却非常实用。利用这个原则,能够实现代码的“高内聚、松耦合”。
- 什么是“高内聚、松耦合”?
- 如何利用迪米特法则来实现“高内聚、松耦合”?
- 有哪些代码设计是明显违背迪米特法则的?对此又该如何重构?
何为“高内聚、松耦合”?
- “高内聚、松耦合”是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。
- 很多设计原则都以实现代码的“高内聚、松耦合”为目的,比如单一职责原则、基于接口而非实现编程等。实际上,“高内聚、松耦合”是一个比较通用的设计思想,可以用来指导不同粒度代码的设计与开发,比如系统、模块、类,甚至是函数,也可以应用到不同的开发场景中,比如微服务、框架、组件、类库等。为了方便,接下来以“类”作为这个设计思想的应用对象来展开讲解,其他应用场景完全类似。
- 在这个设计思想中,“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。不过,这两者并非完全独立不相干。高内聚有助于松耦合,松耦合又需要高内聚的支持。
- 图中左边部分的代码设计中,类的粒度比较小,每个类的职责都比较单一。相近的功能都放到了一个类中,不相近的功能被分割到了多个类中。这样类更加独立,代码的内聚性更好。因为职责单一,所以每个类被依赖的类就会比较少,代码低耦合。一个类的修改,只会影响到一个依赖类的代码改动。只需要测试这一个依赖类是否还能正常工作就行了。
- 图中右边部分的代码设计中,类粒度比较大,低内聚,功能大而全,不相近的功能放到了一个类中。这就导致很多其他类都依赖这个类。当修改这个类的某一个功能代码的时候,会影响依赖它的多个类。需要测试这三个依赖类,是否还能正常工作。这也就是所谓的“牵一发而动全身”。
- 除此之外,从图中也可以看出,高内聚、低耦合的代码结构更加简单、清晰,相应地,在可维护性和可读性上确实要好很多。
“迪米特法则”理论描述
迪米特法则的英文翻译是:Law of Demeter,缩写是 LOD。单从这个名字上来看,完全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle。
关于这个设计原则,先来看一下它最原汁原味的英文定义:
这两部分讲的是两件事情,接下来用两个实战案例分别解读。
理论解读与代码实战一
先看这条原则中的前半部分,“不该有直接依赖关系的类之间,不要有依赖”。
这个例子实现了简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。其中,
NetworkTransporter
类负责底层网络通信,根据请求获取数据;HtmlDownloader
类用来通过 URL 获取网页;Document
表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。具体的代码实现如下所示:public class NetworkTransporter { // 省略属性和其他方法... public Byte[] send(HtmlRequest htmlRequest) { // ... } } public class HtmlDownloader { private NetworkTransporter transporter; // 通过构造函数或IOC注入 public Html downloadHtml(String url) { Byte[] rawHtml = transporter.send(new HtmlRequest(url)); return new Html(rawHtml); } } public class Document { private Html html; private String url; public Document(String url) { this.url = url; HtmlDownloader downloader = new HtmlDownloader(); this.html = downloader.downloadHtml(url); } // ... }
这段代码虽然“能用”,能实现我们想要的功能,但它不够“好用”,有比较多的设计缺陷。
首先,来看
NetworkTransporter
类。作为一个底层网络通信类,希望它的功能尽可能通用,而不只是服务于下载 HTML,所以,不应该直接依赖太具体的发送对象 HtmlRequest。从这一点上讲,NetworkTransporter
类的设计违背迪米特法则,依赖了不该有直接依赖关系的 HtmlRequest 类。应该如何进行重构,才能让
NetworkTransporter
类满足迪米特法则呢?假如现在要去商店买东西,肯定不会直接把钱包给收银员,让收银员自己从里面拿钱,而是你从钱包里把钱拿出来交给收银员。这里的HtmlRequest
对象就相当于钱包,HtmlRequest 里的 address 和 content 对象就相当于钱。我们应该把 address 和 content 交给 NetworkTransporter,而非是直接把 HtmlRequest 交给 NetworkTransporter。根据这个思路,NetworkTransporter 重构之后的代码如下所示:public class NetworkTransporter { // 省略属性和其他方法... public Byte[] send(String address, Byte[] data) { // ... } }
再来看
HtmlDownloader
类。这个类的设计没有问题。不过,我们修改了 NetworkTransporter 的 send() 函数的定义,而这个类用到了 send() 函数,所以需要对它做相应的修改,修改后的代码如下所示:public class HtmlDownloader { private NetworkTransporter transporter; // 通过构造函数或IOC注入 // HtmlDownloader这里也要有相应的修改 public Html downloadHtml(String url) { HtmlRequest htmlRequest = new HtmlRequest(url); Byte[] rawHtml = transporter.send(htmlRequest.getAddress(), htmlRequest.getContent().getBytes()); return new Html(rawHtml); } }
最后看下 Document 类。这个类的问题比较多,主要有三点。第一,构造函数中的
downloader.downloadHtml()
逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。第二,HtmlDownloader
对象在构造函数中通过new
来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。第三,从业务含义上来讲,Document 网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则。虽然 Document 类的问题很多,但修改起来比较简单,只要一处改动就可以解决所有问题。修改之后的代码如下所示:
public class Document { private Html html; private String url; public Document(String url, Html html) { this.html = html; this.url = url; } // ... } // 通过一个工厂方法来创建Document public class DocumentFactory { private HtmlDownloader downloader; public DocumentFactory(HtmlDownloader downloader) { this.downloader = downloader; } public Document createDocument(String url) { Html html = downloader.downloadHtml(url); return new Document(url, html); } }
理论解读与代码实战二
现在,再来看一下这条原则中的后半部分:“有依赖关系的类之间,尽量只依赖必要的接口”。还是结合一个例子来讲解。下面这段代码非常简单,Serialization
类负责对象的序列化和反序列化。
public class Serialization {
public String serialize(Object object) {
String serializedResult = ...;
// ...
return serializedResult;
}
public Object deserialize(String str) {
Object deserializedResult = ...;
// ...
return deserializedResult;
}
}
单看这个类的设计没有一点问题。不过,如果把它放到一定的应用场景里,那就还有继续优化的空间。假设在项目中,有些类只用到了序列化操作,而另一些类只用到反序列化操作。那基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口。
根据这个思路,应该将
Serialization
类拆分为两个更小粒度的类,一个只负责序列化(Serializer 类),一个只负责反序列化(Deserializer 类)。拆分之后,使用序列化操作的类只需要依赖Serializer
类,使用反序列化操作的类只需要依赖Deserializer
类。拆分之后的代码如下所示:public class Serializer { public String serialize(Object object) { String serializedResult = ...; ... return serializedResult; } } public class Deserializer { public Object deserialize(String str) { Object deserializedResult = ...; ... return deserializedResult; } }
尽管拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的地方不至于过于分散。对于刚刚这个例子,如果修改了序列化的实现方式,比如从
JSON
换成了XML
,那反序列化的实现逻辑也需要一并修改。在未拆分的情况下,只需要修改一个类即可。在拆分之后,需要修改两个类。显然,这种设计思路的代码改动范围变大了。如果既不想违背高内聚的设计思想,也不想违背迪米特法则,那该如何解决这个问题呢?实际上,通过引入两个接口就能轻松解决这个问题,具体的代码如下所示。实际上,在讲到“接口隔离原则”的时候,第三个例子就使用了类似的实现思路。
public interface Serializable { String serialize(Object object); } public interface Deserializable { Object deserialize(String text); } public class Serialization implements Serializable, Deserializable { @Override public String serialize(Object object) { String serializedResult = ...; ... return serializedResult; } @Override public Object deserialize(String str) { Object deserializedResult = ...; ... return deserializedResult; } } public class DemoClass_1 { private Serializable serializer; public Demo(Serializable serializer) { this.serializer = serializer; } // ... } public class DemoClass_2 { private Deserializable deserializer; public Demo(Deserializable deserializer) { this.deserializer = deserializer; } // ... }
尽管还是要往 DemoClass_1 的构造函数中,传入包含序列化和反序列化的
Serialization
实现类,但是,依赖的Serializable
接口只包含序列化操作,DemoClass_1 无法使用 Serialization 类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。实际上,上面的的代码实现思路,也体现了“基于接口而非实现编程”的设计原则,结合迪米特法则,可以总结出一条新的设计原则,那就是“基于最小接口而非最大实现编程”。
辩证思考与灵活应用
对于实战二最终的设计思路,你有没有什么不同的观点呢?
整个类只包含序列化和反序列化两个操作,只用到序列化操作的使用者,即便能够感知到仅有的一个反序列化函数,问题也不大。那为了满足迪米特法则,将一个非常简单的类,拆分出两个接口,是否有点过度设计的意思呢?
设计原则本身没有对错,只有能否用对之说。不要为了应用设计原则而应用设计原则,在应用设计原则的时候,一定要具体问题具体分析。
对于刚刚这个 Serialization 类来说,只包含两个操作,确实没有太大必要拆分成两个接口。但是,如果对
Serialization
类添加更多的功能,实现更多更好用的序列化、反序列化函数,重新考虑一下这个问题。修改之后的具体的代码如下public class Serializer { // 参看JSON的接口定义 public String serialize(Object object) { // ... } public String serializeMap(Map map) { // ... } public String serializeList(List list) { // ... } public Object deserialize(String objectString) { // ... } public Map deserializeMap(String mapString) { // ... } public List deserializeList(String listString) { // ... } }
在这种场景下,第二种设计思路要更好些。因为基于之前的应用场景来说,大部分代码只需要用到序列化的功能。对于这部分使用者,没必要了解反序列化的“知识”。而修改之后的 Serialization 类,反序列化的“知识”从一个函数变成了三个。一旦任一反序列化操作有代码改动,都需要检查、测试所有依赖 Serialization 类的代码是否还能正常工作。为了减少耦合和测试工作量,应该按照迪米特法则,将反序列化和序列化的功能隔离开来。