利用 JSON-Schema 对 Json 数据进行校验( Python 示例)

本文尝试通过json数据校验方法解决如下几个问题:

  • 数据没有校验,系统处于裸奔状态,导致后期维护成本高;
  • 编写一堆校验代码,混杂在业务代码中,导致代码可读性降低;
  • API交付的时候提供一大段接口描述文档,但用户还是要揣测文档意思。

1. 背景介绍

1.1 无处不在的JSON

JSON是一种轻量级的数据交换格式,基于Javascript的一个子集, 但采用完全独立于语言的文本格式,易于人阅读和编写,同时也易于机器解析和生成。这些特性使JSON成为理想的数据交换语言, 几乎所有与网页开发相关的语言都有JSON库。目前蓝鲸ESB,甚至公司内绝大多数系统的交互都采用JSON格式。

1.2 令人头疼的数据校验

由于JSON比较灵活,没有固定的schema,使用JSON作为数据交换格式时,我们经常遇到数据校验的问题。一个简单的JSON数据往往需要写一大段代码来校验数据格式是否符合预期,导致代码膨胀,可读性不好。

如下是一段CC系统新增自定义变量的请求参数,大致分成几部分请求账户、操作者、添加到的目标业务和环境类型,最后是要添加的变量列表。

{
    "systemId": "",  # 系统账号
    "password": "",  # 系统密码
    "operator": "hoffer",  # 操作者
    "ticket": "",  # 操作者的ticket信息
    "ApplicationID": 295,  # 业务ID
    "EnviType": 1,  # 环境类型
    "Params": [{
        "Scope": "[0-1000].gameserver.*.*",  # 变量作用域
        "KeyName": "domain",  # 变量名称
        "ValName": "awx.zhunter.com"  # 变量值
    },
    {
        "Scope": "[1001-2000].gameserver.*.*",  # 变量作用域
        "KeyName": "domain",  # 变量名称
        "ValName": "awx.zhunter.com"  # 变量值
    }]
}

为了校验参数的正确性,往往的做法是写如下一段代码(用kwargs表示请求参数)

# 参数数据类型校验
if not isinstance(kwargs, dict):
    return False, "kwargs must be dict"

# 校验systemId是否在参数字段中
if "systemId" not in kwargs:
    return False, "systemId is required"
# 校验systemId值的类型
if not instance(kwargs["systemId"], basestring):
    return False, "systemId must be string"
# 校验systemId值是否为空
if not kwargs["systemId"]:
    return False, "systemId can't be empty"

# 校验Params参数字段
if "Params" not in kwargs:
    return False, "Params is required"
if not isinstance(kwargs["Params"], list):
    return False, "Params must be list"

for v in kwargs["Params"]:
    if not isinstance(v, dict):
        return False, "variable must be dict"
...

完整的将校验代码写下来需要极大的耐心,校验代码很简单,但是又不太好复用,当耐心消耗殆尽的时候,我们就开始铤而走险了,先不去做校验(其实我们都明白这有多不好)。 如果不进行数据校验,系统相当于裸奔的状态,随时可能出问题,尤其是出现偶发性的数据异常时,往往排查难度非常大,如果异常发生在一个逻辑复杂的功能模块中,问题定位花的时间差不多能赶上代码编写的时间了。第三方api接口格式的变更,如果没能及时通知到调用方,也会导致潜在的风险。

1.3 用Django-Form校验数据会是什么样呢

如果用django的form做校验,代码会少点,下面是用django-form对案例数据编写的校验函数:

def validate(kwargs):
    class L1Form(forms.Form):
        systemId = forms.CharField()
        password = forms.CharField()
        operator = forms.CharField()
        ticket = forms.CharField(required=False)
        ApplicationID = forms.IntegerField(min_value=1)
        EnviType = forms.ChoiceField(choices=(1, 2, 3))

    class VariableForm(forms.Form):
        Scope = forms.RegexField(regex="^(\w+|\*|\[[,\d]+\]){3}(\w+|\*|\[[,\d]+\])$")
        KeyName = forms.CharField()
        ValName = forms.CharField()

    l1form = L1Form(data=kwargs)
    if not isinstance(kwargs, dict):
        return False, "kwargs must be dict"

    if not l1form.is_valid():
        return False, l1form.errors

    if "Params" not in kwargs:
        return False, ""

    if not isinstance(kwargs["Params"], list):
        return "Params must be list"

    for variable in kwargs["Params"]:
    if not isinstance(variable, dict):
        return False, "variable must be dict"
        vf = VariableForm(data=variable)
        if not vf.is_valid():
            return False, vf.errors

    return True, "success"

