Fastify
验证和序列化
Fastify 使用基于 schema 的途径,从本质上将 schema 编译成了高性能的函数,来实现路由的验证与输出的序列化。我们推荐使用 JSON Schema,虽然这并非必要。
⚠ 安全须知
应当将 schema 的定义写入代码。 因为不管是验证还是序列化,都会使用
new Function()来动态生成代码并执行。 所以,用户提供的 schema 是不安全的。 更多内容,请看 Ajv 与 fast-json-stringify。
核心观念
验证与序列化的任务分别由两个可定制的工具完成:
- Ajv 6 用于验证请求。
- fast-json-stringify 用于序列化响应的 body。
这些工具相互独立,但共享通过 .addSchema(schema) 方法添加到 Fastify 实例上的 JSON schema。
添加共用 schema (shared schema)
得益于 addSchema API,你能向 Fastify 实例添加多个 schema,并在程序的不同部分复用它们。
像往常一样,该 API 是封装好的。
共用 schema 可以通过 JSON Schema 的 $ref 关键字复用。
以下是引用方法的 总结:
myField: { $ref: '#foo'}将在当前 schema 内搜索$id: '#foo'字段。myField: { $ref: '#/definitions/foo'}将在当前 schema 内搜索definitions.foo字段。myField: { $ref: 'http://url.com/sh.json#'}会搜索含$id: 'http://url.com/sh.json'的共用 schema。myField: { $ref: 'http://url.com/sh.json#/definitions/foo'}会搜索含$id: 'http://url.com/sh.json'的共用 schema,并使用其definitions.foo字段。myField: { $ref: 'http://url.com/sh.json#foo'}会搜索含$id: 'http://url.com/sh.json'的共用 schema,并使用其内部带$id: '#foo'的对象。
简单用法:
fastify.addSchema({$id: 'http://example.com/',type: 'object',properties: {hello: { type: 'string' }}})fastify.post('/', {handler () {},schema: {body: {type: 'array',items: { $ref: 'http://example.com#/properties/hello' }}}})
$ref 作为根引用 (root reference):
fastify.addSchema({$id: 'commonSchema',type: 'object',properties: {hello: { type: 'string' }}})fastify.post('/', {handler () {},schema: {body: { $ref: 'commonSchema#' },headers: { $ref: 'commonSchema#' }}})
获取共用 schema
当自定义验证器或序列化器的时候,Fastify 不再能控制它们,此时 .addSchema 方法失去了作用。
要获取添加到 Fastify 实例上的 schema,你可以使用 .getSchemas():
fastify.addSchema({$id: 'schemaId',type: 'object',properties: {hello: { type: 'string' }}})const mySchemas = fastify.getSchemas()const mySchema = fastify.getSchema('schemaId')
getSchemas 方法也是封装好的,返回的是指定作用域中可用的共用 schema:
fastify.addSchema({ $id: 'one', my: 'hello' })// 只返回 schema `one`fastify.get('/', (request, reply) => { reply.send(fastify.getSchemas()) })fastify.register((instance, opts, done) => {instance.addSchema({ $id: 'two', my: 'ciao' })// 会返回 schema `one` 与 `two`instance.get('/sub', (request, reply) => { reply.send(instance.getSchemas()) })instance.register((subinstance, opts, done) => {subinstance.addSchema({ $id: 'three', my: 'hola' })// 会返回 schema `one`、`two` 和 `three`subinstance.get('/deep', (request, reply) => { reply.send(subinstance.getSchemas()) })done()})done()})
验证
路由的验证是依赖 Ajv 6 实现的。这是一个高性能的 JSON schema 校验工具。验证输入十分简单,只需将字段加入路由的 schema 中即可!
支持的验证类型如下:
body:当请求方法为 POST、PUT 或 PATCH 时,验证 body。querystring或query:验证 querystring。params:验证路由参数。headers:验证 header。
所有的验证都可以是一个完整的 JSON Schema 对象 (包括值为 object 的 type 属性以及包含参数的 properties 对象),也可以是一个没有 type 与 properties,而仅仅在顶层列明参数的简单变种 (见下文示例)。
ℹ 想要使用最新版 Ajv (Ajv 8) 的话,请查阅
schemaController一节,里边描述了比自定义校验器更简单的方法。
示例:
const bodyJsonSchema = {type: 'object',required: ['requiredKey'],properties: {someKey: { type: 'string' },someOtherKey: { type: 'number' },requiredKey: {type: 'array',maxItems: 3,items: { type: 'integer' }},nullableKey: { type: ['number', 'null'] }, // 或 { type: 'number', nullable: true }multipleTypesKey: { type: ['boolean', 'number'] },multipleRestrictedTypesKey: {oneOf: [{ type: 'string', maxLength: 5 },{ type: 'number', minimum: 10 }]},enumKey: {type: 'string',enum: ['John', 'Foo']},notTypeKey: {not: { type: 'array' }}}}const queryStringJsonSchema = {type: 'object',properties: {name: { type: 'string' },excitement: { type: 'integer' }}}const paramsJsonSchema = {type: 'object',properties: {par1: { type: 'string' },par2: { type: 'number' }}}const headersJsonSchema = {type: 'object',properties: {'x-foo': { type: 'string' }},required: ['x-foo']}const schema = {body: bodyJsonSchema,querystring: queryStringJsonSchema,params: paramsJsonSchema,headers: headersJsonSchema}fastify.post('/the/url', { schema }, handler)
请注意,为了通过校验,并在后续过程中使用正确类型的数据,Ajv 会尝试将数据隐式转换为 schema 中 type 属性指明的类型。
Fastify 提供给 Ajv 的默认配置并不支持隐式转换 querystring 中的数组参数。但是,Fastify 允许你通过设置 Ajv 实例的 customOptions 选项为 ‘array’,来将参数转换为数组。举例如下:
const opts = {schema: {querystring: {type: 'object',properties: {ids: {type: 'array',default: []},},}}}fastify.get('/', opts, (request, reply) => {reply.send({ params: request.query })})fastify.listen(3000, (err) => {if (err) throw err})
默认情况下,该处的请求将返回 400:
curl -X GET "http://localhost:3000/?ids=1{"statusCode":400,"error":"Bad Request","message":"querystring/hello should be array"}
设置 coerceTypes 的值为 ‘array’ 将修复该问题:
const ajv = new Ajv({removeAdditional: true,useDefaults: true,coerceTypes: 'array', // 看这里allErrors: true})fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {return ajv.compile(schema)})
curl -X GET "http://localhost:3000/?ids=1{"params":{"hello":["1"]}}
你还可以给每个参数类型 (body, query string, param, header) 都自定义 schema 校验器。
下面的例子改变了 ajv 的默认选项,禁用了 body 的强制类型转换。
const schemaCompilers = {body: new Ajv({removeAdditional: false,coerceTypes: false,allErrors: true}),params: new Ajv({removeAdditional: false,coerceTypes: true,allErrors: true}),querystring: new Ajv({removeAdditional: false,coerceTypes: true,allErrors: true}),headers: new Ajv({removeAdditional: false,coerceTypes: true,allErrors: true})}server.setValidatorCompiler(req => {if (!req.httpPart) {throw new Error('Missing httpPart')}const compiler = schemaCompilers[req.httpPart]if (!compiler) {throw new Error(`Missing compiler for ${req.httpPart}`)}return compiler.compile(req.schema)})
更多信息请看这里。
Ajv 插件
你可以给默认的 ajv 实例提供一组插件。这些插件必须兼容 Ajv 6。
插件格式参见
ajv 选项
const fastify = require('fastify')({ajv: {plugins: [require('ajv-merge-patch')]}})fastify.post('/', {handler (req, reply) { reply.send({ ok: 1 }) },schema: {body: {$patch: {source: {type: 'object',properties: {q: {type: 'string'}}},with: [{op: 'add',path: '/properties/q',value: { type: 'number' }}]}}}})fastify.post('/foo', {handler (req, reply) { reply.send({ ok: 1 }) },schema: {body: {$merge: {source: {type: 'object',properties: {q: {type: 'string'}}},with: {required: ['q']}}}}})
验证生成器
validatorCompiler 返回一个用于验证 body、URL、路由参数、header 以及 querystring 的函数。默认返回一个实现了 ajv 验证接口的函数。Fastify 内在地使用该函数以加速验证。
Fastify 使用的 ajv 基本配置如下:
{removeAdditional: true, // 移除额外属性useDefaults: true, // 当属性或项目缺失时,使用 schema 中预先定义好的 default 的值代替coerceTypes: true, // 根据定义的 type 的值改变数据类型nullable: true // 支持 OpenAPI Specification 3.0 版本的 "nullable" 关键字}
上述配置可通过 ajv.customOptions 修改。
假如你想改变或增加额外的选项,你需要创建一个自定义的实例,并覆盖已存在的实例:
const fastify = require('fastify')()const Ajv = require('ajv')const ajv = new Ajv({// fastify 使用的默认参数(如果需要)removeAdditional: true,useDefaults: true,coerceTypes: true,nullable: true,// 任意其他参数// ...})fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {return ajv.compile(schema)})
注意: 如果你使用自定义校验工具的实例(即使是 Ajv),你应当向该实例而非 Fastify 添加 schema,因为在这种情况下,Fastify 默认的校验工具不再使用,而 addSchema 方法也不清楚你在使用什么工具进行校验。
使用其他验证工具
通过 setValidatorCompiler 函数,你可以轻松地将 ajv 替换为几乎任意的 Javascript 验证工具 (如 joi、yup 等),或自定义它们。
const Joi = require('@hapi/joi')fastify.post('/the/url', {schema: {body: Joi.object().keys({hello: Joi.string().required()}).required()},validatorCompiler: ({ schema, method, url, httpPart }) => {return data => schema.validate(data)}}, handler)
const yup = require('yup')// 等同于前文 ajv 基本配置的 yup 的配置const yupOptions = {strict: false,abortEarly: false, // 返回所有错误(译注:为 true 时出现首个错误后即返回)stripUnknown: true, // 移除额外属性recursive: true}fastify.post('/the/url', {schema: {body: yup.object({age: yup.number().integer().required(),sub: yup.object().shape({name: yup.string().required()}).required()})},validatorCompiler: ({ schema, method, url, httpPart }) => {return function (data) {// 当设置 strict = false 时, yup 的 `validateSync` 函数在验证成功后会返回经过转换的值,而失败时则会抛错。try {const result = schema.validateSync(data, yupOptions)return { value: result }} catch (e) {return { error: e }}}}}, handler)
其他验证工具的验证信息
Fastify 的错误验证与其默认的验证引擎 ajv 紧密结合,错误最终会经由 schemaErrorsText 函数转化为便于阅读的信息。然而,也正是由于 schemaErrorsText 与 ajv 的强关联性,当你使用其他校验工具时,可能会出现奇怪或不完整的错误信息。
要规避以上问题,主要有两个途径:
- 确保自定义的
schemaCompiler返回的错误结构与ajv的一致 (当然,由于各引擎的差异,这是件困难的活儿)。 - 使用自定义的
errorHandler拦截并格式化验证错误。
Fastify 给所有的验证错误添加了两个属性,来帮助你自定义 errorHandler:
- validation:来自
schemaCompiler函数的验证函数所返回的对象上的error属性的内容。 - validationContext:验证错误的上下文 (body、params、query、headers)。
以下是一个自定义 errorHandler 来处理验证错误的例子:
const errorHandler = (error, request, reply) => {const statusCode = error.statusCodelet responseconst { validation, validationContext } = error// 检验是否发生了验证错误if (validation) {response = {// validationContext 的值可能是 'body'、'params'、'headers' 或 'query'message: `A validation error occured when validating the ${validationContext}...`,// 验证工具返回的结果errors: validation}} else {response = {message: 'An error occurred...'}}// 其余代码。例如,记录错误日志。// ...reply.status(statusCode).send(response)}
序列化
通常,你会通过 JSON 格式将数据发送至客户端。鉴于此,Fastify 提供了一个强大的工具——fast-json-stringify 来帮助你。当你在路由选项中提供了输出的 schema 时,它能派上用场。 我们推荐你编写一个输出的 schema,因为这能让应用的吞吐量提升 100-400% (根据 payload 的不同而有所变化),也能防止敏感信息的意外泄露。
示例:
const schema = {response: {200: {type: 'object',properties: {value: { type: 'string' },otherValue: { type: 'boolean' }}}}}fastify.post('/the/url', { schema }, handler)
如你所见,响应的 schema 是建立在状态码的基础之上的。当你想对多个状态码使用同一个 schema 时,你可以使用类似 '2xx' 的表达方法,例如:
const schema = {response: {'2xx': {type: 'object',properties: {value: { type: 'string' },otherValue: { type: 'boolean' }}},201: {// 对比写法value: { type: 'string' }}}}fastify.post('/the/url', { schema }, handler)
序列化函数生成器
serializerCompiler 返回一个根据输入参数返回字符串的函数。你应该提供一个函数,用于序列化所有定义了 response JSON Schema 的路由。
fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => {return data => JSON.stringify(data)})fastify.get('/user', {handler (req, reply) {reply.send({ id: 1, name: 'Foo', image: 'BIG IMAGE' })},schema: {response: {'2xx': {id: { type: 'number' },name: { type: 'string' }}}}})
假如你需要在特定位置使用自定义的序列化工具,你可以使用 reply.serializer(...)。
错误控制
当某个请求 schema 校验失败时,Fastify 会自动返回一个包含校验结果的 400 响应。举例来说,假如你的路由有一个如下的 schema:
const schema = {body: {type: 'object',properties: {name: { type: 'string' }},required: ['name']}}
当校验失败时,路由会立即返回一个包含以下内容的响应:
{"statusCode": 400,"error": "Bad Request","message": "body should have required property 'name'"}
如果你想在路由内部控制错误,可以设置 attachValidation 选项。当出现 验证错误 时,请求的 validationError 属性将会包含一个 Error 对象,在这对象内部有原始的验证结果 validation,如下所示:
const fastify = Fastify()fastify.post('/', { schema, attachValidation: true }, function (req, reply) {if (req.validationError) {// `req.validationError.validation` 包含了原始的验证错误信息reply.code(400).send(req.validationError)}})
schemaErrorFormatter
如果你需要自定义错误的格式化,可以给 Fastify 实例化时的选项添加 schemaErrorFormatter,其值为返回一个错误的同步函数。函数的 this 指向 Fastify 服务器实例。
errors 是 Fastify schema 错误 (FastifySchemaValidationError) 的一个数组。
dataVar 是当前验证的 schema 片段 (params | body | querystring | headers)。
const fastify = Fastify({schemaErrorFormatter: (errors, dataVar) => {// ... 自定义的格式化逻辑return new Error(myErrorMessage)}})// 或fastify.setSchemaErrorFormatter(function (errors, dataVar) {this.log.error({ err: errors }, 'Validation failed')// ... 自定义的格式化逻辑return new Error(myErrorMessage)})
你还可以使用 setErrorHandler 方法来自定义一个校验错误响应,如下:
fastify.setErrorHandler(function (error, request, reply) {if (error.validation) {// error.validationContext 是 [body, params, querystring, headers] 之中的值reply.status(422).send(new Error(`validation failed of the ${error.validationContext}`))}})
假如你想轻松愉快地自定义错误响应,请查看 ajv-errors。具体的例子可以移步这里。
下面的例子展示了如何通过自定义 AJV,为 schema 的每个属性添加自定义错误信息。 其中的注释描述了在不同场景下设置不同信息的方法。
const fastify = Fastify({ajv: {customOptions: { jsonPointers: true },plugins: [require('ajv-errors')]}})const schema = {body: {type: 'object',properties: {name: {type: 'string',errorMessage: {type: 'Bad name'}},age: {type: 'number',errorMessage: {type: 'Bad age', // 为除了必填外的所有限制min: 'Too young' // 自定义错误信息}}},required: ['name', 'age'],errorMessage: {required: {name: 'Why no name!', // 为必填设置age: 'Why no age!' // 错误信息}}}}fastify.post('/', { schema, }, (request, reply) => {reply.send({hello: 'world'})})
想要本地化错误信息,请看 ajv-i18n
const localize = require('ajv-i18n')const fastify = Fastify()const schema = {body: {type: 'object',properties: {name: {type: 'string',},age: {type: 'number',}},required: ['name', 'age'],}}fastify.setErrorHandler(function (error, request, reply) {if (error.validation) {localize.ru(error.validation)reply.status(400).send(error.validation)return}reply.send(error)})
JSON Schema 支持
为了能更简单地重用 schema,JSON Schema 提供了一些功能,来结合 Fastify 的共用 schema。
| 用例 | 验证器 | 序列化器 |
|---|---|---|
引用 ($ref) $id |
✔ | ✔️ |
引用 ($ref) /definitions |
✔️ | ✔️ |
引用 ($ref) 共用 schema $id |
✔ | ✔️ |
引用 ($ref) 共用 schema /definitions |
✔ | ✔️ |
示例
同一个 JSON Schema 中对 $id 的引用 ($ref)
const refToId = {type: 'object',definitions: {foo: {$id: '#address',type: 'object',properties: {city: { type: 'string' }}}},properties: {home: { $ref: '#address' },work: { $ref: '#address' }}}
同一个 JSON Schema 中对 /definitions 的引用 ($ref)
const refToDefinitions = {type: 'object',definitions: {foo: {$id: '#address',type: 'object',properties: {city: { type: 'string' }}}},properties: {home: { $ref: '#/definitions/foo' },work: { $ref: '#/definitions/foo' }}}
对外部共用 schema 的 $id 的引用 ($ref)
fastify.addSchema({$id: 'http://foo/common.json',type: 'object',definitions: {foo: {$id: '#address',type: 'object',properties: {city: { type: 'string' }}}}})const refToSharedSchemaId = {type: 'object',properties: {home: { $ref: 'http://foo/common.json#address' },work: { $ref: 'http://foo/common.json#address' }}}
对外部共用 schema 的 /definitions 的引用 ($ref)
fastify.addSchema({$id: 'http://foo/shared.json',type: 'object',definitions: {foo: {type: 'object',properties: {city: { type: 'string' }}}}})const refToSharedSchemaDefinitions = {type: 'object',properties: {home: { $ref: 'http://foo/shared.json#/definitions/foo' },work: { $ref: 'http://foo/shared.json#/definitions/foo' }}}
资源
- JSON Schema
- 理解 JSON Schema
- fast-json-stringify 文档
- Ajv 文档
- Ajv i18n
- Ajv 自定义错误
- 使用核心方法自定义错误处理,并实现错误文件转储的例子
