0%

重构

为什么要重构

项目在不断演进过程中,代码不停在堆砌。如果没有人为代码质量负责,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开放一套新代码的成本,想要再去重构,已经没有人能做到了。

造成这样的原因往往有以下几点:

  1. 代码之前缺乏有效设计
  2. 成本上的考虑,在原功能堆砌式编程
  3. 缺乏有效代码质量监督机制

对于此类问题,业界已有很好的解决思路:通过持续不断的重构将代码中的坏味道清除掉。

什么是重构

重构一书中的作则Martin Fowler对重构的定义:

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。重构(动词):使用一系列重构手法,在不改变软件观察行为的前提下,调整其结构。

根据重构的规模可以大致分为大型重构和小型重构:

大型重构:对顶层代码设计的重构,包括系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学校过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入bug的风险也会相对比较大。

小型重构:对代码细节的重构,主要针对类、函数、变量等代码级别的重构,比如规范命名和注释、消除超大类或函数、提取重复代码等等。小型重构更多的是使用统一的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入bug的风险相对来说也会比较小。

代码的坏味道

代码重复

  • 实现逻辑相同、执行流程相同

方法过长

  • 方法中的语句不在同一个抽象层级
  • 逻辑难以理解,需要大量的注释
  • 面向过程编程而非面向对象

过大的类

  • 类做了太多事情
  • 包含过多的实例变量和方法
  • 类的命名不足以描述所做的事情

逻辑分散

  • 发散式变化:某个类经常因为不同的原因在不通过的方向上发生变化
  • 散弹式修改你:发生某种变化是,需要在多个类中做修改

严重的情节依恋

  • 某个类的方法过多的使用其他类的成员

数据泥团/基本类型偏执

  • 两个类、方法签名中包含相同的字段或参数
  • 应该使用类但使用基本类型,比如表示数值与币种的Money类、起始值与结束值的Range类

不合理的继承体系

  • 继承打破了封装性,子类依赖父类中特定功能的实现细节
  • 子类必须跟着其父类的更新而演变,除非父类是专门为了扩展而设计,并且有很好的文档说明

过多的条件判断

过长的参数列

临时变量过多

令人迷惑的暂时字段

  • 某个实例变量仅为某种特定情况而设置
  • 将实例变量与对应的方法提取到新的类中

纯数据类

  • 仅包含字段和访问(读写)这些字段的方法
  • 此类被称为数据容器,应保持最小可变性

不恰当的命名

  • 命名无法准确描述做的事情
  • 命名不符合约定俗成的惯例

过多的注释

坏代码的问题

  • 难以复用
  • 系统关联性过多,导致很难分离可重用部分
  • 难于变化
  • 一处变化导致气筒很多部分的修改,不利于系统稳定
  • 难以理解
  • 命名杂乱,结构混乱,难以阅读和理解
  • 难以测试
  • 分支、依赖较多,难以覆盖全面

什么是好代码

代码质量的评价有很强的主观性,描述代码质量的词汇也有很多,比如可读性、可维护性、灵活、优雅、简洁。这些词汇是从不同的维度去评价代码质量的。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准。

要写出高质量代码,我们就需要掌握一些更加细化、更加能落地的编程方法论,这就包含面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等。

如何重构

SOLID原则

在程序设计领域,SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)指代了面向对象编程和面向对象设计的五个基本原则。当这些原则被一起应用时,它们使得一个程序员开发一个容易进行软件维护和扩展的系统变得更加可能。

单一职责原则

一个类只负责完成一个职责或者功能,不要存在多于一种导致类变更的原因。

单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚,松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会减低内聚性,也会影响代码的可维护性。

开放-关闭原则

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

开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。

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

里氏替换原则

子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

子类可以扩展父类的功能,但不能改父类原有的功能

父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。

接口隔离原则

调用方不应该依赖它不需要的接口;一个类对另一个类的依赖因建立在最小的接口上。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口Dev设计就不够职责单一。

依赖反转原则

高层模块不应该依赖底层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。

迪米特法则

一个对象应该对其他对象保持最少的了解。

合成复用原则

尽量使用合成、聚合的方式,而不是使用继承。

单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。

设计模式

设计模式:软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的实验和错误总结出来的。每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案。

  • 创建型:主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。
  • 结构型:主要通过类或对象的不同组合,解耦不同功能的耦合
  • 行为型:主要解决的是类或对象之间的交互行为的耦合 。
