规范与重构
# 规范与重构
来源:极客时间《设计模式之美》 (opens new window)专栏
# 1. 认识重构
重构的目的:为什么重构(why)?
对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场。
重构的对象:重构什么(what)?
按照重构的规模,可以将重构大致分为大规模高层次的重构和小规模低层次的重构。大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识。
重构的时机:什么时候重构(when)?
一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
重构的方法:如何重构(how)?
大规模高层次的重构难度比较大,需要组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要愿意并且有时间,随时随地都可以去做。
# 2. 重构的技术手段
重构有引入 bug 的风险。那如何保证重构不出错呢?除了熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。而最可落地执行、最有效的保证重构不出错的手段是单元测试(Unit Testing)
# 2.1 什么是单元测试?
单元测试有研发工程师编写,用来验证自己写的代码的正确性。单元测试常常与集成测试放到一起来对比。单元测试相对于集成测试(Integration Test)的测试粒度更小一些。集成测试用来测试整个系统或某个功能模块,比如用户登录、注册等。而单元测试测试的是一个类或一个函数,用来测试一个类或函数是否按照预期执行,这时代码层级的测试。
# 2.2 为什么要写单元测试?
单元测试能够有效地为重构保驾护航,也是保证代码质量的有效手段。
单元测试可以提前发现代码 bug
单元测试可以发现代码设计问题
代码的可测试性是评判代码质量的有效性手段,如果代码难以编写测试代码,那说明代码设计存在问题。比如:没有使用依赖注入、使用大量的静态方法、全局变量、代码高度耦合等。
单元测试是集成测试的有效补充
bug 往往出现在一下边界条件、异常情况。比如除数为判空、网络超时等。大部分情况无法在测试环境中模拟,而通过单元测试的 Mock 功能,可以 mock 出想要的情景,来测试在这些情景下代码的健壮性。
尽管单元测试无法替代集成测试,但如果通过单元测试使得底层的类和函数都能按照预期执行,bug 少。那组装起来的系统自然 bug 少。
写单元测试的过程就是重构的过程
编写单元测试相当与对自己代码的一次 review,可以及时发现问题,并针对问题进行重构。
阅读单元测试有助于属性代码
阅读代码的有效方式是先了解业务背景和设计思路,再去看代码就容易得多。实际中,程序员往往不喜欢写文档和注释。在这种情况下,单元测试就起到代替作用。单元测试用例实际上就是用户用例,反应代码功能如何使用,以及哪些情况需要考虑,哪些边界条件需要注意。
单元测试是 TDD 可落地执行的改进方案
单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受和落地执行,而且又兼顾了 TDD 的优点
# 2.3 如何编写单元测试?
写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码。
可以利用测试框架来简化代码的编写,如 Java 的 TestNG、Spring Test、Junit 等。
例如要测试下面函数 toNumber() 的正确性:
public class Text {
private String content;
public Text(String content) {
this.content = content;
}
/**
* 将字符串转化成数字,忽略字符串中的首尾空格;
* 如果字符串中包含除首尾空格之外的非数字字符,则返回null。
*/
public Integer toNumber() {
if (content == null || content.isEmpty()) {
return null;
}
//...省略代码实现...
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
为了保证测试的全面性,针对 toNumber() 函数,需要设计下面这样几个测试用例:
- 如果字符串只包含数字:“123”,toNumber() 函数输出对应的整数:123。
- 如果字符串是空或者 null,toNumber() 函数返回:null。
- 如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber() 返回对应的整数:123。
- 如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123;
- 如果字符串包含非数字字符:“123a4”,“123 4”,toNumber() 返回 null;
通过 Java 的 Junit 框架编写单元测试,如下:
import org.junit.Assert;
import org.junit.Test;
public class TextTest {
@Test
public void testToNumber() {
Text text = new Text("123");
Assert.assertEquals(new Integer(123), text.toNumber());
}
@Test
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());
Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}
@Test
public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}
@Test
public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}
@Test
public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());
Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}
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
关于如何编写单元测试,具体包括以下几点:
写单元测试是否耗时
尽管单元测试的代码量是被测试代码的 1~2 倍,但由于无需考虑代码设计等问题,逻辑十分简单。所以虽然繁琐但并不耗时。
对单元测试的代码质量要求
单元测试中各个测试用例直接一般相对独立,所以不要求编写高质量代码,适当地重复代码、命名不规范等,都是可以接受的。
单元测试覆盖率高低
单元测试的覆盖率是容易量化的标准,常常用来衡量代码好坏的标准。常用的工具有:JaCoCo、Cobertura、Emma、Clover等。覆盖率的计算方式有很多种,比较简单的是语句覆盖,稍微高级点的有:条件覆盖、判定覆盖、路径覆盖。但更重要的是测试用例是否覆盖所有可能的情况。
过度关注代码覆盖率,可能让程序员编写很多无效的单元测试,比如 Getter、Setter的测试用例。一般来说,一个项目的单元测试覆盖率在 60% ~ 70% 即可上线。
写单元测试无需了解代码实现逻辑
单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。因为一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败。
如何选择合适的测试框架
测试用例代码本身并不复杂,只要团队中统一使用一种测试框架即可。
# 2.4 如何在团队中推行单元测试?
一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;另一方面,国内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好。
# 2.4 练习题
写一个二分查找的变体算法,查找递增数组中第一个大于等于某个给定值的元素,并且为代码设计完备的单元测试用例。
TODO
# 3. 写出可测试性的代码
# 3.1 什么是代码的可测试性
所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
# 3.2 编写可测试性代码的有效手段
依赖注入是编写可测试性代码的最有效手段。通过依赖注入,在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是在编写单元测试的过程中最有技术挑战的地方。
# 3.3 常见的 Anti-Patterns(测试不友好)
常见的测试不友好的代码有下面这 5 种:
代码中包含未决行为逻辑
所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。
滥用可变全局变量
单元测试框架可能顺序执行或并发执行,对于在多个测试用例中都使用到的静态全局变量,在修改时可能与预期结果不一样,导致测试用例失败。
滥用静态方法
只有在静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,才需要在单元测试中 mock 这个静态方法。除此之外,如果只是类似 Math.abs() 这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要 mock。
使用复杂的继承关系
高度耦合的代码
一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。
# 3.5 案例
Transaction 是经过抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出。
public class Transaction {
private String id;
private Long buyerId;
private Long sellerId;
private Long productId;
private String orderId;
private Long createTimestamp;
private Double amount;
private STATUS status;
private String walletTransactionId;
// ...get() methods...
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
public boolean execute() throws InvalidTransactionException {
if ((buyerId == null || (sellerId == null || amount < 0.0) {
throw new InvalidTransactionException(...);
}
if (status == STATUS.EXECUTED) return true;
boolean isLocked = false;
try {
isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
if (!isLocked) {
return false; // 锁定未成功,返回false,job兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
long executionInvokedTimestamp = System.currentTimestamp();
if (executionInvokedTimestamp - createdTimestap > 14days) {
this.status = STATUS.EXPIRED;
return false;
}
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
return true;
} else {
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
}
}
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
逻辑重点在 execute() 方法,该方法测试用例如下:
- 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
- buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException。
- 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
- 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true。
- 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
- 交易正在执行着,不会被重复执行,函数直接返回 false。
重点来看其中的 1 和 3 的测试用例如何实现,测试用例1:
execute() 函数的执行依赖两个外部的服务,一个是 RedisDistributedLock,一个 WalletRpcService,需要使用 Mock 方式使得被测试代码与外部系统解依赖。
mock 的方式主要有两种,手动 mock 和利用框架 mock。
手动 Mock:
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return "123bac";
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return null;
}
}
2
3
4
5
6
7
8
9
10
11
WalletRpcService 类使用依赖注入方式,重构后的代码如下:
public class Transaction {
//...
// 添加一个成员变量及其set方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
// ...
public boolean execute() {
// ...
// 删除下面这一行代码
// WalletRpcService walletRpcService = new WalletRpcService();
// ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
重构之后的代码对应的单元测试如下所示:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
// 使用mock对象来替代真正的RPC服务
transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
12
由于 RedisDistributedLock 是一个单例类,相当于全局变量。无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。如果 RedisDistributedLock 是自己维护的可以像 WalletRpcService 一样进行重构,如果不是则无法修改代码。这时可以对 transaction 上锁这部分逻辑重新封装一下。具体代码实现如下所示:
public class TransactionLock {
public boolean lock(String id) {
return RedisDistributedLock.getSingletonIntance().lockTransction(id);
}
public void unlock() {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
public class Transaction {
//...
private TransactionLock lock;
public void setTransactionLock(TransactionLock lock) {
this.lock = lock;
}
public boolean execute() {
//...
try {
isLocked = lock.lock();
//...
} finally {
if (isLocked) {
lock.unlock();
}
}
//...
}
}
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
对应的测试用例代码如下:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
TransactionLock mockLock = new TransactionLock() {
public boolean lock(String id) {
return true;
}
public void unlock() {}
};
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
再来看测试用例 3:交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。针对这个单元测试用例代码如下:
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
如果在 Transaction 类中,并没有暴露修改 createdTimestamp 成员变量的 set 方法(也就是没有定义 setCreatedTimestamp() 函数),这是一类比较常见的问题,就是代码中包含跟“时间”有关的“未决行为”逻辑。一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可,具体的代码实现如下所示:
public class Transaction {
protected boolean isExpired() {
long executionInvokedTimestamp = System.currentTimestamp();
return executionInvokedTimestamp - createdTimestamp > 14days;
}
public boolean execute() throws InvalidTransactionException {
//...
if (isExpired()) {
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
针对重构后的代码,测试用例代码如下:
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
protected boolean isExpired() {
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
可以发现 Transaction 构造函数中并非只包含简单赋值操作。交易 id 的赋值逻辑稍微复杂。为了方便测试,可以把 id 赋值这部分逻辑单独抽象到一个函数中,具体的代码实现如下所示:
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//...
fillTransactionId(preAssignId);
//...
}
protected void fillTransactionId(String preAssignedId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 4. 如何解耦代码
# 4.1 “解耦”的重要性
过于复杂的代码往往在可读性、可维护性上都不友好。解耦保证代码松耦合、高内聚,是控制代码复杂度的有效手段。代码高内聚、松耦合,也就是意味着,代码结构清晰、分层模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。
# 4.2 代码是否需要“解耦”
间接的衡量标准有很多,比如,看修改代码是否牵一发而动全身。直接的衡量标准是把模块与模块、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
# 4.3 如何给代码“解耦”
封装与抽象
封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
中间层
引入中间层能简化模块或类之间的依赖关系。下面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存一级缓存、Redis 二级缓存、DB 持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。
除此之外,在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。可以分下面四个阶段来完成接口的修改:
- 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
- 第二阶段:新开发的代码依赖中间层提供的新接口。
- 第三阶段:将依赖老接口的代码改为调用新接口。
- 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。
模块化
模块化是构建复杂系统常用的手段。在开发代码的时候,一定要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。
模块化的思想无处不在,像 SOA、微服务、lib 库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之。
其他设计思想和原则
单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则、某些设计模式等。
# 5. 改善代码质量的 20 条编程规范
# 5.1 关于命名
命名的关键是能准确达意。对于不同作用域的命名,可以适当地选择不同的长度。作用域小的变量(比如临时变量),可以适当地选择短一些的命名方式。除此之外,命名中也可以使用一些耳熟能详的缩写,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。相反,对于类名这种作用域比较大的,更推荐用长的命名方式。实在想不到好名字的时候,可以去 GitHub 上用相关的关键词联想搜索一下,看看类似的代码是怎么命名的。
我们可以借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。
public class User { private String userName; private String userPassword; private String userAvatarUrl; //... } // 可简化为 public class User { private String name; private String password; private String avatarUrl; //... }
1
2
3
4
5
6
7
8
9
10
11
12
13public void uploadUserAvatarImageToAliyun(String userAvatarImageUri); //利用上下文简化为: public void uploadUserAvatarImageToAliyun(String imageUri);
1
2
3命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名。除此之外,命名要符合项目的统一规范,不要用些反直觉的命名。
在命名的时候最好能符合整个项目的命名习惯。大家都用“selectXXX”表示查询,就不要用“queryXXX”;大家都用“insertXXX”表示插入一条数据,就要不用“addXXX”,统一规约是很重要的,能减少很多不必要的麻烦。
接口有两种命名方式:一种是在接口中带前缀“I”;另一种是在接口的实现类中带后缀“Impl”。对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,一种是不带前缀。这两种命名方式都可以,关键是要在项目中统一。
# 5.2 关于注释
- 注释的目的就是让代码更容易看懂。只要符合这个要求的内容,就可以将它写到注释里。总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,可能还需要写明“如何用”。
- 注释本身有一定的维护成本,所以并非越多越好。类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。
# 5.3 关于代码风格
函数、类多大才合适?
函数的代码行数不要超过一屏幕的大小,比如 50 行。类的大小限制比较难确定。
一行代码多长最合适?
最好不要超过 IDE 显示的宽度。当然,限制也不能太小,太小会导致很多稍微长点的语句被折成两行,也会影响到代码的整洁,不利于阅读。
善用空行分割单元块
对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。在类内部,成员变量与函数之间、静态成员变量与普通成员变量之间、函数之间,甚至成员变量之间,都可以通过添加空行的方式,让不同模块的代码之间的界限更加明确。
四格缩进还是两格缩进?
比较推荐使用两格缩进,这样可以节省空间,特别是在代码嵌套层次比较深的情况下。除此之外,值得强调的是,不管是用两格缩进还是四格缩进,一定不要用 tab 键缩进。
大括号是否要另起一行?
比较推荐将大括号放到跟上一条语句同一行的风格,这样可以节省代码行数。但是,将大括号另起一行,也有它的优势,那就是,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更加一目了然。
类中成员的排列顺序
在 Google Java 编程规范中,依赖类按照字母序从小到大排列。类中先写成员变量后写函数。成员变量之间或函数之间,先写静态成员变量或函数,后写普通变量或函数,并且按照作用域大小依次排列。
# 5.4 关于编程技巧
将复杂的逻辑提炼拆分成函数和类
只有代码逻辑比较复杂的时候,我们其实才建议提炼类或者函数。否则反倒增加阅读成本。
// 重构前的代码 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; }
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避免函数参数过多
通过拆分成多个函数或将参数封装为对象的方式,来处理参数过多的情况。
函数包含 3、4 个参数的时候还是能接受的,大于等于 5 个的时候,会影响到代码的可读性,使用起来也不方便。
考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数
public User getUser(String username, String telephone, String email); // 拆分成多个函数 public User getUserByUsername(String username); public User getUserByTelephone(String telephone); public User getUserByEmail(String email);
1
2
3
4
5
6将函数的参数封装成对象
public void postBlog(String title, String summary, String keywords, String content, String category, long authorId); // 将参数封装成对象 public class Blog { private String title; private String summary; private String keywords; private Strint content; private String category; private long authorId; } public void postBlog(Blog blog);
1
2
3
4
5
6
7
8
9
10
11
12如果函数是对外暴露的远程接口,将参数封装成对象,还可以提高接口的兼容性。
勿用函数参数来控制逻辑
不要在函数中使用布尔类型的标识参数来控制内部逻辑,true 的时候走这块逻辑,false 的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。建议将其拆成两个函数,可读性上也要更好。
public void buyCourse(long userId, long courseId, boolean isVip); // 将其拆分成两个函数 public void buyCourse(long userId, long courseId); public void buyCourseForVip(long userId, long courseId);
1
2
3
4
5如果是 private 方法,考虑其影响范围只在当前类中,且拆分只有两个函数经常同时被调用,可以考虑保留标识不做拆分。
// 拆分成两个函数的调用方式 boolean isVip = false; //...省略其他逻辑... if (isVip) { buyCourseForVip(userId, courseId); } else { buyCourse(userId, courseId); } // 保留标识参数的调用方式更加简洁 boolean isVip = false; //...省略其他逻辑... buyCourse(userId, courseId, isVip);
1
2
3
4
5
6
7
8
9
10
11
12
13除了布尔类型作为标识参数来控制逻辑的情况外,还有一种“根据参数是否为 null”来控制逻辑的情况。针对这种情况,我们也应该将其拆分成多个函数。拆分之后的函数职责更明确,不容易用错。
public List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) { if (startDate != null && endDate != null) { // 查询两个时间区间的transactions } if (startDate != null && endDate == null) { // 查询startDate之后的所有transactions } if (startDate == null && endDate != null) { // 查询endDate之前的所有transactions } if (startDate == null && endDate == null) { // 查询所有的transactions } } // 拆分成多个public函数,更加清晰、易用 public List<Transaction> selectTransactionsBetween(Long userId, Date startDate, Date endDate) { return selectTransactions(userId, startDate, endDate); } public List<Transaction> selectTransactionsStartWith(Long userId, Date startDate) { return selectTransactions(userId, startDate, null); } public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) { return selectTransactions(userId, null, endDate); } public List<Transaction> selectAllTransactions(Long userId) { return selectTransactions(userId, null, null); } private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) { // ... }
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函数设计要职责单一
相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一。
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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23移除过深的嵌套层次
方法包括:去掉多余的 if 或 else 语句,使用 continue、break、return 关键字提前退出嵌套,调整执行顺序来减少嵌套,将部分嵌套逻辑抽象成函数。一般建议嵌套最好不超过两层。
去掉多余的 if 或 else 语句
// 示例一 public double caculateTotalAmount(List<Order> orders) { if (orders == null || orders.isEmpty()) { return 0.0; } else { // 此处的else可以去掉 double amount = 0.0; for (Order order : orders) { if (order != null) { amount += (order.getCount() * order.getPrice()); } } return amount; } } // 示例二 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语句可以合并在一起 if (str.contains(substr)) { matchedStrings.add(str); } } } } return matchedStrings; }
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使用 continue、break、return 关键字,提前退出嵌套
// 重构前的代码 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 && str.contains(substr)) { matchedStrings.add(str); // 此处还有10行代码... } } } return matchedStrings; } // 重构后的代码:使用continue提前退出 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 || !str.contains(substr)) { continue; } matchedStrings.add(str); // 此处还有10行代码... } } return matchedStrings; }
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 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) { if (str.contains(substr)) { matchedStrings.add(str); } } } return matchedStrings; }
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将部分嵌套封装成函数调用
// 重构前的代码 public List<String> appendSalts(List<String> passwords) { if (passwords == null || passwords.isEmpty()) { return Collections.emptyList(); } List<String> passwordsWithSalt = new ArrayList<>(); for (String password : passwords) { if (password == null) { continue; } if (password.length() < 8) { // ... } else { // ... } } return passwordsWithSalt; } // 重构后的代码:将部分逻辑抽成函数 public List<String> appendSalts(List<String> passwords) { if (passwords == null || passwords.isEmpty()) { return Collections.emptyList(); } List<String> passwordsWithSalt = new ArrayList<>(); for (String password : passwords) { if (password == null) { continue; } passwordsWithSalt.add(appendSalt(password)); } return passwordsWithSalt; } private String appendSalt(String password) { String passwordWithSalt = password; if (password.length() < 8) { // ... } else { // ... } return passwordWithSalt; }
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
用字面常量取代魔法数
常量取代魔法数字
public double CalculateCircularArea(double radius) { return (3.1415) * radius * radius; } // 常量替代魔法数字 public static final Double PI = 3.1415; public double CalculateCircularArea(double radius) { return PI * radius * radius; }
1
2
3
4
5
6
7
8
9使用解释性变量来解释复杂表达式
if (date.after(SUMMER_START) && date.before(SUMMER_END)) { // ... } else { // ... } // 引入解释性变量后逻辑更加清晰 boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END); if (isSummer) { // ... } else { // ... }
1
2
3
4
5
6
7
8
9
10
11
12
13
# 5.5 关于编码规范
项目、团队,甚至公司,一定要制定统一的编码规范,并且通过 Code Review 督促执行,这对提高代码质量有立竿见影的效果。
# 6. 实战一:实现 ID 生成器
# 6.1 需求背景
“ID”中文翻译为“标识(Identifier)”。在后端业务系统的开发中,为方便在请求出错时排查问题,会在关键路径上打印日志。为了排查问题,通常需要结合上下文的日志,就需要一个唯一标识码来标记该请求的所有相关日志,方便排查时搜索。
借鉴微服务调用链追踪的实现思路,可以给每个请求分配一个唯一 ID,并且保存在请求的上下文(Context)中,比如,处理请求的工作线程的局部变量中。在 Java 语言中,我们可以将 ID 存储在 Servlet 线程的 ThreadLocal 中,或者利用 Slf4j 日志框架的 MDC(Mapped Diagnostic Contexts)来实现(实际上底层原理也是基于线程的 ThreadLocal)。每次打印日志的时候,从请求上下文中取出请求 ID,跟日志一块输出。这样,同一个请求的所有日志都包含同样的请求 ID 信息,就可以通过请求 ID 来搜索同一个请求的所有日志了。
# 6.2 一份“能用”的代码
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;
}
}
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
上面的代码生成的 ID 示例如下所示。整个 ID 由三部分组成。第一部分是本机名的最后一个字段。第二部分是当前时间戳,精确到毫秒。第三部分是 8 位的随机字符串,包含大小写字母和数字。尽管这样生成的 ID 并不是绝对唯一的,有重复的可能,但事实上重复的概率非常低。对于日志追踪来说,极小概率的 ID 重复是完全可以接受的。
# 6.3 如何发现代码质量问题
业务无关:判断代码质量评判标准,可从下面几方面进行:
- 目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?
- 是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD 等)?
- 设计模式是否应用得当?是否有过度设计?
- 代码是否容易扩展?如果要添加新功能,是否容易实现?
- 代码是否可以复用?是否可以复用已有的项目代码或类库?是否有重复造轮子?
- 代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况?
- 代码是否易读?是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等)?
业务相关:检查代码实现是否满足业务本身特有的功能和非功能需求:
- 代码是否实现了预期的业务需求?
- 逻辑是否正确?是否处理了各种异常情况?
- 日志打印是否得当?是否方便 debug 排查问题?
- 接口是否易用?是否支持幂等、事务等?
- 代码是否存在并发问题?是否线程安全?
- 性能是否有优化空间,比如,SQL、算法是否可以优化?
- 是否有安全漏洞?比如输入输出校验是否全面?
对照上面检查项,发现代码问题。
业务无关:
IdGenerator 的代码比较简单,只有一个类,不涉及目录设置、模块划分、代码结构问题,也不违反基本的 SOLID、DRY、KISS 等设计原则。它没有应用设计模式,所以也不存在不合理使用和过度设计的问题。
IdGenerator 设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。如果项目中需要同时存在两种 ID 生成算法,此时就需要基于接口来实现。
IdGenerator 的 generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性。同时,generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以 generate() 函数本身的可测试性也不好。
IdGenerator 只包含一个函数,并且代码行数也不多,但代码的可读性并不好。特别是随机字符串生成的那部分代码,一方面,代码完全没有注释,生成算法比较难读懂,另一方面,代码里有很多魔法数,严重影响代码的可读性。
业务有关:
- 代码生成的 ID 并非绝对的唯一,但是对于追踪打印日志来说,是可以接受小概率 ID 冲突的,满足预期的业务需求。不过,获取 hostName 这部分代码逻辑并未处理“hostName 为空”的情况。除此之外,尽管代码中针对获取不到本机名的情况做了异常处理,但是对异常的处理是在 IdGenerator 内部将其吞掉,然后打印一条报警日志,并没有继续往上抛出。这样的异常处理是否得当呢?
- 日志打印得当,日志描述能够准确反应问题,方便 debug,并且没有过多的冗余日志。IdGenerator 只暴露一个 generate() 接口供使用者使用,接口的定义简单明了,不存在不易用问题。generate() 函数代码中没有涉及共享变量,所以代码线程安全,多线程环境下调用 generate() 函数不存在并发问题。
- 性能方面,ID 的生成不依赖外部存储,在内存中生成,并且日志的打印频率也不会很高,所以代码在性能方面足以应对目前的应用场景。不过,每次生成 ID 都需要获取本机名,获取主机名会比较耗时,需优化。还有,randomAscii 的范围是 0~122,但可用数字仅包含三段子区间(0~9,a~z,A~Z),极端情况下会随机生成很多三段区间之外的无效数字,需要循环很多次才能生成随机字符串,也需优化。
- 在 generate() 函数的 while 循环里面,三个 if 语句内部的代码非常相似,而且实现稍微有点过于复杂了,实际上可以进一步简化,将这三个 if 合并在一起。
# 6.4 重构 ID 生成器代码
重构代码的过程应遵循“循序渐进,小步快跑”思路。每次改动一点点,改好之后,再进行下一轮的优化,保证每次对代码的改动不会过大,能在很短的时间内完成。将 ID 生成器代码的重构分为:
- 第一轮重构:提高代码的可读性
- 第二轮重构:提高代码的可测试性
- 第三轮重构:编写完善的单元测试
- 第四轮重构:所有重构完成之后添加注释
# 6.4.1 第一轮重构:提高代码的可读性
首先要解决最明显、最急需改进的代码可读性问题:
- hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候;
- 将获取 hostName 的代码抽离出来,定义为 getLastfieldOfHostName() 函数;
- 删除代码中的魔法数,比如,57、90、97、122;
- 将随机数生成的代码抽离出来,定义为 generateRandomAlphameric() 函数;
- generate() 函数中的三个 if 逻辑重复了,且实现过于复杂,我们要对其进行简化;
- 对 IdGenerator 类重命名,并且抽象出对应的接口。
对于 ID 生成器的代码,有下面三种类的命名方式:
命名方式一:IdGenerator、LogTraceIdGenerator
从使用和扩展的角度来分析该命名不合理。
如果扩展新的日志 ID 生成算法,也就是要创建另一个新的实现类,因为原来的实现类已经叫 LogTraceIdGenerator 了,命名过于通用,那新的实现类无法取一个跟 LogTraceIdGenerator 平行的名字。
命名方式二:LogTraceIdGenerator、HostNameMillisIdGenerator
LogTraceIdGenerator 接口的命名是合理的,但是 HostNameMillisIdGenerator 实现类暴露了太多实现细节,只要代码稍微有所改动,就可能需要改动命名,才能匹配实现。
命名方式三:LogTraceIdGenerator、RandomIdGenerator
目前生成的 ID 是一个随机 ID,不是递增有序的,所以命名成 RandomIdGenerator 是比较合理的,即便内部生成算法有所改动,只要生成的还是随机的 ID,就不需要改动命名。如果需要扩展新的 ID 生成算法,比如要实现一个递增有序的 ID 生成算法,可以命名为 SequenceIdGenerator。
更好的一种命名方式是,抽象出两个接口,一个是 IdGenerator,一个是 LogTraceIdGenerator,LogTraceIdGenerator 继承 IdGenerator。实现类实现接口 LogTraceIdGenerator,命名为 RandomIdGenerator、SequenceIdGenerator 等。这样,实现类可以复用到多个业务模块中,比如前面提到的用户、订单。
第一轮重构后的代码如下:
public interface IdGenerator {
String generate();
}
public interface LogTraceIdGenerator extends IdGenerator {
}
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();
String[] tokens = hostName.split("\\.");
substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
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);
}
}
//代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();
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
# 6.4.2 第二轮重构:提高代码的可测试性
主要包含下面两个方面:
generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性;
已经在第一轮重构中解决了。我们将 RandomIdGenerator 类中的 generate() 静态函数重新定义成了普通函数。调用者可以通过依赖注入的方式,在外部创建好 RandomIdGenerator 对象后注入到自己的代码中,从而解决静态函数调用影响代码可测试性的问题。
generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以 generate() 函数本身的可测试性也不好。
主要包括以下几个代码改动:
- 从 getLastfieldOfHostName() 函数中,将逻辑比较复杂的那部分代码剥离出来,定义为 getLastSubstrSplittedByDot() 函数。因为 getLastfieldOfHostName() 函数依赖本地主机名,所以,剥离出主要代码之后这个函数变得非常简单,可以不用测试。重点测试 getLastSubstrSplittedByDot() 函数即可。
- 将 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 这两个函数的访问权限设置为 protected。这样可以直接在单元测试中通过对象来调用两个函数进行测试。
- 给 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 两个函数添加 Google Guava 的 annotation @VisibleForTesting。这个 annotation 没有任何实际的作用,只起到标识的作用,告诉其他人说,这两个函数本该是 private 访问权限的,之所以提升访问权限到 protected,只是为了测试,只能用于单元测试中。
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; } @VisibleForTesting protected String getLastSubstrSplittedByDot(String hostName) { String[] tokens = hostName.split("\\."); String substrOfHostName = tokens[tokens.length - 1]; return substrOfHostName; } @VisibleForTesting protected 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); } }
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
# 6.4.3 第三轮重构:编写完善的单元测试
使用 JUnit 测试框架,由代码可见 getLastSubstrSplittedByDot 和 generateRandomAlphameric 代码逻辑负责,应重点测试:
public class RandomIdGeneratorTest {
@Test
public void testGetLastSubstrSplittedByDot() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1.field2.field3");
Assert.assertEquals("field3", actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1");
Assert.assertEquals("field1", actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1#field2#field3");
Assert.assertEquals("field1#field2#field3", actualSubstr);
}
// 此单元测试会失败,因为我们在代码中没有处理hostName为null或空字符串的情况
// 这部分优化留在第36、37节课中讲解
@Test
public void testGetLastSubstrSplittedByDot_nullOrEmpty() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualSubstr = idGenerator.getLastSubstrSplittedByDot(null);
Assert.assertNull(actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot("");
Assert.assertEquals("", actualSubstr);
}
@Test
public void testGenerateRandomAlphameric() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualRandomString = idGenerator.generateRandomAlphameric(6);
Assert.assertNotNull(actualRandomString);
Assert.assertEquals(6, actualRandomString.length());
for (char c : actualRandomString.toCharArray()) {
Assert.assertTrue(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'));
}
}
// 此单元测试会失败,因为我们在代码中没有处理length<=0的情况
// 这部分优化留在第36、37节课中讲解
@Test
public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualRandomString = idGenerator.generateRandomAlphameric(0);
Assert.assertEquals("", actualRandomString);
actualRandomString = idGenerator.generateRandomAlphameric(-1);
Assert.assertNull(actualRandomString);
}
}
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
而 generate() 函数是直接暴露给外部使用,所以最好也要编写测试用例。写单元测试的时候,测试对象是函数定义的功能,而非具体的实现逻辑。这样才能做到,函数的实现逻辑改变了之后,单元测试用例仍然可以工作。
针对 generate() 函数,可以有三种不同的功能定义,对应三种不同的测试用例:
- 如果把 generate() 函数的功能定义为:“生成一个随机唯一 ID”,那只要测试多次调用 generate() 函数生成的 ID 是否唯一即可。
- 如果把 generate() 函数的功能定义为:“生成一个只包含数字、大小写字母和中划线的唯一 ID”,那不仅要测试 ID 的唯一性,还要测试生成的 ID 是否只包含数字、大小写字母和中划线。
- 如果把 generate() 函数的功能定义为:“生成唯一 ID,格式为:{主机名 substr}-{时间戳}-{8 位随机数}。在主机名获取失败时,返回:null-{时间戳}-{8 位随机数}”,那不仅要测试 ID 的唯一性,还要测试生成的 ID 是否完全符合格式要求。
针对 getLastfieldOfHostName() 函数,由于其调用了静态函数,故难以测试。而这个函数的实现非常简单,肉眼基本上可以排除明显的 bug,不用编写单元测试代码。
# 6.4. 4 第四轮重构:添加注释
主要就是写清楚:做什么、为什么、怎么做、怎么用,对一些边界条件、特殊情况进行说明,以及对函数输入、输出、异常进行说明。
/**
* Id Generator that is used to generate random IDs.
*
* <p>
* The IDs generated by this class are not absolutely unique,
* but the probability of duplication is very low.
*/
public class RandomIdGenerator implements LogTraceIdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
/**
* Generate the random ID. The IDs may be duplicated only in extreme situation.
*
* @return an random ID
*/
@Override
public String generate() {
//...
}
/**
* Get the local hostname and
* extract the last field of the name string splitted by delimiter '.'.
*
* @return the last field of hostname. Returns null if hostname is not obtained.
*/
private String getLastfieldOfHostName() {
//...
}
/**
* Get the last field of {@hostName} splitted by delemiter '.'.
*
* @param hostName should not be null
* @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
*/
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
//...
}
/**
* Generate random string which
* only contains digits, uppercase letters and lowercase letters.
*
* @param length should not be less than 0
* @return the random string. Returns empty string if {@length} is 0
*/
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
//...
}
}
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
# 6.5 ID 生成器代码异常处理
针对 RandomIdGenerator 的四个函数的出错处理方式,存在下面几个问题:
- 对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
- 对于 getLastFiledOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志)?还是应该将异常继续往上抛出?如果往上抛出的话,是直接把 UnknownHostException 异常原封不动地抛出,还是封装成新的异常抛出?
- 对于 getLastSubstrSplittedByDot(String hostName) 函数,如果 hostName 为 NULL 或者是空字符串,这个函数应该返回什么?
- 对于 generateRandomAlphameric(int length) 函数,如果 length 小于 0 或者等于 0,这个函数应该返回什么?
# 6.5.1 重构 generate()
对于第1点倾向于明确地将异常告知调用者,所以抛出受检异常,而非特殊值。故对 generate() 函数进行重构如下:
public String generate() throws IdGenerationFailureException {
String substrOfHostName = getLastFieldOfHostName();
if (substrOfHostName == null || substrOfHostName.isEmpty()) {
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;
}
2
3
4
5
6
7
8
9
10
11
# 6.5.2 重构 getLastFieldOfHostName()
对于第2点,获取主机名失败会影响后续逻辑的处理,所以它是一种异常行为。这里最好是抛出异常,而非返回 NULL 值。重构后的代码:
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
2
3
4
5
6
当 getLastFieldOfHostName() 重构后可能抛出异常,相应的需要修改 generate() 方法,在 generate() 函数中需要捕获 UnknownHostException 异常,并重新包裹成新的异常 IdGenerationFailureException 往上抛出。原因如下:
- 调用者在使用 generate() 函数的时候,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。也就说是,这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节。
- 从代码封装的角度来讲,不希望将 UnknownHostException 这个比较底层的异常,暴露给更上层的代码,也就是调用 generate() 函数的代码。而且,调用者拿到这个异常的时候,并不能理解这个异常到底代表了什么,也不知道该如何处理。
- UnknownHostException 异常跟 generate() 函数,在业务概念上没有相关性。
generate() 重构后如下:
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
# 6.5.3 重构 getLastSubstrSplittedByDot()
对于第 3 点,如果 hostName 为 NULL 或者空字符串,函数应该如何返回。
如果函数是 private 类私有的,只在类内部被调用,完全在自己的掌控之下,自己保证在调用这个 private 函数的时候,不要传递 NULL 值或空字符串就可以了。所以可以不在 private 函数中做 NULL 值或空字符串的判断。如果函数是 public 的,无法掌控会被谁调用以及如何调用(有可能某个同事一时疏忽,传递进了 NULL 值,这种情况也是存在的),为了尽可能提高代码的健壮性,最好是在 public 函数中做 NULL 值或空字符串的判断。
所以,这里最好也加上 NULL 值或空字符串的判断逻辑。虽然加上有些冗余,但多加些检验总归不会错的。重构后如下:
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw IllegalArgumentException("..."); //运行时异常
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
2
3
4
5
6
7
8
9
调用者也需要保证不传递 NULL 或 空字符串,所以 getLastFieldOfHostName() 也需要做相应的改变:
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;
}
2
3
4
5
6
7
8
9
# 6.5.4 重构 generateRandomAlphameric()
对于第 4 点,当 length < 0 时,认为其是一种异常行为,抛出 IllegalArgumentException 异常。当 length = 0 时,如果认为是合理参数值,则返回空字符串,如果认为不合理就抛出 IllegalArgumentException 异常。
# 6.5.4 最终版本代码
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@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;
}
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;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw new IllegalArgumentException("...");
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected 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);
}
}
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
# 7. 程序出错应该返回什么
关于函数出错返回数据类型,我总结了 4 种情况,它们分别是:错误码、NULL 值、空对象、异常对象。
# 7.1 返回错误码
C 语言没有异常这样的语法机制,返回错误码便是最常用的出错处理方式。而 Java、Python 等比较新的编程语言中,大部分情况下,我们都用异常来处理函数出错的情况,极少会用到错误码。
# 7.2 返回 NULL
网上一般不建议返回 NULL,认为这是不好的设计思路,主要原因如下:
- 如果某个函数有可能返回 NULL 值,在使用它的时忘做 NULL 值判断,可能会抛出空指针异常(Null Pointer Exception,缩写为 NPE)。
- 如果定义了很多返回值可能为 NULL 的函数,那代码中就会充斥着大量的 NULL 值判断逻辑,一方面写起来比较繁琐,另一方面跟正常的业务逻辑耦合在一起,会影响代码的可读性。
尽管返回 NULL 值有诸多弊端,但对于以 get、find、select、search、query 等单词开头的查找函数来说,数据不存在,并非一种异常情况,这是一种正常行为,可以返回代表不存在语义的 NULL。而实际可以根据项目代码约定进行选择是返回 NULL 还是抛出异常。(Java 中的 indexOf() 函数,不存在时返回 -1 特殊值)
# 7.3 返回空对象
当函数返回的数据是字符串类型或者集合类型的时候,可以用空字符串或空集合替代 NULL 值,来表示不存在的情况。
// 使用空集合替代NULL
public class UserService {
private UserRepo userRepo; // 依赖注入
public List<User> getUsers(String telephonePrefix) {
// 没有查找到数据
return Collections.emptyList();
}
}
// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) { //这里不需要做NULL值判断
// ...
}
// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
// 如果text中没有大写字母,返回空字符串,而非NULL值
return "";
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters = retrieveUppercaseLetters("wangzheng");
int length = uppercaseLetters.length();// 不需要做NULL值判断
System.out.println("Contains " + length + " upper case letters.");
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 7.4 抛出异常
Java 的异常包括运行时异常也叫作非受检异常(Unchecked Exception)和编译时异常也叫作受检异常(Checked Exception)。
对于运行时异常,可以不用主动去 try-catch,编译器在编译代码的时候,并不会检查代码是否有对运行时异常做了处理。对于编译时异常,需要主动去 try-catch 或者在函数定义中声明,否则编译就会报错。
对于代码 bug(比如数组越界)以及不可恢复异常(比如数据库连接失败),即便捕获也做不了太多事情,所以倾向于使用非受检异常。对于可恢复异常、业务异常,比如提现金额大于余额的异常,更倾向于使用受检异常,明确告知调用者需要捕获处理。
Java 支持的受检异常一直被人诟病,主张所有的异常情况都应该使用非受检异常。理由主要有以下三个:
- 受检异常需要显式地在函数定义中声明。如果函数会抛出很多受检异常,那函数的定义就会非常冗长,这就会影响代码的可读性,使用起来也不方便。
- 编译器强制必须显示地捕获所有的受检异常,代码实现会比较繁琐。而非受检异常正好相反,不需要在定义中显示声明,并且是否需要捕获处理,也可以自由决定。
- 受检异常的使用违反开闭原则。如果给某个函数新增一个受检异常,这个函数所在的函数调用链上的所有位于其之上的函数都需要做相应的代码修改,直到调用链中的某个函数将这个新增的异常 try-catch 处理掉为止。而新增非受检异常可以不改动调用链上的代码。可以灵活地选择在某个函数中集中处理,比如在 Spring 中的 AOP 切面中集中处理异常。
非受检异常使用起来更加灵活,怎么处理的主动权这里就交给了程序员。但过于灵活会带来不可控,非受检异常不需要显式地在函数定义中声明,那在使用函数的时候,就需要查看代码才能知道具体会抛出哪些异常。非受检异常不需要强制捕获处理,那程序员就有可能漏掉一些本应该捕获处理的异常。
总之,在实际开发中根据项目约定用统一的方式来处理异常即可。
当 func2() 调用了 func1(),处理异常有三种方式:
直接吞掉
如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,完全可以在 func2() 内将 func1() 抛出的异常吞掉;
原封不动地 re-throw
如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,可以选择直接将 func1 抛出的异常 re-throw;
包装成新的异常 re-throw
如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,可以将它重新包装成调用方可以理解的新异常,然后 re-throw。