如何利用Fastify 实现更快的 JSON 序列化转载
导语
对于 web 框架而言,更快的 HTTP 请求响应速度意味着更优异的性能。而 HTTP 协议是一个文本协议,传输的格式都是字符串,而我们在代码中常常操作的是 JSON 格式的数据。因此,需要在返回响应数据前将 JSON 数据序列化为字符串。JavaScript 原生提供了 JSON.stringify 这个方法,来将 JSON 转成字符串。先来介绍这个方法。
正文
缓慢的 JSON.stringify
由于 JavaScript 是一个动态语言,它的类型是在运行时才能确定,因此 JSON.stringify 的执行过程中要进行大量的类型判断,对不同类型的键值做不同的处理。由于不能做静态分析,我们很难做进一步优化。而且还需要一层一层的递归,循环引用的话还有爆栈的风险。在以性能著称的 Node.js 框架 Fastify 中,通过使用 fast-json-stringify 这个库,来替代 JSON.stringify,实现 JSON 序列化性能翻倍。那么,fast-json-stringify 是怎么做到的呢?
fast-json-stringify 揭秘
fast-json-stringify 基于 JSON Schema Draft 7 来定义(JSON)对象的数据格式。比如对象:
{
foo: 1,
bar: "abc"
}
它的 JSON Schema 可以是这样:
{
type: "object",
properties: {
foo: {type: "integer"},
bar: {type: "string"}
},
required: ["foo"]
}
除了这种简单的类型定义,JSON Schema 还支持一些条件运算,比如字段类型可能是字符串或者数字,可以用 oneOf 关键字:
"oneOf": [
{
"type": "string"
},
{
"type": "number"
}
]
关于 JSON Schema 的完整定义,可以参考 Ajv 的文档,Ajv 是一个流行的 JSON Schema验证工具,性能表现也非常出众。来看一段使用 fast-json-stringify 的简单代码:
require('http').createServer(handle).listen(3000)
var flatstr = require('flatstr')
var stringify = require('fast-json-stringify')({
type: 'object',
properties: { hello: { type: 'string' } }
})
function handle (req, res) {
res.setHeader('Content-Type', 'application/json')
res.end(flatstr(stringify({ hello: 'world' })))
}
这段代码里,fast-json-stringify 接受一个 JSON Schema 对象作为参数,生成了一个 stringify 函数。通常,Response 的数据结构是固定的,所以可以将其定义为一个 Schema,就相当于提前告诉 stringify 函数,需序列化的对象的数据结构,这样它就可以不必再在运行时去做类型判断。下面来看看 stringify 函数是如何生成的。
生成 stringify 函数
首先,需要对 JSON Schema 进行校验。底层校验逻辑是基于 Ajv 实现的,这里暂不赘述。然后需要预先注入一些工具方法,用于将一些常见类型转成字符串。
const asFunctions = `
function $asAny (i) {
return JSON.stringify(i)
}
function $asNull () {
return 'null'
}
function $asInteger (i) {
if (isLong && isLong(i)) {
return i.toString()
} else if (typeof i === 'bigint') {
return i.toString()
} else if (Number.isInteger(i)) {
return $asNumber(i)
} else {
return $asNumber(parseInteger(i))
}
}
function $asNumber (i) {
const num = Number(i)
if (isNaN(num)) {
return 'null'
} else {
return '' + num
}
}
function $asBoolean (bool) {
return bool && 'true' || 'false'
}
// 省略了一些其他类型......
`
可以看到,如果你使用的是 any 类型,它内部依然还是用的 JSON.stringify。我们经常建议 ts 开发者避免使用 any 类型是有道理的,因为如果是基于 ts interface 生成 JSON Schema 的话,使用 any 也会影响到 JSON 序列化的性能。
接下来,遍历 schema,对不同类型调用对应的工具函数来生成代码。
let code = `
'use strict'
${asFunctions}
`
let main
switch (schema.type) {
// 省略了对象和数组
case 'integer':
main = '$asInteger'
break
case 'number':
main = '$asNumber'
break
case 'boolean':
main = '$asBoolean'
break
case 'null':
main = '$asNull'
break
case undefined:
main = '$asAny'
break
default:
throw new Error(`${schema.type} unsupported`)
}
code += `
;
return ${main}
`
最后,对生成出来的 code 调用 Function 构造函数。
const dependencies = [new Ajv(options.ajv)]
const dependenciesName = ['ajv']
dependenciesName.push(code)
return (Function.apply(null, dependenciesName).apply(null, dependencies))
这里将 ajv 对象作为参数注入到函数里,是为了处理 JSON Schema 中 if、then、else、anyOf 等情况。
由于是调用的 new Function 来动态执行代码,这里其实是有一定的安全风险的。所以建议开发者一定不要使用用户生成的 schema,保证生成的 schema 是安全可控的。
最终,开发者调用 stringify 函数,将 JSON 转成字符串。执行 stringify 的过程本质上就是在做字符串拼接。
总结
Fastify 使用 fast-json-stringify 替代 JSON.stringify,实现了更快的 JSON 序列化。它的原理是通过开发者预先定义 JSON Schema,使得框架可以提前知道 JSON 数据的结构。然后根据 JSON Schema 生成一个 stringify 函数,stringify 内部做的事情其实就是字符串拼接。最后开发者调用 stringify 函数来序列化 JSON。本质上是将类型分析从运行时提前到编译时了。