前些天同事在测试客户发来的大文件时,报告说个别文件在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这种情况)
总结
为什么被这样一个问题困扰了两三天,这个是需要被反思的。原因如下:
- 我们的异常与日志方面做得并不好,对于定位问题并不够友好,应该规范和加强异常日志的记录;
- 解决问题的过程中思路并不够清晰,对于问题我们应该要先思考怎么复现问题,以及触发问题的输入数据,只有清楚了异常时的输入,那解决问题就是轻而易举的事情了。碰到复杂的问题,如果盲目地尝试,可能只会浪费时间;
- python是弱类型语言,在业务逻辑比较复杂的时候,确实很容易出现类型的问题,即使有了typing定义,但是这只能解决部分问题,它没法像强类型语言那样,保证类型安全。在长链条的数据处理过程中,python的弱类型很容易埋下一个一个的大坑。因此,对于业务逻辑比较复杂的系统,最好使用强类型语言进行开发(如golang),如果只能用python,那在系统规划上应该投入多一些时间和精力,在数据在传输过程中,多对数据结构进行测试,保障每个步骤的数据结构都是清晰的。
这两三天,其实如果方法得当,思路清晰,那问题完全可能可以在两三个小时内解决掉的。