Zer0e's Blog

由于代码架构设计不佳而引发的异常消失

字数统计: 2.4k阅读时长: 8 min
2022/01/01 Share

写在前面

真的好久没写文章了,上一篇还是21年的6月写的。工作上事情再加上回来只想打游戏,这半年就没输出文章,中间其实有几次想过写写,但是不知道写点啥。

打扫完房间,坐下打开电脑,正好原神预下载,也想想好久没写文章了,正好最近有个有意思的也是自己遇到问题,就边下载边码字了。

今天是2022年的元旦,先祝看到这篇文章的朋友新年快乐吧。

序言

这篇文章聊聊finally关键字,这个关键字Python和Java程序员肯定都不陌生。相信很多程序员其实都看过很多文章说finally关键字里面最好不要有return关键字,这点肯定很多人都知道,鉴于可能有人不知道,这里还是讲讲(水字数石锤)。

正文

由多个return导致返回值缺失

上文说,很多文章警告不要在finally中使用return,他们文章里给出的例子很简单,这里我以Python为例,假设有以下代码:

1
2
3
4
5
6
7
8
9
10
11

def func():

try:

return 1

finally:

return 2

那么其实这个函数返回值是2,而不是1,容易引起误解,所以大多数情况下,我们不会再finally中写任何return,防止返回值与预期不一致。

脚本执行架构设计

而这篇文章要讲的也是finally,但不是因为返回值不一致。要讲这个之前,我想先讲一讲为啥我会遇到这个问题。

大概是21年10月份,我们的测试框架打算引入前置与后置功能,那这部分的实现就落在我身上了,领导也很看重,希望能实现一个健硕的执行架构,那我也看了一些执行框架源码,比如rebot,但是其实关键部分光看源码其实看不出所以然出来,所以我决定先写下第一行代码再说。

熟悉测试的同学应该知道,测试脚本呢,其实分为三部分,一是环境的准备(setup),测试主体(main),资源回收(teardown),用户只关心的是测试主体是否成功。那基于我们的测试执行框架,其实这三部分并没有什么差别,是分为三个脚本去执行,因此就需要一个调度框架去控制执行的顺序。

执行的代码我们可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

def execute(self, stage):

try:

if stage == 1:

print("执行main")

elif stage == 0:

print("执行setup")

elif stage == 2:

print("执行teardown")

except:

print("发生异常,记录,置该脚本状态为FAIL")

finally:

try:

print("根据stage不同,回填不同阶段的日志到服务器上")

except:

print("回填日志失败,记一下错误")

大概就是这个样子,这个就是最基础的执行方法,通过传入不同的stage,执行方法和回填日志。那调度方法可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

def run_case(self):

try:

​ execute(0) # 执行setup

if self.result == "PASS":

​ execute(1) # setup成功才执行main

finally:

​ execute(2) # 无论如何都需要执行teardown

当然真实情况远没有那么简单,因为是一个执行树,所以需要递归调用,但这个地方不是重点,这里只讲最简单的架构。

起初我对这个设计还是比较满意的,借助try和finally实现了对不同阶段的调用,之后我便考虑如何去停止正在执行的脚本。而关于停止,我设计了两套实现,其一是停止后,继续执行已执行过setup的脚本的teardown内容,其二是真正停止运行,不执行teardown。

我在类中声明了两个类变量,self.exit 和 self.real_exit,分别代表两种退出。其中当real_exit为True时,exit一定为True,但反过来不一定。因此上面的调度代码被我改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

def run_case(self):

try:

​ execute(0) # 执行setup

if not self.exit and self.result == "PASS":

​ execute(1) # setup成功才执行main

finally:

if self.real_exit:

return

​ execute(2) # 无论如何都需要执行teardown

请注意,这段代码是有问题的。

这里先说说exit和real_exit的赋值,上面有说过,因为是执行树,其实这两个变量在使用前其实必须使用self.exit = self.parent.get_exit(),通过递归来获取最顶层的状态,这里暂且提一下,这个不是本文的重点。

请注意,这里我在finally中用了return关键字,乍一看没啥问题,我当初觉得也没啥问题。首先run_case这个方法是没有返回值的,执行结果全在类变量中,然后是execute这个方法,全程也是在try/except的代码块中的,因此当初我认为不会有什么问题。这里run_case方法上层其实还有一层,为用户实际执行的层次,可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

