ThinkJS
首发于ThinkJS

ThinkJS 中数据解析和校验

Web 服务的整个流程中,获取数据是最重要的一环,如何方便快捷的获取用户提交的数据减少开发者的开发成本是一个 Web 服务框架应该考虑的事情。本文将会为大家介绍在 ThinkJS 中请求数据解析和数据校验相关的内容。

暂且不提 HTTP/2,HTTP 请求本质上是一个有一定格式的文本字符串,程序按照规范格式解析后就能获得我们想要的数据。不同的编码方式需要我们使用不同的规范来解析请求体,在 ThinkJS 中提供了强大的数据解析中间件 think-payload,非常方便的将多种类型的请求体数据自动转换为 JavaScript 对象。

当然在实现业务的过程中,我们发现简单的解析请求数据传给业务层是不够的,我们往往需要对用户数据进行各种校验和过滤,例如数据类型校验以及数据合法性校验等等。如果将这部分代码和业务耦合在一块的话会让业务代码变的非常的臃肿。所以 ThinkJS 中提供了非常方便的数据校验中间件 think-logic 增加 Logic 层来专门做数据校验逻辑,这样就非常方便的将复杂的数据校验逻辑与业务代码进行了解耦。下图展示了数据解析中间件和数据校验中间件在整个架构中的位置。



下面我们就常见的请求数据类型来说一说在 ThinkJS 中的一些操作过程。

数据解析

先回顾一下基础,HTTP 协议是基于传输层 TCP/IP 协议之上的应用层协议,协议内容是以 ASCII 码传输的,一个正常的 HTTP 请求由状态码、请求头、请求体组成的,像下面这样:

<method> <request-url> <version>

<headers>

<entity-body>

HTTP 请求方法中,一般 POST 方法是用来提交数据的,协议规定 POST 方法提交的数据必须放在请求体中,明确数据的 MIME 类型,最终 HTTP 请求报文满足上面的格式就可以了。数据发送到服务端后需要成功解析才能使用,像 PHP、Python、Golang、Node.js 的 Web 开发框架,都实现了解析数据的功能,基本上都是根据请求头部 Content-Type 的数据类型,然后使用不同的方法进行解析,下面介绍四种 POST 提交数据的常用 MIME 类型。

application/x-www-form-urlencoded

这是一个经常使用的 MIME 类型,HTML 中 <form> 标签如果不带 enctype 属性,默认就会用这种数据类型提交表单,将数据以key1=value1&key2=value2 的形式进行编码,HTTP 报文大概是下面这样子:

POST /api/user HTTP/1.1
Content-Type: application/x-www-form-urlencoded

name=ldj&age=16

大部分服务端语言对这种类型的数据都有很好支持,Node.js 内建的模块 querystring 就可以解析这种数据。

application/json

这是目前最常用的请求数据类型,尤其是以 Node.js 作为服务端语言时,前后端统一使用 JSON 数据和 JavaScript 能明显降低程序语言给开发带来的复杂度。前端将 JSON 字符串发送到服务端,服务端解析后变为内存中的键值对数据。HTTP报文大概是下面这样子:

POST /api/user HTTP/1.1
Content-Type: application/json;charset=utf-8

{"name":"makeco", "age": 22}

multipart/form-data

使用表单上传文件时,需要在 <form> 标签上设置 enctype 的值为 multipart/form-data,可以使用 FormData API 来控制表单数据,这种类型的 HTTP 报文就有点复杂:

POST /api/user HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA

------WebKitFormBoundaryrGKCBY7qhFd3TrwA 
Content-Disposition: form-data; name="name" 

makeco

------WebKitFormBoundaryrGKCBY7qhFd3TrwA 
Content-Disposition: form-data; name="avatar"; filename="chrome.png" 
Content-Type: image/png 

PNG ... content of chrome.png ... 
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--

确定 boundary 用来分割不同字段,为了和正文内容区分开,boundary 需要足够长,接下来字段都是以 --boundary 开始,紧接着是字段描述信息,然后是一个回车,最后是字段具体内容,如果是文件还要加上文件名和文件的 MIME 类型,最后以 --boundary-- 结束。

text/xml

XML 曾经普遍用于前后端交互,和 JSON 相比,这种数据格式显得臃肿,没有 JSON 数据键值对看起来容易。微信公众号开发向服务器发送的就是 XML 数据,看来这种数据还在 Web 开发中占是有一定地位的,HTTP 报文是这样的:

POST /user/api HTTP/1.1
Content-Type: text/xml

<?xml version="1.0"?> 
<user><name>makeco</name><age><22/age> 
</user>

以上这些就是常见用于POST请求的数据类型,对应到 ThinkJS 中的解析逻辑也非常简单。think-payload 中间件负责判断请求数据的 MIME 类型,使用对应的解析器将数据解析为 JavaScript 对象,然后挂载到上下文中,最终在 Controller 中被消费。如果是请求体数据 MIME 类型是 multipart/form-data,将会把文件放到一个临时文件夹中,然后生成一个包含着该文件基本信息的对象,也会挂载到上下文中。和文件上传有关的配置请看 think-payload 的文档,可以在src/config/middleware.js中修改配置。

数据校验

ThinkJS 提供了非常强大的校验工具来满足开发者的校验需求。根据用户提交的数据的结构层次,我们一般分为简单数据结构和复杂数据结构两种,简单数据结构的校验大致归类为三种,分别是存在性校验、数据类型校验和合法性校验。如果是复杂数据结构,可以自定义全局校验规则,还可以结合 ajv 使用 JSON Schema 来进行更复杂的校验。

简单数据结构

