一个Bug的修复过程回顾

2022-11-07 13:08:41 浏览数 (1)

前些天同事在测试客户发来的大文件时,报告说个别文件在ocr的时候会报识别错误,但是系统并没有记录到详细的详细的错误信息,只是记录了“OCR识别错误”,一开始我是怀疑这是不是系统记录错了,因为就ocr上游引擎来说,我印象中,已经加了比较完善的异常处理,发生异常的话,详细的异常信息应该会被捕获到,就先让系统开发的同事去查,还是反馈说是上游引擎的问题。

因为这个问题花了两三天才解决,特此回顾反思。

问题的定位过程

于是只能去日志信息里找要找,找到对应的错误信息“Out of range float values are not JSON compliant”。看日志,这个错误是在Fastapi返回响应数据的时候报的错,Fastapi这点做得不够好,如果是在响应过程抛出的异常可能不能被异常处理程序捕获到。这就比较麻烦,因为没法在接口层进行异常捕获,就没法对异常数据进行输出。

于是把同事发过来的大文件,直接放到ocr进行识别,几百页的PDF扫描件,识别了半天,并没有在日志里观察到同样的异常信息。重试了几次,也还是还是没有发现对应的异常。我理解这种情况也是可能的,毕竟ocr引擎使用的都是一个神经网络,而我测试的调用方式和系统的调用方式并不完全相同,虽然用的是同一个文件,出现不一样的结果也是可能的。

在晚上搜索这个问题,说是“nan”这个float类型的值导致的,于是把代码过了一遍,找到一个可能出现这个值的地方,在三角函数arcsin计算的时候,如果定义域不在-1到1之间,其结果就会出现nan这个值。于是做了特殊处理,以为问题应该解决了,不过测试发现该问题还存在。

从json的dumps异常来切入

我想,虽然我们没法直接捕获Fastapi框架内部在响应环节的异常,不过可以在数据return前,使用json的dumps对数据进行测试,这里异常不正是一样的吗?于是在数据响应前增加了对返回值的测试及格式化:

代码语言:javascript复制
import pickle
import numpy as np
from json import JSONEncoder, dumps
from fastapi.encoders import jsonable_encoder
from traceback import print_exc

# Fastapi json encoder
out_json_encoder = {
    np.integer: lambda o: int(o),
    np.floating: lambda o: float(o),
    np.ndarray: lambda o: o.tolist(),
}

def test_response_json(data, debug: bool = False):
    """测试并转换响应的json字符串"""
    if not debug:
        return jsonable_encoder(data, custom_encoder=out_json_encoder)

    try:
        dumps(data)
    except Exception as e:
        print("[JSON1 ERROR] %s: %s" % ('*' * 40, datetime.now()))
        print_exc()
        try:
            data = jsonable_encoder(data, custom_encoder=out_json_encoder)
            dumps(data)
        except Exception as e:
            print("[JSON2 ERROR] %s" % ('*' * 40), flush=True)
            print_exc()
            with open("json_error_response.json", 'wb') as f:
                pickle.dump(data, f)

    return data

对于,这段代码自己还挺满意的,感觉完美的模拟json序列化发生异常,并对异常数据进行写文件,感觉解决问题的曙光就在眼前了。

代码推到内网之后,测试确实发现了一个问题,居然发现有numpy.ndarray结构的数据出现在了返回结果里。数据处理的链条太长,返回的数据结构又比较复杂,中间可能有某个步骤没有做类型转换。于是把整个链条定位到对应的地方,做了数据类型转换。这个问题虽然解决了,不过觉得这个问题和文章开头说的问题应该不是同一个,因为numpy.ndarray的类型问题就算报错,也会报“Out of range float values are not JSON compliant”这个错误。

经过测试,问题果然还在。

从fastapi的源码定位到发生异常的数据

虽然我们没法直接捕获响应数据的异常,不过我们却可以直接修改Fastapi的源码,在框架源码中增加异常处理程序,发生异常的时候把数据记录起来。这次终于定位到具体的问题。

从记录的数据可以发现,返回的结果数据中,确实还有一个字段出现了nan值。响应数据的时候是json格式,为什么我们在测试json结构时,没有捕获到这个错误呢?经过查看fastapi对应的源码发现,在默认情况下,fastapi使用的json序列化工具是这样的:

代码语言:javascript复制
class JSONResponse(Response):
    media_type = "application/json"

    def render(self, content: Any) -> bytes:
        return dumps(
            content,
            ensure_ascii=False,
            allow_nan=False,
            indent=None,
            separators=(",", ":"),
        ).encode("utf-8")

虽然,都是使用json的dumps函数序列化,但是fastapi使用的参数和我测试json时使用的不同,特别注意allow_nan这个参数,在fastapi里传的值是False,而查看dumps函数的函数参数可知,该参数的默认值是True。这是造成我们测试代码失效的根本原因。

定位到这里,解决问题就是顺理成章的了,自定义一个json序列化类:

代码语言:javascript复制
class JSONResponse(Response):
    media_type = "application/json"

    def render(self, content: Any) -> bytes:
        """解决nan的问题,解决numpy的问题"""
        return dumps(
            content,
            ensure_ascii=False,
            # allow_nan=False,
            indent=None,
            separators=(",", ":"),
            cls=NumpyJsonEncoder,
        ).encode("utf-8")
        
class NumpyJsonEncoder(JSONEncoder):
    """numpy数据序列化
    example:
        json.dump(np_data, write_file, cls=NumpyJsonEncoder)
    """
    def default(self, obj):
        if isinstance(obj, np.integer):
            return int(obj)
        elif isinstance(obj, np.floating):
            return float(obj)
        elif isinstance(obj, np.ndarray):
            return obj.tolist()
        else:
            return super(NumpyJsonEncoder, self).default(obj)    

allown_nan参数注释掉,即使用默认的True值,另外顺便解决一些漏网的numpy的数据结构问题。

至此,这个问题算是比较彻底地解决了。

在这个过程,还遇到一个很特别的点:

代码语言:javascript复制
# 假设var是一个变量
# 下面这个表达式居然有可能为True值
var != var

即一个变量不等于它自己!

只要该变量的值为:

代码语言:javascript复制
var = float('nan')

简单理解也可以:一个不存在的值和一个不存在的值,不相等。(不过这样理解要注意,python中的无穷大inf和inf确实相等的,看起来,一个变量不等于其自身,只有nan这种情况)

总结

为什么被这样一个问题困扰了两三天,这个是需要被反思的。原因如下:

  1. 我们的异常与日志方面做得并不好,对于定位问题并不够友好,应该规范和加强异常日志的记录;
  2. 解决问题的过程中思路并不够清晰,对于问题我们应该要先思考怎么复现问题,以及触发问题的输入数据,只有清楚了异常时的输入,那解决问题就是轻而易举的事情了。碰到复杂的问题,如果盲目地尝试,可能只会浪费时间;
  3. python是弱类型语言,在业务逻辑比较复杂的时候,确实很容易出现类型的问题,即使有了typing定义,但是这只能解决部分问题,它没法像强类型语言那样,保证类型安全。在长链条的数据处理过程中,python的弱类型很容易埋下一个一个的大坑。因此,对于业务逻辑比较复杂的系统,最好使用强类型语言进行开发(如golang),如果只能用python,那在系统规划上应该投入多一些时间和精力,在数据在传输过程中,多对数据结构进行测试,保障每个步骤的数据结构都是清晰的。

这两三天,其实如果方法得当,思路清晰,那问题完全可能可以在两三个小时内解决掉的。

0 人点赞