如何利用重构提高代码质量 一、一个关于 ID 生成器的案例

一、一个关于 ID 生成器的案例

某日某公司某部门周会上:

老板:”当前我们系统出错一般是通过看日志来排查问题,但是现在我们系统每天的日志量太大了,而且日志文件中不同请求的日志会交织在一起,没办法直接看到某个请求的所有日志,这个大家有什么好办法吗?”

小王:”老板,我觉得我们可以借鉴微服务中调用链追踪的思路,给每个请求分配一个唯一的 ID,保存在请求的上下文中,比如保存在 ThreadLocal 中。每次打印日志的时候,我们从请求上下文中取出 ID,跟日志一块输出,这样,同一个请求的所有日志都包含同样的 ID,我们就可以通过 ID 来搜索同一个请求的所有日志了。”

老板:”嗯,这个方案可以,不过这个 ID 应该如何生成呢?”

小王:”我们需要先定义这个 ID 的格式,比如将 ID 分成三部分,第一部分是本机名的最后一个字段,第二部分是当前时间戳,第三部分是 8 位的随机字符串,包含大小写字母和数字,然后借助一个 ID 生成器生成这个 ID。”

老板:”通过这种方式生成的 ID 不会重复吗?”

小王:”通过这种方式生成的 ID 确实有重复的可能,但是事实上重复的概率非常低,对于我们这个日志追踪的需求来说,极小概率的 ID 重复是完全可以接受的。”

老板:”好的,那就由你来负责这个 ID 生成器的开发吧。”

小王:”收到。”

周会结束后,回到工位的小王开始着手于这个 ID 生成器的开发。对于稍微有点开发经验的小王来说,实现这样一个 ID 生成器并不是件难事。很快,小王就将代码写了出来,具体如下:

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
java复制代码public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

public static String generate() {
String id = "";
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\.");
if (tokens.length > 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8) {
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii >= 65 && randomAscii <= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii >= 97 && randomAscii <= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s", hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}

return id;
}
}

写好代码后,小王兴冲冲地把代码发给老板,请求老板做一下 code review,老板看了看小王的代码,皱了皱眉头,把代码打回给了小王,告诉他代码不够规范,让他重构一下。小王挠了挠头,不知道该如何重构。假设我们现在是小王的同事,我们应该如何帮助小王去重构这样一份代码呢?

在进行重构之前,我们先了解一些重构相关的知识。

二、为什么要重构

首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在演进,代码在不停地堆砌,如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进,当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,想要再去重构,已经没有人能做到了。

其次,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。

最后,重构可以帮助我们学习经典的设计思想、设计原则、设计模式和编程规范。重构实际上就是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力。

三、重构是什么

软件设计大师 Martin Fowler 是这样定义重构的:

重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。

简单来说,重构就是,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量

根据重构的规模,我们可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次重构(以下简称为“小型重构”):

  • 大型重构:大型重构指的是对顶层代码设计的重构,包括系统、模块、代码结构、类与类之间的关系等的重构。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大。
  • 小型重构:小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。

四、什么时候重构

是代码烂到一定程度之后才去重构吗?当然不是。因为当代码真的烂到出现“开发效率低,招了很多人,天天加班,产出却不多,线上 bug 频发,工程师抱怨不断”的时候,基本上重构也无法解决问题了。所以,寄希望于在代码烂到一定程度之后,集中重构解决所有问题是不现实的。

有一种重构策略是持续重构,也是我个人比较赞同的重构策略。在平时工作中,我们可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,我们也可以顺手把不符合编码规范、不好的设计重构一下。如果我们能把持续重构作为日常开发工作的一部分,培养持续重构的意识,使之成为一种开发习惯,那么对项目、对自己都是很有好处的。

五、如何重构

按照重构的规模,重构可以笼统地分为大型重构和小型重构。对于这两种不同规模的重构,我们要区别对待。

5.1 大型重构

对于大型重构来说,因为涉及的模块、代码会比较多,我们要提前做好完善的重构计划,有条不紊地分阶段来进行,每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,我们都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。

对于大型重构来说,最有效的一个手段就是“解耦”。那么,如何进行解耦呢?

5.1.1 封装与抽象

封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