以一个 POST 请求为例,假设请求体数据结构如下:

{
    name: "maekco",
    age: 22,
    gender: "female",
    hobbies: ["tea", "marathon""cooking"]
}

在 Logic 中添加的校验规则:

module.exports = class extends think.Logic {
    postAction() {
        this.rules = {
            name: {
                required: true,
                string: true,
                trim: true,
            },
            age: {
                required: true,
                int: { min: 18, max: 100 },
            },
            gender: {
                required: true,
                in: ['male', 'famale']
            },
            hobbies: {
                required: true,
                array: true,
                children: {
                    string: true,
                    trim: true,
                }
            }
        }
    }
}

这样保证了之后 Controller 业务层一定会取到 name, age, gender, hobbies 四个字段(校验不通过则在 Logic 层就返回失败),且 name 是字符串,age 是一个 18-100 的数字,而 gender 则必须是 male 或 female,hobbies 则必须是字符串数组。

Logic 校验除了校验数据之外还支持根据校验规则回写校验结果,比较常见的是请求传了一个字符串的数字进来,我们通过 Logic 配置该字段 int: true 则能够强制将其回写成整型数据。

复杂数据结构

刚才的数据中 hobbies 是一个数组,我们为所有元素指定相同规则,如果是数组的话还好,但是为对象中的每个元素指定相同的规则,很可能不会满足实际需求,如果在上面的数据中添加一个 family 数组,里面每个对象都是一位家庭成员,很明显不能为所有的字段都指定为字符串类型或者是整数类型,这时就应该使用复杂数据结构的校验方式了。

首先我们在上一个的请求体数据的基础上,增加了一个 famliy 对象数组。

{
    name: "maekco",
    age: 22,
    gender: "female",
    hobbies: ["tea", "marathon""cooking"],
    family: [
        {
            name: "xxx",
            age: 45,
            relation: 'mother'
        },
        {
        name: "xxx",
            age: 15,
            relation: 'sister'
        }
    ]
}

对于上面数据中的nameagegenderhobbies仍使用 ThinkJS 提供的关键字进行校验,family数组需要添加一个全局校验规则,结合 ajv 使用 JSON Schema 定义 family 的数据规则。

{
    "items": {
        "type": "object",
        "properties": {
            "name": {
                "type": "string"
        },
        "age": {
            "type": "number",
        "min": 18,
        "max": 100
        },
        "relation": {
        "enum": ["mother", "father", "sister"]
        }
    },
    "required": ["name", "relation"] // 指定必填字段
    },
    "minItems": 0,
    "maxItems": 4
}

接着在 src/config 下面添加 validator.js 文件,并添加如下代码:

const Ajv = require('ajv');

const ajv = new Ajv({ allErrors: true });
// 编译json schema文件
const familyValidator = ajv.compile(require('../schema/family.json'));

function genSchemaRule(validator) {
  return function(value, { argName }) {
    const result = validator(value);
    if (result) return true;
    // 如果校验结果为false,返回出错信息
    return {
      [argName]: ajv.errorsText(validator.errors)
    };
  };
}

module.exports = {
  // 全局规则
  rules: {
    isFamily: genSchemaRule(familyValidator)
  }
};

补充 Logic 层的校验规则:

module.exports = class extends think.Logic {
    postAction() {
        this.rules = {
            name: {
                required: true,
                string: true,
                trim: true
            },
            age: {
                required: true,
                int: { min: 18, max: 100 }
            },
            gender: {
                required: true,
                in: ['male', 'female']
            },
            hobbies: {
                required: true,
                array: true,
                children: {
                    string: true,
                    trim: true
                }
            },
            family: {
                required: true,
                isFamily: true
            }
        }
    }
}

使用 Postman 构造下面的请求数据:

{
    "name": "maekco",
    "age": 22,
    "gender": "female",
    "hobbies": ["cooking"],
    "family": [
        {
        "name": "xxx",
        "age": "15", // 应该是15,而不是“15”
        "relation": "sister"
    }
    ]
}

发送请求后 Logic 层返回如下校验结果:

{
    "errno": 1001,
    "errmsg": {
        "family": "data[0].age should be number"
    }
}

如果在family中添加5个对象,Logic 层会返回如下校验结果:

{
    "errno": 1001,
    "errmsg": {
        "family": "data should NOT have more than 4 items"
    }
}

上面这种方式是启动时编译,使用 JSON Schema 进行复杂数据结构校验时,一定要注意编写标准的 JSON 格式的规则,否则 ajv 编译会出错,导致程序无法启动。ThinkJS 官网上提供的JSON Schema 校验方法是运行时动态编译的,比较消耗性能,而且在 Logic 文件中编写 JSON 数据会被 prettier 等工具格式化为 JavaScirpt 对象,导致动态编译失败,因此还是推荐使用启动时编译的方式创建 JSON Schema 校验。

后记

其实很多同学对数据上传的逻辑只是大概清楚,很多细节都不明白,因此经常在开发过程中会有很多问题。工欲善其事必先利其器,只有把细节搞懂了才能应对好每一个问题。本文也只是浅谈了一下数据解析和校验相关的细节,如还有不太明白的欢迎大家留言讨论。大家有什么感兴趣的话题也可以留言告诉我,说不定下一期就能看到了:)

参考:

编辑于 2018-07-09

文章被以下专栏收录

    ThinkJS 是一款基于 Koa 2.0 面向未来的企业级 Node.js 框架,致力于整合了大量的项目最佳实践,让企业级开发变得如此简单、高效。本专栏会分享一些在 ThinkJS 项目开发过程中总结的一些经验以及问题,同时也非常欢迎大家投稿。