从前端到全栈:Node参数验证一网打尽(自带测试版)

321 阅读6分钟

无论是在分锅大会上预防小人还是为了保证webserver安全,参数验证是确保应用程序安全和稳定运行的重要环节。无论是API请求参数、表单数据,还是配置文件,验证都能有效防止恶意输入,确保数据的完整性和正确性。

本文将详细介绍几种流行的参数验证库:Joi、Ajv、Zod、Yup、Validator.js、Superstruct、Class-validator,并通过综合实例展示如何在HTTP请求中使用这些库进行参数验证。

为什么验证参数

  1. 安全性:防止SQL注入、XSS攻击等恶意输入。
  2. 数据完整性:确保数据符合预期格式和类型。
  3. 用户体验:及时反馈错误信息,提高用户体验。
  4. 代码健壮性:减少运行时错误,提升代码稳定性。

验证库概览

名称GitHub 地址Stars 数量是否支持 TypeScript功能简述
Joigithub.com/hapijs/joi20k+强大的对象模式描述语言和验证器
Ajvgithub.com/ajv-validat…12k+JSON Schema 验证器,支持JSON格式的验证
Zodgithub.com/colinhacks/…33k+TypeScript优先的模式验证库
Yupgithub.com/jquense/yup22k+JavaScript对象模式验证和解析库
Validator.jsgithub.com/validatorjs…23k+字符串验证库,支持多种常见验证
Superstructgithub.com/ianstormtay…7k+用于验证 JavaScript 数据结构的库
Class-validatorgithub.com/typestack/c…10k+基于装饰器的验证库,适用于TypeScript类

各库详细介绍

1. Joi

优势

  • 功能强大,支持复杂的验证规则。
  • 直观的API设计,易于使用。
  • 支持链式调用和嵌套对象验证。

劣势

  • 体积较大。
  • 学习曲线较陡。

使用方式

const Joi = require('joi');

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')).required(),
  email: Joi.string().email().required()
});

const { error, value } = schema.validate({ username: 'abc', password: '123456', email: 'abc@example.com' });
if (error) {
  console.error(error.details);
}

适用场景

  • 需要复杂验证规则的场景。
  • 需要嵌套对象验证的场景。

2. Ajv

优势

  • 速度快,性能高。
  • 支持JSON Schema标准。
  • 可扩展性强,支持自定义关键字。

劣势

  • JSON Schema学习成本较高。
  • 错误信息不够友好。

使用方式

const Ajv = require('ajv');
const ajv = new Ajv();

const schema = {
  type: 'object',
  properties: {
    username: { type: 'string', minLength: 3, maxLength: 30 },
    password: { type: 'string', pattern: '^[a-zA-Z0-9]{3,30}$' },
    email: { type: 'string', format: 'email' }
  },
  required: ['username', 'password', 'email'],
  additionalProperties: false
};

const validate = ajv.compile(schema);
const valid = validate({ username: 'abc', password: '123456', email: 'abc@example.com' });
if (!valid) {
  console.error(validate.errors);
}

适用场景

  • 需要高性能验证的场景。
  • 需要遵循JSON Schema标准的场景。

3. Zod

优势

  • TypeScript优先,类型推断优秀。
  • API简洁,易于使用。
  • 支持链式调用和嵌套对象验证。

劣势

  • 功能相对较少。
  • 生态系统不如其他库丰富。

使用方式

import { z } from 'zod';

const schema = z.object({
  username: z.string().min(3).max(30),
  password: z.string().regex(/^[a-zA-Z0-9]{3,30}$/),
  email: z.string().email()
});

try {
  schema.parse({ username: 'abc', password: '123456', email: 'abc@example.com' });
} catch (e) {
  console.error(e.errors);
}

适用场景

  • TypeScript项目。
  • 需要类型推断的场景。

4. Yup

优势

  • API设计直观,易于使用。
  • 支持链式调用和嵌套对象验证。
  • 与Formik等表单库集成良好。

劣势

  • 性能稍逊于Ajv。
  • 错误信息不够详细。

使用方式

const yup = require('yup');

const schema = yup.object().shape({
  username: yup.string().min(3).max(30).required(),
  password: yup.string().matches(/^[a-zA-Z0-9]{3,30}$/).required(),
  email: yup.string().email().required()
});

schema.validate({ username: 'abc', password: '123456', email: 'abc@example.com' })
  .catch(err => {
    console.error(err.errors);
  });

适用场景

  • 前端表单验证。
  • 需要简单易用的验证库。

5. Validator.js

优势

  • 轻量级。
  • 支持多种常见验证。
  • 易于集成到其他库中。

劣势

  • 只支持字符串验证。
  • 不支持嵌套对象验证。