咋看之下,代码要简单很多了,用一个django-form可以把一层的简单数据类型都校验了,但仔细看看剩下的代码,会发现几个问题:

  • 用form表单校验首先需要保证数据是dict类型
  • 循环结构需要单独编写代码实现
  • 层次关系用form校验不了,如果json层次很深,校验代码就退化到了直接编码校验

1.4 当前较为流行的RPC框架的解决方案——Data Model

回想thrift作为目前较为流行的一个跨语言开发框架,使用起来就不需要这么繁杂的参数校验,究其原因是因为thrift在接口定义的时候严格定义好了接口的输入输出参数及其类型。 Google的Protocol Buffer也是需要编写一个 proto 文件,定义程序中需要处理的结构化数据。可见,为了提供可靠的数据,得先有关于数据格式的描述(数据模式),如果对json数据校验的时候,先整理出数据模式,是否也能写个通用的检验算法,运用模式对数据进行校验呢?

2. 模式探索——建立数据校验的基石

JSON作为javascript的一个子集,支持的数据类型也是可枚举的,基本数据类型有string/number/boolean/null, 容器类型由array和map。容器中容纳的元素是基本数据类型或容器,因此我们只需校验基本数据类型和对容器的结构进行校验,容器中的元素可以采用递归的方式进行校验。 由于基本的json数据以key-value的形式存在,可以针对各个字段指定应该满足的规则,形式如

"key": {
    "rule1_name": rule1_value,
    "rule2_name": rule2_value,
    ...
}

基本数据类型比较好校验,可以单独定义一些规则用于支持,比如字符串类型,可以定义一个规则名stringMaxLength指定最大长度, 定义规则名stringFormat指定其格式, 甚至定义规则stringPattern指定字符串应该遵循的正则规则。

"key": {
    "type": "string",
    "stringMaxLength": 100,
    "stringMinLength": 1,
    "stringFormat": "ipv4"
    "stringPattern": ...
}

容易类型的其实也可以按照规则-规则值的方式来指定校验规则,容器内的元素用递归的方式指定校验规则。比如一个map格式的校验规则就可以写成:


{
    "type": "map",
    "minKeys": 1,
    "maxKeys": 3,
    "requiredKeys": ["ApplicationID", "EnviType"]
}

另外还需要有字段专门用来指定容器内的元素及其对应的校验规则,可以用keys记录

{
    "type": "map",
    "minKeys": 1,
    "maxKeys": 3,
    "requiredKeys": ["ApplicationID", "EnviType"],
    "keys": {
        "key1_name": rule_for_key1,
        "key2_name": rule_for_key2,
    }
}

3. 一个简易的数据校验算法实现

如下代码就是基于上述数据格式定义规则实现的校验算法:

class ValidateError(Exception):
    pass


def base_type_validate(data, validate_type):
    if validate_type == "string":
        if not isinstance(data, basestring):
            raise ValidateError("data %s is not type %s" % (data, validate_type))
    elif validate_type == "number":
        if not isinstance(data, (int, float)):
            raise ValidateError("data %s is not type %s" % (data, validate_type))
    else:
        pass  # TODO


def stringMaxLength(s, max_length):
    # 只对string类型进行校验,保证长度不超过max_length
    if isinstance(s, basestring):
        if len(s) > max_length:
            raise ValueError("string length exceed max length %s" % max_length)


def validate_data(data, schema):
    """
    JSON格式数据校验
    :param data:
    :param schema:
    :return:
    """
    for rule, v in schema:
        if rule == "type":
            base_type_validate(data, v)
        elif rule == "stringMaxLength":
            stringMaxLength(data, v)
        elif rule == "keys":
            for _key, _schema in v:
                validate_data(_key, _schema)
        else:
            pass  # TODO

4. JSON-Schema

稍微看下上述校验算法,会发现原来实现一个通用的校验规则其实挺简单。目前python开源社区已经有了基于这种方式校验工具JSON-Schema, 其官方文档 中提供了相对完备的数据校验规则以及更好的使用体验。比如JSON-Schema提供了anyOf, allOf, oneOf, not组合规则方便我们组合出更严格的校验规则,另外还提供了definitions方式命名一套复杂的校验方案,使用时用$ref引用这个命名的校验方案(数据模式复用^_^)。更多关于json数据校验的特性还请大致浏览一遍官方文档。

使用JSON-Schema对本文开始提供的例子定义的校验模式为:

