This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
<audio id="audio" title="27 | 理论一:什么情况下要重构?到底重构什么?又该如何重构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/77/f36bd3b692cab75f0710b5a521205177.mp3"></audio>
“重构”这个词对于大部分工程师来说都不陌生。不过,据我了解,大部分人都只是“听得多做得少”,真正进行过代码重构的人不多,而把持续重构作为开发的一部分的人,就更是少之又少了。
一方面,重构代码对一个工程师能力的要求,要比单纯写代码高得多。重构需要你能洞察出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。
另一方面,很多工程师对为什么要重构、到底重构什么、什么时候重构、又该如何重构等相关问题理解不深,对重构没有系统性、全局性的认识,面对一堆烂代码,没有重构技巧的指导,只能想到哪改到哪,并不能全面地改善代码质量。
为了让你对重构有个清晰的认识,对于这部分知识的讲解,我安排了六节课的内容,主要包含以下几个方面:
- 对重构概括性的介绍包括重构的目的why、对象what、时机when、方法how
- 保证重构不出错的手段,这里我会重点讲解单元测试和代码的可测试性;
- 不同规模的重构,重点讲解大规模高层次重构(比如系统、模块、代码结构、类与类之间的交互等的重构)和小规模低层次重构(类、函数、变量等的重构)。
话不多说,现在就让我们来学习第一部分内容:重构的目的、对象、时机和方法。
## 重构的目的为什么要重构why
虽然对于你来说重构这个词可能不需要过多解释但我们还是简单来看一下大师是怎么描述它的。软件设计大师Martin Fowler 是这样定义重构的:“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。”
实际上,当讲到重构的时候,很多书籍都会引用这个定义。这个定义中有一个值得强调的点:“重构不改变外部的可见行为”。我们可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。
**简单了解重构的定义之后,我们重点来看一下,为什么要进行代码重构?**
首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在演进,代码不停地在堆砌。如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,想要再去重构,已经没有人能做到了。
其次优秀的代码或架构不是一开始就能完全设计好的就像优秀的公司和产品也都是迭代出来的。我们无法100%遇见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,所以,随着系统的演进,重构代码也是不可避免的。
最后,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。
**除此之外,重构对一个工程师本身技术的成长也有重要的意义。**
从前面我给出的重构的定义来看,重构实际上是对我们学习的经典设计思想、设计原则、设计模式、编程规范的一种应用。重构实际上就是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力。除此之外,平时堆砌业务逻辑,你可能总觉得没啥成长,而将一个比较烂的代码重构成一个比较好的代码,会让你很有成就感。
除此之外重构能力也是衡量一个工程师代码能力的有效手段。所谓“初级工程师在维护代码高级工程师在设计代码资深工程师在重构代码”这句话的意思是说初级工程师在已有代码框架下修改bug、修改添加功能代码高级工程师从零开始设计代码结构、搭建代码框架而资深工程师为代码质量负责需要发觉代码存在的问题重构代码时刻保证代码质量处于一个可控的状态当然这里的初级、高级、资深只是一个相对概念并不是一个确定的职级
## 重构的对象到底重构什么what
根据重构的规模,我们可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)。
大型重构指的是对顶层代码设计的重构包括系统、模块、代码结构、类与类之间的关系等的重构重构的手段有分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多影响面会比较大所以难度也较大耗时会比较长引入bug的风险也会相对比较大。
小型重构指的是对代码细节的重构主要是针对类、函数、变量等代码级别的重构比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用我们能后面要讲到的编码规范。这类重构要修改的地方比较集中比较简单可操作性较强耗时会比较短引入bug的风险相对来说也会比较小。你只需要熟练掌握各种编码规范就可以做到得心应手。
关于具体如何来做大型重构和小型重构,我会在后面的课程中详细讲解。
## 重构的时机什么时候重构when
搞清楚了为什么重构到底重构什么我们再来看一下什么时候重构是代码烂到一定程度之后才去重构吗当然不是。因为当代码真的烂到出现“开发效率低招了很多人天天加班出活却不多线上bug频发领导发飙中层束手无策工程师抱怨不断查找bug困难”的时候基本上重构也无法解决问题了。
我个人比较反对,平时不注重代码质量,堆砌烂代码,实在维护不了了就大刀阔斧地重构、甚至重写的行为。有时候项目代码太多了,重构很难做得彻底,最后又搞出来一个“四不像的怪物”,这就更麻烦了!所以,寄希望于在代码烂到一定程度之后,集中重构解决所有问题是不现实的,我们必须探索一条**可持续、可演进**的方式。
所以我特别提倡的重构策略是持续重构。这也是我在工作中特别喜欢干的事情。平时没有事情的时候你可以看看项目中有哪些写得不够好的、可以优化的代码主动去重构一下。或者在修改、添加某个功能代码的时候你也可以顺手把不符合编码规范、不好的设计重构一下。总之就像把单元测试、Code Review作为开发的一部分我们如果能把持续重构也作为开发的一部分成为一种开发习惯对项目、对自己都会很有好处。
尽管我们说重构能力很重要,但持续重构意识更重要。我们要正确地看待代码质量和重构这件事情。技术在更新、需求在变化、人员在流动,代码质量总会在下降,代码总会存在不完美,重构就会持续在进行。时刻具有持续重构意识,才能避免开发初期就过度设计,避免代码维护的过程中质量的下降。而那些看到别人代码有点瑕疵就一顿乱骂,或者花尽心思去构思一个完美设计的人,往往都是因为没有树立正确的代码质量观,没有持续重构意识。
## 重构的方法又该如何重构how
前面我们讲到,按照重构的规模,重构可以笼统地分为大型重构和小型重构。对于这两种不同规模的重构,我们要区别对待。
对于大型重构来说因为涉及的模块、代码会比较多如果项目代码质量又比较差耦合比较严重往往会牵一发而动全身本来觉得一天就能完成的重构你会发现越改越多、越改越乱没一两个礼拜都搞不定。而新的业务开发又与重构相冲突最后只能半途而废revert掉所有的改动很失落地又去堆砌烂代码了。
在进行大型重构的时候,我们要提前做好完善的重构计划,有条不紊地分阶段来进行。每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,我们都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。只有这样,我们才能让每一阶段的重构都不至于耗时太长(最好一天就能完成),不至于与新的功能开发相冲突。
大规模高层次的重构一定是有组织、有计划并且非常谨慎的需要有经验、熟悉业务的资深同事来主导。而小规模低层次的重构因为影响范围小改动耗时短所以只要你愿意并且有时间随时都可以去做。实际上除了人工去发现低层次的质量问题我们还可以借助很多成熟的静态代码分析工具比如CheckStyle、FindBugs、PMD来自动发现代码中的问题然后针对性地进行重构优化。
对于重构这件事情资深的工程师、项目leader要负起责任来没事就重构一下代码时刻保证代码质量处在一个良好的状态。否则一旦出现“破窗效应”一个人往里堆了一些烂代码之后就会有更多的人往里堆更烂的代码。毕竟往项目里堆砌烂代码的成本太低了。不过保持代码质量最好的方法还是打造一种好的技术氛围以此来驱动大家主动去关注代码质量持续重构代码。
## 重点回顾
今天的讲解比较偏理论、偏思想教育,主要还是让你对重构有个正确的、全局性的认知,建立持续重构意识。我觉得,这可能比教会你一些重构技巧更重要,因为很多技术问题本身就不是单纯靠技术来解决的,更重要的是要有这种认知和意识。
好了,下面我们还是来总结一下。对于今天的内容,你需要重点理解并且掌握如下知识点。
**1.重构的目的为什么重构why**
对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场。
**2.重构的对象重构什么what**
按照重构的规模,我们可以将重构大致分为大规模高层次的重构和小规模低层次的重构。大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识。
**3.重构的时机什么时候重构when**
我反复强调,我们一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
**4.重构的方法如何重构how**
大规模高层次的重构难度比较大,需要组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。
## 课堂讨论
今天课堂讨论的话题是:关于代码重构,你有什么心得体会、经验教训?或者,你也可以说说,在重构过往项目的时候,你遇到过哪些问题?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,334 @@
<audio id="audio" title="28 | 理论二:为了保证重构不出错,有哪些非常能落地的技术手段?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/c7/fc0d0e4763b777b1fd5f2d0978fe61c7.mp3"></audio>
上一节课中,我们对“为什么要重构、到底重构什么、什么时候重构、该如何重构”,做了概括性介绍,强调了重构的重要性,希望你建立持续重构意识,将重构作为开发的一部分来执行。
据我了解很多程序员对重构这种做法还是非常认同的面对项目中的烂代码也想重构一下但又担心重构之后出问题出力不讨好。确实如果你要重构的代码是别的同事开发的你不是特别熟悉在没有任何保障的情况下重构引入bug的风险还是很大的。
那如何保证重构不出错呢?你需要熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构不出错的手段应该就是**单元测试**Unit Testing了。当重构完成之后如果新的代码仍然能通过单元测试那就说明代码原有逻辑的正确性未被破坏原有的外部可见行为未变符合上一节课中我们对重构的定义。
那今天我们就来学习一下单元测试。今天的内容主要包含这样几个内容:
- 什么是单元测试?
- 为什么要写单元测试?
- 如何编写单元测试?
- 如何在团队中推行单元测试?
话不多说,让我们现在就开始今天的学习吧!
## 什么是单元测试?
单元测试由研发工程师自己来编写用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。单元测试相对于集成测试Integration Testing来说测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块比如测试用户注册、登录功能是否正常是一种端到端end to end的测试。而单元测试的测试对象是类或者函数用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。
这么说比较理论,我举个例子来解释一下。
```
public class Text {
private String content;
public Text(String content) {
this.content = content;
}
/**
* 将字符串转化成数字,忽略字符串中的首尾空格;
* 如果字符串中包含除首尾空格之外的非数字字符则返回null。
*/
public Integer toNumber() {
if (content == null || content.isEmpty()) {
return null;
}
//...省略代码实现...
return null;
}
}
```
如果我们要测试Text类中的toNumber()函数的正确性,应该如何编写单元测试呢?
实际上,写单元测试本身不需要什么高深技术。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。
为了保证测试的全面性针对toNumber()函数,我们需要设计下面这样几个测试用例。
- 如果字符串只包含数字“123”toNumber()函数输出对应的整数123。
- 如果字符串是空或者nulltoNumber()函数返回null。
- 如果字符串包含首尾空格:“ 123”“123 ”,“ 123 ”toNumber()返回对应的整数123。
- 如果字符串包含多个首尾空格:“ 123 ”toNumber()返回对应的整数123
- 如果字符串包含非数字字符“123a4”“123 4”toNumber()返回null
当我们设计好测试用例之后,剩下的就是将其翻译成代码了。翻译成代码的过程非常简单,我把代码贴在下面了,你可以参考一下(注意,我们这里没有使用任何测试框架)。
```
public class Assert {
public static void assertEquals(Integer expectedValue, Integer actualValue) {
if (actualValue != expectedValue) {
String message = String.format(
&quot;Test failed, expected: %d, actual: %d.&quot;, expectedValue, actualValue);
System.out.println(message);
} else {
System.out.println(&quot;Test succeeded.&quot;);
}
}
public static boolean assertNull(Integer actualValue) {
boolean isNull = actualValue == null;
if (isNull) {
System.out.println(&quot;Test succeeded.&quot;);
} else {
System.out.println(&quot;Test failed, the value is not null:&quot; + actualValue);
}
return isNull;
}
}
public class TestCaseRunner {
public static void main(String[] args) {
System.out.println(&quot;Run testToNumber()&quot;);
new TextTest().testToNumber();
System.out.println(&quot;Run testToNumber_nullorEmpty()&quot;);
new TextTest().testToNumber_nullorEmpty();
System.out.println(&quot;Run testToNumber_containsLeadingAndTrailingSpaces()&quot;);
new TextTest().testToNumber_containsLeadingAndTrailingSpaces();
System.out.println(&quot;Run testToNumber_containsMultiLeadingAndTrailingSpaces()&quot;);
new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();
System.out.println(&quot;Run testToNumber_containsInvalidCharaters()&quot;);
new TextTest().testToNumber_containsInvalidCharaters();
}
}
public class TextTest {
public void testToNumber() {
Text text = new Text(&quot;123&quot;);
Assert.assertEquals(123, text.toNumber());
}
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());
Text text2 = new Text(&quot;&quot;);
Assert.assertNull(text2.toNumber());
}
public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(&quot; 123&quot;);
Assert.assertEquals(123, text1.toNumber());
Text text2 = new Text(&quot;123 &quot;);
Assert.assertEquals(123, text2.toNumber());
Text text3 = new Text(&quot; 123 &quot;);
Assert.assertEquals(123, text3.toNumber());
}
public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(&quot; 123&quot;);
Assert.assertEquals(123, text1.toNumber());
Text text2 = new Text(&quot;123 &quot;);
Assert.assertEquals(123, text2.toNumber());
Text text3 = new Text(&quot; 123 &quot;);
Assert.assertEquals(123, text3.toNumber());
}
public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text(&quot;123a4&quot;);
Assert.assertNull(text1.toNumber());
Text text2 = new Text(&quot;123 4&quot;);
Assert.assertNull(text2.toNumber());
}
}
```
## 为什么要写单元测试?
单元测试除了能有效地为重构保驾护航之外也是保证代码质量最有效的两个手段之一另一个是Code Review。我在Google工作的时候写了大量的单元测试代码结合我的这些开发经验我总结了以下几点单元测试的好处。尽管有些听起来有点“务虚”但如果你认真写过一些单元测试的话应该会很有共鸣。
### 1.单元测试能有效地帮你发现代码中的bug
能否写出bug free的代码是判断工程师编码能力的重要标准之一也是很多大厂面试考察的重点特别是像FLAG这样的外企。即便像我这样代码写了十几年逻辑还算缜密、清晰的人通过单元测试也常常会发现代码中的很多考虑不全面的地方。
在离开Google之后尽管我就职的很多公司其开发模式都是“快、糙、猛”对单元测试根本没有要求但我还是坚持为自己提交的每一份代码都编写完善的单元测试。得益于此我写的代码几乎是bug free的。这也节省了我很多fix低级bug的时间能够有时间去做其他更有意义的事情我也因此在工作上赢得了很多人的认可。可以这么说坚持写单元测试是保证我的代码质量的一个“杀手锏”也是帮助我拉开与其他人差距的一个“小秘密”。
### 2.写单元测试能帮你发现代码设计上的问题
前面我们提到,代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。
### 3.单元测试是对集成测试的有力补充
程序运行的bug往往出现在一些边界条件、异常情况下比如除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用下一节课中讲到的mock的方式控制mock的对象返回我们需要模拟的异常来测试代码在这些异常情况的表现。
除此之外,对于一些复杂系统来说,集成测试也无法覆盖得很全面。复杂系统往往有很多模块。每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟,无数的测试用例需要设计,再强大的测试团队也无法穷举完备。
尽管单元测试无法完全替代集成测试但如果我们能保证每个类、每个函数都能按照我们的预期来执行底层bug少了那组装起来的整个系统出问题的概率也就相应减少了。
### 4.写单元测试的过程本身就是代码重构的过程
上一节课中我们提到要把持续重构作为开发的一部分来执行那写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候我们很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我Code Review在这个过程中我们可以发现一些设计上的问题比如代码设计的不可测试以及代码编写方面的问题比如一些边界条件处理不当然后针对性的进行重构。
### 5.阅读单元测试能帮助你快速熟悉代码
阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。但据我了解,程序员都不怎么喜欢写文档和注释,而大部分程序员写的代码又很难做到“不言自明”。在没有文档和注释的情况下,单元测试就起了替代性作用。单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
### 6.单元测试是TDD可落地执行的改进方案
测试驱动开发Test-Driven Development简称TDD是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写。不过要让程序员能彻底地接受和习惯这种开发模式还是挺难的毕竟很多程序员连单元测试都懒得写更何况在编写代码之前先写好测试用例了。
我个人觉得单元测试正好是对TDD的一种改进方案先写代码紧接着写单元测试最后根据单元测试反馈出来问题再回过头去重构代码。这个开发流程更加容易被接受更加容易落地执行而且又兼顾了TDD的优点。
## 如何编写单元测试?
前面在讲什么是单元测试的时候我们举了一个给toNumber()函数写单元测试的例子。根据那个例子,我们可以总结得出,写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程。
在把测试用例翻译成代码的时候我们可以利用单元测试框架来简化测试代码的编写。比如Java中比较出名的单元测试框架有Junit、TestNG、Spring Test等。这些框架提供了通用的执行流程比如执行测试用例的TestCaseRunner和工具类库比如各种Assert判断函数等。借助它们我们在编写测试代码的时候只需要关注测试用例本身的编写即可。
针对toNumber()函数的测试用例我们利用Junit单元测试框架重新实现一下具体代码如下所示。你可以拿它跟之前没有利用测试框架的实现方式对比一下看是否简化了很多呢
```
import org.junit.Assert;
import org.junit.Test;
public class TextTest {
@Test
public void testToNumber() {
Text text = new Text(&quot;123&quot;);
Assert.assertEquals(new Integer(123), text.toNumber());
}
@Test
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());
Text text2 = new Text(&quot;&quot;);
Assert.assertNull(text2.toNumber());
}
@Test
public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(&quot; 123&quot;);
Assert.assertEquals(new Integer(123), text1.toNumber());
Text text2 = new Text(&quot;123 &quot;);
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3 = new Text(&quot; 123 &quot;);
Assert.assertEquals(new Integer(123), text3.toNumber());
}
@Test
public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(&quot; 123&quot;);
Assert.assertEquals(new Integer(123), text1.toNumber());
Text text2 = new Text(&quot;123 &quot;);
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3 = new Text(&quot; 123 &quot;);
Assert.assertEquals(new Integer(123), text3.toNumber());
}
@Test
public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text(&quot;123a4&quot;);
Assert.assertNull(text1.toNumber());
Text text2 = new Text(&quot;123 4&quot;);
Assert.assertNull(text2.toNumber());
}
}
```
对于如何使用这些单元测试框架,大部分框架都给出了非常详细的官方文档,你可以自行查阅。这些东西理解和掌握起来没有太大难度,所以这不是专栏要讲解的重点。关于如何编写单元测试,我更希望传达给你一些我的经验总结。具体包括以下几点。
### 1.写单元测试真的是件很耗时的事情吗?
尽管单元测试的代码量可能是被测代码本身的12倍写的过程很繁琐但并不是很耗时。毕竟我们不需要考虑太多代码设计上的问题测试代码实现起来也比较简单。不同测试用例之间的代码差别可能并不是很大简单copy-paste改改就行。
### 2.对单元测试的代码质量有什么要求吗?
单元测试毕竟不会在产线上运行,而且每个类的测试代码也比较独立,基本不互相依赖。所以,相对于被测代码,我们对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的。
### 3.单元测试只要覆盖率高就够了吗?
单元测试覆盖率是比较容易量化的指标常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计比如JaCoCo、Cobertura、Emma、Clover。覆盖率的计算方式有很多种比较简单的是语句覆盖稍微高级点的有条件覆盖、判定覆盖、路径覆盖。
不管覆盖率的计算方式如何高级将覆盖率作为衡量单元测试质量的唯一标准是不合理的。实际上更重要的是要看测试用例是否覆盖了所有可能的情况特别是一些corner case。我来举个简单的例子解释一下。
像下面这段代码我们只需要一个测试用例就可以做到100%覆盖率比如cal(10.0, 2.0),但并不代表测试足够全面了,我们还需要考虑,当除数等于0的情况下,代码执行是否符合预期。
```
public double cal(double a, double b) {
if (b != 0) {
return a / b;
}
}
```
实际上过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率写很多没有必要的测试代码比如get、set方法非常简单没有必要测试。从过往的经验上来讲一个项目的单元测试覆盖率在6070%即可上线。如果项目对代码质量要求比较高,可以适当提高单元测试覆盖率的要求。
### 4.写单元测试需要了解代码的实现逻辑吗?
单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。我们切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了我们写单元测试的初衷。
### 5.如何选择单元测试框架?
写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足。在公司内部,起码团队内部需要统一单元测试框架。如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写得不够好,代码的可测试性不够好。这个时候,我们要重构自己的代码,让其更容易测试,而不是去找另一个更加高级的单元测试框架。
## 单元测试为何难落地执行?
虽然很多书籍中都会讲到单元测试是保证重构不出错的有效手段也有非常多人已经认识到单元测试的重要性。但是有多少项目有完善的、高质量的单元测试呢据我了解真的非常非常少包括BAT这样级别公司的项目。如果不相信的话你可以去看一下国内很多大厂开源的项目有很多项目完全没有单元测试还有很多项目的单元测试写得非常不完备仅仅测试了逻辑是否运行正确而已。所以100%落实执行单元测试是件“知易行难”的事。
写单元测试确实是一件考验耐心的活儿。一般情况下,单元测试的代码量要大于被测试代码量,甚至是要多出好几倍。很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现[破窗效应](https://wiki.mbalib.com/wiki/%E7%A0%B4%E7%AA%97%E6%95%88%E5%BA%94),慢慢的,大家就都不写了,这种情况很常见。
还有一种情况就是由于历史遗留问题原来的代码都没有写单元测试代码已经堆砌了十几万行了不可能再一个一个去补单元测试。这种情况下我们首先要保证新写的代码都要有单元测试其次每次在改动到某个类时如果没有单元测试就顺便补上不过这要求工程师们有足够强的主人翁意识ownership毕竟光靠leader督促很多事情是很难执行到位的。
除此之外还有人觉得有了测试团队写单元测试就是浪费时间没有必要。程序员这一行业本该是智力密集型的但现在很多公司把它搞成劳动密集型的包括一些大厂在开发过程中既没有单元测试也没有Code Review流程。即便有做的也是差强人意。写好代码直接提交然后丢给黑盒测试狠命去测测出问题就反馈给开发团队再修改测不出的问题就留在线上出了问题再修复。
在这样的开发模式下团队往往觉得没有必要写单元测试但如果我们把单元测试写好、做好Code Review重视起代码质量其实可以很大程度上减少黑盒测试的投入。我在Google的时候很多项目几乎没有测试团队参与代码的正确性完全靠开发团队来保障线上bug反倒非常少。
以上是我对单元测试的认知和实践心得。现在互联网信息如此的公开透明网上有很多文章可以参考对于程序员这个具有很强学习能力的群体来说学会如何写单元测试并不是一件难事难的是能够真正感受到它的作用并且打心底认可、能100%落地执行。这也是我今天的课程特别想传达给你的一点。
## 重点回顾
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
**1.什么是单元测试?**
单元测试是代码层面的测试,由研发自己来编写,用于测试“自己”编写的代码的逻辑的正确性。单元测试顾名思义是测试一个“单元”,有别于集成测试,这个“单元”一般是类或函数,而不是模块或者系统。
**2.为什么要写单元测试?**
写单元测试的过程本身就是代码Code Review和重构的过程能有效地发现代码中的bug和代码设计上的问题。除此之外单元测试还是对集成测试的有力补充还能帮助我们快速熟悉代码是TDD可落地执行的改进方案。
**3.如何编写单元测试?**
写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。我们可以利用一些测试框架来简化单元测试的编写。除此之外,对于单元测试,我们需要建立以下正确的认知:
- 编写单元测试尽管繁琐,但并不是太耗时;
- 我们可以稍微放低对单元测试代码质量的要求;
- 覆盖率作为衡量单元测试质量的唯一标准是不合理的;
- 单元测试不要依赖被测代码的具体实现逻辑;
- 单元测试框架无法测试,多半是因为代码的可测试性不好。
**4.单元测试为何难落地执行?**
一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;另一方面,国内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好。
## 课堂讨论
今天的课堂讨论有以下两个:
1. 你参与的项目有没有写单元测试?单元测试是否足够完备?贯彻执行写单元测试的过程中,遇到过哪些问题?又是如何解决的?
1. 在面试中,我经常会让候选人写完代码之后,列举几个测试用例,以此来考察候选人考虑问题是否全面,特别是针对一些边界条件的处理。所以,今天的另一个课堂讨论话题就是:写一个二分查找的变体算法,查找递增数组中第一个大于等于某个给定值的元素,并且为你的代码设计完备的单元测试用例。
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,475 @@
<audio id="audio" title="29 | 理论三:什么是代码的可测试性?如何写出可测试性好的代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/44/5d92f1dd0f6b15e91d30c3c70da13744.mp3"></audio>
在上一节课中,我们对单元测试做了介绍,讲了“什么是单元测试?为什么要编写单元测试?如何编写单元测试?实践中单元测试为什么难贯彻执行?”这样几个问题。
实际上,写单元测试并不难,也不需要太多技巧,相反,写出可测试的代码反倒是件非常有挑战的事情。所以,今天,我们就再来聊一聊代码的可测试性,主要包括这样几个问题:
- 什么是代码的可测试性?
- 如何写出可测试的代码?
- 有哪些常见的不好测试的代码?
话不多说,让我们正式开始今天的学习吧!
## 编写可测试代码案例实战
刚刚提到的这几个关于代码可测试性的问题,我准备通过一个实战案例来讲解。具体的被测试代码如下所示。
其中Transaction是经过我抽象简化之后的一个电商系统的交易类用来记录每笔订单交易的情况。Transaction类中的execute()函数负责执行转账操作将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用WalletRpcService RPC服务来完成的。除此之外代码中还涉及一个分布式锁DistributedLock单例类用来避免Transaction并发执行导致用户的钱被重复转出。
```
public class Transaction {
private String id;
private Long buyerId;
private Long sellerId;
private Long productId;
private String orderId;
private Long createTimestamp;
private Double amount;
private STATUS status;
private String walletTransactionId;
// ...get() methods...
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null &amp;&amp; !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith(&quot;t_&quot;)) {
this.id = &quot;t_&quot; + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
public boolean execute() throws InvalidTransactionException {
if ((buyerId == null || (sellerId == null || amount &lt; 0.0) {
throw new InvalidTransactionException(...);
}
if (status == STATUS.EXECUTED) return true;
boolean isLocked = false;
try {
isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
if (!isLocked) {
return false; // 锁定未成功返回falsejob兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
long executionInvokedTimestamp = System.currentTimestamp();
if (executionInvokedTimestamp - createdTimestap &gt; 14days) {
this.status = STATUS.EXPIRED;
return false;
}
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
return true;
} else {
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
}
}
```
对比上一节课中的Text类的代码这段代码要复杂很多。如果让你给这段代码编写单元测试你会如何来写呢你可以先试着思考一下然后再来看我下面的分析。
在Transaction类中主要逻辑集中在execute()函数中所以它是我们测试的重点对象。为了尽可能全面覆盖各种正常和异常情况针对这个函数我设计了下面6个测试用例。
1. 正常情况下交易执行成功回填用于对账交易与钱包的交易流水用的walletTransactionId交易状态设置为EXECUTED函数返回true。
1. buyerId、sellerId为null、amount小于0返回InvalidTransactionException。
1. 交易已过期createTimestamp超过14天交易状态设置为EXPIRED返回false。
1. 交易已经执行了status==EXECUTED不再重复执行转钱逻辑返回true。
1. 钱包WalletRpcService转钱失败交易状态设置为FAILED函数返回false。
1. 交易正在执行着不会被重复执行函数直接返回false。
测试用例设计完了。现在看起来似乎一切进展顺利。但是事实是当我们将测试用例落实到具体的代码实现时你就会发现有很多行不通的地方。对于上面的测试用例第2个实现起来非常简单我就不做介绍了。我们重点来看其中的1和3。测试用例4、5、6跟3类似留给你自己来实现。
现在我们就来看测试用例1的代码实现。具体如下所示
```
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
```
execute()函数的执行依赖两个外部的服务一个是RedisDistributedLock一个WalletRpcService。这就导致上面的单元测试代码存在下面几个问题。
- 如果要让这个单元测试能够运行我们需要搭建Redis服务和Wallet RPC服务。搭建和维护的成本比较高。
- 我们还需要保证将伪造的transaction数据发送给Wallet RPC服务之后能够正确返回我们期望的结果然而Wallet RPC服务有可能是第三方另一个团队开发维护的的服务并不是我们可控的。换句话说并不是我们想让它返回什么数据就返回什么。
- Transaction的执行跟Redis、RPC服务通信需要走网络耗时可能会比较长对单元测试本身的执行性能也会有影响。
- 网络的中断、超时、Redis、RPC服务的不可用都会影响单元测试的执行。
我们回到单元测试的定义上来看一下。单元测试主要是测试程序员自己编写的代码逻辑的正确性并非是端到端的集成测试它不需要测试所依赖的外部系统分布式锁、Wallet RPC服务的逻辑正确性。所以如果代码中依赖了外部系统或者不可控组件比如需要依赖数据库、网络通信、文件系统等那我们就需要将被测代码与外部系统解依赖而这种解依赖的方法就叫作“mock”。所谓的mock就是用一个“假”的服务替换真正的服务。mock的服务完全在我们的控制之下模拟输出我们想要的数据。
那如何来mock服务呢mock的方式主要有两种手动mock和利用框架mock。利用框架mock仅仅是为了简化代码编写每个框架的mock方式都不大一样。我们这里只展示手动mock。
我们通过继承WalletRpcService类并且重写其中的moveMoney()函数的方式来实现mock。具体的代码实现如下所示。通过mock的方式我们可以让moveMoney()返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。
```
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return &quot;123bac&quot;;
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return null;
}
}
```
现在我们再来看如何用MockWalletRpcServiceOne、MockWalletRpcServiceTwo来替换代码中的真正的WalletRpcService呢
因为WalletRpcService是在execute()函数中通过new的方式创建的我们无法动态地对其进行替换。也就是说Transaction类中的execute()方法的可测试性很差,需要通过重构来让其变得更容易测试。该如何重构这段代码呢?
在[第19节](https://time.geekbang.org/column/article/177444)中我们讲到依赖注入是实现代码可测试性的最有效的手段。我们可以应用依赖注入将WalletRpcService对象的创建反转给上层逻辑在外部创建好之后再注入到Transaction类中。重构之后的Transaction类的代码如下所示
```
public class Transaction {
//...
// 添加一个成员变量及其set方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
// ...
public boolean execute() {
// ...
// 删除下面这一行代码
// WalletRpcService walletRpcService = new WalletRpcService();
// ...
}
}
```
现在我们就可以在单元测试中非常容易地将WalletRpcService替换成MockWalletRpcServiceOne或WalletRpcServiceTwo了。重构之后的代码对应的单元测试如下所示
```
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
// 使用mock对象来替代真正的RPC服务
transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
```
WalletRpcService的mock和替换问题解决了我们再来看RedisDistributedLock。它的mock和替换要复杂一些主要是因为RedisDistributedLock是一个单例类。单例相当于一个全局变量我们无法mock无法继承和重写方法也无法通过依赖注入的方式来替换。
如果RedisDistributedLock是我们自己维护的可以自由修改、重构那我们可以将其改为非单例的模式或者定义一个接口比如IDistributedLock让RedisDistributedLock实现这个接口。这样我们就可以像前面WalletRpcService的替换方式那样替换RedisDistributedLock为MockRedisDistributedLock了。但如果RedisDistributedLock不是我们维护的我们无权去修改这部分代码这个时候该怎么办呢
我们可以对transaction上锁这部分逻辑重新封装一下。具体代码实现如下所示
```
public class TransactionLock {
public boolean lock(String id) {
return RedisDistributedLock.getSingletonIntance().lockTransction(id);
}
public void unlock() {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
public class Transaction {
//...
private TransactionLock lock;
public void setTransactionLock(TransactionLock lock) {
this.lock = lock;
}
public boolean execute() {
//...
try {
isLocked = lock.lock();
//...
} finally {
if (isLocked) {
lock.unlock();
}
}
//...
}
}
```
针对重构过的代码我们的单元测试代码修改为下面这个样子。这样我们就能在单元测试代码中隔离真正的RedisDistributedLock分布式锁这部分逻辑了。
```
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
TransactionLock mockLock = new TransactionLock() {
public boolean lock(String id) {
return true;
}
public void unlock() {}
};
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
```
至此测试用例1就算写好了。我们通过依赖注入和mock让单元测试代码不依赖任何不可控的外部服务。你可以照着这个思路自己写一下测试用例4、5、6。
现在我们再来看测试用例3交易已过期createTimestamp超过14天交易状态设置为EXPIRED返回false。针对这个单元测试用例我们还是先把代码写出来然后再来分析。
```
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
```
上面的代码看似没有任何问题。我们将transaction的创建时间createdTimestamp设置为14天前也就是说当单元测试代码运行的时候transaction一定是处于过期状态。但是如果在Transaction类中并没有暴露修改createdTimestamp成员变量的set方法也就是没有定义setCreatedTimestamp()函数)呢?
你可能会说如果没有createTimestamp的set方法我就重新添加一个呗实际上这违反了类的封装特性。在Transaction类的设计中createTimestamp是在交易生成时也就是构造函数中自动获取的系统时间本来就不应该人为地轻易修改所以暴露createTimestamp的set方法虽然带来了灵活性但也带来了不可控性。因为我们无法控制使用者是否会调用set方法重设createTimestamp而重设createTimestamp并非我们的预期行为。
那如果没有针对createTimestamp的set方法那测试用例3又该如何实现呢实际上这是一类比较常见的问题就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对Transaction类我们只需要将交易是否过期的逻辑封装到isExpired()函数中即可,具体的代码实现如下所示:
```
public class Transaction {
protected boolean isExpired() {
long executionInvokedTimestamp = System.currentTimestamp();
return executionInvokedTimestamp - createdTimestamp &gt; 14days;
}
public boolean execute() throws InvalidTransactionException {
//...
if (isExpired()) {
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}
```
针对重构之后的代码测试用例3的代码实现如下所示
```
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
protected boolean isExpired() {
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
```
通过重构Transaction代码的可测试性提高了。之前罗列的所有测试用例现在我们都顺利实现了。不过Transaction类的构造函数的设计还有点不妥。为了方便你查看我把构造函数的代码重新copy了一份贴到这里。
```
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null &amp;&amp; !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith(&quot;t_&quot;)) {
this.id = &quot;t_&quot; + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
```
我们发现构造函数中并非只包含简单赋值操作。交易id的赋值逻辑稍微复杂。我们最好也要测试一下以保证这部分逻辑的正确性。为了方便测试我们可以把id赋值这部分逻辑单独抽象到一个函数中具体的代码实现如下所示
```
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//...
fillTransactionId(preAssignId);
//...
}
protected void fillTransactionId(String preAssignedId) {
if (preAssignedId != null &amp;&amp; !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith(&quot;t_&quot;)) {
this.id = &quot;t_&quot; + preAssignedId;
}
}
```
到此为止我们一步一步将Transaction从不可测试代码重构成了测试性良好的代码。不过你可能还会有疑问Transaction类中isExpired()函数就不用测试了吗对于isExpired()函数逻辑非常简单肉眼就能判定是否有bug是可以不用写单元测试的。
实际上,可测试性差的代码,本身代码设计得也不够好,很多地方都没有遵守我们之前讲到的设计原则和思想,比如“基于接口而非实现编程”思想、依赖反转原则等。重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。这也印证了我们之前说过的,代码的可测试性可以从侧面上反应代码设计是否合理。除此之外,在平时的开发中,我们也要多思考一下,这样编写代码,是否容易编写单元测试,这也有利于我们设计出好的代码。
## 其他常见的Anti-Patterns
刚刚我们通过一个实战案例讲解了如何利用依赖注入来提高代码的可测试性以及编写单元测试中最复杂的一部分内容如何通过mock、二次封装等方式解依赖外部服务。现在我们再来总结一下有哪些典型的、常见的测试性不好的代码也就是我们常说的Anti-Patterns。
### 1.未决行为
所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。对于这一点,在刚刚的实战案例中我们已经讲到,你可以利用刚才讲到的方法,试着重构一下下面的代码,并且为它编写单元测试。
```
public class Demo {
public long caculateDelayDays(Date dueTime) {
long currentTimestamp = System.currentTimeMillis();
if (dueTime.getTime() &gt;= currentTimestamp) {
return 0;
}
long delayTime = currentTimestamp - dueTime.getTime();
long delayDays = delayTime / 86400;
return delayDays;
}
}
```
### 2.全局变量
前面我们讲过,全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。我举个例子来解释一下。
RangeLimiter表示一个[-5, 5]的区间position初始在0位置move()函数负责移动position。其中position是一个静态全局变量。RangeLimiterTest类是为其设计的单元测试不过这里面存在很大的问题你可以先自己分析一下。
```
public class RangeLimiter {
private static AtomicInteger position = new AtomicInteger(0);
public static final int MAX_LIMIT = 5;
public static final int MIN_LIMIT = -5;
public boolean move(int delta) {
int currentPos = position.addAndGet(delta);
boolean betweenRange = (currentPos &lt;= MAX_LIMIT) &amp;&amp; (currentPos &gt;= MIN_LIMIT);
return betweenRange;
}
}
public class RangeLimiterTest {
public void testMove_betweenRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertTrue(rangeLimiter.move(1));
assertTrue(rangeLimiter.move(3));
assertTrue(rangeLimiter.move(-5));
}
public void testMove_exceedRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertFalse(rangeLimiter.move(6));
}
}
```
上面的单元测试有可能会运行失败。假设单元测试框架顺序依次执行testMove_betweenRange()和testMove_exceedRange()两个测试用例。在第一个测试用例执行完成之后position的值变成了-1再执行第二个测试用例的时候position变成了5move()函数返回trueassertFalse语句判定失败。所以第二个测试用例运行失败。
当然如果RangeLimiter类有暴露重设resetposition值的函数我们可以在每次执行单元测试用例之前把position重设为0这样就能解决刚刚的问题。
不过每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行有的是并发执行。对于并发执行的情况即便我们每次都把position重设为0也并不奏效。如果两个测试用例并发执行第16、17、18、23这四行代码可能会交叉执行影响到move()函数的执行结果。
### 3.静态方法
前面我们也提到静态方法跟全局变量一样也是一种面向过程的编程思维。在代码中调用静态方法有时候会导致代码不易测试。主要原因是静态方法也很难mock。但是这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下我们才需要在单元测试中mock这个静态方法。除此之外如果只是类似Math.abs()这样的简单静态方法并不会影响代码的可测试性因为本身并不需要mock。
### 4.复杂继承
我们前面提到,相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。
如果父类需要mock某个依赖对象才能进行单元测试那所有的子类、子类的子类……在编写单元测试的时候都要mock这个依赖对象。对于层次很深在继承关系类图中表现为纵向深度、结构复杂在继承关系类图中表现为横向广度的继承关系越底层的子类要mock的对象可能就会越多这样就会导致底层子类在写单元测试的时候要一个一个mock很多依赖对象而且还需要查看父类代码去了解该如何mock这些依赖对象。
如果我们利用组合而非继承来组织类之间的关系类之间的结构层次比较扁平在编写单元测试的时候只需要mock类所组合依赖的对象即可。
### 5.高耦合代码
如果一个类职责很重需要依赖十几个外部对象才能完成工作代码高度耦合那我们在编写单元测试的时候可能需要mock这十几个依赖的对象。不管是从代码设计的角度来说还是从编写单元测试的角度来说这都是不合理的。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
**1.什么是代码的可测试性?**
粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
**2.编写可测试性代码的最有效手段**
依赖注入是编写可测试性代码的最有效手段。通过依赖注入我们在编写单元测试的时候可以通过mock的方法解依赖外部服务这也是我们在编写单元测试的过程中最有技术挑战的地方。
**3.常见的Anti-Patterns**
常见的测试不友好的代码有下面这5种
- 代码中包含未决行为逻辑
- 滥用可变全局变量
- 滥用静态方法
- 使用复杂的继承关系
- 高度耦合的代码
## 课堂讨论
1. 实战案例中的void fillTransactionId(String preAssignedId)函数中包含一处静态函数调用IdGenerator.generateTransactionId()这是否会影响到代码的可测试性在写单元测试的时候我们是否需要mock这个函数
1. 我们今天讲到依赖注入是提高代码可测试性的最有效的手段。所以依赖注入就是不要在类内部通过new的方式创建对象而是要通过外部创建好之后传递给类使用。那是不是所有的对象都不能在类内部创建呢哪种类型的对象可以在类内部创建并且不影响代码的可测试性你能举几个例子吗
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="30 | 理论四:如何通过封装、抽象、模块化、中间层等解耦代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/a0/14a8aaaacd5aa026c7ada1199cd707a0.mp3"></audio>
前面我们讲到,重构可以分为大规模高层重构(简称“大型重构”)和小规模低层次重构(简称“小型重构”)。大型重构是对系统、模块、代码结构、类之间关系等顶层代码设计进行的重构。对于大型重构来说,今天我们重点讲解最有效的一个手段,那就是“解耦”。解耦的目的是实现代码高内聚、松耦合。关于解耦,我准备分下面三个部分来给你讲解。
- “解耦”为何如此重要?
- 如何判定代码是否需要“解耦”?
- 如何给代码“解耦”?
话不多说,现在就让我们正式开始今天的学习吧!
## “解耦”为何如此重要?
软件设计与开发最重要的工作之一就是应对复杂性。人处理复杂性的能力是有限的。过于复杂的代码往往在可读性、可维护性上都不友好。那如何来控制代码的复杂性呢?手段有很多,我个人认为,最关键的就是解耦,保证代码松耦合、高内聚。如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。
我们在[第22讲](https://time.geekbang.org/column/article/179615)有介绍,什么是“高内聚、松耦合”。如果印象不深,你可以再去回顾一下。实际上,“高内聚、松耦合”是一个比较通用的设计思想,不仅可以指导细粒度的类和类之间关系的设计,还能指导粗粒度的系统、架构、模块的设计。相对于编码规范,它能够在更高层次上提高代码的可读性和可维护性。
不管是阅读代码还是修改代码“高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中不需要了解太多其他模块或类的代码让我们的焦点不至于过于发散降低了阅读和修改代码的难度。而且因为依赖关系简单耦合小修改代码不至于牵一发而动全身代码改动比较集中引入bug的风险也就减少了很多。同时“高内聚、松耦合”的代码可测试性也更加好容易mock或者很少需要mock外部依赖的模块或者类。
除此之外,代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。我们可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了。
## 代码是否需要“解耦”?
那现在问题来了,我们该怎么判断代码的耦合程度呢?或者说,怎么判断代码是否符合“高内聚、松耦合”呢?再或者说,如何判断系统是否需要解耦重构呢?
间接的衡量标准有很多,前面我们讲到了一些,比如,看修改代码会不会牵一发而动全身。除此之外,还有一个直接的衡量标准,也是我在阅读源码的时候经常会用到的,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,那我们就需要考虑是否可以通过解耦的方法,让依赖关系变得清晰、简单。当然,这种判断还是有比较强的主观色彩,但是可以作为一种参考和梳理依赖的手段,配合间接的衡量标准一块来使用。
## 如何给代码“解耦”?
前面我们能讲了解耦的重要性,以及如何判断是否需要解耦,接下来,我们再来看一下,如何进行解耦。
### 1.封装与抽象
封装和抽象作为两个非常通用的设计思想可以应用在很多设计场景中比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性隔离实现的易变性给依赖的模块提供稳定且易用的抽象接口。
比如Unix系统提供的open()文件操作函数我们用起来非常简单但是底层实现却非常复杂涉及权限控制、并发控制、物理存储等等。我们通过将其封装成一个抽象的open()函数能够有效控制代码复杂性的蔓延将复杂性封装在局部代码中。除此之外因为open()函数基于抽象而非具体的实现来定义所以我们在改动open()函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合我们前面提到的“高内聚、松耦合”代码的评判标准。
### 2.中间层
引入中间层能简化模块或类之间的依赖关系。下面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前A、B、C三个模块都要依赖内存一级缓存、Redis二级缓存、DB持久化存储三个模块。在引入中间层之后三个模块只需要依赖数据存储一个模块即可。从图上可以看出中间层的引入明显地简化了依赖关系让代码结构更加清晰。
<img src="https://static001.geekbang.org/resource/image/cb/52/cbcefa78026fd1d0cb9837dde9adae52.jpg" alt="">
除此之外,我们在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,我们需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改。
- 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
- 第二阶段:新开发的代码依赖中间层提供的新接口。
- 第三阶段:将依赖老接口的代码改为调用新接口。
- 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。
这样,每个阶段的开发工作量都不会很大,都可以在很短的时间内完成。重构跟开发冲突的概率也变小了。
### 3.模块化
模块化是构建复杂系统常用的手段。不仅在软件行业,在建筑、机械制造等行业,这个手段也非常有用。对于一个大型复杂系统来说,没有人能掌控所有的细节。之所以我们能搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
聚焦到软件开发上面很多大型软件比如Windows之所以能做到几百、上千人有条不紊地协作开发也归功于模块化做得好。不同的模块之间通过API来进行通信每个模块之间耦合很小每个小的团队聚焦于一个独立的高内聚模块来开发最终像搭积木一样将各个模块组装起来构建成一个超级复杂的系统。
我们再聚焦到代码层面。合理地划分模块能有效地解耦代码提高代码的可读性和可维护性。所以我们在开发代码的时候一定要有模块化意识将每个模块都当作一个独立的lib一样来开发只提供封装了内部实现细节的接口给其他模块使用这样可以减少不同模块之间的耦合度。
实际上从刚刚的讲解中我们也可以发现模块化的思想无处不在像SOA、微服务、lib库、系统内模块划分甚至是类、函数的设计都体现了模块化思想。如果追本溯源模块化思想更加本质的东西就是分而治之。
### 4.其他设计思想和原则
“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际上,在前面的章节中,我们已经多次提到过这个设计思想。很多设计原则都以实现代码的“高内聚、松耦合”为目的。我们来一块总结回顾一下都有哪些原则。
- 单一职责原则
我们前面提到,内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。
- 基于接口而非实现编程
基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)。
- 依赖注入
跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换。
- 多用组合少用继承
我们知道,继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段。
- 迪米特法则
迪米特法则讲的是不该有直接依赖关系的类之间不要有依赖有依赖关系的类之间尽量只依赖必要的接口。从定义上我们明显可以看出这条原则的目的就是为了实现代码的松耦合。至于如何应用这条原则来解耦代码你可以回过头去阅读一下第22讲这里我就不赘述了。
除了上面讲到的这些设计思想和原则之外,还有一些设计模式也是为了解耦依赖,比如观察者模式,有关这一部分的内容,我们留在设计模式模块中慢慢讲解。
## 重点回顾
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要重点掌握的内容。
**1.“解耦”为何如此重要?**
过于复杂的代码往往在可读性、可维护性上都不友好。解耦保证代码松耦合、高内聚,是控制代码复杂度的有效手段。代码高内聚、松耦合,也就是意味着,代码结构清晰、分层模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。
**2.代码是否需要“解耦”?**
间接的衡量标准有很多,比如,看修改代码是否牵一发而动全身。直接的衡量标准是把模块与模块、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
**3.如何给代码“解耦”?**
给代码解耦的方法有:封装与抽象、中间层、模块化,以及一些其他的设计思想与原则,比如:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则等。当然,还有一些设计模式,比如观察者模式。
## 课堂讨论
实际上在我们平时的开发中解耦的思想到处可见比如Spring中的AOP能实现业务与非业务代码的解耦IOC能实现对象的构造和使用的解耦。除此之外你还能想到哪些解耦的应用场景吗
欢迎在留言区写下你的思考和答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,163 @@
<audio id="audio" title="31 | 理论五让你最快速地改善代码质量的20条编程规范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/d9/35dcf295c6cbc3514ce0e6d98ca717d9.mp3"></audio>
前面我们讲了很多设计原则,后面还会讲到很多设计模式,利用好它们可以有效地改善代码质量。但是,这些知识的合理应用非常依赖个人经验,用不好有时候会适得其反。而我们接下来要讲的编码规范正好相反。编码规范大部分都简单明了,在代码细节方面,能立竿见影地改善质量。除此之外,我们前面也讲到,持续低层次、小规模重构依赖的基本上都是编码规范,这也是改善代码可读性的有效手段。
关于编码规范、如何编写可读代码很多书籍已经讲得很好了我在前面的加餐中也推荐过几本经典书籍。不过这里我根据我自己的开发经验总结罗列了20条我个人觉得最好用的编码规范。掌握这20条编码规范能你最快速地改善代码质量。因为内容比较多所以我分为三节课来讲解分别介绍编码规范的三个部分命名与注释Naming and Comments、代码风格Code Style和编程技巧Coding Tips
## 命名
大到项目名、模块名、包名、对外暴露的接口,小到类名、函数名、变量名、参数名,只要是做开发,我们就逃不过“起名字”这一关。命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。除此之外,命名能力也体现了一个程序员的基本编程素养。这也是我把“命名”放到第一个来讲解的原因。
取一个特别合适的名字是一件非常有挑战的事情,即便是对母语是英语的程序员来说,也是如此。而对于我们这些英语非母语的程序员来说,想要起一个能准确达意的名字,更是难上加难了。
实际上命名这件事说难也不难关键还是看你重不重视愿不愿意花时间。对于影响范围比较大的命名比如包名、接口、类名我们一定要反复斟酌、推敲。实在想不到好名字的时候可以去GitHub上用相关的关键词联想搜索一下看看类似的代码是怎么命名的。
那具体应该怎么命名呢好的命名有啥标准吗接下来我就从4点来讲解我的经验。
### 1.命名多长最合适?
在过往的团队和项目中,我遇到过两种截然不同的同事。有一种同事特别喜欢用很长的命名方式,觉得命名一定要准确达意,哪怕长一点也没关系,所以,这类同事的项目里,类名、函数名都很长。另外一种同事喜欢用短的命名方式,能用缩写就尽量用缩写,所以,项目里到处都是包含各种缩写的命名。你觉得这两种命名方式,哪种更值得推荐呢?
在我看来,尽管长的命名可以包含更多的信息,更能准确直观地表达意图,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长。在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。
实际上在足够表达其含义的情况下命名当然是越短越好。但是大部分情况下短的命名都没有长的命名更能达意。所以很多书籍或者文章都不推荐在命名时使用缩写。对于一些默认的、大家都比较熟知的词我比较推荐用缩写。这样一方面能让命名短一些另一方面又不影响阅读理解比如sec表示second、str表示string、num表示number、doc表示document。除此之外对于作用域比较小的变量我们可以使用相对短的命名比如一些函数内的临时变量。相反对于类名这种作用域比较大的我更推荐用长的命名方式。
总之,命名的一个原则就是以能准确达意为目标。不过,对于代码的编写者来说,自己对代码的逻辑很清楚,总感觉用什么样的命名都可以达意,实际上,对于不熟悉你代码的同事来讲,可能就不这么认为了。所以,命名的时候,我们一定要学会换位思考,假设自己不熟悉这块代码,从代码阅读者的角度去考量命名是否足够直观。
### 2.利用上下文简化命名
我们先来看一个简单的例子。
```
public class User {
private String userName;
private String userPassword;
private String userAvatarUrl;
//...
}
```
在User类这样一个上下文中我们没有在成员变量的命名中重复添加“user”这样一个前缀单词而是直接命名为name、password、avatarUrl。在使用这些属性时候我们能借助对象这样一个上下文表意也足够明确。具体代码如下所示
```
User user = new User();
user.getName(); // 借助user对象这个上下文
```
除了类之外,函数参数也可以借助函数这个上下文来简化命名。关于这一点,我举了下面这个例子,你一看就能明白,我就不多啰嗦了。
```
public void uploadUserAvatarImageToAliyun(String userAvatarImageUri);
//利用上下文简化为:
public void uploadUserAvatarImageToAliyun(String imageUri);
```
### 3.命名要可读、可搜索
首先,我们来看,什么是命名可读。先解释一下,我这里所说的“可读”,指的是不要用一些特别生僻、难发音的英文单词来命名。
过去我曾参加过两个项目一个叫plateaux另一个叫eyrie从项目立项到结束自始至终都没有几个人能叫对这两个项目的名字。在沟通的时候每当有人提到这两个项目的名字的时候都会尴尬地卡顿一下。虽然我们并不排斥一些独特的命名方式但起码得让大部分人看一眼就能知道怎么读。比如我在Google参与过的一个项目名叫inkstone虽然你不一定知道它表示什么意思但基本上都能读得上来不影响沟通交流这就算是一个比较好的项目命名。
我们再来讲一下命名可搜索。我们在IDE中编写代码的时候经常会用“关键词联想”的方法来自动补全和搜索。比如键入某个对象“.get”希望IDE返回这个对象的所有get开头的方法。再比如通过在IDE搜索框中输入“**Array**”搜索JDK中数组相关的类。所以我们在命名的时候最好能符合整个项目的命名习惯。大家都用“selectXXX”表示查询你就不要用“queryXXX”大家都用“insertXXX”表示插入一条数据你就要不用“addXXX”统一规约是很重要的能减少很多不必要的麻烦。
### 4.如何命名接口和抽象类?
对于接口的命名一般有两种比较常见的方式。一种是加前缀“I”表示一个Interface。比如IUserService对应的实现类命名为UserService。另一种是不加前缀比如UserService对应的实现类加后缀“Impl”比如UserServiceImpl。
对于抽象类的命名也有两种方式一种是带上前缀“Abstract”比如AbstractConfiguration另一种是不带前缀“Abstract”。实际上对于接口和抽象类选择哪种命名方式都是可以的只要项目里能够统一就行。
## 注释
命名很重要,注释跟命名同等重要。很多书籍认为,好的命名完全可以替代注释。如果需要注释,那说明命名不够好,需要在命名上下功夫,而不是添加注释。实际上,我个人觉得,这样的观点有点太过极端。命名再好,毕竟有长度限制,不可能足够详尽,而这个时候,注释就是一个很好的补充。
### 1.注释到底该写什么?
注释的目的就是让代码更容易看懂。只要符合这个要求的内容,你就可以将它写到注释里。总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。我来举一个例子给你具体解释一下。
```
/**
* (what) Bean factory to create beans.
*
* (why) The class likes Spring IOC framework, but is more lightweight.
*
* (how) Create objects from different sources sequentially:
* user specified object &gt; SPI &gt; configuration &gt; default object.
*/
public class BeansFactory {
// ...
}
```
有些人认为注释是要提供一些代码没有的额外信息所以不要写“做什么、怎么做”这两方面在代码中都可以体现出来只需要写清楚“为什么”表明代码的设计意图即可。我个人不是特别认可这样的观点理由主要有下面3点。
- 注释比代码承载的信息更多
命名的主要目的是解释“做什么”。比如void increaseWalletAvailableBalance(BigDecimal amount)表明这个函数用来增加钱包的可用余额boolean isValidatedPassword表明这个变量用来标识是否是合法密码。函数和变量如果命名得好确实可以不用再在注释中解释它是做什么的。但是对于类来说包含的信息比较多一个简单的命名就不够全面详尽了。这个时候在注释中写明“做什么”就合情合理了。
- 注释起到总结性作用、文档的作用
代码之下无秘密。阅读代码可以明确地知道代码是“怎么做”的,也就是知道代码是如何实现的,那注释中是不是就不用写“怎么做”了?实际上也可以写。在注释中,关于具体的代码实现思路,我们可以写一些总结性的说明、特殊情况的说明。这样能够让阅读代码的人通过注释就能大概了解代码的实现思路,阅读起来就会更加容易。
实际上对于有些比较复杂的类或者接口我们可能还需要在注释中写清楚“如何用”举一些简单的quick start的例子让使用者在不阅读代码的情况下快速地知道该如何使用。
- 一些总结性注释能让代码结构更清晰
对于逻辑比较复杂的代码或者比较长的函数,如果不好提炼、不好拆分成小的函数调用,那我们可以借助总结性的注释来让代码结构更清晰、更有条理。
```
public boolean isValidPasword(String password) {
// check if password is null or empty
if (StringUtils.isBlank(password)) {
return false;
}
// check if the length of password is between 4 and 64
int length = password.length();
if (length &lt; 4 || length &gt; 64) {
return false;
}
// check if password contains only a~z,0~9,dot
for (int i = 0; i &lt; length; ++i) {
char c = password.charAt(i);
if (!((c &gt;= 'a' &amp;&amp; c &lt;= 'z') || (c &gt;= '0' &amp;&amp; c &lt;= '9') || c == '.')) {
return false;
}
}
return true;
}
```
### 2.注释是不是越多越好?
注释太多和太少都有问题。太多,有可能意味着代码写得不够可读,需要写很多注释来补充。除此之外,注释太多也会对代码本身的阅读起到干扰。而且,后期的维护成本也比较高,有时候代码改了,注释忘了同步修改,就会让代码阅读者更加迷惑。当然,如果代码中一行注释都没有,那只能说明这个程序员很懒,我们要适当督促一下,让他注意添加一些必要的注释。
按照我的经验来说,类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。
## 重点总结
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
**1.关于命名**
- 命名的关键是能准确达意。对于不同作用域的命名,我们可以适当地选择不同的长度。作用域小的变量(比如临时变量),可以适当地选择短一些的命名方式。除此之外,命名中也可以使用一些耳熟能详的缩写。
- 我们可以借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。
- 命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名。除此之外,命名要符合项目的统一规范,不要用些反直觉的命名。
- 接口有两种命名方式一种是在接口中带前缀“I”另一种是在接口的实现类中带后缀“Impl”。对于抽象类的命名也有两种方式一种是带上前缀“Abstract”一种是不带前缀。这两种命名方式都可以关键是要在项目中统一。
**2.关于注释**
- 注释的目的就是让代码更容易看懂。只要符合这个要求的内容,你就可以将它写到注释里。总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
- 注释本身有一定的维护成本,所以并非越多越好。类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。
## 课堂讨论
1. 在讲到“用总结性注释让代码结构更清晰”的时候我们举了一个isValidPassword()函数的例子,在代码可读性方面,这个函数还有哪些可以继续优化的地方呢?
1. 关于注释,你推荐使用英文还是中文来书写呢?理由是什么呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,108 @@
<audio id="audio" title="32 | 理论五让你最快速地改善代码质量的20条编程规范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/f4/dd07309f7c110797dc92f7a7864d6ff4.mp3"></audio>
上一节课中我们讲了命名和注释这一节课我们来讲一下代码风格Code Style。说起代码风格我们其实很难说哪种风格更好。最重要的也是最需要我们做到的是在团队、项目中保持风格统一让代码像同一个人写出来的整齐划一。这样能减少阅读干扰提高代码的可读性。这才是我们在实际工作中想要实现的目标。
关于代码风格我总结了6点我认为最值得关注的今天跟你一块讨论学习一下。
## 1.类、函数多大才合适?
总体上来讲类或函数的代码行数不能太多但也不能太少。类或函数的代码行数太多一个类上千行一个函数几百行逻辑过于繁杂阅读代码的时候很容易就会看了后面忘了前面。相反类或函数的代码行数太少在代码总量相同的情况下被分割成的类和函数就会相应增多调用关系就会变得更复杂阅读某个代码逻辑的时候需要频繁地在n多类或者n多函数之间跳来跳去阅读体验也不好。
那一个类或函数有多少行代码才最合适呢?
我们在[第15讲](https://time.geekbang.org/column/article/171771)中提到过,要给出一个精确的量化值是很难的。当时我们还跟做饭做了类比,对于“放盐少许”中的“少许”,即便是大厨也很难告诉你一个特别具体的量值。
对于函数代码行数的最大限制网上有一种说法那就是不要超过一个显示屏的垂直高度。比如在我的电脑上如果要让一个函数的代码完整地显示在IDE中那最大代码行数不能超过50。这个说法我觉得挺有道理的。因为超过一屏之后在阅读代码的时候为了串联前后的代码逻辑就可能需要频繁地上下滚动屏幕阅读体验不好不说还容易出错。
对于类的代码行数的最大限制这个就更难给出一个确切的值了。我们在第15讲中也给出了一个间接的判断标准那就是当一个类的代码读起来让你感觉头大了实现某个功能时不知道该用哪个函数了想用哪个函数翻半天都找不到了只用到一个小功能要引入整个类类中包含很多无关此功能实现的函数的时候这就说明类的行数过多了。
## 2.一行代码多长最合适?
在[Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)文档中一行代码最长限制为100个字符。不过不同的编程语言、不同的规范、不同的项目团队对此的限制可能都不相同。不管这个限制是多少总体上来讲我们要遵循的一个原则是一行代码最长不能超过IDE显示的宽度。需要滚动鼠标才能查看一行的全部代码显然不利于代码的阅读。当然这个限制也不能太小太小会导致很多稍长点的语句被折成两行也会影响到代码的整洁不利于阅读。
## 3.善用空行分割单元块
对于比较长的函数,如果逻辑上可以分为几个独立的代码块,在不方便将这些独立的代码块抽取成小函数的情况下,为了让逻辑更加清晰,除了上一节课中提到的用总结性注释的方法之外,我们还可以使用空行来分割各个代码块。
除此之外,在类的成员变量与函数之间、静态成员变量与普通成员变量之间、各函数之间、甚至各成员变量之间,我们都可以通过添加空行的方式,让这些不同模块的代码之间,界限更加明确。写代码就类似写文章,善于应用空行,可以让代码的整体结构看起来更加有清晰、有条理。
## 4.四格缩进还是两格缩进?
“PHP是世界上最好的编程语言代码换行应该四格缩进还是两格缩进”这应该是程序员争论得最多的两个话题了。据我所知Java语言倾向于两格缩进PHP语言倾向于四格缩进。至于到底应该是两格缩进还是四格缩进我觉得这个取决于个人喜好。只要项目内部能够统一就行了。
当然,还有一个选择的标准,那就是跟业内推荐的风格统一、跟著名开源项目统一。当我们需要拷贝一些开源的代码到项目里的时候,能够让引入的代码跟我们项目本身的代码,保持风格统一。
不过,我个人比较推荐使用两格缩进,这样可以节省空间。特别是在代码嵌套层次比较深的情况下,累计缩进较多的话,容易导致一个语句被折成两行,影响代码可读性。
除此之外值得强调的是不管是用两格缩进还是四格缩进一定不要用tab键缩进。因为在不同的IDE下tab键的显示宽度不同有的显示为四格缩进有的显示为两格缩进。如果在同一个项目中不同的同事使用不同的缩进方式空格缩进或tab键缩进有可能会导致有的代码显示为两格缩进、有的代码显示为四格缩进。
## 5.大括号是否要另起一行?
左大括号是否要另起一行呢这个也有争论。据我所知PHP程序员喜欢另起一行Java程序员喜欢跟上一条语句放到一起。具体代码示例如下所示
```
// PHP
class ClassName
{
public function foo()
{
// method body
}
}
// Java
public class ClassName {
public void foo() {
// method body
}
}
```
我个人还是比较推荐,将括号放到跟语句同一行的风格。理由跟上面类似,节省代码行数。但是将大括号另起新的一行的方式,也有它的优势。这样的话,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更一目了然。
不过,还是那句话,大括号跟上一条语句在同一行,还是另起新的一行,只要团队统一、业内统一、跟开源项目看齐就好了,没有绝对的优劣之分。
## 6.类中成员的排列顺序
在Java类文件中先要书写类所属的包名然后再罗列import引入的依赖类。在Google编码规范中依赖类按照字母序从小到大排列。
在类中成员变量排在函数的前面。成员变量之间或函数之间都是按照“先静态静态函数或静态成员变量、后普通非静态函数或非静态成员变量”的方式来排列的。除此之外成员变量之间或函数之间还会按照作用域范围从大到小的顺序来排列先写public成员变量或函数然后是protected的最后是private的。
不过不同的编程语言中类内部成员的排列顺序可能会有比较大的差别。比如C++中成员变量会习惯性放到函数后面。除此之外函数之间的排列顺序会按照刚刚我们提到的作用域的大小来排列。实际上还有另外一种排列习惯那就是把有调用关系的函数放到一块。比如一个public函数调用了另外一个private函数那就把这两者放到一块。
## 重点回顾
好了今天的内容到此就讲完了。我们一块来总结回顾一下你需要重点掌握的内容。这一节课我们通过6点来给你讲了代码风格中的注意点。
**1.函数、类多大才合适?**
函数的代码行数不要超过一屏幕的大小比如50行。类的大小限制比较难确定。
**2.一行代码多长最合适?**
最好不要超过IDE显示的宽度。当然限制也不能太小太小会导致很多稍微长点的语句被折成两行也会影响到代码的整洁不利于阅读。
**3.善用空行分割单元块**
对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。在类内部,成员变量与函数之间、静态成员变量与普通成员变量之间、函数之间,甚至成员变量之间,都可以通过添加空行的方式,让不同模块的代码之间的界限更加明确。
**4.四格缩进还是两格缩进?**
我个人比较推荐使用两格缩进这样可以节省空间特别是在代码嵌套层次比较深的情况下。除此之外值得强调的是不管是用两格缩进还是四格缩进一定不要用tab键缩进。
**5.大括号是否要另起一行?**
我个人还是比较推荐将大括号放到跟上一条语句同一行的风格,这样可以节省代码行数。但是,将大括号另起一行,也有它的优势,那就是,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更加一目了然。
**6.类中成员的排列顺序**
在Google Java编程规范中依赖类按照字母序从小到大排列。类中先写成员变量后写函数。成员变量之间或函数之间先写静态成员变量或函数后写普通变量或函数并且按照作用域大小依次排列。
今天讲到所有的代码风格都没有对错和优劣之分,只要能在团队、项目中统一即可,不过,最好能跟业内推荐的风格、开源项目的代码风格相一致。
## 课堂讨论
聊一聊你熟悉的编程语言的代码风格,比如是四格缩进还是两格缩进?试着给自己的项目整理一份编程规范。
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,433 @@
<audio id="audio" title="33 | 理论五让你最快速地改善代码质量的20条编程规范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/f5/3d70a002608ebd837fe6dc3c6f899ff5.mp3"></audio>
上两节课,我们讲了命名和注释、代码风格,今天我们来讲一些比较实用的编程技巧,帮你切实地提高代码可读性。这部分技巧比较琐碎,也很难罗列全面,我仅仅总结了一些我认为比较关键的,更多的技巧需要你在实践中自己慢慢总结、积累。
话不多说,让我们正式开始今天的学习吧!
## 1.把代码分割成更小的单元块
大部分人阅读代码的习惯都是,先看整体再看细节。所以,我们要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性。不过,只有代码逻辑比较复杂的时候,我们其实才建议提炼类或者函数。毕竟如果提炼出的函数只包含两三行代码,在阅读代码的时候,还得跳过去看一下,这样反倒增加了阅读成本。
这里我举一个例子来进一步解释一下。代码具体如下所示。重构前在invest()函数中最开始的那段关于时间处理的代码是不是很难看懂重构之后我们将这部分逻辑抽象成一个函数并且命名为isLastDayOfMonth从名字就能清晰地了解它的功能判断今天是不是当月的最后一天。这里我们就是通过将复杂的逻辑代码提炼成函数大大提高了代码的可读性。
```
// 重构前的代码
public void invest(long userId, long financialProductId) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return;
}
//...
}
// 重构后的代码:提炼函数之后逻辑更加清晰
public void invest(long userId, long financialProductId) {
if (isLastDayOfMonth(new Date())) {
return;
}
//...
}
public boolean isLastDayOfMonth(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return true;
}
return false;
}
```
## 2.避免函数参数过多
我个人觉得函数包含3、4个参数的时候还是能接受的大于等于5个的时候我们就觉得参数有点过多了会影响到代码的可读性使用起来也不方便。针对参数过多的情况一般有2种处理方法。
- 考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。示例代码如下所示:
```
public User getUser(String username, String telephone, String email);
// 拆分成多个函数
public User getUserByUsername(String username);
public User getUserByTelephone(String telephone);
public User getUserByEmail(String email);
```
- 将函数的参数封装成对象。示例代码如下所示:
```
public void postBlog(String title, String summary, String keywords, String content, String category, long authorId);
// 将参数封装成对象
public class Blog {
private String title;
private String summary;
private String keywords;
private Strint content;
private String category;
private long authorId;
}
public void postBlog(Blog blog);
```
除此之外,如果函数是对外暴露的远程接口,将参数封装成对象,还可以提高接口的兼容性。在往接口中添加新的参数的时候,老的远程接口调用者有可能就不需要修改代码来兼容新的接口了。
## 3.勿用函数参数来控制逻辑
不要在函数中使用布尔类型的标识参数来控制内部逻辑true的时候走这块逻辑false的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。我建议将其拆成两个函数可读性上也要更好。我举个例子来说明一下。
```
public void buyCourse(long userId, long courseId, boolean isVip);
// 将其拆分成两个函数
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);
```
不过如果函数是private私有函数影响范围有限或者拆分之后的两个函数经常同时被调用我们可以酌情考虑保留标识参数。示例代码如下所示
```
// 拆分成两个函数的调用方式
boolean isVip = false;
//...省略其他逻辑...
if (isVip) {
buyCourseForVip(userId, courseId);
} else {
buyCourse(userId, courseId);
}
// 保留标识参数的调用方式更加简洁
boolean isVip = false;
//...省略其他逻辑...
buyCourse(userId, courseId, isVip);
```
除了布尔类型作为标识参数来控制逻辑的情况外还有一种“根据参数是否为null”来控制逻辑的情况。针对这种情况我们也应该将其拆分成多个函数。拆分之后的函数职责更明确不容易用错。具体代码示例如下所示
```
public List&lt;Transaction&gt; selectTransactions(Long userId, Date startDate, Date endDate) {
if (startDate != null &amp;&amp; endDate != null) {
// 查询两个时间区间的transactions
}
if (startDate != null &amp;&amp; endDate == null) {
// 查询startDate之后的所有transactions
}
if (startDate == null &amp;&amp; endDate != null) {
// 查询endDate之前的所有transactions
}
if (startDate == null &amp;&amp; endDate == null) {
// 查询所有的transactions
}
}
// 拆分成多个public函数更加清晰、易用
public List&lt;Transaction&gt; selectTransactionsBetween(Long userId, Date startDate, Date endDate) {
return selectTransactions(userId, startDate, endDate);
}
public List&lt;Transaction&gt; selectTransactionsStartWith(Long userId, Date startDate) {
return selectTransactions(userId, startDate, null);
}
public List&lt;Transaction&gt; selectTransactionsEndWith(Long userId, Date endDate) {
return selectTransactions(userId, null, endDate);
}
public List&lt;Transaction&gt; selectAllTransactions(Long userId) {
return selectTransactions(userId, null, null);
}
private List&lt;Transaction&gt; selectTransactions(Long userId, Date startDate, Date endDate) {
// ...
}
```
## 4.函数设计要职责单一
我们在前面讲到单一职责原则的时候,针对的是类、模块这样的应用对象。实际上,对于函数的设计来说,更要满足单一职责原则。相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一。
具体的代码示例如下所示:
```
public boolean checkUserIfExisting(String telephone, String username, String email) {
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}
if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}
if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}
return false;
}
// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);
```
## 5.移除过深的嵌套层次
代码嵌套层次过深往往是因为if-else、switch-case、for循环过度嵌套导致的。我个人建议嵌套最好不超过两层超过两层之后就要思考一下是否可以减少嵌套。过深的嵌套本身理解起来就比较费劲除此之外嵌套过深很容易因为代码多次缩进导致嵌套内部的语句超过一行的长度而折成两行影响代码的整洁。
解决嵌套过深的方法也比较成熟有下面4种常见的思路。
- 去掉多余的if或else语句。代码示例如下所示
```
// 示例一
public double caculateTotalAmount(List&lt;Order&gt; orders) {
if (orders == null || orders.isEmpty()) {
return 0.0;
} else { // 此处的else可以去掉
double amount = 0.0;
for (Order order : orders) {
if (order != null) {
amount += (order.getCount() * order.getPrice());
}
}
return amount;
}
}
// 示例二
public List&lt;String&gt; matchStrings(List&lt;String&gt; strList,String substr) {
List&lt;String&gt; matchedStrings = new ArrayList&lt;&gt;();
if (strList != null &amp;&amp; substr != null) {
for (String str : strList) {
if (str != null) { // 跟下面的if语句可以合并在一起
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
}
return matchedStrings;
}
```
- 使用编程语言提供的continue、break、return关键字提前退出嵌套。代码示例如下所示
```
// 重构前的代码
public List&lt;String&gt; matchStrings(List&lt;String&gt; strList,String substr) {
List&lt;String&gt; matchedStrings = new ArrayList&lt;&gt;();
if (strList != null &amp;&amp; substr != null){
for (String str : strList) {
if (str != null &amp;&amp; str.contains(substr)) {
matchedStrings.add(str);
// 此处还有10行代码...
}
}
}
return matchedStrings;
}
// 重构后的代码使用continue提前退出
public List&lt;String&gt; matchStrings(List&lt;String&gt; strList,String substr) {
List&lt;String&gt; matchedStrings = new ArrayList&lt;&gt;();
if (strList != null &amp;&amp; substr != null){
for (String str : strList) {
if (str == null || !str.contains(substr)) {
continue;
}
matchedStrings.add(str);
// 此处还有10行代码...
}
}
return matchedStrings;
}
```
- 调整执行顺序来减少嵌套。具体的代码示例如下所示:
```
// 重构前的代码
public List&lt;String&gt; matchStrings(List&lt;String&gt; strList,String substr) {
List&lt;String&gt; matchedStrings = new ArrayList&lt;&gt;();
if (strList != null &amp;&amp; substr != null) {
for (String str : strList) {
if (str != null) {
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
}
return matchedStrings;
}
// 重构后的代码:先执行判空逻辑,再执行正常逻辑
public List&lt;String&gt; matchStrings(List&lt;String&gt; strList,String substr) {
if (strList == null || substr == null) { //先判空
return Collections.emptyList();
}
List&lt;String&gt; matchedStrings = new ArrayList&lt;&gt;();
for (String str : strList) {
if (str != null) {
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
return matchedStrings;
}
```
- 将部分嵌套逻辑封装成函数调用,以此来减少嵌套。具体的代码示例如下所示:
```
// 重构前的代码
public List&lt;String&gt; appendSalts(List&lt;String&gt; passwords) {
if (passwords == null || passwords.isEmpty()) {
return Collections.emptyList();
}
List&lt;String&gt; passwordsWithSalt = new ArrayList&lt;&gt;();
for (String password : passwords) {
if (password == null) {
continue;
}
if (password.length() &lt; 8) {
// ...
} else {
// ...
}
}
return passwordsWithSalt;
}
// 重构后的代码:将部分逻辑抽成函数
public List&lt;String&gt; appendSalts(List&lt;String&gt; passwords) {
if (passwords == null || passwords.isEmpty()) {
return Collections.emptyList();
}
List&lt;String&gt; passwordsWithSalt = new ArrayList&lt;&gt;();
for (String password : passwords) {
if (password == null) {
continue;
}
passwordsWithSalt.add(appendSalt(password));
}
return passwordsWithSalt;
}
private String appendSalt(String password) {
String passwordWithSalt = password;
if (password.length() &lt; 8) {
// ...
} else {
// ...
}
return passwordWithSalt;
}
```
除此之外常用的还有通过使用多态来替代if-else、switch-case条件判断的方法。这个思路涉及代码结构的改动我们会在后面的章节中讲到这里就暂时不展开说明了。
## 6.学会使用解释性变量
常用的用解释性变量来提高代码的可读性的情况有下面2种。
- 常量取代魔法数字。示例代码如下所示:
```
public double CalculateCircularArea(double radius) {
return (3.1415) * radius * radius;
}
// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
return PI * radius * radius;
}
```
- 使用解释性变量来解释复杂表达式。示例代码如下所示:
```
if (date.after(SUMMER_START) &amp;&amp; date.before(SUMMER_END)) {
// ...
} else {
// ...
}
// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&amp;&amp;date.before(SUMMER_END);
if (isSummer) {
// ...
} else {
// ...
}
```
## 重点回顾
好了,今天的内容到此就讲完了。除了今天讲的编程技巧,前两节课我们还分别讲解了命名与注释、代码风格。现在,我们一块来回顾复习一下这三节课的重点内容。
**1.关于命名**
- 命名的关键是能准确达意。对于不同作用域的命名,我们可以适当地选择不同的长度。
- 我们可以借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。
- 命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名。命名要符合项目的统一规范,也不要用些反直觉的命名。
- 接口有两种命名方式一种是在接口中带前缀“I”另一种是在接口的实现类中带后缀“Impl”。对于抽象类的命名也有两种方式一种是带上前缀“Abstract”一种是不带前缀。这两种命名方式都可以关键是要在项目中统一。
**2.关于注释**
- 注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
- 类和函数一定要写注释,而且要写得尽可能全面详细。函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。
**3.关于代码风格**
- 函数、类多大才合适函数的代码行数不要超过一屏幕的大小比如50行。类的大小限制比较难确定。
- 一行代码多长最合适最好不要超过IDE的显示宽度。当然也不能太小否则会导致很多稍微长点的语句被折成两行也会影响到代码的整洁不利于阅读。
- 善用空行分割单元块。对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。
- 四格缩进还是两格缩进我个人比较推荐使用两格缩进这样可以节省空间尤其是在代码嵌套层次比较深的情况下。不管是用两格缩进还是四格缩进一定不要用tab键缩进。
- 大括号是否要另起一行?将大括号放到跟上一条语句同一行,可以节省代码行数。但是将大括号另起新的一行的方式,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更加一目了然。
- 类中成员怎么排列在Google Java编程规范中依赖类按照字母序从小到大排列。类中先写成员变量后写函数。成员变量之间或函数之间先写静态成员变量或函数后写普通变量或函数并且按照作用域大小依次排列。
**4.关于编码技巧**
- 将复杂的逻辑提炼拆分成函数和类。
- 通过拆分成多个函数或将参数封装为对象的方式,来处理参数过多的情况。
- 函数中不要使用参数来做代码执行逻辑的控制。
- 函数设计要职责单一。
- 移除过深的嵌套层次方法包括去掉多余的if或else语句使用continue、break、return关键字提前退出嵌套调整执行顺序来减少嵌套将部分嵌套逻辑抽象成函数。
- 用字面常量取代魔法数。
- 用解释性变量来解释复杂表达式,以此提高代码可读性。
**5.统一编码规范**
除了这三节讲到的比较细节的知识点之外最后还有一条非常重要的那就是项目、团队甚至公司一定要制定统一的编码规范并且通过Code Review督促执行这对提高代码质量有立竿见影的效果。
## 课堂讨论
到此为止我们整个20条编码规范就讲完了。不知道你掌握了多少呢除了今天我提到的这些还有哪些其他的编程技巧可以明显改善代码的可读性
试着在留言区总结罗列一下,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,145 @@
<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类中呢
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,395 @@
<audio id="audio" title="35 | 实战一手把手带你将ID生成器代码从“能用”重构为“好用”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/1d/bc63de6342a76d9c2604f9f11d220d1d.mp3"></audio>
上一节课中我们结合ID生成器代码讲解了如何发现代码质量问题。虽然ID生成器的需求非常简单代码行数也不多但看似非常简单的代码实际上还是有很多优化的空间。综合评价一下的话小王的代码也只能算是“能用”、勉强及格。我们大部分人写出来的代码都能达到这个程度。如果想要在团队中脱颖而出我们就不能只满足于这个60分及格大家都能做的事情我们要做得更好才行。
上一节课我们讲了为什么这份代码只能得60分这一节课我们再讲一下如何将60分的代码重构为80分、90分让它从“能用”变得“好用”。话不多说让我们正式开始今天的学习吧
## 回顾代码和制定重构计划
为了方便你查看和对比,我把上一节课中的代码拷贝到这里。
```
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;
}
}
```
前面讲到系统设计和实现的时候,我们多次讲到要循序渐进、小步快跑。重构代码的过程也应该遵循这样的思路。每次改动一点点,改好之后,再进行下一轮的优化,保证每次对代码的改动不会过大,能在很短的时间内完成。所以,我们将上一节课中发现的代码质量问题,分成四次重构来完成,具体如下所示。
- 第一轮重构:提高代码的可读性
- 第二轮重构:提高代码的可测试性
- 第三轮重构:编写完善的单元测试
- 第四轮重构:所有重构完成之后添加注释
## 第一轮重构:提高代码的可读性
首先,我们要解决最明显、最急需改进的代码可读性问题。具体有下面几点:
- hostName变量不应该被重复使用尤其当这两次使用时的含义还不同的时候
- 将获取hostName的代码抽离出来定义为getLastfieldOfHostName()函数;
- 删除代码中的魔法数比如57、90、97、122
- 将随机数生成的代码抽离出来定义为generateRandomAlphameric()函数;
- generate()函数中的三个if逻辑重复了且实现过于复杂我们要对其进行简化
- 对IdGenerator类重命名并且抽象出对应的接口。
这里我们重点讨论下最后一个修改。实际上对于ID生成器的代码有下面三种类的命名方式。你觉得哪种更合适呢
<img src="https://static001.geekbang.org/resource/image/8f/6b/8f0de72351eeb9138c7a3b8199767a6b.jpg" alt="">
我们来逐一分析一下三种命名方式。
第一种命名方式将接口命名为IdGenerator实现类命名为LogTraceIdGenerator这可能是很多人最先想到的命名方式了。在命名的时候我们要考虑到以后两个类会如何使用、会如何扩展。从使用和扩展的角度来分析这样的命名就不合理了。
首先如果我们扩展新的日志ID生成算法也就是要创建另一个新的实现类因为原来的实现类已经叫LogTraceIdGenerator了命名过于通用那新的实现类就不好取名了无法取一个跟LogTraceIdGenerator平行的名字了。
其次你可能会说假设我们没有日志ID的扩展需求但要扩展其他业务的ID生成算法比如针对用户的UserldGenerator、订单的OrderIdGenerator第一种命名方式是不是就是合理的呢答案也是否定的。基于接口而非实现编程主要的目的是为了方便后续灵活地替换实现类。而LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator三个类从命名上来看涉及的是完全不同的业务不存在互相替换的场景。也就是说我们不可能在有关日志的代码中进行下面这种替换。所以让这三个类实现同一个接口实际上是没有意义的。
```
IdGenearator idGenerator = new LogTraceIdGenerator();
替换为:
IdGenearator idGenerator = new UserIdGenerator();
```
第二种命名方式是不是就合理了呢答案也是否定的。其中LogTraceIdGenerator接口的命名是合理的但是HostNameMillisIdGenerator实现类暴露了太多实现细节只要代码稍微有所改动就可能需要改动命名才能匹配实现。
第三种命名方式是我比较推荐的。在目前的ID生成器代码实现中我们生成的ID是一个随机ID不是递增有序的所以命名成RandomIdGenerator是比较合理的即便内部生成算法有所改动只要生成的还是随机的ID就不需要改动命名。如果我们需要扩展新的ID生成算法比如要实现一个递增有序的ID生成算法那我们可以命名为SequenceIdGenerator。
实际上更好的一种命名方式是我们抽象出两个接口一个是IdGenerator一个是LogTraceIdGeneratorLogTraceIdGenerator继承IdGenerator。实现类实现接口LogTraceIdGenerator命名为RandomIdGenerator、SequenceIdGenerator等。这样实现类可以复用到多个业务模块中比如前面提到的用户、订单。
根据上面的优化策略,我们对代码进行第一轮的重构,重构之后的代码如下所示:
```
public interface IdGenerator {
String generate();
}
public interface LogTraceIdGenerator extends IdGenerator {
}
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);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split(&quot;\\.&quot;);
substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
} catch (UnknownHostException e) {
logger.warn(&quot;Failed to get the host name.&quot;, e);
}
return substrOfHostName;
}
private String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count &lt; length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii &gt;= '0' &amp;&amp; randomAscii &lt;= '9';
boolean isUppercase= randomAscii &gt;= 'A' &amp;&amp; randomAscii &lt;= 'Z';
boolean isLowercase= randomAscii &gt;= 'a' &amp;&amp; randomAscii &lt;= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
//代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();
```
## 第二轮重构:提高代码的可测试性
关于代码可测试性的问题,主要包含下面两个方面:
- generate()函数定义为静态函数,会影响使用该函数的代码的可测试性;
- generate()函数的代码实现依赖运行环境本机名、时间函数、随机函数所以generate()函数本身的可测试性也不好。
对于第一点我们已经在第一轮重构中解决了。我们将RandomIdGenerator类中的generate()静态函数重新定义成了普通函数。调用者可以通过依赖注入的方式在外部创建好RandomIdGenerator对象后注入到自己的代码中从而解决静态函数调用影响代码可测试性的问题。
对于第二点,我们需要在第一轮重构的基础之上再进行重构。重构之后的代码如下所示,主要包括以下几个代码改动。
- 从getLastfieldOfHostName()函数中将逻辑比较复杂的那部分代码剥离出来定义为getLastSubstrSplittedByDot()函数。因为getLastfieldOfHostName()函数依赖本地主机名所以剥离出主要代码之后这个函数变得非常简单可以不用测试。我们重点测试getLastSubstrSplittedByDot()函数即可。
- 将generateRandomAlphameric()和getLastSubstrSplittedByDot()这两个函数的访问权限设置为protected。这样做的目的是可以直接在单元测试中通过对象来调用两个函数进行测试。
- 给generateRandomAlphameric()和getLastSubstrSplittedByDot()两个函数添加Google Guava的annotation @VisibleForTesting。这个annotation没有任何实际的作用只起到标识的作用告诉其他人说这两个函数本该是private访问权限的之所以提升访问权限到protected只是为了测试只能用于单元测试中。
```
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);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn(&quot;Failed to get the host name.&quot;, e);
}
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split(&quot;\\.&quot;);
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count &lt; length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii &gt;= '0' &amp;&amp; randomAscii &lt;= '9';
boolean isUppercase= randomAscii &gt;= 'A' &amp;&amp; randomAscii &lt;= 'Z';
boolean isLowercase= randomAscii &gt;= 'a' &amp;&amp; randomAscii &lt;= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
```
在上一节课的课堂讨论中我们提到打印日志的Logger对象被定义为static final的并且在类内部创建这是否影响到代码的可测试性是否应该将Logger对象通过依赖注入的方式注入到类中呢
依赖注入之所以能提高代码可测试性主要是因为通过这样的方式我们能轻松地用mock对象替换依赖的真实对象。那我们为什么要mock这个对象呢这是因为这个对象参与逻辑执行比如我们要依赖它输出的数据做后续的计算但又不可控。对于Logger对象来说我们只往里写入数据并不读取数据不参与业务逻辑的执行不会影响代码逻辑的正确性所以我们没有必要mock Logger对象。
除此之外一些只是为了存储数据的值对象比如String、Map、UseVo我们也没必要通过依赖注入的方式来创建直接在类中通过new创建就可以了。
## 第三轮重构:编写完善的单元测试
经过上面的重构之后代码存在的比较明显的问题基本上都已经解决了。我们现在为代码补全单元测试。RandomIdGenerator类中有4个函数。
```
public String generate();
private String getLastfieldOfHostName();
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName);
@VisibleForTesting
protected String generateRandomAlphameric(int length);
```
我们先来看后两个函数。这两个函数包含的逻辑比较复杂是我们测试的重点。而且在上一步重构中为了提高代码的可测试性我们已经将这两个部分代码跟不可控的组件本机名、随机函数、时间函数进行了隔离。所以我们只需要设计完备的单元测试用例即可。具体的代码实现如下所示注意我们使用了JUnit测试框架
```
public class RandomIdGeneratorTest {
@Test
public void testGetLastSubstrSplittedByDot() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualSubstr = idGenerator.getLastSubstrSplittedByDot(&quot;field1.field2.field3&quot;);
Assert.assertEquals(&quot;field3&quot;, actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot(&quot;field1&quot;);
Assert.assertEquals(&quot;field1&quot;, actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot(&quot;field1#field2$field3&quot;);
Assert.assertEquals(&quot;field1#field2#field3&quot;, actualSubstr);
}
// 此单元测试会失败因为我们在代码中没有处理hostName为null或空字符串的情况
// 这部分优化留在第36、37节课中讲解
@Test
public void testGetLastSubstrSplittedByDot_nullOrEmpty() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualSubstr = idGenerator.getLastSubstrSplittedByDot(null);
Assert.assertNull(actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot(&quot;&quot;);
Assert.assertEquals(&quot;&quot;, actualSubstr);
}
@Test
public void testGenerateRandomAlphameric() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualRandomString = idGenerator.generateRandomAlphameric(6);
Assert.assertNotNull(actualRandomString);
Assert.assertEquals(6, actualRandomString.length());
for (char c : actualRandomString.toCharArray()) {
Assert.assertTrue(('0' &lt; c &amp;&amp; c &lt; '9') || ('a' &lt; c &amp;&amp; c &lt; 'z') || ('A' &lt; c &amp;&amp; c &lt; 'Z'));
}
}
// 此单元测试会失败因为我们在代码中没有处理length&lt;=0的情况
// 这部分优化留在第36、37节课中讲解
@Test
public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualRandomString = idGenerator.generateRandomAlphameric(0);
Assert.assertEquals(&quot;&quot;, actualRandomString);
actualRandomString = idGenerator.generateRandomAlphameric(-1);
Assert.assertNull(actualRandomString);
}
}
```
我们再来看generate()函数。这个函数也是我们唯一一个暴露给外部使用的public函数。虽然逻辑比较简单最好还是测试一下。但是它依赖主机名、随机函数、时间函数我们该如何测试呢需要mock这些函数的实现吗
实际上这要分情况来看。我们前面讲过写单元测试的时候测试对象是函数定义的功能而非具体的实现逻辑。这样我们才能做到函数的实现逻辑改变了之后单元测试用例仍然可以工作。那generate()函数实现的功能是什么呢?这完全是由代码编写者自己来定义的。
比如针对同一份generate()函数的代码实现我们可以有3种不同的功能定义对应3种不同的单元测试。
1. 如果我们把generate()函数的功能定义为“生成一个随机唯一ID”那我们只要测试多次调用generate()函数生成的ID是否唯一即可。
1. 如果我们把generate()函数的功能定义为“生成一个只包含数字、大小写字母和中划线的唯一ID”那我们不仅要测试ID的唯一性还要测试生成的ID是否只包含数字、大小写字母和中划线。
1. 如果我们把generate()函数的功能定义为“生成唯一ID格式为{主机名substr}-{时间戳}-{8位随机数}。在主机名获取失败时返回null-{时间戳}-{8位随机数}”那我们不仅要测试ID的唯一性还要测试生成的ID是否完全符合格式要求。
**总结一下,单元测试用例如何写,关键看你如何定义函数。**针对generate()函数的前两种定义我们不需要mock获取主机名函数、随机函数、时间函数等但对于第3种定义我们需要mock获取主机名函数让其返回null测试代码运行是否符合预期。
最后我们来看下getLastfieldOfHostName()函数。实际上这个函数不容易测试因为它调用了一个静态函数InetAddress.getLocalHost().getHostName();并且这个静态函数依赖运行环境。但是这个函数的实现非常简单肉眼基本上可以排除明显的bug所以我们可以不为其编写单元测试代码。毕竟我们写单元测试的目的是为了减少代码bug而不是为了写单元测试而写单元测试。
当然如果你真的想要对它进行测试我们也是有办法的。一种办法是使用更加高级的测试框架。比如PowerMock它可以mock静态函数。另一种方式是将获取本机名的逻辑再封装为一个新的函数。不过后一种方法会造成代码过度零碎也会稍微影响到代码的可读性这个需要你自己去权衡利弊来做选择。
## 第四轮重构:添加注释
前面我们提到注释不能太多也不能太少主要添加在类和函数上。有人说好的命名可以替代注释清晰的表达含义。这点对于变量的命名来说是适用的但对于类或函数来说就不一定对了。类或函数包含的逻辑往往比较复杂单纯靠命名很难清晰地表明实现了什么功能这个时候我们就需要通过注释来补充。比如前面我们提到的对于generate()函数的3种功能定义就无法用命名来体现需要补充到注释里面。
对于如何写注释,你可以参看我们在[第31节课](https://time.geekbang.org/column/article/188622)中的讲解。总结一下,主要就是写清楚:做什么、为什么、怎么做、怎么用,对一些边界条件、特殊情况进行说明,以及对函数输入、输出、异常进行说明。
```
/**
* Id Generator that is used to generate random IDs.
*
* &lt;p&gt;
* The IDs generated by this class are not absolutely unique,
* but the probability of duplication is very low.
*/
public class RandomIdGenerator implements LogTraceIdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
/**
* Generate the random ID. The IDs may be duplicated only in extreme situation.
*
* @return an random ID
*/
@Override
public String generate() {
//...
}
/**
* Get the local hostname and
* extract the last field of the name string splitted by delimiter '.'.
*
* @return the last field of hostname. Returns null if hostname is not obtained.
*/
private String getLastfieldOfHostName() {
//...
}
/**
* Get the last field of {@hostName} splitted by delemiter '.'.
*
* @param hostName should not be null
* @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
*/
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
//...
}
/**
* Generate random string which
* only contains digits, uppercase letters and lowercase letters.
*
* @param length should not be less than 0
* @return the random string. Returns empty string if {@length} is 0
*/
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
//...
}
}
```
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。
在这节课中,我带你将小王写的凑活能用的代码,重构成了结构更加清晰、更加易读、更易测试的代码,并且为其补全了单元测试。这其中涉及的知识点都是我们在理论篇中讲过的内容,比较细节和零碎,我就不一一带你回顾了,如果哪里不是很清楚,你可以回到前面章节去复习一下。
实际上,通过这节课,我更想传达给你的是下面这样几个开发思想,我觉得这比我给你讲解具体的知识点更加有意义。
1. 即便是非常简单的需求不同水平的人写出来的代码差别可能会很大。我们要对代码质量有所追求不能只是凑活能用就好。花点心思写一段高质量的代码比写100段凑活能用的代码对你的代码能力提高更有帮助。
1. 知其然知其所以然,了解优秀代码设计的演变过程,比学习优秀设计本身更有价值。知道为什么这么做,比单纯地知道怎么做更重要,这样可以避免你过度使用设计模式、思想和原则。
1. 设计思想、原则、模式本身并没有太多“高大上”的东西,都是一些简单的道理,而且知识点也并不多,关键还是锻炼具体代码具体分析的能力,把知识点恰当地用在项目中。
1. 我经常讲,高手之间的竞争都是在细节。大的架构设计、分层、分模块思路实际上都差不多。没有项目是靠一些不为人知的设计来取胜的,即便有,很快也能被学习过去。所以,关键还是看代码细节处理得够不够好。这些细节的差别累积起来,会让代码质量有质的差别。所以,要想提高代码质量,还是要在细节处下功夫。
## 课堂讨论
1. 获取主机名失败的时候generate()函数应该返回什么最合适呢是特殊ID、null、空字符还是异常在小王的代码实现中获取主机名失败异常在IdGenerator内部被吞掉了打印一条报警日志并没有继续往上抛出这样的异常处理是否得当
1. 为了隐藏代码实现细节我们把getLastSubstrSplittedByDot(String hostName)函数命名替换成getLastSubstrByDelimiter(String hostName),这样是否更加合理?为什么?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,363 @@
<audio id="audio" title="36 | 实战二程序出错该返回啥NULL、异常、错误码、空对象" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0d/f6/0db3e7c71a2ba846bc53ed98a3c5f3f6.mp3"></audio>
我们可以把函数的运行结果分为两类。一类是预期的结果也就是函数在正常情况下输出的结果。一类是非预期的结果也就是函数在异常或叫出错情况下输出的结果。比如在上一节课中获取本机名的函数在正常情况下函数返回字符串格式的本机名在异常情况下获取本机名失败函数返回UnknownHostException异常对象。
在正常情况下函数返回数据的类型非常明确但是在异常情况下函数返回的数据类型却非常灵活有多种选择。除了刚刚提到的类似UnknownHostException这样的异常对象之外函数在异常情况下还可以返回错误码、NULL值、特殊值比如-1、空对象比如空字符串、空集合等。
每一种异常返回数据类型都有各自的特点和适用场景。但有的时候在异常情况下函数到底该返回什么样的数据类型并不那么容易判断。比如上节课中在本机名获取失败的时候ID生成器的generate()函数应该返回什么呢是异常空字符还是NULL值又或者是其他特殊值比如null-15293834874-fd3A9KBnnull表示本机名未获取到
函数是代码的一个非常重要的编写单元,而函数的异常处理,又是我们在编写函数的时候,时刻都要考虑的。所以,今天我们就聊一聊,如何设计函数在异常情况下的返回数据类型。
话不多说,让我们正式开始今天的学习吧!
## 从上节课的ID生成器代码讲起
上两节课中我们把一份非常简单的ID生成器的代码从“能用”重构成了“好用”。最终给出的代码看似已经很完美了但是如果我们再用心推敲一下代码中关于出错处理的方式还有进一步优化的空间值得我们拿出来再讨论一下。
为了方便你查看,我将上节课的代码拷贝到了这里。
```
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() {
String substrOfHostName = getLastFiledOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastFiledOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn(&quot;Failed to get the host name.&quot;, e);
}
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split(&quot;\\.&quot;);
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count &lt; length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii &gt;= '0' &amp;&amp; randomAscii &lt;= '9';
boolean isUppercase= randomAscii &gt;= 'A' &amp;&amp; randomAscii &lt;= 'Z';
boolean isLowercase= randomAscii &gt;= 'a' &amp;&amp; randomAscii &lt;= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
```
这段代码中有四个函数。针对这四个函数的出错处理方式,我总结出下面这样几个问题。
- 对于generate()函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
- 对于getLastFiledOfHostName()函数是否应该将UnknownHostException异常在函数内部吞掉try-catch并打印日志还是应该将异常继续往上抛出如果往上抛出的话是直接把UnknownHostException异常原封不动地抛出还是封装成新的异常抛出
- 对于getLastSubstrSplittedByDot(String hostName)函数如果hostName为NULL或者是空字符串这个函数应该返回什么
- 对于generateRandomAlphameric(int length)函数如果length小于0或者等于0这个函数应该返回什么
对于上面这几个问题,你可以试着思考下,我先不做解答。等我们学完本节课的理论内容之后,我们下一节课再一块来分析。这一节我们重点讲解一些理论方面的知识。
## 函数出错应该返回啥?
关于函数出错返回数据类型我总结了4种情况它们分别是错误码、NULL值、空对象、异常对象。接下来我们就一一来看它们的用法以及适用场景。
### 1.返回错误码
C语言中没有异常这样的语法机制因此返回错误码便是最常用的出错处理方式。而在Java、Python等比较新的编程语言中大部分情况下我们都用异常来处理函数出错的情况极少会用到错误码。
在C语言中错误码的返回方式有两种一种是直接占用函数的返回值函数正常执行的返回值放到出参中另一种是将错误码定义为全局变量在函数执行出错时函数调用者通过这个全局变量来获取错误码。针对这两种方式我举个例子来进一步解释。具体代码如下所示
```
// 错误码的返回方式一pathname/flags/mode为入参fd为出参存储打开的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
if (/*文件不存在*/) {
return EEXIST;
}
if (/*没有访问权限*/) {
return EACCESS;
}
if (/*打开文件成功*/) {
return SUCCESS; // C语言中的宏定义#define SUCCESS 0
}
// ...
}
//使用举例
int fd;
int result = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &amp;fd);
if (result == SUCCESS) {
// 取出fd使用
} else if (result == EEXIST) {
//...
} else if (result == EACESS) {
//...
}
// 错误码的返回方式二函数返回打开的文件句柄错误码放到errno中。
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode{
if (/*文件不存在*/) {
errno = EEXIST;
return -1;
}
if (/*没有访问权限*/) {
errno = EACCESS;
return -1;
}
// ...
}
// 使用举例
int hFile = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
printf(&quot;Failed to open file, error no: %d.\n&quot;, errno);
if (errno == EEXIST ) {
// ...
} else if(errno == EACCESS) {
// ...
}
// ...
}
```
实际上如果你熟悉的编程语言中有异常这种语法机制那就尽量不要使用错误码。异常相对于错误码有诸多方面的优势比如可以携带更多的错误信息exception中可以有message、stack trace等信息等。关于异常我们待会还会非常详细地讲解。
### 2.返回NULL值
在多数编程语言中我们用NULL来表示“不存在”这种语义。不过网上很多人不建议函数返回NULL值认为这是一种不好的设计思路主要的理由有以下两个。
- 如果某个函数有可能返回NULL值我们在使用它的时候忘记了做NULL值判断就有可能会抛出**空指针异常**Null Pointer Exception缩写为NPE
- 如果我们定义了很多返回值可能为NULL的函数那代码中就会充斥着大量的NULL值判断逻辑一方面写起来比较繁琐另一方面它们跟正常的业务逻辑耦合在一起会影响代码的可读性。
我举个例子解释一下,具体代码如下所示:
```
public class UserService {
private UserRepo userRepo; // 依赖注入
public User getUser(String telephone) {
// 如果用户不存在则返回null
return null;
}
}
// 使用函数getUser()
User user = userService.getUser(&quot;18917718965&quot;);
if (user != null) { // 做NULL值判断否则有可能会报NPE
String email = user.getEmail();
if (email != null) { // 做NULL值判断否则有可能会报NPE
String escapedEmail = email.replaceAll(&quot;@&quot;, &quot;#&quot;);
}
}
```
那我们是否可以用异常来替代NULL值在查找用户不存在的时候让函数抛出UserNotFoundException异常呢
我个人觉得尽管返回NULL值有诸多弊端但对于以get、find、select、search、query等单词开头的查找函数来说数据不存在并非一种异常情况这是一种正常行为。所以返回代表不存在语义的NULL值比返回异常更加合理。
不过话说回来刚刚讲的这个理由也并不是特别有说服力。对于查找数据不存在的情况函数到底是该用NULL值还是异常有一个比较重要的参考标准是看项目中的其他类似查找函数都是如何定义的只要整个项目遵从统一的约定即可。如果项目从零开始开发并没有统一约定和可以参考的代码那你选择两者中的任何一种都可以。你只需要在函数定义的地方注释清楚让调用者清晰地知道数据不存在的时候会返回什么就可以了。
再补充说明一点对于查找函数来说除了返回数据对象之外有的还会返回下标位置比如Java中的indexOf()函数用来实现在某个字符串中查找另一个子串第一次出现的位置。函数的返回值类型为基本类型int。这个时候我们就无法用NULL值来表示不存在的情况了。对于这种情况我们有两种处理思路一种是返回NotFoundException一种是返回一个特殊值比如-1。不过显然-1更加合理理由也是同样的也就是说“没有查找到”是一种正常而非异常的行为。
### 3.返回空对象
刚刚我们讲到返回NULL值有各种弊端。应对这个问题有一个比较经典的策略那就是应用空对象设计模式Null Object Design Pattern。关于这个设计模式我们在后面章节会详细讲现在就不展开来讲解了。不过我们今天来讲两种比较简单、比较特殊的空对象那就是**空字符串**和**空集合**。
当函数返回的数据是字符串类型或者集合类型的时候我们可以用空字符串或空集合替代NULL值来表示不存在的情况。这样我们在使用函数的时候就可以不用做NULL值判断。我举个例子来解释下。具体代码如下所示
```
// 使用空集合替代NULL
public class UserService {
private UserRepo userRepo; // 依赖注入
public List&lt;User&gt; getUsers(String telephonePrefix) {
// 没有查找到数据
return Collections.emptyList();
}
}
// getUsers使用示例
List&lt;User&gt; users = userService.getUsers(&quot;189&quot;);
for (User user : users) { //这里不需要做NULL值判断
// ...
}
// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
// 如果text中没有大写字母返回空字符串而非NULL值
return &quot;&quot;;
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters = retrieveUppercaseLetters(&quot;wangzheng&quot;);
int length = uppercaseLetters.length();// 不需要做NULL值判断
System.out.println(&quot;Contains &quot; + length + &quot; upper case letters.&quot;);
```
### 4.抛出异常对象
尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式就是抛出异常。异常可以携带更多的错误信息,比如函数调用栈信息。除此之外,异常可以将正常逻辑和异常逻辑的处理分离开来,这样代码的可读性就会更好。
不同的编程语言的异常语法稍有不同。像C++和大部分的动态语言Python、Ruby、JavaScript等都只定义了一种异常类型运行时异常Runtime Exception。而像Java除了运行时异常外还定义了另外一种异常类型编译时异常Compile Exception
对于运行时异常我们在编写代码的时候可以不用主动去try-catch编译器在编译代码的时候并不会检查代码是否有对运行时异常做了处理。相反对于编译时异常我们在编写代码的时候需要主动去try-catch或者在函数定义中声明否则编译就会报错。所以运行时异常也叫作非受检异常Unchecked Exception编译时异常也叫作受检异常Checked Exception
如果你熟悉的编程语言中只定义了一种异常类型那用起来反倒比较简单。如果你熟悉的编程语言中比如Java定义了两种异常类型那在异常出现的时候我们应该选择抛出哪种异常类型呢是受检异常还是非受检异常
对于代码bug比如数组越界以及不可恢复异常比如数据库连接失败即便我们捕获了也做不了太多事情所以我们倾向于使用非受检异常。对于可恢复异常、业务异常比如提现金额大于余额的异常我们更倾向于使用受检异常明确告知调用者需要捕获处理。
我举一个例子解释一下代码如下所示。当Redis的地址参数address没有设置的时候我们直接使用默认的地址比如本地地址和默认端口当Redis的地址格式不正确的时候我们希望程序能fail-fast也就是说把这种情况当成不可恢复的异常直接抛出运行时异常将程序终止掉。
```
// address格式&quot;192.131.2.33:7896&quot;
public void parseRedisAddress(String address) {
this.host = RedisConfig.DEFAULT_HOST;
this.port = RedisConfig.DEFAULT_PORT;
if (StringUtils.isBlank(address)) {
return;
}
String[] ipAndPort = address.split(&quot;:&quot;);
if (ipAndPort.length != 2) {
throw new RuntimeException(&quot;...&quot;);
}
this.host = ipAndPort[0];
// parseInt()解析失败会抛出NumberFormatException运行时异常
this.port = Integer.parseInt(ipAndPort[1]);
}
```
实际上Java支持的受检异常一直被人诟病很多人主张所有的异常情况都应该使用非受检异常。支持这种观点的理由主要有以下三个。
- 受检异常需要显式地在函数定义中声明。如果函数会抛出很多受检异常,那函数的定义就会非常冗长,这就会影响代码的可读性,使用起来也不方便。
- 编译器强制我们必须显示地捕获所有的受检异常,代码实现会比较繁琐。而非受检异常正好相反,我们不需要在定义中显示声明,并且是否需要捕获处理,也可以自由决定。
- 受检异常的使用违反开闭原则。如果我们给某个函数新增一个受检异常这个函数所在的函数调用链上的所有位于其之上的函数都需要做相应的代码修改直到调用链中的某个函数将这个新增的异常try-catch处理掉为止。而新增非受检异常可以不改动调用链上的代码。我们可以灵活地选择在某个函数中集中处理比如在Spring中的AOP切面中集中处理异常。
不过,非受检异常也有弊端,它的优点其实也正是它的缺点。从刚刚的表述中,我们可以看出,非受检异常使用起来更加灵活,怎么处理的主动权这里就交给了程序员。我们前面也讲到,过于灵活会带来不可控,非受检异常不需要显式地在函数定义中声明,那我们在使用函数的时候,就需要查看代码才能知道具体会抛出哪些异常。非受检异常不需要强制捕获处理,那程序员就有可能漏掉一些本应该捕获处理的异常。
对于应该用受检异常还是非受检异常,网上的争论有很多,但并没有一个非常强有力的理由能够说明一个就一定比另一个更好。所以,我们只需要根据团队的开发习惯,在同一个项目中,制定统一的异常处理规范即可。
**刚刚我们讲了两种异常类型,现在我们再来讲下,如何处理函数抛出的异常?**总结一下,一般有下面三种处理方法。
- 直接吞掉。具体的代码示例如下所示:
```
public void func1() throws Exception1 {
// ...
}
public void func2() {
//...
try {
func1();
} catch(Exception1 e) {
log.warn(&quot;...&quot;, e); //吐掉try-catch打印日志
}
//...
}
```
- 原封不动地re-throw。具体的代码示例如下所示
```
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception1 {//原封不动的re-throw Exception1
//...
func1();
//...
}
```
- 包装成新的异常re-throw。具体的代码示例如下所示
```
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception2 {
//...
try {
func1();
} catch(Exception1 e) {
throw new Exception2(&quot;...&quot;, e); // wrap成新的Exception2然后re-throw
}
//...
}
```
当我们面对函数抛出异常的时候,应该选择上面的哪种处理方式呢?我总结了下面三个参考原则:
- 如果func1()抛出的异常是可以恢复且func2()的调用方并不关心此异常我们完全可以在func2()内将func1()抛出的异常吞掉;
- 如果func1()抛出的异常对func2()的调用方来说,也是可以理解的、关心的 并且在业务概念上有一定的相关性我们可以选择直接将func1抛出的异常re-throw
- 如果func1()抛出的异常太底层对func2()的调用方来说缺乏背景去理解、且业务概念上无关我们可以将它重新包装成调用方可以理解的新异常然后re-throw。
总之是否往上继续抛出要看上层代码是否关心这个异常。关心就将它抛出否则就直接吞掉。是否需要包装成新的异常抛出看上层代码是否能理解这个异常、是否业务相关。如果能理解、业务相关就可以直接抛出否则就封装成新的异常抛出。关于这部分理论知识我们在下一节课中会结合ID生成器的代码来进一步讲解。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。
对于函数出错返回数据类型我总结了4种情况它们分别是错误码、NULL值、空对象、异常对象。
**1.返回错误码**
C语言没有异常这样的语法机制返回错误码便是最常用的出错处理方式。而Java、Python等比较新的编程语言中大部分情况下我们都用异常来处理函数出错的情况极少会用到错误码。
**2.返回NULL值**
在多数编程语言中我们用NULL来表示“不存在”这种语义。对于查找函数来说数据不存在并非一种异常情况是一种正常行为所以返回表示不存在语义的NULL值比返回异常更加合理。
**3.返回空对象**
返回NULL值有各种弊端对此有一个比较经典的应对策略那就是应用空对象设计模式。当函数返回的数据是字符串类型或者集合类型的时候我们可以用空字符串或空集合替代NULL值来表示不存在的情况。这样我们在使用函数的时候就可以不用做NULL值判断。
**4.抛出异常对象**
尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式是抛出异常。异常有两种类型:受检异常和非受检异常。
对于应该用受检异常还是非受检异常,网上的争论有很多,但也并没有一个非常强有力的理由,说明一个就一定比另一个更好。所以,我们只需要根据团队的开发习惯,在同一个项目中,制定统一的异常处理规范即可。
对于函数抛出的异常,我们有三种处理方法:直接吞掉、直接往上抛出、包裹成新的异常抛出。这一部分我们留在下一节课中结合实战进一步讲解。
## 课堂讨论
结合我们今天学的理论知识试着回答一下在文章开头针对RandomIdGenerator提到的四个问题。
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,278 @@
<audio id="audio" title="37 | 实战二重构ID生成器项目中各函数的异常处理代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/0c/fc20bba8fb1d69ca5e8721ce42ab4a0c.mp3"></audio>
平时进行软件设计开发的时候我们除了要保证正常情况下的逻辑运行正确之外还需要编写大量额外的代码来处理有可能出现的异常情况以保证代码在任何情况下都在我们的掌控之内不会出现非预期的运行结果。程序的bug往往都出现在一些边界条件和异常情况下所以说异常处理得好坏直接影响了代码的健壮性。全面、合理地处理各种异常能有效减少代码bug也是保证代码质量的一个重要手段。
在上一节课中我们讲解了几种异常情况的处理方式比如返回错误码、NULL值、空对象、异常对象。针对最常用的异常对象我们还重点讲解了两种异常类型的应用场景以及针对函数抛出的异常的三种处理方式直接吞掉、原封不动地抛出和包裹成新的异常抛出。
除此之外在上一节课的开头我们还针对ID生成器的代码提出了4个有关异常处理的问题。今天我们就用一节课的时间结合上一节课讲到的理论知识来逐一解答一下这几个问题。
话不多说,让我们正式开始今天的内容吧!
## 重构generate()函数
首先我们来看对于generate()函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
```
public String generate() {
String substrOfHostName = getLastFieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
```
ID由三部分构成本机名、时间戳和随机数。时间戳和随机数的生成函数不会出错唯独主机名有可能获取失败。在目前的代码实现中如果主机名获取失败substrOfHostName为NULL那generate()函数会返回类似“null-16723733647-83Ab3uK6”这样的数据。如果主机名获取失败substrOfHostName为空字符串那generate()函数会返回类似“-16723733647-83Ab3uK6”这样的数据。
在异常情况下返回上面两种特殊的ID数据格式这样的做法是否合理呢这个其实很难讲我们要看具体的业务是怎么设计的。不过我更倾向于明确地将异常告知调用者。所以这里最好是抛出受检异常而非特殊值。
按照这个设计思路我们对generate()函数进行重构。重构之后的代码如下所示:
```
public String generate() throws IdGenerationFailureException {
String substrOfHostName = getLastFieldOfHostName();
if (substrOfHostName == null || substrOfHostName.isEmpty()) {
throw new IdGenerationFailureException(&quot;host name is empty.&quot;);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
```
## 重构getLastFieldOfHostName()函数
对于getLastFieldOfHostName()函数是否应该将UnknownHostException异常在函数内部吞掉try-catch并打印日志还是应该将异常继续往上抛出如果往上抛出的话是直接把UnknownHostException异常原封不动地抛出还是封装成新的异常抛出
```
private String getLastFieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn(&quot;Failed to get the host name.&quot;, e);
}
return substrOfHostName;
}
```
现在的处理方式是当主机名获取失败的时候getLastFieldOfHostName()函数返回NULL值。我们前面讲过是返回NULL值还是异常对象要看获取不到数据是正常行为还是异常行为。获取主机名失败会影响后续逻辑的处理并不是我们期望的所以它是一种异常行为。这里最好是抛出异常而非返回NULL值。
至于是直接将UnknownHostException抛出还是重新封装成新的异常抛出要看函数跟异常是否有业务相关性。getLastFieldOfHostName()函数用来获取主机名的最后一个字段UnknownHostException异常表示主机名获取失败两者算是业务相关所以可以直接将UnknownHostException抛出不需要重新包裹成新的异常。
按照上面的设计思路我们对getLastFieldOfHostName()函数进行重构。重构后的代码如下所示:
```
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
```
getLastFieldOfHostName()函数修改之后generate()函数也要做相应的修改。我们需要在generate()函数中捕获getLastFieldOfHostName()抛出的UnknownHostException异常。当我们捕获到这个异常之后应该怎么处理呢
按照之前的分析ID生成失败的时候我们需要明确地告知调用者。所以我们不能在generate()函数中将UnknownHostException这个异常吞掉。那我们应该原封不动地抛出还是封装成新的异常抛出呢
我们选择后者。在generate()函数中我们需要捕获UnknownHostException异常并重新包裹成新的异常IdGenerationFailureException往上抛出。之所以这么做有下面三个原因。
- 调用者在使用generate()函数的时候只需要知道它生成的是随机唯一ID并不关心ID是如何生成的。也就说是这是依赖抽象而非实现编程。如果generate()函数直接抛出UnknownHostException异常实际上是暴露了实现细节。
- 从代码封装的角度来讲我们不希望将UnknownHostException这个比较底层的异常暴露给更上层的代码也就是调用generate()函数的代码。而且,调用者拿到这个异常的时候,并不能理解这个异常到底代表了什么,也不知道该如何处理。
- UnknownHostException异常跟generate()函数,在业务概念上没有相关性。
按照上面的设计思路我们对generate()的函数再次进行重构。重构后的代码如下所示:
```
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException(&quot;host name is empty.&quot;);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
```
## 重构getLastSubstrSplittedByDot()函数
对于getLastSubstrSplittedByDot(String hostName)函数如果hostName为NULL或者空字符串这个函数应该返回什么
```
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split(&quot;\\.&quot;);
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
```
理论上讲参数传递的正确性应该有程序员来保证我们无需做NULL值或者空字符串的判断和特殊处理。调用者本不应该把NULL值或者空字符串传递给getLastSubstrSplittedByDot()函数。如果传递了那就是code bug需要修复。但是话说回来谁也保证不了程序员就一定不会传递NULL值或者空字符串。那我们到底该不该做NULL值或空字符串的判断呢
如果函数是private类私有的只在类内部被调用完全在你自己的掌控之下自己保证在调用这个private函数的时候不要传递NULL值或空字符串就可以了。所以我们可以不在private函数中做NULL值或空字符串的判断。如果函数是public的你无法掌控会被谁调用以及如何调用有可能某个同事一时疏忽传递进了NULL值这种情况也是存在的为了尽可能提高代码的健壮性我们最好是在public函数中做NULL值或空字符串的判断。
那你可能会说getLastSubstrSplittedByDot()是protected的既不是private函数也不是public函数那要不要做NULL值或空字符串的判断呢
之所以将它设置为protected是为了方便写单元测试。不过单元测试可能要测试一些corner case比如输入是NULL值或者空字符串的情况。所以这里我们最好也加上NULL值或空字符串的判断逻辑。虽然加上有些冗余但多加些检验总归不会错的。
按照这个设计思路我们对getLastSubstrSplittedByDot()函数进行重构。重构之后的代码如下所示:
```
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw IllegalArgumentException(&quot;...&quot;); //运行时异常
}
String[] tokens = hostName.split(&quot;\\.&quot;);
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
```
按照上面讲的我们在使用这个函数的时候自己也要保证不传递NULL值或者空字符串进去。所以getLastFieldOfHostName()函数的代码也要作相应的修改。修改之后的代码如下所示:
```
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) { // 此处做判断
throw new UnknownHostException(&quot;...&quot;);
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
```
## 重构generateRandomAlphameric()函数
对于generateRandomAlphameric(int length)函数如果length &lt; 0或length = 0这个函数应该返回什么
```
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count &lt; length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii &gt;= '0' &amp;&amp; randomAscii &lt;= '9';
boolean isUppercase= randomAscii &gt;= 'A' &amp;&amp; randomAscii &lt;= 'Z';
boolean isLowercase= randomAscii &gt;= 'a' &amp;&amp; randomAscii &lt;= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
```
我们先来看length &lt; 0的情况。生成一个长度为负值的随机字符串是不符合常规逻辑的是一种异常行为。所以当传入的参数length &lt; 0的时候我们抛出IllegalArgumentException异常。
我们再来看length = 0的情况。length = 0是否是异常行为呢这就看你自己怎么定义了。我们既可以把它定义为一种异常行为抛出IllegalArgumentException异常也可以把它定义为一种正常行为让函数在入参length = 0的情况下直接返回空字符串。不管选择哪种处理方式最关键的一点是要在函数注释中明确告知length = 0的情况下会返回什么样的数据。
## 重构之后的RandomIdGenerator代码
对RandomIdGenerator类中各个函数异常情况处理代码的重构到此就结束了。为了方便查看我把重构之后的代码重新整理之后贴在这里了。你可以对比着看一下跟你的重构思路是否一致。
```
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException(&quot;...&quot;, e);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format(&quot;%s-%d-%s&quot;,
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) {
throw new UnknownHostException(&quot;...&quot;);
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw new IllegalArgumentException(&quot;...&quot;);
}
String[] tokens = hostName.split(&quot;\\.&quot;);
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
if (length &lt;= 0) {
throw new IllegalArgumentException(&quot;...&quot;);
}
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count &lt; length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii &gt;= '0' &amp;&amp; randomAscii &lt;= '9';
boolean isUppercase= randomAscii &gt;= 'A' &amp;&amp; randomAscii &lt;= 'Z';
boolean isLowercase= randomAscii &gt;= 'a' &amp;&amp; randomAscii &lt;= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
```
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
今天的内容比较偏实战是对上节课学到的理论知识的一个应用。从今天的实战中你学到了哪些更高层的软件设计和开发思想呢我这里抛砖引玉总结了下面3点。
- 再简单的代码,看上去再完美的代码,只要我们下功夫去推敲,总有可以优化的空间,就看你愿不愿把事情做到极致。
- 如果你内功不够深厚理论知识不够扎实那你就很难参透开源项目的代码到底优秀在哪里。就像如果我们没有之前的理论学习没有今天我给你一点一点重构、讲解、分析只是给你最后重构好的RandomIdGenerator的代码你真的能学到它的设计精髓吗
- 对比[第34节课](https://time.geekbang.org/column/article/190979)最初小王的IdGenerator代码和最终的RandomIdGenerator代码它们一个是“能用”一个是“好用”天壤之别。作为一名程序员起码对代码要有追求啊不然跟咸鱼有啥区别
## 课堂讨论
我们花了4节课的时间对一个非常简单的、不到40行的ID生成器代码做了多次迭代重构。除了刚刚我在“重点回顾”中讲到的那几点之外从这个迭代重构的过程中你还学到哪些更有价值的东西
欢迎在留言区写下你的思考和想法,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。