Python是一门弱类型的解释型语言,弱类型有其优势,非常适用于算法开发以及一些短平快的项目,但也有其劣势,当代码越来越多的时候,自由的代价就会呈现出来,维护的代价也会越来越大。特别是,如果我们开发接口给别人使用的时候,如果没有强类型的校验,别人就不能清晰的知道输入输出的数据结构是什么,报错的时候也比较难定位问题,因此在有些场景下,需要对函数输入输出进行强类型约束。
使用包装器尽量减少代码的侵入式
比较笨的实现方式是在每个需要进行参数校验的地方,手动加入类似如下代码:
代码语言:javascript复制assert type(data) == list, "data参数必须是list类型"
这确实可以实现,不失为凑代码行数的好方法,但是这很丑陋,后期也很难维护。
使用FastAPI的体验都比较清楚,在FastAPI中,接口的输入输出参数是可以定义成强类型的,这也是自己最初看到FastAPI就觉得这就是Python当前最好的框架之一。总结一下,我们的实现方式应该做到如下两个要求:
- 非侵入式的,尽量避免对业务代码的更改;
- 实现输入输出参数的强类型校验。
参考FastAPI的实现,我们的实现应该也是采用包装器的形式来实现。本来想直接FastAPI的源码里找到对应的代码,复制出来使用的,但是找到了一段时间,也没有定位到代码对应的地方,就自己直接实现了,其实并不复杂。
在包装器中实现对目标函数的输入输出校验,下面是一个示例的业务代码:
代码语言:javascript复制class ClassTool:
def run(self, input_text: str = '', text_len: int = 100) -> str:
assert input_text, f"input_text参数不能为空"
input_class = '正常'
if len(input_text) > text_len:
input_class = '文本过长'
return input_class
原来的实现就是使用“assert”来对输入参数进行校验,但是对于复杂类型校验就比较麻烦了,例如类型List[str], List[Dict[str, int]]等,硬是要使用“assert”也是可以的,只是代码很罗嗦,很多重复代码。
校验包装器实现
使用包装器实现输入输出参数的校验,具体代码如下:
代码语言:javascript复制from functools import wraps
from inspect import get_annotations
from pydantic import BaseModel
def ToolParamsCheck(fun):
"""工具参数校验
注意:当接口有未知参数的时候,不能使用该参数检查
"""
@wraps(fun)
def wrap_fun(cls, **kwargs):
tool_key = cls.__class__.__name__
# 处理输入参数
params = get_annotations(fun)
keys: list = list(params.keys())
if 'return' in keys: # 去掉返回值
keys.remove('return')
if len(keys) != 1:
raise Exception(f"工具{tool_key}的输入参数数量不为1: {len(keys)}")
InputParams = params[keys[0]] # 输入参数类
support_params = get_annotations(InputParams)
for key in kwargs.keys():
if key not in support_params:
raise Exception(f"工具{tool_key}不支持参数: {key}")
# 执行
try:
input_params = InputParams(**kwargs)
except Exception as e:
raise Exception(f"工具{tool_key}的输入参数异常: {e} 支持的参数列表: {support_params}")
# 执行工具
res = fun(cls, input_params)
# 处理输出参数
if 'return' in params:
class ReturnParam(BaseModel):
param: params['return']
try:
ReturnParam(param=res)
except Exception as e:
raise Exception(f"工具{tool_key}的输出参数异常: {e} 期望的输出类型: {params['return']}, 实际的输出类型: {type(res)}")
return res
return wrap_fun
对应的业务代码也需要做一些简单的修改,如下:
代码语言:javascript复制from pydantic import BaseModel, Field
from .utils import ToolParamsCheck
class InputParams(BaseModel):
"""定义输入参数"""
input_text: str = Field(..., title='输入文本')
text_len: int = Field(100, title='超过该长度,则返回"文本过长",否则返回"正常"')
class ClassTool:
@ToolParamsCheck
def _run(self, params: InputParams) -> str:
input_class = '正常'
if len(params.input_text) > params.text_len:
input_class = '文本过长'
return input_class
这对代码有一点侵入性,是的,但是这正是我所期待的,相比原来的方式输入,个人更喜欢将参数定义成这样,类似FastAPI,后面可以作为对象使用,避免低级错误,例如写错变量名等。
从实现上,要点如下:
- 输入参数:使用参数类(如上面的InputParams)将输入的“**kwargs”参数在包装器中进行转换,如果数据中有类型不匹配,则会抛出异常。注意如果多传了参数,这是不会报错的,需要在包装器中使用代码进行判断;
- 使用“get_annotations”获取目标函数的输入输出参数的类型信息;
- 输出参数:这个的校验比较特别,试了好几种方法,最后觉得这样式最好的,在需要返回值校验的时候,定义了一个动态的类“ReturnParam”(见上面的代码)。
输出参数校验的时候,没有参考FastAPI使用一个“response_model”之类的包装器参数,而是使用更加直接的方式。
说明:因为我们的场景下,输入输出都需要是普通的数据,并没有将输入输出转成强类型数据,外部在调用时(通过HTTP接口)还是普通的输入输出。
使用限制
原业务函数中如果包含了类似*args/**kwargs这类的可变参数,则上面的包装器还是完善,例如对于*args参数,可以类似输出参数的方式进行处理。
不过对于这两类的参数,这个“get_annotations”方法获取不到对应的信息,需要找其他的方式。