一.Code Review目的
1.让代码更加统一,规范,易维护;
2.方便开发人员互相学习经验,达到知识共享;
3.帮助开发人员跳出固定思维,得出最优解决问题的思路;
4.可运行->可重用;
二.Code Review步骤
流程图

三.Code Review清单

四.代码设计
三阶段编程:分析->设计->打磨

1.领域建模
a.分析阶段:当拿到一个需求时,先不要着急想着怎么把这个功能实现,这种很容易陷入事务脚本的模式(面向过程)。分析什么呢?需要分析需求的目的是什么、完成该功能需要哪些实体承担,这一步核心是找实体。
b.设计阶段:分析完了有哪些实体后,再分析职责如何分配到具体的实体上,这就要运用一些设计原则去指导,GRASP中提到一些职责分配的原则:https://www.cnblogs.com/pangjianxin/p/7928083.html。
c.打磨阶段:这个阶段选择合适的模式去实现,大家一看到模式都会理解它是做什么的,比如看到模板类,就会知道处理通用的业务流程,具体变化的部分放在子类中处理。
2.设计原则(SOLID)
单一职责原则
一个类只负责完成一个职责或者功能,不要存在多于一种导致类变更的原因。
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
开放-关闭原则
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。
开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
里氏替换原则
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
子类可以扩展父类的功能,但不能改变父类原有的功能(父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。)
接口隔离原则
调用方不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
依赖反转原则
高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
迪米特法则
一个对象应该对其他对象保持最少的了解。
合成复用原则
尽量使用合成/聚合的方式,而不是使用继承(也就是作为属性聚合,而不是去继承该类)。
单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。
3.设计模式
设计模式:软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,设计模式核心底层逻辑就是:找到变化,封装变化。
l创建型:主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码;
l结构型:主要通过类或对象的不同组合,解耦不同功能的耦合;
l行为型:主要解决的是类或对象之间的交互行为的耦合;



