前面学习了这么多重构相关的知识,比如:持续重构、单元测试、代码的可测试性、解耦、编码规范。都是在理论层面,而重构本身是一个实战的事儿,所以如何将这些理论运用到实践中至关重要,否则只能是纸上谈兵。今天用一个案例case来串联之前学习的重构,来做到融汇贯通。
CASE背景
后端开发中,为了方便在请求出错时排查问题,我们在编写代码的时候会在关键路径上打印日志。某个请求出错之后,希望能搜索出这个请求对应的所有日志,以此来查找问题的原因。而实际情况是,在日志文件中,不同请求的日志会交织在一起。如果没有东西来标识哪些日志属于同一个请求,就无法关联同一个请求的所有日志。每次打印日志的时候可以从请求上下文中取出请求 ID,跟日志一块输出。这样,同一个请求的所有日志都包含同样的请求 ID 信息,就可以通过请求 ID 来搜索同一个请求的所有日志了。ID生成器的简单设计的代码如下:
package com.example.jackson.service; import com.sun.org.slf4j.internal.Logger; import com.sun.org.slf4j.internal.LoggerFactory; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Random; /** 1. @author tianmaolin004 2. @date 2022/9/24 */ public class IdGenerator { private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class); 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) { logger.warn("Failed to get the host name.", e); } return id; } }
整个 ID 由三部分组成。第一部分是本机名的最后一个字段。第二部分是当前时间戳,精确到毫秒。第三部分是 8 位的随机字符串,包含大小写字母和数字,打印测试如下:
代码诊断
代码问题的排查分为常规检查和业务检查
常规检查CheckList
从通用的标准,也就是本专栏追求的最高目标就是:看这段代码是否可读、可扩展、可维护、灵活、简洁、可复用、可测试,具体的判断标准就是:
- 目录设置是否合理、模块划分是否清晰、代码结构是否合理,整体结构考虑OK么?
- 是否遵循经典的设计原则(SOLID、DRY、KISS、YAGNI、LOD)、设计思想(封装、继承、抽象、多态、控制反转、高内聚-松耦合、基于接口而非实现编程,多用组合少用继承),设计模式是否应用得当,是否有过度设计,有明显违反理论的地方么?
- 代码是否容易扩展,如果要添加新功能,是否容易实现,好改么,好加功能么?
- 代码是否可以复用,是否可以复用已有的项目代码或类库?是否有重复造轮子(违反DRY),能抽出来么,通用么?
- 代码是否容易测试,单元测试是否全面覆盖了各种正常和异常的情况,好测试么,mock难度高么?
- 代码是否易读,是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等),命名注释ok么,代码风格和编码技巧ok么?
第1条是从结构划分上考虑,第2条是从理论层面上考虑,后边4条是从编码感觉上考虑。说到设计原则,这里顺便再回顾下之前学习的设计原则
设计原则 | 应用范围 | 看待视角 | 解决什么问题 |
SRP单一职责原则 | 模块、类(接口)、方法 | 实体自身视角 | 提高代码的内聚性,可复用性、可读性、可维护性 |
OCP开闭原则 | 模块、类(接口)、方法 | 实体自身视角 | 提高代码的可扩展 |
LSP里氏替换原则 | 类(接口) | 父子间关系视角 | 指导子类设计、继承设计 |
ISP接口隔离原则 | 接口 | 实体间关系视角 | 降低调用者或使用者依赖,提升接口的可复用性、可读性、可维护性 |
DIP依赖反转原则 | 模块 | 框架设计视角 | 指导框架设计 |
KISS保持简单原则 | 方法 | 全局视角 | 提高代码的简洁性、可读性、可维护性 |
YAGNI勿过度设计原则 | 类(接口)、方法 | 全局视角 | 不过度设计只预留扩展防止降低代码的可读性和可维护性 |
DRY勿重复编码原则 | 类(接口)、方法 | 全局视角 | 去除重复代码 防止降低代码的可读性和可维护性 |
LOD迪米特法则 | 类(接口) | 实体间关系视角 | 防止类间过度依赖,降低代码的耦合性,提高代码的可读性和可维护性 |
参照常规检查对ID生成器进行检查
- IdGenerator 的代码比较简单,只有一个类,所以,不涉及目录设置、模块划分、代码结构问题
- IdGenerator 功能单一,不违反基本的 SOLID、DRY、KISS、YAGNI、LOD 等设计原则。它没有应用设计模式,所以也不存在不合理使用和过度设计的问题。不过IdGenerator 设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想
- IdGenerator功能单一,没有复杂分支,也暂时不需要扩展
- IdGenerator生成业务要求格式的唯一ID,所以没有重复造轮子
- IdGenerator 的
generate
函数定义为静态函数,会影响使用该函数的代码的可测试性。同时,generate
函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以generate
函数本身的可测试性也不好,需要做比较大的重构。代码可测试性不高,同时也没有单元测试 - 虽然 IdGenerator 只包含一个函数,并且代码行数也不多,但代码的可读性并不好。特别是随机字符串生成的那部分代码,一方面,代码完全没有注释,生成算法比较难读懂,另一方面,代码里有很多魔法数,严重影响代码的可读性。代码可读性差,不规范
以上为通用checklist检查出来的问题
业务检查CheckList
以上是一些通用的关注点,可以作为常规检查项,套用在任何代码的重构上。除此之外,我们还要关注代码实现是否满足业务本身特有的功能和非功能需求
- 代码是否实现了预期的业务需求,功能满足么?
- 逻辑是否正确,是否处理了各种异常情况,异常情况都hold住么,异常抛出方式合理么?
- 日志打印是否得当,是否方便 debug 排查问题,好查问题么?
- 接口是否易用,是否支持幂等、事务等,简明稳定么,可以重复调用么?
- 代码是否存在并发问题,是否线程安全,高并发下扛的住么?
- 性能是否有优化空间,比如,SQL、算法是否可以优化,性能还能更好么?
- 是否有安全漏洞,比如输入输出校验是否全面?质量高么,容易出错么?
有了以上的checklist就可以来校验代码到底行不行了。
- 虽然代码生成的 ID 并非绝对的唯一,但是对于追踪打印日志来说,是可以接受小概率 ID 冲突的,满足预期的业务需求
- 获取 hostName 这部分代码逻辑有点问题,并未处理hostName为空的情况,同时尽管代码中针对获取不到本机名的情况做了异常处理,但是对异常的处理是在 IdGenerator 内部将其吐掉,然后打印一条报警日志,并没有继续往上抛出,没有处理所有异常情况,异常抛出的方式也不合理
- 日志打印得当,日志描述能够准确反应问题,方便 debug,并且没有过多的冗余日志
- IdGenerator 只暴露一个 generate() 接口供使用者使用,接口的定义简单明了,不存在不易用问题
- generate函数代码中没有涉及共享变量,所以代码线程安全,多线程环境下调用
generate
函数不存在并发问题 - ID 的生成不依赖外部存储,在内存中生成,并且日志的打印频率也不会很高,所以性能没有大问题,有些小问题
- 每次生成 ID 都需要获取本机名,获取主机名会比较耗时,这部分可以考虑优化一下。
- randomAscii 的范围是
0~122
,但可用数字仅包含三段子区间(0~9,a~z,A~Z)
,极端情况下会随机生成很多三段区间之外的无效数字,需要循环很多次才能生成随机字符串,所以随机字符串的生成算法也可以优化一下
- 没有输入参数,没有大的安全漏洞。
经过常规和业务两轮代码诊断后,可以着手重构处理这些问题
代码重构
问题分析出来后,开始基于重构的手段进行重构。重构要循序渐进、小步快跑,每次改动一点点,改好之后,再进行下一轮的优化,保证每次对代码的改动不会过大,能在很短的时间内完成。
- 第一轮重构:提高代码的可读性:扩展接口,命名优化,代码优化
- 第二轮重构:提高代码的可测试性:干掉静态方法,剥离依赖,减少方法未决行为
- 第三轮重构:编写完善的单元测试
- 第四轮重构:提高代码的可读性:重构拆分方法后添加注释
- 第五轮重构:正确处理异常和抛出异常
接下来按照重构计划有序执行!
第一轮重构:提高代码可读性
首先,要解决最明显、最急需改进的代码可读性问题。具体有下面几点:
- 对 IdGenerator 类重命名,并且抽象出对应的接口。
IdGenerator
为整体功能接口,LogTraceIdGenerator
继承IdGenerator
,从功能的角度上进行细分,然后实现类从实现方式的角度实现接口LogTraceIdGenerator
,命名为RandomIdGenerator
即可,实现方式还可以拓展,例如:SequenceIdGenerator
- hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候;
- 将获取 hostName 的代码抽离出来,定义为
getLastfieldOfHostName
函数;把代码分割成更小的单元块 - 删除代码中的魔法数,比如,57、90、97、122;使用解释型变量
- 将随机数生成的代码抽离出来,定义为
generateRandomAlphameric
函数;把代码分割成更小的单元块 - generate() 函数中的三个 if 逻辑重复且实现过于复杂,要对其进行简化;使用解释型变量
这部分对应于上篇规范相关Blog: 【Java设计模式 规范与重构】 四 小型重构的手段:规范的二十条军规,整体代码重构如下:
package com.example.jackson.service; /** * @author tianmaolin004 * @date 2022/9/24 */ public interface IdGenerator { String generate(); }
package com.example.jackson.service; /** * @author tianmaolin004 * @date 2022/9/24 */ public interface LogTraceIdGenerator extends IdGenerator{ }
package com.example.jackson.service; import com.sun.org.slf4j.internal.Logger; import com.sun.org.slf4j.internal.LoggerFactory; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Random; /** * @author tianmaolin004 * @date 2022/9/24 */ public class RandomIdGenerator implements LogTraceIdGenerator { private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class); @Override public String generate() { String substrOfHostName = getLastFieldOfHostName(); long currentTimeMillis = System.currentTimeMillis(); String randomString = generateRandomAlphameric(8); return String.format("%s-%d-%s", substrOfHostName, currentTimeMillis, randomString); } private String getLastFieldOfHostName() { try { String hostName = InetAddress.getLocalHost().getHostName(); String[] tokens = hostName.split("\\."); return tokens[tokens.length - 1]; } catch (UnknownHostException e) { logger.warn("Failed to get the host name.", e); } return null; } private String generateRandomAlphameric(int length) { char[] randomChars = new char[length]; int count = 0; Random random = new Random(); while (count < length) { int randomAscii = random.nextInt('z'); boolean isDigit = randomAscii >= '0' && randomAscii <= '9'; boolean isUppercase = randomAscii >= 'A' && randomAscii <= 'Z'; boolean isLowercase = randomAscii >= 'a'; if (isDigit || isUppercase || isLowercase) { randomChars[count] = (char) (randomAscii); ++count; } } return new String(randomChars); } }
public static void main(String[] args) { IdGenerator idGenerator = new RandomIdGenerator(); System.out.println(idGenerator.generate()); }
打印结果为: