第7章 敏捷调试
你也许会对木匠那毫无差错的工作印象深刻。但我向你保证,事实不是这样的。真正的高手只是知道如何亡羊补牢。
——Jeff Miller,家具制造者、作家
即使是运作得最好的敏捷项目,也会发生错误。bug、错误、缺陷——不管被称作什么,它们总会发生。
在调试时面对的真正问题,是无法用固定的时间来限制。可以规定设计会议的持续时间,并在时间截止时决定采用最佳的方案。但是调试所耗费的时间,可能是一个小时、一天,甚至一周过去了,还是没有办法找到并解决问题。
对于一个项目来说,这种没有准确把握的时间消耗是不可接受的。不过,我们可以使用一些辅助技术,涵盖的范围包括:保留以前的问题解决方案,以及提供发生问题时的更多有用细节。
要想更加有效地重用你的知识和努力,记录问题解决日志是很有用的,我们会在下一页看到如何具体操作。当编译器警告有问题的时候,要假定警告就是错误,并且马上把它们解决掉(第132页)。
想在一个完整的系统中跟踪问题非常困难——甚至是不可能的。如果可以对问题各个击破,正如我们在第136页中看到的那样,就更容易找到问题了。不同于某些欲盖弥彰的行为,应该报告所有的异常,如第139页所述。最后,在报告某些事情出错之时,必须要考虑用户的感受,并且提供有用的错误信息。我们会在第141页看到这是为什么。
33 记录问题解决日志
“在开发过程中是不是经常遇到似曾相识的问题?这没关系。以前解决过的问题,现在还是可以解决掉的。”
面对问题(并解决它们)是开发人员的一种生活方式。当问题发生时,我们希望赶紧把它解决掉。如果一个熟悉的问题再次发生,我们会希望记起第一次是如何解决的,而且希望下次能够更快地把它搞定。然而,有时一个问题看起来跟以前遇到的完全一样,但是我们却不记得是如何修复的了。这种状况时常发生。
不能通过Web搜索获得答案吗?毕竟互联网已经成长为如此令人难以置信的信息来源,我们也应该好好加以利用。从Web上寻找答案当然胜过仅靠个人努力解决问题。可这是非常耗费时间的过程。有时可以找到需要的答案,有时除了找到一大堆意见和建议之外,发现不了实质性的解决方案。看到有多少开发人员遇到同样的问题,也许会感觉不错,但我们需要的是一个解决办法。
要想得到更好的效果,不妨维护一个保存曾遇到的问题以及对应解决方案的日志。这样,当问题发生时,就不必说:“嘿,我曾碰到过这个问题,但是不记得是怎么解决的了。”可以快速搜索以前用过的方法。工程师们已经使用这种方式很多年了,他们称之为每日日志(daylog)。
不要在同一处跌倒两次
Don’t get burned twice
可以选择符合需求的任何格式。下面这些条目可能会用得上。
□ 问题发生日期。
□ 问题简述。
□ 解决方案详细描述。
□ 引用文章或网址,以提供更多细节或相关信息。
□ 任何代码片段、设置或对话框的截屏,只要它们是解决方案的一部分,或者可以帮助更深入地理解相关细节。
要将日志保存为可供计算机搜索的格式,就可以进行关键字搜索以快速查找细节。图7-1展示了一个简单的例子,其中带有超链接以提供更多信息。
图7-1 带有超链接的解决方案条目示例
如果面临的问题无法在日志中找到解决方案,在问题解决之后,要记得马上将新的细节记录到日志中去。
要共享日志给其他人,而不仅仅是靠一个人维护。把它放到共享的网络驱动器中,这样其他人也可以使用。或者创建一个Wiki,并鼓励其他开发人员使用和更新其内容。
维护一个问题及其解决方案的日志。保留解决方案是修复问题过程的一部分,以后发生相同或类似问题时,就可以很快找到并使用了。
切身感受
解决方案日志应该作为思考的一个来源,可以在其中发现某些特定问题的细节。对于某些类似但是有差异的问题,也能从中获得修复的指引。
平衡的艺术
□ 记录问题的时间不能超过在解决问题上花费的时间。要保持轻量级和简单,不必达到对外发布式的质量。
□ 找到以前的解决方法非常关键。使用足够的关键字,可以帮助你在需要的时候发现需要的条目。
□ 如果通过搜索Web,发现没人曾经遇到同样的问题,也许搜索的方式有问题。
□ 要记录发生问题时应用程序、应用框架或平台的特定版本。同样的问题在不同的平台或版本上可能表现得不同。
□ 要记录团队做出一个重要决策的原因。否则,在6~9个月之后,想再重新回顾决策过程的时候,这些细节就很难再记得了,很容易发生互相指责的情形。
34 警告就是错误
“编译器的警告信息只不过是给过分小心和过于书呆子气的人看的。它们只是警告而已。如果导致的后果很严重,它们就是错误了,而且会导致无法通过编译。所以干脆忽略它们就是了。”
当程序中出现一个编译错误时,编译器或是构建工具会拒绝产生可执行文件。我们别无选择——必须要先修正错误,再继续前行。
然而,警告却是另外一种状况。即使代码编译时产生了警告,我们还是可以运行程序。那么忽略警告信息继续开发代码,会导致什么状况呢?这样做等于是坐在了一个嘀嗒作响的定时炸弹上,而且它很有可能在最糟糕的时刻爆炸。
有些警告是过于挑剔的编译器的良性副产品,有些则不是。例如,一个关于未被使用的变量的警告,可能不会产生什么恶劣影响,但却有可能是暗示某些变量被错误使用了。
最近在一家客户那里,Venkat发现一个开发中的应用有多于300个警告。其中一个被开发人员忽略的警告是这样:
相关代码如下:
也就是说,if语句总是会评估为true,无论不幸的theTextBox变量是什么状况。看到类似这样真正的错误被当作警告忽略掉,真是令人感到害怕。
看看下面的C#代码:
在使用Visual Studio 2003默认的项目设置对其进行编译时,会看到如此信息“构建:1个成功,0失败,0跳过”显示在Output窗口的底部。运行程序,会得到这样的输出:
但这不是我们预期的结果。应该看到两次对Derived类中foo方法的调用。是哪里出错了?如果仔细查看Output窗口,可以发现这样的警告信息:
这明显是一个错误——在Derived类的foo()方法中,应该使用override而不是virtual。[1]想象一下,有组织地忽略代码中类似这样的错误会导致什么样的后果。代码的行为会变得无法预测,其质量会直线下降。
可能有人会说优秀的单元测试可以发现这些问题。是的,它们可以起到帮助作用(而且也应该使用优秀的单元测试)。可如果编译器可以发现这种问题,那为什么不利用它呢?这可以节省大量的时间和麻烦。
要找到一种方式让编译器将警告作为错误提示出来。如果编译器允许调整警告的报告级别,那就把级别调到最高,让任何警告不能被忽略。例如,GCC编译器支持-Werror参数,在Visual Studio中,开发人员可以改变项目设置,将警告视为错误。
对于一个项目的警告信息来说,至少也要做到这种地步。然而,如果采取这种方式,就要对创建的每个项目去进行设置。如果可以尽量以全局化的方式来进行设置就好了。
比如,在Visual Studio中,开发人员可以修改项目模板(查看.NET Gotchas[Sub05]获取更多细节),这样在计算机上创建的任何项目,都会有同样的完整项目设置。在当前版本的Eclipse中,可以按照这样的顺序修改设置:Windows→Preferences→Java→Compiler→Errors/Warnings。如果使用其他的语言或IDE,花一些时间来找出如何在其中将警告作为错误处理吧。
在修改设置的时候,要记得在构建服务器上使用的持续集成工具中,修改同样的设置选项。(要详细了解持续集成,查看第87页习惯21。)这个小小的设置,可以大大提升团队签入到源码控制系统中的代码质量。
在开始一个项目的时候,要把相关的设置都准备好。在项目进行到一半的时候,突然改变警告设置,有可能会带来颠覆性的后果,导致难以控制。
编译器可以轻易处理警告信息,可是你不能。
将警告视为错误。签入带有警告的代码,就跟签入有错误或者没有通过测试的代码一样,都是极差的做法。签入构建工具中的代码不应该产生任何警告信息。
切身感受
警告给人的感觉就像……哦,警告。它们就某些问题给出警告,来吸引开发人员的注意。
平衡的艺术
□ 虽然这里探讨的主要是编译语言,解释型语言通常也有标志,允许运行时警告。使用相关标志,然后捕获输出,以识别并最终消除警告。
□ 由于编译器的bug或是第三方工具或代码的原因,有些警告无法消除。如果确实没有应对之策的话,就不要再浪费更多时间了。但是类似的状况很少发生。
□ 应该经常指示编译器:要特别注意别将无法避免的警告作为错误进行提示,这样就不用费力去查看所有的提示,以找到真正的错误和警告。
□ 弃用的方法被弃用是有原因的。不要再使用它们了。至少,安排一个迭代来将它们(以及它们引起的警告信息)安全地移除掉。
□ 如果将过去开发完成的方法标记为弃用方法,要记录当前用户应该采取何种变通之策,以及被弃用的方法将会在何时一起移除。
35 对问题各个击破
“逐行检查代码库中的代码确实很令人恐惧。但是要调试一个明显的错误,只有去查看整个系统的代码,而且要全部过一遍。毕竟你不知道问题可能发生在什么地方,这样做是找到它的唯一方式。”
单元测试(在第76页,第5章)带来的积极效应之一,是它会强迫形成代码的分层。要保证代码可测试,就必须把它从周边代码中解脱出来。如果代码依赖其他模块,就应该使用mock对象,来把它从其他模块中分离开。这样做不但让代码更加健壮,且在发生问题时,也更容易定位来源。
否则,发生问题时有可能无从下手。也许可以先使用调试器,逐行执行代码,并试图隔离问题。也许在进入到感兴趣的部分之前,要运行多个表单或对话框,这会导致更难发现问题的根源。你会发现自己陷入整个系统之中,徒然增加了压力,而且降低了工作效率。
大型系统非常复杂——在执行过程中会有很多因素起作用。从整个系统的角度来解决问题,就很难区分开,哪些细节对要定位的特定问题产生影响,而哪些细节没有。
答案很清晰:不要试图马上了解系统的所有细节。要想认真调试,就必须将有问题的组件或模块与其他代码库分离开来。如果有单元测试,这个目的就已经达到了。否则,你就得开动脑筋了。
比如,在一个时间紧急的项目中(哪个项目的时间不紧急呢),Fred和George发现他们面对的是一个严重的数据损毁问题。要花很多精力才能知道哪里出了问题,因为开发团队没有将数据库相关的代码与其他的应用代码分离开。他们无法将问题报告给软件厂商,当然不能把整个代码库用电子邮件发给人家!
于是,他们俩开发了一个小型的原型系统,并展示了类似的症状;然后将其发送给厂商作为实例,并询问他们的专家意见,使用原型帮助他们对问题理解得更清晰。
而且,如果他们无法在原型中再现问题的话,原型也能告诉他们可以工作的代码示例,这也有助于分离和发现问题。
识别复杂问题的第一步,是将它们分离出来。既然不可能在半空中试图修复飞机引擎,为什么还要试图在整个应用中,诊断其中某个组成部分的复杂问题呢?当引擎被从飞机中取出来,而且放在工作台上之后,就更容易修复了。同理,如果可以隔离出发生问题的模块,也更容易修复发生问题的代码。
用原型进行分离
Prototype to isolate
可是,很多应用的代码在编写时没有注意到这一点,使得分离变得特别困难。应用的各个构成部分之间会彼此纠结:想把这个部分单独拿出来,其他的会紧随而至。[2]在这些状况下,最好花一些时间把关注的代码提取出来,而且创建一个可让其工作的测试环境。
对问题各个击破,这样做有很多好处:通过将问题与应用其他部分隔离开,可以将关注点直接放在与问题相关的议题上;可以通过多种改变,来接近问题发生的核心——你不可能针对正在运行的系统来这样做。可以更快地发现问题的根源所在,因为只与所需最小数量的相关代码发生关系。
隔离问题不应该只在交付软件之后才着手。在构建系统原型、调试和测试时,各个击破的战略都可以起到帮助作用。
对问题各个击破。在解决问题时,要将问题域与其周边隔离开,特别是在大型应用中。
切身感受
面对必须要隔离的问题时,感觉就像在一个茶杯中寻找一根针,而不是大海捞针。
平衡的艺术
□ 如果将代码从其运行环境中分离后,问题消失不见了,这有助于隔离问题。
□ 另一方面,如果将代码从其运行环境中分离后,问题还在,这也有助于隔离问题。
□ 以二分查找的方式来定位问题是很有用的。也就是说,将问题空间分为两半,看看哪一半包含问题。再将包含问题的一半进行二分,并不断重复这个过程。
□ 在向问题发起攻击之前,先查找你的问题解决日志(见第129页习惯33)。
36 报告所有的异常
“不要让程序的调用者看到那些奇怪的异常。处理它们是你的责任。把你调用的一切都包起来,然后发送自己定义的异常——或者干脆自己解决掉。”
从事任何编程工作,都要考虑事物正常状况下是如何运作的。不过更应该想一想,当出现问题——也就是事情没有按计划进行时,会发生什么。
在调用别人的代码时,它也许会抛异常,这时我们可以试着对其处理,并从失败中恢复。当然,要是在用户没有意识到的情况下,可以恢复并继续正常处理流程,这就最好不过了。要是不能恢复,应该让调用代码的用户知道,到底是哪里出现了问题。
不过也不尽然。Venkat曾经在使用一个非常流行的开源程序库(这里就不提它的名字了)时倍受打击。他调用的一个方法本来应该创建一个对象,可是得到的却是null引用。涉及的代码量非常少,而且没有其他代码发生联系,也很简单。所以从他自己写的这块代码的角度来看,不太可能出问题,他摸不到一点头绪。
幸好这个库是开源的,所以他下载了源代码,然后带着问题检查了相关的方法。这个方法调用了另外的方法,那个方法认为他的系统中缺少了某些必要的组件。这个底层方法抛出了带有相关信息的异常。但是,上层方法却偷偷地用没有异常处理代码的空catch代码块,把异常给忽略掉了,然后就抛出一个null。Venkat所写的代码根本不知道到底发生了什么,只有通过阅读程序库的代码,他才能明白这个问题,并最后安装了缺失的组件。
像Java中那样的检查异常会强迫你捕捉异常,或是把异常传播出去。可是有些开发人员会采取临时的做法:捕捉到异常后,为了不看到编译器的提示,就把异常忽略掉。这样做很危险——临时的补救方式很容易被遗忘,并且会进入到生产系统的代码中。必须要处理所有的异常,倘若可以,从失败中恢复再好不过。如果不能处理,就要把异常传播到方法的调用者,这样调用者就可以尝试对其进行处理了(或者以优雅的方式将问题的信息告诉给用户,见习惯37)。
听起来很明白,是吧?其实不像想象得那么容易。不久前有一条新闻,提到一套大型航空订票系统中发生了严重的问题。系统崩溃,飞机停飞,上千名旅客滞留机场,整个航空运输系统数天之内都乱作一团。原因是什么?在一台应用服务器上发生了一个未检查异常。
也许你很享受CNN新闻上提到你名字的感觉,但是你不太可能希望发生这样的情形。
处理或是向上传播所有的异常。不要将它们压制不管,就算是临时这样做也不行。在写代码时要估计到会发生的问题。
切身感受
当出现问题时,心里知道能够得到抛出的异常。而且没有空的异常处理方法。
平衡的艺术
□ 决定由谁来负责处理异常是设计工作的一部分。
□ 不是所有的问题都应该抛出异常。
□ 报告的异常应该在代码的上下文中有实际意义。在前述的例子中,抛出一个NullPointerException看起来也许不错,不过这就像抛出一个null对象一样,起不到任何帮助作用。
□ 如果代码中会记录运行时调试日志,当捕获或是抛出异常时,都要记录日志信息;这样做对以后的跟踪工作很有帮助。
□ 检查异常处理起来很麻烦。没人愿意调用抛出31种不同检查异常的方法。这是设计上的问题:要把它解决掉,而不是随便打个补丁就算了。
□ 要传播不能处理的异常。
37 提供有用的错误信息
“不要吓着用户,吓程序员也不行。要提供给他们干净整洁的错误信息。要使用如下这样让人舒服的词句:‘用户错误。替换,然后继续’。”
当应用发布并且在真实世界中得到使用之后,仍然会发生这样那样的问题。比如计算模块可能出错,与数据库服务器之间的连接也可能丢失。当无法满足用户需求时,要以优雅的方式进行处理。
类似的错误发生时,是不是只要弹出一条优雅且带有歉意的信息给用户就足够了?并不尽然。当然了,显示通用的信息,告诉用户发生了问题,要好过由于系统崩溃造成应用执行错误的动作,或者直接关闭(用户会因此感到困惑,并希望知道问题所在)。然而,类似“出错了”这样的消息,无法帮助团队针对问题做出诊断。用户在给支持团队打电话报告问题时,我们希望他们提供足够多且好的信息,以帮助尽快识别问题所在。遗憾的是,用很通用的错误消息,是无法提供足够的数据的。
针对这个问题,常用的解决方案是记录日志:当发生问题时,让应用详细记录错误的相关数据。错误日志最起码应该以文本文件的形式维护。不过也许可以发布到一个系统级别的事件日志中。可以使用工具来浏览日志,产生所有日志信息的RSS Feed,以及诸如此类的辅助方式。
记录日志很有用,可是单单这样做是不够的:开发人员认真分析日志,可以得到需要的数据;但对于不幸的用户来说,起不到任何帮助作用。如果展示给他们类似下图中的信息,他们还是一点头绪都没有——不知道自己到底做错了什么,应该怎么做可以绕过这个错误,或者在给技术支持打电话时,应该报告什么。
如果你注意的话,在开发阶段就能发现这个问题的早期警告。作为开发人员,经常要将自己假定为用户来测试新功能。要是错误信息很难理解,或者无助于定位错误的话,就可以想想真正的用户和支持团队,遇到这个问题时会有多么困难了(见图7-2)。
图7-2 无用的异常信息
例如,假定登录UI调用了应用的中间层,后台向数据访问层发送了一个请求。由于无法连接数据库,数据访问层抛出一个异常。这个异常被中间层用自己的异常包裹起来,并继续向上传递。那么UI层应该怎么做呢?它至少应该让用户知道发生了系统错误,而不是由用户的输入引起的。
接下来,用户会打电话并且告诉我们他无法登录。我们怎么知道问题的实质是什么呢?日志文件可能有上百个条目,要找到相关的细节非常困难。
实际上,不妨在显示给用户的信息中提供更多细节。好比说,可以看到是哪条SQL查询或存储过程发生了错误;这样可以很快找到问题并且修正,而不是浪费大把的时间去盲目地碰运气。不过另一方面,在生产系统中,向用户显示数据连接问题的特定信息,不会对他们有多大帮助。而且有可能吓他们一跳。
一方面要提供给用户清晰、易于理解的问题描述和解释,使他们有可能寻求变通之法。另一方面,还要提供具备关于错误的详细技术细节给用户,这样方便开发人员寻找代码中真正的问题所在。
下面是一种同时实现上述两个目的方式:图中显示了清晰的错误说明信息。该错误信息不只是简单的文本,还包括了一个超链接。用户、开发人员、测试人员都可以由此链接得到更多信息,如图7-3、图7-4所示。
图7-3 带有更多细节链接的异常信息
图7-4 供调试用的完整详细信息
进入链接的页面,可以看到异常(以及所有嵌套异常)的详细信息。在开发时,我们可能希望只要看到这些细节就好了。不过,当应用进入生产系统后,就不能把这些底层细节直接暴露给用户了,而要提供链接,或是某些访问错误日志的入口。支持团队可以请用户点击错误信息,并读出错误日志入口的相关信息,这样支持团队可以很快找到错误日志中的特定细节。对于独立系统来说,点击链接,有可能会将错误信息通过电子邮件发送到支持部门。
除了包括出现问题的详细数据外,日志中记录的信息可能还有当时系统状态的一个快照(例如Web应用的会话状态)。[3]
使用上述信息,系统支持团队可以重建发生问题的系统状态,这样对查找和修复问题非常有效。
错误报告对于开发人员的生产率,以及最终的支持活动消耗成本,都有很大的影响。在开发过程中,如果定位和修复问题让人倍受挫折,就考虑使用更加积极主动的错误报告方式吧。调试信息非常宝贵,而且不易获得。不要轻易将其丢弃。
展示有用的错误信息。提供更易于查找错误细节的方式。发生问题时,要展示出尽量多的支持细节,不过别让用户陷入其中。
区分错误类型
程序缺陷。这些是真正的bug,比如NullPointerException、缺少主键等。用户或者系统管理员对此束手无策。
环境问题。该类别包括数据库连接失败,或是无法连接远程Web Service、磁盘空间满、权限不足,以及类似的问题。程序员对此没有应对之策,但是用户也许可以找到变通的方法,如果提供足够详细的信息,系统管理员应该可以解决这些问题。
用户错误。程序员与系统管理员不必担心这些问题。在告知是哪里操作的问题后,用户可以重新来过。
通过追踪记录报告的错误类型,可以为受众提供更加合适的建议。
切身感受
错误信息有助于问题的解决。当问题发生时,可以详细研究问题的细节描述和发生上下文。
平衡的艺术
□ 像“无法找到文件”这样的错误信息,就其本身而言无助于问题的解决。“无法打开/andy/project/main.yaml以供读取”这样的信息更有效。
□ 没有必要等待抛出异常来发现问题。在代码关键点使用断言以保证一切正常。当断言失败时,要提供与异常报告同样详细的信息。
□ 在提供更多信息的同时,不要泄露安全信息、个人隐私、商业机密,或其他敏感信息(对于基于Web的应用,这一点尤其重要)。
□ 提供给用户的信息可以包含一个主键,以便于在日志文件或是审核记录中定位相关内容。
【注释】
[1]这对C++程序员来讲是一个潜伏的陷阱。在C++中代码可以按预期方式工作。
[2]这被亲切地称为“大泥球”(Big Ball of Mud)设计反模式。
[3]有些安全敏感的信息不应该被暴露,甚至不可以记录到日志中去,这其中包括密码、银行账户等。