Отрадно, что сейчас, наконец, появился достойный претендент на место главного веб-фреймворка всех и вся — я имею в виду не 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 |
| 10 | 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 года

