写在前面
真的好久没写文章了,上一篇还是21年的6月写的。工作上事情再加上回来只想打游戏,这半年就没输出文章,中间其实有几次想过写写,但是不知道写点啥。
打扫完房间,坐下打开电脑,正好原神预下载,也想想好久没写文章了,正好最近有个有意思的也是自己遇到问题,就边下载边码字了。
今天是2022年的元旦,先祝看到这篇文章的朋友新年快乐吧。
序言
这篇文章聊聊finally关键字,这个关键字Python和Java程序员肯定都不陌生。相信很多程序员其实都看过很多文章说finally关键字里面最好不要有return关键字,这点肯定很多人都知道,鉴于可能有人不知道,这里还是讲讲(水字数石锤)。
正文
由多个return导致返回值缺失
上文说,很多文章警告不要在finally中使用return,他们文章里给出的例子很简单,这里我以Python为例,假设有以下代码:
1 |
|
那么其实这个函数返回值是2,而不是1,容易引起误解,所以大多数情况下,我们不会再finally中写任何return,防止返回值与预期不一致。
脚本执行架构设计
而这篇文章要讲的也是finally,但不是因为返回值不一致。要讲这个之前,我想先讲一讲为啥我会遇到这个问题。
大概是21年10月份,我们的测试框架打算引入前置与后置功能,那这部分的实现就落在我身上了,领导也很看重,希望能实现一个健硕的执行架构,那我也看了一些执行框架源码,比如rebot,但是其实关键部分光看源码其实看不出所以然出来,所以我决定先写下第一行代码再说。
熟悉测试的同学应该知道,测试脚本呢,其实分为三部分,一是环境的准备(setup),测试主体(main),资源回收(teardown),用户只关心的是测试主体是否成功。那基于我们的测试执行框架,其实这三部分并没有什么差别,是分为三个脚本去执行,因此就需要一个调度框架去控制执行的顺序。
执行的代码我们可以简化为:
1 |
|
大概就是这个样子,这个就是最基础的执行方法,通过传入不同的stage,执行方法和回填日志。那调度方法可以简化为:
1 |
|
当然真实情况远没有那么简单,因为是一个执行树,所以需要递归调用,但这个地方不是重点,这里只讲最简单的架构。
起初我对这个设计还是比较满意的,借助try和finally实现了对不同阶段的调用,之后我便考虑如何去停止正在执行的脚本。而关于停止,我设计了两套实现,其一是停止后,继续执行已执行过setup的脚本的teardown内容,其二是真正停止运行,不执行teardown。
我在类中声明了两个类变量,self.exit 和 self.real_exit,分别代表两种退出。其中当real_exit为True时,exit一定为True,但反过来不一定。因此上面的调度代码被我改为:
1 |
|
请注意,这段代码是有问题的。
这里先说说exit和real_exit的赋值,上面有说过,因为是执行树,其实这两个变量在使用前其实必须使用self.exit = self.parent.get_exit(),通过递归来获取最顶层的状态,这里暂且提一下,这个不是本文的重点。
请注意,这里我在finally中用了return关键字,乍一看没啥问题,我当初觉得也没啥问题。首先run_case这个方法是没有返回值的,执行结果全在类变量中,然后是execute这个方法,全程也是在try/except的代码块中的,因此当初我认为不会有什么问题。这里run_case方法上层其实还有一层,为用户实际执行的层次,可以简化为:
1 |
|
至此,这个执行调度架构也直接上线了。期间也没出现过什么离谱的问题,满意度还是比较高的。
开始意识到不对
我们服务器是保留1个月的日志,所以有概率seaweed会爆满的,当爆满时,日志无法回填,但其实测试脚本还是会继续跑的。所以有用户与我反馈说能不能上传失败就停下脚本,这样很浪费时间,执行完也没报告看,还不如不执行。我觉得有道理,便开始了改造,这改造不要紧,结果发现了这个架构中的大问题,也是我写这篇文章的原因。
由于日志回填是在execute方法中,因此我们改造这个方法。
1 |
|
我在日志回填的时候,增加了简单的错误次数判断,那么如果顺利的话,当上传错误超过3次时,应该是这么一个流程:execute方法在finally中抛出了一个异常,由于没有捕获,抛给了它的调用方run_case,run_case是在try中接收了这个异常,但由于也没有捕获,又向上抛给了run方法,run方法捕获了这个异常,停止执行,并通知用户。
而之所以加入了self.root_case.real_exit,其实是为了不让run_case中finally执行,即不执行teardown。但我在写这篇文章的时候意识到,其实应该让teardown执行才对,不然环境没回收也是个问题,找个时间改下。当然这个也不是重点,因为我们就只想要上传失败3次后停下来,不想多执行其他的脚本了。
写完这个需求,我笑到,这个需求也是蛮简单的。但当我进行调试时,却发现事情的不对。首先我在回填日志时每次都抛出一个异常来模拟出现问题,这里便不多说。然后批量执行几个脚本,观察是否会如同预期一样,3次后直接抛出异常。这里要说的是,不管脚本的前置后置有没有内容,日志都会回填,我的执行树大概是这样:
1 | root_case |
那么失败3次,就是root_case,folder,case1的setup都失败,此时发生异常退出。
但并没有像我预想的一样异常退出,而是正常退出?!我觉得可能是哪里有问题,又多执行了几次,但是还是这样。我就开始调试代码了。调试了很久,因为执行树是递归调用,所以调试很不容易,调了半个下午,这时候我才意识到我的异常被run_case中的finally中的return给吞噬了。
虽然run_case没有返回值,但是finally中的return还是出现了问题,这就是这次经历给我带来的教训,无论如何,不要在finally中写下return!不管是什么原因。
那么后来我便改成了
1 |
|
这时候异常就顺利向上抛出了。趁着没人发现这个bug,偷偷把它修了,哈哈。虽然没造成什么影响就是了,因为下层的一些会发生异常的操作方法,都会捕获异常。实际使用时并不会向上抛异常。
后言
这篇文章其实废话有点多,其实关键就一点,就是finally中不要写任何return,无论什么原因,除非你保证没有返回值或者下层绝对不会抛出异常。
除开这个,也讲了讲自己设计和编写的执行架构,我个人认为这个架构还是挺不错的,鉴于篇幅只讲了其中的一小部分,有机会的话可以来讲讲这个框架里我的callback设计,即执行单个脚本或者整个批次后进行一些调用。