举个例子,Unix 提供的 open() 函数用来打开一个文件,我们用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。我们通过将其封装成一个抽象的 open() 函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。除此之外,因为 open() 函数基于抽象而非具体的实现来定义,所以我们在改动 open() 函数的底层实现的时候,并不需要改动依赖它的上层代码,保证了代码的高内聚、低耦合。

5.1.2 中间层

引入中间层能简化模块或类之间的依赖关系。

image.png

上面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存存储、Redis 存储、DB 存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图中可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰。

系统架构设计中的防腐层,以及设计模式中的门面模式都体现了中间层的设计思想。

5.1.3 模块化

模块化是构建复杂系统常用的手段。对于一个大型、复杂的系统来说,没有人能掌控所有的细节。将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,我们也能协调各个模块,让整个系统有效运转。

聚焦到代码层面,合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。在开发代码的时候要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。

像 SOA、微服务、lib 库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。

5.1.4 一些设计思想和原则

理论指导实践,在重构的过程中,掌握一些通用的设计思想和设计原则可以更有效地帮助我们进行重构,下面列举了几种常用的设计思想和设计原则:

  • 单一职责原则:模块或类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。
  • 基于接口而非实现编程:通过接口这样一个中间层,隔离变化和实现,这样做的好处是,在有依赖关系的两个模块之间,一个模块的改动不会影响到另一个模块。
  • 依赖注入:跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。
  • 多用组合少用继承:继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活。
  • 迪米特法则:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。

5.2 小型重构

如果说大型重构需要构建完善的重构计划、设计复杂的技术方案和花费大量的时间和精力才能完成,小型重构通过编码规范基本上就能够很好地改善代码的质量。

编码规范的内容比较多,公司也有自己的编码规范,每个人的代码风格也不一样,这里我总结下我觉得比较好用的几条编码规范,大家也可以在评论区讨论一下哪种编码规范比较好。

5.2.1 命名

之所以把命名放到编码规范的第一条来说,是因为我觉得,命名太重要了。大到项目名、模块名、包名、对外暴露的接口名,小到类名、函数名、变量名、参数名,只要是做开发,我们就逃不过“起名字”这一关。命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。

那么,具体应该如何命名呢?

1、长命名 or 短命名?

长命名可以包含更多的信息,更能准确直观地表达作者的意图,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长,在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码的可读性。

相反,短命名占用的空间更小,但是往往不能准确地表达作者的意图,同时,命名中出现的各种缩写也会给阅读你代码的人造成很大的理解成本。

我认为,对于作用域比较小的变量,我们可以使用相对短的命名,比如一些函数内的临时变量。相反,对于类名这种作用域比较大的,我更推荐用长的命名方式。

2、利用上下文简化命名

举个例子:

1
2
3
4
5
6
java复制代码public class User {
private String userName;
private String userPassword;
private String userAvatarUrl;
//...
}

在上述代码中,userName 等属性名前的 user 就没有必要加,因为在 User 这个类的上下文中,name 指的就是 userName,表意足够明确。

同理,对于函数参数的命名也可以使用函数名上下文进行简化。

3、接口和抽象类的命名

对于接口的命名,一般有两种比较常见的方式,一种是加前缀 “I”,表示一个 Interface,比如 IUserService,对应的实现类命名为 UserService。另一种是不加前缀,比如 UserService,对应的实现类加后缀 “Impl”,比如 UserServiceImpl。

对于抽象类的命名,也有两种方式,一种是带上前缀 “Abstract”,比如 AbstractConfiguration;另一种是不带前缀 “Abstract”。

我认为,具体哪种命名方式不重要,只要能够在团队和项目中统一就行。

5.2.2 注释

有一种说法是,好的命名完全可以替代注释,如果需要注释,那说明命名不够好,需要在命名上下功夫,而不是添加注释。我认为这种说法有点极端,理由有三:

  • 我们很难保证自己的命名是规范的命名,自己的代码是规范的代码
  • 注释比代码承载的信息更多:函数和变量如果命名得好,确实可以不用再在注释中解释它是做什么的。但是,对于类来说,包含的信息比较多,一个简单的命名就不够全面详尽了。
  • 注释起到总结性作用、文档的作用:在注释中,关于具体的代码实现思路,我们可以写一些总结性的说明、特殊情况的说明。这样能够让阅读代码的人通过注释就能大概了解代码的实现思路,阅读起来就会更加容易。

