API 在执行过程中的一个基本任务是数据验证。 在本文中,我想向你展示如何为你的数据添加防弹验证,同时返回风格良好的格式。

在 Node.js 中进行自定义数据验证既不容易也不快。 为了覆盖所有类型的数据,需要写许多函数。 虽然我已经尝试了一些 Node.js 的表单库 —— Express 和 Koa ——他们从未满足我的项目需求。 这些扩展库要么不兼容复杂的数据结构,要么在异步验证出现问题。

使用 Datalize 在 Node.js 中进行表单验证

这就是为什么我最终决定编写自己的小巧而强大的表单验证库的原因,它被称为 datalize。 它是可扩展的,因此你可以在任何项目中使用它,并根据你的要求进行自定义。 它能够验证请求的正文、查询或参数,还支持async 过滤器和复杂的JSON结构,如 数组嵌套对象

Github:https://github.com/flowstudio/datalize

配置

Datalize可以通过npm安装:

npm install --save datalize

要解析请求的正文,你应该使用其他的库。 如果你还没有用过,我建议使用 koa-body for Koa 或 body-parser for Express。

你可以将本教程用于已配置好的HTTP API服务器,也可以使用以下简单的Koa HTTP服务器代码。

const Koa = require('koa');
const bodyParser = require('koa-body');

const app = new Koa();
const router = new (require('koa-router'))();

// helper for returning errors in routes
app.context.error = function(code, obj) {
this.status = code;
this.body = obj;
};

// add koa-body middleware to parse JSON and form-data body
app.use(bodyParser({
enableTypes: ['json', 'form'],
multipart: true,
formidable: {
maxFileSize: 32 * 1024 * 1024,
}
}));

// Routes...

// connect defined routes as middleware to Koa
app.use(router.routes());
// our app will listen on port 3000
app.listen(3000);

console.log('🌍 API listening on 3000');

但是,这不是生产环境下的设置(你还应该使用 logging,强制 授权错误处理等),不过这几行代码用于向你正常展示后面的例子足够了。

注意:所有代码示例都基于 Koa,但数据验证代码也同样适用于 Express。 datalize 库还有一个实现 Express 表单验证的例子。

一个基本的Node.js表单验证案例

假设你的 API 中有一个 Koa 或 Express Web 写的服务和一个端点,用于在数据库中创建包含多个字段的用户数据。其中某些字段是必需的,有些字段只能具有特定值,或者必须格式化为正确的类型。

你可以像这样写一个简单的逻辑:

/**
 * @api {post} / Create a user
 * ...
 */
router.post('/', (ctx) => {
	const data = ctx.request.body;
	const errors = {};
	
	if (!String(data.name).trim()) {
		errors.name = ['Name is required'];
	}
	
	if (!(/^[\-0-9a-zA-Z\.\+_]+@[\-0-9a-zA-Z\.\+_]+\.[a-zA-Z]{2,}$/).test(String(data.email))) {
		errors.email = ['Email is not valid.'];
	}
	
	if (Object.keys(errors).length) {
		return ctx.error(400, {errors});
	}
	
	const user = await User.create({
			name: data.name,
			email: data.email,
	});
	
	ctx.body = user.toJSON();
});

下面让我们重写这段代码并使用 datalize 验证这个请求:

const datalize = require('datalize');
const field = datalize.field;

/**
 * @api {post} / Create a user
 * ...
 */
router.post('/', datalize([
	field('name').trim().required(),
	field('email').required().email(),
]), (ctx) => {
	if (!ctx.form.isValid) {
		return ctx.error(400, {errors: ctx.form.errors});
	}
	
	const user = await User.create(ctx.form);
	
	ctx.body = user.toJSON();
});

短小精悍并易于阅读。 使用 datalize,你可以指定字段列表,并为它们链接尽可能多的*规则*(用于判断输入是否有效并抛出错误的函数)或*过滤器*(用于格式化输入的函数)。

规则和过滤器的执行顺序与它们定义的顺序相同,所以如果你想要先切分含有空格的字符串,然后再检查它是否有值,则必须在 .trim() 之前定义 .required()

然后,Datalize 将只使用你指定的字段创建一个对象(在更广泛的上下文对象中以 .form 形式提供),因此你不必再次列出它们。 .form.isValid 属性会告诉你验证是否成功。