使用方式

const validator = require('validator');

const isValidUsername = validator.isLength('abc', { min: 3, max: 30 });
const isValidPassword = validator.matches('123456', /^[a-zA-Z0-9]{3,30}$/);
const isValidEmail = validator.isEmail('abc@example.com');

if (!isValidUsername || !isValidPassword || !isValidEmail) {
  console.error('Invalid input');
}

适用场景

  • 需要简单字符串验证的场景。
  • 轻量级项目。

6. Superstruct

优势

  • 轻量级。
  • API设计简洁。
  • 支持嵌套对象验证。

劣势

  • 功能相对较少。
  • 社区支持较少。

使用方式

const { struct } = require('superstruct');

const User = struct({
  username: 'string & min:3 & max:30',
  password: 'string & pattern:/^[a-zA-Z0-9]{3,30}$/',
  email: 'string & email'
});

try {
  User({ username: 'abc', password: '123456', email: 'abc@example.com' });
} catch (e) {
  console.error(e.message);
}

适用场景

  • 需要轻量级验证库的场景。
  • 需要嵌套对象验证的场景。

7. Class-validator

优势

  • 基于装饰器,适用于TypeScript类。
  • 与TypeORM等库集成良好。
  • 支持复杂验证规则。

劣势

  • 仅适用于TypeScript。
  • 学习曲线较陡。

使用方式

import { IsEmail, IsString, Length, Matches, validate } from 'class-validator';

class User {
  @IsString()
  @Length(3, 30)
  username: string;

  @IsString()
  @Matches(/^[a-zA-Z0-9]{3,30}$/)
  password: string;

  @IsEmail()
  email: string;
}

const user = new User();
user.username = 'abc';
user.password = '123456';
user.email = 'abc@example.com';

validate(user).then(errors => {
  if (errors.length > 0) {
    console.error(errors);
  }
});

适用场景

  • TypeScript项目。
  • 需要类装饰器的场景。

综合实例:HTTP请求参数验证

1. 新建文件夹和初始化项目

首先,创建一个新的文件夹并初始化一个新的Node.js项目:

mkdir ts-validation-example
cd ts-validation-example
npm init -y

package.jsong

{
  "name": "ts-validation-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "tsc && node dist/server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "ajv": "^8.17.1",
    "ajv-formats": "^3.0.1",
    "axios": "^1.7.7",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.1",
    "express": "^4.21.0",
    "joi": "^17.13.3",
    "reflect-metadata": "^0.2.2",
    "superstruct": "^2.0.2",
    "validator": "^13.12.0",
    "yup": "^1.4.0",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^22.5.5",
    "@types/validator": "^13.12.1",
    "ts-node": "^10.9.2",
    "typescript": "^5.6.2"
  }
}

2. 安装必要的依赖

接下来,安装Express和各个验证库的依赖,以及TypeScript和类型定义:

npm install express joi ajv zod yup validator superstruct class-validator reflect-metadata axios class-transformer ajv-formats
npm install --save-dev typescript ts-node @types/node @types/express @types/validator

3. 初始化 TypeScript 配置

初始化ts

tsc --init

配置如下