此外,一些总结性注释能让代码结构更清晰,对于逻辑比较复杂的代码或者比较长的函数,如果不好提炼、不好拆分成小的函数调用,那我们可以借助总结性的注释来让代码结构更清晰、更有条理,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public boolean isValidPasword(String password) {
// 检查密码是否为空
if (StringUtils.isBlank(password)) {
return false;
}

// 检查密码的长度是否在 4-64 之间
int length = password.length();
if (length < 4 || length > 64) {
return false;
}

// 检查密码是否只包含小写字母、数字和小数点
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;
}

5.2.3 把代码分割成更小的单元块

大部分人阅读代码的习惯都是,先看整体再看细节。所以,我们要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性。

举个例子:

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
java复制代码// 重构前的代码
public void invest(long userId, long financialProductId) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return;
}
//...
}

// 重构后的代码
public void invest(long userId, long financialProductId) {
if (isLastDayOfMonth(new Date())) {
return;
}
//...
}

public boolean isLastDayOfMonth(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return true;
}

return false;
}

重构前,在 invest() 函数中,最开始的那段关于时间处理的代码,是不是很难看懂?重构之后,我们将这部分逻辑抽象成一个函数,并且命名为 isLastDayOfMonth,从名字就能清晰地了解它的功能,判断今天是不是当月的最后一天。

5.2.4 函数设计要职责单一

将单一职责原则应用到函数上的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public boolean checkUserIfExisting(String telephone, String username, String email)  { 
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}

if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}

if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}

return false;
}

// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);

5.2.5 移除过深的嵌套层次

代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁。可以通过调整执行顺序和使用continue、break、return 等关键字,提前退出嵌套,举个例子:

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
java复制代码// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
List<String> matchedStrings = new ArrayList<>();
if (strList != null && substr != null) {
for (String str : strList) {
if (str != null) {
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
}
return matchedStrings;
}

// 重构后的代码
public List<String> matchStrings(List<String> strList,String substr) {
if (strList == null || substr == null) {
return Collections.emptyList();
}

List<String> matchedStrings = new ArrayList<>();
for (String str : strList) {
if (str == null) {
continue;
}

if (str.contains(substr)) {
matchedStrings.add(str);
}
}

return matchedStrings;
}

5.2.6 善用解释性变量

善用解释性变量来解释复杂表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
// ...
} else {
// ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
// ...
} else {
// ...
}

六、重构 ID 生成器代码

介绍完了重构的一些知识,下面让我们把理论应用到实践中,回过头来看一下,对于文章开头提到的那份 ID 生成器代码,我们如何利用重构把它从一份“能用”的代码变成一份“好用”的代码。

首先,我们来重新审视一下这份 ID 生成器代码:

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
java复制代码public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

public static String generate() {
String id = "";
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\.");
if (tokens.length > 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8) {
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii >= 65 && randomAscii <= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii >= 97 && randomAscii <= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s", hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}

return id;
}
}

通过观察这份代码,我们大致可以找出以下几处代码中存在的问题:

  1. IdGenerator 设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。
  2. 代码的可读性不好。特别是随机字符串生成的那部分代码,一方面,代码完全没有注释,生成算法比较难读懂,另一方面,代码里有很多魔法数,严重影响代码的可读性。
  3. 获取 hostName 这部分代码并未处理 hostName 为空的情况。
  4. 尽管代码中针对获取不到本机名的情况做了异常处理,但是代码中对异常的处理是在 IdGenerator 内部将其吐掉,然后打印一条错误日志,并没有继续往上抛出。
  5. 每次生成 ID 都需要获取本机名,获取主机名会比较耗时,这部分可以考虑优化一下。
  6. randomAscii 的范围是 0-122,但可用数字仅包含三段子区间(0-9,a-z,A-Z),极端情况下会随机生成很多三段区间之外的无效数字,需要循环很多次才能生成随机字符串,所以随机字符串的生成算法也可以优化一下。
  7. 在 generate() 函数的 while 循环里面,三个 if 语句内部的代码非常相似,而且实现稍微有点过于复杂了,实际上可以进一步简化,将这三个 if 合并在一起。

没想到一份只有 30 几行的代码竟然被我们 review 出了 7 个问题,小王的内心一定是崩溃的……

没关系,下面我们一步一步对代码进行重构。