自动错误处理

如果我们不想检查表单是否对每个请求都有效,可以添加一个全局中间件,如果数据未通过验证,则取消请求。

为此,我们只需将这段代码添加到我们创建的 Koa / Express 应用实例的 bootstrap 文件中。

const datalize = require('datalize');

// set datalize to throw an error if validation fails
datalize.set('autoValidate', true);

// only Koa
// add to very beginning of Koa middleware chain
app.use(async (ctx, next) => {
	try {
		await next();
	} catch (err) {
		if (err instanceof datalize.Error) {
			ctx.status = 400;
			ctx.body = err.toJSON();
		} else {
			ctx.status = 500;
			ctx.body = 'Internal server error';
		}
	}
});


// only Express
// add to very end of Express middleware chain
app.use(function(err, req, res, next) {
	if (err instanceof datalize.Error) {
		res.status(400).send(err.toJSON());
	} else {
		res.send(500).send('Internal server error');
	}
});

而且我们不必检查数据是否有效,因为 datalize 将帮我们做到这些。 如果数据无效,它将返回带有无效字段列表的格式化错误消息。

查询验证

是的,你甚至可以非常轻松地验证查询参数——它不仅仅用于POST请求。 我们也可以只使用.query()辅助方法,唯一的区别是数据存储在 .data 对象而不是 .form 中。

const datalize = require('datalize');
const field = datalize.field;

/**
 * @api {get} / List users
 * ...
 */
router.post('/', datalize.query([
	field('keywords').trim(),
	field('page').default(1).number(),
	field('perPage').required().select([10, 30, 50]),
]), (ctx) => {
	const limit = ctx.data.perPage;
	const where = {
	};
	
	if (ctx.data.keywords) {
		where.name = {[Op.like]: ctx.data.keywords + '%'};
	}
	
	const users = await User.findAll({
		where,
		limit,
		offset: (ctx.data.page - 1) * limit,
	});
	
	ctx.body = users;
});

还有一个辅助方法用于参数验证:.params()。 通过在路由的 .post() 方法中传递两个 datalize 中间件,可以同时对查询和表单数据进行验证。

更多过滤器,数组和嵌套对象

到目前为止,我们在 Node.js 表单验证中使用了非常简单的数据。 现在让我们尝试一些更复杂的字段,如数组,嵌套对象等:

const datalize = require('datalize');
const field = datalize.field;
const DOMAIN_ERROR = "Email's domain does not have a valid MX (mail) entry in its DNS record";

/**
 * @api {post} / Create a user
 * ...
 */
