Отрадно, что сейчас, наконец, появился достойный претендент на место главного веб-фреймворка всех и вся — я имею в виду не Fastify.js, а, конечно же, Nest.js. Хотя по количественным показателям популярности, до Express.js ему очень и очень далеко.
Таблица. Показатели популярности пакетов по данным npmjs.org, github.com
№ | Пакет | Количество загрузок | Количество «звезд» |
---|---|---|---|
1 | connect | 4 373 963 | 9 100 |
2 | express | 16 492 569 | 52 900 |
3 | koa | 844 877 | 31 100 |
4 | nestjs | 624 603 | 36 700 |
5 | hapi | 389 530 | 13 200 |
6 | fastify | 216 240 | 18 600 |
7 | restify | 93 665 | 10 100 |
8 | polka | 71 394 | 4 700 |
9 | loopback | 28 501 | 13 300 |
9 | Adonis.js | 3 825 | 10 100 |
Express.js по-прежнему работает в более чем в 2/3 веб-приложений для node.js. Более того, 2/3 наиболее популярных веб-фреймворков для node.js используют подходы Express.js. (Точнее было бы сказать, подходы библиотеки Connect.js, на которой до версии 4 базировался Express.js).
В предлагаемом сообщении обсуждаются особенности основных веб-фреймворков для node.js, и что делает Fastify.js фреймворком другого уровня, что позволяет выбрать его как фреймворк для разработки Вашего следующего проекта.
Критика фреймворков, основаных на синхронных middleware
Что же плохого может быть в таком коде?
app.get('/', (req, res) => {
res.send('Hello World!')
})
1. Функция, которая обрабатывает роут, не возвращает значение. Вместо этого необходимо вызвать один из методов объекта response (res). Если этот метод не будет вызван явно, даже после возврата из функции клиент и сервер останутся в состоянии ожидания ответа сервера пока для каждого из них не истечет таймаут. Это только «прямые убытки», но есть еще и «упущенная выгода». То что эта функция не возвращает значения, делает невозможным просто реализовать востребованную функциональность, например валидацию или логирование возвращаемых клиенту ответов.
2. В Express.js встроенная обработка ошибок всегда синхронная. Однако, редкий роут обходится без вызовов асинхронных операций. Так как Express.js создавался в допромисовую эру, стандартный обработчик синхронных ошибок для асинхронных ошибок не сработает, и асинхронные ошибки нужно обрабатывать так:
app.get('/', async (req, res, next) => {
try {
...
} catch (ex) {
next(ex);
}
})
или так:
app.get('/', (req, res, next) => {
doAsync().catch(next)
})
3. Сложность асинхронной инициализации сервисов. Например, приложение работает с базой данных и обращается к базе данных как к сервису, сохранив ссылку в переменной. Инициализация роутов в Express.js всегда синхронная. Это означает, что когда на роуты начнут приходить первые запросы клиентов, асинхронная инициализация сервиса, скорее всего, еще не успеет отработать, так что придется «тащить» в роуты асинхронный код с получением ссылки на этот сервис. Все это, конечно, реализуемо. Но слишком далеко уходит от наивной простоты изначального кода:
app.get('/', (req, res) => {
res.send('Hello World!')
})
4. Ну и наконец, последнее но немаловажное. В большинстве Express.js приложений работает примерно такой код:
app.use(someFuction);
app.use(anotherFunction());
app.use((req, res, next) => ..., next());
app.get('/', (req, res) => {
res.send('Hello World!')
})
Когда Вы разрабатываете свою часть приложения, то можете быть уверенным что до вашего кода уже успели отработать 10-20 middleware, которые вешают на объект req всевозможные свойства, и, даже, могут модифицировать исходный запрос, ровно как и в том что столько же если не больше middleware может быть добавлено после того, как вы разработаете свою часть приложения. Хотя, к слову сказать, в документации Express.js для навешивания дополнительных свойств неоднозначно рекомендуется объект res.locals:
// из документации Express.js
app.use(function (req, res, next) {
res.locals.user = req.user
res.locals.authenticated = !req.user.anonymous
next()
})
Исторические попытки преодоления недостатков Express.js
Неудивительно, что основной автор Express.js и Connect.js — TJ Holowaychuk — оставил проект, чтобы начать разработку нового фреймворка Koa.js. Koa.js добавляет асинхронность в Express.js. Например, такой код избавляет от необходимости перехватывать асинхронные ошибки в коде каждого роута и выносит обработчик в один middleware:
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// will only respond with JSON
ctx.status = err.statusCode || err.status || 500;
ctx.body = {
message: err.message
};
}
})
Первые версии Koa.js имели замысел внедрить генераторы для обработки асинхронных вызовов:
// from http://blog.stevensanderson.com/2013/12/21/experiments-with-koa-and-javascript-generators/
var request = Q.denodeify(require('request'));
// Example of calling library code that returns a promise
function doHttpRequest(url) {
return request(url).then(function(resultParams) {
// Extract just the response object
return resultParams[];
});
}
app.use(function *() {
// Example with a return value
var response = yield doHttpRequest('http://example.com/');
this.body = "Response length is " + response.body.length;
});
Внедрение async/await свело на нет полезность этой части Koa.js, и сейчас подобных примеров нет даже в документации фреймворка.
Почти ровесник Express.js — фреймворк Hapi.js. Контроллеры в Hapi.js уже возвращают значение, что является шагом вперед, по сравнению с Express.js. Не получив популярность сравнимую с Express.js, мега-успешной стала составная часть проекта Hapi.js — библиотека валидации Joi, которая имеет количество загрузок с npmjs.org 3 388 762, и сейчас используется как на бэкенде, так и на фронтенде. Поняв, что валидация входящих объектов — это не какой-то особый случай, а необходимый атрибут каждого приложения — валидация в Hapi.js была включена как составляющая часть фреймворка, и как параметр в определении роута:
server.route({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
},
options: {
validate: {
params: Joi.object({
name: Joi.string().min(3).max(10)
})
}
}
});
В настоящее время, библиотека Joi выделена в самостоятельный проект.
Если мы определили схему валидации объекта, тем самым мы определили и сам объект. Остается совсем немного, чтобы создать самодокументируемый роут, в котором изменение в схеме валидации данных приводит к изменению документации, и, таким образом, документация всегда соответствует программному коду.
На сегодняшний день, одно из лучших решений в документации API — swagger/openAPI. Было бы очень удачно, если бы схема, описания с учетом требований swagger/openAPI, могла быть использована и для валидации и для формирования документации.
Fastify.js
Подытожу те требования, который мне кажутся существенными при выборе веб-фреймворка:
- Наличие полноценных контроллеров (возвращаемое значение функции возвращается клиенту в теле ответа).
- Удобная обработка синхронных и асинхронных ошибок.
- Валидация входных параметров.
- Самодокументирование на основании определений роутов и схем валидации входных/выходных параметров.
- Инстанциирование асинхронных сервисов.
- Расширяемость.
Всем этим пунктам соответствует Nest.js, с которым я сейчас работаю на нескольких проектах. Особенностью Nest.js является широкое применение декораторов, что может быть в ряде случаев ограничением, если в технических требованиях указано использовать стандартный JavaScript (а как известно со стандартизацией декораторов в JavaScript ситуация застопорилась несколько лет назад, и, похоже, не скоро найдет свое разрешение).
Поэтому альтернативой может стать фреймворк Fastify.js, особенности применения которого я сейчас разберу.
Fastify.js поддерживает и привычный для разработчиков на Express.js стиль формирования ответа сервера, и более перспективный в форме возвращаемого значения функции, при этом оставляя возможность гибко манипулировать другими параметрами ответа (статусом, заголовками):
// Require the framework and instantiate it
const fastify = require('fastify')({
logger: true
})
// Declare a route
fastify.get('/', (request, reply) => {
reply.send({ hello: 'world' })
})
// Run the server!
fastify.listen(3000, (err, address) => {
if (err) throw err
// Server is now listening on ${address}
})
const fastify = require('fastify')({
logger: true
})
fastify.get('/', (request, reply) => {
reply.type('application/json').code(200)
return { hello: 'world' }
})
fastify.listen(3000, (err, address) => {
if (err) throw err
// Server is now listening on ${address}
})
Обработка ошибок может быть встроенной («из коробки») и кастомной.
const createError = require('fastify-error');
const CustomError = createError('403_ERROR', 'Message: ', 403);
function raiseAsyncError() {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new CustomError('Async Error')), 5000);
});
}
async function routes(fastify) {
fastify.get('/sync-error', async () => {
if (true) {
throw new CustomError('Sync Error');
}
return { hello: 'world' };
});
fastify.get('/async-error', async () => {
await raiseAsyncError();
return { hello: 'world' };
});
}
Оба варианта — и синхронный и асинхронный — отрабатывают одинаково встроенным обработчиком ошибок. Конечно, встроенных возможностей всегда мало. Кастомизируем обработчик ошибок:
fastify.setErrorHandler((error, request, reply) => {
console.log(error);
reply.status(error.status || 500).send(error);
});
fastify.get('/custom-error', () => {
if (true) {
throw { status: 419, data: { a: 1, b: 2} };
}
return { hello: 'world' };
});
Эта часть кода упрощена (ошибка выбрасывает литерал). Аналогично можно выбрасывать и кастомную ошибку. (Определение кастомных сериализуемых ошибок — это отдельная тема, поэтому пример не приводится).
Для валидации Fastify.js использует библиотеку Ajv.js, которая реализует интерфейс swagger/openAPI. Этот факт делает возможным интеграцию Fastify.js со swagger/openAPI и самодокументирование API.
По умолчанию валидация не самая строгая (поля опциональные и допускаются отсутствующие в схеме поля). Для того чтобы сделать валдиацию строгой необходимо определить параметры в конфигурации Ajv, и в схеме валидации:
const fastify = require('fastify')({
logger: true,
ajv: {
customOptions: {
removeAdditional: false,
useDefaults: true,
coerceTypes: true,
allErrors: true,
strictTypes: true,
nullable: true,
strictRequired: true,
},
plugins: [],
},
});
const opts = {
httpStatus: 201,
schema: {
description: 'post some data',
tags: ['test'],
summary: 'qwerty',
additionalProperties: false,
body: {
additionalProperties: false,
type: 'object',
required: ['someKey'],
properties: {
someKey: { type: 'string' },
someOtherKey: { type: 'number', minimum: 10 },
},
},
response: {
200: {
type: 'object',
additionalProperties: false,
required: ['hello'],
properties: {
value: { type: 'string' },
otherValue: { type: 'boolean' },
hello: { type: 'string' },
},
},
201: {
type: 'object',
additionalProperties: false,
required: ['hello-test'],
properties: {
value: { type: 'string' },
otherValue: { type: 'boolean' },
'hello-test': { type: 'string' },
},
},
},
},
};
fastify.post('/test', opts, async (req, res) => {
res.status(201);
return { hello: 'world' };
});
}
Поскольку схема входящих объектов уже определена, генерация документации swagger/openAPI сводится к инсталляции плагина:
fastify.register(require('fastify-swagger'), {
routePrefix: '/api-doc',
swagger: {
info: {
title: 'Test swagger',
description: 'testing the fastify swagger api',
version: '0.1.0',
},
securityDefinitions: {
apiKey: {
type: 'apiKey',
name: 'apiKey',
in: 'header',
},
},
host: 'localhost:3000',
schemes: ['http'],
consumes: ['application/json'],
produces: ['application/json'],
},
hideUntagged: true,
exposeRoute: true,
});
Валидация ответа также возможна. Для этого необходимо инсталлировать плагин:
fastify.register(require('fastify-response-validation'));
Валидация достаточно гибкая. Например ответ каждого статуса будет проверяться по своей схеме валидации.
Код связанный с написание статьи можно найти здесь.
Дополнительные источники информации
1. blog.stevensanderson.com/2013/12/21/experiments-with-koa-and-javascript-generators
2. habr.com/ru/company/dataart/blog/312638
apapacy@gmail.com
4 мая 2021 года
V1tol
Для Hapi есть плагин hapi-swagger, который именно это и делает.
apapacy Автор
Я предполагал что должно быть что то в этом роде. В конце концов есть просто библиотека joi-to-swagber которая делает это. Но остается у меня два вопроса. Насколько перекрываются спецификация swagger и api joi. И второе. Как там обстоит дело с описанием response.