6.1 提高代码可读性

首先,我们要解决最明显、最急需改进的代码可读性问题。具体有下面几点:

  • hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候;
  • 将获取 hostName 的代码抽离出来,定义为 getLastfieldOfHostName() 函数;
  • 删除代码中的魔法数,比如,57、90、97、122;
  • 将随机数生成的代码抽离出来,定义为 generateRandomAlphameric() 函数;
  • generate() 函数中的三个 if 逻辑重复了,且实现过于复杂,我们要对其进行简化;
  • 对 IdGenerator 类重命名,并且抽象出对应的接口。

这里我们重点讨论下最后一个修改。实际上,对于 ID 生成器的代码,有下面三种类的命名方式:

image.png

我们来逐一分析一下三种命名方式。

第一种命名方式,将接口命名为 IdGenerator,实现类命名为 LogTraceIdGenerator,这可能是我们最先想到的命名方式了。在命名的时候,我们要考虑到的是,以后两个类会如何使用、会如何扩展。从使用和扩展的角度来分析,这样的命名就不合理了。理由有二:

  • 首先,如果我们扩展新的日志 ID 生成算法,也就是要创建另一个新的实现类,因为原来的实现类已经叫 LogTraceIdGenerator 了,命名过于通用,那新的实现类就不好取名了,无法取一个跟 LogTraceIdGenerator 平行的名字了。
  • 其次,你可能会说,假设我们没有日志 ID 的扩展需求,但要扩展其他业务的 ID 生成算法,比如针对用户的 ID 生成器(UserldGenerator)、订单的 ID 生成器(OrderIdGenerator),第一种命名方式是不是就是合理的呢?答案也是否定的。基于接口而非实现编程,主要的目的是为了方便后续灵活地替换实现类。而 LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator 三个类从命名上来看,涉及的是完全不同的业务,不存在互相替换的场景。也就是说,我们不可能在有关日志的代码中,进行这种替换。所以,让这三个类实现同一个接口,实际上是没有意义的。

第二种命名方式是不是就合理了呢?答案也是否定的。其中,LogTraceIdGenerator 接口的命名是合理的,但是 HostNameMillisIdGenerator 实现类暴露了太多实现细节,只要代码稍微有所改动,就可能需要改动命名,才能匹配实现。

第三种命名方式是我比较推荐的。在目前的 ID 生成器代码实现中,我们生成的 ID 是一个随机 ID,不是递增有序的,所以,命名成 RandomIdGenerator 是比较合理的,即便内部生成算法有所改动,只要生成的还是随机的 ID,就不需要改动命名。如果我们需要扩展新的 ID 生成算法,比如要实现一个递增有序的 ID 生成算法,那我们可以命名为 SequenceIdGenerator。

实际上,更好的一种命名方式是,我们抽象出两个接口,一个是 IdGenerator,一个是 LogTraceIdGenerator,LogTraceIdGenerator 继承 IdGenerator。实现类实现接口 LogTraceIdGenerator,命名为 RandomIdGenerator、SequenceIdGenerator 等。这样,实现类可以复用到多个业务模块中,比如前面提到的用户、订单。

根据上面的优化策略,我们对代码进行第一轮的重构,重构之后的代码如下所示:

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
java复制代码public class RandomIdGenerator implements LogTraceIdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

@Override
public String generate() {
String substrOfHostName = getLastfieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}

private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}

private String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}

private String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}

6.2 重构异常处理代码

接着,我们讨论下代码在异常情况下的处理方式,比如,在本机名获取失败的时候,ID 生成器的 generate() 函数应该返回什么呢?是异常?空字符?还是 null 值?又或者是其他特殊值呢?

在讨论具体的异常处理方式之前,让我们先讨论一下一般函数出错都有哪些处理方式?常见的函数出错返回数据类型有 4 种,它们分别是:错误码、null 值、空对象、异常对象。

1、错误码

C 语言中没有异常这样的语法机制,因此,返回错误码便是最常用的出错处理方式。而在 Java、Python 等比较新的编程语言中,大部分情况下,我们都用异常来处理函数出错的情况,极少会用到错误码。由于我们日常工作中使用的主要语言是 Java,因此这里不对错误码做过多的介绍,感兴趣的小伙伴可以自行查阅资料。

2、null