router.post('/', datalize([
	field('name').trim().required(),
	field('email').required().email().custom((value) => {
		return new Promise((resolve, reject) => {
			dns.resolve(value.split('@')[1], 'MX', function(err, addresses) {
				if (err || !addresses || !addresses.length) {
					return reject(new Error(DOMAIN_ERROR));
				}
				
				resolve();
			});
		});
	}),
	field('type').required().select(['admin', 'user']),
	field('languages').array().container([
		field('id').required().id(),
		field('level').required().select(['beginner', 'intermediate', 'advanced'])
	]),
	field('groups').array().id(),
]), async (ctx) => {
	const {languages, groups} = ctx.form;
	delete ctx.form.languages;
	delete ctx.form.groups;
	
	const user = await User.create(ctx.form);
	
	await UserGroup.bulkCreate(groups.map(groupId => ({
		groupId,
		userId: user.id,
	})));
	
	await UserLanguage.bulkCreate(languages.map(item => ({
		languageId: item.id,
		userId: user.id,
		level: item.level,
	));
});

如果我们需要验证的数据没有内置规则,我们可以用 .custom() 方法创建一个自定义数据验证规则(很不错的名字,对吗?)并在那里编写必要的逻辑。 对于嵌套对象,有 .container() 方法,你可以在其中用和 datalize() 函数相同的方式指定字段列表。 你可以将容器嵌套在容器中,或使用 .array() 过滤器对其进行补充,这些过滤器会将值转换为数组。 如果在没有容器的情况下使用 .array() 过滤器,则指定的规则或过滤器将被用于数组中的每个值。

所以 .array().select(['read', 'write']) 将检查数组中的每个值是 'read' 还是 'write' ,如果有任何一个值不是其中之一,则返回所有错误的索引列表。 很酷,对吧?

PUT/PATCH

在使用 PUT/PATCH (或 POST)更新数据时,你不必重写所有逻辑、规则和过滤器。 只需添加一个额外的过滤器,如 .optional().patch() ,如果未在请求中定义,它将从上下文对象中删除任何字段。 ( .optional() 将使它始终是可选的,而 .patch() 只有在 HTTP 请求的方法是 PATCH 时才会使它成为可选项。)你可以添这个额外的过滤器,以便它可以在数据库中创建和更新数据。

const datalize = require('datalize');
const field = datalize.field;

const userValidator = datalize([
	field('name').patch().trim().required(),
	field('email').patch().required().email(),
	field('type').patch().required().select(['admin', 'user']),
]);

const userEditMiddleware = async (ctx, next) => {
	const user = await User.findByPk(ctx.params.id);
	
	// cancel request here if user was not found
	if (!user) {
		throw new Error('User was not found.');
	}
	
	// store user instance in the request so we can use it later
	ctx.user = user;
	
	return next();
};

/**
 * @api {post} / Create a user
 * ...
 */
router.post('/', userValidator, async (ctx) => {
	const user = await User.create(ctx.form);
	
	ctx.body = user.toJSON();
});

/**
 * @api {put} / Update a user
 * ...
 */
router.put('/:id', userEditMiddleware, userValidator, async (ctx) => {
	await ctx.user.update(ctx.form);
	
	ctx.body = ctx.user.toJSON();
});

/**
 * @api {patch} / Patch a user
 * ...
 */
router.patch('/:id', userEditMiddleware, userValidator, async (ctx) => {
	if (!Object.keys(ctx.form).length) {
		return ctx.error(400, {message: 'Nothing to update.'});
	}
	
	await ctx.user.update(ctx.form);
	
	ctx.body = ctx.user.toJSON();
});

使用两个简单的中间件,我们可以为所有 POST/PUT/PATCH 方法编写大多数逻辑。 userEditMiddleware() 函数验证我们要编辑的记录是否存在,否则便抛出错误。 然后 userValidator() 对所有端点进行验证。 最后 .patch() 过滤器将删除 .form 对象中的任何字段(如果其未定义)或者假如请求的方法是 PATCH 的话。

Node.js表单验证附加功能

在自定义过滤器中,你可以获取其他字段的值并根据该值执行验证。 还可以从上下文对象中获取任何数据,例如请求或用户信息,因为它们都是在自定义函数的回调参数中提供的。

该库涵盖了一组基本规则和过滤器,不过你可以注册能与任何字段一起使用的自定义全局过滤器,所以你不必一遍又一遍地写相同的代码:

const datalize = require('datalize');
const Field = datalize.Field;

Field.prototype.date = function(format = 'YYYY-MM-DD') {
  return this.add(function(value) {
    const date = value ? moment(value, format) : null;

    if (!date || !date.isValid()) {
      throw new Error('%s is not a valid date.');
    }

    return date.format(format);
  });
};

Field.prototype.dateTime = function(format = 'YYYY-MM-DD HH:mm') {
  return this.date(format);
};

有了这两个自定义过滤器,你就可以用 .date().dateTime() 过滤器链接字段对日期输入进行验证。

文件也可以使用 datalize 进行验证:只有 .file(), .mime(), 和 .size() 等文件才有特殊的过滤器,所以你不必单独处理文件。

立即开始编写更好的API

对于小型和大型API,我已经在好几个生产项目中用 datalize 进行 Node.js 表单验证。 这有助于我按时提供优秀项目、减轻开发压力,同时使其更具可读性和可维护性。 在一个项目中,我甚至用它来通过对 Socket.IO 进行简单封装,来验证 WebSocket 消息的数据,其用法与在 Koa 中的定义路由几乎完全相同,所以这很好用。 如果很多人有兴趣的话,我也可以为此编写一个教程。

我希望本教程能够帮助你在 Node.js 中构建更好的API,并使用经过完美验证的数据,而不会出现安全问题或内部服务器错误。 最重要的是,我希望它能为你节省大量时间,否则你将不得不用 JavaScript 投入大量时间来编写额外的函数进行表单验证。