类型模式说明使用场景
创建型单例一个类只允许创建一个实例或对象,并为其提供一个全局的访问点无状态/q全局唯一/控制资源访问
工厂创建一个或者多个相关的对象,而使用者不用关心具体的实现类分离对象的创建和使用
建造者用于创建一种类型的复杂对象,通过设置不同的可选参数进行“定制化”对象的构造参数较多且多数可选
原型通过复制已有对象来创建新的对象对象的创建成本较多且多数可选
结构型代理不改变原始类和不使用继承的情况下,通过引入代理类来给原始类附加功能增加代理访问,比如监控、缓存、限流、事务、RPC
装饰者不改变原始类和不使用继承的情况下,通过组合的方式动态扩展原始类的功能动态扩展类的功能
适配器不改变原始类的情况下,通过组合的方式使其适配新的接口复用现有类,但与期望接口不适配
桥接当类存在多个独立变化的维度时,通过组合的方式使得其可以独立进行扩展存在多个维度的继承体系
门面为子系统中一组接口定义一个更高层的接口,使得子系统更加容易使用解决接口复用性(细粒度)与接口易用性(粗粒度)的矛盾
组合将对象组合成树形结构以表示部分-整体的层次结构,统一单个对和组合对象的处理逻辑满足部分与整体这种树形结构
享元运用共享技术有效地支持大量细粒度的对象当系统存在大量的对象,这些对象的很多字段取值范围固定
行为型观察者多个观察这监听同一主题对象,当主题对象状态发生变化时通知所有观察这,使它们能够自动更新自己解耦事件创建者与接受者
模板定义一个操作中算法的骨架,将某些步骤实现延迟到子类中解决复用和扩展问题
策略定义一组算法类,将每个算法分别封装起来,使得它们可以互相替换消除各种if-else分支判断,解耦策略的定义、创造
状态允许第一个对象在其内部状态改变的时候改变其行为分离对象的状态与行为
职责链将一组对象连城一条链,请求沿着该链传递,直到某个对象能够处理它为止解耦集合对象的内部表示与遍历访问
迭代器封装一些作用于某种数据结构中个元素的操作,在不改变数据结构的前提下,定义作用于这些元素的新操作分离对象的数据结构与行为
访问者封装一些作用于某种数据结构中个元素的操作,在不改变数据结构的前提下,定义作用于这些元素的新操作分离对象的数据结构与行为
备忘录在不违背封装原则的前提下,捕获一个对象的内部状态,以便之后恢复对象为先前的状态用于对象的备份与恢复
命令将不同的请求分装成对应的命令对象,对命令的执行进行控制且对使用方透明用于控制命令的执行,比如异步、延迟、排队、撤销、存储与撤销
解释器为某种语言定义它的语法表示,并定义一个解释器来处理这个语法用于编译器、规则引擎、正则表达式等特定场景
中介定义一个单独的中介对象,来封装一组对象之间的交互,避免对象之间的直接交互使各个对象不需要显式地相互引用,从而使其耦合松散

代码分层

模块结构说明

  • server_main:配置层,负责整个项目的module的module管理,maven配置管理、资源管理等;
  • server_application:应用接入层,承接外部流量入口,例如:RPC接口实现、消息处理、定时任务等;不要在此包含业务逻辑;
  • server_biz:核心业务层,用例服务、领域实体、领域事件等。
  • server_irepository:资源接口层,负责资源接口的暴露
  • server_repository:资源层,负责资源的proxy访问,统一外部资源访问,隔离变化。注意:这里强调的是弱业务性,强数据性;
  • server_common:公共层,vo、工具等

代码开发要遵守各层的规范,并注意层级之间的依赖关系。

命名规范

一个好的命名应该要满足以下两个约束

  • 准确描述锁做得事情
  • 格式符合通用的惯例

如果你觉得一个类或方法难以命名的时候,可能是其承载的功能太多了,需要进一步拆分。

约定俗成的惯例

场景强约束示例
项目名全部小写,多个单词用中划线分隔“-”spring-cloud
包名全部小写com.alibaba.fastjson
类名/接口名单词首字母大写ParserConfig.DefaultFieldDeserializer
变量名首字母小写,多个单词组成是,除首个单词,其他单词首字母都要大写passWord,userName
常量名全部大写,多个单词,用“_”分隔CACHE_EXPIRED_TIME
方法同变量ready()

类命名

类名使用大驼峰命名形式,类命通常使用名词或名词短语。接口名除了用名词和名词短语以外,还可以使用形容词或形容词短语,如Cloneable,Callable等,表示实现该接口的类有某种功能或能力。

