Последние 10 лет среди веб-фреймворков для node.js самой большой популярностью пользуется Express.js. Всем, кто с ним работал, известно, что сложные приложения на Express.js бывает сложно структурировать. Но, как говорится, привычка — вторая натура. От Express.js бывает сложно отказаться. Как, например, сложно бросить курить. Кажется, что нам непременно нужна эта бесконечная цепь middleware, и если у нас забрать возможность создавать их по любому поводу и без повода — проект остановится.

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


Подытожу те требования, который мне кажутся существенными при выборе веб-фреймворка:

  1. Наличие полноценных контроллеров (возвращаемое значение функции возвращается клиенту в теле ответа).
  2. Удобная обработка синхронных и асинхронных ошибок.
  3. Валидация входных параметров.
  4. Самодокументирование на основании определений роутов и схем валидации входных/выходных параметров.
  5. Инстанциирование асинхронных сервисов.
  6. Расширяемость.

Всем этим пунктам соответствует 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 года