CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:规范与重构/34 | 实战一(上):通过一段ID生成器代码,学习如何发现代码质量问题.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

146 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="34 | 实战一通过一段ID生成器代码学习如何发现代码质量问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/f1/307a216b837b545dd9c630539b11ebf1.mp3"></audio>
在前面几节课中,我们讲了一些跟重构相关的理论知识,比如:持续重构、单元测试、代码的可测试性、解耦、编码规范。用一句话总结一下,重构就是发现代码质量问题,并且对其进行优化的过程。
前面的内容相对还是偏理论。今天我就借助一个大家都很熟悉的ID生成器代码给你展示一下重构的大致过程。整个内容分为两节课。这一节课我们讲述如何发现代码质量问题下一节课讲述如何针对发现的质量问题对其进行优化将它从“能用”变得“好用”。
话不多说,让我们正式开始今天的学习吧!
## ID生成器需求背景介绍
“ID”中文翻译为“标识Identifier”。这个概念在生活、工作中随处可见比如身份证、商品条形码、二维码、车牌号、驾照号。聚焦到软件开发中ID常用来表示一些业务信息的唯一标识比如订单的单号或者数据库中的唯一主键比如地址表中的ID字段实际上是没有业务含义的对用户来说是透明的不需要关注
假设你正在参与一个后端业务系统的开发,为了方便在请求出错时排查问题,我们在编写代码的时候会在关键路径上打印日志。某个请求出错之后,我们希望能搜索出这个请求对应的所有日志,以此来查找问题的原因。而实际情况是,在日志文件中,不同请求的日志会交织在一起。如果没有东西来标识哪些日志属于同一个请求,我们就无法关联同一个请求的所有日志。
这听起来有点像微服务中的调用链追踪。不过,微服务中的调用链追踪是服务间的追踪,我们现在要实现的是服务内的追踪。
借鉴微服务调用链追踪的实现思路我们可以给每个请求分配一个唯一ID并且保存在请求的上下文Context比如处理请求的工作线程的局部变量中。在Java语言中我们可以将ID存储在Servlet线程的ThreadLocal中或者利用Slf4j日志框架的MDCMapped Diagnostic Contexts来实现实际上底层原理也是基于线程的ThreadLocal。每次打印日志的时候我们从请求上下文中取出请求ID跟日志一块输出。这样同一个请求的所有日志都包含同样的请求ID信息我们就可以通过请求ID来搜索同一个请求的所有日志了。
好了需求背景我们已经讲清楚了至于具体如何实现整个需求我就不展开来讲解了。如果你感兴趣的话可以自己试着去设计实现一下。我们接下来只关注其中生成请求ID这部分功能的开发。
## 一份“能用”的代码实现
假设leader让小王负责这个ID生成器的开发。对于稍微有点开发经验的程序员来说实现这样一个简单的ID生成器并不是件难事。所以小王很快就完成了任务将代码写了出来具体如下所示
```
public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);
public static String generate() {
String id = &quot;&quot;;
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split(&quot;\\.&quot;);
if (tokens.length &gt; 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count &lt; 8) {
int randomAscii = random.nextInt(122);
if (randomAscii &gt;= 48 &amp;&amp; randomAscii &lt;= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii &gt;= 65 &amp;&amp; randomAscii &lt;= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii &gt;= 97 &amp;&amp; randomAscii &lt;= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format(&quot;%s-%d-%s&quot;, hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn(&quot;Failed to get the host name.&quot;, e);
}
return id;
}
}
```
上面的代码生成的ID示例如下所示。整个ID由三部分组成。第一部分是本机名的最后一个字段。第二部分是当前时间戳精确到毫秒。第三部分是8位的随机字符串包含大小写字母和数字。尽管这样生成的ID并不是绝对唯一的有重复的可能但事实上重复的概率非常低。对于我们的日志追踪来说极小概率的ID重复是完全可以接受的。
```
103-1577456311467-3nR3Do45
103-1577456311468-0wnuV5yw
103-1577456311468-sdrnkFxN
103-1577456311468-8lwk0BP0
```
不过在我看来像小王的这份代码只能算得上“能用”勉强及格。我为啥这么说呢这段代码只有短短不到40行里面却有很多值得优化的地方。你可以先思考一下在纸上试着罗列一下这段代码存在的问题然后再对比来看我下面的讲解。
## 如何发现代码质量问题?
从大处着眼的话,我们可以参考之前讲过的代码质量评判标准,看这段代码是否可读、可扩展、可维护、灵活、简洁、可复用、可测试等等。落实到具体细节,我们可以从以下几个方面来审视代码。
- 目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?
- 是否遵循经典的设计原则和设计思想SOLID、DRY、KISS、YAGNI、LOD等
- 设计模式是否应用得当?是否有过度设计?
- 代码是否容易扩展?如果要添加新功能,是否容易实现?
- 代码是否可以复用?是否可以复用已有的项目代码或类库?是否有重复造轮子?
- 代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况?
- 代码是否易读?是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等)?
以上是一些通用的关注点,可以作为常规检查项,套用在任何代码的重构上。除此之外,我们还要关注代码实现是否满足业务本身特有的功能和非功能需求。我罗列了一些比较有共性的问题,如下所示。这份列表可能还不够全面,剩下的需要你针对具体的业务、具体的代码去具体分析。
- 代码是否实现了预期的业务需求?
- 逻辑是否正确?是否处理了各种异常情况?
- 日志打印是否得当是否方便debug排查问题
- 接口是否易用?是否支持幂等、事务等?
- 代码是否存在并发问题?是否线程安全?
- 性能是否有优化空间比如SQL、算法是否可以优化
- 是否有安全漏洞?比如输入输出校验是否全面?
**现在,对照上面的检查项,我们来看一下,小王编写的代码有哪些问题。**
首先IdGenerator的代码比较简单只有一个类所以不涉及目录设置、模块划分、代码结构问题也不违反基本的SOLID、DRY、KISS、YAGNI、LOD等设计原则。它没有应用设计模式所以也不存在不合理使用和过度设计的问题。
其次IdGenerator设计成了实现类而非接口调用者直接依赖实现而非接口违反基于接口而非实现编程的设计思想。实际上将IdGenerator设计成实现类而不定义接口问题也不大。如果哪天ID生成算法改变了我们只需要直接修改实现类的代码就可以。但是如果项目中需要同时存在两种ID生成算法也就是要同时存在两个IdGenerator实现类。比如我们需要将这个框架给更多的系统来使用。系统在使用的时候可以灵活地选择它需要的生成算法。这个时候我们就需要将IdGenerator定义为接口并且为不同的生成算法定义不同的实现类。
再次把IdGenerator的generate()函数定义为静态函数会影响使用该函数的代码的可测试性。同时generate()函数的代码实现依赖运行环境本机名、时间函数、随机函数所以generate()函数本身的可测试性也不好,需要做比较大的重构。除此之外,小王也没有编写单元测试代码,我们需要在重构时对其进行补充。
最后虽然IdGenerator只包含一个函数并且代码行数也不多但代码的可读性并不好。特别是随机字符串生成的那部分代码一方面代码完全没有注释生成算法比较难读懂另一方面代码里有很多魔法数严重影响代码的可读性。在重构的时候我们需要重点提高这部分代码的可读性。
**刚刚我们参照跟业务本身无关的、通用的代码质量关注点,对小王的代码进行了评价。现在,我们再对照业务本身的功能和非功能需求,重新审视一下小王的代码。**
前面我们提到虽然小王的代码生成的ID并非绝对的唯一但是对于追踪打印日志来说是可以接受小概率ID冲突的满足我们预期的业务需求。不过获取hostName这部分代码逻辑貌似有点问题并未处理“hostName为空”的情况。除此之外尽管代码中针对获取不到本机名的情况做了异常处理但是小王对异常的处理是在IdGenerator内部将其吐掉然后打印一条报警日志并没有继续往上抛出。这样的异常处理是否得当呢你可以先自己思考一下我们把这部分内容放到第36、37讲中具体讲解。
小王代码的日志打印得当日志描述能够准确反应问题方便debug并且没有过多的冗余日志。IdGenerator只暴露一个generate()接口供使用者使用接口的定义简单明了不存在不易用问题。generate()函数代码中没有涉及共享变量所以代码线程安全多线程环境下调用generate()函数不存在并发问题。
性能方面ID的生成不依赖外部存储在内存中生成并且日志的打印频率也不会很高所以小王的代码在性能方面足以应对目前的应用场景。不过每次生成ID都需要获取本机名获取主机名会比较耗时所以这部分可以考虑优化一下。还有randomAscii的范围是0122但可用数字仅包含三段子区间0~9a~zA~Z极端情况下会随机生成很多三段区间之外的无效数字需要循环很多次才能生成随机字符串所以随机字符串的生成算法也可以优化一下。
**刚刚我们还讲到,有一些代码质量问题不具有共性,我们没法一一罗列,需要你针对具体的业务、具体的代码去具体分析。那像小王的这份代码,你还能发现有哪些具体问题吗?**
在generate()函数的while循环里面三个if语句内部的代码非常相似而且实现稍微有点过于复杂了实际上可以进一步简化将这三个if合并在一起。具体如何来做我们留在下一节课中讲解。
今天的知识内容我们讲到这里其实就差不多了。那跟随我看到这里,你有没有觉得,你的内功加深了很多呢?之前看到一段代码,你想要重构,但不知道该如何入手,也不知道该如何评价这段代码写得好坏,更不知道该如何系统、全面地进行分析。而现在,你可以很轻松地罗列出这段代码的质量缺陷,并且做到有章可循、全面系统、无遗漏。之所以现在能做到这样,那是得益于前面很多理论知识的学习和铺垫。所谓“会者不难,难者不会”,其实就是这个道理!
如果我们没有前面n多知识点的铺垫比如面向对象和面向过程的区别、面向对象的四大特性、面向过程编程的弊端以及如何控制弊端带来的副作用、需求分析方法、类的设计思路、类之间的关系、接口和抽象类的区别、各种设计原则和思想等等我相信很多人都不能完美地解决今天的问题。
那你可能要说了,今天这段代码并没有涉及之前所有的知识点啊?你说得没错。但是,**如果没有知识点的全面积累,我们就无法构建出大的知识框架,更不知道知识的边界在哪里,也就无法形成系统的方法论。即便你能歪打误撞回答全面,也不会像现在这样对自己的答案如此自信和笃定。**
## 重点回顾
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要重点掌握的内容。
今天我们其实就重点讲了一个问题那就是如何发现代码质量问题这其实是我整理的一个发现代码质量问题的checklist。之后你在review自己的代码时可以参考这两个checklist来进行全面的review。
首先从大处着眼的话我们可以参考之前讲过的代码质量评判标准看代码是否可读、可扩展、可维护、灵活、简洁、可复用、可测试等。落实到具体细节我们可以从以下7个方面来审视代码。
<img src="https://static001.geekbang.org/resource/image/04/c9/041e22cac6ce2ba3481e246c119adfc9.jpg" alt="">
这些都是一些通用的关注点,可以作为一些常规检查项,套用在任何代码的重构上。除此之外,我们还要关注代码实现是否满足业务本身特有的功能和非功能需求。一些比较共性的关注点如下所示:
<img src="https://static001.geekbang.org/resource/image/98/98/9894233257994a69102afa960692ce98.jpg" alt="">
## 课堂讨论
在今天的代码中打印日志的Logger对象被定义为static final的并且在类内部创建这是否影响到IdGenerator类代码的可测试性是否应该将Logger对象通过依赖注入的方式注入到IdGenerator类中呢
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。