场景约束示例
抽象类Abstract或者Base开头BaseUserService
枚举类Enum作为后缀GenderEnum
工具类Utils作为后缀StringUtils
异常类Exception结尾RuntimeException
接口实现类接口名+implUserServiceImpl
设计模式相关类Builder,Factory等当使用设计模式时,需要使用对应的设计模式作为后缀,如ThreadFactory
处理特定功能的类Handler,Predicate,Validator表示处理器,校验器,断言,这些类工厂还有配套的方法名。如handle,predicate,validate
特定层级的类Controller,Service,ServiceImpl,Dao后缀UserController,UserServiceImpl,UserDao
特定层级的值对象AO,Param,Vo,Config,MessageParam调用入参;Ao为thrift返回结果;Vo通用值对象;Config配置类,Message为MQ消息
测试类Test结尾UserServiceTest,表示用来测试UserService相关业务的测试类

方法命名

方法命名采用小驼峰的形式,首字小写,往后的每个单词首字母都要大写。和类名不同的是,方法命名一般为动画或动画短语,与参数或参数名共同组成动宾短语,即动词+名词。一个好的函数名一般能通过名字直接获知函数实现什么样的功能。

场景约束示例
返回真伪值is/can/has/needs/shouldisVaild/canRemove
用于检查ensure/vaildateensureCapacity/vaildateInputs
按需执行ifNeeder/try/OrDefault/OrElsedrawIfNeeded/tryCreate/getOrDefault
数据相关get/search/save/update/batchSave/batchUpdate/saveOrUpdateselect/insert/update/deletegetUserById/searchUsersByCreateTime
生命周期initialize/pause/stop/destroyinitailze/pause/onPause/stop/onStop
常用动词对split/join、inject/extract、bind/seperate、increase/decrease、lanch/run、observe/listen、build/publish、encode/decode、submit/commit、push/pull、enter/exit、expand/collapse、encode/decode

重构技巧

提炼方法

多个方法代码重复、方法中代码过程或者方法中语句不在一个抽象层级。方法是代码复用的最小粒度,方法过长不利于复用,可读性低,提炼方法往往是重构工作的第一步。

意图导向编程:把处理某件事的流程和具体做事的实现方式分开。

  • 把一个问题分解为一系列功能性步骤,并假定这些功能步骤以实现
  • 我们只需把各个函数组织在一起即可解决这一问题
  • 在组织好整个功能后,我们在分别实现各个方法函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/** 
* 1、交易信息开始于一串标准ASCII字符串。
* 2、这个信息字符串必须转换成一个字符串的数组,数组存放的此次交易的领域语言中所包含的词汇元素(token)。
* 3、每一个词汇必须标准化。
* 4、包含超过150个词汇元素的交易,应该采用不同于小型交易的方式(不同的算法)来提交,以提高效率。
* 5、如果提交成功,API返回”true”;失败,则返回”false”。
*/
public class Transaction{
public Boolean commit(String command){
Boolean result = true;
String[] tokens = tokenize(command);
normalizeToken(tokens);
if(isALargeTransaction(tokens)){
result = processSmallTransaction(tokens);
}else{
result = processSmallTransaction(tokens);
}
return result;
}
}

以函数对象取代函数

将函数放进一个单独对象中,如此一来局部变量就编程利对象内的字段。然后你可以再用一个对象中将这个大型函数分解为多个小型函数。

引入参数对象

方法参数比较多时,将参数封装为参数对象

移除对参数的赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int discount(int inputVal, int quantity, int yearToDate) {
if (inputVal > 50) inputVal -= 2;
if (quantity > 100) inputVal -= 1;
if (yearToDate > 10000) inputVal -= 4;
return inputVal;
}

public int discount(int inputVal, int quantity, int yearToDate) {
int result = inputVal;
if (inputVal > 50) result -= 2;
if (quantity > 100) result -= 1;
if (yearToDate > 10000) result -= 4;
return result;
}

将查询与修改分离

任何有返回值得方法,都不应该有副作用

  • 不要在convert中写操作,避免副作用
  • 常见的例外:将查询结构缓存到本地

移除不必要临时变量

临时变脸仅适用一次或者取值逻辑成本很低的情况下

引入解释性变量

将复杂表达式(或其中一部分)的结果放进一个临时变量,以此变量名称来解释表达式用途

1
2
3
4
5
6
7
8
9
10
11
if ((platform.toUpperCase().indexOf("MAC") > -1) 
&& (browser.toUpperCase().indexOf("IE") > -1) && wasInitialized() && resize > 0) {
// do something
}

final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
final boolean isIEBrowser = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;
if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
// do something
}

使用卫语句替代嵌套条件判断