在多数编程语言中,我们用 null 来表示“不存在”这种语义。不过,网上很多人不建议函数返回 null 值,认为这是一种不好的设计思路,主要的理由有以下两个:

  • 如果某个函数有可能返回 null 值,我们在使用它的时候,忘记了做 null 值判断,就有可能会抛出空指针异常(Null Pointer Exception,NPE)。
  • 如果我们定义了很多返回值可能为 null 的函数,那代码中就会充斥着大量的 null 值判断逻辑,一方面写起来比较繁琐,另一方面它们跟正常的业务逻辑耦合在一起,会影响代码的可读性。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class UserService {
private UserRepo userRepo; // 依赖注入

public User getUser(String telephone) {
// ...

// 如果用户不存在,则返回 nullreturn null;
}
}

// 使用函数 getUser()
User user = userService.getUser("130xxxx0605");
if (user != null) { // 做 null 值判断,否则有可能会报 NPE
String email = user.getEmail();
if (email != null) { // 做 null 值判断,否则有可能会报 NPE
String escapedEmail = email.replaceAll("@", "#");
}
}

那我们是否可以用异常来替代 null 值,在查找用户不存在的时候,让函数抛出 UserNotFoundException 异常呢?

我认为,尽管返回 null 值有诸多弊端,但对于以 get、find、query 等单词开头的查找函数来说,数据不存在,并非一种异常情况,这是一种正常行为。所以,返回代表不存在语义的 null 值比返回异常更加合理。

不过,刚刚说的这个也不是绝对的,还是要看项目中的其他类似的查找函数都是如何定义的,只要整个项目遵从统一的约定即可。

3、空对象

当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代 null 值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 null 值判断。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码// 使用空集合替代 null
public class UserService {
private UserRepo userRepo;

public List<User> getUsers(String telephonePrefix) {
// 没有查找到数据
return Collections.emptyList();
}
}
// getUsers 使用示例
List<User> users = userService.getUsers("1300605");
for (User user : users) { // 这里不需要做 null 值判断
// ...
}

4、异常对象

尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式就是抛出异常。异常可以携带更多的错误信息,比如函数调用栈信息。除此之外,异常可以将正常逻辑和异常逻辑的处理分离开来,这样代码的可读性就会更好。

Java 中的异常分为两大类:

  • 运行时异常(非受检异常)
  • 编译时异常(受检异常)

关于两种异常的说明这里不做过多的介绍,网上资料有很多。这里我们重点讨论下如何处理函数抛出的异常。

一般处理函数抛出的异常有 3 种方法:

  1. 直接吞掉
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public void func1() throws Exception1 {
// ...
}

public void func2() {
//...
 try {
func1();
} catch(Exception1 e) {
log.warn("...", e);
}
//...
}
  1. re-throw
1
2
3
4
5
6
7
8
9
10
java复制代码public void func1() throws Exception1 {
// ...
}


public void func2() throws Exception1 {
//...
func1();
//...
}
  1. 包装成新的异常 re-throw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public void func1() throws Exception1 {
// ...
}


public void func2() throws Exception2 {
//...
 try {
func1();
} catch(Exception1 e) {
throw new Exception2("...", e);
}
//...
}

当我们面对函数抛出异常的情况时,应该选择上面的哪种处理方式呢?结合网上的几种说法我总结了下面 3 个参考原则:

  • 如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,我们完全可以在 func2() 内将 func1() 抛出的异常吞掉。
  • 如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,我们可以选择直接将 func1 抛出的异常 re-throw。
  • 如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的新异常,然后 re-throw。

好了,介绍了这么多关于异常处理的方法,现在,我们来对之前代码中的异常处理部分进行重构。先看下获取主机名的这部分代码 :

generate():

1
2
3
4
5
6
7
java复制代码public String generate() {
String substrOfHostName = getLastFieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s", substrOfHostName, currentTimeMillis, randomString);
return id;
}

getLastFieldOfHostName():

1
2
3
4
5
6
7
8
9
10
java复制代码private String getLastFieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}

在 generate() 函数中,假设主机名获取失败,如果 substrOfHostName 返回 null,那 generate() 函数会返回类似“null-16723733647-83Ab3uK6”这样的数据;如果 substrOfHostName 返回空字符串,那 generate() 函数会返回类似“-16723733647-83Ab3uK6”这样的数据。这两种数据都不是我们期望返回给用户的数据,因此当主机名获取失败时,我们需要抛一个异常。