def run(job):

try:

​ job.run_case()

except:

print("执行发生异常,做个标记")

finally:

print("有异常就通知用户,没有就通知已完成执行")

print("发送邮件")

至此,这个执行调度架构也直接上线了。期间也没出现过什么离谱的问题,满意度还是比较高的。

开始意识到不对

我们服务器是保留1个月的日志,所以有概率seaweed会爆满的,当爆满时,日志无法回填,但其实测试脚本还是会继续跑的。所以有用户与我反馈说能不能上传失败就停下脚本,这样很浪费时间,执行完也没报告看,还不如不执行。我觉得有道理,便开始了改造,这改造不要紧,结果发现了这个架构中的大问题,也是我写这篇文章的原因。

由于日志回填是在execute方法中,因此我们改造这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

def execute(self, stage):

try:

if stage == 1:

print("执行main")

elif stage == 0:

print("执行setup")

elif stage == 2:

print("执行teardown")

except:

print("发生异常,记录,置该脚本状态为FAIL")

finally:

try:

print("根据stage不同,回填不同阶段的日志到服务器上")

except:

print("回填日志失败,记一下错误")

self.upload_error_time += 1

if self.upload_error_time >= 3:

self.root_case.real_exit = True

raise RuntimeError("上传错误次数累计超过上限")

我在日志回填的时候,增加了简单的错误次数判断,那么如果顺利的话,当上传错误超过3次时,应该是这么一个流程:execute方法在finally中抛出了一个异常,由于没有捕获,抛给了它的调用方run_case,run_case是在try中接收了这个异常,但由于也没有捕获,又向上抛给了run方法,run方法捕获了这个异常,停止执行,并通知用户。

而之所以加入了self.root_case.real_exit,其实是为了不让run_case中finally执行,即不执行teardown。但我在写这篇文章的时候意识到,其实应该让teardown执行才对,不然环境没回收也是个问题,找个时间改下。当然这个也不是重点,因为我们就只想要上传失败3次后停下来,不想多执行其他的脚本了。

写完这个需求,我笑到,这个需求也是蛮简单的。但当我进行调试时,却发现事情的不对。首先我在回填日志时每次都抛出一个异常来模拟出现问题,这里便不多说。然后批量执行几个脚本,观察是否会如同预期一样,3次后直接抛出异常。这里要说的是,不管脚本的前置后置有没有内容,日志都会回填,我的执行树大概是这样:

1
2
3
4
5
root_case
folder
case1
case2
case3

那么失败3次,就是root_case,folder,case1的setup都失败,此时发生异常退出。

但并没有像我预想的一样异常退出,而是正常退出?!我觉得可能是哪里有问题,又多执行了几次,但是还是这样。我就开始调试代码了。调试了很久,因为执行树是递归调用,所以调试很不容易,调了半个下午,这时候我才意识到我的异常被run_case中的finally中的return给吞噬了。

虽然run_case没有返回值,但是finally中的return还是出现了问题,这就是这次经历给我带来的教训,无论如何,不要在finally中写下return!不管是什么原因。

那么后来我便改成了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

def run_case(self):

try:

​ execute(0) # 执行setup

if not self.exit and self.result == "PASS":

​ execute(1) # setup成功才执行main

finally:

if not self.real_exit:

​ execute(2) # 无论如何都需要执行teardown

这时候异常就顺利向上抛出了。趁着没人发现这个bug,偷偷把它修了,哈哈。虽然没造成什么影响就是了,因为下层的一些会发生异常的操作方法,都会捕获异常。实际使用时并不会向上抛异常。

后言

这篇文章其实废话有点多,其实关键就一点,就是finally中不要写任何return,无论什么原因,除非你保证没有返回值或者下层绝对不会抛出异常。

除开这个,也讲了讲自己设计和编写的执行架构,我个人认为这个架构还是挺不错的,鉴于篇幅只讲了其中的一小部分,有机会的话可以来讲讲这个框架里我的callback设计,即执行单个脚本或者整个批次后进行一些调用。

CATALOG
  1. 1. 写在前面
  2. 2. 序言
  3. 3. 正文
    1. 3.1. 由多个return导致返回值缺失
    2. 3.2. 脚本执行架构设计
    3. 3.3. 开始意识到不对
  4. 4. 后言