[Python]实现函数的输入输出参数的强类型检验

2023-12-15 15:10:35 浏览数 (1)

Python是一门弱类型的解释型语言,弱类型有其优势,非常适用于算法开发以及一些短平快的项目,但也有其劣势,当代码越来越多的时候,自由的代价就会呈现出来,维护的代价也会越来越大。特别是,如果我们开发接口给别人使用的时候,如果没有强类型的校验,别人就不能清晰的知道输入输出的数据结构是什么,报错的时候也比较难定位问题,因此在有些场景下,需要对函数输入输出进行强类型约束。

使用包装器尽量减少代码的侵入式

比较笨的实现方式是在每个需要进行参数校验的地方,手动加入类似如下代码:

代码语言:javascript复制
assert type(data) == list, "data参数必须是list类型"

这确实可以实现,不失为凑代码行数的好方法,但是这很丑陋,后期也很难维护。

使用FastAPI的体验都比较清楚,在FastAPI中,接口的输入输出参数是可以定义成强类型的,这也是自己最初看到FastAPI就觉得这就是Python当前最好的框架之一。总结一下,我们的实现方式应该做到如下两个要求:

  1. 非侵入式的,尽量避免对业务代码的更改;
  2. 实现输入输出参数的强类型校验。

参考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,后面可以作为对象使用,避免低级错误,例如写错变量名等。

从实现上,要点如下:

  1. 输入参数:使用参数类(如上面的InputParams)将输入的“**kwargs”参数在包装器中进行转换,如果数据中有类型不匹配,则会抛出异常。注意如果多传了参数,这是不会报错的,需要在包装器中使用代码进行判断;
  2. 使用“get_annotations”获取目标函数的输入输出参数的类型信息;
  3. 输出参数:这个的校验比较特别,试了好几种方法,最后觉得这样式最好的,在需要返回值校验的时候,定义了一个动态的类“ReturnParam”(见上面的代码)。

输出参数校验的时候,没有参考FastAPI使用一个“response_model”之类的包装器参数,而是使用更加直接的方式。

说明:因为我们的场景下,输入输出都需要是普通的数据,并没有将输入输出转成强类型数据,外部在调用时(通过HTTP接口)还是普通的输入输出。

使用限制

原业务函数中如果包含了类似*args/**kwargs这类的可变参数,则上面的包装器还是完善,例如对于*args参数,可以类似输出参数的方式进行处理。

不过对于这两类的参数,这个“get_annotations”方法获取不到对应的信息,需要找其他的方式。

0 人点赞