{
    "type": "object",
    "required": ["systemId", "password", "operator", "ApplicationID", "EnviType", "Params"]
    "properties": {
        "systemId": { "type": "string", "minLength": 1},
        "password": { "type": "string"},
        "operator": { "type": "string", "minLength": 1},
        "ApplicationID": { "type": "int"},
        "EnviType": { "type": "int", "enum": [1, 2, 3]},
        "Params": {
            "type": "array",
            "items": {
                "type": "object",
                "required": ["Scope", "KeyName", "ValName"],
                "properties": {
                    "Scope": {
                        "type": "string",
                        "pattern": "^(\w+|\*|\[[,\d]+\]){3}(\w+|\*|\[[,\d]+\])$"
                    },
                    "KeyName": {"type": "string", "minLength": 1},
                    "ValName": {"type": "string"},
                }
            }
        },
    }
}

5. 应用分析

最后,回过头来总结一下用JSON—Schema有哪些好处:

  1. 在输入输出的地方做参数校验,将非法输入拦截在入口, 将数据校验逻辑从业务逻辑中分离开来
    • 用户数据校验,用户无论是从前端还是API提交过来的数据,如果能通过校验发现参数问题,给用户明确提示的同时,也可以避免低效沟通
    • 入口数据校验保证数据准确性,将可以保证逻辑代码尽量精简,不需要对非法输入进行处理
    • 第三方接口提供的数据服务,并不总是可靠,将这种无效的数据拦截在系统之外。可以减少不必要的bug定位
  2. 作为API描述语言使用 当我们的系统作为平台给应用方提供API接口的时候,往往需要写上一大段接口文档,描述哪些字段是必填的,应该怎么填,用户仔细阅读完文档可能还是不知道参数是怎么填,即使提供了demo也很难覆盖全面,运用JSON-Schema定义JSON数据模式正像用数学符号表达数学问题一样,非常简洁,但是又能准确表达意思。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏屈定‘s Blog

设计模式--策略模式的思考

策略模式是一种简单的设计模式,但是其在业务开发中是一种非常有用的设计模式.举个例子,当你的业务需要针对不同的场景(可以简单理解为枚举类),执行不同的策略时那么使...

935
来自专栏码洞

我们天天都在使用的管道命令,Shell 在里面到底动了什么手脚?

管道命令我们经常使用,将一个指令的输出导入另一个指令的输入,也就是屁股对上嘴,这个原理连编程小学生都知道。但是如果要深入问进去,一个指令的输出是如何导入到另一个...

692
来自专栏JackeyGao的博客

Celery用户手册 - Tasks

Tasks是Celery 应用的构建块。事实上Celery应用是由一个或多个Task拼装组成的。

933
来自专栏Python中文社区

Python多进程并行编程实践-mpi4py的使用

專 欄 ❈PytLab,Python 中文社区专栏作者。主要从事科学计算与高性能计算领域的应用,主要语言为Python,C,C++。熟悉数值算法(最优化方法,...

4417
来自专栏程序员的SOD蜜

移花接木:当泛型方法遇上抽象类----我的“内存数据库”诞生记

之前,不怕“重复发明轮子”的我,搞了一个“PDF.NET框架”,即“PWMIS数据开发框架”(目前已经开源),自己用特殊的方式设计了一个实体类基类,然后又设计了...

4185
来自专栏涤生的博客

天池中间件大赛——单机百万消息队列存储设计与实现

这次天池中间件性能大赛初赛和复赛的成绩都正好是第五名,本次整理了复赛《单机百万消息队列的存储设计》的思路方案分享给大家,实现方案上也是决赛队伍中相对比较特别的。

1121
来自专栏Java架构

这些Spring中的设计模式,你都知道吗?

设计模式作为工作学习中的枕边书,却时常处于勤说不用的尴尬境地,也不是我们时常忘记,只是一直没有记忆。

562
来自专栏数据库

单机数据库优化

数据库优化有很多可以讲,按照支撑的数据量来分可以分为两个阶段:单机数据库和分库分表,前者一般可以支撑500W或者10G以内的数据,超过这个值则需要考虑分库分表。...

1887
来自专栏SeanCheney的专栏

《Python分布式计算》 第3章 Python的并行计算 (Distributed Computing with Python)多线程多进程多进程队列一些思考总结

我们在前两章提到了线程、进程,还有并发编程。我们在很高的层次,用抽象的名词,讲了如何组织代码,已让其部分并发运行,在多个CPU上或在多台机器上。 本章中,我们会...

4366
来自专栏DOTNET

ASP.NET MVC编程——控制器

每一个请求都会经过控制器处理,控制器中的每个方法被称为控制器操作,它处理具体的请求。 1操作输入参数 控制器的操作的输入参数可以是内置类型也可以是自定义类型。 ...

2559

扫码关注云+社区