first commit
This commit is contained in:
parent
bf199f7d5e
commit
389450bbd2
@ -58,11 +58,12 @@ foreach ($lines as $line) {
|
||||
echo $fileName;
|
||||
echo PHP_EOL;
|
||||
|
||||
$uri = str_replace(' ', '%20', $name);
|
||||
$fileUrl = $url . $line . '/' . $uri;
|
||||
|
||||
if(filesize($fileName) > 0) continue;
|
||||
|
||||
$fileUlr = $url . $urlList[$key];
|
||||
$fileContents = file_get_contents($fileUlr);
|
||||
|
||||
$fileContents = file_get_contents($fileUrl);
|
||||
preg_match_all('/<div class="book-post">(.*?)<div id="prePage" style="float: left">/s', $fileContents, $divMatchs);
|
||||
|
||||
$a = '<meta charset="UTF-8">'.$divMatchs[1][0];
|
||||
@ -71,7 +72,6 @@ foreach ($lines as $line) {
|
||||
$doc->loadHtml($a);
|
||||
libxml_clear_errors();
|
||||
$text = $doc->textContent;
|
||||
|
||||
file_put_contents($fileName, $text);
|
||||
|
||||
sleep(5);
|
||||
|
146
专栏/程序员的测试课/05一个好的自动化测试长什么样?.md
Normal file
146
专栏/程序员的测试课/05一个好的自动化测试长什么样?.md
Normal file
@ -0,0 +1,146 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 一个好的自动化测试长什么样?
|
||||
你好,我是郑晔!
|
||||
|
||||
在上一讲里我们讲了测试的一个关键点是自动化测试,而自动化刚好是程序员的强项。自从有了自动化测试框架,自动化测试就从业余走向了专业,但这并不是说,有了测试框架你就能把测试写好了,我们来看几个典型的问题:
|
||||
|
||||
|
||||
测试不够稳定,一次运行通过,下次就不能通过了;
|
||||
要测的东西很简单,但是为了测这个东西,光是周边配套的准备就要写很多的代码;
|
||||
一个测试必须在另一个测试之后运行;
|
||||
……
|
||||
|
||||
|
||||
这是让很多团队在测试中挣扎的原因,也是很多人放弃测试的理由。之所以测试会出现这样那样的问题,一个重要的原因是这些测试不够好。这一讲,我们就来讲讲好的测试应该长什么样。
|
||||
|
||||
测试的样子
|
||||
|
||||
关于自动化测试,其实有一个关键的问题我们一直还没有讨论。我们用测试来保证代码的正确性,然而,测试的正确性如何保证呢?
|
||||
|
||||
这是一个会问懵很多人的问题:测试保证代码的正确性,那测试代码的正确性也用测试保证?但你见过有人给测试写测试吗?没有。因为这是一个循环的问题,你给测试写了测试,那新的测试怎么保证正确性呢?难不成要递归地写下去?是不是有种大脑要堆栈溢出的感觉了。
|
||||
|
||||
既然给测试写测试不是一个行得通的做法,那唯一可行的方案就是,把测试写简单,简单到一目了然,不需要证明它的正确性。由此,我们可以知道,一个复杂的测试肯定不是一个好的测试。
|
||||
|
||||
简单的测试应该长什么样呢?我们一起来看一个例子,这就是我们在实战环节中给出的第一个测试。
|
||||
|
||||
@Test
|
||||
public void should_add_todo_item() {
|
||||
// 准备
|
||||
TodoItemRepository repository = mock(TodoItemRepository.class);
|
||||
when(repository.save(any())).then(returnsFirstArg());
|
||||
TodoItemService service = new TodoItemService(repository);
|
||||
|
||||
// 执行
|
||||
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
|
||||
|
||||
// 断言
|
||||
assertThat(item.getContent()).isEqualTo("foo");
|
||||
|
||||
// 清理(可选)
|
||||
|
||||
}
|
||||
|
||||
|
||||
我把这个测试分成了四段,分别是准备、执行、断言和清理,这也是一般测试都会具备的四个阶段,我们分别来看一下。
|
||||
|
||||
准备。这个阶段是为了测试所做的一些准备,比如启动外部依赖的服务,存储一些预置的数据。在我们这个例子里面就是设置所需组件的行为,然后将这些组件组装了起来。
|
||||
|
||||
执行。这个阶段是整个测试中最核心的部分,触发被测目标的行为。通常来说,它就是一个测试点,在大多数情况下,执行应该就是一个函数调用。如果是测试外部系统,就是发出一个请求。在我们这段代码里,它就是调用了一个函数。
|
||||
|
||||
断言。断言是我们的预期,它负责验证执行的结果是否正确。比如,被测系统是否返回了正确的应答。在这个例子,我们验证的是 Todo 项的内容是否是我们添加进去的内容。
|
||||
|
||||
清理。清理是一个可能会有的部分。如果在测试中使到了外部资源,在这个部分要及时地释放掉,保证测试环境被还原到一个最初的状态,就像什么都没发生过一样。比如,我们在测试过程中向数据库插入了数据,执行之后,要删除测试过程中插入的数据。一些测试框架对一些通用的情况已经提供支持,比如之前我们用到的临时文件。
|
||||
|
||||
如果准备和清理的部分是在几个测试用例间通用的,它们就有可能被放到 setUp 和 tearDown 里去完成,这一点我们在上一讲已经讲过了。
|
||||
|
||||
这四个阶段中,必须存在的是执行和断言。想想也是,不执行,目标都没有,还测什么?不断言,预期都没有,跑了也是白跑。如果不涉及到一些资源释放,清理部分很可能就没有了。而对一些简单的测试来说,也不需要做特别的准备。
|
||||
|
||||
从结构上来看,测试用例应该就是这么简单。你去看一下我们在实战中的代码,大部分测试都是可以这样划分的。
|
||||
|
||||
理解了测试的结构,有一些测试存在的问题你一眼就能看出来了。比如对于没有断言的测试来说,看上去测试从来不会出错,但这样的测试几乎是没有价值的。
|
||||
|
||||
再比如,一个测试里有多个执行目标,可能是需要在一个测试里要测多个不同的函数。这就是一个坏味道了。为什么说这是一个坏味道呢?因为测试的根基是简单,一旦复杂了,我们就很难保证测试本身的正确性。如果你有多个目标怎么办?分成多个测试就好了。
|
||||
|
||||
如果测试本身简单到令人发指的程度,出于节省代码篇幅的角度,你可以考虑在一个测试里面写。比如测试字符串为空的函数,我要分别传入空对象和空字符串,每种情况执行和断言一行代码就写完了,那我可能就在一个测试里面写了。
|
||||
|
||||
一段旅程(A-TRIP)
|
||||
|
||||
有了对测试结构的基本认知,我们再进一步,看看如何衡量一个测试有没有做好?有人把好测试的特点总结成一个说法:A-TRIP。这其实是五个单词的缩写,分别是:
|
||||
|
||||
|
||||
Automatic,自动化;
|
||||
Thorough,全面的;
|
||||
Repeatable,可重复的;
|
||||
Independent,独立的;
|
||||
Professional,专业的。
|
||||
|
||||
|
||||
这是什么意思呢?我们分别来解释一下。
|
||||
|
||||
Automatic,自动化。经过上一讲的讲解,这一点你应该已经很容易理解了。自动化测试相比传统测试,核心增强就在自动化上。这也是为什么测试一定要有断言,因为只有在有断言的情况下,机器才能够帮我们判断测试是否成功。
|
||||
|
||||
Thorough,全面的。这一点其实是测试的要求,应该尽可能用测试覆盖各种场景。不管什么样的自动化测试,它的本质还是测试,前面我们讲了向测试人员学习,关键点就在于这有助于我们写出更全面的测试。理解全面还有一个角度,就是测试覆盖率。我们在实战环节中已经见识了如何通过测试覆盖率工具,帮我们去发现代码中测试中没有覆盖到地方。
|
||||
|
||||
Repeatable,可重复的。它要求测试能够反复运行,并且结果都应该是一样的。这是保证测试简单可靠的前提。如果一个测试不是可重复的,我们就没法相信它的运行结果,测试的价值也就荡然无存了。一旦测试报错,我们没法确定是我们程序出错了,还是其它什么地方出错了。
|
||||
|
||||
在内存中执行的测试一般都是可重复的。影响一个测试可重复性的主要因素是外部资源,常见的外部资源包括文件、数据库、中间件、第三方服务等等。如果在测试中遇到这些外部资源,我们就要想办法让这些资源在测试结束后,恢复原来的样子。你在实战中已经见识过如何处理文件,在后面的应用篇,我们还会讲到如何处理数据库。简单说就是在测试执行之后,能够把数据回滚掉。
|
||||
|
||||
如果你遇到中间件,最好有一个独立可控的中间件。而遇到第三方服务,则可以采用模拟服务,我的开源项目 Moco 主要就是为了解决这种外部依赖而生的。
|
||||
|
||||
理解可重复性还有一个角度,那就是一批测试也要可重复。这就需要测试之间彼此没有依赖,这也是我们接下来要讨论的测试的另外一个特点。
|
||||
|
||||
Independent,独立的。测试和测试之间不应该有任何依赖。什么叫有依赖?就是一个测试要依赖于另外一个测试运行的结果。比如两个测试都要依赖于数据库,第一个测试运行时往数据库里写了一些数据,而第二个测试在执行时要用到这些数据。也就是说,第二个测试必须在第一个测试执行之后再执行,这就叫做有依赖。
|
||||
|
||||
我知道,有很多人有很多的理由让测试之间有依赖。比如说为了提高执行效率,但这种做法属于特定的优化。对于其他绝大多数情况而言,一旦你开始这么做了,测试就走上了歧途。比如,一些框架支持多个测试并行运行,一旦测试有依赖,测试就无法并行执行,因为这两个测试之间是有顺序的。再比如,一旦有人破坏了测试的独立性,紧接着就会有更多的人破坏独立性,这就像代码的坏味道一样,很容易传播。
|
||||
|
||||
可重复性和独立性关联非常紧密。因为我们通常认为,可重复是测试按照随机的顺序执行,其结果也是一样的,这就要依赖于测试是独立的。而一旦测试不独立,有了依赖,从单个测试上来看,它也违反了可重复性。
|
||||
|
||||
Professional,专业的。这一点是很多人观念中缺失的,测试代码也是代码,也要按照代码的标准去维护。这就意味着你的测试代码也要写得清晰,比如良好的命名、把函数写小、要重构甚至要抽象出测试的基础库、测试的模式。在 Web 测试中常见的 PageObject 模式,就是这种理念的延伸。
|
||||
|
||||
有一点我准备多说几句,就是测试的命名。很多人写代码时,知道要取一个有意义的命名,但在测试上常常忽略这一点,我们经常可以看到 test1、test2这样的测试命名。那测试应该怎么命名呢?
|
||||
|
||||
我不知道你是否注意到了,我在实战中写的测试,其命名与传统的 Java 函数有着很大的区别。首先,我用了下划线区隔单词,而没有采用驼峰命名;其次,名字都很长;再有,所有的测试都是以 should 开头。
|
||||
|
||||
我为什么要这么写呢?其实,我是希望在测试名中把测试用例的场景给描述出来。换言之,这个测试名不是一个简单的名字,而是一句话,这样测试的名字就会很长。而一旦名字太长,用驼峰阅读起来就不那么舒服了,所以,我采用了下划线区隔。
|
||||
|
||||
我对测试的命名主要有两种:
|
||||
|
||||
|
||||
should_测试场景;
|
||||
should_测试效果_while_测试条件。
|
||||
|
||||
|
||||
第一种命名表示应该做成什么样子,比如,should_add_todo_item,一般来说,对于一个正常情况的测试用例,我会这么命名。第二种情况则表示在什么条件下,应该出现什么效果,比如,should_throw_exception_while_parameter_is_empty,可以用来描述各种异常的情况。你会看到这两种命名方法其实都是写了一句话,而之所以会用 should 开头,它表示这个测试“应该”是什么样的。
|
||||
|
||||
有一些测试框架在测试描述上做得更加进一步,看上去就更像一句话了,下面是一个例子。
|
||||
|
||||
it.should("throw exception while parameter is empty", () -> {
|
||||
...
|
||||
});
|
||||
|
||||
|
||||
经过这一讲的介绍,相信你对一个好的测试应该长成什么样已经有了一个初步的认识,但知道了好测试长什么样,只能帮助你发现测试中存在的问题。下一讲,我们接着来讨论一个影响写好测试的关键因素:软件设计。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲,我们讨论了一个好的测试应该是什么样子的。一个好的测试首先应该是简单的,否则,我们无法保证测试的正确性。
|
||||
|
||||
我们还谈到了测试的基本结构:准备、执行、断言和清理。其中,核心的部分是执行和断言。一个测试既不能执行太多的东西,也不能没有断言。
|
||||
|
||||
怎么衡量测试是否做好了呢?有一个标准:A-TRIP,这是五个单词的缩写,分别是 Automatic(自动化)、Thorough(全面的)、Repeatable(可重复的)、Independent(独立的)和 Professional(专业的)。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:编写简单的测试。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
用今天讲到好测试的原则去对比一下你的测试,你会发现哪些问题呢?欢迎在留言区分享你的发现。
|
||||
|
||||
|
||||
|
||||
|
196
专栏/程序员的测试课/06测试不好做,为什么会和设计有关系?.md
Normal file
196
专栏/程序员的测试课/06测试不好做,为什么会和设计有关系?.md
Normal file
@ -0,0 +1,196 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 测试不好做,为什么会和设计有关系?
|
||||
你好,我是郑晔!
|
||||
|
||||
在前面几讲里,我们讲了测试的一些基础,要有测试思维、要学会使用自动化测试框架、要按照好测试的样子去写测试……但是,懂了这些就能把测试写好吗?
|
||||
|
||||
答案显然是否定的。因为这些东西很多人都知道,但真正到了实际的项目中面对自己的代码,还是不会写测试。主要的问题就是不好测,这也是测试劝退了很多程序员的一个重要原因。
|
||||
|
||||
不好测实际上是一个结果。那造成这个结果的原因是什么呢?答案就是我们今天要讨论的话题:软件设计。
|
||||
|
||||
可测试性
|
||||
|
||||
为什么说不好测是由于软件设计不好造成的呢?其实,更准确的说法是绝大多数人写软件就没怎么考虑过设计。
|
||||
|
||||
软件设计是什么?软件设计就是在构建模型和规范。
|
||||
|
||||
然而,大多数人写软件的关注点是实现。我们学习写程序的过程,一定是从实现一个功能开始的。这一点在最开始是没有问题的,因为需求的复杂度不高。不过需求一旦累积到一定规模,复杂度就会开始大幅度升高,不懂软件设计的人就开始陷入泥潭。
|
||||
|
||||
即便一个人认识到软件设计的重要性,学习了软件设计,但在做设计的时候还是常常会对可测试性考虑不足。可测试性是一个软件/模块对测试的支持程度,也就是当我执行了一个动作之后,我得花多大力气知道我做得到底对不对。
|
||||
|
||||
我们所说的代码不好测,其实就是可测试性不好。当我们添加了一个新功能时,如果必须把整个系统启动起来,然后给系统发消息,再到数据库里写 SQL 把查数据去做对比,这是非常麻烦的一件事。为了一个简单的功能兜这么大一圈,这无论如何都是可测试性很糟糕的表现。然而,这却是很多团队测试的真实状况。因为系统每个模块的可测试性不好,所以,最终只能把整个系统都集成起来进行系统测试。
|
||||
|
||||
如果建楼用的每块材料都不敢保证质量,你敢要求最终建出来的大楼质量很高吗?这就是很多团队面临的尴尬场景:每个模块都没有验证过,只知道系统集成起来能够工作。所以,一旦一个系统可以工作了,最好的办法就是不去动它。然而,还有一大堆新需求排在后面。
|
||||
|
||||
相应地,对一个可测试性好的系统而言,应该每个模块都可以进行独立的测试。在我们把每一个组件都测试稳定之后,再把这些组件组装起来进行验证,这样逐步构建起来的系统,我对它的质量是放心的。即便是要改动某些部分,有了相应的测试做保证,我才敢于放手去改。
|
||||
|
||||
可测试性很重要,但我要怎么让自己的代码有可测试性呢?
|
||||
|
||||
编写可测试的代码
|
||||
|
||||
编写可测试的代码,最简单的回答就是让自己的代码符合软件设计原则。在《软件设计之美》的专栏里,我专门讲了 SOLID 原则,这是目前软件设计中最成体系的一套设计原则。如果代码真的能做到符合 SOLID 原则,那它基本上就是可测的。
|
||||
|
||||
比如,符合单一职责原则的代码一般都不会特别长,也就没有那么多的分支路径,相对来说就比较容易测试。再比如,符合依赖倒置原则的代码,高层的逻辑就不会依赖于底层的实现,测试高层逻辑的部分也就可以用 Mock 框架去模拟底层的实现。
|
||||
|
||||
编写可测试的代码,如果只记住一个通用规则,那就是编写可组合的代码。什么叫可组合的代码?就是要能够像积木一样组装起来的代码。
|
||||
|
||||
既然要求代码是组装出来的,由此得出的第一个推论是不要在组件内部去创建对象。比如,我们在前面的实战中有一个 TodoItemService,它有一个 repository 字段。这个字段从哪来呢?直接创建一个实例理论上是可以的,但它会产生耦合。根据我们的推论,不要在组件内部创建对象,所以,我们考虑从构造函数把它作为参数传进来。
|
||||
|
||||
public class TodoItemService {
|
||||
private final TodoItemRepository repository;
|
||||
|
||||
public TodoItemService(final TodoItemRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
你或许会问了,如果不在内部创建对象,那谁来负责这个对象的创建呢?答案是组件的组装过程。组件组装在 Java 世界里已经有了一个标准答案,就是依赖注入。
|
||||
|
||||
不在内部创建,那就意味着把组件的组装过程外置了。既然是外置了,组装的活可以由产品代码完成,同样也可以由测试过程完成。
|
||||
|
||||
站在测试的角度看,如果我们需要测试 TodoItemService 就不需要依赖于 repository 的具体实现,完全可以使用模拟对象进行替代。
|
||||
|
||||
我们可以完全控制模拟对象的行为,这样,对 TodoItemService 的测试重点就全在 TodoItemService 本身,无需考虑 repository 的实现细节。在实战的过程中你也看到了,我们在实现了 TodoItemService 时,甚至还没有一个 repository 的具体实现。
|
||||
|
||||
现在你知道了,编写可组合的代码意味着,我们把组件之间的关联过程交了出去,组件本身并不会去主动获取其相关联组件的实现。由此,我们要得出第二个推论:不要编写 static 方法。
|
||||
|
||||
我知道很多人喜欢 static 方法,因为它用起来很方便,但对测试来说却不是这样。使用 static 方法是一种主动获取的做法。一旦组件主动获取,测试就没有机会参与到其中,相应地,我们也就控制不了相应的行为,测试难度自然就增大了。所以,如果团队需要有一个统一约定,那就是不使用 static 方法。
|
||||
|
||||
如果非要说有什么特例,那就是编写一些基础库(比如字符串处理等),这种情况可以使用 static 方法。但基本上大部分程序员很少有机会去写基础库,所以,我们还是把不编写 static 方法作为统一的原则。
|
||||
|
||||
如果你能够摒弃掉 static 方法,还有两样东西你也就可以抛弃了,一个是全局状态,一个是 Singleton 模式。
|
||||
|
||||
如果你的系统中有全局状态,那就会造成代码之间彼此的依赖:一段代码改了状态,另一端代码因为要使用这个状态而崩溃。
|
||||
|
||||
但如果我们抛弃了 static 方法,多半你也就没有机会使用全局状态了,因为直接访问的入口点没有了。如果需要确实有状态,那就可以由一个组件来完成,然后,把这个组件注入进来。
|
||||
|
||||
如果你能够理解 static 方法的问题,你也就能够理解 Singleton 模式存在的问题了。它也是一样没有办法去干涉对象的创建,而且它本身限制了继承,也没有办法去模拟。
|
||||
|
||||
你或许已经意识到了,之所以说编写可组合的代码是可测试性的关键,是因为我们在测试的过程中要参与到组件的组装过程中,我们可能会用模拟对象代替真实对象。模拟对象对我们来说是完全可控的,而真实对象则不一定那么方便,比如真实对象可能会牵扯到外部资源,带来的问题可能比解决的问题更多。
|
||||
|
||||
要使用模拟对象,就要保证接口可继承,函数可改写,这也是我们对于编写可测试代码的一个要求。所以,这又回到了设计上,要想保证代码的可测试性,我们就要保证代码是符合面向对象设计原则的,比如要基于行为进行封装等等。
|
||||
|
||||
与第三方代码集成
|
||||
|
||||
如果说前面讨论的内容更多的是面向自己写的代码,那在实际工作中,我们还会面临一个真实的问题,就是与第三方的代码集成。无论是用到开源的程序库,还是用到别人封装好的代码,总之,我们要面对一些自己不可控的代码,而这些代码往往也会成为你编写测试的阻碍。
|
||||
|
||||
对于测试而言,第三方的代码难就难在不可控,要想让它们不再成为阻碍,就要让它们变得可控。
|
||||
|
||||
如何让第三方代码可控呢?答案就是隔离,也就是将第三方代码和我们自己编写的业务代码分开。如何隔离呢?我们分成两种情况来讨论。
|
||||
|
||||
调用程序库
|
||||
|
||||
第一种情况是我们的代码直接去调用一个程序库。在实际工作中,这应该是最广泛的使用场景,可能是对一个协议解析,也可能调用一个服务发送通知。
|
||||
|
||||
在实战的例子中,我们也曾经调用 Jackson 去实现 JSON 的处理。那个例子就表现了一个典型的第三方代码不可控,它抛出的异常我们不好去模拟,所以,很难用测试去覆盖。不过,因为那个例子比较特殊,算是基础库的范畴,我们就直接封装成 static 方法了。
|
||||
|
||||
在大部分的情况下,我们做代码隔离,需要先定义接口,然后,用第三方代码去做一个相应的实现。比如,我们在实战中定义过一个 TodoItemRepository,当时给的实现是一个基于文件的实现。
|
||||
|
||||
interface TodoItemRepository {
|
||||
...
|
||||
}
|
||||
|
||||
class FileTodoItemRepository implements TodoItemRepository {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
如果我们要把数据存到数据库里,那我们就可以给出一个数据的实现。
|
||||
|
||||
class DbTodoItemRepository implements TodoItemRepository {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
而要存到云存储,就写一个云存储的实现。
|
||||
|
||||
class S3TodoItemRepository implements TodoItemRepository {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这里的关键点是定义一个接口,这个接口是高层的抽象,属于我们业务的一部分。但要使用的第三方代码则属于一个具体的实现,它是细节,而不是业务的一部分。如果熟悉软件设计原则,你已经发现了,这其实就是依赖倒置原则。
|
||||
|
||||
有了这层隔离之后,我们就可以竭尽全力地把所有的业务代码用测试覆盖好,毕竟它才是我们的核心。
|
||||
|
||||
由框架回调
|
||||
|
||||
我们再来看与第三方代码集成的另外一种情况,由框架回调。比如,我们在实战里面用到了一个处理命令行的程序库 Picocli,它会负责替我们解析命令行,然后,调用我们的代码,这就是一个典型的由框架回调的过程。
|
||||
|
||||
这种情况在使用一些框架时非常常见,比如,使用 Spring Boot 的时候,我们写的 Controller 就是由框架回调的。使用 Flink 这样的大数据框架时,我们写的代码最终也是由框架回调的。
|
||||
|
||||
不同的框架使用起来轻重是不同的,比如在实战中,我们就直接触发了 Picocli,因为它本身比较轻量级;而像 Flink 这样的大数据框架想要在本地运行就需要做一些配置。
|
||||
|
||||
总而言之,要想测试使用了这些框架的程序,多半就是一种集成测试,而集成测试相对于单元测试来说,是比较重的,启动配置比较麻烦,运行时间比较长。
|
||||
|
||||
如果应用能在一个进程中启动起来,这还是好的情况。我还依然记得当年 Java 的主流开发方式是部署到应用服务器上,每次打包部署都是一个让人痛苦不堪的过程。像今天本地能够启动一个 Spring Boot 进程,这完全是需要感谢嵌入式 Web 服务器的发展。
|
||||
|
||||
面对这样的框架,我们有一个统一的原则:回调代码只做薄薄的一层,负责从框架代码转发到业务代码。
|
||||
|
||||
我们在实战的代码中已经见到了,比如,下面这段代码是添加一个 Todo 项的实现。
|
||||
|
||||
@CommandLine.Command(name = "add")
|
||||
public int add(@CommandLine.Parameters(index = "0") final String item) {
|
||||
if (Strings.isNullOrEmpty(item)) {
|
||||
throw new CommandLine.ParameterException(spec.commandLine(), "empty item is not allowed");
|
||||
}
|
||||
final TodoItem todoItem = this.service.addTodoItem(TodoParameter.of(item));
|
||||
System.out.printf("%d. %s%n", todoItem.getIndex(), todoItem.getContent());
|
||||
System.out.printf("Item <%d> added%n", todoItem.getIndex());
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
这里面的核心代码就一句话,剩下的要么是做校验,要么是与框架的交互,几乎没有太多逻辑可言。
|
||||
|
||||
this.service.addTodoItem(TodoParameter.of(item));
|
||||
|
||||
|
||||
正如你在实战过程中见到的那样,我会先编写自己的业务核心代码,而把与框架接口的部分放到了后面去编写。因为最容易出问题的地方往往不是在这个转发的过程,而是业务的部分。只有当你的业务代码质量提升了,整个系统的质量才会得到真正的提升。所以,如果你看到这一层写了很多的代码,那这段代码一定是有坏味道了。
|
||||
|
||||
或许你也发现了,这其实也是一种常见的模式:防腐层。是的,不仅仅是我们与第三方系统交互有防腐层,与外界的交互同样需要防腐层。
|
||||
|
||||
你也看到了无论是调用程序库还是由框架回调,说来说去,都会回到软件设计上。所以,一个可测试的系统,关键要有一个好的设计。想要写出高质量的代码,软件设计就是程序员必备的一项能力。
|
||||
|
||||
通过软件设计,我们将业务代码同一些实现细节分离开来。但如果我们在测试中使用同样的实现,结果必然是把复杂性又带回来了。那不用同样的实现该怎么测试呢?下一讲,我们就来说说,怎么在测试中给出一个可控的实现。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲,我们讲了软件的可测试性,这是影响到一个系统好不好测的一个重要因素。可测试性好的软件,各个模块都可以独立测试。而可测试性不好的软件,只能做整体的测试,其复杂度和过程中花费的时间都是不可同日而语的。
|
||||
|
||||
提升软件的可测试性,关键是改善软件的设计,编写可测试的代码。关于如何编写可测试的代码,我给了一个路标:编写可组合的代码。从这个路标出发,我们得出了两个推论:
|
||||
|
||||
|
||||
不要在组件内部创建对象;
|
||||
不要编写 static 方法。
|
||||
|
||||
|
||||
由不编写 static 方法,我们可以推导出:
|
||||
|
||||
|
||||
不要使用全局状态;
|
||||
不要使用 Singleton 模式。
|
||||
|
||||
|
||||
在实际工作中,除了要编写业务代码,还会遇到第三方集成的情况:
|
||||
|
||||
|
||||
对于调用程序库的情况,我们可以定义接口,然后给出调用第三方程序库的实现,以此实现代码隔离;
|
||||
如果我们的代码由框架调用,那么回调代码只做薄薄的一层,负责从框架代码转发到业务代码。
|
||||
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:编写可测试的代码。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲了代码中不好测的情况主要是由于软件设计不好造成的。在实际的工作中,你还有遇到过哪些不好测的情况呢?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
187
专栏/程序员的测试课/07Mock框架:怎么让测试变得可控?.md
Normal file
187
专栏/程序员的测试课/07Mock框架:怎么让测试变得可控?.md
Normal file
@ -0,0 +1,187 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 Mock 框架:怎么让测试变得可控?
|
||||
你好,我是郑晔!
|
||||
|
||||
上一讲,我们谈到测试不好测,关键是软件设计问题。一个好的设计可以把很多实现细节从业务代码中隔离出去。
|
||||
|
||||
之所以要隔离出去,一个重要的原因就是这些实现细节不那么可控。比如,如果我们依赖了数据库,就需要保证这个数据库环境同时只有一个测试在用。理论上这样不是不可能,但成本会非常高。再比如,如果依赖了第三方服务,那么我们就没法控制它给我们返回预期的值。这样一来,很多出错的场景,我们可能都没法测试。
|
||||
|
||||
所以,在测试里,我们不能依赖于这些好不容易隔离出去的细节。否则,测试就会变得不稳定,这也是很多团队测试难做的重要原因。不依赖于这些细节,那我们的测试总需要有一个实现出现在所需组件的位置上吧?或许你已经想到答案了,没错,这就是我们这一讲要讲的 Mock 框架。
|
||||
|
||||
从模式到框架
|
||||
|
||||
做测试,本质上就是在一个可控的环境下对被测系统/组件进行各种试探。拥有大量依赖于第三方代码,最大的问题就是不可控。
|
||||
|
||||
怎么把不可控变成可控?第一步自然是隔离,第二步就是用一个可控的组件代替不可控的组件。换言之,用一个假的组件代替真的组件。
|
||||
|
||||
这种用假组件代替真组件的做法,在测试中屡见不鲜,几乎成了标准的做法。但是,因为各种做法又有细微的差别,所以,如果你去了解这个具体做法会看到很多不同的名词,比如:Stub、Dummy、Fake、Spy、Mock 等等。实话说,你今天问我这些名词的差异,我也需要去查找相关的资料,不能给出一个立即的答复。它们之间确实存在差异,但差异几乎到了可以忽略不计的份上。
|
||||
|
||||
Gerard Meszaros 写过一本《xUnit Test Patterns》,他给这些名词起了一个统一的名字,形成了一个新的模式:Test Double(测试替身)。其基本结构如下图所示。
|
||||
|
||||
|
||||
|
||||
在这个图里,SUT 指的是被测系统(System Under Test),Test Double 就是与 SUT 进行交互的一个组件。有了我们之前的讲解,这个图应该不难看懂。
|
||||
|
||||
然而,这个名字也没有在业界得到足够广泛的传播,你更熟悉的说法应该是 Mock 对象。因为后来在这个模式广泛流行起来之前,Mock 框架先流行了起来。
|
||||
|
||||
Mock 框架
|
||||
|
||||
Mock 框架的基本逻辑很简单,创建一个模拟对象并设置它的行为,主要就是用什么样的参数调用时,给出怎样的反馈。虽然 Mock 框架本身的逻辑很简单,但前期也经过了很长一段时间的发展,什么东西可以 Mock 以及怎样去表现 Mock,不同的 Mock 框架给出了不同的答案。
|
||||
|
||||
今天我们的讨论就以 Mockito 这个框架作为我们讨论的基础,这也是目前 Java 社区最常用的 Mock 框架。
|
||||
|
||||
要学习 Mock 框架,必须要掌握它最核心的两个点:设置模拟对象与校验对象行为。
|
||||
|
||||
设置 Mock 对象
|
||||
|
||||
要设置一个模拟对象,首先要创建一个模拟对象。在实战中,我们已经见识过了。
|
||||
|
||||
TodoItemRepository repository = mock(TodoItemRepository.class);
|
||||
|
||||
|
||||
接下来就是设置它的行为,下面是从实战中摘取的两个例子。
|
||||
|
||||
when(repository.findAll()).thenReturn(of(new TodoItem("foo")));
|
||||
when(repository.save(any())).then(returnsFirstArg());
|
||||
|
||||
|
||||
一个好程序库其 API 要有很强的表达性,像前面这两段代码,即便我不解释,看语句本身也知道它做了些什么。
|
||||
|
||||
模拟对象的设置核心就是两点:参数是什么样的以及对应的处理是什么样的。
|
||||
|
||||
参数设置其实是一个参数匹配的过程,核心要回答的问题就是判断给出的实参是否满足这里设置的条件。像上面代码中,save 的写法表示任意参数都可以,我们也可以设置它是特定的值,比如像下面这样。
|
||||
|
||||
when(repository.findByIndex(1)).thenReturn(new TodoItem("foo"));
|
||||
|
||||
|
||||
其实它也是一个参数匹配的过程,只不过这里做了些省略,完整的写法应该是下面这样。
|
||||
|
||||
when(repository.findByIndex(eq(1))).thenReturn(new TodoItem("foo"));
|
||||
|
||||
|
||||
如果你有更复杂的参数匹配过程,甚至可以自己去实现一个匹配过程。但我强烈建议你不要这么做,因为测试应该是简单的。一般来说,相等和任意参数这两种用法在大多数情况下已经够用了。
|
||||
|
||||
设置完参数,接下来,就是对应的处理。能够设置相应的处理,这是体现模拟对象可控的关键。前面的例子我们看到了如何设置相应的返回值,我们也可以抛出异常,模拟异常场景。
|
||||
|
||||
when(repository.save(any())).thenThrow(IllegalArgumentException.class);
|
||||
|
||||
|
||||
同设置参数类似,相应的处理也可以写得很复杂,但我同样建议你不要这么做,原因也是一样的,测试要简单。知道怎样设置返回值,怎样抛出异常,已经足够大多数情况下使用了。
|
||||
|
||||
校验对象行为
|
||||
|
||||
模拟对象的另外一个重要行为是校验对象行为,就是知道一个方法有没有按照预期的方式调用。比如,我们可以预期 save 函数在执行过程中得到了调用。
|
||||
|
||||
verify(repository).save(any());
|
||||
|
||||
|
||||
这只是校验了 save 方法得到了调用,我们还可以校验这个方法调用了多少次。
|
||||
|
||||
verify(repository, atLeast(3)).save(any());
|
||||
|
||||
|
||||
同样,校验也有很多可以设置的参数,但我同样不建议你把它用得太复杂了,就连verify 本身我都建议你不要用得太多。
|
||||
|
||||
verify 用起来会给人一种安全感,所以,会让人有一种多用的倾向,但这是一种错觉。我在讲测试框架时说过,verify 其实是一种断言。断言意味着这是一个函数应该具备的行为,是一种行为上的约定。
|
||||
|
||||
一旦设置了 verify,实际上也就约束了函数的实现。但 verify 约束的对象又是底层的组件,是一种实现细节。换言之,过度使用 verify 造成的结果就是把一个函数的实现细节约定死了。
|
||||
|
||||
过度使用 verify,在写代码的时候,你会有一种成就感。但是,一旦涉及代码修改,整个人就不好了。因为实现细节被 verify 锁定死,一旦修改代码,这些 verify 就很容易造成测试无法通过。
|
||||
|
||||
测试应该测试的是接口行为,而不是内部实现。所以,verify 虽好,还是建议少用。如果有一些场景不用 verify 就没有什么可断言的了,那该用 verify 还是要用。
|
||||
|
||||
如果按照测试模式来说,设置 Mock 对象的行为应该算是 Stub,而校验对象行为的做法,才是 Mock。如果按照模式的说法,我们应该常用 Stub,少用 Mock。
|
||||
|
||||
Mock 框架的延伸
|
||||
|
||||
Mock 框架的主要作用是模拟对象的行为,但作为一种软件设计思想,它却有着更大的影响。既然我们可以模拟对象行为,那本质上来说,我们也可以模拟其它东西。所以,后面也有一些基于这种模拟思想的框架,其中,目前行业中使用最为广泛的是模拟服务器。
|
||||
|
||||
模拟服务器顾名思义,它模拟的是服务器行为,现在在行业中广泛使用的模拟服务器主要是 HTTP 模拟服务器。HTTP 服务器的主要行为就是收到一个请求之后,给出一个应答,从行为上说,这与对象接受一系列参数,给出相应的处理如出一辙。
|
||||
|
||||
接下来我就以 Moco 为例,简单介绍一下模拟服务器。Moco 是我自己编写的一个开源模拟服务器程序库,曾在 2013 年获得 Oracle 的 Duke 选择奖。(在《软件设计之美》中讲到程序库的设计时,我讲过 Moco 整个设计的来龙去脉。如果你有兴趣,可以去回顾一下。)
|
||||
|
||||
下面是一个使用了 Moco 的测试代码。
|
||||
|
||||
public void should_return_expected_response() {
|
||||
// 设置模拟服务器的信息
|
||||
// 设置服务器访问的端口
|
||||
HttpServer server = httpServer(12306);
|
||||
// 访问/foo 这个 URI 时,返回 bar
|
||||
server.request(by(uri("/foo"))).response("bar");
|
||||
|
||||
// 开始执行测试
|
||||
running(server, () -> {
|
||||
// 这里用了 Apache HTTP库访问模拟服务器,实际上,可以使用你的真实项目
|
||||
Content content = Request.Get("http://localhost:12306/foo")
|
||||
.execute()
|
||||
.returnContent();
|
||||
|
||||
// 对结果进行断言
|
||||
assertThat(content.asString(), is("bar"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
在这段代码里,我们启动了一个 HTTP 服务器,当你访问 /foo 这个 URI 时,它会给你返回一个应答 bar。这其中最关键的一行代码就是设置请求应答的那行。
|
||||
|
||||
server.request(by(uri("/foo"))).response("bar");
|
||||
|
||||
|
||||
Moco 的 API 本身也有很强的表达性,通过代码本身你就能看到,这里就是设置了一个请求以及相应的应答。
|
||||
|
||||
Moco 的配置支持很多的 HTTP 元素,像下面这段代码,你可以同时匹配请求内容和 URI,也可以同时设置应答文本和 HTTP 的状态码。
|
||||
|
||||
server
|
||||
.request(and(by("foo"), by(uri("/foo"))))
|
||||
.response(and(with(text("bar")), status(200)));
|
||||
|
||||
|
||||
在上面的例子里面,running 是负责模拟服务器启停的代码,里面包含的代码就是,通过自己真实的服务代码发出的真实请求。
|
||||
|
||||
Moco 还支持 verify,如果你想像 Mock 框架那样去校验服务器是否收到了相应的请求,就可以使用它。
|
||||
|
||||
RequestHit hit = requestHit();
|
||||
final HttpServer server = httpServer(port(), hit);
|
||||
running(server, () -> {
|
||||
...
|
||||
})
|
||||
|
||||
hit.verify(by(uri("/foo")), times(1));
|
||||
|
||||
|
||||
虽然 Moco 支持这样的能力,但同使用 Mock 框架类似,我也建议你少用 verify。
|
||||
|
||||
Moco 最大的价值就是让原本不可控的第三方 HTTP 服务器,现在可以按照我们预期的方式执行。比如,在真实的集成过程,你很难要求第三方服务器给你一个错误的应答,或者一个超时的应答,但使用 Moco 你就可以让它模拟出这样的行为。
|
||||
|
||||
Moco 还有一个很大的价值,原本你要做集成,唯一的选项是把整个系统跑起来,基本上就到了系统集成的范畴。而现在使用 Moco,验证工作可以用集成测试的代码就可以完成。作为程序员我们很清楚,相比于系统测试,这种做法轻太多了,一旦出现问题,定位起来也容易很多。从开发效率上看,这简直是数量级的提升。
|
||||
|
||||
Moco 不仅仅支持模拟 HTTP 服务器,还做了进一步延伸,支持模拟 WebSocket 服务器。
|
||||
|
||||
HttpServer server = httpServer(12306);
|
||||
webSocketServer = server.websocket("/ws");
|
||||
webSocketServer.request(by("foo")).response("bar");
|
||||
|
||||
|
||||
无论是模拟 HTTP 服务器,还是模拟 WebSocket 服务器,本质上来说,它都是模拟对象这一思想的延伸。而所有这一切的出发点都是,我们希望在测试中得到一个可控的环境。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们主要讲了 Mock 框架。Mock 框架是源自 Test Double(测试替身)这种测试模式。我们希望自己有一个可控的环境对被测系统/组件进行测试,背后的思想就是用假的却可控的组件去代替真实不可控的组件。
|
||||
|
||||
现在 Mock 框架已经成为了测试的重要组成部分,理解一个Mock框架核心就是要理解如何设置对象行为以及如何校验对象行为。设置对象行为主要是设置相应的参数以及对应的处理,无论这个处理是给出返回值,还是抛出异常。校验对象行为是一种断言,是看对象是否按照预期方式执行。不过,我给你提了一个醒,verify 虽好,尽量少用。
|
||||
|
||||
最后,我们还以 Moco 为例讲到了 Mock 框架的延伸,也就是模拟服务器。Moco 主要是模拟 HTTP 服务器,其核心就是对什么样的请求,给出什么样的应答。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:使用 Mock 框架,少用 verify。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲了 Mock 框架,你在实际工作中用到过 Mock 框架吗?它解决了你怎样的问题,或是你在使用它的过程中遇到怎样的困难,欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
160
专栏/程序员的测试课/08单元测试应该怎么写?.md
Normal file
160
专栏/程序员的测试课/08单元测试应该怎么写?.md
Normal file
@ -0,0 +1,160 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 单元测试应该怎么写?
|
||||
你好,我是郑晔!
|
||||
|
||||
经过前面的介绍,我们已经对测试的基础有了理解,已经会用自动化测试框架来写测试了。对于那些不可控的组件,我们也可以用 Mock 框架将其替换掉,让测试环境变得可控。其实,我们在前面介绍的这些东西都是为了让我们能够更好地编写单元测试。
|
||||
|
||||
单元测试是所有测试类型中最基础的,它的优点是运行速度快,可以尽早地发现问题。只有通过单元测试保证了每个组件的正确性,我们才拥有了构建系统的一块块稳定的基石。
|
||||
|
||||
按道理来说,我们应该尽可能多地编写单元测试,这可以帮助我们提高代码质量以及更准确地定位问题。但在实际的工作中,真正大面积编写单元测试的团队却并不多。前面我们已经提到了一部分原因(比如设计没有做好),也有团队虽然写了单元测试,但单元测试没有很好地起到保护网的作用,反而是在代码调整过程中成了阻碍。
|
||||
|
||||
这一讲,我们就把前面学到的知识串联起来,谈谈如何做好单元测试。
|
||||
|
||||
单元测试什么时候写
|
||||
|
||||
你是怎么编写单元测试的呢?很多人的做法是先把所有的功能代码都写完,然后,再针对写好的代码一点一点地补写测试。
|
||||
|
||||
在这种编写测试的做法中,单元测试扮演着非常不受人待见的角色。你的整个功能代码都写完了,再去写测试就成了一件为了应付差事不得不做的事情。更关键的一点是,你编写的这些代码可能是你几天的工作量,你已经很难记得在编写这堆代码时所有的细节了,这个时候补写的测试对提升代码质量的帮助已经不是很大了。
|
||||
|
||||
所以,想要写好单元测试,最后补测试的做法总是很糟糕的,仅仅比不写测试好一点。你要想写好单元测试的话,最好能够将代码和测试一起写。
|
||||
|
||||
你或许会说,我在功能写完后立即就补测试了,这不就是代码和测试一起写的吗?其中的差异在于,把所有的功能写完的这个粒度实在是太大了。为一个大任务编写测试,是一件难度非常大的事,这也是很多人觉得测试难写的重要因素。要想做好单元测试,关键就是工作的粒度要小。
|
||||
|
||||
如果你学过《10x 程序员工作法》,或许已经听出来了。没错,这里的关键点就是要做好任务分解,而任务分解的关键就是粒度要小。
|
||||
|
||||
|
||||
I’m not a great programmer; I’m just a good programmer with great habits.-
|
||||
|
||||
我不是一个伟大的程序员,只是一个有着好习惯的优秀程序员。-
|
||||
|
||||
—— Kent Beck
|
||||
|
||||
|
||||
任务分解是每个程序员都应该拥有的好习惯,即便你想写好单元测试也要从任务分解开始。所以,你需要把一个要完成的需求拆分成很多颗粒度很小的任务。粒度要小到可以在很短时间内完成,比如,半个小时就可以写完。只有能够把任务分解成微操作,我们才能够认清有足够的心力思考其中的每个细节。千万不要高估自己对于任务把控的粒度,一定要把任务分解到非常小,这是能够写好代码,写好测试的前提条件,甚至可以说是最关键的因素(如何具体分解一个需求,我曾经在《10x 程序员工作法》中专门用了一讲的篇幅进行介绍,如果你有兴趣不妨去回顾一下)。
|
||||
|
||||
当我们把需求拆分成颗粒度很小的任务时,我们才开始进入到编码的状态。而从这里开始,我们进入到代码和测试一起写的状态。
|
||||
|
||||
编写单元测试的过程
|
||||
|
||||
对于一个具体的任务,我们首先要弄清楚的是,怎么样算是完成了。一个完整的需求我们需要知道其验收标准是什么。具体到一个任务,虽然没有业务人员给我们提供验收标准,我们自己也要有一个验收标准,我们要能够去衡量怎么样才算是这个代码写合格了。
|
||||
|
||||
经过我们这一系列关于测试的介绍,你应该已经知道我要说什么了:一个任务的代码要通过测试才算编码阶段的完成。
|
||||
|
||||
但测试用例从哪来呢?这就需要我们设计了。不同于业务测试的测试用例,我们现在要写的是单元测试。而我们要测的单元现在还没有写,所以,没有人会给我们提供测试用例,单元测试的用例只能我们自己来。
|
||||
|
||||
还记得我们在实战里怎么做的添加 Todo 项吗?接下来,我们就结合这个部分来谈谈具体怎么做。
|
||||
|
||||
我们首先要确定的是待测单元的行为,也就是要实现的类里的一个函数,它的行为是什么样的。或许你已经发现了,这其实就是一个软件设计的过程。这里的设计指的是微观的设计,就是具体的一个函数准备写成什么样子。通常到了动手写代码这一步,大的设计已经在前面做完了。
|
||||
|
||||
因为我们现在不仅仅要写代码,还要写测试。所以,我们在设计这个函数接口时,还必须增加一点考量:它要怎么测。
|
||||
|
||||
在添加一个 Todo 项时,我们经过设计出来的函数接口就是下面这样。
|
||||
|
||||
TodoItem addTodoItem(final TodoParameter todoParameter);
|
||||
|
||||
|
||||
有了一个具体的函数接口设计,我们就可以针对它进行更具体的测试用例设计,也就是设计测试用例来描述这个接口的行为。
|
||||
|
||||
是的,这里我们并没有着急写代码。对很多人来说,写代码的优先级很高,但是,如果不在这里停一下的话,你可能就不会去思考是否还有要考虑的问题,而是直奔代码细节去了。而当我们专注于细节时,有限的注意力就会让你忽略掉很多东西。所以,先设计测试用例,后写代码,这是一个编码习惯的问题。
|
||||
|
||||
有了添加 Todo 项接口之后,我们就准备了两个测试场景:
|
||||
|
||||
|
||||
添加正常的参数对象,返回一个创建好的 Todo 项;
|
||||
添加空的参数对象,抛出异常。
|
||||
|
||||
|
||||
有了测试场景,接下来把这些场景实例化出来,这个步骤相对来说就比较简单了。比如,对于添加正常的参数对象来说,那什么样的参数对象是正常的?我们就代入一个具体的正常参数(比如 foo)。有了这个实例化过的参数,我们就可以把具体的测试用例表现出来了。
|
||||
|
||||
@Test
|
||||
public void should_add_todo_item() {
|
||||
TodoItemRepository repository = mock(TodoItemRepository.class);
|
||||
when(repository.save(any())).then(returnsFirstArg());
|
||||
TodoItemService service = new TodoItemService(repository);
|
||||
|
||||
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
|
||||
|
||||
assertThat(item.getContent()).isEqualTo("foo");
|
||||
}
|
||||
|
||||
|
||||
在实际的工作中,究竟是先写测试,还是先写实现代码,这是个人工作习惯的问题。当我们有了测试用例之后,其实就是把一个具体的任务进一步拆分成更小的子任务了。只要我们完成一个子任务,我们就可以做一次代码的提交,因为我们这个时候,既有测试代码又有实现代码,而且实现代码是通过了测试的。
|
||||
|
||||
测接口还是测实现?
|
||||
|
||||
不知道你是否注意到了,在前面我一直在说,我们要测的是函数接口的行为。我一直说,单元测试是一种白盒测试。在一些人的理解中,白盒测试的关注点应该是内部实现。那单元测试到底应该关注接口,还是应该关注实现呢?
|
||||
|
||||
或许你还不清楚二者之间的区别,让我们把前面添加 Todo 项的例子拿过来。如果采用更加面向实现的做法,我们应该对 addTodoItem 这个函数的内部实现有进一步的约束,就像下面这样。
|
||||
|
||||
@Test
|
||||
public void should_add_todo_item() {
|
||||
TodoItemRepository repository = mock(TodoItemRepository.class);
|
||||
when(repository.save(any())).then(returnsFirstArg());
|
||||
TodoItemService service = new TodoItemService(repository);
|
||||
|
||||
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
|
||||
|
||||
assertThat(item.getContent()).isEqualTo("foo");
|
||||
verify(repository).save(any());
|
||||
}
|
||||
|
||||
|
||||
这段代码中核心的差别就是增加了一句 verify,这也就意味着,我规定在 addTodoItem 的实现中必须要调用 repository 的 save 函数。
|
||||
|
||||
你或许会好奇,repository 本来就要调用 save 方法,那我在这里校验它调用了 save 方法,似乎也没什么大不了的。
|
||||
|
||||
单独这么看确实看不出什么问题,但是,如果你有很多测试都是这么写,当你准备重构时,你就会发现问题了。很多团队代码一调整,测试就失败,一个重要的原因就是代码实现和测试之间紧紧地绑定在了一起。因为测试约束的是实现细节,而只要调整实现细节,测试当然就失败了。这也是很多团队抱怨单元测试问题很多的重要原因。
|
||||
|
||||
所以,在实际的项目中,我会更倾向于测试接口,尽可能减少对于实现细节的约束。其实,这个原则不仅仅是在接口层面上,在一些测试的细节上也可以这么约定,比如下面这行代码。
|
||||
|
||||
when(repository.save(any())).then(returnsFirstArg());
|
||||
|
||||
|
||||
这其实是一种宽泛的写法,所以用了 any。如果严格限制的话,应该严格限定一个非常具体的参数。
|
||||
|
||||
when(repository.save(new TodoItem("foo"))).then(returnsFirstArg());
|
||||
|
||||
|
||||
同样,上一讲我们讲到了 Moco,我们设置模拟服务器可以设置得非常具体,像下面这样。
|
||||
|
||||
server
|
||||
.request(and(by("foo"), by(uri("/foo"))))
|
||||
.response(and(with(text("bar")), status(200)));
|
||||
|
||||
|
||||
也可以设置得非常宽泛,像这样。
|
||||
|
||||
server.request(by(uri("/foo"))).response("bar");
|
||||
|
||||
|
||||
除非这个测试里面有多个类似的请求,必须要做区分,否则,我倾向于使用宽泛一些的约束。这在某种程度上会降低未来重构代码时带来的影响。
|
||||
|
||||
不过实话说,要想完全消除对于实现细节的依赖,有时候也是很难的。比如在我们前面的 TodoItemService 的例子里面,repository 本身也是 TodoItemService 的一种实现细节,一旦进行一些重构,把 repository 的依赖从 TodoItemService 中拿掉,很多测试代码也需要调整。所以,在实际的项目中,我们只能说尽可能减少对于实现细节的依赖。
|
||||
|
||||
其实,关于实现细节的测试也是一种重复,等于你用测试把代码又重新写了一遍。程序员的工作中有一种重要的原则:DRY(Don’t Repeat Yourself),这不仅仅是说代码中不要有重复,而且各种信息都不要重复(我在《软件设计之美》中讲过 DRY 原则,有兴趣不妨回顾一下)。
|
||||
|
||||
我建议你在设计单元测试的时候不要面向实现细节。但反过来,有些时候测试确实会漏掉一些细节,尤其是一些实现代码中的分支。怎么样发现自己的代码中是否有遗漏呢?这就是我们下一讲要讲的内容:测试覆盖率。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了如何去写单元测试。很多团队由于多方面的原因(比如设计做得不好),导致单元测试写得少。但为了提高代码质量以及更准确地定位问题,我们应该多写单元测试。
|
||||
|
||||
单元测试最好是和实现代码一起写,以便减少后续补测试的痛苦。想写好测试,关键要做好任务分解,否则,面对一个巨大的需求,没有人知道如何去给它写单元测试。
|
||||
|
||||
编写单元测试的过程,实际上就是一个任务开发的过程。一个任务代码的完成,不仅仅是写了实现代码,还要通过相应的测试。一般而言,任务开发要先设计相应的接口,确定其行为,然后根据这个接口设计相应的测试用例,最后,把这些用例实例化成一个个具体的单元测试。
|
||||
|
||||
单元测试常见的一个问题是代码一重构,单元测试就崩溃。这很大程度上是由于测试对实现细节的依赖过于紧密。一般来说,单元测试最好是面向接口行为来设计,因为这是一个更宽泛的要求。其实,在测试中的很多细节也可以考虑设置得宽泛一些,比如模拟对象的设置、模拟服务器的设置等等。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:做好任务分解,写好单元测试。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讨论了如何写好单元测试,你在实际项目中写过单元测试吗?你遇到了哪些问题,或者有哪些经验可以分享呢?欢迎在留言区分享你的观点。
|
||||
|
||||
|
||||
|
||||
|
120
专栏/程序员的测试课/09测试覆盖率:如何找出没有测试到的代码?.md
Normal file
120
专栏/程序员的测试课/09测试覆盖率:如何找出没有测试到的代码?.md
Normal file
@ -0,0 +1,120 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 测试覆盖率:如何找出没有测试到的代码?
|
||||
你好,我是郑晔!
|
||||
|
||||
经过前面内容的介绍,相信你现在已经知道如何去编写单元测试了。上一讲,我们说编写单元测试应该面向接口行为来编写,不过这样一来,就存在一种可能:我预期的行为都对了,但是因为我在实现里写了一些预期行为之外的东西(比如有一些分支判断),在代码实际执行的时候,可能就会出现预期之外的行为。
|
||||
|
||||
如何尽可能消除预期之外的行为,让代码尽在掌控之中呢?这一讲,我们就来讲讲如何查缺补漏,找到那些测试没有覆盖到的代码。我们要来讨论一下测试覆盖率。
|
||||
|
||||
测试覆盖率
|
||||
|
||||
测试覆盖率是一种度量指标,指的是在运行一个测试集合时,代码被执行的比例。它的一个主要作用就是告诉我们有多少代码测试到了。其实更严格地说,测试覆盖率应该叫代码覆盖率,只不过大多数情况它都是被用在测试的场景下,所以在很多人的讨论中,并不进行严格的区分。
|
||||
|
||||
既然测试覆盖率是度量指标,我们就需要知道有哪些具体的指标,常见的测试覆盖率指标有下面这几种:
|
||||
|
||||
|
||||
函数覆盖率(Function coverage):代码中定义的函数有多少得到了调用;
|
||||
语句覆盖率(Statement coverage):代码中有多少语句得到了执行;
|
||||
分支覆盖率(Branches coverage):控制结构中的分支有多少得到了执行(比如 if 语句中的条件);
|
||||
条件覆盖率(Condition coverage):每个布尔表达式的子表达式是否都检查过 true 和 false 的不同情况;
|
||||
行覆盖率(Line coverage):代码中有多少行得到了测试。
|
||||
|
||||
|
||||
以函数覆盖率为例,如果我们在代码中定义了 100 个函数,运行测试之后只执行 80 个,那它的函数覆盖率就是 80⁄100=0.8,也就是 80%。
|
||||
|
||||
这几个指标基本上看一眼就知道是怎么回事,唯一稍微复杂一点就是条件覆盖率,因为它要测试的是在一个布尔表达式中每个子表达式所有真假值的情况,我们来看看下面这个代码。
|
||||
|
||||
if ((a || b) && c) {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
就是这么一个看上去很简单的情况,因为它牵扯到 a、b、c 三个子表达式,又要把每个子表达式的真假值都要测试到,所以,就需要有 8 种情况。
|
||||
|
||||
|
||||
|
||||
在这么一个条件比较简单的情况下,其实条件覆盖率已经是很复杂了。如果条件进一步增多,复杂度会进一步提升,想要在测试里对条件进行全覆盖也不是一件容易的事。这也给了我们一个编码上的提示:尽可能减少条件。事实上,在真实的项目中,很多条件都是不必要的复杂,可以通过提前返回将一些复杂的条件做一个拆分。
|
||||
|
||||
其实,测试覆盖率的指标还有一些,不过上面这些已经足够我们在日常工作中使用了。而且,具体能够使用哪个指标,还要看我们使用的工具具体支持哪些指标。
|
||||
|
||||
JaCoCo:一个 Java 的测试覆盖率工具
|
||||
|
||||
下面我就以 Jacoco 为例,讲讲如何实际地使用一个测试覆盖率工具。
|
||||
|
||||
JaCoCo 是 Java 社区常用的一个测试覆盖率工具,这个名字一看就是 Java Code Coverage 的缩写。开发它的团队原本是开发一个叫 EclEmma 的 Eclipse 插件,这个插件本身就是用来做测试覆盖率的。只不过,后来团队发现开源社区虽然有不少测试覆盖率的实现,但大多绑定在特定工具上,于是,他们决定启动 JaCoCo 这个项目,把它当做一个不绑定在特定工具上的独立实现,让它成为 JVM 环境中的标准技术。
|
||||
|
||||
我们已经知道了测试覆盖率有好多不同的指标,学习一个具体的测试覆盖率工具,主要就是把指标做一个对应,知道如何设置相应的指标。
|
||||
|
||||
在 JaCoCo 里,指标对应的概念是 counter。我们要在覆盖率中使用哪些指标,也就是要指定哪些不同的 counter。
|
||||
|
||||
每个 counter 提供了不同的配置,比如覆盖的数量(COVEREDCOUNT),没有覆盖的数量(MISSEDCOUNT)等等,但我们最关心的只有一件事:覆盖率(COVEREDRATIO)。
|
||||
|
||||
有了 counter,选定了配置,接下来,要确定的就是取值的范围,也就是最大值(maximum)和最小值(minimum)是多少。比如,我们这里关注的就是覆盖率的值应该是多少,一般就是配置它的最小值(minimum)是多少。
|
||||
|
||||
覆盖率是一个比例,所以,它的取值范围就是从 0 到 1。我们可以根据自己项目的需要来进行配置。根据上面的介绍,如果我们要求行覆盖率达到 80%,我们就可以这样配置。
|
||||
|
||||
counter: "LINE", value: "COVEREDRATIO", minimum: "0.8"
|
||||
|
||||
|
||||
好,你现在已经有了对于 JaCoCo 的基本了解。但通常在项目中,我们很少会直接使用它,而是会把它与我们项目的自动化过程结合起来。
|
||||
|
||||
在项目中使用测试覆盖率
|
||||
|
||||
其实,我们在前面的实战中每次执行提交之前的检查命令时,都会运行到 JaCoCo。只不过在大多数情况下,只要测试写得好,这项检查很容易就通过了。不过,在第二讲当我们处理到 Jackson 时,我们被测试覆盖率挡住了,当时是发现了异常处理的问题。
|
||||
|
||||
这就是自动化检查的价值。一般情况下,只要你工作做得好,它就默默地在下面工作,并不会影响到你,而一旦你因为一些疏忽忘记了一些事情,它就会跳出来提醒你。
|
||||
|
||||
无论是 Ant,还是 Maven,抑或是 Gradle,Java 社区主流的自动化工具都提供了对于 JaCoCo 的支持,我们可以根据自己选用的工具进行配置。大部分情况下,配置一次,全团队的人就都可以使用了。
|
||||
|
||||
这里面的关键点在于,把测试覆盖率与提交过程联系起来。我们在实战中,提交之前要运行检查过程,测试覆盖率检查就在这个过程里。这样,就保证了它不是一个独立的存在,不仅在我们开发过程中起作用,更进一步,在持续集成的过程中也能够起到作用。
|
||||
|
||||
在日常开发中,真正与我们经常打交道的是测试覆盖率不通过的时候,比如,在我们的实战中,运行脚本对代码进行检查时,如果测试覆盖率不够,我们就会得到下面这样的提示。
|
||||
|
||||
Rule violated for package com.github.dreamhead.todo.cli.file: lines covered ratio is 0.9, but expected minimum is 1.0
|
||||
|
||||
|
||||
这里会有哪些报错,取决于我们配置了多少个 counter。按照我通常的习惯,我会把所有的 counter 都配置上去,这样就可以发现更多的问题了。
|
||||
|
||||
不过,这个提示只是告诉我们测试覆盖率不够,但具体哪不够,我们还需要查看测试覆盖率的报告。一般来说,测试覆盖率的报告是我们在与工具集成的时候配置好的。JaCoCo 可以提供好多种报告类型:XML、CSV、HTML 等等。按照一般使用习惯来说,我会优选使用 HTML 的报告,这样就可以直接用浏览器打开看了。如果你有工具需要其它格式的报告,也可以配置不同的格式。
|
||||
|
||||
生成报告的位置也是可以配置的,我在实战项目中,把它配置在 \(buildDir/reports/jacoco 这个目录下,这里的 \)buildDir 指的是每个模块构建生成物的目录,一般来说,就是 build 目录。所以,每次当我看到因为测试覆盖率造成构建失败,就要就可以打开这个目录下的 index.html 文件,它会给你所有这个模块测试覆盖情况的总览。
|
||||
|
||||
|
||||
|
||||
在实战项目中,我们配置的覆盖率要求是 100%,所以,我们很容易就发现没有覆盖到的地方在哪里,就是那个有红色的地方。然后我们可以一路追踪进去,找到具体类,再找到具体的方法,最终定位到具体的语句,下面就是我们在实战中定位到的问题。
|
||||
|
||||
|
||||
|
||||
找到了具体的测试覆盖不足的地方,接下来,就是想办法提高测试率。一般来说,在简单的情况里通过增加或调整几个测试,就可以把这些场景覆盖到。但也有一些不是那么容易覆盖的,比如在实战中,我们看到 Jackson API 中抛出的 IOException。
|
||||
|
||||
不过,具体如何解决这个问题,对不同的同学来说,会有各自的解决方案。这个地方真正容易引起争议的地方是为什么测试覆盖率要设置成 100%。
|
||||
|
||||
在真实的项目中,很多不愿意写测试的人巴不得这个数字越低越好,但实际上我们也很清楚,这个数字设置得很低就没有任何意义了。
|
||||
|
||||
先不说一个既有的项目应该设成多少,如果是一个全新的项目,测试覆盖率应该设成多少呢?我在这里已经给出了我的答案是:100%。这不是我为了这个实战故意设置的值,而是我在真实的项目中就是这样要求的。估计有人看到这个数字已经有一种快要疯了的感觉,在真实的项目中,设置成 100%怎么可能达到吗?
|
||||
|
||||
预告一下,这就是下一讲的主题,我们来讨论为什么100%的测试覆盖率是可能的。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲我们讲了测试覆盖率。测试覆盖率是帮我们发现在测试中没有覆盖到的代码,也就是帮助我们在测试之外查缺补漏。
|
||||
|
||||
测试覆盖率实际上是一组不同指标的组合,所谓覆盖率就是运行一组测试,执行到的元素和总的元素比例。大部分指标都比较好理解,只是条件覆盖率要求比较高,与其通过测试覆盖那么多的条件,不如把代码本身写简单,降低测试的难度。
|
||||
|
||||
我以 JaCoCo 为例,给你介绍了一个测试覆盖率工具,其中的 counter 对应着测试覆盖率的指标。在实际的项目中使用测试覆盖率工具,关键是要把它与自动化的过程结合起来,让它不是独立的存在。每次提交,每次 CI 过程都要进行测试覆盖率的检查。
|
||||
|
||||
最后我们还讲到了如何通过测试覆盖率的报告找到未覆盖的代码,定位到问题之后,补齐测试对于大多数程序员来说还是相对容易的。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:将测试覆盖率的检查加入到自动化过程之中。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲到了测试覆盖率,你的项目中用到了测试覆盖率吗?你对于测试覆盖率是怎样要求的呢?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
86
专栏/程序员的测试课/10为什么100%的测试覆盖率是可以做到的?.md
Normal file
86
专栏/程序员的测试课/10为什么100%的测试覆盖率是可以做到的?.md
Normal file
@ -0,0 +1,86 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 为什么 100% 的测试覆盖率是可以做到的?
|
||||
你好,我是郑晔!
|
||||
|
||||
上一讲我们谈到了测试覆盖率,讲了如何在实际的项目中利用测试覆盖率发现没有覆盖到的代码。最后,我们留下了一个问题:测试覆盖率应该设置成多少?我给出的答案是 100%,但这显然是一个令很多人崩溃的答案。别急,这一讲我们就来说说怎样向着 100%的测试覆盖率迈进。
|
||||
|
||||
很多人对测试覆盖率的反对几乎是本能的,核心原因就是测试覆盖率是一个数字。我在《10x 程序员工作法》中曾经说过,要尽可能地把自己的工作数字化。本来这是一件好事,但是,很多管理者就会倾向于把它变成一个 KPI(Key Performance Indicator,关键绩效指标)。KPI 常常是上下级博弈的地方,上级希望高一点,下级希望低一点。所以,从本质上说,很多人对测试覆盖率的反对,首先是源于对 KPI 本能的恐惧。
|
||||
|
||||
抛开这种本能的恐惧,我们先来分析一下,如果我们想得到更高质量的代码,测试肯定是越多越好。那多到什么程度算最多呢?答案肯定是 100%。如果把测试覆盖率设置成 100%,就没有那么多扯皮的地方了。比如,你设成了 80%,肯定有人问为啥不设置成 85%;当你设置成 85%的时候,就会有人问为啥不是 90%,而且他们的理由肯定是一样的:测试覆盖率越高越好。那我设置成 100%,肯定不会有人再问为啥不设置成更高的。
|
||||
|
||||
现在你知道了,我们把覆盖率设置成 100% 这应该是极限的标准了。接下来,要回答的一个问题就是,怎么把覆盖率做成 100%。
|
||||
|
||||
向 100% 迈进
|
||||
|
||||
首先,我们需要明确的一点是,我们用测试覆盖的代码主要是我们自己编写的代码。为什么要强调这一点呢?因为很多时候,我们会涉及使用第三方程序库,而第三方程序库的功能不应该由我们来验证。比如 Jackson 将对象转换为 JSON 是否转得正确,其实我们是不关心的,这是 Jackson 这个程序库要来保证的。
|
||||
|
||||
之所以要先强调这一点,因为在很多人编写的代码中,自己编写的业务代码和第三方程序库的代码常常是混杂在一起的。我们工作的重点是,保证自己编写的代码 100% 测试覆盖。这意味着什么呢?
|
||||
|
||||
首先,让自己可控的代码有完全的测试保证,其次,如果有第三方的代码影响到测试覆盖,我们应该把第三方的代码和我们的代码隔离开。
|
||||
|
||||
我知道,很多人已经准备强调 100%的测试覆盖是如何困难了。其实,不知道你有没有注意,我们在实战环节中,已经完成了一次 100%的测试覆盖。你可以去看看实战环节的构建脚本,其中用到的测试覆盖率工具就是 JaCoCo,而覆盖率的要求就是 100%,也就是 1.0。问题是我们是怎么做到的呢?
|
||||
|
||||
我们不妨一起回想一下,在做好了整体的设计之后,我们每实现一个具体的功能,都考虑了测试的场景,测试用例和代码是同步在实现。最后通过测试覆盖率检查,找出没有覆盖到的代码。对于一些不方便测试的第三方程序库代码,我们进行了隔离,而且要求隔离是非常薄的一层。这样,就保证了我们所有编写业务代码都能够很好地得到测试覆盖。
|
||||
|
||||
说起来并不复杂,但你或许会说,这是因为我们只实现了基本的功能,代码复杂度比较低,如果是实现了更为复杂的功能,是不是就没办法覆盖了呢?
|
||||
|
||||
我们在前面的内容中说过,要想写好测试,一个关键点是要有良好的软件设计,而且代码本身要尽可能地消除坏味道。到这里你就清楚了,其实程序员写测试不单单是写测试,同时,也是在发现自己代码中的不足,无论是设计上,还是代码本身。
|
||||
|
||||
所以说,即便是再复杂的功能,通过软件设计和良好的编码,也可以落实到一个一个小代码块上。这里的重点是小,代码能否写短小,这是一个程序员编码基本功的问题。
|
||||
|
||||
你让我给一个长达几百上千的代码去写测试,我也很难做到 100%覆盖,因为代码写得太复杂了,我们理解起来很吃力,为它写测试当然也很吃力。所以,我们会把讨论先集中在一个新项目该如何写测试上。如果一个程序员不能够在干干净净的代码库上写好代码,你就很难指望他在面对一个遗留代码库时能够写好代码。
|
||||
|
||||
不知道你注意到了没有,我们说在实战中达成 100%测试覆盖时,还有一个工作习惯,就是测试和代码同步写。为什么要这么做呢?因为没有人愿意补测试,无论这个代码是你写的还是别人写的。
|
||||
|
||||
这也就是为什么要把测试放在自动化过程中,这样,我们每完成一个任务,就要确保编写了相应的测试。而且,我前面也强调过,任务的关键是小,比如,小到半个小时就可以提交一次,这样,你写测试的负担相对来说是小的。小事相比大事更容易坚持,这是符合人性的做法。
|
||||
|
||||
你现在已经知道了,一个新项目想要达到 100%的测试覆盖,首先,要有可测试的设计,要能够编写整洁的代码;其次,测试和代码同步写。
|
||||
|
||||
测不到的代码
|
||||
|
||||
关于 100%测试覆盖率,很多人有一个误区:100%覆盖了,是不是就意味着代码没问题了?答案是否定的。即便我们有了 100%的测试覆盖,还是会有你想不到的场景出现。100%的覆盖只是保证我们已经写的代码没有场景遗漏,不会有异常场景没有处理,不会有分支条件没有考虑到,仅此而已。
|
||||
|
||||
100%的测试覆盖只是程序员做好了本职工作,保证了在这个环节内没有出错。而软件整体质量是一个系统性的工程,首先要保证我们尽可能多地考虑到了各种测试场景,这是我们在第 3 讲中讨论的内容。
|
||||
|
||||
对程序员来说,通过把测试覆盖率设置 100%,我们就有了一个查缺补漏的机会。一旦发现有些缺漏很难补上怎么办?就像我们在实战环节中见到的那样,模拟 Jackson 的异常成本过高,我们就会采用隔离的方式,将不好测试的地方隔离开来,形成一个封装层。实际上,我们是在用软件设计的方式在解决问题。
|
||||
|
||||
理解了达成 100%测试覆盖的基础之后,我还必须再强调一下。第一点是前面提到的封装层,这一层一定要非常薄。很多情况下,可能就是直接的方法调用。如果有复杂的逻辑,比如在防腐层代码中有对象之间的转换,我们都可以把转换的逻辑拿出来,单独地去写测试,因为这个转换逻辑多半是可以测试的。100%的测试覆盖率我们不是说说而已,而是要坚持做到能覆盖的尽量去覆盖。
|
||||
|
||||
另外还有一点,隔离出来的代码怎么办呢?我们要在测试覆盖的检查中将它们排除,具体的做法就是在构建文件中,把这个文件标记为不需要测试覆盖。
|
||||
|
||||
coverage {
|
||||
excludeClasses = [
|
||||
"com.github.dreamhead.todo.util.Jsons"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
在我的项目中,我会要求这里只能有那个薄薄的封装层。有些初次接触项目的人,常常会把这里理解成项目中有我不想测的代码,却还要保证 100%测试覆盖,这里就是一种妥协。绝对不是这个意思!所以,一方面,我们要在团队中强调这个纪律,另一方面,我们也要经常性地做代码评审,保证这个用来隔离封装层的地方不会遭到滥用。
|
||||
|
||||
100%虽然要求很高,但要想做到,首先是理念上的认同,然后,我们就可以想各种办法去做到。在实际的项目中,很多人先从理念去否定,认为不可能做到,只要有一点困难就放弃,这其实才是 100%测试覆盖率难以达成的最主要原因。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们延续了上一讲测试覆盖率的话题,讨论了在一个新项目中,测试覆盖率应该设置成多少,我给出的答案就是 100%。
|
||||
|
||||
100%的测试覆盖率会遭到很多人的反对,但这种反对首先是对 KPI 行为的一种本能恐惧。在真实项目中,大家都认同的观点是测试覆盖率越高越好,最高的覆盖率肯定是 100%。
|
||||
|
||||
我们强调的 100%测试覆盖,主要指的是对自己编写的代码 100%测试覆盖。这就意味着,我们一方面要保证自己的代码完全可控,另一方面,对于影响到测试覆盖的第三方代码要进行隔离。要想做到100%的测试覆盖,技术上说,要有可测试的设计以及编写整洁的代码,实践上看,要测试和代码同步产出。
|
||||
|
||||
100%的测试覆盖并不是说代码没有问题了,而应该是程序员对自己编写代码的一种质量保证,它是一个帮助我们查缺补漏的过程。
|
||||
|
||||
对于无法测试到第三方代码,要用一个薄薄的隔离层将代码隔离出去,在构建脚本中将隔离层排除在外。有一点需要注意的是,排除脚本千万别被滥用了。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:100%的测试覆盖率是程序员编写高质量代码的保证。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲了如何达到 100%的测试覆盖,你在实际工作中遇到过哪些难以测试的情况呢?期待在留言区看到你的想法。
|
||||
|
||||
|
||||
|
||||
|
96
专栏/程序员的测试课/11集成测试:单元测试可以解决所有问题吗?.md
Normal file
96
专栏/程序员的测试课/11集成测试:单元测试可以解决所有问题吗?.md
Normal file
@ -0,0 +1,96 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 集成测试:单元测试可以解决所有问题吗?
|
||||
你好,我是郑晔!
|
||||
|
||||
前面我们花了大量的篇幅在讲单元测试。单元测试是所有测试类型中,运行速度最快,整体覆盖面可以达到最大的测试。因为单元测试的存在,我们甚至可以把测试覆盖率拉高到 100%。测试覆盖率都已经 100%了,是不是我们用单元测试就可以解决所有的问题了?
|
||||
|
||||
正如我们在上一讲强调的那样,100%的测试覆盖率并不代表代码没有问题。同样,即便是 100% 的单元测试也不能解决所有的问题。有一个重要的原因在于,我们在编写每个单元时都会假设这些单元彼此之间能够很好地协同,但这个假设是不是一定成立呢?答案是不一定。
|
||||
|
||||
让一个个单元正常运行,我们靠的不是美好的预期,而是单元测试。同样,各个单元能够很好地协同,我们也不能靠预期,而是要靠集成测试。这一讲,我们就来讨论一下集成测试。
|
||||
|
||||
代码的集成
|
||||
|
||||
在具体讨论集成测试之前,我们澄清一下概念。集成测试到底测的是什么?答案很显然是集成。问题是,集成要集成什么呢?一种是代码之间的集成,一种是代码与外部组件的集成。说白了,集成测试就是把不同的组件组合到一起,看看它们是不是能够很好地配合到一起。
|
||||
|
||||
我们先来看代码的集成。代码之间的集成,主要看我们编写的各个单元能否很好地彼此协作。既然集成测试是为了测试单元之间的配合,那是不是只要有单元之间的协作,我们就要为它们编写一个集成测试呢?比如按照常规的架构分层,一个 REST 服务会有一个 Resource(或者叫 Controller),一个 Service,一个 Repository,那是不是要 Service 和 Repository 的集成要写一个集成测试,Resource 和 Service 的集成测一次,Resource、Service 和 Repository 的集成再测一次呢?
|
||||
|
||||
如果我们按照前面讨论的方式来编写了单元测试,其实,这就意味着我们每个组件都已经经过了测试。所以,集成测试的重点就不再是组件之间两两协同进行测试了。一般来说,在实践中,我们可以选择的测试方式是,选择一条任务执行的路径,把路径上用到的组件集成到一起进行测试。比如在前面提到的那种情况中,我们只要把 Resource、Service 和 Repository 都组装到一起就可以了。
|
||||
|
||||
如果所有的代码都是我们自己编写,那么我们就编写一个个的单元,然后组装到一起进行测试,这个很好理解。但是,现在很多人都在使用框架,比如我们在实战中处理命令行时使用了 Picocli 这个框架,所有的命令解析的过程都是由这个框架完成;再比如,很多人在开发后端服务时,使用了 Spring Boot,一些路由匹配,甚至参数检查都是由框架完成的。那么我们在集成测试中,要不要把这个部分集成进来呢?
|
||||
|
||||
我对此的答案是,取决于你是否能够把这个框架集成进来,如果能,最好是做一个完整的集成测试。在实战中,我们已经展示过如何去集成 Picocli,因为这个框架本身比较简单,很容易找到这个框架的外部入口,我们就把它集成起来,做了一个完整的测试。
|
||||
|
||||
有的框架可能就没有那么简单了,就像当年 Java EE 盛行时,我们编写的代码需要部署到一个 Java EE 的容器里面才能运行。在这种情况下,如果强行把 Java EE 容器也加到集成测试里,对于大多数人来说,这是非常有难度的一件事情。换言之,像这种有单独运行时的框架,做整体的集成难度很大,我们只能退而求其次,做大部分的代码集成。
|
||||
|
||||
现在的很多框架替我们做了很多的事情,有些甚至是业务验收标准上的事情,比如,Spring Boot 会替我们做参数检查,利用好 Spring Boot 给我们提供的机制,我们甚至不用写什么代码,只要给字段加上一些 Annotation 就够了。这些 Annotation 加的是否正确,我们其实是需要验证的,因为它是业务验收标准的一部分。
|
||||
|
||||
所以我希望尽可能地去集成,如果我们能够把整个框架集成起来,这些东西也就可以验证了。从代码上来看,这种测试只是针对一个单元在测试,在某种程度上说,这种集成测试其实是一种单元测试,只不过,它必须把系统集成起来才行,所以,它兼具单元测试和集成测试的特点。
|
||||
|
||||
小小预告一下,Spring Boot 在测试上的支持是真的很不错,让我们可以很容易地在测试里对框架处理过程进行集成,在后面的课程里你会看到如何使用 Spring Boot 提供的测试基础设施进行测试。
|
||||
|
||||
你也看到了,我们希望尽可能地把框架集成进来,但市面上的各种框架层出不穷,不是所有的框架都提供了对测试很好地支持。所以,一个框架设计得好坏与否,对测试的支持程度也是一个很重要的衡量标准,这能很好地体现出框架设计者的品味。
|
||||
|
||||
能够方便测试的框架,通常来说都是很轻量级的,这样的框架对开发非常友好,我们能够在一个普通的 IDE 里很方便地进行调试,对于定位问题也是极其友好的。而各种有运行时需要部署的框架,相对来说,就是重量级的框架,对于开发非常不友好。如果你用过一些 IDE 支持的远程调试功能,你会发现这些功能跟本地调试相比,便捷程度完全不在一个档次上。
|
||||
|
||||
好消息是,我们还是能看到一些框架的进步,即便重如 Java EE 这样的框架,现在也有了嵌入式容器的概念。今天,我们之所以能够很方便地使用 Spring Boot 这样的框架,嵌入式容器给我们提供了非常好的基础。
|
||||
|
||||
集成外部组件
|
||||
|
||||
说完了代码的集成,我们再来看看与外部组件的集成。
|
||||
|
||||
在真实世界的开发中,我们经常会遇到与外部组件打交道的情形,最简单是数据要写到数据库里,还有发消息可能会用到消息队列,甚至还可能会涉及与第三方系统的集成。
|
||||
|
||||
理想情况下,我们当然希望把所有相关的组件都集成到一起,但是,一旦牵扯到外部组件,测试的难度立刻就增大了。比如在测试中添加了 Todo 项,如果我的断言写的是先判断数据库里 Todo 项表里有唯一的一条记录,执行之前,你因为其它操作在数据库里插入了数据,这个断言就失败了。即便没有人操作,这个测试执行第一次成功了,再执行一次,可能就失败了,因为第二次执行测试又插入了一条数据。
|
||||
|
||||
所以,与外部组件集成,难点就在于外部组件的状态如何控制。
|
||||
|
||||
如果能够控制外部组件的状态,在系统里集成它是没有问题的。比如拿数据库集成来说,通常的做法是一方面,我们会建立单独的数据库,保证不与其他部分冲突。比如在 MySQL 里面,我们会建立一个测试用的数据库。
|
||||
|
||||
CREATE DATABASE todo_test;
|
||||
|
||||
|
||||
另一方面,我们要保证它在每个测试之后,都能够恢复到之前的状态。一种做法就是使用数据库的回滚技术,每个测试完成之后就回滚掉,保证数据的干净。后面讲到 Spring Boot 测试的时候,我们会看到具体的做法。
|
||||
|
||||
相对来说,数据库在测试方面的实践已经算是比较成熟了。这也让我们可以去验证 Repository,也就是数据访问层的代码实现。不管使用什么样的框架,写了 SQL 之后,我们都需要验证其正确性。只不过,很多人的选择是把整个系统跑起来,人工去验证 SQL 的正确性,这种做法一方面有些小题大做了,另一方面还是不够自动化。
|
||||
|
||||
有了数据库在测试上的实践,我们就可以用自动化测试的方式进行测试了。其实,从某种意义上说,这也是一种单元测试,因为它的代码只涉及到了一个单元,只不过它需要集成数据库,所以,它还是集成测试
|
||||
|
||||
还有一些外部组件在这方面的支持相对来说,就不那么令人满意了。比如第三方系统。即便是服务做得很完善的第三方系统,也很少有专门为测试提供支持的。
|
||||
|
||||
遇到这种情况,我们就要分析一下,看看有没有什么替代方案。很多第三方系统对外提供服务的方式都是 REST API,对于这种情况,我们就可以用通用的模拟服务器来代替。模拟服务器的价值就在于能够替代这样的第三方服务。
|
||||
|
||||
在这种情况下,我们该怎么做呢?我们需要按照我们的使用场景去访问第三方服务,把整个访问的报文记录下来,作为设置模拟服务器的参考依据。我介绍过的 Moco 甚至提供了代理(proxy)功能,你可以让你的服务去连接 Moco,然后,用 Moco 连接第三方的服务,只要查看 Console 输出,所有的报文就清清楚楚地展现在你面前了。
|
||||
|
||||
如果外部组件没有现成的替代方案怎么办?有两个角度看待这个问题。一个角度是,这也许是一个做新项目的机会,我在《软件设计之美》中讲过 Moco 的开发过程,其起始点就是一个没有很好解决的问题。
|
||||
|
||||
另一角度,估计是大多数人的选择,那就是既然这里测不了,我可以选择在集成测试里使用模拟对象,而不是真实的对象。在这样的情况下,我们的系统在测试方面其实有一个漏洞没有被测试很好地覆盖。也就是说,我们要把这个漏洞留到更上一层的测试。如果这个漏洞是一个简单的逻辑(比如一个消息队列发消息的接口),这样还好。如果里面有逻辑,我们必须把它作为一个重点的风险提示加以重视。不过,好在这种情况并不是很多,毕竟像 SQL 这种有复杂逻辑的东西,我们已经有了解决方案。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了集成测试,相对于单元测试只关注单元行为,集成测试关注的多个组件协同工作的表现。今天我们讨论了两类典型的集成问题,一种是代码之间的集成,一种是代码与外部组件的集成。
|
||||
|
||||
对代码之间的集成来说,一方面要考虑我们自己编写的各个单元如何协作;另一方面,在使用各种框架的情况下,要考虑与框架的集成。如果我们有了单元测试,这种集成主要是关心链路的通畅,所以一般来说我们只要沿着一条执行路径,把相关的代码组装到一起进行测试就可以了。
|
||||
|
||||
如果涉及框架,最好是能够把框架集成一起做了,设计得比较好的框架是对于测试的支持比较好的(比如像 Spring Boot),可以让我们很方便地进行测试。
|
||||
|
||||
对于外部组件的集成而言,难点在于如何控制外部组件的状态。数据库在这方面相对已经有比较成熟的解决方案:使用单独的数据库,以及在测试结束之后进行回滚。
|
||||
|
||||
但大部分系统没有这么好的解决方案,尤其是第三方的服务。这时候,我们就要看有没有合适的替代方案。对于大多数 REST API,我们可以采用模拟服务器对服务进行模拟。
|
||||
|
||||
通过今天的讨论你会发现,严格地说,有些代码由于基础设施的问题是不容易在自动化场景覆盖的,这也是我们为什么要强调与框架结合的代码一定要薄,让这种代码的影响尽可能少。这也是在减少用上层测试覆盖的工作量。
|
||||
|
||||
到这里,大部分的场景我们都已经可以用自动化测试进行覆盖了,我们对自己的系统已经有了更完整的理解。其实,测试的种类还有更多,比如系统测试,把整个系统集成起来测试;验收测试,交由业务人员或测试人员进行测试。但这些测试对于很多团队来说,已经到了测试人员的工作范畴了。作为程序员,我们能够把单元测试和集成测试做好,整个软件的质量已经是初步合格了。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:想办法将不同组件集成起来进行测试。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲了集成测试,你也看到了集成测试难点就在于如何集成。在实际工作中,你遇到过哪些难以在测试中集成的情况吗?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
326
专栏/程序员的测试课/12实战:将ToDo应用扩展为一个REST服务.md
Normal file
326
专栏/程序员的测试课/12实战:将ToDo应用扩展为一个REST服务.md
Normal file
@ -0,0 +1,326 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 实战:将 ToDo 应用扩展为一个 REST 服务
|
||||
你好,我是郑晔!
|
||||
|
||||
经过了基础篇的介绍,相信你已经对在日常开发中测试应该做到什么程度有了一个初步的认识。有了基础固然好,但对于很多人来说,面对常见的场景还是不知道如何下手。在接下来的应用篇中,我们就用一些开发中常见的场景,给你具体介绍一下怎么样把我们学到的知识应用起来。
|
||||
|
||||
在后端开发中,最常见的一种情况就是开发一个 REST 服务,将数据写到数据库里面,也就是传说中的 CRUD 操作。这一讲,我们就把前面已经写好的 ToDo 应用扩展一下,让它变成一个 REST 服务。
|
||||
|
||||
扩展前的准备
|
||||
|
||||
具体动手写任何代码之前,我们先要搞清楚我们要把这个应用改造成什么样子。把 ToDo 应用扩展为一个 REST 服务也就是说,原来本地的操作现在要以 REST 服务的方式提供了。另外,在这次改造里面,我们还会把原来基于文件的 Repository 改写成基于数据库的 Repository,这样,就和大多数人在实际的项目中遇到的情况是类似的了。
|
||||
|
||||
有人可能会想,既然是 REST 服务,那是不是要考虑多用户之类的场景。你可以暂时把它理解成一个本地运行的服务(也就是说只有你一个人在使用),所以我们可以不考虑多用户的情况。这样做可以让我们把注意力更多放在测试本身上,而增加更多的能力是需求实现的事情,你可以在后面拿这个项目练手时,做更多的尝试。
|
||||
|
||||
确定好了需求目标,接下来,我们就要进入到具体的实现过程里面了。RESTful API 不同于命令行应用,不应该把它的代码同命令行的代码混杂在一起,所以,我们可以建一个单独的模块来放置这些代码,我把这个模块叫 todo-api。至于具体采用的技术栈,我们就使用在 Java 社区最常用的 Spring Boot,Spring Boot 能够极大简化了 REST 服务的开发。
|
||||
|
||||
同之前一样,我们先实现 Repository 的部分,然后再来做接口。或许你会有一个疑问,难道不是要实现业务核心部分吗?别忘了,我们在之前的实现中特意将业务核心部分隔离了出来,让它不依赖于任何具体的外部实现。虽然我们是将一个命令行应用改成一个 RESTful API,但业务核心部分并没有发生任何改变,所以,我们也不需要重新编写一份。这就是软件设计的价值所在。
|
||||
|
||||
数据访问
|
||||
|
||||
前面说过,我们要把之前基于文件版本的 Repository 实现改成基于数据库的版本,所以我们要先来确定数据访问相关的技术。我选择 MySQL 这个大家最常用的数据库,访问数据库的程序库我选择的是 Spring Data JPA,因为它可以让我尽可能少编写代码。
|
||||
|
||||
技术选型
|
||||
|
||||
两种常见的访问数据库的方式分别是 MyBatis 和 JPA。MyBatis 倾向于让人手工编写 SQL 语句,而 JPA 则采用更加面向对象的角度,它访问数据库的 SQL 语句常常是由框架生成的。二者的差异主要是 MyBatis 更加面向具体的实现,而 JPA 则提供了更好的抽象能力。
|
||||
|
||||
目前国内的现状是很多团队会使用 MyBatis,他们给出的理由大多是自己写 SQL 比较好控制,尤其是对一些复杂场景来说更容易优化。不过,实际情况往往是,如果采用 JPA 的话,很多团队对于生成什么样的代码自己完全心里没有数,因为欠缺建模能力才用 MyBatis。而对于很多建模做得比较好的团队来说,使用 JPA 往往开发效率更高。
|
||||
|
||||
Spring Data JPA 在 JPA 上提供了进一步的封装,一些常见的数据访问甚至不需要去编写代码,因为访问数据库的 SQL 都是由框架生成的,是一个标准操作。因为不是我们编写的代码,我们也无需验证它的正确性,只要保证我们自己写的代码正确地表达了我们的意图即可。如果真的有一些比较复杂的 SQL 逻辑要实现,Spring Data JPA 也允许我们自己手写 SQL,这是框架留给我们的优化手段。
|
||||
|
||||
所以,我们这里选择 Spring Data JPA。下面我们就来开始我们的实现之旅。
|
||||
|
||||
数据库迁移
|
||||
|
||||
在开始编码测试工作之前,我们要先确定 Todo 项存储的结构。所以,我们要在数据库中创建一个表。
|
||||
|
||||
CREATE TABLE todo_items (
|
||||
`id` int auto_increment,
|
||||
`content` varchar(255) not null,
|
||||
`done` tinyint not null default 0,
|
||||
primary key (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
我们已经在实战中看见过实体的样子了,所以,这里的表结构并不难理解。唯一需要稍微解释一下的就是在表里面我们用了 id,而在 Todo 项的实体中,它对应的是 index。其实,只要你稍微仔细地想一下就不难发现,在我们之前的设计中,index 就是起到了 id 的作用。对应的实体就是下面这样:
|
||||
|
||||
@Entity
|
||||
@Table(name = "todo_items")
|
||||
public class TodoItem {
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private long index;
|
||||
|
||||
@Column
|
||||
private String content;
|
||||
|
||||
@Column
|
||||
private boolean done;
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
在项目自动化中,数据库迁移脚本我们采用了 Flyway,它可以很方便地将数据库的变更管理起来。我们只要在 $rootDir/gradle/config/migration 这个位置创建一个迁移脚本,把上面的 SQL 写进去就好,具体的细节你可以参考我们的开源项目。
|
||||
|
||||
有了迁移脚本,我们就可以执行命令将这个表创建出来。
|
||||
|
||||
./gradlew flywayMigrate
|
||||
|
||||
|
||||
好,基础已经准备好了,我们准备要动手写测试了。
|
||||
|
||||
编写测试
|
||||
|
||||
我在上一讲说过,测试数据库相关的内容属于兼具集成测试和单元测试两种属性的测试,一方面,它要对数据库做集成,另一方面,它要测的内容本身属于验证一个单元代码是否编写正确的范畴。对于数据库相关的测试,Spring 提供了很好的支持,让我们可以更好地完成验证工作。
|
||||
|
||||
下面就是一个测试。如果你还记得之前文件版本 Repository 的测试,这个测试你可能会很眼熟。没错,这里的测试我几乎就是原封不动地把前面的测试搬了过来,因为 Repository 接口的行为几乎是一致的。这也是我这里并没有做测试场景分析的原因。
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@TestPropertySource("classpath:test.properties")
|
||||
public class TodoItemRepositoryTest {
|
||||
@Autowired
|
||||
private TodoItemRepository repository;
|
||||
|
||||
@Test
|
||||
public void should_find_nothing_for_empty_repository() {
|
||||
final Iterable<TodoItem> items = repository.findAll();
|
||||
assertThat(items).hasSize(0);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
看到 @Autowired,如果你熟悉 Spring 应该感到非常亲切,它表示这个字段由框架自动绑定的。那这个自动绑定为什么能起作用呢?这就要拜前面几个 Annotation 所赐了。
|
||||
|
||||
@ExtendWith(SpringExtension.class),在这里面,@ExtendWith 是 JUnit 5 提供的扩展机制,让第三方有机会编写自己的代码。而 SpringExtension 就是 Spring 提供的扩展,用来做一些 Spring 自己需要的准备和清理之类的工作,比如依赖注入就是通过它完成的。
|
||||
|
||||
@DataJpaTest,表示这个测试采用 Spring Data JPA。有了这个 Annotation,Spring 框架会替我们把 Repository 的实例生成出来。因为使用 Spring Data JPA 的时候,我们只编写了接口。还记得 TodoItemRepository 这个接口吗?现在它变成了下面这个样子。
|
||||
|
||||
public interface TodoItemRepository extends Repository<TodoItem, Long> {
|
||||
TodoItem save(TodoItem item);
|
||||
|
||||
Iterable<TodoItem> findAll();
|
||||
}
|
||||
|
||||
|
||||
同之前相比,这里的方法没用任何变化,只是扩展了一个接口 Repository,这是一个标记接口,也就是意味着只有接口,没有方法。实现这个接口是 Spring Data JPA 的要求,它会在运行时为这个接口生成相应的实例,换言之,我们不需要为此编写具体的实现。
|
||||
|
||||
其实,Spring Data JPA的函数名是有一些约定的,在前面给 Repository 的函数命名的时候,我就是参考了 Spring Data JPA 的命名规则,所以,我们在这里可以无缝地与 Spring Data JPA 对接在一起。
|
||||
|
||||
按照 Spring Data JPA 的要求,我们要让 Spring 在启动的时候能够找到我们配置的实体和 Repository。因为我们这里的实体和 Repository 不在缺省的扫描路径上,所以这里需要单独配置一下。下面就是我们的配置,这是一个典型的 Spring Boot 的应用。
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableJpaRepositories({"com.github.dreamhead.todo.core"})
|
||||
@EntityScan({"com.github.dreamhead.todo.core"})
|
||||
public class Bootstrap {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
万事俱备,我们现在可以运行测试了,如果一切顺利的话,测试会一次性运行通过。
|
||||
|
||||
这里其实有个实现的细节,测试并没有在数据库留下任何痕迹,正如我们在讲集成测试中说过的那样,这里的测试在运行之后回滚了在测试过程中插入的数据,这是 DataJpaTest 的缺省行为,大大简化了测试的难度。
|
||||
|
||||
你会发现,其实我们并没有写多少有逻辑的代码:表是 SQL 语句生成的,测试是从前面的测试搬过来的,主要的工作都是配置,而数据库访问的过程是框架生成的。减少自己编码的工作量,我们的测试压力也就小了很多。
|
||||
|
||||
RESTful API
|
||||
|
||||
有了 Repository,接下来,我们就要来设计实现 API 接口了。对于一个服务而言,对外提供哪些接口是很重要的。任何一个提供后端服务的团队都要仔细地设计其服务接口,确定它应该提供哪些能力,而不仅仅是围绕着前端需求去做。
|
||||
|
||||
设计 RESTful API
|
||||
|
||||
还记得我们的 ToDo 应用提供了哪些能力吗?我们回顾一下:
|
||||
|
||||
|
||||
添加一个 Todo 项;
|
||||
完成一个 Todo 项;
|
||||
Todo 项列表。
|
||||
|
||||
|
||||
接下来,我们就把它们设计成 API 接口。所有这三个能力都是围绕着 Todo 项进行的,所以,我们可以把它们设计在一个资源下,不妨就把它的 URI 设计成 /todo-items,一般来说,这里一般会使用复数,表示这有一堆资源。
|
||||
|
||||
有了最基础的资源,接下来,就是一个一个地按照 RESTful API 的方式设计出来。首先是添加一个 Todo 项。按照通常 RESTful API 接口的设计方式,相当于在服务端创建了一个新的资源,而创建的语义一般会用 POST 请求表示。创建一个 Todo 项,主要包含的就是 Todo 项的内容,其格式我们就采用 RESTful API 常用的 JSON 格式了。
|
||||
|
||||
POST /todo-items
|
||||
|
||||
{
|
||||
"content": "foo"
|
||||
}
|
||||
|
||||
|
||||
有了创建 Todo 项的服务,接下来就是完成一个 Todo 项了。完成一个 Todo 项,按照 RESTful API 的设计方式,这个动作相当于对已有资源的修改,修改对应的 HTTP 动词是 PUT。不同于 POST,PUT 操作需要指定一个具体的资源,我们这里使用索引作为唯一标识,其对应的内容就是完成字段(done)置为 true。目前来说,我们也不支持其它的处理,所以严格地说,这里的内容其实意义不大。
|
||||
|
||||
PUT /todo-items/{index}
|
||||
|
||||
{
|
||||
done: true
|
||||
}
|
||||
|
||||
|
||||
最后是一个 Todo 项列表。列表操作实际上是一种查询,在 RESTful API 设计中,查询对应的 HTTP 动词是 GET。在我们的实战需求中,Todo 项列表还分为查询未完成的 Todo 项和查询所有,从查询的角度来看,就是查询的参数不同。我们这里设置查询参数为 all,缺省情况下 all 的值为 false,如果显示设置了这个值,则按照设置的值进行查询。
|
||||
|
||||
GET /todo-items?all=true
|
||||
|
||||
|
||||
测试 RESTful API
|
||||
|
||||
做好了基本的设计工作,接下来我们就该进入代码编写的环节了。
|
||||
|
||||
同 Repository 部分一样,我们在这个部分的测试也准备从之前的测试中借鉴过来。所以,我们这里不把重点放在测试场景的分析上,而是来讨论如何编写测试。下面就是一个测试。
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
public class TodoItemResourceTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private TodoItemRepository repository;
|
||||
|
||||
...
|
||||
|
||||
.
|
||||
@Test
|
||||
public void should_add_item() throws Exception {
|
||||
String todoItem = "{ " +
|
||||
"\"content\": \"foo\"" +
|
||||
"}";
|
||||
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(todoItem))
|
||||
.andExpect(status().isCreated());
|
||||
assertThat(repository.findAll()).anyMatch(item -> item.getContent().equals("foo"));
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
从测试的名字便不难看出,这个测试是用来测试添加 Todo 项的。在这个类的开头有几个 Annotation:
|
||||
|
||||
|
||||
@SpringBootTest,它告诉我们,接下来的测试是把所有组件都集成起来的集成测试。在前面的实战中,我说过最外面的接口很薄,所以我把集成测试和单元测试的工作量放到了一起。
|
||||
@AutoConfigureMockMvc,表示我们要使用的是模拟的网络环境,也就不是真实的网络环境,这样做可以让访问速度快一些。
|
||||
@Transactional,说明这个测试是事务性的,在缺省的测试事务中,执行完测试之后,数据是要回滚,也就是不对数据库造成实际的影响。这要单独标记,否则就会有数据写入到数据库里面。而之前的 @DataJpaTest 自身就包含了这个 Annotation,所以不用特别声明。
|
||||
|
||||
|
||||
有了这些基础准备,我们就可以测试了。你可以认为,当我们执行测试时服务已经起好了,我们这里就像一个普通的客户端一样去访问一个服务,核心的部分就是下面这段代码。
|
||||
|
||||
todoItem = "{ " +
|
||||
"\"content\": \"foo\"" +
|
||||
"}";
|
||||
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(todoItem))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
|
||||
我们创建了一个请求,设置了这个请求的基本信息,用什么样的 HTTP 动词(POST) 访问哪个地址(/todo-items),具体的内容是什么等等。然后,预期返回的参数是什么(状态码是 201,也就是 CREATED)。
|
||||
|
||||
这里我们用的是 MockMVC,因为我们配置了@AutoConfigureMockMvc,它给我们创建了一个模拟的网络环境。这就是 Spring 在测试方面做得好的地方,作为框架的使用者,我们面对的都是编程的接口,支撑这些接口的实现在正常情况下是标准的网络环境,但 Spring 为我们提供了测试专用的实现,也就是不同的运行时,这就是做好了软件设计的结果。
|
||||
|
||||
不同于直接调用接口进行单元测试,这里的测试是集成测试,走的是完整的路径。所以,我们可以测试一些属于外部接口的行为,比如我们可以测试传入空的字符串该怎么办。
|
||||
|
||||
@Test
|
||||
public void should_fail_to_add_unknown_request() throws Exception {
|
||||
String todoItem = "";
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(todoItem))
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
|
||||
编写 RESTful API
|
||||
|
||||
有了测试,接下来就是实现相应的代码了。
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/todo-items")
|
||||
public class TodoItemResource {
|
||||
private TodoItemService service;
|
||||
|
||||
@Autowired
|
||||
public TodoItemResource(final TodoItemService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity addTodoItem(@RequestBody final AddTodoItemRequest request) {
|
||||
if (Strings.isNullOrEmpty(request.getContent())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
final TodoParameter parameter = TodoParameter.of(request.getContent());
|
||||
final TodoItem todoItem = this.service.addTodoItem(parameter);
|
||||
|
||||
final URI uri = ServletUriComponentsBuilder
|
||||
.fromCurrentRequest()
|
||||
.path("/{id}")
|
||||
.buildAndExpand(todoItem.getIndex())
|
||||
.toUri();
|
||||
return ResponseEntity.created(uri).build();
|
||||
}
|
||||
...
|
||||
}`
|
||||
|
||||
|
||||
如果你熟悉 Spring Boot 的话,这段代码对你来说应该不难。即便你不熟悉,仅仅是通过阅读代码,也很容易理解这段代码的含义:
|
||||
|
||||
|
||||
@RestController,告诉 Spring 这是一个 REST 服务的入口类。这个类的命名是 TodoItemResource,因为在 REST 服务中,资源是一个很重要的概念,而这里的 Controller,可以说是从历史遗留的产物。
|
||||
@RequestMapping(“/todo-items”),说明服务入口的地址是 /todo-items,这是这个类里所有服务的根。
|
||||
每个具体的方法都会有自己相应的配置,对应着一个具体的服务,比如,在 addTodoItem 中是 @PostMapping,表示这个方法接收的是 POST 请求。
|
||||
|
||||
|
||||
POST 服务一般都会有一个请求体,在这个方法中,我们使用 AddTodoItemRequest 的实例来接收这个请求体。在 HTTP 传输过程中传输的是文本,Spring 框架会替我们将文本转换成一个对象。只要我们把转换规则声明出来,Spring Boot 采用的 JSON 处理框架是 Jackson,所以,我们要在类的声明时采用 Jackson 的规则,就像下面这样。
|
||||
|
||||
public class AddTodoItemRequest {
|
||||
@Getter
|
||||
private String content;
|
||||
|
||||
@JsonCreator
|
||||
public AddTodoItemRequest(@JsonProperty("content") final String content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这里,@JsonCreator 表示这是一个 JSON 对象的构造方法,而@JsonProperty 则表示将对应属性的值赋值给这里的参数。
|
||||
|
||||
从软件设计的角度说,Resource 是一个防腐层,AddTodoItemRequest 是一个外部请求对象。把外部对象和内部对象分开,这是很重要的(我在《代码之丑》中分析过这种做法的原因)。所以,在具体的函数中,我们首先要做就是把外部对象转换成内部对象。
|
||||
|
||||
final TodoParameter parameter = TodoParameter.of(request.getContent());
|
||||
|
||||
|
||||
好,到这里,我们把这段代码中主要的设计考量都已经分析过了。这段代码完整的实现,你可以参考我们的开源项目。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲,我们将原本的 ToDo 应用从一个命令行应用扩展为一个 REST 服务。因为我们已经构建好了业务核心,所以这里的工作同之前是一样的:要增加一个 Repository,要编写服务的入口。
|
||||
|
||||
在增加 Repository 方面,我们选择了 Spring Data JPA,目的是减少代码的编写。然后我们增加了相应的数据库迁移脚本,这里采用 Flyway 管理数据库迁移的工作。
|
||||
|
||||
因为选择了 Spring Data JPA,我们在测试里用@DataJpaTest,它会帮我们设置好 Repository,也会帮我们在测试运行之后回滚数据。
|
||||
|
||||
对外的接口我们采用 RESTful API 的设计。这里我们同样采用了集成测试代替单元测试的做法,集成测试是靠@SpringBootTest 把各种组件都集成起来。这里我们还用到了 MockMVC 让我们的测试不依赖于真实的环境,访问速度可以稍微快一点点。
|
||||
|
||||
接口层本身是一个典型的防腐层,所以一般来说这层会做得非常薄,会把外部请求与业务层分隔开来。
|
||||
|
||||
如果今天的内容你只能记住一句话,那么请记住,集成测试回滚数据,保证测试的可重复性。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们用 Spring 的基础设施演示了如何进行测试。你使用过 Spring 吗?有哪些测试特性让你印象深刻的?或者你用哪个框架给你提供了很好地测试支持呢?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
160
专栏/程序员的测试课/13在Spring项目中如何进行单元测试?.md
Normal file
160
专栏/程序员的测试课/13在Spring项目中如何进行单元测试?.md
Normal file
@ -0,0 +1,160 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 在 Spring 项目中如何进行单元测试?
|
||||
你好,我是郑晔!
|
||||
|
||||
上一讲,我们将 ToDo 应用从命令行扩展为了 REST 服务。在这个应用里,我们用到了 Spring 这个在 Java 世界中广泛使用的框架。大多数人对于 Spring 这个框架的认知都停留在如何使用它完成各种功能特性上,而 Spring 更大的价值其实在对于开发效率的巨大提升上,其中就包含了对测试的支持。
|
||||
|
||||
在接下来的两讲,我们就把注意力从一个具体的项目上挪开,放到 Spring 框架本身,看看它对开发效率提升的支持。
|
||||
|
||||
轻量级开发的 Spring
|
||||
|
||||
很多人对于 Spring 的理解是从依赖注入容器开始的,但是,Spring 真正对行业的影响却是从它对原有开发模式的颠覆开始。
|
||||
|
||||
在 21 世纪初的时候,Java 世界的主流开发方式是 J2EE,也就是 Java 的企业版。在那个时候,企业版代表软件开发的最高水准。在这个企业版的构想中,所有的复杂都应该隐藏起来,写代码的程序员不需要知道各种细节,需要的东西拿过来用就好了。
|
||||
|
||||
这种想法本身是没有问题的,时至今日,很多平台和框架也是这么想的。到了具体的做法上,J2EE 提供了一个应用服务器,我把这些复杂性都放在这个应用服务器里实现,你写好的程序部署到这个应用服务器上就万事大吉了。但正是因为应用服务器的存在,使用 J2EE 进行开发变成了一件无比复杂的事情。
|
||||
|
||||
将程序打包部署这件事说起来很简单,但在实际的工作中,如果一个团队没有做好自动化,打包部署会非常麻烦。再者,除了自己的业务代码,所有相关的第三方 JAR 包都需要打到最终的发布包中,造成的结果就是发布包非常大。在那个网络带宽还不是特别大的年代,传输这个发布包也要花很长的时间。
|
||||
|
||||
更关键的是,一旦出了问题怎么去定位也是个令人头疼的问题。
|
||||
|
||||
程序员最熟悉定位问题的方式就是调试代码。之前所有的代码都是在本地,调试起来还比较容易,现在代码运行在应用服务器上,我们必须连接到远程应用服务器上进行调试,而要连接应用服务器进行调试,还需要一些配置,总之,这件事真的是非常麻烦。
|
||||
|
||||
对于麻烦的事情,人们倾向于少做或不做,但是 J2EE 让这件麻烦事成了必选项。所以,那个年代的 Java 程序员处于一种痛苦不堪的状态,开发效率极其低下。
|
||||
|
||||
就在整个 Java 社区饱受折磨之际,Spring 横空出世。对于 J2EE 提出的问题,Spring 是承认的,但对其给出的解决方案,它却是不认的。因为应用服务器太重了,Spring 给社区带来了轻量级开发。
|
||||
|
||||
Spring 的逻辑很简单,这些东西通过程序库的方式就可以完成,为什么非要弄一个应用服务器呢?采用程序库的方式,最大的优势就在于可以在本地开发环境中进行开发和调试,这就极大地降低开发的难度。于是,对于同样的问题,Spring 抛弃了 J2EE 中的大部分内容,给出了自己的程序库解决方案,应用服务器变得可有可无了。
|
||||
|
||||
事实证明,人们更喜爱简单的解决方案,即便 J2EE 有强大的官方背书,程序员们还是义无反顾地抛弃了它。Spring 从此成了 Java 社区的主流,也成了轻量级开发的代名词。
|
||||
|
||||
Spring 不仅是恰当地把握了时机,占据了 Java 世界中的关键位置,更重要的是,在随后的发展中,一直凭借对于轻量级开发的追求以及良好的品位,使得它在 Java 程序员心目中占据着无可替代的位置。即便中间有部分地方其它的程序库做得稍微好一些,它也能很快地学习过来。
|
||||
|
||||
前面我说过,虽然 Spring 抛弃了 J2EE 中的大部分内容,基于 Web 服务器的开发还是得到了保留。因为当时确实没有什么更好的选择,虽然大部分代码可以在本地测试,但很多时候我们还是要打成一个 WAR 包部署到像 Tomcat 这样的 Web 服务器上。不过,随着 Tomcat 和一众 Web 服务器提供了可嵌入的 API,打包部署这种 J2EE 残留方式就彻底成为了过去,也就诞生今天很多 Java 程序员熟悉的 Spring Boot,可以说 Spring Boot 是 Spring 多年努力的集大成者!
|
||||
|
||||
Spring 的测试
|
||||
|
||||
不过在 Spring Boot 出现之前,正是因为无法摆脱打包部署的这样的模式,基于这条路走下去开发难度依然不小,可以说并没有从根本上改变问题。但 Spring 的轻量级开发理念是支撑它一路向前的动力,既然那个时候 Web 服务器不能舍弃,索性 Spring 就选择了另外一条路:从测试支持入手。
|
||||
|
||||
所以 Spring 提供了一条测试之路,让我们在最终打包之前,能够让自己编写的代码在本地得到完整验证。你在实战环节中已经见识过如何使用 Spring 做测试了。简单来说就是使用单元测试构建稳定的业务核心,使用 Spring 提供的基础设施进行集成测试。
|
||||
|
||||
严格地说,构建稳定的业务核心其实并不依赖于 Spring,但 Spring 提供了一个将组件组装到一起基础设施,也就是依赖注入(Dependency Injection,简称 DI)容器。通常我们会利用 DI 容器完成我们的工作,也正是因为 DI 容器用起来很容易,所以常常会造成 DI 容器的误用,反而会阻碍测试。
|
||||
|
||||
在第6讲中,我们讨论过要编写能够组合的代码。依赖注入的风格会引导我们编写能够组合的代码,也就是不要在类的内部创建组件,而是通过依赖注入的方式将组件注入到对象之中。
|
||||
|
||||
所以,在一个使用 Spring 项目进行单元测试的关键就是,保证代码可以组合的,也就是通过依赖注入的。你可能会说,我们都用了 Spring,那代码肯定是组合的。这还真不一定,有些错误的做法就会造成对依赖注入的破坏,进而造成单元测试的困难。
|
||||
|
||||
不使用基于字段的注入
|
||||
|
||||
有一种典型的错误就是基于字段的注入,比如像下面这样。
|
||||
|
||||
@Service
|
||||
public class TodoItemService {
|
||||
@Autowired
|
||||
private TodoItemRepository repository;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Autowired 是一个很好用的特性,它会告诉 Spring 自动帮我们注入相应的组件。在字段上加Autowired 是一个容易写的代码,但它对单元测试却很不友好,因为你需要很繁琐地去设置这个字段的值,比如通过反射。
|
||||
|
||||
如果不使用基于字段的注入该怎么做呢?其实很简单,提供一个构造函数就好,把@Autowired 放在构造函数上,像下面这样子。
|
||||
|
||||
@Service
|
||||
public class TodoItemService {
|
||||
private final TodoItemRepository repository;
|
||||
|
||||
@Autowired
|
||||
public TodoItemService(final TodoItemRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这样一来,编写测试的时候我们只要像普通对象一样去测试就好了,具体的做法你要是记不清了,可以去回顾一下实战环节。
|
||||
|
||||
这种构造函数一般我们都可以利用 IDE 的快捷键生成,所以这段代码对我们来说也不是很重的负担。如果你还嫌弃这种代码的冗余,也可以用 Lombok(Lombok 是一个帮助我们生成代码的程序库)的 Annotation 来简化代码,像下面这样。
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TodoItemService {
|
||||
private final TodoItemRepository repository;
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
不依赖于 ApplicationContext
|
||||
|
||||
使用 Spring 还有一种典型的错误,就是通过 ApplicationContext 获取依赖的对象,比如像下面这样。
|
||||
|
||||
@Service
|
||||
public class TodoItemService {
|
||||
@Autowired
|
||||
private ApplicationContext context;
|
||||
|
||||
private TodoItemRepository repository;
|
||||
|
||||
public TodoItemService() {
|
||||
this.repository = context.getBean(TodoItemRepository.class);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
我们可以把 ApplicationContext 理解成 DI 容器,原本使用 DI 容器的优点就是可以不知晓依赖是怎么产生的,而在这段代码里,却知晓了 DI 容器,这就完全打破了 DI 容器设计的初衷(关于 Spring 的设计初衷,我在《软件设计之美》中专门有一讲分析过,如果你有兴趣可以去了解一下)。
|
||||
|
||||
在业务核心代码中出现 ApplicationContext 是一种完全错误的做法。一方面,它打破了 DI 容器原本的设计,另一方面,还让业务核心代码对第三方代码(也就是 ApplicationContext)产生了依赖。
|
||||
|
||||
我们再从设计的角度看一下,AppliationContext 的出现使得我们在测试这段代码时,必须引入 ApplicationContext。要想在代码里获取到相应的组件,需要在测试中向 ApplicationContext 里添加相应的组件,这会让一个原本很简单的测试变得复杂起来。
|
||||
|
||||
你看,一个正常的测试是如此简单,但正是因为引入了 Spring,许多人反而会做错。Spring 最大的优点是可以在代码层面上不依赖于 Spring,而错误的做法反而是深深地依赖于 Spring。
|
||||
|
||||
我们前面讨论了这么多,其实并没有针对 Spring 对单元测试的支持进行讲解,但 Spring 其实还真提供了一个对单元测试的支持,也就是@MockBean,也就是帮我们进行 Mock 对象的初始化,像对于下面这行代码来说:
|
||||
|
||||
@MockBean
|
||||
private TodoItemRepository repository;
|
||||
|
||||
|
||||
它就等同于下面这段。
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
this.repository = mock(TodoItemRepository.class);
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
但是我并不想特意强调这种做法。一方面,这种初始化的代码清晰且不复杂,另一方面,即便我们真的打算节省这两行的代码,更好的做法是根据你使用的 Mock 框架采用其对应的做法。比如使用 Mockito,我们可以像下面这么写。
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class TodoItemServiceTest {
|
||||
@Mock
|
||||
private TodoItemRepository repository;
|
||||
}
|
||||
|
||||
|
||||
不过@MockBean 并非一无是处,我们在集成测试中会用到它,让它参与到依赖注入的过程中去。下一讲,我们就来讨论一下如何使用 Spring 进行集成测试。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲我们讲到了 Spring 这个 Java 世界使用最广泛的框架,它最大的贡献是对开发模式的颠覆:由原来 J2EE 依赖于部署的重量级开发模式,到可以在本地开发环境完成主要工作的轻量级开发方式。
|
||||
|
||||
轻量级的开发方式是 Spring 一以贯之的追求,采用 Spring 开发可以在部署到容器之前就完成所有代码的验证,其中对测试的支持是非常重要的一环。
|
||||
|
||||
虽然我们今天的主题是如何使用 Spring 进行单元测试,但实际上真正做好的业务测试和普通代码的测试是没有区别的,所以,我们更多地是在谈如何规避过度使用 Spring 框架犯下的错误。比如不要使用基于字段的注入,也不要依赖于 ApplicationContext 获取相应的依赖,这些做法都会让原本简单的测试变得复杂。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:业务代码不要过度依赖于框架。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们的重点是错误使用了框架,你在实际的工作中,遇到过度使用框架特性,反而让代码陷入难以调整的困境吗?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
214
专栏/程序员的测试课/14在Spring项目如何进行集成测试?.md
Normal file
214
专栏/程序员的测试课/14在Spring项目如何进行集成测试?.md
Normal file
@ -0,0 +1,214 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 在 Spring 项目如何进行集成测试?
|
||||
你好,我是郑晔!
|
||||
|
||||
上一讲我们讲了 Spring 对轻量级开发的支持。不同于传统的开发方式,Spring 希望可以做到开发不依赖于应用服务器。为了达成这个目标,Spring 提供了各种支持,能够让你在部署到容器之前完成所有代码的基础验证工作。在核心业务部分,只要我们能够不过分依赖于 Spring 的种种特性,测试就和普通的单元测试差别不大。
|
||||
|
||||
不过在真实世界的软件开发中,我们总要与其它的外部组件集成。一旦牵扯到集成,测试的难度就上来了。不过正如前面所说,Spring 要尽可能让你在不依赖于容器的情况下进行测试。Spring 的做法就是提供一套自己的方案,替代掉对于容器的依赖。
|
||||
|
||||
这一讲,我们就来看看采用 Spring 的项目如何做集成测试。
|
||||
|
||||
数据库的测试
|
||||
|
||||
今天数据库几乎成了所有商业项目的标配,所以,Spring 也提供了对于数据库测试很好的支持。我们之前说过,一个好的测试要有可重复性,这句话放到数据库上就是要保证测试之前的数据库和测试之后的数据库是一样的。怎么做到这一点呢?
|
||||
|
||||
测试配置
|
||||
|
||||
通常有两种做法,一种是采用嵌入式内存数据库,也就是在测试执行之后,内存中的数据一次丢掉。另一种做法就是采用真实的数据库,为了保证测试前后数据库是一致的,我们会采用事务回滚的方式,并不把数据真正地提交进数据库里。
|
||||
|
||||
我们做测试的一个关键点就是不能随意修改代码,切记,不能为了测试的需要而修改代码。如果真的要修改,也许应该修改的是设计,而不仅仅是代码。
|
||||
|
||||
虽然不能修改代码,但我们可以提供不同的配置。只要我们给应用提供不同的数据库连接信息,它就会连到不同的数据库上。Spring 就给了我们一个提供不同配置的机会,只要我们在测试中声明一个不同的属性配置即可,下面就是一个例子。
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@TestPropertySource("classpath:test.properties")
|
||||
public class TodoItemRepositoryTest {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
在这段代码里,我们提供了一个测试用的配置,也就是 @TestPropertySource 给出的一个配置。这是在用 classpath 上的 test.properties 这个文件中的配置,去替换掉我们缺省的配置(也就是我们真实的数据库)。
|
||||
|
||||
嵌入式内存数据库
|
||||
|
||||
正如我们前面所说,我们要保证数据库的可重复性有两种做法:嵌入式内存数据库和事务回滚。要想使用嵌入式内存数据库,我们需要提供一个嵌入式内存数据库的配置。在 Java 世界中,常见的嵌入式内存数据库有 H2、HSQLDB、Apache 的 Derby 等。我们配置一个测试的依赖就好,以 H2 为例,像下面这样。
|
||||
|
||||
testImplementation "com.h2database:h2:$h2Version"
|
||||
|
||||
|
||||
然后,再提供一个相应的配置,像下面这样。
|
||||
|
||||
jdbc.driverClassName=org.h2.Driver
|
||||
jdbc.url=jdbc:h2:mem:todo;DB_CLOSE_DELAY=-1
|
||||
hibernate.dialect=org.hibernate.dialect.H2Dialect
|
||||
hibernate.hbm2ddl.auto=create
|
||||
|
||||
|
||||
如果运气好的话,你的测试就可以顺利地运行了。是的,运气好的话。
|
||||
|
||||
之所以把软件开发这么严肃认真的事归结到运气,这就不得不说说使用嵌入式内存数据库的问题了。
|
||||
|
||||
严格地说,这不是嵌入式内存数据库的问题,这其实是只要运行在不同的数据库上都会有的问题,也就是 SQL 的不一致。虽然我们知道 SQL 有一个统一的标准,然而,几乎每个数据库引擎为了某些特点都有一些特殊的处理。造成的结果就是,虽然理论上说 SQL 可以运行在所有的数据库引擎上,然而真实情况却是总有一部分 SQL 只能运行在特定的引擎上。
|
||||
|
||||
如果你用的是 JPA 这种技术,因为 JPA 会根据数据库引擎替我们生成真实的 SQL,这个问题体现得还不是特别明显。但如果你用的 MyBatis 或者是其它需要手写 SQL 的技术,一旦发现了不能运行的 SQL,你就不得不在此权衡一下,如何去面对两个有差异的数据库。
|
||||
|
||||
所以,嵌入式内存数据库这种技术看上去很美,但我在实际的项目中用得并不多,我更多会采用事务回滚的方式。
|
||||
|
||||
事务回滚
|
||||
|
||||
在事务回滚的方式中,我们的配置几乎与标准的应用配置是一样的,下面是我们在实战中所采用的配置。
|
||||
|
||||
spring.datasource.url=jdbc:mysql://localhost:3306/todo_test?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true
|
||||
spring.datasource.username=todo
|
||||
spring.datasource.password=geektime
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
|
||||
通常来说,为了不让测试过程和开发过程造成数据冲突,我们会创建两个不同的数据库,在 MySQL 中,这就是两条 SQL 语句。
|
||||
|
||||
create database todo_dev;
|
||||
create database todo_test;
|
||||
|
||||
|
||||
这样,一个用来做手工测试用,另外一个交由自动化测试使用,你从数据库后缀名上就可以看出二者的差异。顺便说一下,这种做法在业界的普遍流行是源自 Ruby on Rails(一个 Ruby 的 Web 开发框架),当年它在软件开发实践上给整个行业带来了极大的颠覆。
|
||||
|
||||
采用这种做法,我们的代码面对的是同样的数据库引擎,也就不必担心 SQL 不兼容的问题了。
|
||||
|
||||
我们所说的事务回滚体现在 @DataJpaTest 上,它把数据库回滚做成缺省的配置,所以我们什么都不用做,就可以获得这样的能力。
|
||||
|
||||
与大多数测试一样,测试与数据库的集成时,我们也要做一些准备。需要准备的往往是一些数据,提前插入到数据库里。我们可以使用 Spring 给我们准备的基础设施(TestEntityManager)向数据库中完成这个工作,下面是一个例子。
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@DataJpaTest
|
||||
public class ExampleRepositoryTests {
|
||||
@Autowired
|
||||
private TestEntityManager entityManager;
|
||||
|
||||
@Test
|
||||
public void should_work() throws Exception {
|
||||
this.entityManager.persist(new User("sboot", "1234"));
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
如果你用的不是 JPA 而是其它的数据访问方式,Spring 也给我们提供了 @JdbcTest,这相当于是一个更基础的配置,因为只要有 DataSource, 它就可以很好地工作起来,这适用于绝大多数的测试情况。相应地,数据工作也更加地直接,采用 SQL 就可以,下面是一个例子。
|
||||
|
||||
@JdbcTest
|
||||
@Sql({"test-data.sql"})
|
||||
class EmployeeDAOIntegrationTest {
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
Web 接口测试
|
||||
|
||||
除了数据库,另外一个几乎成了今天标配的就是 Web。Spring 对于 Web 测试也提供了非常好的支持。
|
||||
|
||||
如果按照我在实战中的方式工作,你会发现到了编写 Web 接口这步,我们基本上完成了几乎所有的工作,只差给外界一个接口让它和我们的系统连接起来。在前面的实战中,我们采用整体集成的方式对系统进行测试,这里的关键点就是@SpringBootTest,它把所有的组件都连接了起来。
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
public class TodoItemResourceTest {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
在讲集成测试的时候我曾经说过,集成测试分为两种,一种把所有代码都集成起来的测试,另外一种是针对外部组件的集成。从代码上来看,后一种测试只是针对一个单元在测试,所以它兼具单元测试和集成测试的特点。其实,测试 Web 接口也有一种类似于单元测试的集成方式,它采用的 @WebMvcTest。
|
||||
|
||||
@WebMvcTest(TodoItemResource.class)
|
||||
public class TodoItemResourceTest {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
正如你在这段代码中看见的那样,这里我们指定了要测试的组件 TodoItemResource。在这个测试里,它不会集成所有的组件,只会集成与 TodoItemResource 相关的部分,但整个 Web 处理过程是完整的。
|
||||
|
||||
如果把它视为单元测试,服务层后面的代码都是外部的,我们可以采用模拟对象把它控制在可控范围内,这个时候,上一讲遗漏的 MockBean 就开始发挥作用了。
|
||||
|
||||
@WebMvcTest(TodoItemResource.class)
|
||||
public class TodoItemResourceTest {
|
||||
@MockBean
|
||||
private TodoItemService service;
|
||||
|
||||
@Test
|
||||
public void should_add_item() throws Exception {
|
||||
when(service.addTodoItem(TodoParameter.of("foo"))).thenReturn(new TodoItem("foo"));
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这里,@MockBean 标记的 TodoItemService 模拟对象会参与到组件组装的过程中,成为 TodoItemResource 的组成部分,我们就可以设置它的行为。如果 Web 接口同服务层有比较复杂的交互,那这种做法就能够很好的处理。当然,正如我们一直在说的,我不建议这里做得过于复杂。
|
||||
|
||||
@WebMvcTest 这种偏向于单元测试的做法,执行速度相对于@SpringBootTest 这种集成了所有组件的做法而言要快一些。所以如果测试的量大起来,采用@WebMvcTest 会有一定的优势。
|
||||
|
||||
理解 Web 接口测试还有一个关键点。正如我在之前内容中说过,当年 Spring 摆脱了大部分对于应用服务器的依赖,但是 Web 却是它一直没有摆脱的。所以,怎么更好地不依赖于 Web 服务器进行测试,就是摆在 Spring 面前的问题。答案是 Spring 提供了模拟的 Web 环境。
|
||||
|
||||
具体到我们的测试上,它就是 MockMvc 对象发挥的作用。我们用下面的代码回顾一下它的用法。
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
public class TodoItemResourceTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
...
|
||||
|
||||
@Test
|
||||
public void should_add_item() throws Exception {
|
||||
String todoItem = "{ " +
|
||||
"\"content\": \"foo\"" +
|
||||
"}";
|
||||
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(todoItem))
|
||||
.andExpect(status().isCreated());
|
||||
assertThat(repository.findAll()).anyMatch(item -> item.getContent().equals("foo"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里的关键是 @AutoConfigureMockMvc,它为我们配置好了 MockMvc,剩下的就是我们使用这个配置好的环境进行访问。
|
||||
|
||||
从程序库的角度看,MockMvc 可以理解成客户端的 Moco,同样是设置请求和应答。和 Moco 不同的点在于,它的请求是设置好的,而应答要匹配。
|
||||
|
||||
从实现的角度理解,它就是那个模拟的 Web 环境。所谓模拟的环境,是因为它根本没有启动真正的Web服务器,而是直接去调用了我们的代码,省略了请求在网络上走一遭的过程。但请求进到服务器之后的主要处理都在,所以相应的处理都在(无论是各种 Filter 的处理,还是从请求体到请求对象的转换)。现在你应该明白了,MockMvc 是 Spring 轻量级开发的一个重要的组成部分。
|
||||
|
||||
到这里,我给你介绍了 Spring 集成测试中最常用到的两种:数据库测试和 Web 接口测试。这里介绍的也是推荐你去使用的做法。还有一些细节的做法我在这里没有提到,比如可以取消数据的回滚,再比如使用真实的 Web 环境(走网络的那种),不提是因为它们并不是值得推荐的做法。
|
||||
|
||||
正如我在最近两讲一直说的那样,Spring 在支持轻量级开发上做了很大的努力,所以,在把整个系统集成起来之前,绝大部分内容我们都已经验证过了。我在这里介绍的只是其中最为典型的用法,Spring 的测试绝对是一个值得挖掘的宝藏,你可以阅读它的文档去发掘更多有趣的用法。
|
||||
|
||||
现在我们对怎样在真实项目中做好单元测试和集成测试已经有了一个基本的理解,但在实际的项目中,不同类型的测试该怎么配比呢?这就是我们下一讲要讨论的内容。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讨论了在 Spring 项目中怎么进行集成测试,主要讲解了如何做数据库和 Web 接口的集成测试。
|
||||
|
||||
做数据库测试,难点在于如何在测试之后恢复环境。有两种典型的做法:使用嵌入式内存数据库或是使用事务回滚的机制。无论是哪种做法,重点是给测试提供不同的配置,保证代码不变。
|
||||
|
||||
因为不同数据库引擎对 SQL 兼容程度不同,我更建议你使用事务回滚的做法。
|
||||
|
||||
Web接口测试通常是最外层的测试,可以做整体的集成测试(@SpingBootTest),或对一个单元进行测试的集成测试(@WebMvcTest)。
|
||||
|
||||
在Web接口测试中,一个关键点是采用模拟 Web 环境,这样可以在不启动 Web 服务器的前提下进行测试。这种做法不依赖于部署过程,测试速度可以大幅度提升。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:采用轻量级的测试手段,保证代码的正确性。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲了 Spring 对于集成测试的支持,希望你可以通过阅读文档,了解它的更多特性。如果你在阅读文档的过程中发现了哪些有趣的特性,欢迎在留言区分享你的所得。
|
||||
|
||||
|
||||
|
||||
|
95
专栏/程序员的测试课/15测试应该怎么配比?.md
Normal file
95
专栏/程序员的测试课/15测试应该怎么配比?.md
Normal file
@ -0,0 +1,95 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 测试应该怎么配比?
|
||||
你好,我是郑晔!
|
||||
|
||||
经过前面内容的讲解,相信你对在实际项目中如何编写单元测试和集成测试已经有了一个基本的认识。无论你是经验丰富的老程序员还是初入职场的新程序员,如果只是单独写几个测试,相信你都可以手到擒来。但真实的项目中我们不是要编写几个测试,而是要大批量地编写测试。
|
||||
|
||||
一旦编写的测试增多,你脑海里必然会出现一个疑问:有一些内容用单元测试覆盖可以,用集成测试覆盖也可以,如果只写单元测试总有些不放心,如果同时用单元测试和集成测试去覆盖,工作量似乎又会增大,不同的测试应该怎样配比呢?这就是我们这一讲要讨论的内容。
|
||||
|
||||
测试的特点
|
||||
|
||||
在讨论如何配比测试之前,我们需要先了解各种类型测试的特点,毕竟正是因为它们有着不同的特点,我们才需要对不同的测试按照不同的比例进行配比。
|
||||
|
||||
首先来看单元测试。单元测试是针对一个单元的测试,因为涉及面很小,所以单元测试要进行的设置会比较少。单元测试不牵扯到外部组件,一般而言只在内存中执行,执行速度很快。所以谈及单元测试的特点我们一般会说,它成本低、速度快、单个测试的覆盖面小,但整体覆盖面大。
|
||||
|
||||
再来看集成测试。相比于单元测试来说,集成测试的涉及面要广一些,设置起来就比较麻烦。有的集成测试还会集成外部组件,这也就意味着设置起来要更麻烦,比如你在上一讲见识过的数据库测试,就要准备各种配置信息。同时,无论是组件多还是集成外部组件,这都意味着执行速度要比单元测试慢。所以相比于单元测试,集成测试成本要高一些、速度要慢一点;单个测试的覆盖面要大一些,但整体覆盖面要小一些。
|
||||
|
||||
虽然我们主要讨论的是单元测试和集成测试,但实际上,还有一种测试有的团队也会做,就是系统测试(把整个系统集成起来进行测试)。
|
||||
|
||||
系统测试的设置会更加复杂,比如,为了让各种组件配合到一起,要配置各种信息。而执行系统测试,先要把系统启动起来,然后要走完整的执行路径,执行时间会更长。所以,系统测试的特点就是成本高、速度慢,但单个测试覆盖面大,整体覆盖面小。
|
||||
|
||||
顺便说一下,在实际的项目中,有时候我会用系统测试去验证系统组装的过程,保证改了配置或者调整了代码之后,系统依然能够正常启动。
|
||||
|
||||
前面说到的一些特点都是非常容易想到的。其实,如果把测试放到软件开发的生命周期中,我们还会发现一些特点。比如,单个系统测试覆盖面大,反过来看,覆盖面中任何一点出了问题,或是有调整,这个测试都会受到影响,所以,相对来说系统测试是脆弱的。而低层一些的测试因为覆盖面小,只有它覆盖到的代码有变化时它才会受到影响,相对而言,稳定度就要高一些。
|
||||
|
||||
再比如,一旦测试出错,需要定位具体的问题。使用系统测试定位问题就如同大海捞针,难度系数很大,而单元测试因为只有一个单元,定位问题就要容易许多。我把刚刚讨论的内容整合成了一个表格,你可以对照着再复习一下。
|
||||
|
||||
|
||||
|
||||
好,到这里,你已经对常见的测试特点有了一个了解,接下来,我们就来看看不同的测试配比模型。
|
||||
|
||||
测试配比模型
|
||||
|
||||
所谓不同的测试配比,其实就是什么样的测试多写一些。而决定什么样的测试多写一些,主要是不同人的不同出发点。有人认为一个测试应该尽可能覆盖面广一些,所以,要多写系统测试,有人认为测试应该考虑速度和成本,所以,要多写单元测试。
|
||||
|
||||
正是有不同的出发点,行业中有两种典型的测试配比模型,一种是冰淇淋蛋卷模型,一种是测试金字塔模型。
|
||||
|
||||
我们先来看冰淇淋蛋卷模型,如下图所示。
|
||||
|
||||
|
||||
|
||||
在这个图里,单元测试在最下面,表示它是底层的;然后层次逐渐升高,系统测试,也就是图上的端到端测试就是高层测试,在最上面。所有自动化测试形成了蛋卷部分,而外面的冰淇淋部分则是手工的测试。
|
||||
|
||||
这里面每一层的宽窄表示了测试数量的多少。从图中我们不难看出,它对测试配比的预期:少量的单元测试,大量的系统测试。
|
||||
|
||||
冰淇淋蛋卷的出发点就是从单个测试的覆盖面考虑的,只要一些系统测试,就足以覆盖系统的大部分情况。当然,对于那些系统测试无法覆盖的场景就需要有低层的测试配合,比如,集成测试和单元测试。在冰淇淋蛋卷模型里,主力就是高层测试,低层测试只是作为高层测试的补充。
|
||||
|
||||
了解了冰淇淋蛋卷模型,我们再来看测试金字塔,下面这张图表示的就是测试金字塔。
|
||||
|
||||
|
||||
|
||||
在表现形式上测试金字塔和冰淇淋蛋卷模型是一致的,都是下面表示低层测试,越往上测试的层次越高,而每一层的宽窄表示了测试数量的多少。
|
||||
|
||||
测试金字塔这个概念是 Mike Cohn 在自己的著作《Succeeding with Agile》中提出,但大多数人都是通过 Martin Fowler 的文章知道的这个概念。从图的整体形状我们不难看出,测试金字塔同冰淇淋蛋卷正相反,它的重点是多写单元测试,而上层的测试数量则逐层递减。
|
||||
|
||||
测试金字塔的出发点是低层测试成本低、速度快、整体覆盖面广,所以要多写。因为低层测试覆盖了几乎所有的情况,高层的测试就可以只做一些大面上的覆盖,保证不同组件之间的协作是没有问题的。在这个模型里,主力是单元测试,而高层的测试则是作为补充。
|
||||
|
||||
好,有了对于测试配比模型的理解,接下来我们要回答的问题就是怎样使用这两个模型。
|
||||
|
||||
从行业的最佳实践角度看,测试金字塔已经是行业中的最佳实践。测试金字塔以单元测试为基础,因为成本低、速度快等特点,单元测试可以让我们在开发过程中迅速得到反馈。对于一个想要编写测试的团队而言,测试金字塔模型也是更容易坚持做到的。
|
||||
|
||||
实际上,我们在实战环节中采用的就是测试金字塔模型,也就是以单元测试为主,附以少量的集成测试或系统测试。所以,如果你准备开始一个新项目,最好采用测试金字塔模型,而具体的做法我们在实战环节中已经见识过了,那就是一层一层地写测试。每完成一个功能,代码和测试总是同步写出来的,代码总是得到验证的,这样我们就可以稳步向前。
|
||||
|
||||
既然测试金字塔都成为了行业的最佳实践,那我们为什么还要了解冰淇淋蛋卷模型呢?因为不是所有项目都是新项目。
|
||||
|
||||
因为各种历史原因,很多遗留项目是没有测试的。当项目发展了一段时间之后,团队开始关注产品质量,于是大家开始补测试。
|
||||
|
||||
在这种情况下,补测试是希望能够快速地建立起安全网,那必然是从系统测试入手来得快。只要写上一些高层测试,就能够覆盖到系统的大部分功能,属于“投资少见效快”的做法。这也是很多人喜欢冰淇淋蛋卷模型的重要原因。
|
||||
|
||||
但是,我们必须知道一点,在补测试的情况下,这么做是没问题的。如果我们把它当作开发的常态,那就有问题了。这就像治病和健身的关系一样,虽然去医院能在短时间内快速解决一定问题,但你不能没事就去医院,只有日常多运动,才能减少去医院的次数。
|
||||
|
||||
所以,对于冰淇淋蛋卷模型,我的建议是,它是遗留项目写测试的起点。在有了一个安全网的底线之后,我们还是要向测试金字塔方向前进,以单元测试作为整体的基础。新写的代码都是要按照测试金字塔的方式来组织测试,这才是一个可以持续的方向。具体如何在遗留系统上写测试,这是我们下一讲要讨论的主题。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讨论了各种不同的测试在项目中应该如何配比,因为从实用的角度上看,我们不太可能用各种类型的测试做所有代码的覆盖,这是一种浪费。
|
||||
|
||||
在决定如何配比各种类型的测试前,你首先要了解各种测试的特点。比如,单元测试速度快成本低,但覆盖面小;集成测试和系统测试覆盖面大,但速度慢成本高。
|
||||
|
||||
行业中目前有两种典型的测试模型:冰淇淋蛋卷和测试金字塔。二者对于测试的配比要求刚好相反,冰淇淋蛋卷要求多写高层测试,而测试金字塔则希望多写低层测试。
|
||||
|
||||
行业中的最佳实践是测试金字塔,这是每个新项目都应该做到的。对于遗留项目,我们可以在一开始的时候,先采用冰淇淋蛋卷建立基础的安全网,在有了最低保障之后,开始向测试金字塔方向努力。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:新项目采用测试金字塔,遗留项目从冰淇淋蛋卷出发。
|
||||
|
||||
思考题
|
||||
|
||||
你的团队写哪种测试比较多呢?你们是怎样考虑的呢?欢迎在留言区分享你的思考。
|
||||
|
||||
|
||||
|
||||
|
217
专栏/程序员的测试课/16怎么在遗留系统上写测试?.md
Normal file
217
专栏/程序员的测试课/16怎么在遗留系统上写测试?.md
Normal file
@ -0,0 +1,217 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 怎么在遗留系统上写测试?
|
||||
你好,我是郑晔!
|
||||
|
||||
迄今为止,我们讨论的话题主要是围绕着如何在一个新项目上写测试。但在真实世界中,很多人更有可能面对的是一个问题重重的遗留系统。相比于新项目,在一个遗留系统上,无论是写代码还是写测试,都是一件有难度的事。
|
||||
|
||||
在讨论如何在遗留系统上写测试前,我们首先要弄清楚一件事:什么样的系统算是遗留系统。在各种遗留系统的定义中,Michael Feathers 在《修改代码的艺术》(Working Effectively with Legacy Code)中给出的定义让我印象最为深刻——遗留系统就是没有测试的系统。
|
||||
|
||||
根据这个定义你会发现,即便是新写出来的系统,因为没有测试,它就是遗留系统。由此可见测试同遗留系统之间关系之密切。想要让一个遗留系统转变成为一个正常的系统,关键点就是写测试。
|
||||
|
||||
给遗留系统写测试
|
||||
|
||||
众所周知,给遗留系统写测试是一件很困难的事情。但你有没有想过,为什么给遗留系统写测试很困难呢?
|
||||
|
||||
如果代码都写得设计合理、结构清晰,即便是补测试也困难不到哪去。但大部分情况下,我们面对的遗留系统都是代码冗长、耦合紧密。你会不会一想到给遗留系统写测试就头皮发麻?因为实在是太麻烦了。由此我们知道,给遗留系统写测试,难点不在于测试,而在于它的代码。
|
||||
|
||||
如果不能了解到一个系统应该长成什么样子,我们即便努力做到了局部的一些改进,系统也会很快地退化成原来的样子。这也是为什么我们学习写测试要从一个新项目开始,因为我希望你对一个正常系统的样子有个认知,写测试不只是写测试的事,更是写代码的事。
|
||||
|
||||
在遗留系统上写测试,本质上就是一个系统走向正常的过程。对于一个系统来说,一旦能够正常运行,最好的办法就是不动它,即便是要给它写测试。但在真实世界中,一个有生命力的系统总会有一些让我们不得不去动它的理由,可能是增加新特性,可能是要修改一个 Bug,也可能是要做系统的优化。
|
||||
|
||||
我们不会一上来就给系统完整地添加测试,这也几乎是不可能完成的任务。所以,本着实用的态度,我们的做法是,动到哪里,给哪里写测试。
|
||||
|
||||
要动哪里,很大程度上就是取决于我们对既有代码库的理解。不过,既然是遗留代码,可能出现的问题是,你不一定理解你要修改的这段代码究竟是怎么起作用的。最有效的解决办法当然是找懂这段代码的人请教一番,但如果你的代码库生命周期够长,很有可能已经没有人知道这段代码是怎么来的了。在如今这个时代里,摸黑看代码时,我们可以使用 IDE 提供的重构能力,比如提取方法,将大方法进行拆分,这样有助于降低难度。
|
||||
|
||||
至于给哪里写测试,最直观的做法当然是编写最外层的系统测试。这种做法可行,但正如我们在上一讲所说,越是外层的测试,编写的成本越高,执行速度越慢。虽然覆盖面会广一些,但具体到我们这里要修改代码而言,存在一种可能就是控制得不够准确。换言之,很有可能我们写了一个测试,但是我们改不改代码,对这个测试影响不大。所以,只要有可能,我们还是要努力地降低测试的层次,更精准地写测试。也就是能写集成测试,就不写系统测试;能写单元测试,就不写集成测试。
|
||||
|
||||
或许你会说,我也知道能写单元测试很好,但通常遗留系统最大的问题就在于单元测试不好写。造成测试不好写的难点就是耦合,无论是代码与外部系统之间的耦合,还是代码与第三方程序库的耦合,抑或是因为代码写得不好,自己的代码就揉成了一团。所以,想在遗留系统中写好测试,一个关键点就是解耦。
|
||||
|
||||
一个解耦的例子
|
||||
|
||||
我们在专栏前面中讲过,测试的关键就在于构建一个可控的环境。对于编写单元测试来说,可控环境很关键的一步就是使用模拟对象,也就是基于 Mock 框架生成的对象。
|
||||
|
||||
同样,在遗留系统上如果想要编写单元测试,模拟对象也很关键。换言之,我们要给一个类编写单元测试,首先要把它周边的组件由模拟对象替换掉,让它有一个可控的环境。说起来很简单,但面对遗留系统时,想要用模拟对象替换掉真实对象就不是一件轻松的事。
|
||||
|
||||
下面我们就用一个例子看看如何在一个遗留系统上进行解耦,然后又是如何给代码写测试。我们有一个订单服务,完成了下单过程之后,要发出一个通知消息给到 Kafka,以便通知下游的服务。
|
||||
|
||||
public class OrderService {
|
||||
private KafkaProducer producer;
|
||||
|
||||
public void placeOrder(final OrderParameter parameter) {
|
||||
...
|
||||
this.producer.send(
|
||||
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
很显然,这段代码我们直接依赖了 KafkaProducer,这是 Kafka 提供的 API,如果要想测试 OrderService 这个类,我们就需要把 Kafka 加到这个测试里,而我们的测试重点是下单的过程,这个过程本身同 Kafka 没有关系。要测试这个类,我们必须把 Kafka 从我们的代码中解耦开。
|
||||
|
||||
首先,我们用提取方法(Extract Method)这个重构手法把 Kafka 相关的代码调用封装起来,通过使用 IDE 的重构功能就可以完成。
|
||||
|
||||
public class OrderService {
|
||||
private KafkaProducer producer;
|
||||
|
||||
public void placeOrder(final OrderParameter parameter) {
|
||||
...
|
||||
send(orderId);
|
||||
}
|
||||
|
||||
private void send(final OrderId orderId) {
|
||||
this.producer.send(
|
||||
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
接下来,我们要把 KafkaProducer 与我们的业务代码分离开。正如我们在之前讨论的内容所说,我们需要有一个封装层,把对第三方程序库的访问封装进去。所以,我们在这里引入一个新的类承担这个封装层的作用。我们可以使用**提取委托(Extract Delegate)创建出一个新的类,提取的时候,我们还要选上生成访问器(Generate Accessors)**的选项,它会为我们生成对应的 Getter。
|
||||
|
||||
public class KafkaSender {
|
||||
private KafkaProducer producer;
|
||||
|
||||
public KafkaProducer getProducer() {
|
||||
return producer;
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
而 OrderService 的 send 方法就变成了下面的样子。
|
||||
|
||||
class OrderService {
|
||||
...
|
||||
private void send(final OrderId orderId) {
|
||||
this.kafkaSender.getProducer().send(
|
||||
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
很显然,从当前的实现看,它只与 KafkaSender 相关,接下来,我们可以使用搬移实例方法(Move Instance Method)把它搬移到 KafkaSender 中。
|
||||
|
||||
class KafkaSender {
|
||||
...
|
||||
|
||||
public void send(final OrderId orderId, OrderService orderService) {
|
||||
getProducer().send(
|
||||
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
...
|
||||
|
||||
public void placeOrder(final OrderParameter parameter) {
|
||||
...
|
||||
kafkaSender.send(orderId, this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从代码上我们可以看到,虽然 KafkaSender 的 send 方法有 OrderService 这个参数,但是我们并没有用它,可以安全地删除它(Safe Delete),这也是一个快捷键就可以完成的工作。还有,这里用到 getProducer 方法,因为我们在 KafkaSender 这个类里面了,所以,我们就不需要通过 Getter 访问了,可以通过内联方法(Inline Method)将它去掉。
|
||||
|
||||
class KafkaSender {
|
||||
...
|
||||
|
||||
public void send(final OrderId orderId) {
|
||||
producer.send(
|
||||
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
...
|
||||
|
||||
public void placeOrder(final OrderParameter parameter) {
|
||||
...
|
||||
kafkaSender.send(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
到这里,我们的业务代码(OrderService)已经不再依赖于 KafkaProducer 这个第三方的代码,而是依赖于我们自己的封装层,这已经是一个进步了。不过从软件设计上讲,KafkaSender 是一个具体的实现,它不应该出现在业务代码中。所以,我们还需要再进一步,提取出一个接口,让我们的业务类不依赖于具体的实现。回到代码上,我们可以在 KafkaSender 这个类上执行提取接口(Extract Interface)这个重构动作,创建出一个新的接口。
|
||||
|
||||
public interface Sender {
|
||||
void send(OrderId orderId);
|
||||
}
|
||||
|
||||
public class KafkaSender implements Sender {
|
||||
@Override
|
||||
public void send(final OrderId orderId) {
|
||||
producer.send(
|
||||
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderService {
|
||||
private final Sender sender;
|
||||
|
||||
public OrderService(Sender sender) {
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
经过这番改造,OrderService 这个业务类已经与具体的实现完全无关了。我们就可以用模拟对象模拟出 sender,用完全可控的方式给这个类添加测试了。
|
||||
|
||||
class OrderServiceTest {
|
||||
private OrderService service;
|
||||
private Sender sender;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
this.sender = mock(Sender.class);
|
||||
this.service = new OrderService(this.sender);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
到这里,你或许会有一个疑问,我在这里改动了这么多的代码,真的没问题吗?如果这些代码是我们手工修改,这确实是个问题。不过,现在借助 IDE 的重构功能,我们并没有手工修改任何代码,相比于过去,这也是今天做遗留系统调整的优势所在。由此可见,理解重构,尤其是借助 IDE 的重构功能,是我们更好地去做遗留系统调整的基础。否则,我们必须先构建更外层的测试,无论是系统测试还是人工测试。
|
||||
|
||||
现在我们来回顾一下前面做了些什么。首先,我们有一个大目标:为了能够有效地测试,我们需要把具体实现和业务解耦开。在前面的例子中,主要就是要把 KafkaProducer 从业务类中分开。
|
||||
|
||||
把具体实现的代码从业务实现中隔离开,我们采用的手法是提取方法,这一步是为了后面把具体实现从业务类中挪出去做准备。通过引入一个封装类(KafkaSender),我们将具体的实现(KafkaProducer)从业务类中挪了出去。
|
||||
|
||||
到这里,我们的业务类已经完全依赖自己编写的代码。不过,这个封装类还是特定于具体的实现,让业务依赖于一个具体实现在设计上也是不恰当的。所以,我们这里再进一步,提取出一个接口。
|
||||
|
||||
从软件设计的角度看,这个提取出来的接口就是这个设计中缺失的一个模型,所以,提取这个接口不是画蛇添足,而恰恰是补齐了之前在设计上的欠缺。
|
||||
|
||||
换个角度看,模拟对象模拟的是接口行为,而很多遗留代码只有具体的类,而没有接口。虽然有些具体类也是可以模拟的,但出于统一原则的考虑,我们应该针对所有具体类提取一个接口出来,而让原来的类成为实现这个接口的一个实现类。有了接口,我们也就可以使用模拟对象,做行为可控的测试了。
|
||||
|
||||
这一系列的做法非常有用,比如,业务代码中调用了static方法,它在测试中也不好模拟。我们也可以通过提取方法把它隔离出来,然后把它挪到一个封装类里面,引入一个新的接口,让一段无法模拟的代码变得可以模拟。如果你真的能够理解这种做法,已经可以消灭掉很多设计不好的代码了。
|
||||
|
||||
当然,这里没有涵盖在遗留系统上写测试的各种做法,但你已经掌握了最精髓的部分:先隔离,再分离。如果你有兴趣了解更多的做法,推荐一本书给你,就是前面提到的《修改代码的艺术》(Working Effectively with Legacy Code)。虽然它是一本介绍处理遗留代码的书,在我看来,它更是一本教人如何写测试的书。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们谈到了在遗留系统上写测试。遗留系统就是那些没有测试的系统,给遗留系统写测试就是让一个系统恢复正常的过程。
|
||||
|
||||
在遗留系统上做改进,关键是要知道改进成什么样子。在一个遗留系统上写测试,不仅是写测试,还会牵扯到写代码。
|
||||
|
||||
完整地给一个遗留系统写测试是比较困难的。一个实用的改进策略是,动到哪里,改哪里。具体如何写测试,最好是测试的层次越低越好,但低层次的测试就会涉及代码耦合的问题,而这里就需要我们对代码进行解耦。
|
||||
|
||||
解耦,主要是把业务代码和具体实现分开。通过提取方法,把一段耦合紧密的代码隔离开,再创建一个新的封装类把它挪进去。如果代码里有很多具体类,我们还可以通过引入接口进行解耦。这里面的关键是利用 IDE 给我们提供的重构功能,减少手工改代码的操作。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:改造遗留系统的关键是解耦。
|
||||
|
||||
思考题
|
||||
|
||||
你有遗留系统改造的经验吗?你是怎么保证改造的正确性的呢?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
96
专栏/程序员的测试课/17TDD就是先写测试后写代码吗?.md
Normal file
96
专栏/程序员的测试课/17TDD就是先写测试后写代码吗?.md
Normal file
@ -0,0 +1,96 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 TDD 就是先写测试后写代码吗?
|
||||
你好,我是郑晔!
|
||||
|
||||
到这里,我已经给你介绍了在真实项目中做好测试需要的基础知识。写测试远远不是用 xUnit 框架写代码就能做好的一件事,只有在工作方式、软件设计、编写代码、测试理念等方方面面都做好,我们才能做好测试。不过随之而来的是,我们有了一个强大的测试基础,这可以让我们放心大胆地不断向前,因为我们已经进入到编写高质量代码的正向循环之中。越写测试越安心,越安心也就越有时间编写高质量的代码。
|
||||
|
||||
有了这些基础,应对日常工作已经绰绰有余。不过在行业里总有人在探索着更好的做法,所以在最后的扩展篇,我将给你介绍 TDD 和 BDD 两项实践。在你学有余力的情况下,可以挑战一下,让自己再向前走一步。这一讲,我们先来说说 TDD,也就是测试驱动开发(Test Drvien Development)。
|
||||
|
||||
TDD 的节奏
|
||||
|
||||
或许你已经迫不及待地要举手了:“TDD 我知道,就是先写测试,后写代码。”但真的是这样吗?严格地说,“先写测试、后写代码”的做法叫测试先行开发(Test First Development),而不是测试驱动开发。
|
||||
|
||||
测试驱动开发不也是先写测试后写代码吗?二者之间有什么区别呢?
|
||||
|
||||
要回答这个问题,我们需要知道 TDD 的一个关键要素,TDD 的节奏:红-绿-重构。
|
||||
|
||||
|
||||
|
||||
红表示写了一个新的测试,测试还没有通过的状态;绿表示写了功能代码,测试通过的状态;而重构就是在完成基本功能之后,调整代码的过程。
|
||||
|
||||
这里说到的红和绿源自单元测试框架。因为在很多单元测试框架运行测试的过程中,测试不过时会用红色展示测试结果,而通过时则采用绿色进行展示,这已经成了单元测试框架约定俗成的规则。
|
||||
|
||||
在前面内容中我们说过,让单元测试框架流行起来的是 JUnit,其作者之一是 Kent Beck。TDD 走进大众视野则依赖于极限编程这个软件工程方法论的兴起,而极限编程的创始人也是 Kent Beck。Kent Beck 在 JUnit 和 TDD 两件事都有着重大贡献,也就不难理解为什么 TDD 的节奏叫“红-绿-重构”了。
|
||||
|
||||
先写测试,然后写代码完成功能,在第一步和第二步上,测试先行开发和测试驱动开发是一样的。二者的差别在于,测试驱动开发并没有就此打住,它还有一个更重要的环节:重构(refactoring)。
|
||||
|
||||
也就是说,在功能完成而且测试跑通之后,我们还会再次回到代码上,处理一下代码中写得不理想的地方,或是消除新增代码与旧有代码之间的重复。你或许会问,那为啥不在第二步“绿”的时候就把代码写好呢?因为“绿”的关注点只是让测试通过,把功能完成。
|
||||
|
||||
所以我们说,测试先行开发和测试驱动开发的差异就在重构上。
|
||||
|
||||
很多人只记住了“先写测试,后写代码”,因为在很多人的印象中,写代码唯一重要的事就是完成功能。通过了测试,就是完成了功能,也就意味着万事大吉了。然而,这种想问题的方式会让人忽略新增代码可能带来的坏味道(Code Smell),坏味道会让代码逐渐腐坏,这是一个工程问题,也就是会有长期影响的问题。
|
||||
|
||||
人的注意力是有限的,让人在一个阶段把所有事情都做好,很难。事实上我们会看到,很多团队代码变乱的一个重要原因就是把全部的注意力都放到完成功能上,根本无暇顾及代码本身的质量。从这个角度上看,TDD 是更符合人性的做法,它把完成功能和代码调整当成了两个阶段。
|
||||
|
||||
重构就是一个消除代码坏味道的过程。一旦你有了测试,你就可以大胆地重构了,因为任何修改错误,测试都会替你捕获到。
|
||||
|
||||
在测试驱动开发中,重构与测试是相辅相成的:没有测试,修改代码只能是提心吊胆;没有重构,代码的混乱程度会逐步增加,测试也会变得越来越不好写。
|
||||
|
||||
现在,你已经理解了测试驱动开发不只是“先写测试,后写代码”。但这只是破除了概念上的误区,我们还需要再进一步,知道测试怎么“驱动”开发。
|
||||
|
||||
测试“驱动”开发
|
||||
|
||||
不难理解,重构和测试相互配合,这个过程就会“驱动”着我们把代码写得越来越好。不过,这只是对“驱动”一词最粗浅的理解。
|
||||
|
||||
首先,我来问你一个问题,测试驱动开发,从哪里开始呢?很多人会说,测试驱动开发不是从测试开始的吗?这个答案非常直观,我们可以接着追问下去,写测试要从哪里开始呢?
|
||||
|
||||
对很多人来说,TDD 是一种难以接受的做法,抛开理念上的差异,更重要的原因是,写测试无从下手。学习过我们这个专栏你会发现,很多时候写不出测试,主要是面对的需求太大了。所以,真正动手做开发的第一步是任务分解,把一个规模很大的需求拆分成若干小任务。面对一个具体的小任务,我们才有动手写测试的基础。测试驱动开发要从任务分解开始。
|
||||
|
||||
具体到了写测试的环节,即便面对的是一个小任务,对很多人来说,这依然不是一件容易完成的事。同样,我们在前面分析过,想要写出测试,需要有可测试的代码。这意味着,我们的代码需要有一个可测试的设计。如果不能写测试,我们就要调整代码,让代码变得可以测试,这是我们上一讲中谈遗留系统测试所讲的内容。
|
||||
|
||||
从这里你可以看出,从测试出发考虑问题的这种思考方式,会彻底颠覆掉我们原有的工作习惯,甚至是为了测试调整设计。但结果是我们得到了一个更好的设计,所以,很多懂 TDD 的人会把 TDD 解释为测试驱动设计(Test Driven Design)。
|
||||
|
||||
现在你可以理解了,为了写测试,首先“驱动”着我们把需求分解成一个一个的任务,然后会“驱动”着我们给出一个可测试的设计,而在具体的写代码阶段,又会“驱动”着我们不断改进写出来的代码。把这些内容结合起来看,我们真的是在用测试“驱动”着开发。
|
||||
|
||||
TDD 这么好,为什么行业里采用 TDD 这种工作方式的人并不多呢?首先,很多人本身对 TDD 的理解是错误的,这是我在前面分析过的;其次,TDD 看似简单的节奏中,其实需要很多前置的基础,比如任务分解、可测试的设计等等,而这些能力是很多人不具备的。换个角度看,TDD 只是冰山一角,露在海面之上的是 TDD 的节奏,而藏在海面下的是任务分解、软件设计这些需要一定时间积累的能力。
|
||||
|
||||
我们这个专栏介绍TDD的方式相比于传统介绍 TDD 的方式,还是挺不一样的。学过我们这个专栏之后,你其实已经具备了 TDD 的基础。因为在前面部分的介绍中,我已经把这些基础能力给你串讲过了。到了这里,你只需要知道 TDD 的节奏是怎样的,如果想尝试 TDD,那么按照 TDD 的节奏练习一段时间,你就知道 TDD 是怎么回事了。
|
||||
|
||||
最后,再给你补充一个知识点。前面说过 TDD 是来自极限编程,那极限编程为什么要叫极限编程呢?
|
||||
|
||||
极限编程之所以叫“极限”,它背后的理念就是把好的实践推向极限:
|
||||
|
||||
|
||||
如果集成是好的,我们就尽早集成,推向极限就是每一次修改都集成,这就是持续集成。
|
||||
如果程序员写测试是好的,我们就尽早测试,推向极限就是先写测试,再根据测试调整代码,这就是测试驱动开发。
|
||||
如果代码评审是好的,我们就多做评审,推向极限就是随时随地代码评审,这就是结对编程。
|
||||
如果客户交流是好的,我们就和客户多交流,推向极限就是客户与开发团队时时刻刻在一起,这就是现场客户。
|
||||
|
||||
|
||||
极限编程本身的实践值得我们好好学习,但极限编程背后这种理念其实也非常值得我们学习。我们在日常工作中也不妨多想想,有哪些做法是好的,如果把它推向极致会是什么样子。这种想问题的方式会在很大程度上拓宽你的思路。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了 TDD,也就是测试驱动开发。我们在专栏前面的内容中学习的是在代码层面上如何写测试,而 TDD 这种实践探索的是在开发过程中如何写测试。
|
||||
|
||||
测试驱动开发已经是行业中的优秀实践,学习测试驱动开发的第一步是记住测试驱动开发的节奏:红——绿——重构。
|
||||
|
||||
知道了 TDD 的节奏之后,我们还需要理解测试怎样驱动开发。在 TDD 的过程中,我们要先进行任务分解,把大需求拆成小任务,然后考虑代码的可测试性,编写出整洁的代码,这一切都是在“测试”驱动下产生的。
|
||||
|
||||
正是因为视角的转变,为了编写可测的代码,我们甚至要为此调整设计,所以,有人也把 TDD 称为测试驱动设计。
|
||||
|
||||
无论你是否采用 TDD 的实践,在动手写代码之前,从测试的角度进行思考都是非常有价值的一件事,这也是编写高质量代码的重要一环。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:从测试的视角出发看待代码。
|
||||
|
||||
思考题
|
||||
|
||||
你尝试过 TDD 吗?如果没有,不妨试试,然后在留言区分享一下你采用 TDD 进行开发的感受。
|
||||
|
||||
|
||||
|
||||
|
176
专栏/程序员的测试课/18BDD是什么东西?.md
Normal file
176
专栏/程序员的测试课/18BDD是什么东西?.md
Normal file
@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 BDD 是什么东西?
|
||||
你好,我是郑晔!
|
||||
|
||||
在扩展篇中,我们要讨论的是在不同方向上的写测试探索。在上一讲里,我给你介绍了 TDD。TDD 是在写测试的时机上进行了不同的探索。这一讲,我们再来讲另一个实践——BDD,它是在写测试的表达方式上进行的不同探索。
|
||||
|
||||
我们都知道,在软件开发中最重要的一个概念就是分层,也就是在一些模型的基础上,继续构建新的一些模型。程序员最耳熟能详的分层概念就是网络的七层模型,只要一层模型成熟了,就会有人基于这个模型做延伸的思考,这样的做法在测试上也不例外。
|
||||
|
||||
当 JUnit 带来的自动化测试框架风潮迅速席卷了整个开发者社区,成了行业的事实标准,就开始有人基于测试框架的模型进行延伸了。各种探索中,最有影响力的就是 BDD。
|
||||
|
||||
行为驱动开发
|
||||
|
||||
BDD 的全称是 Behavior Driven Development,也就是行为驱动开发。BDD 这个概念是2003年由 Dan North 提出来的。
|
||||
|
||||
单元测试框架写测试的方式更多的是面向具体的实现,这种做法的层次是很低的,BDD 希望把这个思考的层次拉高。拉到什么程度呢?软件变化的源动力在业务需求上,所以,最好是能够到业务上,而校验业务的正确与否的就是业务行为。这种想法很大程度上是受到当时刚刚兴起的领域驱动设计(Domain Driven Design)中通用语言的影响。在 BDD 的话语体系中,“测试”的概念就由“行为”所代替,所以,这种做法称之为行为驱动开发。
|
||||
|
||||
Dan North 不仅仅提出了概念,而且为了践行他的想法,他还创造了第一个 BDD 的框架:JBehave。后来又改写出基于 Ruby 的版本 RBehave,这个项目后来被并到 RSpec 中。
|
||||
|
||||
好,了解了 BDD 的由来,接下来,我们就来看看采用 BDD 的方式进行开发,测试会写成什么样子。
|
||||
|
||||
今天最流行的 BDD 框架应该是 Cucumber,它的作者就是 RSpec 的作者之一 Aslak Hellesøy。从最开始基于 Ruby 的 BDD 框架发展成今天,Cucumber 已经变成了支持很多不同程序设计语言的 BDD 测试框架,比如常见的 Java、JavaScript、PHP 等等。
|
||||
|
||||
下面是一个 BDD 的示例,其场景就是我们前面实战的内容。
|
||||
|
||||
Scenario: List todo item
|
||||
Given todo item "foo" is added
|
||||
And todo item "bar" is added
|
||||
When list todo items
|
||||
Then todo item "foo" should be contained
|
||||
And todo item "bar" should be contained
|
||||
|
||||
|
||||
从这个例子我们不难看出,BDD 的测试用例有很强的可读性。即便我们不熟悉技术,单凭这段文字,我们也能看出这个用例想表达的含义。这也就是我们前面说 BDD 测试用例更贴近业务的原因。它希望成为业务人员和技术团队之间沟通的桥梁,所以,它的表述方式更贴近于业务。
|
||||
|
||||
虽然这个表述已经很贴近业务了,但它并不是自然语言描述,而是有一种特定的格式,其实这是一门领域特定语言(Domain Specific Language,简称 DSL),称之为 Gherkin。
|
||||
|
||||
不要看到一门新的语言就被吓退,其实它非常简单。这里的核心点就是它的描述格式:“Given…When…Then”。Given 表示一个假设前提,When 表示具体的操作,Then 则对应着这个用例要验证的结果。
|
||||
|
||||
我们在第 5 讲谈到测试的结构时说过,测试一般包含四个阶段:准备、执行、断言和清理。把它对应到这里,Given 对应着准备,When 对应执行,而 Then 对应断言。至于清理,这个阶段会做一些资源释放的工作,不过这个工作属于实现层面的内容,在业务层面上意义不大,所以在以业务描述为主要目标的 BDD 中,这个阶段是不存在的。
|
||||
|
||||
了解了格式,我们再来关注具体的内容。首先,这里描述的行为都是站在业务的角度进行叙述的。其次,Given、When、Then 都是独立的,可以自由组合。这也就意味着,一旦基础框架搭好了,有人就可以使用这些基础语句来编写新的测试用例,甚至可以不需要技术人员参与。
|
||||
|
||||
从这里我们不难看出,Gherkin 语言本身有一个很好的目标,与其说它是为了技术人员设计的,不如说它是为了业务人员设计的。
|
||||
|
||||
Gherkin 语言这层只提供了业务描述,作为程序员我们很清楚,这层描述并不能直接发挥作用,必须要有一个具体的实现。那具体的实现要放在哪里呢?这就轮到胶水层(Glue)发挥作用了,这个将测试用例与实现联系起来的胶水层,在 Cucumber 的术语里,称之为步骤定义(Step Definition),下面就是一个步骤定义的示例。
|
||||
|
||||
public class TodoItemStepDefinitions ... {
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
public TodoItemStepDefinitions() {
|
||||
...
|
||||
|
||||
Given("todo item {string} is added", (String content) ->
|
||||
addTodoItem(content)
|
||||
);
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
private void addTodoItem(final String content) {
|
||||
AddTodoItemRequest request = new AddTodoItemRequest(content);
|
||||
final ResponseEntity<String> entity =
|
||||
restTemplate.postForEntity("http://localhost:8080/todo-items", request, String.class);
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
既然步骤定义是 Gherkin 文件与具体实现之间的胶水,所以,理解步骤定义的关键就是知道它是如何将二者关联起来的。在这段代码中,Given 就是这样的连接点。对比一下我们就会发现, Given 里面的参数就是我们在前面 Gherkin 文件中的描述,不同的点是,这里把其中的一部分变成了参数。由此我们可以知道,对于同样一个描述,可以根据用例的差异,采用不同的参数。
|
||||
|
||||
如果说 Gherkin 语言部分几乎在各种 BDD 框架之间是通用的,那步骤定义部分则是框架强相关。这里我们采用 Cucumber Java 8 的方式进行了步骤定义,也就是采用 Given 方法进行定义,如果你去看其它的资料,也会看到基于 Annotation 的定义,这就是选择不同依赖程序库的结果。
|
||||
|
||||
到了具体的实现上,程序员就很有底气了。在这里我们根据业务动作进行相应的处理。在上面这段代码中,添加 Todo 项就是向自己编写的服务发出了一个 POST 请求。
|
||||
|
||||
这些东西理解起来都很容易,唯一需要稍微注意一点的是,给 Then 编写代码时,因为它是表示断言的,在这个部分我们一定要写出断言,比如像下面这样。
|
||||
|
||||
Then("todo item {string} should be contained", (String content) -> {
|
||||
assertThat(Arrays.stream(responses)
|
||||
.anyMatch(item -> item.getContent().equals(content))).isTrue();
|
||||
});
|
||||
|
||||
|
||||
上面这段代码的更多细节实现,你可以去参考我们的实战项目。
|
||||
|
||||
实战中的 BDD
|
||||
|
||||
现在我们已经有了对 BDD 的初步了解,接下来,我们就来看看在实际的项目中可以怎样使用 BDD。
|
||||
|
||||
前面我们已经知道了,Gherkin 语言是面向业务人员的。不同于写代码我们只能用英文,Gherkin 在设计时就考虑到了业务人员的实际需要,所以它的设计本身是本地化的。我们甚至可以用中文编写测试用例,下面就是一个登录的测试用例。
|
||||
|
||||
假定 张三是一个注册用户,其用户名密码是分别是 zhangsan 和 zspassword
|
||||
当 在用户名输入框里输入 zhangsan,在密码输入框里输入 zspassword
|
||||
并且 点击登录
|
||||
那么 张三将登录成功
|
||||
|
||||
|
||||
这个用例怎么样呢?或许你会说,这个用例写得挺好。如果你这么想,说明你是站在程序员的视角。我在前面已经说过了,BDD 需要站在业务的角度,而这个例子完全是站在实现的角度。如果登录方式有所调整,用户输完用户名密码自动登录,不需要点击,那这个用例是不是需要改呢?下面我换了一种方式描述,你再感受一下。
|
||||
|
||||
假定 张三是一个注册用户,其用户名密码是分别是 zhangsan 和 zspassword
|
||||
当 用户以用户名 zhangsan 和密码 zspassword 登录
|
||||
那么 张三将登录成功
|
||||
|
||||
|
||||
这是一个站在业务视角的描述,除非做业务的调整,不用用户名密码登录了,否则这个用例不需要改变。即便实现的具体方式调整了,需要改变的也是具体的步骤定义。所以,想写好 BDD 的测试用例,关键点在用业务视角描述。
|
||||
|
||||
既然 BDD 的用例更多偏向业务视角,所以在真实的项目中使用它时,我们更多偏向于把它当做验收测试的工具来用。这里就会有一个我们常常忽略的点:业务测试的模型。很多人的第一直觉是,一个测试要啥模型?
|
||||
|
||||
既然 BDD 更多的使用场景是复杂的验收场景,所以,相应地我们也要为测试场景进行建模。还记得我们讲好测试应该具备的属性吗?其中一点就是专业性。对于复杂场景而言,想要写好测试同写好代码是一样的,一个好的模型是不可或缺的。
|
||||
|
||||
这方面一个可以作为参考的例子是做 Web 测试常用的一个模型:Page Object。它把对页面的访问封装了起来,即便你在写的是步骤定义,你也不应该在代码中直接操作 HTML 元素,而是应该访问不同的页面对象。
|
||||
|
||||
以前面的登录为例,我们可能会定义这样的页面对象。
|
||||
|
||||
public class LoginPage {
|
||||
public boolean login(String name, String password) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
如此一来,在步骤定义中,你就不必关心具体怎么定位到输入框会让代码的抽象程度得到提升。当然这只是一个参考,面对你自己的应用时,你要考虑构建自己的业务测试模型。
|
||||
|
||||
BDD 的延伸
|
||||
|
||||
最后,我们再来说说 BDD 的一些延伸。从上面的内容我们可以知道,BDD 的用例和普通测试的用例只是在表述方式上有所差异,从结构上看,二者几乎是完全等价的。所以,只要你想,完全可以采用 BDD 的方式进行从单元测试到系统测试所有类型的测试。
|
||||
|
||||
所以我们会看到,在行业里还有一些 BDD 风格的单元测试框架,其中最典型的就是 RSpec。我从 RSpec 的文档上截取了一段代码,你可以感受一下。
|
||||
|
||||
RSpec.describe Order do
|
||||
it "sums the prices of its line items" do
|
||||
order = Order.new
|
||||
order.add_entry(LineItem.new(:item => Item.new(
|
||||
:price => Money.new(1.11, :USD)
|
||||
)))
|
||||
order.add_entry(LineItem.new(:item => Item.new(
|
||||
:price => Money.new(2.22, :USD),
|
||||
:quantity => 2
|
||||
)))
|
||||
expect(order.total).to eq(Money.new(5.55, :USD))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
其实,它与前面的 Cucumber 用例还是有很大差异的,因为它属于单元测试的范畴,所以没有像 Gherkin 部分那种面向于业务人员的描述。但同时你也能看到,它同传统的 xUnit 框架有着很大的不同,主要是框架本身会引导你写出更具描述性的代码。
|
||||
|
||||
BDD 的另外一个延伸方向是对需求进行文档化的表述。既然 BDD 是在朝着业务方向靠近,争取让业务人员能够很好地理解这些测试用例,那从本质上来说,它就起到了文档的作用,这个文档和真实实现是紧密相关的,是一种“活”文档(Living Document)。活文档指的是持续更新的文档,这个概念本身不局限于技术领域。Cucumber 本身有对活文档的支持,它可以与 JIRA 去集成,可以直接把 Cucumber 测试用例变成文档。
|
||||
|
||||
既然要写文档,那就不局限于是否采用 BDD 这样的格式,所以,还出现了像 Concordion 这样的工具,甚至可以让我们把验收用例写成一个完整的参考文档。最开始它支持用 HTML 的方式写文档,现在也支持用 Markdown 的方式来编写文档。
|
||||
|
||||
无论是 BDD 也好,活文档也罢,它们背后还有一个概念,叫做实例化需求(Specification by Example,SbE),也就是用实例的方式对需求进行阐述,你可以看到 BDD 和活文档就是通过这种方式在将需求表现出来。
|
||||
|
||||
总之,如果你对这个方向有兴趣,前面还是有很多东西可以探索。总的来说,它就是让技术团队不再局限于技术本身,更加贴近业务,这和整个行业的发展趋势是高度吻合的。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲,我们讲了 BDD,也就是行为驱动开发。这种思想是站在 xUnit 的框架基础之上,让测试用例的表达更贴近业务行为。
|
||||
|
||||
我用 Cucumber 这个今天最为流行的 BDD 框架给你介绍了如何编写测试用例,你只要记住 “Given…When…Then” 的格式,就算抓住了 Gherkin 语言表述的核心。
|
||||
|
||||
在实际的项目中使用 BDD 我们可以采用本地化的表述方式,不过,重点是要让测试用例贴近业务而非实现细节。一般来说,BDD 多用于验收测试,所以相应地,我们在编写步骤定义时,对于复杂业务可以考虑构建业务测试模型,对实现细节进行封装。
|
||||
|
||||
最后,我们还谈到了 BDD 的延伸,无论是 BDD 风格的单元测试框架,还是活文档、实例化需求,这些都是你可以进一步探索的东西。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:技术团队要更加贴近业务。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讨论的 BDD 更多是用在验收测试中的,你的团队是怎么做验收测试呢?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
101
专栏/程序员的测试课/答疑解惑那些东西怎么测?.md
Normal file
101
专栏/程序员的测试课/答疑解惑那些东西怎么测?.md
Normal file
@ -0,0 +1,101 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
答疑解惑 那些东西怎么测?
|
||||
你好,我是郑晔!
|
||||
|
||||
到现在,《程序员的测试课》的正文内容我已经全部交付给你了。在专栏上线这段时间里,感谢你的一路相伴。有不少同学在专栏的问了一些非常典型的问题,引发了我的一些思考。所以,我准备了这期加餐,把我的这些延伸思考分享给你。
|
||||
|
||||
实战项目
|
||||
|
||||
这个专栏是我第一次用完整实战的方式给你演示如何做一个项目。秉承我写专栏一贯的特点,我写实战的重点是做事情的思路,而非具体的源码。
|
||||
|
||||
程序设计语言
|
||||
|
||||
不过,只要涉及具体的代码,就会有各种问题产生。最典型的问题就是,在实战中我采用了Java,有一些擅长其他语言的同学会问到我能不能提供其他语言的版本。
|
||||
|
||||
首先,我必须向这些同学说声抱歉,因为时间和能力有限,我没法提供各种程序设计语言的实现版本。这个ToDo 应用原本是《代码之丑》中的练习题,在那一讲里,同学们给出了很多不同的实现,有各种语言的版本,如果你有兴趣不妨去看看留言区里其他同学是怎么实现的。
|
||||
|
||||
当然,这个问题本身并不复杂,最好是你自己实现一遍,然后再对比我的实现过程,看看有哪些有差异的地方。编程这件事,讲道理远远不如动手实践来得感受更深刻。至于我写的代码本身,重要性没有那么强,只是一个参考。
|
||||
|
||||
之所以采用 Java 语言,一方面是因为它受众极广,另一方面更重要的是,Java 语言在工程上的能力非常好。无论是各种工具和框架还是工程实践上,Java 社区往往是走在整个行业的前列。
|
||||
|
||||
写这个专栏的时候,我已经尝试尽量降低理解的难度,把解决问题的过程和使用的工具尽量做到通用,尤其是前两讲的中用到的。即便你用的是其他程序设计语言,也可以找到对应的解决方案,比如命令行解析的框架、覆盖率检查的工具、构建工具等等。我们学习任何一门语言,要学的内容都不应局限于语言本身,还应该学习这个语言相关的生态。所以,如果你使用的是其它语言,却不知道我提到的这些东西,不妨借着这个机会,给自己补充一下。
|
||||
|
||||
不过,像 Spring 这种框架不是每个语言都有的,严格地说,Spring 已经超过了框架的范畴,它已经是一个完整的生态了。
|
||||
|
||||
关于程序设计语言,我一贯的建议就是多学几门,千万别局限在某一门语言里。所以,如果你擅长的是其它语言,可以借这个机会了解一下 Java 程序是怎么写的。
|
||||
|
||||
程序库
|
||||
|
||||
除了程序设计语言,还有一类问题是关于其它程序库的,也就是借着实战的项目做了一下延展,比如使用了某某程序库的程序该怎么测。
|
||||
|
||||
其实这个问题我在集成测试里已经说过了,首先要有一个单独的防腐层,将第三方代码隔离开,先保证逻辑的正确,再来看第三方程序库。与第三方程序库的集成,能测就尽量测,不能测就只能靠更高层的测试来覆盖了。
|
||||
|
||||
其实,现在很多的程序库或是中间件都有自己对于测试的支持,一种简单的方案就是用要集成的东西加上 Mock 作为关键字去搜索,比如 Mock Kafka、Mock Dubbo 等,如果你运气好的话,会得到相应的答案,剩下就是阅读文档的工作了。
|
||||
|
||||
有一些地方是我在写专栏的过程中没注意到的,可能会给你造成困扰。比如我用到 Lombok 这个程序库,它会替我们生成一些代码,节省编码的工作量,没用过 Lombok 的同学看到这样的代码可能会有些懵。
|
||||
|
||||
说到 Lombok,我再多补充一点。在测试的过程中,有一些典型的样板代码是让人很难受的,比如 getter、equals、hashCode、toString,这种代码不写,行为不对;写,就会有测试覆盖率的问题。给这些方法写测试是非常麻烦的一件事,这里的麻烦就是单纯的麻烦,实现细节是标准的,写的测试也几乎长一个模样。
|
||||
|
||||
所以在实际的项目中,我现在常常会采用 Lombok 这个程序库,它会替我生成这些标准的方法,就像下面这样。
|
||||
|
||||
@EqualsAndHashCode
|
||||
@Getter
|
||||
@ToString
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class TodoItem {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
我们只要在相应的位置加上 Annotation,在编译的过程中,Lombok 就会替我们生成相应的代码。所以,虽然我没有一行一行地把代码写出来,但我们依然可以在程序中使用相应的方法。这就是一个好的程序库价值所在,它可以极大地简化代码的编写。
|
||||
|
||||
这些方法是生成的,而不是我们手工编写的,所以,我们也没有必要去检查这些方法是否正确,在执行测试覆盖率检查时,我们可以忽略掉这些方法。Lombok 和 Jacoco 之间已经有了这种默契,所以,你会在 Lombok 的配置文件 lombok.config 看到下面这样一句。
|
||||
|
||||
lombok.addLombokGeneratedAnnotation = true
|
||||
|
||||
|
||||
它会给生成的方法加上一个 Annotation,Jacoco 看到了这个 Annotation 就会知道它是一个生成的方法,进而在测试覆盖的统计中忽略掉它。
|
||||
|
||||
那些东西怎么测?
|
||||
|
||||
还有一类的问题相对来说就很宽泛了,比如有人问怎么测试大数据模型的正确性。要回答这个问题,我们要先看看自动化测试究竟能解决什么问题。
|
||||
|
||||
首先,我们需要明确一点,程序员在日常开发中写测试除了我们之前说过的种种原因之外,还有一点就是为我们的开发保驾护航,这些测试要能够时时刻刻起到警戒线的作用。这种测试写出来要常常跑的,而不是束之高阁。所以,它还有一个好搭档就是持续集成。多说一句,只有做好了自动化测试,持续集成才能发挥出它最大的价值。
|
||||
|
||||
有了前面的理解,我们再来看哪些东西能起到这样的作用:那些能够让人形成稳定预期的东西。我们前面写的各种测试,都是能够有稳定预期的,这也是我们说测试一定要有断言的重要原因。
|
||||
|
||||
回过头来看前面的问题。大数据模型这种东西,你能有稳定的预期吗?你或许会说,我希望得到一个好的结果,然而,这样说法一点都不具体,什么叫好的结果呢?这就像我希望身体健康一样,这只是美好的愿望,它并不会因为我们“希望”了,它就能够实现出来。对于这种你连用语言都无法描述的东西,就不要指望用测试描述了。
|
||||
|
||||
其实,你真正关心的不是模型的正确性,而且它的效用,也就是说,它到底有什么用。
|
||||
|
||||
效用这种东西,我们没有办法对它形成稳定的预期。举个例子,你说 100 万多吗?对大部分普通人来说,这已经是很大一笔钱了,但对于世界首富来说,这笔钱就不算多了。所以,同样是得到 100 万,对不同的人来说,效用完全不同。即便是对同一个人,当他人生处于不同的阶段,这笔钱对他来说意义也是完全不同的。
|
||||
|
||||
效用甚至都很难达成共识,那就更别说预期了,所以,大数据模型不是靠自动化测试能解决的。类似的东西还有很多,比如用户界面好不好看、软件的体验好不好,这些东西都属于效用。
|
||||
|
||||
那效用的东西就不能测了吗?也能,只不过,不是自动化测试。严格地说,效用的好坏要依赖于反馈。数据模型的有效性要靠业务来反馈,软件的好用还是好看要靠用户来反馈。
|
||||
|
||||
在软件开发的实践中有一种实践叫用户测试,简单说就是让用户参与到软件开发的过程中。还有一种实践叫 A/B 测试,就是把不同的东西给不同的用户看,把用户行为当作下一步决策的基础。你会发现,无论是哪种做法都不是靠简单的自动化测试能够覆盖的,因为这个过程中要有人的参与,人会根据反馈回来的信息进行判断。
|
||||
|
||||
既然这些依赖于人的测试不好自动化,是不是技术类的就都可以了呢?比如有人问,性能测试能不能放到程序员的测试里?
|
||||
|
||||
性能测试是一种可以用技术覆盖的测试,现在有很多程序库支持我们进行性能测试,比如 JMH、Gatling 等等。大到系统,小到单元,都可以使用性能测试进行覆盖。也正是这些程序库的存在,让我们可以通过代码来写性能测试,也让性能测试用测试框架覆盖成为可能。
|
||||
|
||||
不过,有一点我必须提醒你,性能测试最难处理的是断言怎么写。你或许会说,不就是和其他测试一样写吗?不尽然。
|
||||
|
||||
有很多团队开发用的是 Windows 或者是 Mac,而实际的项目是在 Linux 运行。我们知道,性能这个东西在不同的机器上跑出来的结果差异很大。即便是同一台机器,因为负载的差异,跑出来的结果也会有很大差异。所以,我们很难写出一个放在所有环境上都可用的断言。
|
||||
|
||||
从实践的角度上看,我不会把性能测试归到程序员的测试范畴。当然,如果你愿意用单元测试框架去写一个自动化性能测试,也是可以的,但请把它同其他测试隔离开来,其他的测试要在持续集成的全过程中运行,而这种自动化性能测试只在单一的机器上运行,比如持续集成服务器,这样做至少可以保证前后运行的结果不会因为机器原因产生很大的差异。
|
||||
|
||||
测试固然有用,但它不是万能的。作为程序员,我们只有分辨清楚自己面对的究竟是什么问题,才能使用相应的工具去解决问题。
|
||||
|
||||
思考题
|
||||
|
||||
关于程序员的测试,你还有哪些问题或者哪些经验可以分享吗?欢迎在留言区分享你的思考。
|
||||
|
||||
|
||||
|
||||
|
87
专栏/程序员的测试课/结束语对代码的信心要从测试里来.md
Normal file
87
专栏/程序员的测试课/结束语对代码的信心要从测试里来.md
Normal file
@ -0,0 +1,87 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 对代码的信心要从测试里来
|
||||
你好,我是郑晔!
|
||||
|
||||
《程序员的测试课》到这里已经接近尾声了,经过整个专栏的学习,相信你对“程序员该如何做测试”这件事已经有了一个更加完整的认识。这一讲我们不去深入技术细节,我想先从一本书和你聊起。
|
||||
|
||||
无知之错和无能之错
|
||||
|
||||
这本书叫《清单革命》,作者是阿图·葛文德,他是一名医生,曾是白宫最年轻的健康政策顾问。在书的开篇作者提到,人类的错误可以分为两大类型。第一类是“无知之错”,我们犯错是因为没有掌握相关知识。第二类是“无能之错”,我们犯错并非因为没有掌握相关知识,而是因为没有正确使用这些知识。无知之错,可以原谅,无能之错,不可原谅。
|
||||
|
||||
在作者看来,目前这个世界上还有很多疾病没有很好的治疗方案,这个算是无知之错。但在真实世界中,很多治疗的失败却是因为医疗团队没有做好该做的事,那就属于无能之错了。
|
||||
|
||||
这个分类方式给了我很大的震撼,让我一下子想明白很多事。有了分类,针对不同的错误,我们可以采用不同的修正方式。无知之错,因为欠缺的是知识,所以如果要修正这类错误,需要补充相关的知识。而无能之错并非知识的欠缺,所以,要修正这类错误,需要改进的是工作的方法和流程。
|
||||
|
||||
身为程序员,我们是幸运的,我们生活在一个对软件有巨大需求的时代。但实事求是地说,软件也是各种问题的高发地带,我们在其中不停犯着各种错误,很多甚至是低级错误。
|
||||
|
||||
这些错误应该归为哪种类型的错误呢?
|
||||
|
||||
在外人看来,软件开发团队就应该有做好软件的能力,没做好肯定就是各种疏忽造成,这种错误是一种无能之错,要解决无能之错,显然就应该从改进工作方法和流程入手。于是,我们看到很多公司一旦觉得自己的公司需要提升软件质量了,首先是引入一套新的流程,无论是拼命写文档,还是引入专人负责。然而结果是软件团队疲于应付各种流程,软件的实际质量却并未得到有效改善。虽然初衷是好的,但因为诊断错了病因,用错了药方,治不好病也就在所难免了。
|
||||
|
||||
软件质量的病不在外部,而在内部。一个没有质量意识的团队只靠外部的推动很难做出高质量软件,这就像一个孩子如果仅仅靠家长的逼迫很难取得长期的好成绩一样。然而,软件质量靠流程却成了行业中的常态,不得不说,这是一个悲哀的结果。孩子想取得好成绩,归根结底是要有自己对学习的热爱,同样,软件质量要想得到真正的提升,要将做到内建质量(Build Quality In)。
|
||||
|
||||
内建质量
|
||||
|
||||
内建质量,就是将质量的思考内建于软件开发的全生命周期中。
|
||||
|
||||
说起来很简单,然而,产品经理一拍脑袋,程序员拼命加班,把一个漏洞百出的软件送到了测试人员手里。然后,在业务强大的压力之下,测试人员闭着眼睛,把一个质量不彰的软件送上了线。每个环节都在放水,结果就是水漫金山。线上系统问题不断,新的需求接踵而来,团队疲惫不堪。然而,没有人真正地想问题到底出在哪里。这才是很多团队的真实情况。
|
||||
|
||||
在这样的团队中,质量只是测试人员的事,相当于把整个团队的责任压在了少数人的头上,线上出问题就是情理之中的事了。内建质量就是要把软件开发中的每一个环节都加入质量的考虑:
|
||||
|
||||
|
||||
业务负责人不能只要求上线日期,也要给出需求验证的业务目标和业务的验收标准;
|
||||
产品经理不只是要给出产品说明,更要给出每个需求点的验收标准;
|
||||
程序员不只给出代码,还要给出覆盖每行代码的自动化测试。
|
||||
|
||||
|
||||
所谓内建质量,本质上就是用任务分解的方式,让每个环节都交付满足一定质量标准的交付物。其实,现在软件行业已经懂得了用迭代交付替代瀑布式交付,把大的需求拆分为小需求的集合,逐步交付给市场,尽早收集反馈,避免走过多的弯路。而内建质量则是通过在每个环节中加入对质量的思考,在每一个环节都要验证交付物是否符合目标,尽早发现问题。这样才不至于让测试人员成为最后的防洪堤坝,才不至于把大招憋成内伤,才有可能拿出一个高质量的软件。
|
||||
|
||||
在软件研发的环节加入质量的思考,对很多人来说,是一件有难度的事。因为在他们看来,这么做是增加了工作量。比如很多程序员会说“写测试就是浪费时间”。然而,真的是这样吗?
|
||||
|
||||
一个内建质量的团队,可以在工作的诸多环节规避掉很多问题。从软件生命周期的角度看,规避了这些问题可以从整体上节省时间。虽然很多人学过软件工程的基本理论,但这种东西实在太反直觉,就像不相信 0.99… = 1,也有很多人不相信前期的投入会给团队带来长期的回报。
|
||||
|
||||
但这种不相信其实更多是一种不愿意相信,因为相信了就意味着要做出改变,而改变才是很多人真正惧怕的。是的,很多人真正的不愿意是“改变”这件事。正是因为有太多的人不愿意改变,才使得愿意改变的人很容易脱颖而出。
|
||||
|
||||
一旦你想明白了这一点,你就能理解软件研发中暴露的很多错误根本不是无能之错,而是无知之错。换言之,正是因为很多人只愿意墨守成规,所以,他们根本看不到自己其实是欠缺了一个质量的维度,而这个维度上也有着一张知识网。
|
||||
|
||||
把无知之错当做无能之错去解决,根本就是走错了方向,再多的流程改进也不会让人学会写测试。因为程序员欠缺的是写测试的知识,而很少有人会意识到原来很多程序员不会写测试。解决无知之错要从知识的补充入手,而前提条件是你愿意改变。
|
||||
|
||||
简单的代码
|
||||
|
||||
感谢你一路学到这里,我相信你是愿意改变的。现在你已经具备了改变所需的基础知识,相比于还在努力改变流程的人,你已经领先了很多。对你来说,接下来要做的是花更多的时间来练习,并在练习的过程中发现自己欠缺的知识,进行相应的补充。
|
||||
|
||||
《程序员的测试课》虽然给了你一个写好测试的知识结构,但估计你也发现了,其实写好测试要具备的知识储备并不小,就像我在形容 TDD 所说:
|
||||
|
||||
|
||||
TDD 只是冰山一角,露在海面之上的是 TDD 的节奏,而藏在海面下的是任务分解、软件设计这些需要一定时间积累的能力。
|
||||
|
||||
|
||||
同样,写出来的测试也是冰山一角,背后是那些需要时间积累的能力。但是,不要被这些东西吓到。其实,你在实战中已经见识过了,我写的代码很简单。有人会认为,这是一个演示的例子,所以写出来会很简单。但实际上,我在真实项目中也是这么写代码的,只不过业务逻辑更复杂一些。是的,业务逻辑复杂和代码复杂是两回事。不管业务逻辑多复杂,代码都可以写得清晰而简洁。只有那些写得不好的代码才是复杂的,会有着各种各样奇怪的写法。
|
||||
|
||||
优秀的代码平平无奇,糟糕的代码千奇百怪。
|
||||
|
||||
随着经验的丰富,我越能理解简单的价值,能坚持把代码写简单是一种能力。这需要我们不仅要有把代码写简单的意识,还要有把代码写简单的能力。好消息是,简单的代码也是容易写测试的代码,无论后续添加新功能还是修改已有的问题,难度都会下降很多,所以,它也是高质量的代码。
|
||||
|
||||
你只要不断地用测试作为一把尺子衡量你写的代码,你的代码质量就会越来越高。当你不知道该怎么办时,不妨回到专栏里,看看我们在专栏里是怎样解决问题的:把问题还原到简单的情形,再去想办法解决。化繁为简,是一个优秀程序员应该具备的品质。
|
||||
|
||||
如果整个专栏你只能记住一件事,那请记住:写代码时问问自己,这段代码应该怎么测。
|
||||
|
||||
在这篇结束语的末尾,我来讲个小八卦,跟你说说这个专栏是怎么来的。我在自己的公众号“郑大晔校”中写了一篇文章《为什么程序员大多不写测试?》,说的就是程序员不会写测试这件事。在文章的最后,我说程序员没有太好的方式学习写测试。《极客时间》的主编看到这篇文章就说,既然没有,那你来写一个吧。于是,有了这个专栏,也算是我自己挖坑自己填了。
|
||||
|
||||
这个专栏从开始构思到上线只用了一个月的时间,创下了我写作专栏的记录。算起来,这已经是我在《极客时间》写的第四个专栏了,我能够梳理成体系的结构化思考都以专栏的形式呈现了。当然,如果你还对我零散思考有兴趣,不妨关注我的公众号“郑大晔校”。
|
||||
|
||||
这次的《程序员的测试课》之旅就暂告一段落了!如果以后有机会,我会再来与你分享我对软件开发的理解。
|
||||
|
||||
最后是我们的小福利环节,我给你留了一个有奖小问卷,希望你花两分钟来填写一下,你的反馈意见对我来说很重要,我会根据你的意见持续维护这个专栏。
|
||||
|
||||
再见!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
65
专栏/程序员进阶攻略/00开篇词程序行知:走在同样的路上,遇见自己的风景.md
Normal file
65
专栏/程序员进阶攻略/00开篇词程序行知:走在同样的路上,遇见自己的风景.md
Normal file
@ -0,0 +1,65 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 程序行知:走在同样的路上,遇见自己的风景
|
||||
你好,我是胡峰,从毕业到今天,在程序这条道路上已经走了十多年了,前期在金融、电信行业写写程序,最近七年在互联网行业从事电商应用相关系统的技术工作,也一路从程序员成长为了一名架构师。
|
||||
|
||||
今天这个时代,对于程序员来说是一个特别幸运的时代。每一个有追求的程序员都希望能获得快速的成长,但成长的道路没那么平坦和舒适,一路上充满了崎岖、障碍和迷雾。
|
||||
|
||||
同样,在我成长的道路上,我也走过很多弯路(更详细的故事,在后面的文章中会分享),这一路上我也有过迷茫,有过困惑。
|
||||
|
||||
如今,经历了这一路,当我回顾时,看见了一条渐渐清晰的成长路线。而幸运的是,在我行路的过程中,从七年前开始,我断断续续通过写作记录了这中间的所见、所感、所惑与所思。今年,我一直想找个时间去好好回顾并梳理下这条路线,而在一个适当的时机,遇到了 “极客时间”,所以这就给了我契机以专栏的形式来完成这件事。
|
||||
|
||||
在做这个专栏的过程中,我确信,一方面通过适时地驻足、回顾与梳理,它能帮助自己更好地认识到:我是如何从昨天走到今天的,并指导自己走向更好的明天。另一方面,程序(IT)行业还在高速发展,走在这条路上的人越来越多,而我对自身成长路径的反思与认知,想必也可以帮助到很多走在同样路上的人。
|
||||
|
||||
如今看,在这条路上我起步不算晚,但“永远有走在你前面的人”,当年他们留下的 “脚印” 和路径也给予了我很多的启发与指引。所以,这个专栏的意义在于,它提供了另一种可供参考的路标,正如题中所言:“走在同样的路上,遇见自己的风景。”
|
||||
|
||||
所以,这是一个关于程序员成长路径的专栏,它会围绕程序这个行业、程序员这个职业,画出一条清晰的成长路径。在这条路径上,有不同的成长阶段,会面临各种不同的问题与困惑。我会结合自身成长路径上面临的实际问题、设身处地去思索、分析、拆解这些问题,并给出可供参考的答案。
|
||||
|
||||
因为这是一个关于路径与行路的专栏,所以也就否定了另一面:它不会提供某一类具体的知识,并且由浅入深地去指导学习。
|
||||
|
||||
后面这一类知识,我称之为 “技能性知识”,需要你在日常的学习和工作中勤学苦练,练成之后你就会成为某一类问题的 “解答题高手”。
|
||||
|
||||
但前一类是关于路径选择和自我认知的知识,它能让你在成长的不同阶段重新认识自己,因为 “知” 从而改变你的 “行”。有时选择对了合适的路,比光顾着赶路要重要得多。
|
||||
|
||||
在这条成长的路径上,有期待、有坚持、有故事,也会有迷茫,以及最后穿越迷雾的曙光。而这个专栏的内容正是关于成长路径的,这条路径在我脑海里已形成了清晰的画面,现在我就把它画了出来,如下图:
|
||||
|
||||
|
||||
|
||||
这是一条成长线的表意图,有两个部分:图上左侧的路径,是匹配不同成长阶段,对应不同职业角色;右侧是一条由不同成长阶段组成的成长线,包括如下:
|
||||
|
||||
|
||||
征途:启程之初
|
||||
修炼:程序之术
|
||||
修行:由术入道
|
||||
徘徊:道中彷徨
|
||||
寻路:路在何方
|
||||
蜕变:破茧成蝶
|
||||
|
||||
|
||||
“启程之初”,是你刚踏上程序之路面临的一些问题和感悟。“程序之术”,是你工作早期的主要内容,以修炼编程技能为主。除了编程写代码,还有很多其他的内容,这是另外一个维度的修行之路,也即 “由术入道”。
|
||||
|
||||
工作数年,成长到一定阶段,你可能会面临一个成长平台期的困扰,在此就进入了 “道中彷徨” 的徘徊期。这些困扰和彷徨很多都关乎选择,这期间是你发出 “路在何方” 之问的寻路期。最后,你坚定了道路,继续前行,前面的路上还有一道 “断层”,突破之后你将会蜕变,最终 “破茧成蝶”。
|
||||
|
||||
而此次专栏正是围绕这6个不同的阶段所写(详细目录如下图所示),为你的成长排忧解难。同时,为了保证内容的连贯性,我会把之前的几篇旧文稍作整理之后作为福利放到这里,你可以免费阅读。
|
||||
|
||||
|
||||
|
||||
最后,你将会收获什么?我想会有如下一些方面:
|
||||
|
||||
|
||||
建立技术学习的体系框架与思维模型
|
||||
梳理清晰的成长与进阶路线
|
||||
扫清成长路上的迷茫与障碍
|
||||
形成明确的自我定位与认知
|
||||
|
||||
|
||||
它也许会是一扇观察的窗口,一张行路的地图,一瓶回血的苦药,一份认知的启发。其始于 “知”,需终于 “行”,在行走的道上,会有崎岖与气馁,希望能在这里,帮你找到未来的方向,给予指引;找到有效的方法,破除障碍;找到理想的自我,获得力量。
|
||||
|
||||
前路很长,而专栏的时间很短,希望我们有缘一起走上一程:走在同样成长的路上,遇见自己憧憬的风景。
|
||||
|
||||
|
||||
|
||||
|
89
专栏/程序员进阶攻略/01初心:为什么成为一名程序员?.md
Normal file
89
专栏/程序员进阶攻略/01初心:为什么成为一名程序员?.md
Normal file
@ -0,0 +1,89 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 初心:为什么成为一名程序员?
|
||||
在走上程序的道路前,你不妨先问问自己成为程序员的初心是什么。回首往昔,我似乎是阴差阳错地走上了这条路,正因初心未明,所以早期的路上就多了很多迟疑和曲折。
|
||||
|
||||
人生路漫漫,在专栏的第一个模块里,我会和你讲讲自己走上程序道路的故事,希望这些故事能够给迷茫或者奋进中的你以启发。在人生的不同阶段里,我都喜欢做“复盘”,一方面审视过去的自己,另外一方面思索未来的方向。现在看来,这些有节奏的复盘也给我自己留下了深深的印记,也让我在某些关键节点的决策更加坚决。
|
||||
|
||||
首次接触
|
||||
|
||||
说起我和程序的渊源,大概可以回溯到二十多年前。
|
||||
|
||||
那时,我还在读初中二年级,那是四川一所少数民族中学,硬件条件不是太好。那是1995年,国际友人赞助赠送了学校几台苹果 II 代电脑。作为学校成绩名列前茅的学生,在比较重视分数排名的背景下我还算有点 “小特权”。这点“小特权”就是可以接触这批电脑,所以在那时我为了搞懂怎么 “玩” 这些电脑去学了下 BASIC 语言,然后在上面编程并在单调的绿色屏幕上画出了一些几何图形。
|
||||
|
||||
当时还挺有成就感的,一度畅想将来要考清华的计算机专业。可能,那时觉得清华就是最好的学校,而计算机和编程是当时的最爱。然而,实际情况是上了高中以后学习压力大增,再也没时间去 “玩” 这些电脑了,光应对考试已然应接不暇,渐渐就忘了初中那时的想法。
|
||||
|
||||
现在回想起来第一次接触程序的时候,感觉它们还算是好 “玩” 的,有一种智力上的挑战,但当时也不太可能想到十年后我将会以此为生,走上程序之路。
|
||||
|
||||
彼时,初心未有。
|
||||
|
||||
选择专业
|
||||
|
||||
对我们80后这一代人,高考算是人生第一次重要的选择了吧。
|
||||
|
||||
我那时高考填志愿,都是在考试前进行。高中三年,我都再没接触过程序了,早已忘记当年的想法。高考前,当时觉得自己对物理最有兴趣就填报了南京大学的物理系,应该也考虑过清华什么的,但没什么信心。
|
||||
|
||||
关于兴趣有一个有趣的说法:“往往并不是有兴趣才能做好,而是做好了才有兴趣。”高中后可能觉得当时物理学得还不错,所以就有了兴趣,并在填报高考志愿时选择了物理专业。
|
||||
|
||||
后来高考的结果,一方面信心不是很足,另一方面单科数学发挥也失常。南大的物理系没能上成,落到了第二志愿东北大学,调剂成了机械工程专业。这是一个随机调剂的专业,着实让我无比苦闷,学了一年后,我非常清楚,我并不喜欢这个专业,也看不清未来的职业前景。
|
||||
|
||||
再回首时你总会发现,有些最重要的人生路径选择,就这么有点 “无厘头” 地完成了。在面临人生重要路径的选择时,当时只考虑了兴趣,如今看来也没那么靠谱。应该多听听众人的看法,参考前人的路径,最后再自己做出决定。人生路径还是需要自己来主动、有意识地掌舵的。
|
||||
|
||||
彼时,初心已有,但却是混乱的。
|
||||
|
||||
转换专业
|
||||
|
||||
机械专业煎熬了两年,迎来了第二次选择专业的机会。
|
||||
|
||||
在我读完大二的时候,国家开始兴办软件学院,新开软件工程专业。我当时在机械专业也会学一门编程课:C 语言。那时对 C 语言比较感兴趣,而对专业课机械制图则完全无感,所以当机会出现时就义无反顾去转了专业。
|
||||
|
||||
新专业面向所有非计算机的工程专业招生,但有一个门槛是:高学费。当时,机械专业一年学费四千多点,而软件工程本科一年一万六,学费读一年就抵得上别人读四年了,这对一个工薪家庭的压力不算小。
|
||||
|
||||
总之,我就是这么阴差阳错地又绕到了计算机专业这条路上。作为一门新开专业,软件工程相对计算机专业更偏应用,对接企业用人需求。可见,当时(2002 年)整个 IT 行业已经面临人才缺乏的问题,国家之所以新开软件工程专业,恐怕也是经济规律在发挥作用,平衡供需两端。
|
||||
|
||||
于我而言,转换专业算是时代给予的机遇,我在懵懂中做出了一次正确的选择。当时并不明了,但如今回顾却是如此清晰:面对新开的软件工程专业,其实表明了一个信息,这个行业发展很快,前景很好。
|
||||
|
||||
人生路很长,走了一段,也需要时不时重新审视当前的路径是否适合,是否无意错过了前途更好的岔路口。
|
||||
|
||||
我如今会感到庆幸没有错过这个路口,当时的确是没想过从机械专业换到软件工程会有更好的发展前景,但就是这样,我绕绕弯弯、曲曲折折地入了行,成为了一名程序员。
|
||||
|
||||
彼时,初心虽已不乱,但依然未明。
|
||||
|
||||
转换行业
|
||||
|
||||
人的一生面临很多重要选择,除了高考选专业,我想转行也是其中之一。
|
||||
|
||||
入行后,一路走来也碰到过很多从其他行业转行成为程序员的人。曾经在招聘面试时碰到过两个程序员,他们一个是毕业于中医药大学,在药房工作两年后转行并干了 3 年;另外一个主修环境工程专业,在该行业工作 9 年后才转行程序员,并在这行干了 5 年。
|
||||
|
||||
那时我就在想,为什么他们都要转行做一名程序员呢?也许,客观上来说,行业的景气度让程序员的薪酬水平水涨船高。需求的持续上涨,吸引着更多的人进入,这也是经济规律。但主观上来说,可能我们也没有想好为什么就要转行成为一名程序员。
|
||||
|
||||
我转换到软件工程专业,毕业后顺利进入程序这行。早期一开始就是为一些传统行业公司写企业应用程序,提供 IT 服务,完成一份合同。工作五年后,我才渐渐明白,同样写程序,但为不同的行业写的程序价值真是完全不同。因此,我选择了切换到电商互联网行业来写程序。
|
||||
|
||||
而这一次的选择我很确定的是,至少我模糊地看到了这条路的前景,并坚定地在众多选项中排除其他路径。转行,不同的跨度,代价或大或小。但不转变就没代价吗?不见得,因为有时不做选择的代价可能更大。
|
||||
|
||||
此时,初心才算渐渐明了。
|
||||
|
||||
心明行远
|
||||
|
||||
在成长的路上,我先后经历了换专业、换城市、换行业。
|
||||
|
||||
去年底(2017)我适时地驻足回顾了一下从进入大学到如今这些年的学习、工作和成长经历。其中有一些重要的时间事件节点,我把它们连接起来,就成了我们大多数人的成长线。下图,是我过去18年的成长线:
|
||||
|
||||
|
||||
|
||||
在这张图上,选专业、换专业、换城市、换行业,这几个重要的人生选择点,我都用红色字体标记了。把过往的18年浓缩到一张图上后,我就清晰地看出了趋势,在切换行业之前,初心未明,成长的路上起起伏伏,波动很大,也因为不成熟的选择带来过巨大的落差感。
|
||||
|
||||
在工作的前几年,图上也有一段快速的自然成长期。因为这时我们就像一张白纸,只要是在认真地做事儿,总是能成长。这段时期,心其实是乱的,但因为忙而充实,也获得了很多成长,但它的问题是:这样的自然成长期有多长取决于你所做事情的天花板,所以才有了后面的一次切换城市带来的落差。
|
||||
|
||||
切换了行业,一路走到现在,前路不尽,心已明,行将远。
|
||||
|
||||
为什么成为一名程序员,初心若何?有人有天赋,有人凭兴趣,有人看前景。也许,你上路之初还未曾明了,但在路上不时叩问内心,找到初心,会走得更坚定,更长远。
|
||||
|
||||
闭上眼睛,你可以试着问自己走上程序道路的初心是否已经明了呢?欢迎给我留言,我们一起分享和讨论。
|
||||
|
||||
|
||||
|
||||
|
91
专栏/程序员进阶攻略/02初惑:技术方向的选择.md
Normal file
91
专栏/程序员进阶攻略/02初惑:技术方向的选择.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 初惑:技术方向的选择
|
||||
初入职场或还在校的同学想必都会有些共同的疑惑,比如:“到底我该选哪个技术方向?”“现在该学哪门语言?”“未来 Java 语言的发展趋势如何?”这些问题的本质其实都是技术的投资决策问题,也即现在我该把时间精力花在哪个方向上,未来的收益才可能最大。
|
||||
|
||||
这个问题并不好回答,因为这个问题的本质和 “我现在应该投资哪只股票” 一样。没有人能回答好这个问题,我觉得最好的做法就是:从投资的出发点而非终点来选择一条路径。
|
||||
|
||||
至于这样选择的路径是否能在未来获得很好的收益,这是没法预测的。但选择技术方向和选择股票不同的是,只要你在这条路径上持续努力、学习与进步,基本可以保证能和 “大盘” 持平而不至于有亏损,但是否能取得超过 “大盘” 的收益,其实是看运气的。
|
||||
|
||||
选择语言
|
||||
|
||||
选择技术方向,从某种意义上讲就是选择语言。
|
||||
|
||||
虽然有一些流传的说法,类似于:“语言并不重要,必要的时候可以在各种语言间自由切换。”但实际情况是,能做到自由切换的前提是你得对一门语言掌握到通透之后,再学习其他语言才可能触类旁通。
|
||||
|
||||
计算机程序语言虽然很多,但种类其实有限。2018 TIOBE 程序语言排行榜(见下图)上的前三位(Java、C、C++),本质上其实是一类语言。但大部分人只能选择去熟悉并通透其中一种,因为这些语言背后都有庞大的生态圈。
|
||||
|
||||
|
||||
|
||||
2018 TIOBE 程序语言排行榜
|
||||
|
||||
要做到通透,只熟悉语言本身是远远不够的,其实是要熟悉整个生态圈。而三门语言中最年轻的 Java 都有二十多年历史了,足够你耗费数年时光去熟悉其整个生态圈,而且目前其生态圈还处在不断扩张的状态,展现出一种蓬勃的生命力。
|
||||
|
||||
那么,要是我来选,我会如何选择语言呢?我会选择那些展现出蓬勃生命力的语言。
|
||||
|
||||
但其实十多年前我只是凑巧选择了 Java,它就像是被潮水推到我脚边的漂流瓶,顺手捡了起来。没想到居然蓬勃地发展了十多年,还没见衰退迹象。
|
||||
|
||||
那时的 Java 刚诞生不过七八年,和今天的 Go 语言很像。Go 语言在排行榜上的位置蹿升得很快,而且在云计算时代的基础设施上大放异彩,号称是:易用性要超越 PHP,而性能要超越 Java。
|
||||
|
||||
那么在 Java 之前我学的是什么?是 Visual Basic、ASP 和 Delphi / Object Pascal。我想今天不少年轻的程序员都未必听过这些语言了。但神奇的是,在 TIOBE 的排行榜上,VB 加了个 .NET 排名竟在世界最广泛的 Web 语言 PHP 和JavaScript 之上。而十五年前我用的 Delphi / Object Pascal 居然落后 JavaScript 也不远,且远高于 Go、Objective-C,力压 Swift。
|
||||
|
||||
这些老牌语言还值得学吗?当然不值得了。因为它们早已进入暮年,没了蓬勃的生命力。但为什么排名还这么高?也许是因为它们也曾有过蓬勃生命力的热血青春,留下了大量的软件系统和程序遗产,至今还没能退出历史的舞台吧。
|
||||
|
||||
美国作家纳西姆·塔勒布(《黑天鹅》《反脆弱》等书作者)曾说:
|
||||
|
||||
|
||||
信息或者想法的预期寿命,和它的现有寿命成正比。
|
||||
|
||||
|
||||
而编程语言以及由它编写的所有软件系统和程序,本质就是信息了。换句话说就是,如果你想预测一门语言还会存在多久,就看看它已经存在了多久。存活时间足够长的语言,可以预期,它未来也还可能存活这么长时间。当然这一论断并不绝对,但它更多想说明越是新的语言或技术,升级换代越快,也越容易被取代。
|
||||
|
||||
这一点在 Delphi 这门语言上已经得到了体现,进入二十一世纪后,这种编写 C/S 架构软件的语言,居然还存活了这么久。
|
||||
|
||||
选择回报
|
||||
|
||||
选择技术方向,选择语言,本质都是一种投资。
|
||||
|
||||
我们为此感到焦虑的原因在于,技术变化那么快,就怕自己选了一个方向,投了几年的时间、精力,最后却被技术迭代的浪潮拍在了沙滩上。
|
||||
|
||||
按上面塔勒布的说法,越年轻的语言和方向,风险越高。一个今年刚出现的新方向、新语言,你怎么知道它能在明年幸存下来?所以,考虑确定性的回报和更低的风险,你应该选择有一定历史的方向或语言,也许不能带来超额的回报,但最起码能带来稳定的回报,让你先在这个行业里立稳脚跟。在此基础上,再去关注新潮流、新方向或新技术,观察它们的可持续性。
|
||||
|
||||
有一句投资箴言:“高风险未必带来高回报。”在选择职业方向的路上,你甚至没法像分散投资一样来控制风险,所以选择确定性的回报,要比抱着赌一把的心态更可取。看看当前的市场需求是什么,最需要什么,以及长期需要什么。
|
||||
|
||||
比如,今天技术的热潮在人工智能、机器学习、区块链等上面,这是市场最需要的,而市场给的价格也是最高的。所以,你应该投入这里么?先别头脑发热,看看自己的基础,能否翻越门槛,及时上得了车吗?
|
||||
|
||||
世纪之初,互联网时代的到临,网络的爆发,你会写个 HTML 就能月薪上万。上万,似乎不多,但那时北京房价均价也才 5000 多啊。2010 年左右,移动互联网兴起,一年移动开发经验者的平均待遇达到了五到十年 Java 开发的水平。如今,你只会 HTML 基本找不到工作,你有五年移动开发经验和有五年 Java 开发经验的同学,薪资待遇也变得相差不多了。
|
||||
|
||||
关于技术,有一句流行的话:“技术总是短期被高估,但长期被低估。”今天,在人工智能领域获得超额回报的顶级专家,实际数十年前在其被低估时就进入了这个领域,数十年的持续投入,才在如今迎来了人工智能的 “牛市” ,有了所谓的超额回报。所以,不妨投入到一些可能在长期被低估的基础技术上,而不是被技术潮流的短期波动所左右。
|
||||
|
||||
技术的选择,都是赚取长期回报,短期的波动放在长期来看终将被抵消掉,成为时代的一朵小浪花。
|
||||
|
||||
选择行业
|
||||
|
||||
搞清楚了语言、技术方向和回报的关系后,最后做出选择的立足点通常会落在行业上。
|
||||
|
||||
当你问别人该选什么语言时,有人会告诉你,你应该学习 JavaScript,因为这是互联网 Web 时代的通用语言,到了移动互联网时代依然通用,而且现阶段生命力旺盛得就像再年轻十岁的 Java。也有人告诉你也许从 Python 开始更合适,语法简单,上手容易。还有人告诉你,现在学 Java 找工作最容易,平均工资也蛮高。这各种各样的说法充斥在你的耳边,让你犹豫不决,左右为难。
|
||||
|
||||
一个问题就像一把锁,开锁的钥匙肯定不会在锁上。否则这个问题也就不是问题了,太容易就解开了,不是吗?所以,选择什么语言通常不在于语言本身的特性。
|
||||
|
||||
选语言,就是选职业,而选职业首先选行业。
|
||||
|
||||
先想想自己想从事哪个行业的软件开发;然后,再看看:这个行业的现状如何?行业的平均增速如何?和其他行业相比如何?这个行业里最好的公司相比行业平均增速又如何?最后,再看看这些最好的公司都用些什么样的技术栈和语言。如果你想进入这样的公司,那就很简单了,就选择学这样的技术和语言。
|
||||
|
||||
这样选择是不是太功利了?选择不是应该看兴趣么?注意,这里选择的前提可不是发展什么业余爱好,而是为了获得安身立命的本领,获得竞争的相对优势。而兴趣,就是这件事里有些吸引你的东西,让你觉这是 “很好玩” 的事。但有个通常的说法是:“一旦把兴趣变成了职业也就失去了兴趣。”因为,职业里面还有很多 “不好玩” 的事。
|
||||
|
||||
兴趣能轻松驱动你做到前 50%,但按二八原则,要进入前 20%的高手领域,仅仅靠兴趣就不够了。兴趣给你的奖励是 “好玩”,但继续往前走就会遇到很多 “不好玩” 的事,这是一种前进的障碍,这时功利,也算是给予你越过障碍所经历痛苦的补偿吧。
|
||||
|
||||
以上,就是我关于技术方向选择的一些原则与方法。无论你当初是如何选择走上技术道路的,都可以再想想你为什么要选择学习一门编程语言,学习编程的一部分是学习语言的语法结构,但更大的一部分,同时也是耗时更久且更让你头痛的部分:学习如何像一个工程师一样解决问题。
|
||||
|
||||
有时这样的选择确实很难,因为我们缺乏足够的信息来做出最优选择。赫伯特·西蒙说:“当你无法获得决策所需的所有信息时,不要追求最优决策,而要追求满意决策。”定下自己的满意标准,找到一个符合满意标准的折中方案,就开始行动吧。
|
||||
|
||||
而停留在原地纠结,什么也不会改变。
|
||||
|
||||
最后,亲爱的读者朋友,你当初是如何选择技术道路的呢?欢迎留言分享。-
|
||||
|
||||
|
||||
|
||||
|
151
专栏/程序员进阶攻略/03初程:带上一份技能地图.md
Normal file
151
专栏/程序员进阶攻略/03初程:带上一份技能地图.md
Normal file
@ -0,0 +1,151 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 初程:带上一份技能地图
|
||||
程序世界是一片广阔的大地,相比我十多年前进入这个世界时,这片大地的边界又扩大了很多倍。初入程序世界难免迷茫,要在这个世界立足、生存,并得到很好的发展,应首要具备怎样的技能呢?未来的程序之路,先给自己准备一份基本的技能地图,先有图,再上路。
|
||||
|
||||
在程序的技能地图中,需要先开启和点亮哪些部分呢?回顾我过去的经历并结合现实的需要,可以从如下两个不同程度的维度来说明:
|
||||
|
||||
|
||||
掌握
|
||||
了解
|
||||
|
||||
|
||||
掌握,意味着是一开始就要求熟练掌握的硬技能,这是生存之本。而至于掌握的深度,是动态的,倒是可以在行进过程中不断去迭代加深。了解,相对掌握不是必需,但也需要达到知其然的程度,甚至知其所以然更好。
|
||||
|
||||
一、掌握
|
||||
|
||||
上路之初,需要掌握的核心生存技能有哪些呢?
|
||||
|
||||
1.开发平台
|
||||
|
||||
开发平台,它包括一种编程语言、附带的平台生态及相关的技术。在如今这个专业化分工越来越细的时代,开发平台决定了你会成为什么类型和方向的程序员。比如:服务端、客户端或前端开发等。其中进一步细分客户端还可以有 Windows、Mac、iOS 和 Android 等不同的平台。
|
||||
|
||||
编程语言
|
||||
|
||||
语言的选择基本决定了开发平台的性质,但有些语言可能例外,如:C++、JS、C# 等,这些语言都可以跨多个平台。但即使你选的是这些语言,基本也会归属到某一类平台上。好比你选了 C++,如果你去做了客户端开发,就很少可能再去用 C++ 写服务端程序了。
|
||||
|
||||
关于语言的选择,前面我已经写过了选择的逻辑,便不再多说。但选择了语言,我们不仅仅是熟悉语言自身的特性,还需要掌握支撑语言的平台库。Java 若仅从语言特性上来说,有其优点,但其瑕疵和缺陷也一直被吐槽,要是没有 JDK 强大的平台库支撑,想必也不会有今天的繁荣。
|
||||
|
||||
平台生态
|
||||
|
||||
与语言平台关联的还有其技术生态以及各种技术框架的繁荣程度。这些平台技术生态的存在让使用这门语言编程完成特定的任务变得容易和简单得多。Java 的生命力除了 JDK 的强大支撑,实际还有其平台生态的繁荣,也起了决定性的作用。
|
||||
|
||||
在选择了开发平台后,除了语言和平台库之外,其生态体系内主流的技术框架和解决方案也是必选的掌握内容。
|
||||
|
||||
2.常用算法
|
||||
|
||||
在学校学习的算法,基本是解决各种计算机科学问题的通用方法。
|
||||
|
||||
还记得在学校时看过一本算法经典书《算法导论》。刚又把这本书的目录翻了出来过了一遍,发现已经忘记了百分之七、八十的内容。因为忘记的这部分内容,在过去的十多年工作中我基本都没机会用上。那么掌握算法的目的是为了什么呢?
|
||||
|
||||
有时候你可能会觉得学校教科书上学习的经典算法,在实际工作中根本就用不上。我还记得考研的时候,专业考试课就是算法与数据结构,在考卷上随手写个排序、树遍历手到擒来。但到研究生毕业去参加腾讯校招面试时,让在白纸上手写一个快排算法,我被卡住了,自然也就没通过。因为好久已经没有进行这样的练习了,而在研究生阶段一年期的公司实习工作场景也没有这样的需求。
|
||||
|
||||
那么为什么还要学习这些经典算法?
|
||||
|
||||
算法,表达的是一个计算的动态过程,它引入了一个度量标准:时空复杂度。当我回思时,发现这个度量标准思维在工作十余年中一直在发挥作用。如今,几乎所有的经典算法都能在开发平台库里找到实现,不会再需要自己从头写。但结合工作实际的业务场景,我们需要去设计更贴合需求的算法,而只要是算法它都受到时空复杂度的约束,而我们只是在其中进行平衡与折衷。
|
||||
|
||||
学校教科书的经典算法,是剥离了业务场景的高度抽象,当时学来有种不知道用在哪里的感觉;如今回头结合真实的业务场景需求再看,会有一种恍然大悟之感。
|
||||
|
||||
3.数据结构
|
||||
|
||||
数据结构通常都和算法一起出现,但算法表达的是动态特性,而数据结构表达的是一种静态的结构特性。大部分开发平台库都提供了最基础和常用的数据结构实现,这些都是我们需要熟悉并掌握的,包括:
|
||||
|
||||
|
||||
数组 Array
|
||||
链表 Linked List
|
||||
队列 Queues
|
||||
堆栈 Stacks
|
||||
散列 Hashes
|
||||
集合 Sets
|
||||
|
||||
|
||||
另外,还有两种数据结构不属于基础结构,但在现实中有非常广泛的直接映射场景。
|
||||
|
||||
|
||||
树 Trees
|
||||
图 Graphs
|
||||
|
||||
|
||||
每种结构都有各种变体,适用于不同的场景,甚至很多时候你还需要会组合不同的结构去解决一些更复杂的问题。
|
||||
|
||||
二、了解
|
||||
|
||||
需要了解的内容比需要掌握的更广泛,但了解了这些方面会让你更高效地协作并解决问题。
|
||||
|
||||
1.数据存储
|
||||
|
||||
不管你写什么样的程序系统,估计都离不开数据存储。数据是一个业务系统的核心价值所在,所以怎么存储不同类型的生产数据,是你必须要了解的。如今广泛流行的数据存储系统有下面三类:
|
||||
|
||||
|
||||
SQL 关系型数据库(如:MySQL、Oracle)
|
||||
NoSQL 非关系型数据库(如:HBase、MongoDB)
|
||||
Cache 缓存(如:Redis、Memcached)
|
||||
|
||||
|
||||
每一种数据存储系统都有其特定的特性和应用场景。作为程序员,我们通常的需求就是最有效地用好各类数据存储,那么按了解的深度需要依次知道如下几点:
|
||||
|
||||
|
||||
如何用?在什么场景下,用什么数据存储的什么特性?
|
||||
它们是如何工作的?
|
||||
如何优化你的使用方式?
|
||||
它们的量化指标,并能够进行量化分析?
|
||||
|
||||
|
||||
这 4 点虽不要求一开始就能掌握到一定程度,但你最好一开始就有这个层次思维,在日后的工作中不断去迭代它的深度。
|
||||
|
||||
2.测试方法
|
||||
|
||||
为什么我们做开发还需要了解测试?
|
||||
|
||||
测试思维是一种与开发完全不同的思维模式。有一种流行的开发方法论叫 “测试驱动开发(TDD)”,它的流行不是没有道理的。在写代码的时候,用测试的思维与方式(提供单元测试)去审视和检测代码,也就是说明确要开发某个功能后,先思考如何对这个功能进行测试,并完成测试代码的编写,然后编写相关的代码满足这些测试用例。
|
||||
|
||||
开发与测试这两种相反视角的切入维度,能真正长期地提高你写代码的效率和水平。
|
||||
|
||||
3.工程规范
|
||||
|
||||
每一种开发平台和语言,估计都有其相应约定俗成的一些工程规范要求。最基础的工程规范是代码规范,包括两个方面:
|
||||
|
||||
|
||||
代码结构
|
||||
代码风格
|
||||
|
||||
|
||||
像 Java 这么多年下来,逐渐形成了一种基于 Maven 的代码组织结构规范,这种约定俗成的代码结构规范省却了很多没必要的沟通。有时候,同样的内容,有更规范的结构,其可阅读性、理解性就能得到提升。
|
||||
|
||||
而至于代码风格,相对没那么标准化。但为了写出更清晰、易读的代码,我们至少要坚持自己写的代码具有某种一致性的风格。另外,除了风格问题,也可以借助静态代码检查工具来规避一些新手爱犯的低级错误,而老手也可以通过这些工具来找到自己的认知与习惯盲点。
|
||||
|
||||
4.开发流程
|
||||
|
||||
在开发流程方法论上,敏捷基本已经横扫天下,所以我们至少要了解下敏捷开发方法论。
|
||||
|
||||
虽然敏捷方法论定义了一些参考实践,但它依然是一组非常松散的概念。每个实践敏捷的开发团队,估计都会根据自己的理解和摸索建立一整套逐渐约定成型的开发流程规范。而为了和团队其他成员更好地协作,估计每个新加入团队的成员都需要了解团队演进形成的开发流程规范。
|
||||
|
||||
先了解,再优化。
|
||||
|
||||
5.源码管理
|
||||
|
||||
既然我们生产代码,自然也需要了解如何管理好代码。
|
||||
|
||||
在我的从业经历中,源码管理工具经历了从 CVS 到 SVN 再到 Git 的变迁。Git 诞生的背景是为 Linux 这样超大规模的开源项目准备的,自然决定了其能应对各种复杂场景的源码管理需求。所以,你至少要了解 Git,并用好它。
|
||||
|
||||
当工具变得越来越强大时,工具背后的思想其实更重要,对其的理解决定了我们应用工具的模式。而对源码进行管理的最基本诉求有以下三点:
|
||||
|
||||
|
||||
并行:以支持多特性,多人的并行开发
|
||||
协作:以协调多人对同一份代码的编写
|
||||
版本:以支持不同历史的代码版本切换
|
||||
|
||||
|
||||
最后,我把以上内容总结为如下一张图:红色区域相对更小而聚焦,是需要掌握的部分,要求深度;蓝色区域的部分更广而泛,需要广度。-
|
||||
|
||||
|
||||
程序员的基础技能图
|
||||
|
||||
以上就是我回顾走过的路径后,觉得需要具备的一份基础技能图。十多年后,这张图上的每一个分类都出现了新的技术迭代,有了新的框架、算法和产品等,但它们并不过时,依然可以为你的技能点亮之路提供方向指引。也许,你程序生涯的第一个一万小时你就会花在这张图上了。-
|
||||
|
||||
|
||||
|
||||
|
121
专栏/程序员进阶攻略/04初感:别了校园,入了江湖.md
Normal file
121
专栏/程序员进阶攻略/04初感:别了校园,入了江湖.md
Normal file
@ -0,0 +1,121 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 初感:别了校园,入了江湖
|
||||
每年七月,盛夏,一种火辣兼有闷润的热,但在这份热辣中也有一丝略显冰凉的愁绪。一批刚毕业的学生,将要告别校园,进入职场:一个新的江湖。
|
||||
|
||||
一到毕业季,就会经常看到一些转发给新入行程序员的“老司机”指南,老实说,这些指南不少都是金玉良言。当年我毕业新入行时可不像现在有这么多发蒙解惑的“老司机”指南,所以坑都没少踩,若说有什么坑没掉进去,那一定都是因为运气。
|
||||
|
||||
当毕业生们看到前路如此多坑时,其实也不必有太大的心理压力,毕竟成长之路不可能是轻松的。我也是这样一路走过来的。所以,这篇就不写关于坑的指南了,而是分享一些我的故事和感悟,给刚踏上征程的你或你们吧。
|
||||
|
||||
重剑无锋
|
||||
|
||||
作为一名新入职的程序员,首要之事就是配备一台电脑。
|
||||
|
||||
这个时代基本都是标配笔记本了,近年公司给配的电脑都很不错了,程序员全是高配大屏的 Macbook Pro 了。遥想我第一份工作,领到的是一个二手华硕笔记本,应该是上一个离职的前辈用了好几年的,这也是我的第一个笔记本电脑。
|
||||
|
||||
程序员就应该配笔记本电脑,为什么必须是笔记本电脑?不可以是台式机吗?笔记本电脑之于程序员,就像剑之于剑客。剑客的剑是不应该离开身边的,稍有风吹草动,听风辨器,拔剑出鞘(程序员一声不发就掏出笔记本开始写代码)。
|
||||
|
||||
当招程序员时,若来者不问公司配备什么笔记本,一般也就属于大多数的普通程序员啦,不太可能是那种 “不滞于物,草木竹石均可为剑” 的独孤求败级高手。
|
||||
|
||||
但也会有少数人问公司配什么笔记本电脑的,当对公司提供的笔记本感觉不满意时,就会要求是否可以自备笔记本电脑,而由公司给予补贴。
|
||||
|
||||
后一类同学,对环境、工具和效率是敏感的,我觉着就会比前面那类要强,当然我没有具体统计数据,也是纯凭感觉。
|
||||
|
||||
我毕业那年,腾讯来学校招聘,本科年薪六万,硕士八万,博士十万,都是税前。那时我心中最好的笔记本应该还是 IBM 的 ThinkPad T 系列,最差也得 10000+ 起的价格吧。但现在年薪十万在一线的北上广深算相当普遍了吧?而笔记本还是一万,所以能买到很不错的笔记本已经不是什么难事了。若现在的公司再在程序员的 “剑” 上琢磨省钱那绝对是得不偿失了。
|
||||
|
||||
我的第一个二手华硕笔记本相比现在的超薄轻快的笔记本,那绝对算是相反的厚重慢了。所以我把它称为 “重剑”,和独孤前辈不同的是,他老人家是先用轻剑,再用重剑,而我是先用重剑,然后越用越轻了。
|
||||
|
||||
但只是一年后我换了公司,这把 “重剑” 就还了回去。到了第二家公司,入职后才发现公司不配笔记本电脑,全是台式机。你看,当年我就是那种没问公司配什么电脑的普通程序员,考虑那时确实台式机占据主流,并且笔记本还属于一般程序员至少要两三个月工资才能买得起一台趁手的奢侈品范畴,我也就忍了。
|
||||
|
||||
新入职没多久,熟悉了公司环境和老同事交接过来的一个旧系统,公司派我出差去客户现场开发调试。我满心以为出差至少得配个笔记本吧,但组长过来说我们这边出差都带台式机的。然后我看看组长一脸正气不带开玩笑的表情,再看看桌上台式机硕大的机箱和 17 寸的 CRT 显示器,瞠目结舌。
|
||||
|
||||
“显示器也要带去?” 我问。-
|
||||
“噢,显示器可以让公司 IT 部给你寄过去,但主机箱要自带,因为快递很容易弄坏。” 组长说。-
|
||||
“好吧…”
|
||||
|
||||
对话结束,我立马奔去广州太平洋电脑城,花了之前一年工作攒下来的一万块中的七千,买了一台只有 ThinkPad T 系价位零头的 R 系笔记本,之后这把 “剑” 陪伴了我五年。
|
||||
|
||||
初入职场的同学,既然选择了某个公司,最大的因素除了薪酬外,想必每个人都还有这样或那样的在意点。当年我最不满意的就是笔记本这个问题,但从工作第二年开始我一直都是自备笔记本工作,持续多年,没花多少钱,但少了很多折腾和不便。
|
||||
|
||||
再后来,我挣得稍微多了些,就又自己换了大内存加固态硬盘(SSD)的 Mac。刚换上 Mac 就惊喜地发现,以前一个 Java 工程编译打包下要 50 多秒,在 Mac 下只需要 20 秒了。考虑写程序的这么多年,每天我要执行多少次编译打包命令,虽然每次节省的时间不多,但总体来看它大大提高了我的效率。
|
||||
|
||||
为什么我要单独谈谈笔记本这件小事?因为这是我们程序员每天使用最多的工具,如果你不在乎你的工具,可能你也就不会在乎你的时间和效率。
|
||||
|
||||
野蛮生长
|
||||
|
||||
现在的公司基本都会给新入职的同学配备一个老员工,俗称 “导师”。
|
||||
|
||||
导师制的主要目的是帮助新员工迅速熟悉公司环境和融入团队中。这个初衷自然是没问题的,只是因为导师制其实不算正式的规章制度,更多是一种文化上的强制介入,但很多时候又没有绑定任何利益机制,所以它的效果实际上和个体差异有很大关系。
|
||||
|
||||
我觉着新入行的同学,尽量不要去依赖此类导师制。我并不是否定这种机制,而是提醒你不要形成心理依赖,觉着面临问题时,总是有人可以询问和帮忙。职场的第一个台阶就是形成独立性:独立承担职责的能力。这和协作没有冲突,团队协作可以算是履行职责的一种途径和手段。
|
||||
|
||||
就以简单的问问题为例,如果只抱着获得答案搞定自己的事情为出发点,一次、两次也就罢了,长此以往是不可行的。因为通过你问出什么样的问题,就可以看出你对这个问题做出了怎样的探索与思考,以及让你困惑的矛盾点在哪里。有些人就喜欢不假思索地问些 Google 都能够轻易回答的问题,形成路径依赖,虽然最终搞定了问题,但换得的评价却不会高,特别要省之戒之。
|
||||
|
||||
当你能够问出 Google 都不容易回答的问题时,这可能才是你真正走上职业程序员的开端。
|
||||
|
||||
知乎上有个问题:“普通人到底跟职业运动员有多大差距?”里面有个回答,以篮球运动为例给出一个生动的评分体系。假如巅峰时的迈克尔·乔丹算满分 100,那么国内顶级的球员,巅峰时的易建联可能刚刚及格得分在 60~70 之间,而大部分业余选手基本就在 0.1~0.N 之间波动了。
|
||||
|
||||
幸运的是程序员这个职业不像运动员那么惨烈,借用上面的评分标准,假如把奠定计算机行业基础的那一批图灵奖得主级别的程序员定义在 90~100 分档,那么我们很多靠编码为生的程序员基本都在 1~10 分档间分布,而业余的只是写写 Demo 的程序员可能就是在 0.1~0.N 之间了。
|
||||
|
||||
所以,进入职场后,你需要先把得分从小数提高到整数,再持续努努力提升到两位数,搞不好就成了行业某个垂直细分领域小有名气的专家了。
|
||||
|
||||
都不需要及格的分数,程序员就能获得不错的成就感和回报。只需要是巅峰者的十分之一或二十分之一,基本就能超越身边绝大多数人了。望着遥远的巅峰不停地去靠近,翻越身前脚下的一座座小山包,然后不知不觉就超越了自己。
|
||||
|
||||
总之,应像野草一样野蛮而快速地生长起来。
|
||||
|
||||
青春有价
|
||||
|
||||
青春,到底是无价,还是有价?
|
||||
|
||||
电影《寒战》里面有个情节,劫匪劫持了一辆警方的冲锋车和五名警员,勒索赎金时让警方自己算一辆冲锋车及其装备外加五名警员的性命值多少钱。然后电影里一阵眼花缭乱的计算得出了最终价格,大约九千多万港币。
|
||||
|
||||
后来采访导演问是怎么个算法,导演如是说:
|
||||
|
||||
|
||||
五个警员,若不在事件中死去,由现在到退休期间的十多二十年任职的每月薪酬、房屋及子女医疗津贴、加上假设退休前的职位升迁,香港市民平均年龄以男方 79 岁,女方 85.9 岁的生存上限而计算的长俸,并加上冲锋车流动武器库内的价值、冲锋车本身的车价及保险等最后算出来的总值。
|
||||
|
||||
|
||||
按这么一算,其实一生值不了多少钱啊。
|
||||
|
||||
年轻时候嘴边爱唠叨一句话叫:青春无价。其实从我们挣到第一份工资开始,人生就是有价的了。而最黄金时段的青春就在毕业后的十年内,这是大部分人心理和心智走向成熟的阶段,但这十年的价值从市场价格衡量来看是十分有限的。
|
||||
|
||||
对于2018 年的毕业生, BAT 三家给出的年薪大约二十万左右,换算到月上每月接近两万了。而另外很大一部分进不了 BAT 三家的毕业生可能平均拿不到那么高,但估计在一线城市一万是差不多的。这样一算,未来十年你觉得能挣多少钱?
|
||||
|
||||
喜欢从静止的视角看问题的人一算大概一年十来万,十年也就一百多万,这个收入相对目前一线城市的房价,还能安居乐业吗?
|
||||
|
||||
另外思考一个问题:你能在十年后做到相比刚毕业时稳定收入增长十倍吗?也就是说现在月薪一万的人,十年后能月入十万吗?难,非常难。不信我们下面仔细算算。
|
||||
|
||||
我们回到用动态的视角看前面那个问题,你持续学习,努力工作,年年涨薪 20%(注意我说的是年年,这很不容易,也许你会说跳一次槽就可能翻倍,但你能年年跳槽翻倍么),十年后你年的收入将是十年前的 6.2 倍,离十倍还有距离,但换算为年薪也有七八十万了。所以要想靠加薪加到月入十万真的是一件极难的事情,而且即使做到了也离我们心中的无价青春,还差很远吧?
|
||||
|
||||
认清了这个现实,我们明白了这十年的青春是十分有价的。所以这时有人说了,要去创业,才有可能突破。前两年(2015)都在鼓励万众创业,但真实的现实是,你要给目前的万众创业者一个稳定的七八十万年薪,80%+ 的创业者表示就会放弃创业了,这数据是来自 TOMsInsight 深度观察文《互联网乱世之下,那些人才流动中的心酸和无奈》对 100 个创业者的抽样调查。-
|
||||
|
||||
|
||||
TOMsInsight 创业者放弃公司的薪水额度抽样调查
|
||||
|
||||
那么持续努力的学习还有意义吗?我只是说你很难做到每年加薪 20%,但是却可以做到每年比去年的自己多增长 20% 的知识、见识和能力。而关于知识、见识和能力的积累与相应价值的变现,理论与现实的对比可能如下图,纵坐标:年薪(单位万),横坐标:工作年限。-
|
||||
|
||||
|
||||
年薪与工作年限概念图
|
||||
|
||||
现实不太可能因为你的能力每增长 20% 就会立刻体现在你的收入上。现实有两种可能:一种存在一个拐点让你的积累获得相应的价格体现,另一种也可能不存在这个拐点,停留在某个水平位。其中拐点就是我们现实中常说的机遇吧。
|
||||
|
||||
无论怎样,要想获得拐点出现的机遇,可能你也只能持续努力地积累下去。
|
||||
|
||||
关于人生的选择,从来都是 All In,可没有股票那种分批建仓的办法,写到这里想起了曾经在网上记录下来的几句话,现分享给你:
|
||||
|
||||
|
||||
我不停的擦拭手中的利剑,不是因为我喜欢它,也不是因为它能带来安全,只是因为,每当下一次冲锋的号角响起时,我能够迅速拔出,纵横厮杀,直至战斗结束,不让自己倒下。-
|
||||
……-
|
||||
生活在这样的时代,与其被迫上场,心怀恐惧,不如主动征伐,加入时代的滚滚大潮当中,去见识一下时代的风采,写下自己的故事。
|
||||
|
||||
|
||||
这个江湖会有你的故事吗?
|
||||
|
||||
在这里我分享了一些我刚入江湖的故事,那你有怎样的精彩故事呢?欢迎你留言,和我一起分享。
|
||||
|
||||
|
||||
|
||||
|
144
专栏/程序员进阶攻略/05架构与实现:它们的连接与分界?.md
Normal file
144
专栏/程序员进阶攻略/05架构与实现:它们的连接与分界?.md
Normal file
@ -0,0 +1,144 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 架构与实现:它们的连接与分界?
|
||||
把一种想法、一个需求变成代码,这叫 “实现”,而在此之前,技术上有一个过程称为设计,设计中有个特别的阶段叫 “架构”。
|
||||
|
||||
程序员成长的很长一段路上,一直是在 “实现”,当有一天,需要承担起 “架构” 的责任时,可能会有一点搞不清两者的差异与界线。
|
||||
|
||||
是什么
|
||||
|
||||
架构是什么?众说纷纭。
|
||||
|
||||
架构(Architecture)一词最早源自建筑学术语,后来才被计算机科学领域借用。以下是其在维基百科(Wikipedia)中的定义:
|
||||
|
||||
|
||||
架构是规划、设计和构建建筑及其物理结构的过程与产物。在计算机工程中,架构是描述功能、组织和计算机系统实现的一组规则与方法。-
|
||||
Architecture is both the process and the product of planning, designing, and constructing buildings and other physical structures. In computer engineering, “computer architecture” is a set of rules and methods that describe the functionality, organization, and implementation of computer systems.
|
||||
|
||||
|
||||
在建筑学领域,有一组清晰的规则和方法来定义建筑架构。但可惜,到目前为止,在计算机软件工程领域并没有如此清晰的一组规则与方法来定义软件架构。
|
||||
|
||||
好在经过多年的实践,行业里逐渐形成了关于软件架构的共同认知:软件系统的结构与行为设计。而实现就是围绕这种已定义的宏观结构去开发程序的过程。
|
||||
|
||||
做什么
|
||||
|
||||
架构做什么?很多人会感觉糊里糊涂的。
|
||||
|
||||
我刚获得“架构师”称号时,也并不很明确架构到底在做什么,交付的是什么。后来不断在工作中去反思、实践和迭代,我才慢慢搞清楚架构工作和实现工作的差异与分界线。
|
||||
|
||||
从定义上,你已知道架构是一种结构设计,但它同时可能存在于不同的维度和层次上:
|
||||
|
||||
|
||||
高维度:指系统、子系统或服务之间的切分与交互结构。
|
||||
中维度:指系统、服务内部模块的切分与交互结构。
|
||||
低维度:指模块组成的代码结构、数据结构、库表结构等。
|
||||
|
||||
|
||||
在不同规模的团队中,存在不同维度的架构师,但不论工作在哪个维度的架构师,他们工作的共同点包括下面4个方面:
|
||||
|
||||
|
||||
确定边界:划定问题域、系统域的边界。
|
||||
切分协作:切分系统和服务,目的是建立分工与协作,并行以获得效率。
|
||||
连接交互:在切分的各部分之间建立连接交互的原则和机制。
|
||||
组装整合:把切分的各部分按预期定义的规则和方法组装整合为一体,完成系统目标。
|
||||
|
||||
|
||||
有时,你会认为架构师的职责是要交付 “一种架构”,而这“一种架构” 的载体通常又会以某种文档的形式体现。所以,很容易误解架构师的工作就是写文档。但实际上架构师的交付成果是一整套决策流,文档仅仅是交付载体,而且仅仅是过程交付产物,最终的技术决策流实际体现在线上系统的运行结构中。
|
||||
|
||||
而对于实现,你应该已经很清楚是在做什么了。但我在这里不妨更清晰地分解一下。实现的最终交付物是程序代码,但这个过程中会发生什么?一般会有下面6个方面的考虑:选型评估;程序设计;执行效率;稳定健壮;维护运维;集成部署。
|
||||
|
||||
下表为其对应的详细内容:-
|
||||
|
||||
|
||||
我以交付一个功能需求为例,讲述下这个过程。
|
||||
|
||||
实现一个功能,可能全部自己徒手做,也可能选择一些合适的库或框架,再从中找到需要的API。
|
||||
|
||||
确定了合适的选型后,需要从逻辑、控制与数据这三个方面进一步考虑程序设计:
|
||||
|
||||
|
||||
逻辑,即功能的业务逻辑,反映了真实业务场景流程与分支,包含大量业务领域知识。
|
||||
控制,即考虑业务逻辑的执行策略,哪些可以并行执行,哪些可以异步执行,哪些地方又必须同步等待结果并串行执行?
|
||||
数据,包括数据结构、数据状态变化和存取方式。
|
||||
|
||||
|
||||
开始编码实现时,你进一步要考虑代码的执行效率,需要运行多长时间?要求的最大等待响应时间能否满足?并发吞吐能力如何?运行的稳定性和各种边界条件、异常处理是否考虑到了?上线后,出现 Bug,相关的监控、日志能否帮助快速定位?是否有动态线上配置和变更能力,可以快速修复一些问题?新上线版本时,你的程序是否考虑了兼容老版本的问题等?
|
||||
|
||||
最后你开发的代码是以什么形态交付?如果是提供一个程序库,则需要考虑相关的依赖复杂度和使用便利性,以及未来的升级管理。如果是提供服务,就需要考虑服务调用的管理、服务使用的统计监控,以及相关的 SLA 服务保障承诺。
|
||||
|
||||
以上,就是我针对整个实现过程自己总结的一个思维框架。如果你每次写代码时,都能有一个完善的思维框架,应该就能写出更好的代码。这个思维框架是在过去多年的编程经验中逐步形成的,在过去每次写代码时如果漏掉了其中某个部分,后来都以某种线上 Bug 或问题的形式,让我付出了代价,做出了偿还。
|
||||
|
||||
“实现”作为一个过程,就是不断地在交付代码流。而完成的每一行代码,都包含了上面这些方面的考虑,而这些方面的所有判断也是一整套决策流,然后固化在了一块块的代码中。
|
||||
|
||||
因为实现是围绕架构来进行的,所以架构的决策流在先,一定程度上决定了实现决策流的方向与复杂度,而架构决策的失误,后续会成倍地放大实现的成本。
|
||||
|
||||
关注点
|
||||
|
||||
架构与实现过程中,有很多很多的点值得关注,若要选择一个核心点,会是什么?
|
||||
|
||||
架构的一个核心关注点,如果只能是一个点,我想有一个很适合的字可以表达: 熵。“熵”是一个物理学术语,在热力学中表达系统的混乱程度,最早是“信息论之父”克劳德·艾尔伍德·香农借用了这个词,并将其引入了信息科学领域,用以表达系统的混乱程度。
|
||||
|
||||
软件系统或架构,不像建筑物会因为时间的流逝而自然损耗腐坏,它只会因为变化而腐坏。一开始清晰整洁的架构与实现随着需求的变化而不断变得浑浊、混乱。这也就意味着系统的“熵”在不断增高。
|
||||
|
||||
这里我用一个图展示软件系统“熵”值的生命周期变化,如下:-
|
||||
|
||||
|
||||
系统只要是活跃的,“熵”值就会在生命周期中不断波动。需求的增加和改变,就是在不断增加“熵”值(系统的混乱程度)。但软件系统的“熵”有个临界值,当达到并超过临界值后,软件系统的生命也基本到头了。这时,你可能将迫不得已采取一种行动:重写或对系统做架构升级。
|
||||
|
||||
如果你不关注、也不管理系统的“熵”值,它最终的发展趋势就如图中的蓝线,一直升高,达到临界点,届时你就不得不付出巨大的代价来进行系统架构升级。
|
||||
|
||||
而实现中重构与优化的动作则是在不断进行减“熵”,作出平衡,让系统的“熵”值在安全的范围内波动。
|
||||
|
||||
那么,关于实现的核心关注点,也就呼之欲出了,我们也可以用一个字表达:简。
|
||||
|
||||
简,是简单、简洁、简明、简化,都是在做减法,但不是简陋。关于实现的全部智慧都浓缩在了这一个字里,它不仅减少代码量,也减少了开发时间,减少了测试时间,减少了潜在 Bug 的数量,甚至减少了未来的维护、理解与沟通成本。
|
||||
|
||||
架构关注复杂度的变化,自然就会带来简化,而实现则应当顺着把“简”做到极致。
|
||||
|
||||
断裂带
|
||||
|
||||
架构与实现之间,存在一条鸿沟,这是它们之间的断裂带。
|
||||
|
||||
断裂带出现在架构执行过程之中,落在文档上的架构决策实际上是静态的,但真正的架构执行过程却是动态的。架构师如何准确地传递架构决策?而开发实施的效果又如何能与架构决策保持一致?在这个过程中出现实施与决策的冲突,就又需要重新协调沟通讨论以取得新的一致。
|
||||
|
||||
当系统规模比较小时,有些架构师一个人就能把全部的设计决策在交付期限内开发完成,这就避免了很多沟通协调的问题。好些年前,我就曾这样做过一个小系统的架构升级改造,但后来的系统越来越大,慢慢就需要几十人的团队来分工协作。光是准确传递决策信息,并维持住大体的一致性,就是一件非常有挑战的工作了。
|
||||
|
||||
当系统规模足够大了,没有任何架构师能够把控住全部的细节。在实践中,我的做法是定期对系统的状态做快照,而非去把握每一次大大小小的变化,因为那样直接就会让我过载。在做快照的过程中我会发现很多的细节,也许和我当初想的完全不一样,会产生出一种“要是我来实现,绝对不会是这样”的感慨。
|
||||
|
||||
但在我发现和掌握的所有细节中,我需要做一个判断,哪些细节上的问题会是战略性的,而我有限的时间和注意力,必须放在这样的战略性细节上。而其他大量的实现细节也许和我想的不同,但只要没有越出顶层宏观结构定义的边界即可。系统是活的,控制演化的方向是可行的,而妄图掌控演化过程的每一步是不现实的。
|
||||
|
||||
关注与把控边界,这就比掌控整个领地的范围小了很多,再确认领地中的战略要地,那么掌控的能力也就有了支撑。架构与实现的鸿沟会始终存在,在这条鸿沟上选择合适的地方建设桥梁,建设桥梁的地方必是战略要地。
|
||||
|
||||
等效性
|
||||
|
||||
架构升级中,经常被问到一个问题:“这个架构能实现么?”
|
||||
|
||||
其实,这根本不是一个值得疑惑的问题。相对于建筑架构,软件架构过程其实更像是城市的规划与演变过程。有一定历史的城市,慢慢都会演变出所谓的旧城和新城。而新城相对于旧城,就是一次架构升级的过程。
|
||||
|
||||
城市规划师会对城市的分区、功能划分进行重新定位与规划。一个旧城所拥有的所有功能,如:社区、学校、医院、商业中心,难道新城会没有,或者说 “实现” 不了吗?
|
||||
|
||||
任何架构的可实现性,是完全等效的,但实现本身却不是等效的,对不同的人或不同的团队可实现性的可能、成本、效率是绝对不等效的。
|
||||
|
||||
近些年,微服务架构火了,很多人都在从曾经的单体应用架构升级到微服务架构。以前能实现的功能,换成微服务架构肯定也可以实现,只是编写代码的方式不同,信息交互的方式也不同。
|
||||
|
||||
架构升级,仅仅是一次系统的重新布局与规划,成本和效率的重新计算与设计,“熵”的重新分布与管理。
|
||||
|
||||
最后我归纳下:架构是关注系统结构与行为的决策流,而实现是围绕架构的程序开发过程;架构核心关注系统的“熵”,而实现则顺应“简”;架构注重把控系统的边界与 “要塞”,而实现则去建立 “领地”;所有架构的可实现性都是等效的,但实现的成本、效率绝不会相同。
|
||||
|
||||
文中提到,架构和实现之间有一条断裂带,而让架构与实现分道扬镳的原因有:
|
||||
|
||||
|
||||
沟通问题:如信息传递障碍。
|
||||
水平问题:如技术能力不足。
|
||||
态度问题:如偷懒走捷径。
|
||||
现实问题:如无法变更的截止日期(Deadline)。
|
||||
|
||||
|
||||
以上都是架构执行中需要面对的问题,你还能想到哪些?欢迎给我留言,和我一起探讨。
|
||||
|
||||
|
||||
|
||||
|
97
专栏/程序员进阶攻略/06模式与框架:它们的关系与误区?.md
Normal file
97
专栏/程序员进阶攻略/06模式与框架:它们的关系与误区?.md
Normal file
@ -0,0 +1,97 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 模式与框架:它们的关系与误区?
|
||||
在学习程序设计的路上,你一定会碰到“设计模式”,它或者给你启发,或者让你疑惑,并且你还会发现在不同的阶段遇到它,感受是不同的。而“开发框架”呢?似乎已是现在写程序的必备品。那么框架和模式又有何不同?它们有什么关系?在程序设计中又各自扮演什么角色呢?
|
||||
|
||||
设计模式
|
||||
|
||||
设计模式,最早源自 GoF 那本已成经典的《设计模式:可复用面向对象软件的基础》一书。该书自诞生以来,在程序设计领域已被捧为“圣经”。
|
||||
|
||||
软件设计模式也是参考了建筑学领域的经验,早在建筑大师克里斯托弗·亚历山大(Christopher Alexander)的著作《建筑的永恒之道》中,已给出了关于“模式”的定义:
|
||||
|
||||
|
||||
每个模式都描述了一个在我们的环境中不断出现的问题,然后描述了该问题的解决方案的核心,通过这种方式,我们可以无数次地重用那些已有的成功的解决方案,无须再重复相同的工作。
|
||||
|
||||
|
||||
而《设计模式》一书借鉴了建筑领域的定义和形式,原书中是这么说的:
|
||||
|
||||
|
||||
本书中涉及的设计模式并不描述新的或未经证实的设计,我们只收录那些在不同系统中多次使用过的成功设计;尽管这些设计不包括新的思路,但我们用一种新的、便于理解的方式将其展现给读者。
|
||||
|
||||
|
||||
虽然该书采用了清晰且分门别类的方式讲述各种设计模式,但我相信很多新入门的程序员在看完该书后还是会像我当年一样有困扰,无法真正理解也不知道这东西到底有啥用。
|
||||
|
||||
早年我刚开始学习 Java 和面向对象编程,并编写 JSP 程序。当我把一个 JSP 文件写到一万行代码时,自己终于受不了了,然后上网大量搜索到底怎样写 JSP 才是对的。之后,我就碰到了《设计模式》一书,读完了,感觉若有所悟,但再去写程序时,反而更加困扰了。
|
||||
|
||||
因为学 “设计模式” 之前,写程序是无所顾忌,属于拿剑就刺,虽无章法却还算迅捷。但学了一大堆 “招式” 后反而变得有点瞻前顾后,每次出剑都在考虑招式用对没,挥剑反倒滞涩不少。有人说:“设计模式,对于初窥门径的程序员,带来的麻烦简直不逊于它所解决的问题。”回顾往昔,我表示深有同感。
|
||||
|
||||
后来回想,那个阶段我把《设计模式》用成了一本 “菜谱” 配方书。现实是,没做过什么菜只是看菜谱,也只能是照猫画虎,缺少好厨师的那种能力——火候。初窥门径的程序员其实缺乏的就是这样的“火候”能力,所以在看《设计模式》时必然遭遇困惑。而这种“火候”能力则源自大量的编程设计实践,在具体的实践中抽象出模式的思维。
|
||||
|
||||
“设计模式” 是在描述一些抽象的概念,甚至还给它们起了一些专有名字,这又增加了一道弯儿、一层抽象。初窥门径的程序员,具体的实践太少,面临抽象的模式描述时难免困惑。但实践中,经验积累到一定程度的程序员,哪怕之前就没看过《设计模式》,他们却可能已经基于经验直觉地用起了某种模式。
|
||||
|
||||
前面我说过我刚学习编程时看过一遍《设计模式》,看完后反而带来更多的干扰,不过后来倒也慢慢就忘了。好些年后,我又重读了一遍,竟然豁然开朗起来,因为其中一些模式我已经在过往的编程中使用过很多次,另一些模式虽未碰到,但理解起来已不见困惑。到了这个阶段,其实我已经熟练掌握了从具体到抽象之间切换的思维模式,设计模式的 “招数” 看来就亲切了很多。
|
||||
|
||||
在我看来,模式是前人解决某类问题方式的总结,是一种解决问题域的优化路径。但引入模式也是有代价的。设计模式描述了抽象的概念,也就在代码层面引入了抽象,它会导致代码量和复杂度的增加。而衡量应用设计模式付出的代价和带来的益处是否值得,这也是程序员 “火候” 能力另一层面的体现。
|
||||
|
||||
有人说,设计模式是招数;也有人说,设计模式是内功。我想用一种大家耳熟能详的武功来类比:降龙十八掌。以其中一掌“飞龙在天”为例,看其描述:
|
||||
|
||||
|
||||
气走督脉,行手阳明大肠经商阳…此式跃起凌空,居高下击,以一飞冲天之式上跃,双膝微曲,提气丹田,急发掌劲取敌首、肩、胸上三路。
|
||||
|
||||
|
||||
以上,前半句是关于内功的抽象描述,后半部分是具体招数的描述,而设计模式的描述表达就与此有异曲同工之妙。所以,设计模式是内功和招数并重、相辅相成的 “武功”。
|
||||
|
||||
当你解决了一个前人从没有解决的问题,并把解决套路抽象成模式,你就创造了一招新的 “武功”,后来的追随者也许会给它起个新名字叫:某某模式。
|
||||
|
||||
开发框架
|
||||
|
||||
不知从何时起,写程序就越来越离不开框架了。
|
||||
|
||||
记得我还在学校时,刚学习 Java 不久,那时 Java 的重点是 J2EE(现在叫 Java EE 了),而 J2EE 的核心是 EJB。当我终于用“JSP + EJB + WebLogic(EJB 容器)+ Oracle数据库”搭起一个 Web 系统时,感觉终于掌握了 Java 的核心。
|
||||
|
||||
后来不久,我去到一家公司实习,去了以后发现那里的前辈们都在谈论什么 DI(依赖注入)和 IoC(控制反转)等新概念。他们正在把老一套的 OA 系统从基于 EJB 的架构升级到一套全新的框架上,而那套框架包含了一堆我完全没听过的新名词。
|
||||
|
||||
然后有前辈给我推荐了一本书叫 _J2EE Development Without EJB_,看完后让我十分沮丧,因为我刚刚掌握的 Java 核心技术 EJB 还没机会出手就已过时了。
|
||||
|
||||
从那时起,我开始知道了框架(Framework)这个词,然后学习了一整套的基于开源框架的程序开发方式,知道了为什么 EJB 是重量级的,而框架是轻量级的。当时 EJB 已步入暮年,而框架的春天才刚开始来临,彼时最有名的框架正好也叫 Spring。如今框架已经枝繁叶茂,遍地开花。
|
||||
|
||||
现在的编程活动中,已是大量应用框架,而框架就像是给程序员定制的开发脚手架。一个框架是一个可复用的设计组件,它统一定义了高层设计和接口,使得从框架构建应用程序变得非常容易。因此,框架可以算是打开“快速开发”与“代码复用”这两扇门的钥匙。
|
||||
|
||||
在如今这个框架遍地开花的时代,正因为框架过于好用、易于复用,所以也可能被过度利用。
|
||||
|
||||
在 Java 中,框架很多时候就是由一个或一些 jar 包组成的。早在前几年(2012 年的样子)接触到一个 Web 应用系统,当时我尝试去拷贝一份工程目录时,意外发现居然有接近 500M 大小,再去看依赖的 jar 包多达 117 个,着实吓了一跳。在 500M 工程目录拷贝进度条缓慢移动中,我在想:“如今的程序开发是不是患上了框架过度依赖症?”
|
||||
|
||||
我想那时应该没有人能解释清楚为什么这个系统需要依赖 117 个 jar 包之多,也许只是为了完成一个功能,引入了一个开源框架,而这个框架又依赖了其他 20 个 jar 包。
|
||||
|
||||
有时候,框架确实帮我们解决了大部分的脏活累活,如果运气好,这些框架的质量很高或系统的调用量不大,那么它们可能也就从来没引发过什么问题,我们也就不需要了解它们是怎么去解决那些脏活、累活的。但若不巧,哪天某个框架在某些情况下出现了问题,在搞不懂框架原理的情况下,就总会有人惊慌失措。
|
||||
|
||||
如今,框架带来的束缚在于,同一个问题,会有很多不同框架可供选择。如何了解、评估、选择与取舍框架,成了新的束缚。
|
||||
|
||||
一些知名框架都是从解决一个特定领域问题的微小代码集合开始发展到提供解决方案、绑定概念、限定编程模式,并尝试不断通用化来扩大适用范围。
|
||||
|
||||
这样的框架自然不断变得庞大、复杂、高抽象度。
|
||||
|
||||
我一直不太喜欢通用型的框架,因为通用则意味着至少要适用于大于两种或以上的场景,场景越多我们的选择和取舍成本越高。另外,通用意味着抽象度更高,而现实是越高的抽象度,越不容易被理解。例如,人生活在三维世界,理解三维空间是直观的,完全没有抽象,理解四维空间稍微困难点,那五维或以上理解起来就很困难了。
|
||||
|
||||
框架,既是钥匙,也是枷锁,既解放了我们,也束缚着我们。
|
||||
|
||||
两者关系
|
||||
|
||||
分析了模式,解读了框架,那么框架和模式有什么关系呢?
|
||||
|
||||
框架和模式的共同点在于,它们都提供了一种问题的重用解决方案。其中,框架是代码复用,模式是设计复用。
|
||||
|
||||
软件开发是一种知识与智力的活动,知识的积累很关键。框架采用了一种结构化的方式来对特定的编程领域进行了规范化,在框架中直接就会包含很多模式的应用、模式的设计概念、领域的优化实践等,都被固化在了框架之中。框架是程序代码,而模式是关于这些程序代码的知识。
|
||||
|
||||
比如像 Spring 这样的综合性框架的使用与最佳实践,就隐含了大量设计模式的套路,即使是不懂设计模式的初学者,也可以按照这些固定的编程框架写出符合规范模式的程序。但写出代码完成功能是一回事,理解真正的程序设计又是另外一回事了。
|
||||
|
||||
小时候,看过一部漫画叫《圣斗士》。程序员就像是圣斗士,框架是“圣衣”,模式是“流星拳“,但最重要的还是自身的“小宇宙”啊。
|
||||
|
||||
我相信在编程学习与实践的路上,你对设计模式与开发框架也有过自己的思考。欢迎给我留言,说说你有过怎样的认识变化和体会,我们一起讨论。
|
||||
|
||||
|
||||
|
||||
|
110
专栏/程序员进阶攻略/07多维与视图:系统设计的思考维度与展现视图.md
Normal file
110
专栏/程序员进阶攻略/07多维与视图:系统设计的思考维度与展现视图.md
Normal file
@ -0,0 +1,110 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 多维与视图:系统设计的思考维度与展现视图
|
||||
大学上机械设计课程时学习了 “三视图” 。三视图是观测者从三个不同位置观察同一个空间几何体所画出的图形,是正确反映物体长宽高尺寸正投影的工程图,在工程设计领域十分有用。三视图也是精确的,任何现实世界中的立体物都必然能被 “三视图” 投影到二维的平面,有了这张图就能准确制作出相应的机械零部件。
|
||||
|
||||
但在软件设计领域,则有较大的不同,软件系统是抽象的,而且维度更多。20世纪90年代,软件行业诞生了 UML(Unified Modeling Language): 统一建模语言,一种涵盖软件设计开发所有阶段的模型化与可视化支持的建模语言。
|
||||
|
||||
从 UML 的出现中就可以知道,软件先驱们一直在不懈地努力,使软件系统设计从不可直观感受触摸的抽象思维空间向现实空间进行投影。
|
||||
|
||||
UML 是一种类似于传统工程设计领域 “三视图” 的尝试,但却又远没有达到 “三视图” 的精准。虽然 UML 没能在工程实施领域内广泛流行起来,但其提供的建模思想给了我启发。让我一直在思考应该需要有哪些维度的视图,才能很好地表达一个软件系统的设计。
|
||||
|
||||
而在多年的工程实践中,我逐渐得到了一些维度的视图,下面就以我近些年一直在持续维护、设计、演进的系统(京东咚咚)为例来简单说明下。
|
||||
|
||||
一、组成视图
|
||||
|
||||
组成视图,表达了系统由哪些子系统、服务、组件部分构成。
|
||||
|
||||
2015 年,我写过一篇关于咚咚的文章:《京东咚咚架构演进》。当时我们团队对系统进行了一次微服务化的架构升级,而微服务的第一步就是拆分服务,并表达清楚拆分后整个系统到底由哪些服务构成,所以有了下面这张系统服务组成图。
|
||||
|
||||
如下图示例,它对服务进行大类划分,图中用了不同的颜色来表达这种分类:
|
||||
|
||||
|
||||
|
||||
组成视图示例
|
||||
|
||||
每一类服务提供逻辑概念上比较相关的功能,而每一个微服务又按照如下两大原则进行了更细的划分:
|
||||
|
||||
|
||||
单一化:每个服务提供单一内聚的功能集。
|
||||
正交化:任何一个功能仅由一个服务提供,无提供多个类似功能的服务。
|
||||
|
||||
|
||||
如上,就是我们系统的服务组成视图,用于帮助团队理解整体系统的宏观组成,以及个人的具体工作内容在整个系统中的位置。
|
||||
|
||||
了解了服务的组成,进一步自然就需要了解服务之间的关系与交互。
|
||||
|
||||
二、交互视图
|
||||
|
||||
交互视图,表达了系统或服务与外部系统或服务的协作关系,也即:依赖与被依赖。
|
||||
|
||||
由于咚咚系统的业务场景繁多,拆分出来的服务种类也比较多,交互关系复杂。所以可以像地图一样通过不同倍率的缩放视角来表达和观察服务之间的交互关系。
|
||||
|
||||
如下图,是一张宏观大倍率的整体交互视图示例。它隐藏了内部众多服务的交互细节,强调了终端和服务端,以及服务端内部交互的主要过程。这里依然以地图作类比,它体现了整体系统主干道场景的运动过程。而每一个服务本身,在整体的交互图中,都会有其位置,有些在主干道上,而有些则在支线上。
|
||||
|
||||
|
||||
|
||||
交互视图示例
|
||||
|
||||
如果我们把目光聚焦在一个服务上,以其为中心的表达方式,就体现了该服务的依赖协作关系。所以,可以从不同服务为中心点出发,得到关注点和细节更明确的局部交互细节图,而这样的细节图一般掌握在每个服务开发者的脑中。当我们需要写关于某个服务的设计文档时,这样的局部细节交互图也应该是必不可少的。
|
||||
|
||||
在逻辑的层面了解了服务间的协作与交互后,则需要更进一步了解这些服务的部署环境与物理结构。
|
||||
|
||||
三、部署视图
|
||||
|
||||
部署视图,表达系统的部署结构与环境。
|
||||
|
||||
部署视图,从不同的人员角色出发,关注点其实不一样,不过从应用开发和架构的角度来看,会更关注应用服务实际部署的主机环境、网络结构和其他一些环境元素依赖。下面是一张强调服务部署的机房结构、网络和依赖元素的部署图示例。
|
||||
|
||||
|
||||
|
||||
部署视图示例
|
||||
|
||||
部署视图本身也可以从不同的视角来画,这取决于你想强调什么元素。上面这张示例图,强调的是应用部署的 IDC 及其之间的网络关系,和一些关键的网络通讯延时指标。因为这些内容可能影响系统的架构设计和开发实现方式。
|
||||
|
||||
至此,组成、交互和部署图更多是表达系统的宏观视图:关注系统组合、协作和依存的关系。但还缺乏关于系统设计或实现本身的表达,这就引出了流程和状态两类视图。
|
||||
|
||||
四、流程视图
|
||||
|
||||
流程视图,表达系统内部实现的功能和控制逻辑流程。
|
||||
|
||||
可能有人喜欢用常见的流程图来表达系统设计与实现的流程,但我更偏好使用 UML 的序列图,个人感觉更清晰些。
|
||||
|
||||
下图是咚咚消息投递的一个功能逻辑流程表达,看起来就像是 UML 的序列图,但并没有完全遵循 UML 的图例语法(主要是我习惯的画图工具不支持)。而且,我想更多人即使是程序员也并不一定会清楚地了解和记得住 UML 的各种图例语法,所以都用文字做了补充说明,也就没必要一定要遵循其语法了,重点还是在于要把逻辑表达清楚。
|
||||
|
||||
|
||||
|
||||
流程视图示例
|
||||
|
||||
逻辑流程一般分两种:业务与控制。有些系统业务逻辑很复杂,而有些系统业务逻辑不复杂但请求并发很高,导致对性能、安全与稳定的要求高,所以控制逻辑就复杂了。这两类复杂的逻辑处理流程都需要表达清楚,而上图就是对业务功能逻辑的表达示例。
|
||||
|
||||
除了逻辑流程的复杂性,系统维持的状态变迁很可能也是另一个复杂性之源。
|
||||
|
||||
五、状态视图
|
||||
|
||||
状态视图,表达系统内部管理了哪些状态以及状态的变迁转移路径。
|
||||
|
||||
像咚咚这样的 IM 消息系统,就自带一个复杂的状态管理场景:消息的已读/未读状态。它的复杂性体现在,它本身就处在一个不可控的分布式场景下,在用户的多个终端和服务端之间,需要保持尽可能的最终一致性。
|
||||
|
||||
为什么没法满足绝对严格的最终一致性?如下图所示,IM 的 “已读/未读” 状态需要在用户的多个终端和服务端之间进行分布式的同步。按照分布式 CAP 原理,IM 的业务场景限定了 AP 是必须满足的,所以 C 自然就是受限的了。
|
||||
|
||||
|
||||
|
||||
状态视图示例
|
||||
|
||||
所有的业务系统都一定会有状态,因为那就是业务的核心价值,并且这个系统只要有用户使用,用户就会产生行为,行为导致系统状态的变迁。比如,IM 中用户发出的消息,用户的上下线等等都是行为引发的状态变化。
|
||||
|
||||
但无状态服务相比有状态的服务和系统要简单很多,一个系统中不是所有的服务都有状态,只会有部分服务需要状态,我们的设计仅仅是围绕在,如何尽可能地把状态限制在系统的有限范围内,控制其复杂性的区域边界。
|
||||
|
||||
至此,关于软件系统设计,我感觉通用的维度与视图就这些,但每个具体的系统可能也还有其独特的维度,也会有自己独有的视图。
|
||||
|
||||
用更系统化的视图去观察和思考,想必也会让你得到更成体系化的系统设计。
|
||||
|
||||
以上就是我关于系统设计的一些通用维度与视图的思考,那么你平时都用怎样的方式来表达程序系统设计呢?
|
||||
|
||||
|
||||
|
||||
|
91
专栏/程序员进阶攻略/08代码与分类:工业级编程的代码分类与特征.md
Normal file
91
专栏/程序员进阶攻略/08代码与分类:工业级编程的代码分类与特征.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 代码与分类:工业级编程的代码分类与特征
|
||||
编程,就是写代码,那么在真实的行业项目中你编写的这些代码可以如何分类呢?回顾我曾经写过的各种系统代码,按代码的作用,大概都可以分为如下三类:
|
||||
|
||||
|
||||
功能
|
||||
控制
|
||||
运维
|
||||
|
||||
|
||||
如果你想提高编程水平,写出优雅的代码,那么就必须要清晰地认识清楚这三类代码。
|
||||
|
||||
一、功能
|
||||
|
||||
功能代码,是实现需求的业务逻辑代码,反映真实业务场景,包含大量领域知识。
|
||||
|
||||
一个程序软件系统,拥有完备的功能性代码仅是基本要求。因为业务逻辑的复杂度决定了功能性代码的复杂度,所以要把功能代码写好,最难的不是编码本身,而是搞清楚功能背后的需求并得到正确的理解。之后的编码活动,就仅是一个“翻译”工作了:把需求“翻译”为代码。
|
||||
|
||||
当然,“翻译” 也有自己独有的技术和积累,并不简单。而且 “翻译” 的第一步要求是 “忠于原文”,也即真正地理解并满足用户的原始需求。可这个第一步的要求实现起来就很困难。
|
||||
|
||||
为什么搞清楚用户需求很困难?因为从用户心里想要的,到他最后得到的之间有一条长长的链条,如下所示:
|
||||
|
||||
|
||||
用户心理诉求 -> 用户表达需求 -> 产品定义需求 -> 开发实现 -> 测试验证 -> 上线发布 -> 用户验收
|
||||
|
||||
|
||||
需求信息源自用户的内心,然后通过表达显性地在这个链条上传递,最终固化成了代码,以程序系统的形态反馈给了用户。
|
||||
|
||||
但信息在这个链条中的每个环节都可能会出现偏差与丢失,即使最终整个链条上的各个角色都貌似达成了一致,完成了系统开发、测试和发布,但最终也可能发现用户的心理诉求要么表达错了,要么被理解错了。
|
||||
|
||||
因为我近些年一直在做即时通讯产品(IM),所以在这儿我就以微信这样一个国民级的大家都熟悉的即时通讯产品为样本,举个例子。
|
||||
|
||||
微信里有个功能叫:消息删除。你该如何理解这个功能背后的用户心理诉求呢?用户进行删除操作的期待和反馈又是什么呢?从用户发消息的角度,我理解其删除消息可能的诉求有如下几种:
|
||||
|
||||
|
||||
消息发错了,不想对方收到。
|
||||
消息发了后,不想留下发过的痕迹,但期望对方收到。
|
||||
消息已发了,对于已经收到的用户就算了,未收到的最好就别收到了,控制其传播范围。
|
||||
|
||||
|
||||
对于第一点,微信提供了两分钟内撤回的功能;而第二点,微信提供的删除功能正好满足;第三点,微信并没有满足。我觉着第三点其实是一个伪需求,它其实是第一点不能被满足情况下用户的一种妥协。
|
||||
|
||||
用户经常会把他们的需要,表达成对你的行为的要求,也就是说不真正告诉你要什么,而是告诉你要做什么。所以你才需要对被要求开发的功能进行更深入的思考。有时,即使是日常高频使用的产品背后的需求,你也未必能很好地理解清楚,而更多的业务系统其实离你的生活更远,努力去理解业务及其背后用户的真实需求,才是写好功能代码的基本能力。
|
||||
|
||||
程序存在的意义就在于实现功能,满足需求。而一直以来我们习惯于把完成客户需求作为程序开发的主要任务,当功能实现了便感觉已经完成了开发,但这仅仅是第一步。
|
||||
|
||||
二、控制
|
||||
|
||||
控制代码,是控制业务功能逻辑代码执行的代码,即业务逻辑的执行策略。
|
||||
|
||||
编程领域熟悉的各类设计模式,都是在讲关于控制代码的逻辑。而如今,很多这些常用的设计模式基本都被各类开源框架固化了进去。比如,在 Java 中,Spring 框架提供的控制反转(IoC)、依赖注入(DI)就固化了工厂模式。
|
||||
|
||||
通用控制型代码由各种开源框架来提供,程序员就被解放出来专注写好功能业务逻辑。而现今分布式领域流行的微服务架构,各种架构模式和最佳实践也开始出现在各类开源组件中。比如微服务架构模式下关注的控制领域,包括:通信、负载、限流、隔离、熔断、异步、并行、重试、降级。
|
||||
|
||||
以上每个领域都有相应的开源组件代码解决方案,而进一步将控制和功能分离的 “服务网格(Service Mesh)” 架构模式则做到了极致,控制和功能代码甚至运行在了不同的进程中。
|
||||
|
||||
控制代码,都是与业务功能逻辑不直接相关的,但它们和程序运行的性能、稳定性、可用性直接相关。提供一项服务,功能代码满足了服务的功能需求,而控制代码则保障了服务的稳定可靠。
|
||||
|
||||
有了控制和功能代码,程序系统终于能正常且稳定可靠地运行了,但难保不出现异常,这时最后一类 “运维” 型代码便要登场了。
|
||||
|
||||
三、运维
|
||||
|
||||
运维代码,就是方便程序检测、诊断和运行时处理的代码。它们的存在,才让系统具备了真正工业级的可运维性。
|
||||
|
||||
最常见的检测诊断性代码,应该就是日志了,打日志太过简单,因此我们通常也就疏于考虑。其实即使是打日志也需要有意识的设计,评估到底应该输出多少日志,在什么位置输出日志,以及输出什么级别的日志。
|
||||
|
||||
检测诊断代码有一个终极目标,就是让程序系统完成运行时的自检诊断。这是完美的理想状态,却很难在现实中完全做到。
|
||||
|
||||
因为它不仅仅受限于技术实现水平,也与实现的成本和效益比有关。所以,我们可以退而求其次,至少在系统异常时可以具备主动运行状态汇报能力,由开发和运维人员来完成诊断分析,这也是我们常见的各类系统或终端软件提供的机制。
|
||||
|
||||
在现实中,检测诊断类代码经常不是一开始就主动设计的。但生产环境上的程序系统可能会偶然出现异常或故障,而因为一开始缺乏检测诊断代码输出,所以很难找到真实的故障原因。现实就这样一步一步逼着你去找到真实原因,于是检测诊断代码就这么被一次又一次地追问为什么而逐渐完善起来了。
|
||||
|
||||
但如果一开始你就进行有意识地检测诊断设计,后面就会得到更优雅的实现。有一种编程模式:面向切面编程(AOP),通过早期的有意设计,可以把相当范围的检测诊断代码放入切面之中,和功能、控制代码分离,保持优雅的边界与距离。
|
||||
|
||||
而对于特定的编程语言平台,比如 Java 平台,有字节码增强相关的技术,可以完全干净地把这类检测诊断代码和功能、控制代码彻底分离。
|
||||
|
||||
运维类代码的另一种类,是方便在运行时,对系统行为进行改变的代码。通常这一类代码提供方便运维操作的 API 服务,甚至还会有专门针对运维提供的服务和应用,例如:备份与恢复数据、实时流量调度等。
|
||||
|
||||
功能、控制、运维,三类代码,在现实的开发场景中优先级这样依次排序。有时你可能仅仅完成了第一类功能代码就迫于各种压力上线发布了,但你要在内心谨记,少了后两类代码,将来都会是负债,甚至是灾难。而一个满足工业级强度的程序系统,这三类代码,一个也不能少。
|
||||
|
||||
而对三类代码的设计和实现,越是优雅的程序,这三类代码在程序实现中就越是能看出明显的边界。为什么需要边界?因为,“码以类聚,人以群分”。功能代码易变化,控制代码固复杂,运维代码偏繁琐,这三类不同的代码,不仅特征不同,而且编写它们的人(程序员)也可能分属不同群组,有足够的边界与距离才能避免耦合与混乱。
|
||||
|
||||
而在程序这个理性世界中,优雅有时就是边界与距离。
|
||||
|
||||
|
||||
|
||||
|
96
专栏/程序员进阶攻略/09粗放与精益:编程的两种思路与方式.md
Normal file
96
专栏/程序员进阶攻略/09粗放与精益:编程的两种思路与方式.md
Normal file
@ -0,0 +1,96 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 粗放与精益:编程的两种思路与方式
|
||||
几年前,我给团队负责的整个系统写过一些公共库,有一次同事发现这个库里存在一个Bug,并告诉了我出错的现象。然后我便去修复这个Bug,最终只修改了一行代码,但发现一上午就这么过去了。
|
||||
|
||||
一上午只修复了一个Bug,而且只改了一行代码,到底发生了什么?时间都去哪里了?以前觉得自己写代码很快,怎么后来越来越慢了?我认真地思考了这个问题,开始认识到我的编程方式和习惯在那几年已经慢慢发生了变化,形成了明显的两个阶段的转变。这两个阶段是:
|
||||
|
||||
|
||||
写得粗放,写得多
|
||||
写得精益,写得好
|
||||
|
||||
|
||||
多与粗放
|
||||
|
||||
粗放,在软件开发这个年轻的行业里其实没有确切的定义,但在传统行业中确实存在相近的关于 “粗放经营” 的概念可类比。引用其百科词条定义如下:
|
||||
|
||||
|
||||
粗放经营(Extensive Management),泛指技术和管理水平不高,生产要素利用效率低,产品粗制滥造,物质和劳动消耗高的生产经营方式。
|
||||
|
||||
|
||||
若把上面这段话里面的 “经营” 二字改成 “编程”,就很明确地道出了我想表达的粗放式编程的含义。
|
||||
|
||||
一个典型的粗放式编程场景大概是这样的:需求到开发手上后,开始编码,编码完成,人肉测试,没问题后快速发布到线上,然后进入下一个迭代。
|
||||
|
||||
我早期参与的大量项目过程都与此类似,不停地重复接需求,快速开发,发布上线。在这个过程中,我只是在不停地堆砌功能代码,每天产出的代码量不算少,但感觉都很类似,也很粗糙。这样的过程持续了挺长一个阶段,一度让我怀疑:这样大量而粗放地写代码到底有什么作用和意义?
|
||||
|
||||
后来读到一个故事,我逐渐明白这个阶段是必要的,它因人、因环境而异,或长或短。而那个给我启发的故事,是这样的。
|
||||
|
||||
有一个陶艺老师在第一堂课上说,他会把班上学生分成两组,一组的成绩将会以最终完成的陶器作品数量来评定;而另一组,则会以最终完成的陶器品质来评定。
|
||||
|
||||
在交作业的时候,一个很有趣的现象出现了:“数量” 组如预期一般拿出了很多作品,但出乎意料的是质量最好的作品也全部是由 “数量” 组制作出来的。
|
||||
|
||||
按 “数量” 组的评定标准,他们似乎应该忙于粗制滥造大量的陶器呀。但实际情况是他们每做出一个垃圾作品,都会吸取上一次制作的错误教训,然后在做下一个作品时得到改进。
|
||||
|
||||
而 “品质” 组一开始就追求完美的作品,他们花费了大量的时间从理论上不断论证如何才能做出一个完美的作品,而到了最后拿出来的东西,似乎只是一堆建立在宏大理论上的陶土。
|
||||
|
||||
读完这个故事,我陷入了沉思,感觉故事里的制作陶器和编程提升之路是如此类似。很显然,“品质” 组的同学一开始就在追求理想上的 “好与精益” ,而 “数量” 组同学的完成方式则似我早期堆砌代码时的“多与粗放”,但他们正是通过做得多,不断尝试,快速迭代 ,最后取得到了更好的结果。
|
||||
|
||||
庆幸的是,我在初学编程时,就是在不断通过编程训练来解答一个又一个书本上得来的困惑;后来工作时,则是在不断写程序来解决一个又一个工作中遇到的问题。看到书上探讨各种优雅的代码之道、编程的艺术哲学,那时的我也完全不知道该如何通往这座编程的 “圣杯”,只能看着自己写出的蹩脚代码,然后继续不断重复去制作下一个丑陋的 “陶器”,不断尝试,不断精进和进阶。
|
||||
|
||||
《黑客与画家》书里说:“编程和画画近乎异曲同工。”所以,你看那些成名画家的作品,如果按时间顺序来排列展示,你会发现每幅画所用的技巧,都是建立在上一幅作品学到的东西之上;如果某幅作品特别出众,你往往也能在更早期的作品中找到类似的版本。而编程的精进过程也是类似的。
|
||||
|
||||
总之,这些故事和经历都印证了一个道理:在通往 “更好” 的路上,总会经过 “更多” 这条路。
|
||||
|
||||
好与精益
|
||||
|
||||
精益,也是借鉴自传统行业里的一个类比:精益生产。
|
||||
|
||||
|
||||
精益生产(Lean Production),简言之,就是一种以满足用户需求为目标、力求降低成本、提高产品的质量、不断创新的资源节约型生产方式。
|
||||
|
||||
|
||||
若将定义中的 “生产” 二字换成 “编程”,也就道出了精益编程的内涵。它有几个关键点:质量、成本与效率。但要注意:在编程路上,如果一开始就像 “品质” 组同学那样去追求完美,也许你就会被定义 “完美” 的品质所绊住,而忽视了制作的成本与效率。
|
||||
|
||||
因为编程的难点是,无论你在开始动手编程时看过多少有关编程理论、方法、哲学与艺术的书,一开始你还是无法领悟到什么是编程的正确方法,以及什么是“完美” 的程序。毕竟纸上得来终觉浅,绝知此事要躬行。
|
||||
|
||||
曾经,还在学校学习编程时,有一次老师布置了一个期中课程设计,我很快完成了这个课程设计中的编程作业。而另一位同学,刚刚看完了那本经典的《设计模式》书。
|
||||
|
||||
他尝试用书里学到的新概念来设计这个编程作业,并且又用 UML 画了一大堆交互和类图,去推导设计的完美与优雅。然后兴致勃勃向我(因为我刚好坐在他旁边)讲解他的完美设计,我若有所悟,觉得里面确实有值得我借鉴的地方,就准备吸收一些我能听明白的东西,重构一遍已经写好的作业程序。
|
||||
|
||||
后来,这位同学在动手实现他的完美设计时,发现程序越写越复杂,交作业的时间已经不够了,只好借用我的不完美的第一版代码改改凑合交了。而我在这第一版代码基础上,又按领悟到的正确思路重构了一次、改进了一番后交了作业。
|
||||
|
||||
所以,别被所谓 “完美“ 的程序所困扰,只管先去盯住你要用编程解决的问题,把问题解决,把任务完成。
|
||||
|
||||
编程,其实一开始哪有什么完美,只有不断变得更好。
|
||||
|
||||
工作后,我做了大量的项目,发现这些项目都有很多类似之处。每次,即使项目上线后,我也必然重构项目代码,提取其中可复用的代码,然后在下一个项目中使用。循环往复,一直干了七八年。每次提炼重构,都是一次从 “更多” 走向 “更好” 的过程。我想,很多程序员都有类似的经历吧?
|
||||
|
||||
回到开头修改Bug 的例子,我用半天的时间改一个Bug,感觉效率不算高,这符合精益编程的思路吗?先来回顾下这半天改这个Bug 的过程。
|
||||
|
||||
由于出问题的那个公共库是我接到Bug 时的半年前开发的,所以发现那个Bug 后,我花了一些时间来回忆整个公共库的代码结构设计。然后我研究了一下,发现其出现的场景比较罕见,要不不至于线上运行了很久也没人发现,属于重要但不紧急。
|
||||
|
||||
因此,我没有立刻着手去修改代码,而是先在公共库的单元测试集中新写了一组单元测试案例。单元测试构建了该Bug的重现场景,并顺利让单元测试运行失败了,之后我再开始去修改代码,并找到了出问题的那一行,修改后重新运行了单元测试集,并顺利看见了测试通过的绿色进度条。
|
||||
|
||||
而作为一个公共库,修改完成后我还要为本次修改更新发布版本,编写对应的文档,并上传到 Maven 仓库中,才算完成。回想这一系列的步骤,我发现时间主要花在了构建重现Bug 的测试案例场景中,有时为了构建一个测试场景编写代码的难度可能比开发功能本身更困难。
|
||||
|
||||
为修改一个Bug 付出的额外单元测试时间成本,算一种浪费吗?虽说这确实提高了代码的修复成本,但也带来了程序质量的提升。按前面精益的定义,这似乎是矛盾的,但其实更是一种权衡与取舍。
|
||||
|
||||
就是在这样的过程与反复中,我渐渐形成了属于自己的编程价值观:世上没有完美的解决方案,任何方案总是有这样或那样一些因子可以优化。一些方案可能面临的权衡取舍会少些,而另一些方案则会更纠结一些,但最终都要做取舍。
|
||||
|
||||
以上,也说明了一个道理:好不是完美,好是一个过程,一个不断精益化的过程。
|
||||
|
||||
编程,当写得足够多了,也足够好了,你才可能自如地在 “多” 与 “好” 之间做出平衡。
|
||||
|
||||
编程的背后是交付程序系统,交付关心的是三点:功能多少,质量好坏,效率快慢。真实的编程环境下, 你需要在三者间取得平衡,哪些部分可能是多而粗放的交付,哪些部分是好而精益的完成,同时还要考虑效率快慢(时间)的需求。
|
||||
|
||||
编程路上,“粗放的多” 是 “精益的好和快” 的前提,而好和快则是你的取舍:是追求好的极致,还是快的极致,或者二者的平衡?
|
||||
|
||||
在多而粗放和好而精益之间,现在你处在哪个阶段了?欢迎留言谈谈你的看法。
|
||||
|
||||
|
||||
|
||||
|
71
专栏/程序员进阶攻略/10炫技与克制:代码的两种味道与态度.md
Normal file
71
专栏/程序员进阶攻略/10炫技与克制:代码的两种味道与态度.md
Normal file
@ -0,0 +1,71 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 炫技与克制:代码的两种味道与态度
|
||||
虽然你代码可能已经写得不少了,但要真正提高代码水平,其实还需要多读代码。就像写作,写得再多,不多读书,思维和认知水平其实是很难提高的。
|
||||
|
||||
代码读得多了,慢慢就会感受到好代码中有一种味道和品质:克制。但也会发现另一种代码,它也会散发出一种味道:炫技。
|
||||
|
||||
炫技
|
||||
|
||||
什么是炫技的代码?
|
||||
|
||||
我先从一个读代码的故事说起。几年前我因为工作需要,去研究一个开源项目的源代码。这是一个国外知名互联网公司开源的工具项目,据说已在内部孵化了 6 年之久,这才开源出来。从其设计文档与代码结构来看,它高层设计的一致性还是比较好的,但到了源代码实现就显得凌乱了些,而且发现了一些炫技的痕迹。
|
||||
|
||||
代码中炫技的地方,具体来说就是关于状态机的使用。状态机程序本是不符合线性逻辑思维的,有点类似goto语句,程序执行会突然发生跳转,所以理解状态机程序的代码要比一般程序困难些。除此之外,它的状态机程序实现又是通过自定义的内存消息机制来驱动,这又额外添加了一层抽象复杂度。
|
||||
|
||||
而在我看来,状态机程序最适合的场景是一种真实领域状态变迁的映射。那什么叫真实领域状态呢?比如,红绿灯就表达了真实交通领域中的三种状态。而另一种场景,是网络编程领域,广泛应用在网络协议解析上,表达解析器当前的运行状态。
|
||||
|
||||
而但凡使用状态机来表达程序设计实现中引入的 “伪” 状态,往往都添加了不必要的复杂性,这就有点炫技的感觉了。但是我还是能常常在一些开源项目中看到一些过度设计和实现的复杂性,而这些项目往往还都是一些行业内头部大公司开源的。
|
||||
|
||||
在程序员的成长路径上,攀登公司的晋升阶梯时,通常会采用同行评审制度,而作为技术人就容易倾向性地关注项目或工程中的技术含量与难点。
|
||||
|
||||
这样的制度倾向性,有可能导致人为制造技术含量,也就是炫技了。就像体操运动中,你完成一个高难度动作,能加的分数有限,而一旦搞砸了,付出的代价则要惨重很多。所以,在比赛中高难度动作都是在关键的合适时刻才会选择。同样,项目中的炫技,未必能加分,还有可能导致减分,比如其维护与理解成本变高了。
|
||||
|
||||
而除了增加不必要的复杂性外,炫技的代码,也可能更容易出 Bug。
|
||||
|
||||
刚工作的头一年,我在广东省中国银行写过一个小程序,就是给所有广东省中国银行的信用卡客户发邮件账单。由于当时广东中行信用卡刚起步,第一个月只有不到 10 万客户,所以算是小程序。
|
||||
|
||||
这个小程序就是个单机程序,为了方便业务人员操作,我写了个 GUI 界面。这是我第一次用 Java Swing 库来写 GUI,为了展示发送进度,后台线程每发送成功一封邮件,就通知页面线程更新进度条。
|
||||
|
||||
为什么这么设计呢?因为那时我正在学习 Java 线程编程,感觉这个技术很高端,而当时的 Java JDK 都还没标配线程 concurrent 包。所以,我选择线程间通信的方案来让后台发送线程和前端界面刷新线程通信,这就有了一股浓浓的炫技味道。
|
||||
|
||||
之后,就出现了界面动不动就卡住等一系列问题,因为各种线程提前通知、遗漏通知等情况没考虑到,代码也越改越难懂。其实后来想想,用个共享状态,定时轮询即可满足需要,而且代码实现会简单很多(前面《架构与实现》一文中,关于实现的核心我总结了一个字:简。这都是血泪教训啊),出 Bug 的概率也小了很多。
|
||||
|
||||
回头想想,成长的路上不免见猎心喜,手上拿个锤子看到哪里都是钉子。
|
||||
|
||||
炫技是因为你想表达得不一样,就像平常说话,你要故意说得引经据典去彰显自己有文化,但其实效果不一定佳,因为我们更需要的是平实、易懂的表达。
|
||||
|
||||
克制
|
||||
|
||||
在说克制之前,先说说什么叫不克制,写代码的不克制。
|
||||
|
||||
刚工作的第二年,我接手了一个比较大的项目中的一个主要子系统。在熟悉了整个系统后,我开始往里面增加功能时,有点受不了原本系统设计分层中的 DAO(Data Access Object, 数据访问对象)层,那是基于原生的 JDBC 封装的。每次新增一个 DAO 对象都需要复制粘贴一串看起来很类似的代码,难免生出厌烦的感觉。
|
||||
|
||||
当时开源框架 Hibernate 刚兴起,我觉得它的设计理念优雅,代码写出来也简洁,所以就决定用 Hibernate 的方式来取代原本的实现。原来的旧系统里,说多不多,说少也不少,好几百个 DAO 类,而重新实现整个 DAO 层,让我连续加了一周的班。
|
||||
|
||||
这个替换过程,是个纯粹的搬砖体力活,弄完了还没松口气就又有了新问题:Hibernate 在某些场景下出现了性能问题。陆陆续续把这些新问题处理好,着实让我累了一阵子。后来反思这个决策感觉确实不太妥当,替换带来的好处仅仅是每次新增一个 DAO 类时少写几行代码,却带来很多当时未知的风险。
|
||||
|
||||
那时年轻,有激情啊,对新技术充满好奇与冲动。其实对于新技术,即使从我知道、我了解到我熟悉、我深谙,这时也还需要克制,要等待合适的时机。这让我想起了电影《勇敢的心》中的一个场景,是战场上华莱士看着对方冲过来,高喊:“Hold!Hold!”新技术的应用,也需要等待一个合适的出击时刻,也许是应用在新的服务上,也许是下一次架构升级。
|
||||
|
||||
不克制的一种形态是容易做出臆想的、通用化的假设,而且我们还会给这种假设安一个非常正当的理由:扩展性。不可否认,扩展性很重要,但扩展性也应当来自真实的需求,而非假设将来的某天可能需要扩展,因为扩展性的反面就是带来设计抽象的复杂性以及代码量的增加。
|
||||
|
||||
那么,如何才是克制的编程方式?我想可能有这样一些方面:
|
||||
|
||||
|
||||
克制的编码,是每次写完代码,需要去反思和提炼它,代码应当是直观的,可读的,高效的。
|
||||
克制的代码,是即使站在远远的地方去看屏幕上的代码,甚至看不清代码的具体内容时,也能感受到它的结构是干净整齐的,而非 “意大利面条” 似的混乱无序。
|
||||
克制的重构,是每次看到 “坏” 代码不是立刻就动手去改,而是先标记圈定它,然后通读代码,掌握全局,重新设计,最后再等待一个合适的时机,来一气呵成地完成重构。
|
||||
|
||||
|
||||
总之,克制是不要留下多余的想象,是不炫技、不追新,且恰到好处地满足需要,是一种平实、清晰、易懂的表达。
|
||||
|
||||
克制与炫技,匹配与适度,代码的技术深度未必体现在技巧上。有句话是这么说的:“看山是山,看水是水;看山不是山,看水不是水;看山还是山,看水还是水。”转了一圈回来,机锋尽敛,大巧若拙,深在深处,浅在浅处。
|
||||
|
||||
最后,亲爱的读者朋友,在你的编码成长过程中,有过想要炫技而不克制的时候吗?欢迎你留言。
|
||||
|
||||
|
||||
|
||||
|
186
专栏/程序员进阶攻略/11三阶段进化:调试,编写与运行代码.md
Normal file
186
专栏/程序员进阶攻略/11三阶段进化:调试,编写与运行代码.md
Normal file
@ -0,0 +1,186 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 三阶段进化:调试,编写与运行代码
|
||||
刚开始学编程写代码,总会碰到一些困惑。比如,曾经就有刚入行的同学问我:“写程序是想到哪写到哪,边写边改边验证好,还是先整体梳理出思路,有步骤、有计划地分析后,再写更好?”
|
||||
|
||||
老实说,我刚入行时走的是前一条路,因为没有什么人或方法论来指导我,都是自己瞎摸索。一路走来十多年后,再回溯编程之路的经历,总结编程的进化过程,大概会经历下面三个阶段。
|
||||
|
||||
阶段一:调试代码 Debugging
|
||||
|
||||
编程,是把用自然语言描述的现实问题,转变为用程序语言来描述并解决问题的过程;翻译,也是把一种语言的文字转变为另一种语言的文字,所以我想编程和翻译应该是有相通之处的。
|
||||
|
||||
好些年前,我曾偶然读到一篇关于性能的英文文章,读完不禁拍案叫绝,就忍不住想翻译过来。那是我第一次尝试翻译长篇英文,老实说翻得很痛苦,断断续续花了好几周的业余时间。那时的我,之于翻译,就是一个刚入门的初学者。
|
||||
|
||||
初次翻译,免不了遇到不少不熟悉的单词或词组,一路磕磕碰碰地查词典或 Google。一些似乎能理解含义的句子,却感觉无法很好地用中文来表达,如果直白地译出来感觉又不像正常的中文句子表达方式。
|
||||
|
||||
如是种种的磕碰之处,难道不像你刚学编程时候的情形吗?刚开始写代码,对语法掌握得不熟,对各种库和 API 不知道,不了解,也不熟悉。一路写代码,翻翻书,查查 Google,搜搜 API 文档,好不容易写完一段代码,却又不知道能否执行,执行能否正确等等。
|
||||
|
||||
小心翼翼地点击 Debug 按钮开始了单步调试之旅,一步步验证所有的变量或执行结果是否符合预期。如果出错了,是在哪一步开始或哪个变量出错的?一段不到一屏的代码,足足单步走了半小时,反复改了好几次,终于顺利执行完毕,按预期输出了执行结果。
|
||||
|
||||
如果不是自己写全新的代码,而是一来就接手了别人的代码,没有文档,前辈稍微给你介绍两句,你就很快又开始了 Debug 的单步调试之旅,一步步搞清代码运行的所有步骤和内部逻辑。根据你接手代码的规模,这个阶段可能持续数天到数周不等。
|
||||
|
||||
这就是我感觉可以划为编程第一阶段的 “调试代码 Debugging” 时期。这个时期或长或短,也许你曾经为各种编程工具或 IDE 提供的高级 Debug 功能激动不已,但如果你不逐渐降低使用Debug 功能的频率,那么你可能很难走入第二阶段。
|
||||
|
||||
阶段二:编写代码 Coding
|
||||
|
||||
翻译讲究 “信、达、雅”,编码亦如此。
|
||||
|
||||
那么何谓 “信、达、雅” ?它是由我国清末新兴启蒙思想家严复提出的,他在《天演论》中的 “译例言” 讲到:
|
||||
|
||||
|
||||
译事三难:信、达、雅。求其信已大难矣,顾信矣,不达,虽译犹不译也,则达尚焉。
|
||||
|
||||
|
||||
信,指不违背原文,不偏离原文,不篡改,不增不减,要求准确可信地表达原文描述的事实。
|
||||
|
||||
这条应用在编程上就是:程序员需要深刻地理解用户的原始需求。虽然需求很多时候来自于需求(产品)文档,但需求(产品)文档上写的并不一定真正体现了用户的原始需求。关于用户需求的“提炼”,早已有流传甚广的“福特之问”。
|
||||
|
||||
|
||||
福特:您需要一个什么样的更好的交通工具?-
|
||||
用户:我要一匹更快的马。
|
||||
|
||||
|
||||
用户说需要一匹更快的马,你就跑去 “养” 只更壮、更快的马;后来用户需求又变了,说要让马能在天上飞,你可能就傻眼了,只能拒绝用户说:“这需求不合理,技术上实现不了。”可见,用户所说的也不可 “信” 矣。只有真正挖掘并理解了用户的原始需求,最后通过编程实现的程序系统才是符合 “信” 的标准的。
|
||||
|
||||
但在这一条的修行上几乎没有止境,因为要做到 “信” 的标准,编写行业软件程序的程序员需要在一个行业长期沉淀,才能慢慢搞明白用户的真实需求。
|
||||
|
||||
达,指不拘泥于原文的形式,表达通顺明白,让读者对所述内容明达。
|
||||
|
||||
这条应用在编程上就是在说程序的可读性、可理解性和可维护性。
|
||||
|
||||
按严复的标准,只满足 “信” 一条的翻译,还不如不译,至少还需要满足 “达” 这条才算尚可。
|
||||
|
||||
同样,只满足 “信” 这一条的程序虽然能准确地满足用户的需要,但没有 “达” 则很难维护下去。因为程序固然是写给机器去执行的,但其实也是给人看的。
|
||||
|
||||
所有关于代码规范和风格的编程约束都是在约定 “达” 的标准。个人可以通过编程实践用时间来积累经验,逐渐达到 “达” 的标准。但一个团队中程序员们的代码风格差异如何解决?这就像如果一本书由一群人来翻译,你会发现每章的文字风格都有差异,所以我是不太喜欢读由一群人一起翻译的书。
|
||||
|
||||
一些流行建议的解决方案是:多沟通,深入理解别人的代码思路和风格,不要轻易盲目地修改。但这些年实践下来,这个方法在现实中走得并不顺畅。
|
||||
|
||||
随着微服务架构的流行,倒是提供了另一种解决方案:每个服务对应一个唯一的负责人(Owner)。长期由一个人来维护的代码,就不会那么容易腐烂,因为一个人不存在沟通问题。而一个人所能 “达” 到的层次,完全由个人的经验水平和追求来决定。
|
||||
|
||||
雅,指选用的词语要得体,追求文章本身的古雅,简明优雅。
|
||||
|
||||
雅的标准,应用在编程上已经从技艺上升到了艺术的追求,这当然是很高的要求与自我追求了,难以强求。而只有先满足于 “信” 和 “达” 的要求,你才有余力来追求 “雅” 。
|
||||
|
||||
举个例子来说明下从 “达” 到 “雅” 的追求与差异。
|
||||
|
||||
下面是一段程序片段,同一个方法,实现完全一样的功能,都符合 “信” 的要求;而方法很短小,命名也完全符合规范,可理解性和维护性都没问题,符合 “达” 的要求;差别就在对 “雅” 的追求上。
|
||||
|
||||
private String generateKey(String service, String method) {
|
||||
String head = "DBO$";
|
||||
String key = "";
|
||||
|
||||
int len = head.length() + service.length() + method.length();
|
||||
if (len <= 50) {
|
||||
key = head + service + method;
|
||||
} else {
|
||||
service = service.substring(service.lastIndexOf(".") + 1);
|
||||
len = head.length() + service.length() + method.length();
|
||||
key = head + service + method;
|
||||
if (len > 50) {
|
||||
key = head + method;
|
||||
if (key.length() > 50) {
|
||||
key = key.substring(0, 48) + ".~";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
该方法的目标是生成一个字符串 key 值,传入两个参数:服务名和方法名,然后返回 key 值,key 的长度受外部条件约束不能超过 50 个字符。方法实现不复杂,很短,看起来也还不错,分析下其中的逻辑:
|
||||
|
||||
|
||||
先 key 由固定的头(head)+ service(全类名)+ method(方法)组成,若小于 50 字符,直接返回。
|
||||
若超过 50 字符限制,则去掉包名,保留类名,再判断一次,若此时小于 50 字符则返回。
|
||||
若还是超过 50 字符限制,则连类名一起去掉,保留头和方法再判断一次,若小于 50 字符则返回。
|
||||
最后如果有个变态长的方法名(46+ 个字符),没办法,只好暴力截断到 50 字符返回。
|
||||
|
||||
|
||||
这个实现最大限度地在生成的 key 中保留全部有用的信息,对超过限制的情况依次按信息重要程度的不同进行丢弃。这里只有一个问题,这个业务规则只有 4 个判断,实现进行了三次 if 语句嵌套,还好这个方法比较短,可读性还不成问题。
|
||||
|
||||
而现实中很多业务规则比这复杂得多,以前看过一些实现的 if 嵌套多达 10 层的,方法也长得要命。当然一开始没有嵌套那么多层,只是后来随着时间的演变,业务规则发生了变化,慢慢增加了。之后接手的程序员就按照这种方式继续嵌套下去,慢慢演变至此,到我看到的时候就有 10 层了。
|
||||
|
||||
程序员有一种编程的惯性,特别是进行维护性编程时。一开始接手一个别人做的系统,不可能一下能了解和掌控全局。当要增加新功能时,在原有代码上添加逻辑,很容易保持原来程序的写法惯性,因为这样写也更安全。
|
||||
|
||||
所以一个 10 层嵌套 if 的业务逻辑方法实现,第一个程序员也许只写了 3 次嵌套,感觉还不错,也不失简洁。后来写 4、5、6 层的程序员则是懒惰不愿再改,到了写第 8、9、10 层的程序员时,基本很可能就是不敢再乱动了。
|
||||
|
||||
那么如何让这个小程序在未来的生命周期内,更优雅地演变下去?下面是另一个版本的实现:
|
||||
|
||||
private String generateKey(String service, String method) {
|
||||
String head = "DBO$";
|
||||
String key = head + service + method;
|
||||
|
||||
// head + service(with package) + method
|
||||
if (key.length() <= 50) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// head + service(without package) + method
|
||||
service = service.substring(service.lastIndexOf(".") + 1);
|
||||
key = head + service + method;
|
||||
if (key.length() <= 50) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// head + method
|
||||
key = head + method;
|
||||
if (key.length() <= 50) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// last, we cut the string to 50 characters limit.
|
||||
key = key.substring(0, 48) + ".~";
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
从嵌套变成了顺序逻辑,这样可以为未来的程序员留下更优雅地编程惯性方向。
|
||||
|
||||
阶段三:运行代码 Running
|
||||
|
||||
编程相对翻译,其超越 “信、达、雅” 的部分在于:翻译出来的文字能让人读懂,读爽就够了;但代码写出来还需要运行,才能产生最终的价值。
|
||||
|
||||
写程序我们追求 “又快又好”,并且写出来的代码要符合 “信、达、雅” 的标准,但清晰定义 “多快多好” 则是指运行时的效率和效果。为准确评估代码的运行效率和效果,每个程序员可能都需要深刻记住并理解下面这张关于程序延迟数字的图:
|
||||
|
||||
|
||||
|
||||
每个程序员都应该知道的延迟数字
|
||||
|
||||
只有深刻记住并理解了程序运行各环节的效率数据,你才有可能接近准确地评估程序运行的最终效果。当然,上面这张图只是最基础的程序运行效率数据,实际的生产运行环节会需要更多的基准效率数据才可能做出更准确的预估。
|
||||
|
||||
说一个例子,曾经我所在团队的一个高级程序员和我讨论要在所有的微服务中引入一个限流开源工具。这对于他和我们团队都是一个新东西,如何进行引入后线上运行效果的评估呢?
|
||||
|
||||
第一步,他去阅读资料和代码搞懂该工具的实现原理与机制并能清晰地描述出来。第二步,去对该工具进行效果测试,又称功能可用性验证。第三步,进行基准性能测试,或者又叫基准效率测试(Benchmark),以确定符合预期的标准。
|
||||
|
||||
做完上述三步,他拿出一个该工具的原理性描述说明文档,一份样例使用代码和一份基准效率测试结果,如下:
|
||||
|
||||
|
||||
|
||||
上图中有个红色字体部分,当阀值设置为 100 万而请求数超过 100 万时,发生了很大偏差。这是一个很奇怪的测试结果,但如果心里对各种基准效率数据有谱的话,会知道这实际绝不会影响线上服务的运行。
|
||||
|
||||
因为我们的服务主要由两部分组成:RPC 和业务逻辑。而 RPC 又由网络通信加上编解码序列化组成。服务都是 Java 实现的,而目前 Java 中最高效且吞吐最大的网络通信方式是基于 NIO 的方式,而我们服务使用的 RPC 框架正是基于 Netty(一个基于 Java NIO 的开源网络通信框架)的。
|
||||
|
||||
我曾经单独在一组 4 核的物理主机上测试过 Java 原生 NIO 与 Netty v3 和 v4 两个版本的基准性能对比,经过 Netty 封装后,大约有 10% 的性能损耗。在 1K 大小报文时,原生的 Java NIO 在当时的测试环境所能达到 TPS(每秒事务数) 的极限大约 5 万出头(极限,就是继续加压,但 TPS 不再上升,CPU 也消耗不上去,延时却在增加),而 Netty 在 4.5 万附近。增加了 RPC 的编解码后,TPS 极限下降至 1.3 万左右。
|
||||
|
||||
所以,实际一个服务在类似基准测试的环境下单实例所能承载的 TPS 极限不可能超过 RPC 的上限,因为 RPC 是没有包含业务逻辑的部分。加上不算简单的业务逻辑,我能预期的单实例真实 TPS 也许只有 1千 ~2 千。
|
||||
|
||||
因此,上面 100 万的阀值偏差是绝对影响不到单实例的服务的。当然最后我们也搞明白了,100 万的阀值偏差来自于时间精度的大小,那个限流工具采用了微秒作为最小时间精度,所以只能在百万级的范围内保证准确。
|
||||
|
||||
讲完上述例子,就是想说明一个程序员要想精确评估程序的运行效率和效果,就得自己动手做大量的基准测试。
|
||||
|
||||
基准测试和测试人员做的性能测试不同。测试人员做的性能测试都是针对真实业务综合场景的模拟,测试的是整体系统的运行;而基准测试是开发人员自己做来帮助准确理解程序运行效率和效果的方式,当测试人员在性能测试发现了系统的性能问题时,开发人员才可能一步步拆解根据基准测试的标尺效果找到真正的瓶颈点,否则大部分的性能优化都是在靠猜测。
|
||||
|
||||
到了这个阶段,一段代码写出来,基本就该在你头脑中跑过一遍了。等上线进入真实生产环境跑起来,你就可以拿真实的运行数据和头脑中的预期做出对比,如果差距较大,那可能就掩藏着问题,值得你去分析和思考。
|
||||
|
||||
最后,文章开头那个问题有答案了吗?在第一阶段,你是想到哪就写到哪;而到了第三阶段,写到哪,一段鲜活的代码就成为了你想的那样。
|
||||
|
||||
你现在处在编程的哪个阶段?有怎样的感悟?欢迎你留言分享。
|
||||
|
||||
|
||||
|
||||
|
84
专栏/程序员进阶攻略/12Bug的空间属性:环境依赖与过敏反应.md
Normal file
84
专栏/程序员进阶攻略/12Bug的空间属性:环境依赖与过敏反应.md
Normal file
@ -0,0 +1,84 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 Bug的空间属性:环境依赖与过敏反应
|
||||
从今天开始,咱们专栏进入 “程序之术” 中关于写代码的一个你可能非常熟悉,却也常苦恼的小主题:Bug。
|
||||
|
||||
写程序的路上,会有一个长期伴随你的 “同伴”:Bug,它就像程序里的寄生虫。不过,Bug 最早真的是一只虫子。
|
||||
|
||||
1947年,哈佛大学的计算机哈佛二代(Harvard Mark II)突然停止了运行,程序员在电路板编号为 70 的中继器触点旁发现了一只飞蛾。然后把飞蛾贴在了计算机维护日志上,并写下了首个发现 Bug 的实际案例。程序错误从此被称作 Bug。
|
||||
|
||||
这只飞蛾也就成了人类历史上的第一个程序 Bug。
|
||||
|
||||
回想下,在编程路上你遇到得最多的 Bug 是哪类?我的个人感受是,经常被测试或产品经理要求修改和返工的 Bug。这类 Bug 都来自于对需求理解的误差,其实属于沟通理解问题,我并不将其归类为真正的技术性 Bug。
|
||||
|
||||
技术性 Bug 可以从很多维度分类,而我则习惯于从 Bug 出现的 “时空” 特征角度来分类。可划为如下两类:
|
||||
|
||||
|
||||
空间:环境过敏
|
||||
时间:周期规律
|
||||
|
||||
|
||||
我们就先看看 Bug 的空间维度特征。
|
||||
|
||||
环境过敏
|
||||
|
||||
环境,即程序运行时的空间与依赖。
|
||||
|
||||
程序运行的依赖环境是很复杂的,而且一般没那么可靠,总是可能出现这样或那样的问题。曾经我经历过一次因为运行环境导致的故障案例:一开始系统异常表现出来的现象是,有个功能出现时不时的不可用;不久之后,系统开始报警,不停地接到系统的报警短信。
|
||||
|
||||
这是一个大规模部署的线上分布式系统,从一开始能感知到的个别系统功能异常到逐渐演变成大面积的报警和业务异常,这让我们陷入了一个困境:到底异常根源在哪里?为了迅速恢复系统功能的可用性,我们先把线上流量切到备用集群后,开始紧急地动员全体团队成员各自排查其负责的子系统和服务,终于找到了原因。
|
||||
|
||||
只是因为有个别服务器容器的磁盘故障,导致写日志阻塞,进程挂起,然后引发调用链路处理上的连锁雪崩效应,其影响效果就是整个链路上的系统都在报警。
|
||||
|
||||
互联网企业多采用普通的 PC Server 作为服务器,而这类服务器的可靠性大约在 99.9%,换言之就是出故障的概率是千分之一。而实际在服务器上,出问题概率最高的可能就是其机械硬盘。
|
||||
|
||||
Backblaze 2014 年发布的硬盘统计报告指出,根据对其数据中心 38000 块硬盘(共存储 100PB 数据)的统计,消费级硬盘头三年出故障的几率是 15%。而在一个足够大规模的分布式集群部署上,比如 Google 这种百万级服务器规模的部署级别上,几乎每时每刻都有硬盘故障发生。
|
||||
|
||||
我们的部署规模自是没有 Google 那么大,但也不算小了,运气不好,正好赶上我们的系统碰上磁盘故障,而程序的编写又并未考虑硬盘 I/O 阻塞导致的挂起异常问题,引发了连锁效应。
|
||||
|
||||
这就是当时程序编写缺乏对环境问题的考虑,引发了故障。人有时换了环境,会产生一些从生理到心理的过敏反应,程序亦然。运行环境发生变化,程序就出现异常的现象,我称其为 “程序过敏反应”。
|
||||
|
||||
以前看过一部美剧《豪斯医生》,有一集是这样的:一个手上出现红色疱疹的病人来到豪斯医生的医院,豪斯医生根据病症现象初步诊断为对某种肥皂产生了过敏,然后开了片抗过敏药,吃过后疱疹症状就减轻了。但一会儿后,病人开始出现呼吸困难兼并发哮喘,豪斯医生立刻给病人注射了 1cc 肾上腺素,之后病人呼吸开始变得平稳。但不久后病人又出现心动过速,而且很快心跳便停止了,经过一番抢救后,最终又回到原点,病人手上的红色疱疹开始在全身出现。
|
||||
|
||||
这个剧情中表现了在治疗病人时发生的身体过敏反应,然后引发了连锁效应的问题,这和我之前描述的例子有相通之处:都是局部的小问题,引发程序过敏反应,再到连锁效应。
|
||||
|
||||
过敏在医学上的解释是:“有机体将正常无害的物质误认为是有害的东西。”而我对 “程序过敏反应” 的定义是:“程序将存在问题的环境当作正常处理,从而产生的异常。”而潜在的环境问题通常就成了程序的 “过敏原”。
|
||||
|
||||
该如何应对这样的环境过敏引发的 Bug 呢?
|
||||
|
||||
应对之道
|
||||
|
||||
应对环境过敏,自然要先从了解环境开始。
|
||||
|
||||
不同的程序部署和运行的环境千差万别,有的受控,有的不受控。比如,服务端运行的环境,一般都在数据中心(IDC)机房内网中,相对受控;而客户端运行的环境是在用户的设备上,存在不同的品牌、不同的操作系统、不同的浏览器等等,多种多样,不可控。
|
||||
|
||||
环境那么复杂,你需要了解到何种程度呢?我觉得你至少必须关心与程序运行直接相关联的那一层环境。怎么理解呢?以后端 Java 程序的运行为例,Java 是运行在 JVM 中,那么 JVM 提供的运行时配置和特性就是你必须要关心的一层环境了。而 JVM 可能是运行在 Linux 操作系统或者是像 Docker 这样的虚拟化容器中,那么 Linux 或 Docker 这一层,理论上你的关心程度就没太多要求,当然,学有余力去了解到这一层次,自是更好的。
|
||||
|
||||
那么前文案例中的磁盘故障,已经到了硬件的层面,这个环境层次比操作系统还更低一层,这也属于我们该关心的?虽说故障的根源是磁盘故障,但直接连接程序运行的那一层,其实是日志库依赖的 I/O 特性,这才是我们团队应该关心、但实际却被忽略掉的部分。
|
||||
|
||||
同理,现今从互联网到移动互联网时代,几乎所有的程序系统都和网络有关,所以网络环境也必须是你关心的。但网络本身也有很多层次,而对于在网络上面开发应用程序的你我来说,可以把网络模糊抽象为一个层次,只用关心网络距离延时,以及应用程序依赖的具体平台相关网络库的 I/O 特性。
|
||||
|
||||
当然,如果能对网络的具体层次有更深刻的理解,自然也是更好的。事实上,如果你和一个对网络具体层次缺乏理解的人调试两端的网络程序,碰到问题时,经常会发现沟通不在一个层面上,产生理解困难。(这里推荐下隔壁的“趣谈网络协议”专栏)
|
||||
|
||||
了解了环境,也难免不出 Bug。因为我们对环境的理解是渐进式的,不可能一下子就完整掌握,全方位,无死角。当出现了因为环境产生的过敏反应时,收集足够多相关的信息才能帮助快速定位和解决问题,这就是前面《代码与分类》文章中 “运维” 类代码需要提供的服务。
|
||||
|
||||
收集信息,不仅仅局限于相关直接依赖环境的配置和参数,也包括用户输入的一些数据。真实场景确实大量存在这样一种情况:同样的环境只针对个别用户发生异常过敏反应。
|
||||
|
||||
有一种药叫抗过敏药,那么也可以有一种代码叫 “抗过敏代码”。在收集了足够的信息后,你才能编写这样的代码,因为现实中,程序最终会运行在一些一开始你可能没考虑到的环境中。收集到了这样的环境信息,你才能写出针对这种环境的 “抗过敏代码”。
|
||||
|
||||
这样的场景针对客户端编程特别常见,比如客户端针对运行环境进行的自检测和自适应代码。检测和适应范围包括:CPU、网络、存储、屏幕、操作系统、权限、安全等各方面,这些都属于环境抗过敏类代码。
|
||||
|
||||
而服务端相对环境一致性更好,可控,但面临的环境复杂性更多体现在 “三高” 要求,即:高可用、高性能、高扩展。针对 “三高” 的要求,服务端程序生产运行环境的可靠性并不如你想象的高,虽然平时的开发、调试中你可能很难遇到这些环境故障,但大规模的分布式程序系统,面向失败设计和编码(Design For Failure)则是服务端的 “抗过敏代码” 了。
|
||||
|
||||
整体简单总结一下就是:空间即环境,包括了程序的运行和依赖环境;环境是多维度、多层次的,你对环境的理解越全面、越深入,那么出现空间类 Bug 的几率也就越低;对环境的掌控有广度和深度两个方向,更有效的方法是先广度全面了解,再同步与程序直接相连的一层去深度理解,最后逐层深入,“各个击破”。
|
||||
|
||||
文章开头的第一只飞蛾 Bug,按我的分类就应该属于空间类 Bug 了,空间类 Bug 感觉麻烦,但若单独出现时,相对有形(异常现场容易捕捉);如果加上时间的属性,就变得微妙多了。
|
||||
|
||||
下一篇,我将继续为你分解 Bug 的时间维度特征。
|
||||
|
||||
|
||||
|
||||
|
101
专栏/程序员进阶攻略/13Bug的时间属性:周期特点与非规律性.md
Normal file
101
专栏/程序员进阶攻略/13Bug的时间属性:周期特点与非规律性.md
Normal file
@ -0,0 +1,101 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 Bug的时间属性:周期特点与非规律性
|
||||
在上一篇文章中,我说明了“技术性 Bug 可以从很多维度分类,而我则习惯于从 Bug 出现的 ‘时空’ 特征角度来分类”。并且我也已讲解了Bug 的空间维度特征:程序对运行环境的依赖、反应及应对。
|
||||
|
||||
接下来我再继续分解 Bug 的时间维度特征。
|
||||
|
||||
Bug 有了时间属性,Bug 的出现就是一个概率性问题了,它体现出如下特征。
|
||||
|
||||
周期特点
|
||||
|
||||
周期特点,是一定频率出现的 Bug 的特征。
|
||||
|
||||
这类 Bug 因为会周期性地复现,相对还是容易捕捉和解决。比较典型的呈现此类特征的 Bug 一般是资源泄漏问题。比如,Java 程序员都不陌生的 OutOfMemory 错误,就属于内存泄漏问题,而且一定会周期性地出现。
|
||||
|
||||
好多年前,我才刚参加工作不久,就碰到这么一个周期性出现的 Bug。但它的特殊之处在于,出现 Bug 的程序已经稳定运行了十多年了,突然某天开始就崩溃(进程 Crash)了。而程序的原作者,早已不知去向,十多年下来想必也已换了好几代程序员来维护了。
|
||||
|
||||
一开始项目组内经验老到的高工认为也许这只是一个意外事件,毕竟这个程序已经稳定运行了十来年了,而且检查了一遍程序编译后的二进制文件,更新时间都还停留在那遥远的十多年前。所以,我们先把程序重启起来让业务恢复,重启后的程序又恢复了平稳运行,但只是安稳了这么一天,第二天上班没多久,进程又莫名地崩溃了,我们再次重启,但没多久后就又崩溃了。这下没人再怀疑这是意外了,肯定有 Bug。
|
||||
|
||||
当时想想能找出一个隐藏了这么多年的 Bug,还挺让人兴奋的,就好像发现了埋藏在地下久远的宝藏。
|
||||
|
||||
寻找这个 Bug 的过程有点像《盗墓笔记》中描述的盗墓过程:项目经理(三叔)带着两个高级工程师(小哥和胖子)连续奋战了好几天,而我则是个新手,主要负责 “看门”,在他们潜入跟踪分析探索的过程中,我就盯着那个随时有可能崩溃的进程,一崩掉就重启。他们“埋伏”在那里,系统崩溃后抓住现场,定位到对应的源代码处,最后终于找到了原因并顺利修复。
|
||||
|
||||
依稀记得,最后定位到的原因与网络连接数有关,也是属于资源泄漏的一种,只是因为过去十来年交易量一直不大且稳定,所以没有显现出来。但在我参加工作那年(2006年),中国股市悄然引来一场有史以来最大的牛市,这个处理银行和证券公司之间资金进出的程序的“工作量”突然出现了爆发性增长,从而引发了该 Bug。
|
||||
|
||||
我可以理解上世纪九十年代初那个编写该服务进程的程序员,他可能也难以预料到当初写的用者寥寥的程序,最终在十多年后的一天会服务于成百上千万的用户。
|
||||
|
||||
周期性的 Bug,虽然乍一看很难解决的样子,但它总会重复出现,就像可以重新倒带的 “案发现场”,找到真凶也就简单了。案例中这个 Bug 隐藏的时间很长,但它所暴露出的周期特点很明显,解决起来也就没那么困难。
|
||||
|
||||
其实主要麻烦的是那种这次出现了,但不知道下次会在什么时候出现的 Bug。
|
||||
|
||||
非规律性
|
||||
|
||||
没有规律性的 Bug,才是让人抓狂的。
|
||||
|
||||
曾经我接手过一个系统,是一个典型的生产者、消费者模型系统。系统接过来就发现一个比较明显的性能瓶颈问题,生产者的数据源来自数据库,生产者按规则提取数据,经过系统产生一系列的转换渲染后发送到多个外部系统。这里的瓶颈就在数据库上,生产能力不足,从而导致消费者饥饿。
|
||||
|
||||
问题比较明显,我们先优化 SQL,但效果不佳,遂改造设计实现,在数据库和系统之间增加一个内存缓冲区从而缓解了数据库的负载压力。缓冲区的效果,类似大河之上的堤坝,旱时积水,涝时泄洪。引入缓冲区后,生产者的生产能力得到了有效保障,生产能力高效且稳定。
|
||||
|
||||
本以为至此解决了该系统的瓶颈问题,但在生产环境运行了一段时间后,系统表现为速度时快时慢,这时真正的 Bug 才显形了。
|
||||
|
||||
这个系统有个特点,就是 I/O 密集型。消费者要与多达 30 个外部系统并发通信,所以猜测极有可能导致系统性能不稳定的 Bug 就在此,于是我把目光锁定在了消费者与外部系统的 I/O 通信上。既然锁定了怀疑区域,接下来就该用证据来证明,并给出合理的解释原因了。一开始假设在某些情况下触碰到了阈值极限,当达到临界点时程序性能则急剧下降,不过这还停留在怀疑假设阶段,接下来必须量化验证这个推测。
|
||||
|
||||
那时的生产环境不太方便直接验证测试,我便在测试环境模拟。用一台主机模拟外部系统,一台主机模拟消费者。模拟主机上的线程池配置等参数完全保持和生产环境一致,以模仿一致的并发数。通过不断改变通信数据包的大小,发现在数据包接近 100k 大小时,两台主机之间直连的千兆网络 I/O 达到满负载。
|
||||
|
||||
于是,再回头去观察生产环境的运行状况,当一出现性能突然急剧下降的情况时,立刻分析了生产者的数据来源。其中果然有不少大报文数据,有些甚至高达 200k,至此基本确定了与外部系统的 I/O 通信瓶颈。解决办法是增加了数据压缩功能,以牺牲 CPU 换取 I/O。
|
||||
|
||||
增加了压缩功能重新上线后,问题却依然存在,系统性能仍然时不时地急剧降低,而且这个时不时很没有时间规律,但关联上了一个 “嫌疑犯”:它的出现和大报文数据有关,这样复现起来就容易多了。I/O 瓶颈的怀疑被证伪后,只好对程序执行路径增加了大量跟踪调试诊断代码,包含了每个步骤的时间度量。
|
||||
|
||||
在完整的程序执行路径中,每个步骤的代码块的执行时间独立求和结果仅有几十毫秒,最高也就在一百毫秒左右,但多线程执行该路径的汇总平均时间达到了 4.5 秒,这比我预期值整整高了两个量级。通过这两个时间度量的巨大差异,我意识到线程执行该代码路径的时间其实并不长,但花在等待 CPU 调度的时间似乎很长。
|
||||
|
||||
那么是 CPU 达到了瓶颈么?通过观察服务器的 CPU 消耗,平均负载却不高。只好再次分析代码实现机制,终于在数据转换渲染子程序中找到了一段可疑的代码实现。为了验证疑点,再次做了一下实验测试:用 150k 的线上数据报文作为该程序输入,单线程运行了下,发现耗时居然接近 50 毫秒,我意识到这可能是整个代码路径中最耗时的一个代码片段。
|
||||
|
||||
由于这个子程序来自上上代程序员的遗留代码,包含一些稀奇古怪且复杂的渲染逻辑判断和业务规则,很久没人动过了。仔细分析了其中实现,基本就是大量的文本匹配和替换,还包含一些加密、Hash 操作,这明显是一个 CPU 密集型的函数啊。那么在多线程环境下,运行这个函数大概平均每个线程需要多少时间呢?
|
||||
|
||||
先从理论上来分析下,我们的服务器是 4 核,设置了 64 个线程,那么理想情况下同一时间可以运行 4 个线程,而每个线程执行该函数约为 50 毫秒。这里我们假设 CPU 50 毫秒才进行线程上下文切换,那么这个调度模型就被简化了。第一组 4 个线程会立刻执行,第二组 4 个线程会等待 50 毫秒,第三组会等待 100 毫秒,依此类推,第 16 组线程执行时会等待 750 毫秒。平均下来,每组线程执行前的平均等待时间应该是在 300 到 350 毫秒之间。这只是一个理论值,实际运行测试结果,平均每个线程花费了 2.6 秒左右。
|
||||
|
||||
实际值比理论值慢一个量级,这是为什么呢?因为上面理论的调度模型简化了 CPU 的调度机制,在线程执行过程的 50 毫秒中,CPU 将发生非常多次的线程上下文切换。50 毫秒对于 CPU 的时间分片来说,实在是太长了,因为线程上下文的多次切换和 CPU 争夺带来了额外的开销,导致在生产环境上,实际的监测值达到了 4.5 秒,因为整个代码路径中除了这个非常耗时的子程序函数,还有额外的线程同步、通知和 I/O 等操作。
|
||||
|
||||
分析清楚后,通过简单优化该子程序的渲染算法,从近 50 毫秒降低到 3、4 毫秒后,整个代码路径的线程平均执行时间下降到 100 毫秒左右。收益是明显的,该子程序函数性能得到了 10 倍的提高,而整体执行时间从 4.5 秒降低为 100 毫秒,性能提高了 45 倍。
|
||||
|
||||
至此,这个非规律性的 Bug 得到了解决。
|
||||
|
||||
虽然案例中最终解决了 Bug,但用的方法却非正道,更多依靠的是一些经验性的怀疑与猜测,再去反过来求证。这样的方法局限性非常明显,完全依赖程序员的经验,然后就是运气了。如今再来反思,一方面由于是刚接手的项目,所以我对整体代码库掌握还不够熟悉;另一方面也说明当时对程序性能的分析工具了解有限。
|
||||
|
||||
而更好的办法就应该是采用工具,直接引入代码 Profiler 等性能剖析工具,就可以准确地找到有性能问题的代码段,从而避免了看似有理却无效的猜测。
|
||||
|
||||
面对非规律性的 Bug,最困难的是不知道它的出现时机,但一旦找到它重现的条件,解决起来也没那么困难了。
|
||||
|
||||
神出鬼没
|
||||
|
||||
能称得上神出鬼没的 Bug 只有一种:海森堡 Bug(Heisenbug)。
|
||||
|
||||
这个 Bug 的名字来自量子物理学的 “海森堡不确定性原理”,其认为观测者观测粒子的行为会最终影响观测结果。所以,我们借用这个效应来指代那些无法进行观测的 Bug,也就是在生产环境下不经意出现,费尽心力却无法重现的 Bug。
|
||||
|
||||
海森堡 Bug 的出现场景通常都是和分布式的并发编程有关。我曾经在写一个网络服务端程序时就碰到过一次海森堡 Bug。这个程序在稳定性负载测试时,连续跑了十多个小时才出现了一次异常,然后在之后的数天内就再也不出现了。
|
||||
|
||||
第一次出现时捕捉到的现场信息太少,然后增加了更多诊断日志后,怎么测都不出现了。最后是怎么定位到的?还好那个程序的代码量不大,就天天反复盯着那些代码,好几天过去还真就灵光一现发现了一个逻辑漏洞,而且从逻辑推导,这个漏洞如果出现的话,其场景和当时测试发现的情况是吻合的。
|
||||
|
||||
究其根源,该 Bug 复现的场景与网络协议包的线程执行时序有关。所以,一方面比较难复现,另一方面通过常用的调试和诊断手段,诸如插入日志语句或是挂接调试器,往往会修改程序代码,或是更改变量的内存地址,或是改变其执行时序。这都影响了程序的行为,如果正好影响到了 Bug,就可能诞生了一个海森堡 Bug。
|
||||
|
||||
关于海森堡 Bug,一方面很少有机会碰到,另一方面随着你编程经验的增加,掌握了很多编码的优化实践方法,也会大大降低撞上海森堡 Bug 的几率。
|
||||
|
||||
综上所述,每一个 Bug 都是具体的,每一个具体的 Bug 都有具体的解法。但所有 Bug 的解决之道只有两类:事后和事前。
|
||||
|
||||
事后,就是指 Bug 出现后容易捕捉现场并定位解决的,比如第一类周期特点的 Bug。但对于没有明显重现规律,甚至神出鬼没的海森堡 Bug,靠抓现场重现的事后方法就比较困难了。针对这类 Bug,更通用和有效的方法就是在事前预防与埋伏。
|
||||
|
||||
之前在讲编程时说过一类代码:运维代码,它们提供的一种能力就像人体血液中的白细胞,可以帮助发现、诊断、甚至抵御 Bug 的 “入侵”。
|
||||
|
||||
而为了得到一个更健康、更健壮的程序,运维类代码需要写到何种程度,这又是编程的 “智慧” 领域了,充满了权衡选择。
|
||||
|
||||
程序员不断地和 Bug 对抗,正如医生不断和病菌对抗。不过Bug 的存在意味着这是一段活着的、有价值的代码,而死掉的代码也就无所谓 Bug 了。
|
||||
|
||||
在你的程序员职业生涯中,有碰到过哪些有意思的 Bug呢?欢迎你给我留言分享讨论。
|
||||
|
||||
|
||||
|
||||
|
150
专栏/程序员进阶攻略/14Bug的反复出现:重蹈覆辙与吸取教训.md
Normal file
150
专栏/程序员进阶攻略/14Bug的反复出现:重蹈覆辙与吸取教训.md
Normal file
@ -0,0 +1,150 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 Bug的反复出现:重蹈覆辙与吸取教训
|
||||
Bug 除了时间和空间两种属性,还有一个特点是和程序员直接相关的。在编程的路上,想必你也曾犯过一些形态各异、但本质重复的错误,导致一些 Bug 总是以不同的形态反复出现。在你捶胸顿足懊恼之时,不妨试着反思一下:为什么你总会写出有 Bug 的程序,而且有些同类型的 Bug 还会反复出现?
|
||||
|
||||
|
||||
重蹈覆辙
|
||||
——–
|
||||
|
||||
|
||||
重蹈覆辙的错误,老实说曾经我经历过不止一次。
|
||||
|
||||
也许每次具体的形态可能有些差异,但仔细究其本质却是类似的。想要写出没有 Bug 的程序是不可能的,因为所有的程序员都受到自身能力水平的局限。而我所经历的重蹈覆辙型错误,总结下来大概都可以归为以下三类原因。
|
||||
|
||||
1.1 粗心大意
|
||||
|
||||
人人都会犯粗心大意的错误,因为这就是 “人” 这个系统的普遍固有缺陷(Bug)之一。所以,作为人的程序员一定会犯一些非常低级的、因为粗心大意而导致的 Bug。
|
||||
|
||||
这就好比写文章、写书都会有错别字,即使经历过三审三校后正式出版的书籍,都无法完全避免错别字的存在。
|
||||
|
||||
而程序中也有这类 “错别字” 类型的低级错误,比如:条件if 后面没有大括号导致的语义变化,==、= 和 === 的数量差别,++ 或-- 的位置,甚至 ;的有无在某些编程语言中带来的语义差别。即使通过反复检查也可能有遗漏,而自己检查自己的代码会更难发现这些缺陷,这和自己不容易发现自己的错别字是一个道理。
|
||||
|
||||
心理学家汤姆·斯塔福德(Tom Stafford)曾在英国谢菲尔德大学研究拼写错误,他说:“当你在书写的时候,你试图传达想法,这是非常高级的任务。而在做高级任务时,大脑将简单、零碎的部分(拼词和造句)概化,这样就可以更专注于更复杂的任务,比如将句子变成复杂的观点。”
|
||||
|
||||
而在阅读时,他解释说:“我们不会抓住每个细节,相反,我们吸收感官信息,将感觉和期望融合,并且从中提炼意思。”这样,如果我们读的是他人的作品,就能帮助我们用更少的脑力更快地理解含义。
|
||||
|
||||
但当我们验证自己的文章时,我们知道想表达的东西是什么。因为我们预期这些含义都存在,所以很容易忽略掉某些感官(视觉)表达上的缺失。我们眼睛看到的,在与我们脑子里的印象交战。这,便是我们对自己的错误视而不见的原因。
|
||||
|
||||
写程序时,我们是在进行一项高级的复杂任务:将复杂的需求或产品逻辑翻译为程序逻辑,并且还要补充上程序固有的非业务类控制逻辑。因而,一旦我们完成了程序,再来复审写好的代码,这时我们预期的逻辑含义都预先存在于脑中,同样也就容易忽略掉某些视觉感官表达上的问题。
|
||||
|
||||
从进化角度看,粗心写错别字,还看不出来,不是因为我们太笨,而恰恰还是进化上的权衡优化选择。
|
||||
|
||||
1.2 认知偏差
|
||||
|
||||
认知偏差,是重蹈覆辙类错误的最大来源。
|
||||
|
||||
曾经,我就对 Java 类库中的线程 API 产生过认知偏差,导致反复出现问题。Java 自带线程池有三个重要参数:核心线程数(core)、最大线程数(max)和队列长度(queues)。我曾想当然地以为当核心线程数(core)不够了,就会继续创建线程达到最大线程数(max),此时如果还有任务需要处理但已经没有线程了就会放进队列等待。
|
||||
|
||||
但实际却不是这样工作的,类库的实现是核心线程(core)满了就会进队列(queues)等待,直到队列也满了再创建新线程直至达到最大线程数(max)的限制。这类认知偏差曾带来线上系统的偶然性异常故障,然后还怎么都找不到原因。因为这进入了我的认知盲区,我以为的和真正的现象之间的差异一度让我困惑不解。
|
||||
|
||||
还有一个来自生活中的小例子,虽然不是关于程序的,但本质是一个性质。
|
||||
|
||||
有时互联网上,朋友圈中小道消息满天飞,与此类现象有关的一个成语叫 “空穴来风”,现在很多媒体文章有好多是像下面这样用这个成语的:
|
||||
|
||||
|
||||
他俩要离婚了?看来空穴来风,事出有因啊!-
|
||||
物价上涨的传闻恐怕不是空穴来风。
|
||||
|
||||
|
||||
第一句是用的成语原意:指有根据、有来由,“空”发三声读 kǒng,意同 “孔”。第二句是表达:没有根据和由来,“空”发一声读kōnɡ。第二种的新意很多名作者和普通大众沿用已久,约定俗成,所以又有辞书与时俱进增加了这个新的义项,允许这两种完全相反的解释并存,自然发展,这在语义学史上也不多见。
|
||||
|
||||
而关于程序上有些 API 的定义和实现也犯过 “空穴来风” 的问题,一个 API 可以表达两种完全相反的含义和行为。不过这样的 API 就很容易引发认知偏差导致的 Bug,所以在设计和实现 API 时我们就要避免这种情况的出现,而是要提供单一原子化的设计。
|
||||
|
||||
1.3 熵增问题
|
||||
|
||||
熵增,是借用了物理热力学的比喻,表达更复杂混乱的现象;程序规模变大,复杂度变高之后,再去修改程序或添加功能就更容易引发未知的 Bug。
|
||||
|
||||
腾讯曾经分享过 QQ 的架构演进变化,到了 3.5 版本 QQ 的用户在线规模进入亿时代,此时在原有架构下去新增一些功能,比如:
|
||||
|
||||
|
||||
“昵称” 长度增加一半,需要两个月;
|
||||
|
||||
增加 “故乡” 字段,需要两个月;
|
||||
|
||||
最大好友数从 500 变成 1000,需要三个月。
|
||||
|
||||
|
||||
后端系统的高度复杂性和耦合作用导致即使增加一些小功能特性,也可能带来巨大的牵连影响,所以一个小改动才需要数月时间。
|
||||
|
||||
我们不断进行架构升级的本质,就在于随着业务和场景功能的增加,去控制住程序系统整体 “熵” 的增加。而复杂且耦合度高(熵很高)的系统,正是容易滋生 Bug 的温床。
|
||||
|
||||
|
||||
吸取教训
|
||||
——–
|
||||
|
||||
|
||||
为了避免重蹈覆辙,我们有什么办法来吸取曾经犯错的教训么?
|
||||
|
||||
2.1 优化方法
|
||||
|
||||
粗心大意,可以通过开发规范、代码风格、流程约束,代码评审和工具检查等工程手段来加以避免。甚至相对写错别字,代码更进一步,通过补充单元测试在运行时做一个正确性后验,反过来去发现这类我们视而不见的低级错误。
|
||||
|
||||
认知偏差,一般没什么太好的自我发现机制,但可以依赖团队和技术手段来纠偏。每次掉坑里爬出来后的经验教训总结和团队内部分享,另外就是像一些静态代码扫描工具也提供了内置的优化实践,通过它们的提示来发现与你的认知产生碰撞纠偏。
|
||||
|
||||
熵增问题,业界不断迭代更新流行的架构模式就是在解决这个问题。比如,微服务架构相对曾经的单体应用架构模式,就是通过增加开发协作,部署测试和运维上的复杂度来换取系统开发的敏捷性。在协作方式、部署运维等方面付出的代价都可以通过提升自动化水平来降低成本,但只有编程活动是没法自动化的,依赖程序员来完成,而每个程序员对复杂度的驾驭能力是有不同上限的。
|
||||
|
||||
所以,微服务本质上就是将一个大系统的熵增问题,局部化在一个又一个的小服务中。而每个微服务都有一个熵增的极限值,而这个极限值一般是要低于该服务负责人的驾驭能力上限的。对于一个熵增接近极限附近的微服务,服务负责人就需要及时重构优化,降低熵的水平。而高水平和低水平程序员负责的服务本质差别在于熵的大小。
|
||||
|
||||
而熵增问题若不及时重构优化,最后可能会付出巨大的代价。
|
||||
|
||||
丰田曾陷入的 “刹车门” 事件,就是因为其汽车动力控制系统软件存在缺陷。而为追查其原因,在十八个月中,有 12 位嵌入式系统专家受原告诉讼团所托,被关在马里兰州一间高度保安的房间内对丰田动力控制系统软件(主要是 2005 年的凯美瑞)源代码进行深度审查。最后得到的结论把丰田的软件缺陷分为三类:
|
||||
|
||||
|
||||
非常业余的结构设计
|
||||
不符合软件开发规范
|
||||
对关键变量缺乏保护
|
||||
|
||||
|
||||
第一类属于熵增问题,导致系统规模不断变大、变复杂,结果驾驭不了而失控;第二类属于开发过程的认知与管理问题;第三类才是程序员实现上的水平与粗心大意问题。
|
||||
|
||||
2.2 塑造环境
|
||||
|
||||
为了修正真正的错误,而不是头痛医头、脚痛医脚,我们需要更深刻地认识问题的本质,再来开出 “处方单”。
|
||||
|
||||
在亚马逊(Amazon),严重的故障需要写一个 COE(Correction of Errors)的文档,这是一种帮助去总结经验教训,加深印象避免再犯的形式。其目的也是为了帮助认识问题的本质,修正真正的错误。
|
||||
|
||||
但一旦这个东西和 KPI 之类的挂上钩,引起的负面作用是 COE 的数量会变少,但真正的问题并没有减少,只是被隐藏了。而其正面的效应像总结经验、吸取教训、找出真正问题等,就会被大大削弱。
|
||||
|
||||
关于如何构造一个鼓励修正错误的环境,我们可以看看来自《异类》一书讲述的大韩航空的例子,大韩航空曾一度困扰于它的飞机损失率:
|
||||
|
||||
|
||||
美国联合航空 1988 年到 1998 年的飞机损失率为百万分之 0.27,也就是说联合航空每飞行 400 万次,会在一次事故中损失一架飞机;而大韩航空同期的飞机损失率为百万分之 4.79,是前者的 17 倍之多。
|
||||
|
||||
|
||||
事实上大韩航空的飞机也是买自美国,和联合航空并无多大差别。它的飞行员们的飞行时长,经验和训练水平从统计数据看也差别不大,那为什么飞机损失率会如此地高于其他航空公司的平均水平呢?在《异类》这本书中,作者以此为案例做了详细分析,我这里直接引用结论。
|
||||
|
||||
|
||||
现代商业客机,就目前发展水平而言,跟家用烤面包机一样可靠。空难很多时候是一系列人为的小失误、机械的小故障累加的结果,一个典型空难通常包括 7 个人为的错误。
|
||||
|
||||
|
||||
一个飞机上有正副两个机长,副机长的作用是帮助发现、提醒和纠正机长在飞行过程中可能发生的一些人为小错误。大韩航空的问题正在于副机长是否敢于以及如何提醒纠正机长的错误。其背后的理论依据源自荷兰心理学家吉尔特·霍夫斯泰德(Geert Hofstede)对不同族裔之间文化差异的研究,就是今天被社会广泛接受的跨文化心理学经典理论框架:霍夫斯泰德文化纬度(Hofstede’s Dimensions)。
|
||||
|
||||
|
||||
在霍夫斯泰德的几个文化维度中,最引人注目的大概就是 “权力距离指数(Power Distance Index)”。权力距离是指人们对待比自己更高等级阶层的态度,特别是指对权威的重视和尊重程度。
|
||||
|
||||
而霍夫斯泰德的研究也提出了一个航空界专家从未想到过的问题:让副机长在机长面前维护自己的意见,必须帮助他们克服所处文化的权力距离。
|
||||
|
||||
|
||||
想想我们看过的韩国电影或电视剧中,职场上后辈对前辈、下级对上级的态度,就能感知到韩国文化相比美国所崇尚的自由精神所表现出来的权力距离是特别远的。因而造成了大韩航空未被纠正的人为小错误比例更高,最终的影响是空难率也更高,而空难就是航空界的终极系统故障,而且结果不可挽回。
|
||||
|
||||
吸取大韩航空的教训应用到软件系统开发和维护上,就是:需要建立和维护有利于程序员及时暴露并修正错误,挑战权威和主动改善系统的低权力距离文化氛围,这其实就是推崇扁平化管理和 “工程师文化” 的关键所在。
|
||||
|
||||
一旦系统出了故障非技术背景的管理者通常喜欢用流程、制度甚至价值观来应对问题,而技术背景的管理者则喜欢从技术本身的角度去解决当下的问题。我觉着两者需要结合,站在更高的维度去考虑问题:规则、流程或评价体系的制定所造成的文化氛围,对于错误是否以及何时被暴露,如何被修正有着决定性的影响。
|
||||
|
||||
我们常与错误相伴,查理·芒格说:
|
||||
|
||||
|
||||
世界上不存在不犯错误的学习或行事方式,只是我们可以通过学习,比其他人少犯一些错误,也能够在犯了错误之后,更快地纠正错误。但既要过上富足的生活又不犯很多错误是不可能的。实际上,生活之所以如此,是为了让你们能够处理错误。
|
||||
|
||||
|
||||
人固有缺陷,程序固有 Bug;吸取教训避免重蹈覆辙,除了不断提升方法,也要创造环境。你觉得呢?欢迎你留言和我分享。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
94
专栏/程序员进阶攻略/15根源:计划的愿景——仰望星空.md
Normal file
94
专栏/程序员进阶攻略/15根源:计划的愿景——仰望星空.md
Normal file
@ -0,0 +1,94 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 根源:计划的愿景——仰望星空
|
||||
在前面第 2 章节 “程序之术” 中,我已把对“设计”“编程”和“Bug”的思考与理解都分享给你了。今天开始进入第 3 章节,是关于成长修行中 “由术入道” 的部分,而“道”的维度众多,我就先从和个人成长最直接相关的 “计划体系” 讲起。它会有助于你一步一步走向你“理想的自己”,所以可别小看它的重要性。
|
||||
|
||||
我想你肯定做过计划,我也不例外。一般在开始一件中长期的活动前,我都会做计划,但更重要的是反问为什么要做这个计划,因为计划是抵达愿望的途径。如果不能清晰地看见计划之路前方的愿景,计划半途而废的概率就很大了。
|
||||
|
||||
古希腊哲学家苏格拉底有一句名言:“未经检视的人生不值得活。”那么我们为什么要检视自己的人生呢?正是因为我们有成长的愿望,那么愿望的根源又到底是什么呢?
|
||||
|
||||
需求模型
|
||||
|
||||
上世纪四十年代(1943 年)美国心理学家亚伯拉罕·马斯洛在《人类激励理论》中提出了需求层次理论模型,它是行为科学的理论之一。
|
||||
|
||||
该理论认为个体成长发展的内在力量是动机,而动机是由多种不同性质的需要所组成,各种需要之间,有先后顺序与高低层次之分,每一层次的需要与满足,将决定个体人格发展的境界或程度。 其层次模型的经典金字塔图示如下:
|
||||
|
||||
|
||||
|
||||
马斯洛的经典金字塔图:需求层次模型
|
||||
|
||||
在人生的不同阶段,会产生不同层次的目标需求。
|
||||
|
||||
在人生的早期,我们努力学习,考一个好大学,拥有一技之长,找一份好工作,带来更高薪的收入,这很大程度都是为了满足图中最底层的生存需求,让生活变得更舒适美好。
|
||||
|
||||
成长拼搏数年,事业小成,工作稳定,有房,有车,有娃后,第二层次,也就是安全的需求开始凸显。有人在这阶段开始给自己、父母、老婆、孩子都买人寿保险,开始考虑理财,投资甚至强身健体。然而处在这个阶段时,我却有一种强烈的不安全感,这也许和长年的程序员职业经历养成的习惯也有关系。
|
||||
|
||||
我们做系统应用服务时总是需要考虑各种意外和异常事件发生,一般至少提供主备方案。于人生而言,保持持续学习,与时俱进,追求成长,这其实也是一种主备方案:主,指当前支撑生活的工作;备,是通过持续学习,同步成长,保持核心能力的不断积累与时间的付出来获得一份备份保障,以避免 “主” 出现意外时,“备” 的能力已被时代淘汰。
|
||||
|
||||
需求金字塔底部两层属于物质层次的 “经济基础”,而再往上则进入了更高精神层次的 “上层建筑”。就个体而言,高层次需求要比低层次需求具有更大的价值。在 “生存” 和 “安全” 基本满足的保障基础之上,我们才会更从容地向内求,更多地探求内心,进而向外索,对外去探索、发现和建立不同的圈层关系,以满足上层的社交 “归属”、获得 “尊重” 与 “自我实现” 的需求。
|
||||
|
||||
马斯洛把底层的四类需求:生存、安全、归属、尊重归类为 “缺失性” 需求,它们的满足需要从外部环境去获得。而最顶层的“自我实现” 则属于 “成长性” 需求。成长就是自我实现的过程,成长的动机也来自于 “自我实现” 的吸引。就像很多植物具有天生的向阳性,而对于人,我感觉也有天生的 “自我实现” 趋向性。
|
||||
|
||||
人生最激荡人心的时刻,就在于自我实现的创造性过程中,产生出的一种 “高峰体验” 感。正因为人所固有的需求层次模型,我们才有了愿望,愿望产生目标,目标则引发计划。
|
||||
|
||||
生涯发展
|
||||
|
||||
在攀登需求金字塔的过程中,我们创造了关于人生的 “生涯”。而 “生涯” 一词最早来自庄子语:
|
||||
|
||||
|
||||
吾生也有涯,而知也无涯。以有涯随无涯,殆已。
|
||||
|
||||
|
||||
“涯” 字的原意是水边,隐喻人生道路的尽头,尽头已经没了路,是终点,是边界。正因如此,人生有限,才需要计划。著名生涯规划师古典有一篇文章《你的生命有什么可能?》对生涯提出了四个维度:高度、宽度、深度和温度。这里就借他山之玉,来谈谈我的理解。
|
||||
|
||||
|
||||
高度:背后的价值观是影响与权力。代表性关键词有:追逐竞争、改变世界。
|
||||
深度:背后的价值观是卓越与智慧。代表性关键词有:专业主义、工匠精神。
|
||||
宽度:背后的价值观是博爱与和谐。代表性关键词有:多种角色、丰富平衡。
|
||||
温度:背后的价值观是自由与快乐。代表性关键词有:自我认同、精彩程度。
|
||||
|
||||
|
||||
每个人的人生发展路线都会有这四个维度,只是不同人的偏好、愿望和阶段不同导致了在四个维度分布重心的差异。在不同维度的选择,都代表了不一样的 “生涯”,每一种 “生涯” 都需要一定程度的计划与努力。
|
||||
|
||||
虽有四种维度,四个方向,但不代表只能选其一。虽然我们不太可能同时去追求这四个维度,但可以在特定的人生不同阶段,在其中一个维度上,给自己一个去尝试和探索的周期。所以,这就有了选择,有了计划。而计划会有开始,也会有结束,我们需要计划在人生的不同阶段,重点开始哪个维度的追求,以及大概需要持续的周期。
|
||||
|
||||
人生本是多维的,你会有多努力、多投入来设计并实现自己的生涯规划呢?不计划和努力一下,也许你永远无法知道自己的边界和所能达到的程度。
|
||||
|
||||
上世纪七十年代初,一个文学专业成绩很一般的学生毕业了。他虽然喜欢读文学作品却没写出过什么东西,毕业后就结了婚,和老婆开了个酒吧,生意不错,生活无忧。到了七十年代末,他似乎感受到某种 “召唤”,觉得应该写点什么东西了,于是每天酒吧打烊后,他就在餐桌上写两小时的小说,这一写就写了三十多年。熟悉的人想必已经知道他是谁了?对,就是村上春树。
|
||||
|
||||
所以,总要开始计划做点啥,你才能知道自己的 “涯” 到底有多远;而计划就是在系统地探索生涯,甚至人生的无限可能性。
|
||||
|
||||
回首无悔
|
||||
|
||||
关于后悔,有研究说:“我们最后悔的是没做什么,而不是做过什么。”回味一下,这个结论也确实符合我们的感觉。
|
||||
|
||||
万维钢写过一篇文章《决策理性批判》,里面引用了一个最新(2018)的关于后悔的研究,这个研究从 “理想的自己” 与 “义务的自己” 两个角度来说明:
|
||||
|
||||
|
||||
“理想的自己” 就是你想要成为什么人。
|
||||
|
||||
“义务的自己” 就是你应该干什么。
|
||||
|
||||
|
||||
若放到前面马斯洛需求金字塔中,“理想的自己” 就是站在顶端 “自我实现” 位置的那个自己;而 “义务的自己” 正在金字塔下面四层,挣扎于现实的处境。如果你从来没有去向 “理想的自己” 望上一眼,走上一步,将来终究会后悔的。事实上,研究结论也证明了这点:70% 以上的人都会后悔没有成为 “理想的自己”。
|
||||
|
||||
当我把自己进入大学以后的这十八年分作几个阶段来回顾时,有那么一段的好多时间我就是那样浑浑噩噩地混过去了,以至于现在回忆那段日子发现记忆是如此的粘连与模糊。后悔么?当然。
|
||||
|
||||
如果我能好好计划一下那段日子,也许会得到一个更 “理想的自己”。而在最近的这一段,我也感谢好些年前 “曾经的我”,幸运兼有意地做了一些计划。虽然一路走来,有些辛苦,但感觉会充实很多,而且如今再去回首,就没有太多后悔没做的事了。
|
||||
|
||||
计划,就是做选择,你在为未来的你做出选择,你在选择未来变成 “谁”。如果你还在为今天的自己而后悔,那就该为明天的自己做出计划了。
|
||||
|
||||
人生的征程中,先是恐惧驱动,地狱震颤了你,想要逃离黑暗深渊;后来才是愿望驱动,星空吸引了你,想要征服星辰大海。
|
||||
|
||||
逃离与征服的路,是一条计划的路,也是一条更困难的路,而 “你内心肯定有着某种火焰,能把你和其他人区别开来” 才让你选择了它。
|
||||
|
||||
最后,你想去到哪片星空?你为它点燃了内心的火焰了吗?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
122
专栏/程序员进阶攻略/16方式:计划的方法——脚踏实地.md
Normal file
122
专栏/程序员进阶攻略/16方式:计划的方法——脚踏实地.md
Normal file
@ -0,0 +1,122 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 方式:计划的方法——脚踏实地
|
||||
当你内心成长的火焰被点燃,有成长的愿望,也形成了清晰的成长愿景,但却可能苦恼于不知道如何确定目标、制定计划,以达成愿景。
|
||||
|
||||
就拿我来说,每年结束我都会做一次全年总结,然后再做好新一年的计划,一开始这个过程确实挺艰难且漫长的,因为毕竟要想清楚一年的计划还是挺难的。但慢慢的,我开始摸索和学习到了一套制定富有成效计划的方法,成为了我成长的捷径。现借此机会我将其总结、分享给你。
|
||||
|
||||
目标
|
||||
|
||||
富有成效的计划的第一步,便是确定目标。
|
||||
|
||||
在设定目标这个领域,国外一位研究者马克·墨菲(Mark Murphy)曾提出过一种 HARD 方法。HARD 是 4 个英文词的首字母缩写:
|
||||
|
||||
|
||||
Heartfelt 衷心的,源自内心的
|
||||
Animated 活生生,有画面感的
|
||||
Required 必须的,需求明确的
|
||||
Difficult 困难的,有难度的
|
||||
|
||||
|
||||
如其解释,这是一种强调内心愿望驱动的方法。按这个标准,一种源自内心的强烈需求在你头脑中形成很具体的画面感,其难度和挑战会让你感到既颤栗又激动,那么这也许就是一个好目标。
|
||||
|
||||
应用到个人身上,HARD 目标中的 H 体现了你的兴趣、偏好与心灵深处的内核。就拿写作这个事情来说吧,于我而言,兴趣只是驱动它的一种燃料,而另一种燃料是内心深处的表达欲望。写作本身不是目标,通过写作去完成一部作品才是目标,就像通过写代码去实现一个系统,它们都是作品,其驱动内核就是一种 “创造者之心”。
|
||||
|
||||
而 A 是你对这个目标形成的愿景是否足够清晰,在头脑中是否直接就能视觉化、具象化。就拿我个人来说,我非常喜欢读书,常在夜深人静的时候,默默潜读,掩卷而思,和作者产生一种无声的交流。这样一种画面,慢慢烙在脑海中,渐渐就激发起了想要拥有一部作品的目标。
|
||||
|
||||
R 则是由上一篇文章中的马斯洛需求模型层次决定的。写作一方面本是自带属于第三层次的社交属性,但另一方面更多是一种成长性的自我实现需求在激发。完成一部作品,需要明确一个主题,持续地写作,一开始我从每月写,到每周写,再到写这个专栏,作品也就渐渐成型。
|
||||
|
||||
而最后的 D 是其难度,决定了目标的挑战门槛。太容易的目标不值得设定,太难或离你现实太远的目标也不合适。基于现实的边界,选择舒适圈外的一两步,可能就是合适的目标。于我,从写代码到写作,其实也真就只有那么一两步的距离。
|
||||
|
||||
以 HARD 目标法为指导,我回顾了我工作以来的成长发展阶段,根据目标的清晰度,大概可以划分为如下三个阶段:
|
||||
|
||||
|
||||
目标缺乏,随波逐流
|
||||
目标模糊,走走停停
|
||||
目标清晰,步履坚定
|
||||
|
||||
|
||||
第一个阶段,属于工作的前三、四年,虽然每天都很忙,感觉也充实,一直在低头做事。但突然某一天一抬头,就迷茫了,发现不知道自己要去向哪里,原来在过去的几年里,虽然充实,但却没有形成自己明确的目标,一直在随波逐流。
|
||||
|
||||
在那时,人生的浪花把我推到了彼时彼地,我停在岸边,花了半年的时间重新开始思考方向。当然这样的思考依然逃不脱现实的引力,它顶多是我当时工作与生活的延伸,我知道我还会继续走在程序这条路上,但我开始问自己想要成为一个怎样的程序员,想要在什么行业,什么公司,写怎样的程序。就这样,渐渐确立了一个模糊的目标。
|
||||
|
||||
重新上路,比之前好了不少,虽然当时定的目标不够清晰,但至少有了大致方向,一路也越走越清晰。从模糊到清晰的过程中,难免走走停停,但停下迷茫与徘徊的时间相对以前要少了很多,模糊的目标就像一张绘画的草图,逐渐变得清晰、丰富、立体起来。当目标变得越来越清晰时,步履自然也就变得越发坚定。
|
||||
|
||||
回顾目标在我身上形成的经历,我在想即使当时我想一开始就要去定一个目标,想必也不可能和如今的想法完全一致。毕竟当时受限于眼界和视野,思维与认知也颇多局限,所立的目标可能也高明不到哪里去;但有了目标,就有了方向去迭代与进化,让我更快地摆脱了一些人生路上的漩涡。
|
||||
|
||||
假如,你觉得现状不好,无法基于现状延伸出目标。那么也许可以试试这样想:假如我不做现在的事情,那么你最想做的是什么?通常你当前最想做的可能并不能解决你的谋生问题,那么在这两者之间的鸿沟,如何去搭建一条桥梁,可能就是一个值得考虑的目标。
|
||||
|
||||
我们为什么要立 HARD 目标?有一句话是这么说的:
|
||||
|
||||
|
||||
Easy choices, hard life. Hard choices, easy life.
|
||||
|
||||
容易的选择,艰难的生活;艰难的选择,轻松的生活。
|
||||
|
||||
|
||||
方法
|
||||
|
||||
目标是愿望层面的,计划是执行层面的,而计划的方式也有不同的认识维度。
|
||||
|
||||
从时间维度,可以拟定 “短、中、长” 三阶段的计划:
|
||||
|
||||
|
||||
短期:拟定一年内的几个主要事项、行动周期和检查标准。
|
||||
中期:近 2~3 年内的规划,对一年内不足以取得最终成果的事项,可以分成每年的阶段性结果。
|
||||
长期:我的长期一般也就在 5~7 年周期,属于我的 “一辈子” 的概念范围了,而 “一辈子” 当有一个愿景。
|
||||
|
||||
|
||||
短期一年可以完成几件事或任务,中期两三年可以掌握精熟一门技能,长期的 “一辈子” 达成一个愿景,实现一个成长的里程碑。
|
||||
|
||||
从路径维度,订计划可以用一种 SMART 方法,该方法是百年老店通用电气创造的。在 20 世纪 40 年代的时候,通用电气就要求每一个员工把自己的年度目标、实现方法及标准写信告诉自己的上级。上级也会根据这个年度目标来考核员工。这种方法进化到了 20 世纪 80 年代,就成了著名的SMART原则。
|
||||
|
||||
SMART 也是 5 个英文词的首字母缩写:
|
||||
|
||||
|
||||
Specific 具体的
|
||||
Measurable 可衡量的
|
||||
Achievable 可实现的
|
||||
Relevant 相关的
|
||||
Time-bound 有时限的
|
||||
|
||||
|
||||
今天 SMART 已经非常流行和常见,我就不解释其具体含义了,而是讲讲我如何通过 SMART 来跟踪个人年度计划执行的。按 SMART 方式定义的计划执行起来都是可以量化跟踪的,我通常用如下格式的一张表来跟踪:
|
||||
|
||||
|
||||
|
||||
计划跟踪表示意图
|
||||
|
||||
其实,一年值得放进这张表的就那么几件事,每件事又可以分解为具体的几个可量化的任务,再分解到一年 50 周,就可以很明显地看出理想计划和现实路径的曲线对比。如下,是我 2017 年的一张计划与实际执行的对比曲线图:
|
||||
|
||||
|
||||
|
||||
计划与实际执行对比示意图
|
||||
|
||||
按 SMART 原则方法使用计划跟踪表的优点是:简单、直接、清晰。但缺点也明显:即使百分百完成了所有的计划,也都是预期内的,会缺乏一些惊喜感。而因为制定目标和计划会有意识地选择有一定难度的来挑战,所以实际还很难达成百分百。
|
||||
|
||||
说到目标的难度与挑战,使用 SMART 方法最值得注意的点就是关于目标的设定和方法的选择。鉴于人性和现实的因素,制定计划时很可能是这样一种情况:基于现实掌握的方法,考虑计划的可达性。这样制定出来的计划看起来靠谱,但却失去了真正挑战与创新的可能。
|
||||
|
||||
通用电气传奇 CEO 杰克·韦尔奇执掌时期,有一个飞机引擎工厂制定了一个减少 25% 产品缺陷的目标。韦尔奇当时就觉得这个 SMART 目标很普通,没什么挑战,但工厂负责人却觉得已经很有难度了。韦尔奇执意坚持,把目标提高到了减少 70% 的缺陷,工厂负责人一开始很焦虑,认为这根本不可能完成。
|
||||
|
||||
没办法,标准是韦尔奇定的,改不了。工厂负责人知道按以前的方法根本达不成,只好去寻找新方法。在寻找的过程中,他们发现,要想如此大幅度地减少缺陷,不能只靠质检人员,而是必须让每名员工都有质检意识。
|
||||
|
||||
于是,工厂开始大规模进行培训;同时,工厂开始有意识招聘综合素质更高的技术工人。为了吸引并留住这些工人,工厂必须改变以前的管理方式,给他们更多的自主权,因为这些工人普遍受过很好的教育,而且很容易找到工作。最后,一个拔高的目标计划改变了整个工厂的培训、招聘和运行方式。
|
||||
|
||||
SMART 计划,正如其名,需要聪明且智慧地设定并使用它。
|
||||
|
||||
有时你可能会觉得计划没有变化快,或者计划好的人生,过起来好机械,没劲。其实计划是准备,变化才是永恒,而计划就是为了应对变化。为此,我经常会把计划表按优先级排得满满的,但我永远只做那些计划表顶部最让自己感到 HARD 的事情。
|
||||
|
||||
变化来了,就把它装进计划表中,看这样的变化会排在哪个位置,和之前计划表前列的事情相比又如何。如果变化的事总能排在顶上,那么说明你的人生实际就在不断变得更精彩,做的事情也会让你更激动。而如果变化老是那些并不重要却还总是紧急的事情,老打断当下的计划,那么也许你就要重新审视下你当前的环境和自身的问题了。
|
||||
|
||||
这样,计划表就成了变化表,人生无法机械执行,只有准备应对。
|
||||
|
||||
最后,找到属于你的 HARD 目标,开始有计划且 SMART 的每一天;这样的每一天,走的每一步也许会更重些、累些,但留下的脚印却很深、很长。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
87
专栏/程序员进阶攻略/17检视:计划的可行——时间与承诺.md
Normal file
87
专栏/程序员进阶攻略/17检视:计划的可行——时间与承诺.md
Normal file
@ -0,0 +1,87 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 检视:计划的可行——时间与承诺
|
||||
有了愿景,也有了具体的计划,但经常还是一年过去,发现实际和计划相比,总是有差距。是的,这是普遍现象,你可能并不孤独和例外:统计数字表明,在年初制定了计划的人中,只有8% 实现了这些计划。
|
||||
|
||||
老实说,我回顾了近几年的个人年度计划完成情况,也只完成了约 70% 的样子。但我个人把这70% 的比例算作“完成”,毕竟一年中谁还没个变化呢?于是,我把另外的30%留给变化,毕竟一成不变地按计划来的人生,感觉太过枯燥,有 30% 的变化还可能会碰到 “惊喜”;而如果 70% 都是变化,那可能就是 “惊吓”了。
|
||||
|
||||
程序员啊,有一个特点就是偏乐观,所以对于计划的估计总是过于乐观,乐观地期待 “惊喜”,然后又“惊吓”地接受现实。那如何才能让计划更具可行性呢?又可以从哪些方面来检视呢?
|
||||
|
||||
时间与周期
|
||||
|
||||
计划的第一个影响因素是和时间有关。
|
||||
|
||||
在过去的人类社会生活中,人们已经习惯了以年为单位来进行时间分界,所以我们都会习惯于做年度计划。在个人的时间感觉中,一年,似乎也算是挺长一段时间了,但在过去这么些年的计划与实践中,我学到的经验是:做计划不能靠模糊的感觉,而是需要精确理性的计算。
|
||||
|
||||
先来计算下,一年,我们到底有多少时间?一个正常参与社会工作的人,时间大约会被平均分成三份。
|
||||
|
||||
其中的 1/3(约 8 小时)会被睡过去了,这里假设一个正常人的生理睡眠需求大约 8 小时。虽然有一些讲述成功人士关于睡眠的说法,比如:“你见过凌晨四点钟的…”,似乎在暗示他们之所以成功,是因为每天都很努力只睡四个小时。但这个说法并没有提每天几点入睡,只是说四点起床而已。而我写这篇文字也是在周末的早晨五点开始的,但前一晚十点之前便睡了过去,至少我对睡眠时间的要求是没法长期低于 8 小时的。
|
||||
|
||||
另一个 1⁄3 你会贡献到和你的工作有关的各种事项中,虽然国家法律规定了每周只用上 5 天班,每天 8 小时,似乎用不了 1⁄3 的时间。但如果你的工作不是那种 “混日子” 的清闲工作的话,实际占用的时间基本总会多于法律规定的,至少程序员这份工作肯定是这样了。不过值得庆幸的是程序员的工作是可以随着时间积累起相应的知识、技能和经验,那么这份时间投入就是很有价值的了,随着时间积累,慢慢你就会成为工作领域内的行家。
|
||||
|
||||
最后的 1⁄3 就是我们常说的决定人生的业余 8 小时。可能有人会说我根本就没有业余 8 小时,天天都在加班。实际上工作和业余的 8 小时有时不太那么具有明显的分界线。程序员的工作,是一份知识性工作,很可能工作时间你在学习,也有很多情况是你在业余时间处理工作的事务。对于严格区分工作和业余时间的思维,我碰到过一种人:上厕所都要忍着,到了公司利用工作时间再去,以达成变相在工作时间偷懒的感觉。但,其实时间总是自己的。
|
||||
|
||||
一年 52 周,会有一些法定的长假和个人的休假安排,我们先扣除两周用于休假。那么一天业余 8 小时,一年算 350 天,那么一年总共有 2800 小时的业余时间。但实际这 2800 小时里还包括了你全部的周末和一些零星的假期,再预扣除每周 8 小时用于休闲娱乐、处理各种社会关系事务等等,那么你还剩下 2400 小时。
|
||||
|
||||
这2400 小时就是你可以比较自由地用来安排的全部业余时间了,这就是理性计算的结果。这样看来,一年实际能用来计划的时间并不多,需要仔细挑选合理的事项,放进计划表,并真正地执行。而实际,一年中你还需要把时间合理地分配在 “短、中、长” 三种不同周期的计划上。
|
||||
|
||||
|
||||
短期:完成事项,获取结果,得到即时反馈与成就感(比如:写这个专栏)。
|
||||
中期:学习技能,实践经验,积累能力(比如:学一门语言)。
|
||||
长期:建立信念,达成愿景(比如:成长为一名架构师)。
|
||||
|
||||
|
||||
你可以从时间的维度,看看你计划的时间安排是否合理分配在了不同周期的计划事项上。如果计划的事项和周期匹配错了,计划的执行就容易产生挫败感从而导致半途而废,曾经的我就犯过这样的错误。
|
||||
|
||||
这个错误就是在学习英语的计划上。两年多以前,工作十年后的我又重启了英语提升计划,希望能通过每天 3 ~ 4 小时的英语学习,一年内使自己的英语听读都能达到接近汉语的水平。但实际情况是,我用了两年(接近 1500 小时吧)才勉强比刚从学校毕业时上了一个台阶,离母语水平,我不知道前面还有多少个台阶。
|
||||
|
||||
英语提升计划,我搞错了周期,一度颇受打击。英语技能,实际就是一个 10000 小时技能,虽然我是从初中开始学习,然后至大学毕业拿到六级证,差不多有十年时间。但实际真正有效的学习时间有多少呢?假如每天一节课算 1 小时,一周 6 小时,每年 50 周,十年上课下来也就 3000 小时,再考虑为了考试自己的主动复习时间,再加 2000 小时,那么过去在学校总共投入了 5000 小时。
|
||||
|
||||
但从学校毕业后的十年,实际工作环境中,除了技术英语阅读,我几乎很少再接触英语了。而语言基本就是用进废退的技能,所以再重启学习提升计划时,我对此计划的周期完全估算错误,最后得到的效果也远低于我的预期。其实这应该是一个长期的计划,定一个合理的愿景,循序渐进成为一名熟练的英语使用者。
|
||||
|
||||
要让计划可行,就是选择合适的事项,匹配正确的周期,建立合理的预期,得到不断进步的反馈。
|
||||
|
||||
兴趣与承诺
|
||||
|
||||
既然时间有限,那该如何选择有限的事项,才可能更有效地被执行下去呢?
|
||||
|
||||
其中有一个很重要的因素:兴趣。有的人兴趣可能广泛些,有的人兴趣可能少一些,但每个人多多少少都会有些个人的兴趣爱好。对于兴趣广泛的人来说,这就有个选择取舍问题,若不取舍,都由着兴趣来驱动,计划个十几、二十件事,每样都浅尝辄止。实际从理性上来说价值不大,从感性上来说只能算是丰富了个人生活吧。
|
||||
|
||||
彼得·蒂尔在《从 0 到 1 》这本书里批判了现在的一个观点:过程胜于实效。他解释说:“当人们缺乏一些具体的计划去执行时,就会用很正式的规则来建立一些可做的事情选项的组合。就像今天美国中学里一样,鼓励学生参与各种各样的课外活动,表现的似乎全面发展。到了大学,再准备好一份看似非常多元化的简历来应对完全不确定的将来。言外之意,不管将来如何变化,都在这个组合内能找到可以应对的准备。但实际情况是,他们在任何一个特定方面都没有准备好。”
|
||||
|
||||
因此,在有限的学校生涯中,你就得做出选择。就好像我大学那时,学校开了几十门(记得大概有 45 门)各类专业课,这就是一个组合。但其中真正重要的课程实际只有个位数,重心应该放在少数课程上,其他的只起到一个开阔眼界和凑够学分的作用。
|
||||
|
||||
几十门课是学校给的选项,你可以从中做出选择。那应该选择哪些事项放进计划表呢?我建议你可以从兴趣作为出发点,因为这样更容易启动;而对于中期目标,像学习提升一项技能,只靠兴趣是不足以驱动去有效执行的,甚至达不到预期效果。关于此,吴军有一个观点:
|
||||
|
||||
|
||||
凡事从 0 分做到 50 分,靠的是直觉和经验;从 50 分到 90 分,就要靠技艺了。
|
||||
|
||||
|
||||
凭借兴趣驱动的尝试,结合直觉和经验就能达成 50 分的效果,而要到 90 分就需要靠技艺了。而技艺的习得是靠刻意练习的,而刻意练习通常来说都不太有趣。要坚持长期的刻意练习,唯一可靠的办法就是对其做出郑重的承诺。
|
||||
|
||||
通过兴趣来启动,但要靠承诺才能有效地执行下去。感兴趣和做承诺的差别在于,只是感兴趣的事,到了执行的时候,总可以给自己找出各种各样的原因、借口或外部因素的影响去延期执行;而承诺就是这件事是每天的最高优先级,除非不可抗力的因素,都应该优先执行。
|
||||
|
||||
比如,写作本是我的兴趣,但接下 “极客时间” 的专栏后,这就是承诺了,所以为此我就只能放弃很多可以用于休闲、娱乐的时间。
|
||||
|
||||
兴趣让计划更容易启动,而承诺让计划得以完成。
|
||||
|
||||
而在现实生活中,让计划不可行或半途而废的常见错误有:
|
||||
|
||||
|
||||
以为一年之内自己有足够多的自由支配时间;
|
||||
对计划的事情误判了其开发与成长的周期;
|
||||
兴趣很多,一直在尝试,却不见有结果。
|
||||
|
||||
|
||||
放进计划表的事项是你精心识别、选择并做出的承诺,而承诺也是一种负担,若承诺太多,负担可能就太重,会让你感觉自己不堪重负,最后就可能放弃了,到头来又是一场空。其实,一年下来,重要的不是开启了多少计划,而是完成了几个计划。
|
||||
|
||||
所以,可行的计划应该是:有限的时间,适合的周期,兴趣的选择,郑重的承诺。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
114
专栏/程序员进阶攻略/18评估:计划的收获——成本与收益.md
Normal file
114
专栏/程序员进阶攻略/18评估:计划的收获——成本与收益.md
Normal file
@ -0,0 +1,114 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 评估:计划的收获——成本与收益
|
||||
做计划自是为了有收获,实现愿景也好,获得成长也罢,每一份计划背后都有付出与收获的关系。如果计划的收益不能高于执行它付出的成本,那么其实这种的计划就几乎没有执行价值。
|
||||
|
||||
执行计划的成本通常是你付出的时间或金钱,但收益则没那么明确,这就需要你去仔细评估和取舍。
|
||||
|
||||
而有些计划本身从成本和收益的角度看就不是一个好计划,比如,我见过一些计划是:今年计划读20本书。读书本是好事,但读书的数量并不是关键点,关键是计划今年读哪些书。因为只有明确了读哪些书,才能评估是否值得和适合在这阶段去读。
|
||||
|
||||
值得与否,就是关于成本与收益的评估,而为了更好制定有价值的计划,你就需要去仔细权衡这种关系。
|
||||
|
||||
成本与机会
|
||||
|
||||
计划即选择,而但凡选择就有成本。
|
||||
|
||||
从经济学思维的角度,做计划就是做选择,选择了某些事情;而选择了这些事情,就意味着放弃了另外可能做的事情,这里面的成本就是机会成本。机会成本是放弃的代价,选择这些事情从而放弃的其他可能选项中拥有最高价值的事情。
|
||||
|
||||
就好像同样一个晚上,有人选择了用来玩网络游戏,你以为的成本是几小时的点卡钱,但实际你放弃的是用来学习、看书等其他事项的潜在价值与收益。青少年时代谁还没玩过游戏,我也玩过十多年的游戏,虽不能简单地认为游戏毫无意义,但十年前,我明白了机会成本的概念后,就做出了选择。
|
||||
|
||||
我的长期计划中有一项是写作。从我 2011 年开始写下第一篇博客放在网上到现在,已经过去了七年。那写作的成本和收益又是怎样的呢?
|
||||
|
||||
一开始总有一些人愿意免费写一些优质内容放在网上,从读者的角度来看,他们总是希望作者能长期免费地创造优质内容。但从花费的时间成本来看,这是不太现实的,也很难长久持续下去。
|
||||
|
||||
从作者的角度,时间成本其实是越来越高,而且很刚性。比如,七年前我写一篇文章的时间其实和现在差不太多,时间成本按说是增加的(因为单位成本随时间增加了);但是写作会持续创造价值,我可以在持续写作中不断总结获得成长,而成长的价值都会通过职业生涯发展获得收益,这是间接收益。而一些成功的作者,可能还可以通过写作获得直接收益,比如目前蒸蒸日上的各类知识付费专栏。
|
||||
|
||||
在中国互联网快速发展的这十多年间,我的学习路径也发生了转变。前期,我都是从网上去扒各种免费的电子书,看免费的博客,读开源的代码;但今天我几乎不会再去网上找免费的学习材料了,而是直接付费购买。
|
||||
|
||||
而且你应该也发现了现在知识和内容付费的趋势在扩大,这是为什么?因为大家都意识到了时间的成本,是选择花费自己的时间去搜索、甄别和筛选内容,还是付出一点点费用得到更成体系的优质内容?大家已经做出了选择。
|
||||
|
||||
学习计划是个人成长计划中的一部分,而成长计划中,最大的成本依然是时间。在你早期的学习阶段,虽然时间没那么值钱,但把钱和时间都花在加速成长上,其实是“成本有限,潜在收益巨大”的选择。
|
||||
|
||||
而计划,就是对你的时间做分配。时间在不同的阶段,价值不同,那么成本也就不同。你要敏感地去感知自己时间的成本,去提升时间的价值,根据时间的价值再去调整自己的计划和行动。成长过程中,早期的成本低而选项多,后期的成本高且选项少。
|
||||
|
||||
文艺复兴时期法国作家蒙田曾说过:
|
||||
|
||||
|
||||
真正的自由,是在所有时候都能控制自己。
|
||||
|
||||
|
||||
如蒙田所说,计划才能给你真正的自由,你对计划的控制力越强,离自由也就更近了。
|
||||
|
||||
结果与收益
|
||||
|
||||
计划得到了执行,产生了预期的结果,才会有期望的收益。
|
||||
|
||||
但据抽样统计,制定了年度计划的人里面,仅有 8% 的人能完成他们的年度计划。年度计划通常都是一份从未向任何人公布的计划,从某种意义上来说,除了你自己自律,并没有任何约束可言。这个世界的外部环境变化那么快,你很容易找到一个理由说服自己:计划赶不上变化。
|
||||
|
||||
变化之后的计划,只是一份更契合实际的计划,而非不再存在。很多外部因素是你无法预测和控制的,总会来干扰你的计划,所以这给了你足够的客观原因。但无论有多少客观原因,你做计划的初衷是:一点点尝试去控制自己的生活,然后得到自己想要的结果。
|
||||
|
||||
在获得结果的路上,这个世界上似乎有两类人:
|
||||
|
||||
|
||||
第一类人,自己给自己施加约束,保持自律并建立期望;
|
||||
第二类人,需要外部环境给予其约束和期望。
|
||||
|
||||
|
||||
在我读高中时,现实中就有一种巨大的社会期望和约束施加己身,那就是高考。在这种巨大的社会外部约束和期望下,第二类人可以表现得非常好,好到可以考出状元的分数。但进入大学后,这样的外部约束和期望会瞬间下降,最后可能也就泯然众人之间了。
|
||||
|
||||
心理学上有个皮格马利翁效应:
|
||||
|
||||
|
||||
人们基于对某种情境的知觉而形成的期望或预言,会使该情境产生适应这一期望或预言的效应。
|
||||
|
||||
|
||||
通俗点说就是,如果有人(可以是别人或自己)对你的期望很高,你会不自觉地行动去满足并符合这种期望;若周围没有这样的期望,最终你可能就是一个符合周围人群平均期望的人。而所谓的自驱力,就是你对自己的期望所形成的推动力量。
|
||||
|
||||
要获得好的结果,你就要做第一类人,需要对自己有更高的期望,需要有自驱力。
|
||||
|
||||
进入大学或工作以后,周围环境对你的期望已经降到很低。于我而言,来自父辈的那一代人,也就是上世纪四五十年代那一代,经历了饥荒甚至战争,他们的期望通常代表一代人,都是平平安安、健健康康,有个稳定的工作就够了。
|
||||
|
||||
这样的期望对于大部分读了大学、有个工作的人来说都不足以形成驱动力了,更何况我们大多数人每日工作忙里忙外,不外乎忧心柴米油盐,困于当下。少了外部足够强大的期望推动,多数第二类人的内心驱动从此也就熄火了,但还是有少数的第一类人在 “仰望星空”,比如科幻小说《三体》的作者大刘(刘慈欣)。
|
||||
|
||||
我是 1999 年在四川成都的一本科幻杂志《科幻世界》(现已停刊)上读到他的首部短篇小说的。实际他 85 年毕业,在电厂任工程师,89 年开始写科幻小说,直到 99 年才见到他的第一部作品公开发表。从 89 年到 99 年这十年间基本就是独自“仰望星空”来完成了写作这门技艺的打磨过程,并留下了自己的第一部作品,再之后到写完《三体》,这又是另一个十年了。
|
||||
|
||||
而于我,除了写作,还有另一项长期计划:学好英语。快三年前了,我重启了英语提升计划,付出的成本是每天至少一到数小时不等的学习和听读文章的时间成本,那么收益呢?学好英语是能产生直接收益的,比如通过翻译就能赚钱,但这就落入了一种狭隘的思维。
|
||||
|
||||
一方面,翻译的时间单价市场行情是非常低的,目前英译中的普通文章,恐怕不到 100 元每千字,相比一个初中级程序员的市场价,时间成本是很不划算的。所以,学好英语从我的角度来说,赚取的不是直接的经济收益,而是间接的结构性收益,增强直接收益结构价值。
|
||||
|
||||
那么如何理解收益结构?以我现阶段的状态来说,已有三个直接收益结构:
|
||||
|
||||
|
||||
专业
|
||||
写作
|
||||
理财
|
||||
|
||||
|
||||
专业,自然是指程序专业技能,通过出售自己的时间和人力资源来获取一份相对稳定的工资收入来源。写作,到今天这个专栏出品后,终于可以通过作品的形式产生直接收益,它只需一次性投入时间来完成作品。而理财属于资产性收益,就是任何等价于钱的家庭动产或不动产,能产生利息、分红或租金的收入,它需要长期的收入结余积累。
|
||||
|
||||
而英语技能的提升对这三个直接收益结构,都能产生增益作用。程序行业自不必多说,行业里最好的文章、书籍或专业论文材料等可能都是英文的,只有少部分被翻译了过来,但翻译总是有损失、有偏差、有歧义,能直接高效地阅读英语对提升你的专业技能和能力帮助巨大。
|
||||
|
||||
而写作,英语给我提供了另外一个更广阔世界的写作素材和看待世界的角度。所以,我在时间分配上不仅看中文文章,也看一些英文媒体文章和书籍。
|
||||
|
||||
至于理财,英语让我更直接高效地接收中文世界以外的信息,从某种角度来说,具备了更多元化的视角和思维结构。而思维和视角是投资理财的核心能力,在这个领域全是选择题,只有做对选择的概率高于做错的概率,才可能获得正收益。
|
||||
|
||||
这就是我选择一项长期计划时关于结果与收益的思考,而成长计划的收益,从经济价值来说,都是远期收益,是为了变得更值钱。也许期望的结果达成,目标实现,真的会变得更值钱,就像上面例子里的大刘。但也可能没能实现目标,那么你还能收获什么?也许有来自过程的体验,这也是选择目标时,源自内心和兴趣是如此重要的原因。
|
||||
|
||||
在考虑付出与收获时,后来读到一句话,大意如下:
|
||||
|
||||
|
||||
生活也许不会像计划那样发生,但对待生活的态度可以是:期待伟大的事情发生,同时也要保持快乐和幸福,即使它没能发生。
|
||||
|
||||
|
||||
如此,面对真实的生活,也当释然了。
|
||||
|
||||
最后,留个思考题:关于计划你感觉是束缚了你的生活,还是让你更自由了?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
95
专栏/程序员进阶攻略/19障碍:从计划到坚持,再到坚持不下去的时候.md
Normal file
95
专栏/程序员进阶攻略/19障碍:从计划到坚持,再到坚持不下去的时候.md
Normal file
@ -0,0 +1,95 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 障碍:从计划到坚持,再到坚持不下去的时候
|
||||
设定一个计划并不困难,真正的困难在于执行计划。若你能够坚持把计划执行下去,想必就能超越绝大部分人,因为大部分人的计划最终都半途而废了。
|
||||
|
||||
为什么那么多计划都半途而废了?在执行计划时,你会碰到怎样的障碍?我想从计划生命周期的各个阶段来分析下。
|
||||
|
||||
酝酿
|
||||
|
||||
酝酿期,是计划的早期雏形阶段;这阶段最大的障碍来自内心:理性与感性的冲突。
|
||||
|
||||
计划的目标是源自内心的,但也是有难度的,若是轻而易举的事情,也就不用计划了。这些需要坚持的事情,通常都 “不好玩”,而人是有惰性的,内心里其实并不愿意去做,这是我们感性的部分。但理性告诉我们,去完成这些计划,对自己是有长远好处的。这,就是冲突的地方。
|
||||
|
||||
就以我自己写作的例子来看,我不是一开始就写作的,我是工作了 5 年后,碰到了平台期,撞上了天花板,感觉颇为迷茫。于是就跑到网上到处看看有没有人分享些经验,找找道路。然后,看到了一些 “大神” 们写的博客,分享了他们一路走过的经历,在我迷茫与灰暗的那个阶段的航行中,就像一盏灯塔指引着前进方向。
|
||||
|
||||
于是我在想,也许我也可以开始写写东西。那时,内心里出现了两个声音,一个声音说:“你现在能写什么呢?有什么值得写的吗?有人看吗?”而另一个声音反驳说:“写,好过不写,写作是一件正确的事,就算没人看,也是对自己一个时期的思考和总结。”
|
||||
|
||||
最终,理性占了上风,开启了写作计划,然后注册了一个博客,想了一句签名:“写下、记下、留下”。
|
||||
|
||||
启动
|
||||
|
||||
启动期,是计划从静止到运动的早期阶段;这阶段的最大障碍是所谓的“最大静摩擦力”。
|
||||
|
||||
我们都学过初中物理,知道 “最大静摩擦力” 是大于 “滑动摩擦力” 的,也就是说要让一个物体动起来所需要的推力,比它开始运动后要大一些。这个现象,放在启动一个计划上时,也有类似的感觉,所以才有一句俗语叫:“万事开头难”。
|
||||
|
||||
还是回到我开始写作那个例子,我的第 1 篇博客的写作过程,至今还记得很清楚:一个周六的下午,在租的小房间里整整写了一下午。写得很艰苦,总感觉写得不好,不满意。最后一看天都黑了,肚子也饿了,就勉勉强强把它发了出去。
|
||||
|
||||
发出去后的前两天,我也会经常去刷新,看看阅读量有多少,有没有人评论啊。让人失望的是,前一个声音的说法变成了事实:的确没什么人看。两天的点击量不到一百,一条评论也没有,而且这一百的阅读计数里,搞不好还有些是搜索引擎的爬虫抓取留下的。
|
||||
|
||||
但是,写完了第一篇,我终于克服了写作的 “最大静摩擦力” 开始动了起来,一直写到了今天,这已经过去了 7 年。
|
||||
|
||||
执行
|
||||
|
||||
执行期,是计划实现过程中最漫长的阶段;这阶段的最大障碍就是容易困倦与乏味。
|
||||
|
||||
漫长的坚持过程期,大部分时候都是很无聊、乏味的,因为真实的人生就是这样,并没有那么多戏剧性的故事。所以,我在想这也许就是为什么那么多人爱看小说、电视剧和电影的原因吧,戏中的人物经历,总是更有戏剧性。
|
||||
|
||||
美国当代著名作家库尔特·冯内古特在一次谈话中谈及人生,他用了一组形象的类比来描述人生。我翻译过来并演绎了一下,如下面系列图示:
|
||||
|
||||
其中,纵坐标表示生活的幸福程度。越往上,代表幸福指数越高;越往下,代表幸福指数越低。中间的横线表示普通大众的平凡人生。
|
||||
|
||||
|
||||
|
||||
那么先来看一个大家都很熟悉的从 “丑小鸭” 变 “白天鹅”的故事:灰姑娘 。
|
||||
|
||||
|
||||
|
||||
我们从小就听过这个故事,人们喜欢这样的故事。同样的故事内核,被用在不同的故事里书写了上千次,传诵了上千年。这是一个皆大欢喜的故事,而下面则是一个稍微悲伤点的故事。
|
||||
|
||||
|
||||
|
||||
故事虽以悲剧开始,但好在以喜剧结束。人们也喜欢这样的故事,生活不就该这样吗?问题是,真实的生活可能是下面这样的。
|
||||
|
||||
|
||||
|
||||
没有那么多大起大落,我们大部分人的生活只是在经历一些平平凡凡的琐事。也许其中有些会让你感到高兴与兴奋,有些又让你感到烦躁与郁闷。但这些琐事都不会沉淀进历史中,被人们传诵上千年,它仅仅对你自己有意义。
|
||||
|
||||
所以呢,你明白为什么你感觉你的坚持那么无聊、单调与乏味了吧,大多数时候它都缺乏像 “灰姑娘” 故事曲线的戏剧性。而对抗这种过程的无聊,恰恰需要的就是故事。你看人类的历史上为什么要创造这么多戏剧性的故事,让这些戏剧性的故事包围了我们的生活,让人们想象生活充满了戏剧性,这种想象是治疗乏味的良药,也成为了创造更美好生活的动力。
|
||||
|
||||
万维钢的一篇文章《坚持坚持再坚持》里也提到:
|
||||
|
||||
|
||||
故事的价值不在于真实准确,而在于提供人生的意义。
|
||||
|
||||
|
||||
坚持,特别是长期的坚持,是需要动力的,而动力来自目标和意义。而获得目标与意义的最好方式是讲好一个故事。你看,成功的企业家会把未来的愿景包进一个美好的故事里,让自己深信不疑;然后再把这个故事传播出去,把所有相信这个故事的人聚在一起去追寻这个故事;最后,这个关于未来的故事就这样在现实中发生了。
|
||||
|
||||
漫长的人生,你需要为自己讲好一个故事。
|
||||
|
||||
挫败
|
||||
|
||||
挫败,不是一个阶段,而是坚持路上的一些点;正是在这些点上你遭遇了巨大的挫败感。
|
||||
|
||||
为什么会产生挫败感?可能的原因有,一开始你就不知道这件事有多难,直到走了一段后才发现这太难了。一开始就评估清楚一个计划的难度,需要投入大量的时间、经历和金钱,甚或有更高的技能与能力要求,这本身就是一件不容易的事。
|
||||
|
||||
而如果你计划的是一件从来没做过的事情,这就更难准确评估了。在路上,行至中途遭遇 “低估” 的挫败感就再正常不过了,而不少人,因为挫败过一两次后,就会放弃了计划。有时,遭遇挫败,选择了放弃,这个未必就是不合适的,但这要看这个放弃的决策是在什么情况下做出的。
|
||||
|
||||
遭遇挫败,你会进入一种心情与情绪的低谷,这个时候有很高的概率做出放弃的决策。而我的经验是,不要在挫败的情绪低谷期进行任何的选择与决策。可以暂时放下这件事,等待情绪回归到正常,再重新理性地评估计划还是否该坚持。
|
||||
|
||||
每经历一次挫败之后,你还选择坚持,那么就已经收获了成长。
|
||||
|
||||
最后总结来说,就是:你为了做成一件事,定一个计划,在执行计划的过程中,在 “酝酿”“启动” 和 “执行” 的不同阶段都会碰到各种障碍,可能都会让你产生一种快坚持不下去了的感觉。每到此时,你都要想想清楚,哪些是真正客观的障碍?哪些是主观的退却?
|
||||
|
||||
从坚持到持续,就是试图让现实的生活进入童话的过程,而后童话又变成了现实。
|
||||
|
||||
本文分析了计划的执行障碍,最后我也想问问你,在你成长的路上,遭遇过哪些障碍?是什么原因让你坚持不下去了的?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
103
专栏/程序员进阶攻略/20执行:从坚持到持续,再到形成自己的节奏.md
Normal file
103
专栏/程序员进阶攻略/20执行:从坚持到持续,再到形成自己的节奏.md
Normal file
@ -0,0 +1,103 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 执行:从坚持到持续,再到形成自己的节奏
|
||||
有了一个目标后,我们通常会做好全方位的计划,并满心期待启动它,本想着朝着既定目标“一骑红尘飞奔而去”。但计划赶不上变化,很多时候,执行了一段时间后,我们可能会觉得比较累,有种快坚持不下去了的感觉,然后就半途而废了。这种场景我们每个人应该都不陌生。
|
||||
|
||||
其实,在执行过程中,容易半途而废的一个很可能的原因在于节奏出了问题。
|
||||
|
||||
计划的节奏
|
||||
|
||||
一个计划被制定出来后,我们通常会根据它的周期设定一个执行的节奏。
|
||||
|
||||
长期,就像长跑,跑五千米是长跑,跑马拉松(四万多米)也是长跑,但我们知道跑五千米和跑拉松肯定是用不同的节奏在跑。
|
||||
|
||||
一个长期的目标可以是五年,也可以是十年,因目标而异。要精熟一门技能领域,比如编程,确切地说应该是编程中的某一分支领域,对于一般人来说,可能就需要三五年不等了。而像精通一门外语,可能需要的时间更长,我是从初中开始学习英语的,如今二十多年过去了,别说精,可能连熟都谈不上。
|
||||
|
||||
于我而言,可能因为编程技能是要解决吃饭温饱的需要,刚需比较强烈;而英语这么多年,都是考试的需要,刚需太弱,故二者的学习和练习节奏完全不同,最后学习掌握的能力也相差甚远。
|
||||
|
||||
一个中期的目标,也许是一年。比如,计划用一年时间写一本书,假如一本书 20 万字,那每周大约需要完成 4000 字,再细化到每天就是800 字左右。这就是我们做一年计划的方式,计划成型后,相应做出分解,分解到周这个级别后,基本的计划节奏就出来了。
|
||||
|
||||
一个短期的目标,可能是几个月。比如,我这个 “极客时间” 专栏,计划就是几个月内完成的事情。它上面已经形成了每周三篇更新的节奏,这样的写作节奏对于我来说基本已经算是全力冲刺了,所以时间就不能拉得太长。
|
||||
|
||||
不同周期的计划,都会有一个共同的问题:计划总是过于乐观了,现实的执行很难完全符合计划。
|
||||
|
||||
你可能也遇到过,计划的节奏总是会被现实的“意外”打断,每次计划的节奏被打断后,都会陷入一种内疚的挫败感中;然后就强迫自己去完成每日计划列表中的每一项,否则不休息,最终也许是获得了数量,但失去了质量。在这样的挫败中纠结了几次后,你慢慢就会发现,现实总是比计划中的理想情况复杂多变。
|
||||
|
||||
不过,这才是真实的人生。偶尔错过计划没什么大不了的,如果人生都是按计划来实现,那岂不也有些无聊。
|
||||
|
||||
万维钢有篇文章叫《喜欢 = 熟悉 + 意外》,这篇文章下有位读者留言说:
|
||||
|
||||
|
||||
贾宝玉第一次见到林黛玉说的第一句话就是 “这个妹妹好像在哪儿见过似的”。有点熟悉,也有点意外,这就是喜欢了。
|
||||
|
||||
|
||||
所以,当“意外”出现时你不必感到太过闹心,试着换个角度来看,这偶尔出现的“意外”也许会反而让你更喜欢这样的人生呢。
|
||||
|
||||
计划更多是给予预期和方向,去锚定现实的走向,但在行进的过程中,“意外” 难免会出现。所以,你要从心理上接受它,并从行为上合理地应对它。
|
||||
|
||||
下面我就来说说我是怎么应对这些“意外”的。
|
||||
|
||||
按程序员的思考方式,我会为所有计划中的事情创建了一个优先级队列,每次都只取一件最高优先级的事情来做。而现实总会有临时更高优先级的 “意外” 紧急事件插入,处理完临时的紧急事件,队列中经常还满满地排着很多本来计划当天要做的事情。
|
||||
|
||||
以前,我总是尝试去清空队列,不清空不休息,但实际上这很容易让人产生精疲力竭的感觉。如今,我对每个计划内的事情对应了一个大致的时间段,如果被现实干扰,错过了这个时间段,没能做成这件计划内的事情,就跳过了,一天下来到点就休息,也不再内疚了。
|
||||
|
||||
举例来说,我计划今晚会看看书或写篇文章,但如果这天加班了,或者被其他活动耽误了,这件计划中的事情也就不做了。但第二天,这件事依然会进入队列中,并不会因为中断过就放弃了。只要在队列里,没有其他事情干扰,到了对应的时间段就会去执行。
|
||||
|
||||
计划的节奏,就像中学物理课上假设的理想的无摩擦力环境,而现实中,摩擦力则总是难以避免的,所以你要学会慢慢习惯并适应这真实而有点“意外”的节奏。
|
||||
|
||||
他人的节奏
|
||||
|
||||
跑马拉松的时候,一大群人一起出发,最后到达终点时却是稀稀拉拉。这说明每个人的节奏是不同的,即便同一人在不同阶段的节奏也是不一样。
|
||||
|
||||
同理,就拿我的写作节奏来说,在七年中也慢慢从每月一篇提升到了每周一篇。当然,有些微信公众号的作者写作速度一直都很快,可能是每天一篇。但如果我要用他们的节奏去写作,可能一开始坚持不了多久就会放弃写作这件事了。
|
||||
|
||||
所以,从写作这件长期的事情中,我收获的关于节奏的体会是:每个人都会有自己不同的节奏,这需要自己去摸索、练习,并慢慢提升。如果开始的节奏太快,可能很快就会疲惫、倦怠,很容易放弃;但如果一直节奏都很慢,则会达不到练习与提升的效果,变成了浪费时间。
|
||||
|
||||
执行长期计划,就如同跑马拉松,本来是一群人一起出发,慢慢地大家拉开了距离,再之后你甚至前后都看不到人了。是的,正如《那些匀速奔跑的人你永远都追不上》那篇文章所说:
|
||||
|
||||
|
||||
匀速奔跑的人是那些可以耐住寂寞的人,试想当你按照自己的节奏持之以恒默默努力地去做一件事情时,是极少会有伙伴同行的,因为大家的节奏各不一样,即便偶尔会有也只是陪你走过一段。
|
||||
|
||||
|
||||
但有时,我们看见别人跑得太快没了踪影,心里会很是焦急。我们身边有太多这样的人,把一切都当成是任务,必须要在某个确定的时间做完它,必须要在一个规定的时间内取得它应有的效益。
|
||||
|
||||
的确,我们的世界变化太快了,快到我们都怕浪费一分一秒,快到我们被这个世界的节奏所裹挟,所以就逼迫自己去努力,去完成,去改变,但却完全失去了自己的节奏,直到我们决定随它去吧,和大家随波逐流就好。
|
||||
|
||||
有时太急迫地“追赶”,最后反而阻挡了你稳步前进的步伐和节奏。
|
||||
|
||||
自己的节奏
|
||||
|
||||
找到并控制好自己的节奏,才能长期匀速地奔跑,才能更高效地利用好自己的时间和注意力。
|
||||
|
||||
对于每日计划的执行节奏,我自己的经验是:把自己的时间安排成一段一段的,高度集中和高度分心交叉分布。
|
||||
|
||||
假如某段时间需要高度集中注意力,就可以处理或思考一些比较难的事情。比如,50 ~ 60 分钟,集中注意力处理工作事务,远离手机信息推送及其他各种环境的打扰;然后休息一会儿,10 ~ 15 分钟左右,回复一些聊天或邮件这类其实不需要那么高注意力的事情。
|
||||
|
||||
有时,当你想去处理一件复杂困难的事情,比如写作,这是一种短时间内需要高度集中注意力的活动,但这时脑中总是在同时想着其他很多事情或者被动地接收一些环境信息(周围的谈话声之类的),还控制不住,很难集中注意力。这种情况下,就不用勉强开始,我通常会通过切换环境,从外部去排除一些干扰。
|
||||
|
||||
另外,如果感觉是比较疲惫,则更不能马上开始了,这种状态下,一般我都是立刻去小憩片刻或者闭目养神一段时间(20 ~ 30 分钟),进入一种浅睡眠状态再恢复过来,精力的恢复感会比较好。
|
||||
|
||||
恢复精力,我的感觉是浅睡优于深度睡眠,一是因为进入深度睡眠需要更长的时间,二是因为从中恢复过来也需要更长时间。所以,一旦进入深度睡眠,中途被人打断叫醒,会感觉非常困倦,我想很多人都有过这种感觉,俗称:睡过头了。
|
||||
|
||||
而另外一种中长期目标的执行节奏,控制起来可能要更困难一些。
|
||||
|
||||
比如,我们大部分人人生中第一阶段的奔跑目标:高考。为了奔向高考这个目标,我们有十二年时间进入学校,按照固定的节奏学习。一开始轻松些,跑得随意些;慢慢长大后,学业的压力开始明显起来,竞争的味道开始浓厚起来。特别是进入高中后,所有的同学都开始加速奔跑,以这样一种被设计好的节奏奔向目标。
|
||||
|
||||
这高考之前的学习节奏,更多是被整个社会和教育体系设计好的。我们只是在适应这个节奏,适应得很好的学生,高考一般都会取得不错的成绩;当然也有适应不了的同学,甚至有到不了参加高考就已经离开了赛道的。
|
||||
|
||||
在这个过程中,外界会给予我们一些期望的节奏压力,但要取得最好的效果,我们还是要找到自己的节奏。节奏是我们能长期持续奔跑的很重要的因素。还好高考结束后,再没有一个固定的时间点,也没有那么强大的外部环境去制约甚至强迫改变我们的节奏。
|
||||
|
||||
有时,只需要有一个目标,制一个计划,然后持续按自己的节奏跑下去。
|
||||
|
||||
找到自己的节奏,就是在每天略感挑战的状态下,形成不断加速前行,直到一个最终接近匀速的状态。匀速是我们能长期坚持的临界点,它能让我们跑得更久,跑得更远。
|
||||
|
||||
至此,关于计划一节的内容就全部结束了,我在文中分享了一些我的长期计划。那你有怎样的计划呢?是在用怎样的节奏去执行并完成它呢?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user