{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

4. 创建项目结构和代码文件

创建项目结构和 server.ts 文件:

mkdir src
touch src/server.ts

src/server.ts 文件中,编写以下代码:

import express, { Request, Response } from "express";
import Joi from "joi";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import { z } from "zod";
import * as yup from "yup";
import validator from "validator";
import { assert, object, string, define } from "superstruct";
import "reflect-metadata";
import { plainToInstance } from "class-transformer";
import {
  IsEmail,
  IsString,
  Length,
  Matches,
  validate as classValidate,
} from "class-validator";
import fs from "fs";
import path from "path";

const app = express();
app.use(express.json());

function getHrTimeInMicroseconds() {
  const [seconds, nanoseconds] = process.hrtime();
  return seconds * 1e6 + nanoseconds / 1e3;
}

app.post("/register", async (req: Request, res: Response) => {
  const body = req.body;
  console.log("收到请求体:", body);

  let errors: any[] = [];
  const validationTimes: { [key: string]: number } = {};

  // Joi
  const joiStart = getHrTimeInMicroseconds();
  const joiSchema = Joi.object({
    username: Joi.string().alphanum().min(3).max(30).required(),
    password: Joi.string()
      .pattern(new RegExp("^[a-zA-Z0-9]{3,30}$"))
      .required(),
    email: Joi.string().email().required(),
  });

  const { error: joiError } = joiSchema.validate(body);
  validationTimes["Joi"] = getHrTimeInMicroseconds() - joiStart;
  console.log("Joi 验证结果:", joiError ? joiError.details : "无错误");
  if (joiError) errors.push({ 库: "Joi", 细节: joiError.details });

  // Ajv
  const ajvStart = getHrTimeInMicroseconds();
  const ajv = new Ajv({ allErrors: true });
  addFormats(ajv); // 添加这一行以引入格式验证
  const ajvSchema = {
    type: "object",
    properties: {
      username: { type: "string", minLength: 3, maxLength: 30 },
      password: { type: "string", pattern: "^[a-zA-Z0-9]{3,30}$" },
      email: { type: "string", format: "email" },
    },
    required: ["username", "password", "email"],
    additionalProperties: false,
  };

  const validate = ajv.compile(ajvSchema);
  const valid = validate(body);
  validationTimes["Ajv"] = getHrTimeInMicroseconds() - ajvStart;
  console.log("Ajv 验证结果:", valid ? "无错误" : validate.errors);
  if (!valid) errors.push({ 库: "Ajv", 细节: validate.errors });

  // Zod
  const zodStart = getHrTimeInMicroseconds();
  const zodSchema = z
    .object({
      username: z.string().min(3).max(30),
      password: z.string().regex(/^[a-zA-Z0-9]{3,30}$/),
      email: z.string().email(),
    })
    .strict();

  try {
    zodSchema.parse(body);
    validationTimes["Zod"] = getHrTimeInMicroseconds() - zodStart;
    console.log("Zod 验证结果: 无错误");
  } catch (e: any) {
    validationTimes["Zod"] = getHrTimeInMicroseconds() - zodStart;
    console.log("Zod 验证结果:", e.errors);
    errors.push({ 库: "Zod", 细节: e.errors });
  }

  // Yup
  const yupStart = getHrTimeInMicroseconds();
  const yupSchema = yup
    .object()
    .shape({
      username: yup.string().min(3).max(30).required(),
      password: yup
        .string()
        .matches(/^[a-zA-Z0-9]{3,30}$/)
        .required(),
      email: yup.string().email().required(),
    })
    .noUnknown(true, { message: "不允许未知字段" });

  try {
    await yupSchema.validate(body, { abortEarly: false });
    validationTimes["Yup"] = getHrTimeInMicroseconds() - yupStart;
    console.log("Yup 验证结果: 无错误");
  } catch (e: any) {
    validationTimes["Yup"] = getHrTimeInMicroseconds() - yupStart;
    console.log("Yup 验证结果:", e.errors);
    errors.push({ 库: "Yup", 细节: e.errors });
  }

  // Validator.js
  const validatorStart = getHrTimeInMicroseconds();
  const isValidUsername = validator.isLength(body.username as string, {
    min: 3,
    max: 30,
  });
  const isValidPassword = validator.matches(
    body.password as string,
    /^[a-zA-Z0-9]{3,30}$/
  );
  const isValidEmail = body.email && validator.isEmail(body.email as string);
  validationTimes["Validator.js"] = getHrTimeInMicroseconds() - validatorStart;

  console.log("Validator.js 验证结果:", {
    isValidUsername,
    isValidPassword,
    isValidEmail,
  });

  if (!isValidUsername || !isValidPassword || !isValidEmail) {
    errors.push({ 库: "Validator.js", 细节: "输入无效" });
  }

  // Superstruct
  const superstructStart = getHrTimeInMicroseconds();
  const Username = define("Username", (value: unknown) => {
    return typeof value === "string" && value.length >= 3 && value.length <= 30;
  });

  const Password = define("Password", (value: unknown) => {
    return typeof value === "string" && /^[a-zA-Z0-9]{3,30}$/.test(value);
  });

  const Email = define("Email", (value: unknown) => {
    return typeof value === "string" && validator.isEmail(value as string);
  });

  const User = object({
    username: Username,
    password: Password,
    email: Email,
  });

  try {
    assert(body, User);
    validationTimes["Superstruct"] =
      getHrTimeInMicroseconds() - superstructStart;
    console.log("Superstruct 验证结果: 无错误");
  } catch (e: any) {
    validationTimes["Superstruct"] =
      getHrTimeInMicroseconds() - superstructStart;
    console.log("Superstruct 验证结果:", e.message);
    errors.push({ 库: "Superstruct", 细节: e.message });
  }

  // Class-validator
  const classValidatorStart = getHrTimeInMicroseconds();
  class UserClass {
    @IsString()
    @Length(3, 30)
    username!: string;

    @IsString()
    @Matches(/^[a-zA-Z0-9]{3,30}$/)
    password!: string;

    @IsEmail()
    email!: string;
  }

  const user = plainToInstance(UserClass, body);

  const classErrors = await classValidate(user);
  validationTimes["Class-validator"] =
    getHrTimeInMicroseconds() - classValidatorStart;
  console.log(
    "Class-validator 验证结果:",
    classErrors.length > 0 ? classErrors : "无错误"
  );
  if (classErrors.length > 0) {
    errors.push({ 库: "Class-validator", 细节: classErrors });
  }

  // 记录验证执行时间
  const logFilePath = path.join(__dirname, "validation_times.log");
  fs.appendFileSync(logFilePath, JSON.stringify(validationTimes) + "\n");

  if (errors.length > 0) {
    return res.status(400).json({ 错误: errors });
  }

  res.status(200).json({ 消息: "注册成功" });
});

app.listen(3000, () => {
  console.log("服务器运行在端口 3000");
});

5. 编译和运行项目

编译 TypeScript 文件并运行项目:

npx tsc
node dist/server.js

访问 http://localhost:3000/register,并发送POST请求来测试验证功能。

6. 测试接口

在根目录中新建testRegister.js文件来做一些测试

const axios = require("axios");

const api = axios.create({
  baseURL: "http://localhost:3000", 
  timeout: 1000,
});

const testCases = [
  {
    description: "所有字段有效",
    data: {
      username: "validUser",
      password: "validPass123",
      email: "valid@example.com",
    },
    expectedStatus: 200,
    expectedMessage: "注册成功",
  },
  {
    description: "用户名太短",
    data: {
      username: "ab",
      password: "validPass123",
      email: "valid@example.com",
    },
    expectedStatus: 400,
  },
  {
    description: "用户名太长",
    data: {
      username: "a".repeat(31),
      password: "validPass123",
      email: "valid@example.com",
    },
    expectedStatus: 400,
  },
  {
    description: "密码不符合正则",
    data: {
      username: "validUser",
      password: "invalid_pass",
      email: "valid@example.com",
    },
    expectedStatus: 400,
  },
  {
    description: "无效的邮箱地址",
    data: {
      username: "validUser",
      password: "validPass123",
      email: "invalid-email",
    },
    expectedStatus: 400,
  },
  {
    description: "缺少必填字段",
    data: {
      username: "validUser",
      password: "validPass123",
    },
    expectedStatus: 400,
  },
  {
    description: "包含额外字段",
    data: {
      username: "validUser",
      password: "validPass123",
      email: "valid@example.com",
      extraField: "extra",
    },
    expectedStatus: 400,
  },
];

const runTests = async () => {
  for (const testCase of testCases) {
    try {
      const response = await api.post("/register", testCase.data);
      console.log(`${testCase.description}: 成功 - 状态码: ${response.status}`);
      if (response.data.message === testCase.expectedMessage) {
        console.log(`消息: ${response.data.message}`);
      }
    } catch (error) {
      if (error.response) {
        console.log(
          `${testCase.description}: 失败 - 状态码: ${error.response.status}`
        );
        console.log(
          `错误信息: ${JSON.stringify(error.response.data.error, null, 2)}`
        );
      } else {
        console.log(`${testCase.description}: 请求失败`);
      }
    }
  }
};

runTests();

7.测试结果(平均值)

两个表格两次不同的测试,第一个可能不够700次

测试次数Joi (µs)Ajv (µs)Zod (µs)Yup (µs)Validator.js (µs)Superstruct (µs)Class-validator (µs)
7001466.4119388.93442.441003.0278.12218.92728.75

image.png

总结

本文详细介绍了七种流行的参数验证库:Joi、Ajv、Zod、Yup、Validator.js、Superstruct和Class-validator,并比较了它们的优势、劣势及适用场景。

  • Joi:功能强大,适合复杂验证规则和嵌套对象验证,但体积较大,学习曲线较陡。
  • Ajv:性能高,支持JSON Schema标准,适合需要高性能和JSON Schema的场景,但错误信息不够友好。
  • Zod:TypeScript优先,类型推断优秀,API简洁,适合TypeScript项目,但功能相对较少。
  • Yup:API设计直观,易于使用,适合前端表单验证和简单易用的验证库,但性能稍逊于Ajv。
  • Validator.js:轻量级,支持多种常见验证,适合简单字符串验证的场景,但不支持嵌套对象验证。
  • Superstruct:轻量级,API简洁,支持嵌套对象验证,但功能相对较少,社区支持较少。
  • Class-validator:基于装饰器,适用于TypeScript类,支持复杂验证规则,但仅适用于TypeScript,学习曲线较陡。

通过综合实例展示了如何在HTTP请求中使用这些库进行参数验证,提供了具体的代码示例和测试结果。根据测试结果,各验证库在不同场景下表现出不同的优势和劣势,开发者可以根据项目需求选择合适的验证工具,确保应用程序的安全性、数据完整性和用户体验。