把复杂的条件表达式拆分成多个表达式,减少嵌套。嵌套了好几层的if-then-else语句,转换为多个if语句

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
//未使用卫语句
public void getHello(int type) {
if (type == 1) {
return;
} else {
if (type == 2) {
return;
} else {
if (type == 3) {
return;
} else {
setHello();
}
}
}
}

//使用卫语句
public void getHello(int type) {
if (type == 1) {
return;
}
if (type == 2) {
return;
}
if (type == 3) {
return;
}
setHello();
}

使用多态替代条件判断

当存在这样一类表达式,它根据对象类型的不同选择不同的行为。可以将这种表达式的每个分支放进一个子类内的复写函数中,然后将原始函数声明为抽象函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int calculate(int a, int b, String operator) {
int result = Integer.MIN_VALUE;

if ("add".equals(operator)) {
result = a + b;
} else if ("multiply".equals(operator)) {
result = a * b;
} else if ("divide".equals(operator)) {
result = a / b;
} else if ("subtract".equals(operator)) {
result = a - b;
}
return result;
}

当出现大量类型检查和判断时,if else(或switch)语句的体积会比较臃肿,这无疑降低了代码的可读性。另外,if else(或switch)本身就是一个“变化点”,当需要扩展新的类型时,我们不得不追加if else(或switch)语句块,以及相应的逻辑,这无疑降低了程序的可扩展性,也违反了面向对象的开闭原则。

基于这种场景,我们可以考虑使用“多态”来代替冗长的条件判断。将if else(或switch)中的“变化点”封装到子类中。这样,就不需要使用if else(或switch)语句了,取而代之的是子类多态的实例,从而提高代码的可读性和可扩展性。很多设计模式使用都是这种套路,比如策略模式、状态模式。

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
public interface Operation { 
int apply(int a, int b);
}

public class Addition implements Operation {
@Override
public int apply(int a, int b) {
return a + b;
}
}

public class OperatorFactory {
private final static Map<String, Operation> operationMap = new HashMap<>();
static {
operationMap.put("add", new Addition());
operationMap.put("divide", new Division());
// more operators
}

public static Operation getOperation(String operator) {
return operationMap.get(operator);
}
}

public int calculate(int a, int b, String operator) {
if (OperatorFactory .getOperation == null) {
throw new IllegalArgumentException("Invalid Operator");
}
return OperatorFactory .getOperation(operator).apply(a, b);
}

使用异常替代返回错误码

非正常业务状态,使用抛出异常的方式代替返回错误码

  • 不要使用异常处理用于正常的业务流程控制

    • 异常处理的性能成本非常高
  • 尽量使用标准异常

  • 避免在finally语句块中抛出异常

    • 如果同时抛出两个异常,则第一个异常的调用栈会丢失
    • finally块中应只做关闭资源这类的事情
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//使用错误码
public boolean withdraw(int amount) {
if (balance < amount) {
return false;
} else {
balance -= amount;
return true;
}
}

//使用异常
public void withdraw(int amount) {
if (amount > balance) {
throw new IllegalArgumentException("amount too large");
}
balance -= amount;
}

引入断言

某一段代码需要对程序状态做出某种假设,以断言明确表现这种假设。

  • 不要滥用断言,不要使用它来检查“应该为真”的条件,只使用它来检查“一定必须为真”的条件
  • 如果断言所指示的约束条件不能满足,代码是否能正常运行?如果可以就去掉断言

引入Null对象或特殊对象

当使用一个方法返回的对象时,而这个对象可能为空,这个时候需要对这个对象进行操作前,需要进行判空,否则就会报空指针。当这种判断频繁的出现在各处代码之中,就会影响代码的美观程度和可读性,甚至增加Bug的几率。

空引用的问题的Java中无法避免,但可以通过代码编程技巧(引入空对象)来改善这一问题。

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
//空对象的例子
public class OperatorFactory {
static Map<String, Operation> operationMap = new HashMap<>();
static {
operationMap.put("add", new Addition());
operationMap.put("divide", new Division());
// more operators
}
public static Optional<Operation> getOperation(String operator) {
return Optional.ofNullable(operationMap.get(operator));
}
}
public int calculate(int a, int b, String operator) {
Operation targetOperation = OperatorFactory.getOperation(operator)
.orElseThrow(() -> new IllegalArgumentException("Invalid Operator"));
return targetOperation.apply(a, b);
}

//特殊对象的例子
public class InvalidOp implements Operation {
@Override
public int apply(int a, int b) {
throw new IllegalArgumentException("Invalid Operator");
}
}