"If debugging is the process of removing software bugs, then programming must be the process of putting them in." – Edsger W. Dijkstra
在专业程序员的生活中,调试和故障排除会占用大量时间。即使你在人类编写的最漂亮的代码库上工作,仍然会有 bug;这是有保证的。
我们花了大量的时间阅读别人的代码,在我看来,一个好的软件开发人员是能够保持高度注意力的人,即使他们阅读的代码没有被报告为错误或错误。
能够高效快速地调试代码是每个程序员都需要不断改进的技能。有些人认为,因为他们已经阅读了手册,所以他们很好,但事实是,游戏中的变量太多了,以至于没有手册。有一些指导方针可以遵循,但没有一本神奇的书可以教给你所有你需要知道的东西,以便在这方面做得很好。
我觉得在这个问题上,我从同事那里学到的最多。看到一个非常熟练的人攻击一个问题,我感到很惊讶。我喜欢看到他们采取的步骤,他们核实的事情排除可能的原因,以及他们认为嫌疑犯最终引导他们解决问题的方式。
与我们共事的每一位同事都能教给我们一些东西,或者用一个奇妙的猜测让我们大吃一惊。当这种情况发生时,不要只是保持惊讶(或者更糟糕的是,嫉妒),而是抓住这一刻,问他们是如何得到这种猜测的,以及为什么。答案将让你看到是否有什么东西可以在以后深入研究,这样,也许下一次,你将是一个谁会赶上错误。
有些 bug 很容易被发现。它们来自粗糙的错误,一旦你看到这些错误的影响,就很容易找到解决问题的方法。
但还有其他更微妙、更狡猾的缺陷,需要真正的专业知识、大量的创造力和开箱即用的思维来处理。
最糟糕的,至少对我来说,是那些不确定的。这些事情有时会发生,有时不会。有些只发生在环境 A 中,而不发生在环境 B 中,即使 A 和 B 应该完全相同。那些虫子是真正邪恶的,它们会让你发疯。
当然,虫子不会只发生在沙箱里,对吧?你的老板告诉你,*“别担心!慢慢来解决这个问题。先吃午饭!”*不。它们发生在一个周五五点半,当你的大脑被煮熟,你只想回家。当每个人都在瞬间感到不安,当你的老板在你的脖子上呼吸时,你必须保持冷静。我是认真的。这是最重要的技能,如果你想能够有效地打击错误。如果你让你的大脑感到压力,那就告别创造性思维,告别逻辑推理,告别那一刻你需要的一切。所以深呼吸,坐好,集中注意力。
在本章中,我将尝试展示一些有用的技术,您可以根据缺陷的严重性来使用这些技术,以及一些建议,希望这些建议能够帮助您增强针对缺陷和问题的武器。
具体来说,我们将了解以下内容:
- 调试技术
- 轮廓
- 断言
- 故障排除指南
在这一部分中,我将向您介绍最常用的技术,也就是我最常使用的技术;但是,请不要认为这个清单是详尽无遗的。
这可能是最简单的技术。它不是很有效,不能在任何地方使用,它需要访问源代码和运行它的终端(因此显示print函数调用的结果)。
然而,在许多情况下,这仍然是一种快速而有用的调试方法。例如,如果您正在开发一个 Django 网站,而页面中发生的事情并不是您所期望的,那么您可以在重新加载页面时用打印填充视图,并监视控制台。当您在代码中分散调用print时,通常会出现重复大量调试代码的情况,这可能是因为您正在打印时间戳(就像我们在测量列表理解和生成器的速度时所做的那样),也可能是因为您必须以某种方式构建要显示的某种字符串。
另一个问题是,在代码中很容易忘记对print的调用。
因此,出于这些原因,我有时宁愿编写自定义函数,而不是使用对print的简单调用。让我们看看如何。
在代码段中有一个自定义函数,可以快速获取并粘贴到代码中,然后用于调试,这是非常有用的。如果你速度快,你可以随时编写一个代码。重要的是,要以这样一种方式对其进行编码:当您最终删除调用及其定义时,它不会留下任何东西。因此以一种完全独立的方式对其进行编码非常重要。此要求的另一个很好的理由是,它将避免与代码的其余部分发生潜在的名称冲突。
让我们看一个这样一个函数的例子:
# custom.py
def debug(*msg, print_separator=True):
print(*msg)
if print_separator:
print('-' * 40)
debug('Data is ...')
debug('Different', 'Strings', 'Are not a problem')
debug('After while loop', print_separator=False)在本例中,我使用一个只包含关键字的参数来打印分隔符,它是一行40破折号。
功能非常简单。我只是将msg中的内容重定向到print调用,如果print_separator是True,我就打印一个行分隔符。运行代码将显示以下内容:
$ python custom.py
Data is ...
----------------------------------------
Different Strings Are not a problem
----------------------------------------
After while loop如您所见,最后一行之后没有分隔符。
这只是一种简单的方法,可以以某种方式增加对print函数的简单调用。让我们看看如何计算调用之间的时间差,利用 Python 的一个棘手特性:
# custom_timestamp.py
from time import sleep
def debug(*msg, timestamp=[None]):
print(*msg)
from time import time # local import
if timestamp[0] is None:
timestamp[0] = time() #1
else:
now = time()
print(
' Time elapsed: {:.3f}s'.format(now - timestamp[0])
)
timestamp[0] = now #2
debug('Entering nasty piece of code...')
sleep(.3)
debug('First step done.')
sleep(.5)
debug('Second step done.')这有点棘手,但仍然很简单。首先,请注意,我们从time模块的debug函数内部导入了time函数。这使我们可以避免在函数之外添加导入,并且可能会忘记它。
看看我是如何定义timestamp的。当然,这是一个列表,但这里重要的是它是一个可变对象。这意味着它将在 Python 解析函数时设置,并在不同的调用中保留其值。因此,如果我们在每次调用后都在其中添加时间戳,我们就可以跟踪时间,而不必使用外部全局变量。我从我对闭包的研究中借用了这个技巧,我鼓励大家阅读这个技巧,因为它非常有趣。
对,所以,在打印了我们必须打印的任何消息和一些导入时间之后,我们检查了timestamp中唯一项目的内容。如果是None,则我们之前没有参考,因此我们将该值设置为当前时间(#1。
另一方面,如果我们有一个先前的引用,我们可以计算一个差(我们很好地将其格式化为三个十进制数字),然后我们最终将当前时间再次放入timestamp(#2。这是个好把戏,不是吗?
运行此代码将显示以下结果:
$ python custom_timestamp.py
Entering nasty piece of code...
First step done.
Time elapsed: 0.304s
Second step done.
Time elapsed: 0.505s无论您的情况如何,拥有这样一个自包含的函数都是非常有用的。
我们在第 8 章测试、分析和处理异常中简要介绍了回溯,当时我们看到了几种不同类型的异常。回溯为您提供有关应用程序中出现错误的信息。阅读它很有帮助,所以让我们看一个小例子:
# traceback_simple.py
d = {'some': 'key'}
key = 'some-other'
print(d[key])我们有一个字典,我们试图访问一个不在其中的密钥。您应该记住,这将引发一个KeyError异常。让我们运行代码:
$ python traceback_simple.py
Traceback (most recent call last):
File "traceback_simple.py", line 3, in <module>
print(d[key])
KeyError: 'some-other'您可以看到,我们获得了所需的所有信息:模块名称、导致错误的行(数字和指令)以及错误本身。有了这些信息,您可以返回源代码并尝试了解发生了什么。
现在让我们创建一个更有趣的示例,它构建在这个示例的基础上,并练习一个仅在 Python3 中可用的特性。假设我们正在验证一个字典,处理必填字段,因此我们希望它们在那里。如果没有,我们需要提出一个定制ValidationError,我们将在运行验证器的流程中进一步捕获上游(这里没有显示,所以它可能是任何东西,真的)。应该是这样的:
# traceback_validator.py
class ValidatorError(Exception):
"""Raised when accessing a dict results in KeyError. """
d = {'some': 'key'}
mandatory_key = 'some-other'
try:
print(d[mandatory_key])
except KeyError as err:
raise ValidatorError(
f'`{mandatory_key}` not found in d.'
) from err我们定义了一个自定义异常,该异常在强制键不存在时引发。注意,它的主体由文档字符串组成,因此我们不需要添加任何其他语句。
非常简单,我们定义一个虚拟 dict 并尝试使用mandatory_key访问它。我们捕获KeyError并在发生这种情况时提升ValidatorError。我们通过使用raise ... from ...语法来实现这一点,PEP3134(在 Python3 中引入了这种语法 https://www.python.org/dev/peps/pep-3134/ ),以链接异常。这样做的目的是,我们可能还希望在其他情况下提高ValidatorError,而不一定是因为缺少强制密钥。这种技术允许我们在一个只关心ValidatorError的简单try/except中运行验证。
如果无法链接异常,我们将丢失关于KeyError的信息。代码生成以下结果:
$ python traceback_validator.py
Traceback (most recent call last):
File "traceback_validator.py", line 7, in <module>
print(d[mandatory_key])
KeyError: 'some-other'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "traceback_validator.py", line 10, in <module>
'`{}` not found in d.'.format(mandatory_key)) from err
__main__.ValidatorError: `some-other` not found in d.这很精彩,因为我们可以看到导致我们提出ValidationError的异常的回溯,以及ValidationError本身的回溯。
我与我的一位评论员就pip安装程序提供的回溯进行了很好的讨论。为了查看第 13 章、数据科学的代码,他无法设置所有内容。他新安装的 Ubuntu 缺少几个库,pip包需要这些库才能正确运行。
他被阻止的原因是他试图修复从顶部开始的回溯中显示的错误。我建议他从底部开始,然后解决这个问题。原因是,如果安装程序已经到达了最后一行,我想在那之前,无论发生了什么错误,仍然可以从中恢复。直到最后一行之后,pip才决定不可能再继续下去,因此我开始修复这一行。一旦安装了修复该错误所需的库,其他一切都会顺利进行。
阅读回溯可能很棘手,我的朋友缺乏正确解决这个问题的必要经验。因此,如果你最终处于同样的情况。不要气馁,试着改变一下,不要想当然。
Python 有一个庞大而精彩的社区,当您遇到问题时,您不太可能是第一个看到它的人,所以打开浏览器并进行搜索。通过这样做,您的搜索技能也将提高,因为您必须将错误减少到最低限度,但这是使您的搜索有效的基本细节集。
如果您想更好地玩和理解回溯,在标准库中有一个模块可以使用,名为“惊喜惊喜”traceback。它提供了一个标准接口来提取、格式化和打印 Python 程序的堆栈跟踪,模仿 Python 解释器在打印堆栈跟踪时的行为。
调试 Python 的另一种非常有效的方法是使用 Python 调试器:pdb。但是,您不应该直接使用它,而应该查看pdbpp库。pdbpp通过提供一些方便的工具来扩充标准的pdb界面,我最喜欢的工具是粘滞模式,它允许您在一步一步地查看整个功能的说明。
使用此调试器有几种不同的方法(无论哪个版本,都不重要),但最常见的方法是设置断点并运行代码。当 Python 到达断点时,执行将暂停,您可以通过控制台访问该断点,以便检查所有名称,依此类推。您还可以动态更改数据以更改程序流。
作为一个玩具示例,让我们假设有一个解析器正在提升KeyError,因为字典中缺少一个键。字典来自一个我们无法控制的 JSON 负载,目前我们只想欺骗并传递该控制,因为我们对随后发生的事情感兴趣。让我们看看我们如何截获这一时刻,检查数据,修复数据,并通过pdbpp找到问题的根源:
# pdebugger.py
# d comes from a JSON payload we don't control
d = {'first': 'v1', 'second': 'v2', 'fourth': 'v4'}
# keys also comes from a JSON payload we don't control
keys = ('first', 'second', 'third', 'fourth')
def do_something_with_value(value):
print(value)
for key in keys:
do_something_with_value(d[key])
print('Validation done.')如您所见,当key获取字典中缺少的'third'值时,此代码将中断。记住,我们假装d和keys都是动态地来自我们不控制的 JSON 负载,所以我们需要检查它们,以便修复d并通过for循环。如果按原样运行代码,则会得到以下结果:
$ python pdebugger.py
v1
v2
Traceback (most recent call last):
File "pdebugger.py", line 10, in <module>
do_something_with_value(d[key])
KeyError: 'third'所以我们看到字典中缺少了key,但是因为每次我们运行这个代码时,我们可能会得到不同的字典或keys元组,所以这些信息并没有真正帮助我们。让我们在for循环之前向pdb注入一个调用。您有两个选择:
import pdb
pdb.set_trace()这是最常见的方法。您导入pdb并调用其set_trace方法。许多开发人员的编辑器中都有宏,可以使用键盘快捷键添加此行。但是,从 Python 3.7 开始,我们可以进一步简化事情,如下所示:
breakpoint()新的breakpoint内置函数调用引擎盖下的sys.breakpointhook(),默认编程为调用pdb.set_trace()。但是,您可以重新编程sys.breakpointhook()来调用您想要的任何东西,因此breakpoint也会指向它,这非常方便。
本例代码在pdebugger_pdb.py模块中。如果我们现在运行这段代码,事情会变得有趣(请注意,您的输出可能会有一些变化,并且此输出中的所有注释都是我添加的):
$ python pdebugger_pdb.py
(Pdb++) l
16
17 -> for key in keys: # breakpoint comes in
18 do_something_with_value(d[key])
19
(Pdb++) keys # inspecting the keys tuple
('first', 'second', 'third', 'fourth')
(Pdb++) d.keys() # inspecting keys of `d`
dict_keys(['first', 'second', 'fourth'])
(Pdb++) d['third'] = 'placeholder' # add tmp placeholder
(Pdb++) c # continue
v1
v2
placeholder
v4
Validation done.首先,请注意,当您到达一个断点时,会收到一个控制台,告诉您在哪里(Python 模块)以及下一行将执行哪一行。此时,您可以执行一系列探索性操作,例如在下一行前后检查代码、打印堆栈跟踪以及与对象交互。请参考官方 Python 文档(https://docs.python.org/3.7/library/pdb.html 上的pdb了解更多信息。在我们的例子中,我们首先检查keys元组。之后,我们检查d的钥匙。我们看到'third'不见了,所以我们把它放在了自己的身上(想想这会不会很危险)。最后,现在所有的键都在了,我们输入c,这意味着(ccontinue)。
pdb还使您能够使用(next)一次执行一行代码,将(step 转换为函数进行更深入的分析,或使用(break)处理中断。有关完整的命令列表,请参阅控制台中的文档或类型(help)。
从上一次运行的输出可以看出,我们最终可以完成验证。
pdb(或pdbpp)是我每天使用的无价工具。所以,去玩一玩,在某个地方设置一个断点,并尝试检查它,遵循官方文档并尝试代码中的命令,以查看它们的效果并很好地学习它们。
Notice that in this example I have assumed you installed pdbpp. If that is not the case, then you might find that some commands don't work the same in pdb. One example is the letter d, which would be interpreted from pdb as the down command. In order to get around that, you would have to add a ! in front of d, to tell pdb that it is meant to be interpreted literally, and not as a command.
调试行为异常的应用程序的另一种方法是检查其日志文件。日志文件是一种特殊的文件,应用程序在其中记录各种事情,通常与内部发生的事情有关。如果启动了一个重要的过程,我通常会在日志中看到相应的行。当它结束的时候是一样的,可能是因为它里面发生了什么。
需要记录错误,以便在出现问题时,我们可以通过查看日志文件中的信息来检查出了什么问题。
在 Python 中设置记录器有许多不同的方法。日志记录非常灵活,您可以对其进行配置。简而言之,游戏中通常有四名玩家:记录器、处理程序、过滤器和格式化程序:
- 记录器:直接公开应用程序代码使用的接口
- 处理程序:将日志记录(由记录器创建)发送到相应的目的地
- 过滤器:提供更细粒度的工具,用于确定要输出哪些日志记录
- 格式化程序:指定最终输出中日志记录的布局
通过调用Logger类实例上的方法来执行日志记录。您记录的每一行都有一个级别。通常使用的级别为:DEBUG、INFO、WARNING、ERROR和CRITICAL。您可以从logging模块导入它们。它们是按严重程度排序的,正确使用它们非常重要,因为它们将帮助您根据搜索内容过滤日志文件的内容。日志文件通常会变得非常大,因此正确地编写其中的信息非常重要,以便在重要的时候能够快速找到它。
您可以登录到文件,但也可以登录到网络位置、队列、控制台等。通常,如果您的体系结构部署在一台机器上,则可以将日志记录到文件,但当您的体系结构跨越多台机器时(例如,在面向服务或微服务体系结构的情况下),实现一个集中式的日志记录解决方案非常有用,这样来自每个服务的所有日志消息都可以在一个地方存储和调查。这很有帮助,否则,试图将来自多个不同来源的巨大文件关联起来以找出哪里出了问题可能会变得非常困难。
A service-oriented architecture (SOA) is an architectural pattern in software design in which application components provide services to other components via a communications protocol, typically over a network. The beauty of this system is that, when coded properly, each service can be written in the most appropriate language to serve its purpose. The only thing that matters is the communication with the other services, which needs to happen via a common format so that data exchange can be done. Microservice architectures are an evolution of SOAs, but follow a different set of architectural patterns.
在这里,我将向您展示一个非常简单的日志记录示例。我们将把一些消息记录到一个文件中:
# log.py
import logging
logging.basicConfig(
filename='ch11.log',
level=logging.DEBUG, # minimum level capture in the file
format='[%(asctime)s] %(levelname)s: %(message)s',
datefmt='%m/%d/%Y %I:%M:%S %p')
mylist = [1, 2, 3]
logging.info('Starting to process `mylist`...')
for position in range(4):
try:
logging.debug(
'Value at position %s is %s', position, mylist[position]
)
except IndexError:
logging.exception('Faulty position: %s', position)
logging.info('Done parsing `mylist`.')让我们一行一行地看一遍。首先,我们导入logging模块,然后设置一个基本配置。一般来说,生产日志配置要比这复杂得多,但我想让事情尽可能简单。我们指定文件名、要在文件中捕获的最低日志记录级别以及消息格式。我们将记录日期和时间信息、级别和消息。
我将首先记录一条info消息,告诉我我们即将处理我们的列表。然后,我将记录(这次使用DEBUG级别,使用debug函数),这是某个位置的值。我在这里使用debug是因为我希望将来能够过滤掉这些日志(通过将最低级别设置为logging.INFO或更高),因为我可能需要处理非常大的列表,我不想记录所有的值。
如果我们得到IndexError(我们得到了,因为我在range(4)上循环),我们调用logging.exception(),这与logging.error()相同,但它也会打印回溯。
在代码末尾,我记录了另一条info消息,说我们完成了。结果是:
# ch11.log
[05/06/2018 11:13:48 AM] INFO:Starting to process `mylist`...
[05/06/2018 11:13:48 AM] DEBUG:Value at position 0 is 1
[05/06/2018 11:13:48 AM] DEBUG:Value at position 1 is 2
[05/06/2018 11:13:48 AM] DEBUG:Value at position 2 is 3
[05/06/2018 11:13:48 AM] ERROR:Faulty position: 3
Traceback (most recent call last):
File "log.py", line 15, in <module>
position, mylist[position]))
IndexError: list index out of range
[05/06/2018 11:13:48 AM] INFO:Done parsing `mylist`.这正是我们所需要的,以便能够调试一个在盒子上运行的应用程序,而不是在控制台上运行的应用程序。我们可以看到发生了什么,任何异常的回溯,等等。
The example presented here only scratches the surface of logging. For a more in-depth explanation, you can find information in the Python HOWTOs section of the official Python documentation: Logging HOWTO, and Logging Cookbook.
伐木是一门艺术。您需要在记录所有内容和不记录任何内容之间找到一个良好的平衡。理想情况下,您应该记录确保应用程序正常工作所需的任何内容,以及所有错误或异常。
在最后一节中,我将简要演示一些您可能会发现有用的技术。
我们在第 8 章、测试、评测和异常处理中讨论了评测,我在这里只提到它,因为评测有时可以解释由于组件太慢而导致的奇怪错误。特别是在涉及网络的情况下,了解应用程序必须经历的时间和延迟对于理解出现问题时可能发生的情况非常重要,因此我建议您熟悉分析技术,并从故障排除的角度进行分析。
断言是让代码确保假设得到验证的好方法。如果是,所有收益都会定期进行,但如果不是,你会得到一个很好的例外,你可以处理。有时,与其进行检查,不如在代码中删除几个断言以排除可能性。让我们看一个例子:
# assertions.py
mylist = [1, 2, 3] # this ideally comes from some place
assert 4 == len(mylist) # this will break
for position in range(4):
print(mylist[position])这段代码模拟了一种情况,其中mylist当然不是由我们这样定义的,但我们假设它有四个元素。所以我们在那里放了一个断言,结果是:
$ python assertions.py
Traceback (most recent call last):
File "assertions.py", line 3, in <module>
assert 4 == len(mylist) # this will break
AssertionError这正好告诉我们问题出在哪里。
在 Python 官方文档中,有一个专门介绍调试和评测的部分,您可以从中了解到bdb调试器框架,以及faulthandler、timeit、trace、tracemallock、当然还有pdb等模块。只要转到文档中的标准库部分,您就会很容易找到所有这些信息。
在这个简短的部分中,我想给你一些来自我的故障排除经验的提示。
首先,熟悉使用Vim或nano作为编辑器,并学习控制台的基础知识。当事情发生变化时,你没有你的编辑的奢侈,那里有所有的钟声和口哨。你必须连接到一个盒子,然后从那里开始工作。因此,使用控制台命令轻松浏览您的生产环境是一个非常好的主意,并且能够使用基于控制台的编辑器(如 vi、Vim 或 nano)编辑文件。不要让你通常的开发环境破坏了你。
我的第二个建议是关于调试断点的位置。无论您使用的是print、自定义函数还是pdb,您都必须选择在何处进行提供信息的调用,对吗?
嗯,有些地方比其他地方好,而且有一些方法比其他地方更好地处理调试过程。
我通常避免在if子句中放置断点,因为如果不执行该子句,我将失去获得所需信息的机会。有时到达断点并不容易或很快,因此在放置断点之前请仔细考虑。
另一件重要的事情是从哪里开始。假设您有 100 行处理数据的代码。数据来自第 1 行,但不知何故,第 100 行是错误的。你不知道臭虫在哪里,那你怎么办?您可以在第 1 行放置断点,耐心地检查所有行,检查数据。在最坏的情况下,99 行(和许多杯咖啡)之后,您发现了错误。因此,考虑使用不同的方法。
从第 50 行开始检查。如果数据是好的,这意味着错误会在以后发生,在这种情况下,您将在第 75 行设置下一个断点。如果第 50 行的数据已经坏了,可以在第 25 行设置断点。然后,你重复一遍。每一次,你向前或向后移动,移动幅度为上次跳跃的一半。
在我们最坏的情况下,调试将以线性方式从 1、2、3、…、99 跳到一系列跳跃,例如 50、75、87、93、96、…、99,这要快得多。事实上,它是对数的。这种搜索技术被称为二进制搜索,它基于分而治之的方法,非常有效,所以要努力掌握它。
您还记得第 8 章、测试、分析和处理异常中关于测试的内容吗?好吧,如果我们有一个 bug,并且所有测试都通过了,这意味着我们的测试代码库中出现了一些错误或缺失。因此,一种方法是修改测试,使其符合已发现的新边缘情况,然后按照您的方式处理代码。这种方法是非常有益的,因为它可以确保您的 bug 在修复后会被测试覆盖。
监测也非常重要。当软件应用程序遇到边缘情况(如网络关闭、队列已满或外部组件无响应)时,它们可能会变得完全疯狂,并出现不确定的打嗝。在这些情况下,重要的是要了解问题发生时的总体情况,并能够以微妙、或许神秘的方式将其与相关事物联系起来。
您可以监视 API 端点、进程、网页可用性和加载时间,以及几乎所有可以编写的代码。一般来说,当从头开始启动应用程序时,在设计应用程序时牢记您希望如何监视它是非常有用的。
在这短短的一章中,我们研究了调试和排除代码故障的不同技术和建议。调试始终是软件开发人员工作的一部分,因此擅长调试非常重要。
如果以正确的态度来对待,它可能是有趣和有益的。
我们探索了基于函数、日志、调试器、回溯信息、评测和断言检查代码的技术。我们看到了其中大多数的简单例子,我们还讨论了一套在面对火灾时会有所帮助的指导原则。
只要记住时刻保持冷静和专注,调试就会容易得多。这也是一项需要学习的技能,也是最重要的。一个激动和紧张的头脑不能正常地、合乎逻辑地、创造性地工作,因此,如果你不加强它,你就很难把你所有的知识都很好地利用起来。
在下一章中,我们将探索 GUI 和脚本,从更常见的 web 应用程序场景中走一条有趣的弯路。