1 代码重构
1.1 重构的定义
通俗讲就是不影响外界访问的前提下,修改代码的行为。这里的修改代码包括重新设计(业务设计和技术设计)和代码结构的调整。
重构是在不改变软件可观察行为的前提下改善其内部结构,使得代码更容易被理解和修改
- 重构通常不是一次性的,它贯穿软件的整个生命周期,只要觉得不合理都是重构的时机。
- 重构不同于性能优化,性能优化往往使代码较难理解,但是为了得到所需的性能你不得不那么做。
任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码,才是优秀的程序员
1.2 重构的作用
1.2.1 我们希望的程序
- 容易阅读
- 所有逻辑都只在唯一地点指定
- 新的改动不会危及现有行为
- 尽可能简单的表达逻辑
1.2.2 有问题的程序
- 代码看了半天还是看不懂
- 一个需求要改若干处,牵一发而动全身
- 到处都是逻辑重复的代码
- 各种复杂逻辑的条件判断
- 特殊逻辑散落在各处,漏改一处往往导致线上故障
- 战战兢兢不敢修改代码,于是拷贝代码新辟流程
总结起来就是:差劲的系统很难修改
1.2.3 重构的作用
- 让代码更容易理解
- 改善内部程序结构,修改更容易
- 提高编程速度,加快业务迭代
- 用最小的代码,实现复杂业务
1.3 代码的坏味道
1.3.1 1.重复代码
解决方法
- 抽离出共用方法
- 提取继承体系中重复的属性与方法到父类
- 将不同点抽取成抽象方法
1.3.2 2.过长函数
解决方法
- 抽离成多个子方法
- 一个函数只做一件事,最好从名字上就能看出函数的职责
1.3.3 3.过大的类
解决方法
- 按职责提取子类,拆分成多个小类
- 保证类的单一职权原则、内聚原则
1.3.4 4.过长参数
解决方法
- 引入参数对象
样例:
class BadExample {
public void someMethod(int i, int j, int k, int l, int m, int n) {
//code
}
}
class GoodExample {
public void someMethod(Data data){
//code
}
}
1.3.5 5.发散式变化
一个类会因为不同性质的原因被修改
解决方法
- 按职责提取类,拆分成多个小类,这样每次只会修改一个类
1.3.6 6.散弹式修改
一个需求变动需要修改多个方法多个变量
解决方法
- 将所有强相关的方法和变量放到一个类中
- 用一个类解决一个业务问题
1.3.7 7.函数主体不明确
函数的放置位置影响函数的使用,当多处存在相同业务逻辑时不利于维护
解决方法
- 将函数移动到使用最多的类,或者最相关的类中
- 让属性的所有者提供应该提供的方法
- 私有函数在首次被使用的位置下面定义
样例
class BadExample {
public int someMethod(Data data) {
int i = data.getI();
int j = data.getJ();
int k = data.getK();
return i * j * k;
}
public static class Data{
private int i;
private int j;
private int k;
public Data(int i, int j, int k) {
super();
this.i = i;
this.j = j;
this.k = k;
}
public int getI() {
return i;
}
public int getJ() {
return j;
}
public int getK() {
return k;
}
}
}
class GoodExample {
public int someMethod(Data data){
return data.getResult();
}
public static class Data{
private int i;
private int j;
private int k;
public Data(int i, int j, int k) {
super();
this.i = i;
this.j = j;
this.k = k;
}
public int getI() {
return i;
}
public int getJ() {
return j;
}
public int getK() {
return k;
}
public int getResult(){
return i * j * k;
}
}
}
1.3.8 8.临时变量过多
解决方法
- 去掉一次性使用的临时变量,用方法调用代替
- 将众多临时变量封装成对象使用
- 临时变量在第一次被使用的地方定义
1.3.9 9.意义不明的常量计算
意义不明的常量,常常会增加业务理解的困难程度
解决方法
- 用符号常量替换无意义数字,并使用能体现意图的命名
- 用枚举代替可罗列类型
1.3.10 10.无逻辑的中间类
解决方法
- 消除中间人,让使用方之间载入方法提供类
1.3.11 11.庞大的switch块
解决方法
- 采用多态解决逻辑分支
- 将每个case的内容提取成单独的函数,让主函数更精简
1.3.12 12.层峦叠嶂的if
解决方法
- 使用卫语句
样例
class BadExample {
public void someMethod(Object A,Object B) {
if (A != null) {
if (B != null) {
//code[1]
} else {
//code[3]
}
} else {
//code[2]
}
}
}
class GoodExample {
public void someMethod(Object A,Object B) {
if (A == null) {
//code[2]
return;
}
if (B == null) {
//code[3]
return;
}
//code[1]
}
}
- 在子类中采用多态解决逻辑分支
1.3.13 13.复杂的if条件
解决方法
- 将复杂表达式替换成一个或多个有意义的临时变量
样例
public boolean badExample(Session session) {
return corpService.hasCorpService(session.getCorpId(), CorpPermissions.EVALUATION_RIGHT) && staffService.checkRight(session.getStaffId(), StaffRights.EVALUATION_RIGHT) && session.getTyper() == Session.Type.Normal && session.getInteraction() > 0 && session.getStaffId() > 0 && session.getStatus() == Session.Status.CLOSED && session.getFromType().equals(Session.FromType.WEB);
}
public boolean goodExample(Session session) {
boolean checkRight = corpService.hasCorpService(session.getCorpId(), CorpPermissions.EVALUATION_RIGHT) && staffService.checkRight(session.getStaffId(), StaffRights.EVALUATION_RIGHT);
if (!checkRight) {
return false;
}
boolean checkSessionType = session.getTyper() == Session.Type.Normal && session.getInteraction() > 0 && session.getFromType().equals(Session.FromType.WEB);
if (!checkSessionType) {
return false;
}
boolean checkStaff = session.getStaffId() > 0;
if (!checkStaff) {
return false;
}
boolean checkStatus = session.getStatus() == Session.Status.CLOSED;
return checkStatus;
}
1.3.14 14.与逻辑纠缠不清的临时变量
程序中一大堆临时变量,方法又复杂有长,很难理解
解决方法
- 将相关的变量和方法提取成一个函数对象,将局部变量变成对象内的字段
- 一个临时变量应该只被赋值一次,再次被赋值时可以考虑新增变量
1.3.15 15.走了弯路的逻辑实现
- 如果发现某段程序有更简单的方案实现,建议替换原有程序
- 修改之前最好找相关人员确认优化方案,并增加流程控制开关。
1.3.16 16.过多的注释
逻辑混乱的代码才需要大量的注释
解决方法
- 按找上面讲到的方法进行重构
- 给方法或者变量取一个有意义的名字,不要怕名称过长
2 代码规范
2.1 命名规范
2.1.1 大小写规范
- 类名使用 UpperCamelCase 风格,但以下情形例外:BO / DTO / VO / DAO 等
正例:UserDO / XmlService / TcpUdpDeal / TaPromotion
反例:UserDo / XMLService / TCPUDPDeal / TAPromotion
- 方法名、参数名、成员变量、局部变量都统一使用 lowerCamelCase 风格,必须遵从驼峰形式
localValue / getHttpMessage() / inputUserId
- 常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要嫌名字长
正例:MAXSTOCKCOUNT
反例:MAX_COUNT
- 包名统一使用小写,
点分隔符之间有且仅有一个自然语义的英语单词
。包名统一使用单数形式,但是类名如果有复数含义,类名可以使用复数形式
2.1.2 前后缀规范
- 抽象类命名使用 Abstract 或 Base 开头
- 异常类命名使用 Exception 结尾
-
测试类命名以它要测试的类的名称开始,以 Test 结尾
-
POJO 类中布尔类型的变量,都不要加 is 前缀,否则部分框架解析会引起序列化错误
反例:定义为基本数据类型 Boolean isDeleted 的属性,它的方法也是 isDeleted(),RPC框架在反向解析的时候,“误以为”对应的属性名称是 deleted,导致属性获取不到,进而抛出异常。
2.1.3 词义规范
-
杜绝完全不规范的缩写,避免望文不知义。
-
类名应该使用名词或者名词短语
- 方法应该使用动词或则动词短语
反例:AbstractClass“缩写”命名成 AbsClass;condition“缩写”命名成 condi,此类随意缩写严重降低了代码的可阅读性。
-
代码中的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式
-
为了达到代码自解释的目标,任何自定义编程元素在命名时,使用尽量完整的单词组合来表达其意。
正例:在 JDK 中,表达原子更新的类名为:AtomicReferenceFieldUpdater。 反例:变量 int a 的随意命名方式
2.1.4 接口定义
- 如果模块、接口、类、方法使用了设计模式,在命名时需体现出具体模式。
说明:将设计模式体现在名字中,有利于阅读者快速理解架构设计理念。
正例:
public class OrderFactory;
public class LoginProxy;
public class ResourceObserver;
- 接口类中的方法和属性不要加任何修饰符号(public 也不要加),保持代码的简洁性,并加上有效的 Javadoc 注释。
- 尽量不要在接口里定义变量,如果一定要定义变量,肯定是与接口方法相关,并且是整个应用的基础常量。
正例:
// 接口方法签名
void commit();
// 接口基础常量
String COMPANY = "alibaba";
反例:
// 接口方法定义
public abstract void f();
说明:JDK8 中接口允许有默认实现,那么这个 default 方法,是对所有实现类都有价值的默认实现。
2.1.5 枚举定义
- 枚举类名建议带上 Enum 后缀,枚举成员名称需要全大写,单词间用下划线隔开。
正例:枚举名字为 ProcessStatusEnum 的成员名称:SUCCESS / UNKNOWN_REASON。
说明:枚举其实就是特殊的类,域成员均为常量,且构造方法被默认强制是私有。
2.1.6 常量定义
- 不允许任何魔法值(即未经预先定义的常量)直接出现在代码中。常量内容必须定义成常量变量
反例:
String key = "Id#taobao_" + tradeId;
cache.put(key, value);
- 在 long 或者 Long 赋值时,数值后使用大写的 L,不能是小写的 l,小写容易跟数字1 混淆,造成误解。
说明:Long a = 2l; 写的是数字的 21,还是 Long 型的 2?
- 不要使用一个常量类维护所有常量,要按常量功能进行归类,分开维护
说明:大而全的常量类,杂乱无章,使用查找功能才能定位到修改的常量,不利于理解和维护
正例:缓存相关常量放在类 CacheConsts 下;系统配置相关常量放在类 ConfigConsts 下
- 如果变量值仅在一个固定范围内变化用 enum 类型来定义
正例:
public enum SeasonEnum {
SPRING(1), SUMMER(2), AUTUMN(3), WINTER(4);
private int seq;
SeasonEnum(int seq){
this.seq = seq;
}
}
2.2 代码格式
2.2.1 空格与换行
-
如果是大括号内为空,则简洁地写成{}即可,不需要换行;
-
如果是非空代码块则:
1) 左大括号前不换行。 2) 左大括号后换行。 3) 右大括号前换行。 4) 右大括号后还有 else 等代码则不换行;表示终止的右大括号后必须换行。
正例:
if (count > 10) {
// xxx
} else {
// xxx
}
- 左小括号和字符之间不出现空格;同样,右小括号和字符之间也不出现空格;而左大括号前需要空格。
反例:
if (空格 a == b 空格)
-
if/for/while/switch/do 等保留字与括号之间都必须加空格
-
任何二目、三目运算符的左右两边都需要加一个空格。
说明:运算符包括赋值运算符=、逻辑运算符&&、加减乘除符号等。
正例:
if (status == ONLINE && staffId > 0) {
// xxx
}
- 采用 4 个空格缩进,禁止使用 tab 字符。
说明:如果使用 tab 缩进,必须设置 1 个 tab 为 4 个空格。IDEA 设置 tab 为 4 个空格时,请勿勾选 Use tab character;而在 eclipse 中,必须勾选 insert spaces for tabs。
-
注释的双斜线与注释内容之间有且仅有一个空格。
-
方法参数在定义和传入时,多个参数逗号后边必须加空格。
正例:下例中实参的 args1,后边必须要有一个空格。
method(args1, args2, args3);
正例:
public static void main(String[] args) {
// 缩进 4 个空格
String say = "hello";
// 运算符的左右必须有一个空格
int flag = 0;
// 关键词 if 与括号之间必须有一个空格,括号内的 f 与左括号,0 与右括号不需要空格
if (flag == 0) {
System.out.println(say);
}
// 左大括号前加空格且不换行;左大括号后换行
if (flag == 1) {
System.out.println("world");
// 右大括号前换行,右大括号后有 else,不用换行
} else {
System.out.println("ok");
// 在右大括号后直接结束,则必须换行
}
}
2.2.2 代码分行和对齐
- 单行字符数限制不超过 120 个,超出需要换行,换行时遵循如下原则:
1) 第二行相对第一行缩进 4 个空格,从第三行开始,不再继续缩进,参考示例。 2) 运算符与下文一起换行。 3) 方法调用的点符号与下文一起换行。 4) 方法调用中的多个参数需要换行时,在逗号后进行。 5) 在括号前不要换行,见反例
正例:
StringBuffer sb = new StringBuffer();
// 超过 120 个字符的情况下,换行缩进 4 个空格,点号和方法名称一起换行
sb.append("zi").append("xin")...
.append("huang")...
.append("huang")...
.append("huang");
反例:
StringBuffer sb = new StringBuffer();
// 超过 120 个字符的情况下,不要在括号前换行
sb.append("zi").append("xin")...append
("huang");
// 参数很多的方法调用可能超过 120 个字符,不要在逗号前换行
method(args1, args2, args3, ...
, argsX);
- 没有必要增加若干空格来使某一行的字符与上一行对应位置的字符对齐。
正例:
int one = 1;
long two = 2L;
float three = 3F;
StringBuffer sb = new StringBuffer();
- 不同逻辑、不同语义、不同业务的代码之间插入一个空行分隔开来以提升可读性。
说明:任何情形,没有必要插入多个空行进行隔开。
IDE 的 text file encoding 设置为 UTF-8; IDE 中文件的换行符使用 Unix 格式,不要使用 Windows 格式
2.2.3 代码长度限制
- 单个方法的总行数不超过 80 行。
说明:包括方法签名、结束右大括号、方法内代码、注释、空行、回车及任何不可见字符的总
- 行数不超过 80 行。做到从一个方法名字上就能看出一个方法的职能。
正例:代码逻辑分清红花和绿叶,个性和共性,绿叶逻辑单独出来成为额外方法,使主干代码更加清晰;共性逻辑抽取成为共性方法,便于复用和维护。
2.3 OOP规约
2.3.1 注解和序列化
-
所有的覆写方法,必须加@Override 注解。过时的接口必须加@Deprecated 注解。
-
序列化类新增属性时,请不要修改 serialVersionUID 字段,避免反序列失败;如果完全不兼容升级,避免反序列化混乱,那么请修改 serialVersionUID 值。
说明:注意 serialVersionUID 不一致会抛出序列化运行时异常
2.3.2 规避运行时异常
- Object 的 equals 方法容易抛空指针异常,应使用常量或确定有值的对象来调用equals。
正例:"test".equals(object);
反例:object.equals("test");
- 所有的相同类型的包装类对象之间值的比较,全部使用 equals 方法比较。
说明:对于 Integer var = ? 在-128 至 127 范围内的赋值,Integer 对象是在IntegerCache.cache 产生,会复用已有对象,这个区间内的 Integer 值可以直接使用==进行判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象,这是一个大坑,推荐使用 equals 方法进行判断
- 关于基本数据类型与包装数据类型的使用标准如下:
1) 【强制】所有的 POJO 类属性必须使用包装数据类型。 2) 【强制】RPC 方法的返回值和参数必须使用包装数据类型。 3) 【推荐】所有的局部变量使用基本数据类型。
说明:POJO 类属性没有初值是提醒使用者在需要使用时,必须自己显式地进行赋值,任何NPE 问题,或者入库检查,都由使用者来保证。
正例:数据库的查询结果可能是 null,因为自动拆箱,用基本数据类型接收有 NPE 风险。
反例:比如显示成交总额涨跌情况,即正负 x%,x 为基本数据类型,调用的 RPC 服务,调用不成功时,返回的是默认值,页面显示为 0%,这是不合理的,
2.3.3 提高程序的逻辑清晰性
-
构造方法里面禁止加入任何业务逻辑,如果有初始化逻辑,请放在 init 方法中
-
当一个类有多个构造方法,或者多个同名方法,这些方法应该按顺序放置在一起,便于阅读
-
类内方法定义的顺序依次是:公有方法或保护方法 > 私有方法 > getter/setter方法。
说明:公有方法是类的调用者和维护者最关心的方法,首屏展示最好;保护方法虽然只是子类关心,也可能是“模板设计模式”下的核心方法;而私有方法外部一般不需要特别关心,是一个黑盒实现;因为承载的信息价值较低,所有 Service 和 DAO 的 getter/setter 方法放在类体最后
- 在getter/setter 方法中,不要增加业务逻辑,增加排查问题的难度。
反例:
public Integer getData() {
if (condition) {
return this.data + 100;
} else {
return this.data - 100;
}
}
2.3.4 规避性能陷阱
- 循环体内,字符串的连接方式,使用 StringBuilder 的 append 方法进行扩展。
说明:下例中,反编译出的字节码文件显示每次循环都会 new 出一个 StringBuilder 对象,然后进行 append 操作,最后通过 toString 方法返回 String 对象,造成内存资源浪费。
反例:
String str = "start";
for (int i = 0; i < 100; i++) {
str = str + "hello";
}
-
循环体内,谨慎调用数据库或者RPC接口,可能会导致严重性能问题。可以将数据查询挪到循环体之外。
-
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下: 1)FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAXVALUE,可能会堆积大量的请求,从而导致 OOM。 2)CachedThreadPool 和 ScheduledThreadPool:允许的创建线程数量为 Integer.MAXVALUE,可能会创建大量的线程,从而导致 OOM。
- 高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。
说明:尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法
- 对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。
说明:线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是 A、B、C,否则可能出现死锁
2.4 控制语句
2.4.1 提高控制语句的可读性
-
在一个 switch 块内,每个 case 要么通过 break/return 等来终止,要么注释说明程序将继续执行到哪一个 case 为止;在一个 switch 块内,都必须包含一个 default 语句并且放在最后,即使空代码。
-
超过 3 层的 if-else 的逻辑判断代码可以使用卫语句、策略模式、状态模式等来实现
-
除常用方法(如 getXxx/isXxx)等外,不要在条件判断中执行其它复杂的语句,将复杂逻辑判断的结果赋值给一个有意义的布尔变量名,以提高可读性。
-
说明:很多 if 语句内的逻辑相当复杂,阅读者需要分析条件表达式的最终结果,才能明确什么样的条件执行什么样的语句,那么,如果阅读者分析逻辑表达式错误呢?*
正例:
// 伪代码如下
final boolean existed = (file.open(fileName, "w") != null) && (...) || (...);
if (existed) {
...
}
反例:
if ((file.open(fileName, "w") != null) && (...) || (...)) {
...
}
- 避免采用取反逻辑运算符。
说明:取反逻辑不利于快速理解,并且取反逻辑写法必然存在对应的正向逻辑写法
正例:使用 if (x < 628) 来表达 x 小于 628。
反例:使用 if (!(x >= 628)) 来表达 x 小于 628。
2.4.2 方法参数校验
- 下列情形,需要进行参数校验:
1) 调用频次低的方法。 2) 执行时间开销很大的方法。此情形中,参数校验时间几乎可以忽略不计,但如果因为参数错误导致中间执行回退,或者错误,那得不偿失。 3) 需要极高稳定性和可用性的方法。 4) 对外提供的开放接口,不管是 RPC/API/HTTP 接口。 5) 敏感权限入口。
- 下列情形,不需要进行参数校验: 1) 极有可能被循环调用的方法。但在方法说明里必须注明外部参数检查要求。 2) 底层调用频度比较高的方法。毕竟是像纯净水过滤的最后一道,参数错误不太可能到底层才会暴露问题。一般 DAO 层与 Service 层都在同一个应用中,部署在同一台服务器中,所以 DAO 的参数校验,可以省略。 3) 被声明成 private 只会被自己代码所调用的方
2.5 注释规约
2.5.1 注释格式规范
- 类、类属性、类方法的注释必须使用 Javadoc 规范,使用/*内容/格式,不得使用// xxx 方式。所有的类都必须添加创建者和创建日期。
说明:在 IDE 编辑窗口中,Javadoc 方式会提示相关注释,生成 Javadoc 可以正确输出相应注释;在 IDE 中,工程调用方法时,不进入方法即可悬浮提示方法、参数、返回值的意义,提高阅读效率
- 所有的抽象方法(包括接口中的方法)必须要用 Javadoc 注释、除了返回值、参数、异常说明外,还必须指出该方法做什么事情,实现什么功能。
说明:对子类的实现要求,或者调用注意事项,请一并说明
-
方法内部单行注释,在被注释语句上方另起一行,使用//注释。方法内部多行注释使用/* */注释,注意与代码对齐
-
所有的枚举类型字段必须要有注释,说明每个数据项的用途
2.5.2 注释语义规范
- 对于注释的要求:
1) 能够准确反应设计思想和代码逻辑; 2) 能够描述业务含义,使别的程序员能够迅速了解到代码背后的信息。
完全没有注释的大段代码对于阅读者形同天书,注释是给自己看的,即使隔很长时间,也能清晰理解当时的思路;注释也是给继任者看的,使其能够快速接替自己的工作
- 好的命名、代码结构是自解释的,看到名称就知道意义了。注释力求精简准确、表达到位。避免出现注释的一个极端:过多过滥的注释,代码的逻辑一旦修改,修改注释是相当大的负担。
反例:
// put elephant into fridge
put(elephant, fridge);
- 与其“半吊子”英文来注释,不如用中文注释把问题说清楚。专有名词与关键字保持英文原文即可。
反例:“TCP 连接超时”解释成“传输控制协议连接超时”,理解反而费脑筋
2.5.3 注释维护规约
- 代码修改的同时,注释也要进行相应的修改,尤其是参数、返回值、异常、核心逻辑等的修改。
说明:代码与注释更新不同步,就像路网与导航软件更新不同步一样,如果导航软件严重滞后,就失去了导航的意义
- 谨慎注释掉代码。在上方详细说明,而不是简单地注释掉。如果无用,则删除。
说明:代码被注释掉有两种可能性:1)后续会恢复此段代码逻辑。2)永久不用。前者如果没有备注信息,难以知晓注释动机。后者建议直接删掉(代码仓库保存了历史代码)
2.5.4 特殊注释标记
-
特殊注释标记,请注明标记人与标记时间。注意及时处理这些标记,通过标记扫描,经常清理此类标记。线上故障有时候就是来源于这些标记处的代码。
-
待办事宜(TODO):( 标记人,标记时间,[预计处理时间])
表示需要实现,但目前还未实现的功能。这实际上是一个 Javadoc 的标签,目前的 Javadoc还没有实现,但已经被广泛使用。只能应用于类,接口和方法(因为它是一个 Javadoc 标签)。
- 错误,不能工作(FIXME):(标记人,标记时间,[预计处理时间])
在注释中用 FIXME 标记某代码是错误的,而且不能工作,需要及时纠正的情况