五.coding技巧
1.提炼方法
多个方法代码重复、方法中代码过长或者方法中的语句不在一个抽象层级。方法是代码复用的最小粒度,方法过长不利于复用,可读性低,提炼方法往往是重构工作的第一步。
意图导向编程:把处理某件事的流程和具体做事的实现方式分开。
l 把一个问题分解为一系列功能性步骤,并假定这些功能步骤已经实现
l 我们只需把把各个函数组织在一起即可解决这一问题
l 在组织好整个功能后,我们在分别实现各个方法函数
java
/**
* 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);
normalizeTokens(tokens);
if (isALargeTransaction(tokens)) {
result = processLargeTransaction(tokens);
} else {
result = processSmallTransaction(tokens);
}
return result;
}
}
2.以函数对象取代函数
将函数放进一个单独对象中,如此一来局部变量就变成了对象内的字段。然后你可以在同一个对象中将这个大型函数分解为多个小型函数。
3.引入参数对象
方法参数比较多时,将参数封装为参数对象。
4.移除对参数的赋值
java
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;
}
5.将查询与修改分离
任何有返回值的方法,都不应该有副作用
l 不要在convert中调用写操作,避免副作用
l 常见的例外:将查询结果缓存到本地
6.移除不必要临时变量
临时变量仅使用一次或者取值逻辑成本很低的情况下。
7.引入解释性变量
将复杂表达式(或其中一部分)的结果放进一个临时变量,以此变量名称来解释表达式用途。
java
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
}
8.使用卫语句替代嵌套条件判断
把复杂的条件表达式拆分成多个条件表达式,减少嵌套。嵌套了好几层的if - then-else语句,转换为多个if语句
java
//未使用卫语句
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();
}
9.使用多态替代条件判断
当存在这样一类条件表达式,它根据对象类型的不同选择不同的行为。可以将这种表达式的每个分支放进一个子类内的复写函数中,然后将原始函数声明为抽象函数。
java
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)语句块,以及相应的逻辑,这无疑降低了程序的可扩展性,也违反了面向对象的开闭原则。
l 基于这种场景,我们可以考虑使用"多态"来代替冗长的条件判断,将if else(或switch)中的"变化点"封装到子类中。这样,就不需要使用if else(或switch)语句了,取而代之的是子类多态的实例,从而使得提高代码的可读性和可扩展性。很多设计模式使用都是这种套路,比如策略模式、状态模式。
java
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);
}
10.使用异常替代返回错误码
非正常业务状态的处理,使用抛出异常的方式代替返回错误码。通过最上层(访问入口层)去统一处理异常,转换成标准返回码。
l 不要使用异常处理用于正常的业务流程控制
l 异常处理的性能成本非常高
l 尽量使用标准异常
l 避免在finally语句块中抛出异常
l 如果同时抛出两个异常,则第一个异常的调用栈会丢失
l finally块中应只做关闭资源这类的事情
java
//使用错误码
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;
}
典型案例:
① 业务逻辑抛出异常:
② 入口层简单调用服务:

③ 上层统一拦截处理异常(controller):

③ 上层统一拦截处理异常(dubbo):

11.引入断言
某一段代码需要对程序状态做出某种假设,以断言明确表现这种假设。
l 不要滥用断言,不要使用它来检查"应该为真"的条件,只使用它来检查"一定必须为真"的条件
l 如果断言所指示的约束条件不能满足,代码是否仍能正常运行?如果可以就去掉断言
12.引入Null对象或特殊对象
当使用一个方法返回的对象时,而这个对象可能为空,这个时候需要对这个对象进行操作前,需要进行判空,否则就会报空指针。当这种判断频繁的出现在各处代码之中,就会影响代码的美观程度和可读性,甚至增加Bug的几率。
空引用的问题在Java中无法避免,但可以通过代码编程技巧(引入空对象)来改善这一问题。
java
//空对象的例子
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 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");
}
}
13.提炼类
根据单一职责原则,一个类应该有明确的责任边界。但在实际工作中,类会不断的扩展。当给某个类添加一项新责任时,你会觉得不值得分离出一个单独的类。于是,随着责任不断增加,这个类包含了大量的数据和函数,逻辑复杂不易理解。
此时你需要考虑将哪些部分分离到一个单独的类中,可以依据高内聚低耦合的原则。如果某些数据和方法总是一起出现,或者某些数据经常同时变化,这就表明它们应该放到一个类中。另一种信号是类的子类化方式:如果你发现子类化只影响类的部分特性,或者类的特性需要以不同方式来子类化,这就意味着你需要分解原来的类。
java
//原始类
public class Person {
private String name;
private String officeAreaCode;
private String officeNumber;
public String getName() {
return name;
}
public String getTelephoneNumber() {
return ("(" + officeAreaCode + ")" + officeNumber);
}
public String getOfficeAreaCode() {
return officeAreaCode;
}
public void setOfficeAreaCode(String arg) {
officeAreaCode = arg;
}
public String getOfficeNumber() {
return officeNumber;
}
public void setOfficeNumber(String arg) {
officeNumber = arg;
}
}
//新提炼的类(以对象替换数据值)
public class TelephoneNumber {
private String areaCode;
private String number;
public String getTelephnoeNumber() {
return ("(" + getAreaCode() + ")" + number);
}
String getAreaCode() {
return areaCode;
}
void setAreaCode(String arg) {
areaCode = arg;
}
String getNumber() {
return number;
}
void setNumber(String arg) {
number = arg;
}
}
14.组合优先于继承
继承使实现代码重用的有力手段,但这并非总是完成这项工作的最佳工具,使用不当会导致软件变得很脆弱。与方法调用不同的是,继承打破了封装性。子类依赖于其父类中特定功能的实现细节,如果父类的实现随着发行版本的不同而变化,子类可能会遭到破坏,即使他的代码完全没有改变。
举例说明,假设有一个程序使用HashSet,为了调优该程序的性能,需要统计HashSet自从它创建以来添加了多少个元素。为了提供该功能,我们编写一个HashSet的变体。
java
// Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;
public InstrumentedHashSet() { }
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
通过在新的类中增加一个私有域,它引用现有类的一个实例,这种设计被称为组合,因为现有的类变成了新类的一个组件。这样得到的类将会非常稳固,它不依赖现有类的实现细节。即使现有的类添加了新的方法,也不会影响新的类。许多设计模式使用就是这种套路,比如代理模式、装饰者模式。
java
// Reusable forwarding class
public class ForwardingSet<E> implements Set<E> {
private final Set s;
public ForwardingSet(Set s) { this.s = s; }
@Override
public int size() { return s.size(); }
@Override
public boolean isEmpty() { return s.isEmpty(); }
@Override
public boolean contains(Object o) { return s.contains(o); }
@Override
public Iterator iterator() { return s.iterator(); }
@Override
public Object[] toArray() { return s.toArray(); }
@Override
public T[] toArray(T[] a) { return s.toArray(a); }
@Override
public boolean add(E e) { return s.add(e); }
@Override
public boolean remove(Object o) { return s.remove(o); }
@Override
public boolean containsAll(Collection c) { return s.containsAll(c); }
@Override
public boolean addAll(Collection c) { return s.addAll(c); }
@Override
public boolean retainAll(Collection c) { return s.retainAll(c); }
@Override
public boolean removeAll(Collection c) { return s.removeAll(c); }
@Override
public void clear() { s.clear(); }
}
// Wrappter class - uses composition in place of inheritance
public class InstrumentedHashSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedHashSet1(Set s) {
super(s);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
继承与组合如何取舍
l 只有当子类真正是父类的子类型时,才适合继承。对于两个类A和B,只有两者之间确实存在"is-a"关系的时候,类B才应该继承A;
l 在包的内部使用继承是非常安全的,子类和父类的实现都处在同一个程序员的控制之下;
l 对于专门为了继承而设计并且具有很好的文档说明的类来说,使用继承也是非常安全的;
l 其他情况就应该优先考虑组合的方式来实现
15.接口优于抽象类
Java提供了两种机制,可以用来定义允许多个实现的类型:接口和抽象类。自从Java8为接口增加缺省方法(default method),这两种机制都允许为实例方法提供实现。主要区别在于,为了实现由抽象类定义的类型,类必须称为抽象类的一个子类。因为Java只允许单继承,所以用抽象类作为类型定义受到了限制。
接口相比于抽象类的优势:
l 现有的类可以很容易被更新,以实现新的接口。
l 接口是定义混合类型(比如Comparable)的理想选择。
l 接口允许构造非层次结构的类型框架。
l 接口虽然提供了缺省方法,但接口仍有有以下局限性:
l 接口的变量修饰符只能是public static final的
l 接口的方法修饰符只能是public的
l 接口不存在构造函数,也不存在this
l 可以给现有接口增加缺省方法,但不能确保这些方法在之前存在的实现中都能良好运行。
l 因为这些默认方法是被注入到现有实现中的,它们的实现者并不知道,也没有许可
l 接口缺省方法的设计目的和优势在于:
l 为了接口的演化
l Java 8 之前我们知道,一个接口的所有方法其子类必须实现(当然,这个子类不是一个抽象类),但是 java 8 之后接口的默认方法可以选择不实现,如上的操作是可以通过编译期编译的。这样就避免了由 Java 7 升级到 Java 8 时项目编译报错了。Java8在核心集合接口中增加了许多新的缺省方法,主要是为了便于使用lambda。
l 可以减少第三方工具类的创建
l 例如在 List 等集合接口中都有一些默认方法,List 接口中默认提供 replaceAll(UnaryOperator)、sort(Comparator)、、spliterator()等默认方法,这些方法在接口内部创建,避免了为了这些方法而专门去创建相应的工具类。
l 可以避免创建基类
l 在 Java 8 之前我们可能需要创建一个基类来实现代码复用,而默认方法的出现,可以不必要去创建基类。
由于接口的局限性和设计目的的不同,接口并不能完全替换抽象类。但是通过对接口提供一个抽象的骨架实现类,可以把接口和抽象类的优点结合起来。 接口负责定义类型,或许还提供一些缺省方法,而骨架实现类则负责实现除基本类型接口方法之外,剩下的非基本类型接口方法。扩展骨架实现占了实现接口之外的大部分工作。这就是模板方法(Template Method)设计模式。

接口Protocol:定义了RPC协议层两个主要的方法,export暴露服务和refer引用服务
抽象类AbstractProtocol:封装了暴露服务之后的Exporter和引用服务之后的Invoker实例,并实现了服务销毁的逻辑
具体实现类XxxProtocol:实现export暴露服务和refer引用服务具体逻辑。
16.优先考虑泛型
声明中具有一个或者多个类型参数(type parameter)的类或者接口,就是泛型(generic)类或者接口。泛型类和接口统称为泛型(generic type)。泛型从Java 5引入,提供了编译时类型安全检测机制。泛型的本质是参数化类型,通过一个参数来表示所操作的数据类型,并且可以限制这个参数的类型范围。泛型的好处就是编译期类型检测,避免类型转换。
java
// 比较三个值并返回最大值
public static <T extends Comparable> T maximum(T x, T y, T z) {
T max = x;
</T extends Comparable// 假设x是初始最大值
if ( y.compareTo( max ) > 0 ) {
max = y; //y 更大
} if ( z.compareTo( max ) > 0 ) {
max = z; // 现在 z 更大
} return max; // 返回最大对象
}
public static void main( String args[] ) {
System.out.printf( "%d, %d 和 %d 中最大的数为 %d\n\n", 3, 4, 5, maximum( 3, 4, 5 ));
System.out.printf( "%.1f, %.1f 和 %.1f 中最大的数为 %.1f\n\n", 6.6, 8.8, 7.7, maximum( 6.6, 8.8, 7.7 ));
System.out.printf( "%s, %s 和 %s 中最大的数为 %s\n","pear", "apple", "orange", maximum( "pear", "apple", "orange" ) );
}
17.不要使用原生态类型
由于为了保持Java代码的兼容性,支持和原生态类型转换,并使用擦除机制实现的泛型。但是使用原生态类型就会失去泛型的优势,会受到编译器警告。
18.要尽可能地消除每一个非受检警告
每一条警告都表示可能在运行时抛出ClassCastException异常。要尽最大的努力去消除这些警告。如果无法消除但是可以证明引起警告的代码是安全的,就可以在尽可能小的范围中,使用@SuppressWarnings("unchecked")注解来禁止警告,但是要把禁止的原因记录下来。
19.利用有限制通配符来提升API的灵活性
参数化类型不支持协变的,即对于任何两个不同的类型Type1和Type2而言,List既不是List的子类型,也不是它的超类。为了解决这个问题,提高灵活性,Java提供了一种特殊的参数化类型,称作有限制的通配符类型,即List和List。使用原则是producer-extends,consumer-super(PECS)。如果即是生产者,又是消费者,就没有必要使用通配符了。
还有一种特殊的无限制通配符List,表示某种类型但不确定。常用作泛型的引用,不可向其添加除Null以外的任何对象。
java
//List
// Number 可以认为 是Number 的 "子类"
ListNumber> numberArray = new ArrayList<Number>();
// Integer 是 Number 的子类
ListNumber> numberArray = new ArrayList();
// Double 是 Number 的子类
ListNumber> numberArray = new ArrayList();
//List
// Integer 可以认为是 Integer 的 "父类"
Listsuper Integer> array = new ArrayList();、
// Number 是 Integer 的 父类
Listsuper Integer> array = new ArrayList<Number>();
// Object 是 Integer 的 父类
Listsuper Integer> array = new ArrayList<Object>();
public static void copy(Listsuper T> dest, List src) {
int srcSize = src.size();
if (srcSize > dest.size())
throw new IndexOutOfBoundsException("Source does not fit in dest");
if (srcSize < COPY_THRESHOLD || (src instanceof RandomAccess && dest instanceof RandomAccess)) {
for (int i=0; i <srcSize; i++)
dest.set(i, src.get(i));
} </srcSize; i++)
else {
ListIteratorsuper T> di=dest.listIterator();
ListIterator si=src.listIterator();
for (int i=0; i <srcSize; i++) {
di.next();
di.set(si.next());
}
}
} </srcSize; i++) {
20.静态成员类优于非静态成员类
嵌套类(nested class)是指定义在另一个类的内部的类。嵌套类存在的目的只是为了它的外部类提供服务,如果其他的环境也会用到的话,应该成为一个顶层类(top-level class)。 嵌套类有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名类(anonymous class)和 局部类(local class)。除了第一种之外,其他三种都称为内部类(inner class)。
21.匿名类(anonymous class)
没有名字,声明的同时进行实例化,只能使用一次。当出现在非静态的环境中,会持有外部类实例的引用。通常用于创建函数对象和过程对象,不过现在会优先考虑lambda。
22.局部类(local class)
任何可以声明局部变量的地方都可以声明局部类,同时遵循同样的作用域规则。跟匿名类不同的是,有名字可以重复使用。不过实际很少使用局部类。
23.静态成员类(static member class)
最简单的一种嵌套类,声明在另一个类的内部,是这个类的静态成员,遵循同样的可访问性规则。常见的用法是作为公有的辅助类,只有与它的外部类一起使用才有意义。
24.非静态成员类(nonstatic member class)
尽管语法上,跟静态成员类的唯一区别就是类的声明不包含static,但两者有很大的不同。非静态成员类的每个实例都隐含地与外部类的实例相关联,可以访问外部类的成员属性和方法。另外必须先创建外部类的实例之后才能创建非静态成员类的实例。
总而言之,这四种嵌套类都有自己的用途。假设这个嵌套类属于一个方法的内部,如果只需要在一个地方创建实例,并且已经有了一个预置的类型可以说明这个类的特征,就要把它做成匿名类。如果一个嵌套类需要在单个方法之外仍然可见,或者它太长了,不适合放在方法内部,就应该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的,否则就做成静态的。
25.优先使用模板/工具类
通过对常见场景的代码逻辑进行抽象封装,形成相应的模板工具类,可以大大减少重复代码,专注于业务逻辑,提高代码质量。
26.分离对象的创建与使用
面向对象编程相对于面向过程,多了实例化这一步,而对象的创建必须要指定具体类型。我们常见的做法是"哪里用到,就在哪里创建",使用实例和创建实例的是同一段代码。这似乎使代码更具有可读性,但是某些情况下造成了不必要的耦合。
java
public class BusinessObject {
public void actionMethond {
//Other things
Service myServiceObj = new Service();
myServiceObj.doService();
//Other things
}
}
public class BusinessObject {
public void actionMethond {
//Other things
Service myServiceObj = new ServiceImpl();
myServiceObj.doService();
//Other things
}
}
public class BusinessObject {
private Service myServiceObj;
public BusinessObject(Service aService) {
myServiceObj = aService;
}
public void actionMethond {
//Other things
myServiceObj.doService();
//Other things
}
}
public class BusinessObject {
private Service myServiceObj;
public BusinessObject() {
myServiceObj = ServiceFactory;
}
public void actionMethond {
//Other things
myServiceObj.doService();
//Other things
}
}
对象的创建者耦合的是对象的具体类型,而对象的使用者耦合的是对象的接口。也就是说,创建者关心的是这个对象是什么,而使用者关心的是它能干什么。这两者应该视为独立的考量,它们往往会因为不同的原因而改变。
当对象的类型涉及多态、对象创建复杂(依赖较多)可以考虑将对象的创建过程分离出来,使得使用者不用关注对象的创建细节。设计模式中创建型模式的出发点就是如此,实际项目中可以使用工厂模式、构建器、依赖注入的方式。
27.可访问性最小化
区分一个组件设计得好不好,一个很重要的因素在于,它对于外部组件而言,是否隐藏了其内部数据和实现细节。Java提供了访问控制机制来决定类、接口和成员的可访问性。实体的可访问性由该实体声明所在的位置,以及该实体声明中所出现的访问修饰符(private、protected、public)共同决定的。
对于顶层的(非嵌套的)类和接口,只有两种的访问级别:包级私有的(没有public修饰)和公有的(public修饰)。
对于成员(实例/域、方法、嵌套类和嵌套接口)有四种的访问级别,可访问性如下递增:
私有的(private修饰)--只有在声明该成员的顶层类内部才可以访问这个成员;
包级私有的(默认)--声明该成员的包内部的任何类都可以访问这个成员;
受保护的(protected修饰)--声明该成员的类的子类可以访问这个成员,并且声明该成员的包内部的任何类也可以访问这个成员;
公有的(public修饰)--在任何地方都可以访问该成员;
正确地使用这些修饰符对于实现信息隐藏是非常关键的,原则就是:尽可能地使每个类和成员不被外界访问(私有或包级私有)。这样好处就是在以后的发行版本中,可以对它进行修改、替换或者删除,而无须担心会影响现有的客户端程序。
如果类或接口能够做成包级私有的,它就应该被做成包级私有的;
如果一个包级私有的顶层类或接口只是在某一个类的内部被用到,就应该考虑使它成为那个类的私有嵌套类;
公有类不应直接暴露实例域,应该提供相应的方法以保留将来改变该类的内部表示法的灵活性;
当确定了类的公有API之后,应该把其他的成员都变成私有的;
如果同一个包下的类之间存在比较多的访问时,就要考虑重新设计以减少这种耦合;
28.可变性最小化
不可变类是指其实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例时提供,并在对象的整个生命周期内固定不变。不可变类好处就是简单易用、线程安全、可自由共享而不容易出错。Java平台类库中包含许多不可变的类,比如String、基本类型包装类、BigDecimal等。
为了使类成为不可变,要遵循下面五条规则:
声明所有的域都是私有的
声明所有的域都是final的
如果一个指向新创建实例的引用在缺乏同步机制的情况下,从一个线程被传递到另一个线程,就必须确保正确的行为
不提供任何会修改对象状态的方法
保证类不会被扩展(防止子类化,类声明为final)
防止粗心或者恶意的子类假装对象的状态已经改变,从而破坏该类的不可变行为
确保对任何可变组件的互斥访问
如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法中返回该对象引用。在构造器、访问方法和readObject 方法中使用保护性拷贝技术
可变性最小化的一些建议:
除非有很好的理由要让类成为可变的类,否则它就应该是不可变的;
如果类不能被做成不可变的,仍然应该尽可能地限制它的可变性;
除非有令人信服的理由要使域变成非final的,否则要使每个域都是private final的;
构造器应该创建完全初始化的对象,并建立起所有的约束关系;
29.写好代码注释
在《代码简洁之道》这本书中作者提到了一个观点,注释的恰当用法是用来弥补我们在用代码表达意图时的失败。换句话说,当无法通过读代码来了解代码所表达的意思的时候,就需要用注释来说明。
作者之所以这么说,是因为作者觉得随着时间的推移,代码可能会变动,如果不及时更新注释,那么注释就容易产生误导,偏离代码的实际意义。而不及时更新注释的原因是,程序员不喜欢写注释。
但是这不意味着可以不写注释,当通过代码如果无法表达意思的时候,就需要注释,比如如下代码:
java
for (Integer id : ids) {
if (id == 0) {
continue;
}
//做其他事
}
为什么 id == 0 需要跳过,代码是无法看出来了,就需要注释了。
好的注释应当满足以下几点:
解释代码的意图,说明为什么这么写,用来做什么
对参数和返回值注释,入参代表什么,出参代表什么
有警示作用,比如说入参不能为空,或者代码是不是有坑
当代码还未完成时可以使用 todo 注释来注释
30.try catch 内部代码抽成一个方法
try catch 代码有时会干扰我们阅读核心的代码逻辑,这时就可以把 try catch 内部主逻辑抽离成一个单独的方法
如下图是 Eureka 服务端源码中服务下线的实现中的一段代码:

整个方法非常长,try 中代码是真正的服务下线的代码实现,finally 可以保证读锁最终一定可以释放。
所以这段代码其实就可以对核心的逻辑进行抽取:

31.方法别太长
方法别太长就是字面的意思。一旦代码太长,给人的第一眼感觉就很复杂,让人不想读下去;同时方法太长的代码可能读起来容易让人摸不着头脑,不知道哪一些代码是同一个业务的功能。所以一旦方法过长,可以尝试将相同业务功能的代码单独抽取一个方法,最后在主方法中调用即可。一般要求一个方法不要超过80行。
32.多用return
在有时我们平时写代码的情况可能会出现 if 条件套 if 的情况,当 if 条件过多的时候可能会出现如下情况:

面对这种情况,可以换种思路,使用卫语句,先判断非正常情况, return 来优化:

33.if 条件表达式不要太复杂
比如在如下代码:
java
if (((StringUtils.isBlank(person.getName())
|| "三友的java日记".equals(person.getName()))
&& (person.getAge() != null && person.getAge() > 10))
&& "汉".equals(person.getNational())) {
// 处理逻辑
}
这段逻辑,这种条件表达式很难让人理解是什么意思,且容易在后续修改时,因为太复杂,修改错误,这时就可以这么优化:
java
boolean sanyouOrBlank = StringUtils.isBlank(person.getName()) || "三友的java日记".equals(person.getName());
boolean ageGreaterThanTen = person.getAge() != null && person.getAge() > 10;
boolean isHanNational = "汉".equals(person.getNational());
if (sanyouOrBlank && ageGreaterThanTen && isHanNational) {
// 处理逻辑
}
34.优雅地参数校验
参数校验原则:入口参数校验是必须的,对于repo这层,如果是入参是分片键这种情况,那分片键都没有就应该直接阻断。但是如果不是分片键这种场景,应该是repo层的查询接口要分类,唯一条件查询的(id,custId等),应该跟一般条件查询的(时间、类型等)接口区分开,且一般条件查询的最好分页。对于要查询所有的,如果量不大,只要在上层对每种场景预估一个不可能超过的值传入即可,且这个是必传字段。如果量大,严格按分页的方式写。
当前端传递给后端参数的时候,通常需要对参数进场检验,校验分两种:格式校验、业务校验。
格式验证
一般可能会这么写:

这种写虽然可以,但是当字段的多的时候,光校验就占据了很长的代码,不够优雅。
针对格式校验这个问题,有第三方库已经封装好了,比如 hibernate-validator 框架,只需要拿来用即可。
所以就在实体类上加 @NotBlank、@NotNull等 注解来进行校验:

此时 Controller 接口就需要方法上就需要加上 @Valid 注解:
java
@PostMapping
public void addPerson(@RequestBody @Valid AddPersonRequest addPersonRequest) {
// 处理新增逻辑
}
对于dubbo接口则需要在接口配置中加入校验属性:
dubbo.provider.validation=true代表在接口提供方校验,
也可以指定 dubbo.consumer.validation=true,在消费端校验。
业务验证
指的是有业务含义的验证。如:用户号唯一,修改后的邮箱与已有邮箱号冲突等。
业务验证也使用跟格式验证类似的方式,只不过需要我们自定义校验注解。案例如下:
自定义注解

自定义注解逻辑

使用自定义注解

35.统一返回值
后端在设计接口的时候,需要统一返回值:

不仅是给前端参数,也包括提供给第三方的接口等,这样接口调用方法可以按照固定的格式解析代码,不用进行判断。如果不一样,相信我,前端半夜都一定会来找你。
Spring 中很多方法可以做到统一返回值,而不用每个方法都返回,比如基于 AOP,或者可以自定义 HandlerMethodReturnValueHandler 来实现统一返回值。
36.统一异常处理
当你没有统一异常处理的时候,那么所有的接口避免不了 try catch 操作。

每个接口都得这么玩,那到处都是 try catch。
所以可以基于 Spring 提供的统一异常处理机制来完成。
37.尽量使用工具类
很多时候不要自己重复造轮子,多使用常用的工具类。
比如在对集合判空的时候,可以这么写:

但是一般不推荐这么写,可以通过一些判断的工具类来写:

再比如格式化日期来来说,我们如果自己实现,比如如下代码:
java
private static final SimpleDateFormat DATE_TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String formatDateTime(Date date) {
return DATE_TIME_FORMAT.format(date);
}
这段代码看似没啥问题,但是却忽略了 SimpleDateFormat 是个线程不安全的类,所以这就会引起坑。
一般对于这种已经有开源的项目并且已经做得很好的时候,比如 Hutool,就可以把轮子直接拿过来用了。
38.面向接口编程
在一些可替换的场景中,应该引用父类或者抽象,而非实现。
举个例子,在实际项目中可能需要对一些图片进行存储,但是存储的方式很多,比如可以选择阿里云的 OSS,又或者是七牛云,存储服务器等等。所以对于存储图片这个功能来说,这些具体的实现是可以相互替换的。
所以在项目中,我们不应当在代码中耦合一个具体的实现,而是可以提供一个存储接口:

如果选择了阿里云 OSS 作为存储服务器,那么就可以基于 OSS 实现一个 FileStorage,在项目中哪里需要存储的时候,只要实现注入这个接口就可以了。

假设用了一段时间之后,发现阿里云的 OSS 比较贵,此时想换成七牛云的,那么此时只需要基于七牛云的接口实现 FileStorage 接口,然后注入到 IOC,那么原有代码用到 FileStorage 根本不需要动,实现轻松的替换。
39.null 值判断
空指针是代码开发中的一个难题,作为程序员的基本修改,应该要防止空指针。
可能产生空指针的原因:
数据返回对象为 null
自动拆箱导致空指针
rpc 调用返回的对象可能为空格
所以在需要这些的时候,需要强制判断是否为 null。前面也提到可以使用 Optional 来优雅地进行 null 值判断。
40.魔法值用常量表示

代码里,广东省就是一个魔法值,那么就可以将用一个常量来保存(这里最好是用一个常量类(接口)去分类定义这些常量):

41.涉及线程间可见性加 volatile
在 RocketMQ 源码中有这么一段代码:

在消费者在从服务端拉取消息的时候,会单独开一个线程,执行 while 循环,只要 stopped 状态一直为 false,那么就会一直循环下去,线程就一直会运行下去,拉取消息。
当消费者客户端关闭的时候,就会将 stopped 状态设置为 true,告诉拉取消息的线程需要停止了。但是由于并发编程中存在可见性的问题,所以虽然客户端关闭线程将 stopped 状态设置为 true,但是拉取消息的线程可能看不见,不能及时感知到数据的修改,还是认为 stopped 状态设置为 false,那么就还会运行下去。
针对这种可见性的问题,java 提供了一个 volatile 关键字来保证线程间的可见性。

所以,源码中就加了 volatile 关键字。
加了 volatile 关键字之后,一旦客户端的线程将 stopped 状态设置为 true 时候,拉取消息的线程就能立马知道 stopped 已经是 false 了,那么再次执行 while 条件判断的时候,就不成立,线程就运行结束了,然后退出。
42.锁的使用规范
1、减小锁使用范围
减小锁的范围就是给需要加锁的代码加锁,不需要加锁的代码不要加锁。这样就能减少加锁的时间,从而可以较少锁互斥的时间,提高效率。

比如 CopyOnWriteArrayList 的 addAll 方法的实现,lock.lock(); 代码完全可以放到代码的第一行,但是作者并没有,因为前面判断的代码不会有线程安全的问题,不放到加锁代码中可以减少锁抢占和占有的时间。
2、一定要释放锁
任何锁一定要在finally中主动释放锁。
3、分布式锁一定要设置过期时间
锁一定要设置过期时间,且时间一般会根据具体业务场景预估一下加锁需要进行的操作最长耗时,然后在最长耗时基础上再加一个buffer(一般为这个业务操作时长或它的一半)的时间来确定。
4、分布式锁一定要设置超时时间
一定要设置获取锁的超时时间,视情况而定,一般可跟锁过期时间设置成一样或略大于锁过期时间;
43.有类型区分时定义好枚举
比如在项目中不同的类型的业务可能需要上传各种各样的附件,此时就可以定义好不同的一个附件的枚举,来区分不同业务的附件。
不要在代码中直接写死,不定义枚举,代码阅读起来非常困难,直接看到数字都是懵逼的。
44.远程接口调用设置超时时间
比如在进行微服务之间进行 rpc 调用的时候,又或者在调用第三方提供的接口的时候,需要设置超时时间,防止因为各种原因,导致线程"卡死"在那。
45.集合使用应当指明初始化大小
比如在写代码的时候,经常会用到 List、Map 来临时存储数据,其中最常用的就是 ArrayList 和 HashMap。但是用不好可能也会导致性能的问题。
比如说,在 ArrayList 中,底层是基于数组来存储的,数组是一旦确定大小是无法再改变容量的。但不断的往 ArrayList 中存储数据的时候,总有那么一刻会导致数组的容量满了,无法再存储其它元素,此时就需要对数组扩容。所谓的扩容就是新创建一个容量是原来 1.5 倍的数组,将原有的数据给拷贝到新的数组上,然后用新的数组替代原来的数组。
在扩容的过程中,由于涉及到数组的拷贝,就会导致性能消耗;同时 HashMap 也会由于扩容的问题,消耗性能。所以在使用这类集合时可以在构造的时候指定集合的容量大小。
46.尽量不要使用 BeanUtils 来拷贝属性
在开发中经常需要对 JavaBean 进行转换,但是又不想一个一个手动 set,比较麻烦,所以一般会使用属性拷贝的一些工具,比如说 Spring 提供的 BeanUtils 来拷贝。不得不说,使用 BeanUtils 来拷贝属性是真的舒服,使用一行代码可以代替几行甚至十几行代码,我也喜欢用。
但是喜欢归喜欢,但是会带来性能问题,因为底层是通过反射来的拷贝属性的,所以尽量不要用 BeanUtils 来拷贝属性。
比如你可以装个 JavaBean 转换的插件,帮你自动生成转换代码;又或者可以使用性能更高的 MapStruct 来进行 JavaBean 转换,MapStruct 底层是通过调用(settter/getter)来实现的,而不是反射来快速执行。
47.使用 StringBuilder 进行字符串拼接
如下代码:

使用 + 拼接字符串的时候,会创建一个 StringBuilder,然后将要拼接的字符串追加到 StringBuilder,再 toString,这样如果多次拼接就会执行很多次的创建 StringBuilder,z 执行 toString 的操作。
所以可以手动通过 StringBuilder 拼接,这样只会创建一次 StringBuilder,效率更高。

48.不循环调用数据库
不要在循环中访问数据库,这样会严重影响数据库性能。
比如需要查询一批人员的信息,人员的信息存在基本信息表和扩展表中,错误的代码如下:
java
public List selectPersons(List personIds) {
List persons = new ArrayList<>(personIds.size());
List personList = personMapper.selectByIds(personIds);
for (Person person : personList) {
PersonVO vo = new PersonVO();
PersonExt personExt = personExtMapper.selectById(person.getId());
// 组装数据
persons.add(vo);
}
return persons;
}
遍历每个人员的基本信息,去数据库查找。
正确的方法应该先批量查出来,然后转成 map:
java
public List selectPersons(List personIds) {
List persons = new ArrayList<>(personIds.size());
List personList = personMapper.selectByIds(personIds);
//批量查询,转换成Map
List personExtList = personExtMapper.selectByIds(person.getId());
Map personExtMap = personExtList.stream().collect(Collectors.toMap(PersonExt::getPersonId, Function.identity()));
for (Person person : personList) {
PersonVO vo = new PersonVO();
//直接从Map中查找
PersonExt personExt = personExtMap.get(person.getId());
// 组装数据
persons.add(vo);
}
return persons;
}
49.用业务代码代替多表 join
如上面代码所示,原本也可以将两张表根据人员的 id 进行关联查询。但是不推荐这么,阿里也禁止多表 join 的操作

而之所以会禁用,是因为 join 的效率比较低。
MySQL 是使用了嵌套循环的方式来实现关联查询的,也就是 for 循环会套 for 循环的意思。用第一张表做外循环,第二张表做内循环,外循环的每一条记录跟内循环中的记录作比较,符合条件的就输出,这种效率肯定低。而且多表join的话,如果某个表更改了,这次查询在mysql的缓存就会失效。
50.日志打印
跑批开始结束需要打日志;
系统调用外部接口及提供外部调用接口的关键出入参需要打印日志;
逻辑未往下执行时的逻辑中止需要打印日志;
异常情况下需要打日志(如果已经抛出业务异常就不需要打印错误日志,统一在上层统一处理异常的地方打印);
视错误的具体影响打印error或者warn,出现一次就必须立马关注,排查解决问题的,打印error。出现次数达到一定量才需要关注的,打印warn;
前端调用后端的接口需要打印入参日志(简单查询类的可不打印);
日志需要明确输出业务实体id(如流水号、客户id等)、场景,能辅助排查问题;
日志不要打印大实体,特别是请求量大的场景,避免直接直接通过JSON.toJSONString()输出日志;
51.代码分层
代码如果不分层,不按层划分职责,就非常容易出现逻辑散落,代码冗余无法复用的情况,最后就是代码一变动,就出现bug。
一般我们会将代码按接入层,应用层,服务层,基础设施层划分。
接入层:它是各种程序入口,如controller(web服务入口)、listener(消息接收入口)、batch(跑批调度入口)等,它只是很薄的一层对应用层服务的引用,不能包含业务逻辑;
应用层:它是存放含有业务逻辑的服务,是对服务层的领域服务的组装;
服务层:它包含领域聚合及领域服务,它不能包含业务逻辑,只包含领域聚合的行为;
基础设施层:它主要是包含基础技术的实现,比如数据库操作,消息,邮件发送等;
代码示例:
接入层:

应用层:

服务层:

基础设施层:

52.事务最小化
加事务的代码范围要控制到最小,以免产生长事务,造成数据库层面的长时间锁等待,最终导致应用或数据库崩溃。
事务注解方法中不要包含外部调用。如果数据库事务级别是RC,那么数据库查询也没必要包在事务中,除非是带for update的查询(但是不建议这样写查询语句),即使事务级别是RR,也只是在需要事务开始时的快照一致性场景才需要把读取包在事务中。