进到 getLastFieldOfHostName() 函数中看一下,在当前函数的实现中,如果主机名获取失败函数内部会捕获 UnknownHostException 异常,打印一条错误日志,同时返回 null 值。这里相当于是把 UnknownHostException 异常吞掉了。由于这里主机名获取失败会影响到后续逻辑的执行,因此这是一种异常行为,我们应该向上抛出异常而不是返回 null。

至于是直接将 UnknownHostException 抛出,还是重新封装成新的异常抛出,要看函数跟异常是否有业务相关性。getLastFieldOfHostName() 函数用来获取主机名的最后一个字段,UnknownHostException 异常表示主机名获取失败,两者算是业务相关,所以可以直接将 UnknownHostException 抛出,不需要重新包裹成新的异常。

重构后的 getLastFieldOfHostName() 函数代码如下所示:

1
2
3
4
5
6
java复制代码private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}

getLastFieldOfHostName() 函数修改之后,generate() 函数也要做相应的修改。这里我们选择捕获 getLastFieldOfHostName() 抛出的 UnknownHostException 异常,并重新包裹成新的异常 IdGenerationFailureException 往上抛出。之所以这么做原因有三:

  • 调用者在使用 generate() 函数的时候,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。也就说是,这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节。
  • 从代码封装的角度来讲,我们不希望将 UnknownHostException 这个比较底层的异常,暴露给更上层的代码,也就是调用 generate() 函数的代码。而且,调用者拿到这个异常的时候,并不能理解这个异常到底代表了什么,也不知道该如何处理。
  • UnknownHostException 异常跟 generate() 函数,在业务概念上没有相关性。

重构后的 generate() 函数代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s", substrOfHostName, currentTimeMillis, randomString);
return id;
}

对于 getLastSubstrSplittedByDot() 和 generateRandomAlphameric() 函数,我们也需要对非法入参进行检查,当入参不合法时抛出异常。

至此,我们就完成了对 ID 生成器代码的重构,重构后的代码如下所示,你可以拿来和之前的代码对比一下,看相对于之前的代码,现在的代码是否在可读性、可维护、健壮性上有所提升:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
java复制代码/**
* ID 生成器,用来生成随机的 ID。
*
* <p>
* 通过这个类生成的 ID 不是绝对唯一的,但是重复的概率非常小。
*/
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

/**
* 生成随机的 ID。
*
* @return 一个随机的 ID
*/
@Override
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("...", e);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}

/**
* 获得本地主机名的最后一个字段。主机名字段之间通过 '.' 进行分隔。
*
* @return 主机名的最后一个字段。当主机名获取失败时返回空。
*/
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) {
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}

/**
* 获得 {@hostname} 的最后一个字段,字段之间通过 '.' 进行分隔。
*
* @param hostName 不能为空
* @return {@hostname} 的最后一个字段。当 {@hostname} 为空时返回空字符串。
*/
private String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw new IllegalArgumentException("...");
}

String[] tokens = hostName.split("\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}

/**
* 生成随机的字符串,该字符串只包括数字、大写字母和小写字母。
*
* @param length 不能小于 0
* @return 随机的字符串。当 {@length} 为 0 时返回空字符串。
*/
private String generateRandomAlphameric(int length) {
if (length <= 0) {
throw new IllegalArgumentException("...");
}

char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}

七、总结

至此,本文对重构的介绍就告一段落了。我想说的是,在平时的工作中,由于业务迭代比较快,需要频繁地产出,很多人往往忽视了代码规范和代码质量,无休止地堆砌“烂”代码,等到系统 bug 频出,代码难以维护,开发效率降低的时候,我们想重构,却又无从下手。其实,我们在平时写代码的时候就应该培养一种持续重构的意识,再简单的代码,看上去再完美的代码,只要我们下功夫去推敲,总有可以优化的空间,就看你愿不愿把事情做到极致。作为一名程序员,起码对代码要有追求啊,不然跟咸鱼有啥区别呢?

八、参考

  1. 《重构:改善既有代码的设计》
  2. 《设计模式之美》
  3. 《代码整洁之道》
  4. 代码重构
  5. 重构介绍与原则

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%