重构
为什么要重构?
- 重构代码是保持代码质量的一个极其有效的手段,它可以避免你的代码腐化到无可救药的地步。
- 重构可以应对不断增加的需求,可以适应不断迭代的系统。
- 有了重构的意识,可以避免你在前期设计代码的时候进行过度设计。
重构的对象
- 重构分为大型重构和小型重构。
- 大型重构是对顶层代码的重构,它包括对系统、模块、代码接口、类之间的关系等的重构。在大型重构中,需要用到设计原则和设计模式。
- 小型重构是对代码细节的重构,集中在类、函数、变量等级别。对于细节的重构,主要是规范代码细节,消除大型函数,提取重复代码等。
重构的时机
- 时时刻刻都应当有重构的思想。持续重构提供了一条可持续、可演进的方式。
如何重构
- 小型重构因为影响范围小,所以可以随时进行。
- 大型重构的规模很大,很难在某段时间内完成,所以面对大型重构时,要分阶段进行。在某一段时间内,重构一小部分代码,保证代码仓库中的代码一直处于可运行,逻辑正确的状态。
重构的过程中,要考虑好如何兼容老代码的逻辑。在必要的时候,你还可以写一些过渡性的代码。 -
对于如何检查自己的代码在哪些方面需要重构,可以参考下面两个情况:
常规检查事项
测试(单元测试)
概念
- 单元测试是对函数、类一级别的代码进行的测试。
- 单元测试不需要高深技术,更多的是考验程序员的思维缜密的程度。
- 单元测试可以提高你的编码能力,让你写出 bug free 的代码。
- 单元测试可以侧面考量你的代码设计,如果一段代码难以进行单元测试,那这段代码的设计可能是有问题的。
- 单元测试需要考虑输入、异常、边界条件。
具体
- 依赖注入是编写可测试性代码的最有效的手段。
- 在测试中,我们会需要依赖外部服务的调用。
1.这种调用可能会对测试造成影响,比如性能问题或者你并不知道这个外部服务会如何返回数据。
2.你不是这部分代码的维护者,没有权限修改这部分代码。
3.这种时候,你需要使用 mock + 依赖注入 的方式修改代码。对于任何有外部依赖服务的地方,都设置一个 set 方法和本地变量。这样我们只需要注入这部分代码即可。 - 代码中经常有使用当前时间判断日期的这一类未决行为,代码逻辑要求我们对这部分代码保持封装,所以修改系统时间间接完成测试不是一个好方法。我们可以使用 isInTime() 这种方式,将判断时间是否䄦的代码封装进去,测试的时候重写这部分代码即可。
- 有一些常见的,不好测试的代码,总结如下:
1.未决行为:这种代码的输出是随机或者不确定的。我们可以使用 isxxx 的方式封装输出并在测试中重写。
2.全局变量:一段代码如果使用了全局变量,多次连续的测试会让变量不可控,尤其是测试框架异步测试的时候。我们可以在每次测试完成后重置变量部分解决这个问题。
3.静态方法:和全局变量的逻辑类似,但是如果这个方法非常简单,就并不需要 mock 这个方法。
4.复杂继承:多层继承会导致需要 mock 的类变多,使用组合替代继承可以解决这个问题。
5.高度耦合:没啥说的,解耦吧。
解耦
解耦的重要性
- 解耦可以控制代码不至于复杂到无法控制的手段。
- 降低耦合,可以让我们不必了解太多其他模块的代码,降低我们的心智负担。
- 高内聚、低耦合意味着代码结构清晰、分层和模块化合理、依赖关系简单等。这样的代码的整体质量就不会差。
- 是否需要解耦:
1.代码是否牵一发动全身。
2.画出模块与模块、类与类之间的依赖关系。
如何解耦
使用封装与抽象:封装和抽象可以隐藏实现的复杂性,隔离实现的易变性。给依赖的模块提供稳定且易用的接口,是简化依赖关系的有效手段。
中间层:引入中间层可以简化模块或类之间的依赖关系,如下图:
在引入中间层时,如何避免开发对现有业务的影响,你需要考虑重构的步骤:
- 引入中间层,包含老接口,提供新接口
- 新代码依赖新接口
- 将依赖老接口的代码修改为调用新接口
- 修改工作完成后,删掉老接口
模块化:将每个模块都当做一个独立的 lib 去开发,只提供接口给外部使用,而不向其他模块暴露自身的细节。模块化的本质思想是分而治之。
其他设计原则:
- 单一职责:实现高内聚的原则
- 基于接口编程:封装稳定接口,隔离不稳定的实现
- 依赖注入:依赖注入无法解耦,但是可以做到插拔替换
- 多用组合:继承是一种强依赖关系,父类和子类高度耦合
- 迪米特法则:不该有依赖的类不要有依赖,有依赖的类只依赖不要的接口
编程规范
- 注释:包含三个方面:做什么、为什么、怎么做
- 如果一个函数的代码过多,将这个函数拆分成多个函数,来简化理解
- 不要使用参数来控制逻辑:依据参数控制程序的内部逻辑,明显违背了单一职责原则和接口隔离原则:
public void buyCourse(long userId, long courseId, boolean isVip);
// 将其拆分成两个函数
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);
如果两段代码经常同时被调用,你也可以考虑将他们放在一起“
// 拆分成两个函数的调用方式
boolean isVip = false;
//...省略其他逻辑...
if (isVip) {
buyCourseForVip(userId, courseId);
} else {
buyCourse(userId, courseId);
}
// 保留标识参数的调用方式更加简洁
boolean isVip = false;
//...省略其他逻辑...
buyCourse(userId, courseId, isVip);
还有一种情况,根据参数是否为 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) {
// ...
}
- 函数的职责要单一:函数的代码更少,功能能多单一就多单一,例如下面的示例:
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);
异常
函数运行的结果可以分成两类:一类是预期的结果,一类是非预期的异常。对异常的处理,常常影响代码的健壮。
异常处理的返回
- 返回错误码:C语言中没有异常处理机制,它对异常的处理就是返回一个错误码。一般来说,现在大多数的语言都会有异常处理机制,所以返回错误码的应用场景比较少。
- 返回NULL:即返回一个“不存在”的定义,但是NULL会造成空指针异常的情况,所以不建议使用。
- 返回空对象:个人认为是返回NULL的升级版。实际上,空对象是一种设计模式,在GO中大量应用了这种设计模式,在我看来,这也是一种很好用的设计。
对于返回空对象来说,你可以注意一下:
1.对于 search 等单词开头,执行查找的函数来说,数据不存在可能并不是一种异常情况,返回空对象不失为一个好选择。
2.如果上游使用迭代或者 len 判断,返回空对象是非常合适的。 - 抛出异常:这是最常用的函数错误处理方式,因为异常可以携带更多的错误信息(例如调用栈信息)。除此之外,异常还可以将正常和异常的逻辑分开,提高代码的可读性。但是,异常也会带来一些问题,过多的异常会让代码变得冗长。
如何处理异常
如果是查询数据出现问题,并且上游代码会对结果使用迭代处理,使用空对象处理会比较好。
如果程序其他方面出现问题,使用异常机制来处理问题比价好。对于异常机制,有三种处理方式:
- 直接吞掉:如果上游代码对下游的异常并不关心,且上游可以恢复这种异常。上游可以直接吞掉。
- 对下游代码的异常直接向上抛出:如果上游代码的上游需要理解下游的异常,(或者说业务概念上有一定的相关性)可以直接抛出。
- 包装下游抛出的异常,再抛出:如果下游的异常过于底层,上游的上游缺乏背景去理解,上游代码就可以包装这个异常。
总之,一个异常是否向上继续抛出,要看上层代码是否关心这个异常。关心就将它抛出,不关心就吞掉。如果上层代码需要理解这个异常,业务相关,就直接抛出;否则就包装后再抛出。
网友评论