如何code review代码?
代码code review 从大处着眼可以从可读性、可维护性、可扩展、可复用、可测试等方面来衡量;落实到具体细节,可以从非功能性和功能性两个方面来进行衡量。
非功能性
- 目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚,低耦合”特性;
- 是否遵循经典设计原则与设计思想,如:SOLID、DRY、KISS、YAGNI和LOD等;
- 设计模式是否应用合理,是否过度设计;
- 代码是否易扩展,是否满足开闭原则;
- 代码是否可复用,是否可以复用已有代码或类库,是否存在重复造轮子;
- 代码是否易测试,UT对正常和异常边界情况是否覆盖全面;
- 代码是否可读,是否满足编码规范,如:命名是否准确达意、注释是否恰当、代码风格是否一致、编程是否存在多层嵌套,复杂逻辑或多个入参情况是否进行拆分或封装等。
功能性
- 代码设计是否符合功能预期;
- 逻辑是否正确,异常边界情况是否处置合理;
- 日志打印是否得当,是否方便排查问题?
- 接口是否易用,是否支持CUD场景事务和幂等操作;
- 并发场景下代码是否线程安全,是否存在共享变量;
- 性能是否有优化空间,如:SQL、算法是否可以继续优化;
- 是否存在安全漏洞,输入、输出校验是否全面合理;
案例
需求
为了方便排查问题,请设计一个ID 生成器,每次请求会将生成的ID 保存在 Servlet 线程的 ThreadLocal 中,每次打印日志的时候,我们从请求上下文中取出请求 ID,跟日志一块输出。这样,同一个请求的所有日志都包含同样的请求 ID 信息,我们就可以通过请求 ID 来搜索同一个请求的所有日志了。
实现
@Slf4j
public class IdGenerator {
/**
* 获取请求ID
*
* @return
*/
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){
log.warn("Failed to get the host name.",e);
}
return id;
}
}
优化
非功能分析
待优化名称 | 是否需要优化 | 备注 |
---|---|---|
非功能 1 | 否 | IdGenerator只有一个类,不涉及目录设置、模块划分和代码结构 |
非功能 2 | 否 | IdGenerator只有一个类,不涉及设计原则和设计思想 |
非功能 3 | 否 | IdGenerator只有一个类,没有使用设计模式,不存在过度设计。 |
非功能 4 | 是 | IdGenerator设计成实现类而非接口形式,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想,不过目前场景下也满足需求,如果或者有其他常见的ID生成则需要进行改动。 |
非功能 5 | 否 | |
非功能 6 | 是 | IdGenerator.generate()为静态函数不利于测试,同时依赖运行环境(本机名)、时间函数、随机函数也不利于测试 |
非功能 7 | 是 | 代码的可读性不好,一方面没有注释,另一方面存在大量魔法值,随机串部分生成逻辑比较复杂不宜理解。 |
功能分析
待优化名称 | 是否需要优化 | 备注 |
---|---|---|
功能 1 | 否 | 虽然生成ID并非绝对唯一,但是对于追踪打印日志来说,是可以接受小概率 ID 冲突的,满足我们预期的业务需求 |
功能 2 | 是 | 获取hostName部分,没有处理hostName == null的情况,同时针对异常的处理也有问题。null和异常处理可以参考异常处理部分。 |
功能 3 | 否 | 日志的作用是方便debug排查问题,此处日志打印是合理。 |
功能 4 | 否 | IdGenerator 只暴露一个 generate() 接口供调用者使用,接口的定义简单明了,不存在不易用问题 |
功能 5 | 否 | generate()函数不存在共享变量,线程安全,并发场景下不存在线程安全问题。 |
功能 6 | 是 | 生成ID需要获取本机名,获取本机名比较耗时;同时生成随机字符串极端情况下需要循环多次才能生成符合要求的字符串(09,az,A~Z),也需要优化下。 |
功能 7 | 否 |
异常处理
函数出错返回数据类型,我总结了 4 种情况分别是:错误码、NULL 值、空对象、异常对象。
错误码
在没有异常语法机制的语言中,常用错误码来处理错误,比如:code >= 0 表示接口调用成功;code < 0表示接口调用失败;同时code值也可以赋予特殊意义。
NULL值
在多数编程语言中,我们用 NULL 来表示“不存在”这种语义。对于查找函数来说,数据不存在并非一种异常情况,是一种正常行为,所以返回表示不存在语义的 NULL 值比返回异常更加合理。
空对象
返回 NULL 值有各种弊端,对此有一个比较经典的应对策略,那就是应用空对象设计模式。当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代 NULL 值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 NULL 值判断。
异常对象
对于接口抛出的异常,我们有三种处理方法:直接吞掉、直接往上抛出、包裹成新的异常抛出。
适用场景
- 直接吞掉:如果 被调用方抛出的异常是可以恢复,的调用方并不关心此异常,我们完全可以在 调用方中抛出的异常吞掉;
- 直接往上抛出:如果被调用方抛出的异常对调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,我们可以选择直接将 被调用方抛出的异常 re-throw;
- 包裹成新的异常抛出:如果被调用方抛出的异常太底层,对调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的新异常,然后 re-throw。
优化计划
重构代码的过程需要遵循循序渐进,小步快跑思路。每次改动一点点,测试通过之后再进行下一部分。针对本次重构计划可以分成四部分进行,具体如下:
-
第一轮:提高可读性;
具体来说分别是:
- hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候;
- 将获取 hostName 的代码抽离出来,定义为 getLastfieldOfHostName() 函数;
- 删除代码中的魔法数;
- generate() 函数中的三个 if 逻辑重复了,且实现过于复杂,我们要对其进行简化;
- 对 IdGenerator 类重命名,并且抽象出对应的接口。
-
第二轮:提高可测试性;
具体来说分别是:
- generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性,需要改为非静态函数;
- generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数测试性不好需要进行对相应逻辑进行拆分和封装,具体来下:1、从 getLastfieldOfHostName() 函数中,将逻辑比较复杂的那部分代码剥离出来,定义为 getLastSubstrSplittedByDot() 函数;2、将 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 这两个函数的访问权限设置为 protected。这样做的目的是,可以直接在单元测试中通过对象来调用两个函数进行测试。3、给 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 两个函数添加@VisibleForTesting。告诉其他人说,这两个函数本该是 private 访问权限的,之所以提升访问权限到 protected,只是为了测试,只能用于单元测试中。
- 针对异常和特殊值进行适当处理,抛出调用方理解的异常。
-
第三轮:编写完成UT,写单元测试的时候,测试对象是函数定义的功能,而非具体的实现逻辑;
针对generate()函数可以有三个不同功能的定义;
- 如果我们把 generate() 函数的功能定义为:“生成一个随机唯一 ID”,那我们只要测试多次调用 generate() 函数生成的 ID 是否唯一即可。
- 如果我们把 generate() 函数的功能定义为:“生成一个只包含数字、大小写字母和中划线的唯一 ID”,那我们不仅要测试 ID 的唯一性,还要测试生成的 ID 是否只包含数字、大小写字母和中划线。
- 如果我们把 generate() 函数的功能定义为:“生成唯一 ID,格式为:{主机名 substr}-{时间戳}-{8 位随机数}。在主机名获取失败时,返回:null-{时间戳}-{8 位随机数}”,那我们不仅要测试 ID 的唯一性,还要测试生成的 ID 是否完全符合格式要求。
总结:UT如何写,关键看你如何定义函数。
第四轮:添加完整注释,注释主要就是写清楚:做什么、为什么、怎么做、怎么用,对一些边界条件、特殊情况进行说明,以及对函数输入、输出、异常进行说明。;
优化结果
/**
* IdGenerator接口 重构一 提高代码可扩展性
*/
public interface IdGenerator {
public String generate() throws IdGenerationFailureException;
}
/**
* LogTraceIdGenerator接口 重构一 提高代码可扩展性
*/
public interface LogTraceIdGenerator extends IdGenerator{
}
/**
* 用于生成随机 ID 的 Id 生成器。//重构四:添加注释
*
*
* 此类生成的 ID 不是绝对唯一的,
* 但重复的可能性非常低。
*/
@Slf4j
public class RandomIdGenerator implements LogTraceIdGenerator{//重构一 提高代码可扩展性
/**
* 生成随机 ID。只有在极端情况下,才能生成重复ID。
*
* @return随机 ID
*/
@Override
public String generate() throws IdGenerationFailureException {//重构一
String substrOfHostName = null;
try {
substrOfHostName = getLastfieldOfHostName();
} catch (UnknownHostException e) { //重构二:封装新异常继续抛出
throw new IdGenerationFailureException("host name is empty.");
}
//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;
}
/**
* 获取本地主机名和
* 提取由分隔符 '.' 拆分的名称字符串的最后一个字段。
*
* @return 主机名的最后一个字段。如果未获取主机名,则返回 null。
*/
private String getLastfieldOfHostName() throws UnknownHostException {// 重构一
String substrOfHostName = null;
//try {
String hostName = InetAddress.getLocalHost().getHostName();
//重构二 NULL判断
if (hostName == null || hostName.isEmpty()) {
throw new UnknownHostException();
}
//重构二 逻辑拆分
substrOfHostName = getLastSubstrSplittedByDot(hostName);
//String[] tokens = hostName.split("\\.");
//substrOfHostName = tokens[tokens.length -1];
//}catch (Exception e){////重构二:异常处理,直接抛出
// log.warn("Failed to get the host name.",e);
//}
return substrOfHostName;
}
/**
* 获取 {@hostName} 的最后一个字段,该字段由 delemiter '.' 拆分。
*
* @param hostName 主机名不应为空
* @return {@hostName} 的最后一个字段。如果 {@hostName} 为空字符串,则返回空字符串。
*/
@VisibleForTesting //重构二
protected String getLastSubstrSplittedByDot(String hostName) {//重构二 提供代码可测试性
//重构二:NULL 判断,如果传入NULL 则抛运行异常
if(hostName == null || hostName.isEmpty()){
throw new IllegalArgumentException("host name is empty");
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length -1];
return substrOfHostName;
}
/**
* 生成随机字符串,其中
* 仅包含数字、大写字母和小写字母。
*
* @param length 长度应不小于0
* @return 随机字符串 如果 {@length} 为 0 则返回空字符串
*
*/
@VisibleForTesting //重构二
protected String generateRandomAlphameric(int length) {//重构一 复杂逻辑拆分 //重构二 为了提高测试性,将private 改为 protected
if(length < 0){ //重构二 边界处理
throw new IllegalArgumentException("parameter length is illegal.");
}
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length){
int maxAscii = 'z';
int radomAscii = random.nextInt(maxAscii);
//重构一 提高代码可读性
boolean isDigit = radomAscii >= '0' && radomAscii < '9';
boolean isLowercase = radomAscii >= 'a' && radomAscii < 'z';
boolean isUppercase = radomAscii >= 'A' && radomAscii < 'Z';
if(isDigit || isLowercase || isUppercase){
randomChars[count] = (char) radomAscii;
count++;
}
}
return new String(randomChars);
}
}
/**
* RandomIdGeneratorTest类 重构三:编写UT
*/
public class RandomIdGeneratorTest {
@Test
public void testGetLastSubstrSplittedByDot(){
RandomIdGenerator generator = new RandomIdGenerator();
String actualSubstr = generator.getLastSubstrSplittedByDot("field1.field2.field3");
Assert.assertEquals("field3",actualSubstr);
actualSubstr = generator.getLastSubstrSplittedByDot("field1");
Assert.assertEquals("field1",actualSubstr);
actualSubstr = generator.getLastSubstrSplittedByDot("field1#field2#field3");
Assert.assertEquals("field1#field2#field3",actualSubstr);
}
@Test
public void testGetLastSubstrSplittedByDot_nullOrEmpty(){
RandomIdGenerator generator = new RandomIdGenerator();
String actualSubstr = generator.getLastSubstrSplittedByDot("");
Assert.assertEquals("",actualSubstr);
actualSubstr = generator.getLastSubstrSplittedByDot(null);
Assert.assertNull(actualSubstr);
}
@Test
public void testGenerateRandomAlphameric(){
RandomIdGenerator generator = new RandomIdGenerator();
String randomString = generator.generateRandomAlphameric(8);
Assert.assertNotNull(randomString);
Assert.assertEquals(8,randomString.length());
for (char s : randomString.toCharArray()) {
Assert.assertTrue((s >= '0' && s < '9') || (s >= 'a' && s < 'z') || (s >= 'A' && s < 'Z'));
}
}
@Test
public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero(){
RandomIdGenerator generator = new RandomIdGenerator();
String randomString = generator.generateRandomAlphameric(0);
Assert.assertEquals("",randomString);
randomString = generator.generateRandomAlphameric(-1);
Assert.assertNull(randomString);
}
/**
* generate() 函数的功能定义为:“生成唯一 ID,格式为:{主机名 substr}-{时间戳}-{8 位随机数}
*/
@Test
public void testGenerate(){
RandomIdGenerator generator = new RandomIdGenerator();
String id1 = generator.generate();
Assert.assertNotNull(id1);
Assert.assertTrue(id1.length() > 0);
for (char c: id1.toCharArray()) {
Assert.assertTrue((c == '-')||(c >= '0' && c < '9') || (c >= 'a' && c < 'z') || (c >= 'A' && c < 'Z'));
}
String id2 = generator.generate();
Assert.assertNotNull(id1);
Assert.assertTrue(id1.length() > 0);
for (char c: id2.toCharArray()) {
Assert.assertTrue((c == '-')||(c >= '0' && c < '9') || (c >= 'a' && c < 'z') || (c >= 'A' && c < 'Z'));
}
Assert.assertNotEquals(id1,id2);
}
/**
* generate() 函数在主机名获取失败时,返回:null-{时间戳}-{8 位随机数}”,测试生成的 ID 是否完全符合格式要求。
*/
@Test
public void testGenerate_withoutSubHostName(){
RandomIdGenerator generator = new RandomIdGenerator();
generator.getLastSubstrSplittedByDot(null);
String id = generator.generate();
Assert.assertNotNull(id);
Assert.assertTrue(id.length() > 0);
String[] split = id.split("-");
Assert.assertTrue(split[0] == null);
Assert.assertTrue(System.currentTimeMillis() == Long.valueOf(split[1]));
for (char c: split[2].toCharArray()) {
Assert.assertTrue((c >= '0' && c < '9') || (c >= 'a' && c < 'z') || (c >= 'A' && c < 'Z'));
}
}
}