Перейти к содержанию

Изучение хуков

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

Даже если эта тема покажется вам сложной, цель последующих разделов — дать хорошее представление о том, как «думает» фреймворк Fastify, и сформировать правильную уверенность в использовании этих инструментов.

Однако прежде чем перейти к их детальному рассмотрению, нам необходимо ввести еще одну концепцию, которая делает все возможным. А именно, нам нужно узнать о жизненном цикле приложений Fastify.

В этой главе мы сосредоточимся на этих понятиях:

  • Что такое жизненный цикл?
  • Объявление хуков
  • Понимание жизненного цикла приложения
  • Понимание жизненного цикла запроса и ответа

Технические требования

Чтобы изучить эту главу, вам понадобится следующее:

  • Текстовый редактор, например VS Code
  • Рабочая установка Node.js v18
  • Доступ к оболочке, такой как Bash или CMD
  • Приложение командной строки curl.

Все фрагменты кода для этой главы доступны на GitHub.

Что такое жизненный цикл?

Говоря о жизненном цикле в контексте фреймворка, мы имеем в виду порядок выполнения функций. Сложность заключается в том, что разработчики приложения пишут только часть его кода, а остальное разрабатывается и компонуется разработчиками фреймворка.

Во время выполнения приложения вызовы функций перемещаются между внутренним и пользовательским кодом, и уследить за происходящим может быть непросто. Поэтому, естественно, в этот момент глубокие знания в этой области имеют решающее значение.

Жизненный цикл зависит от архитектуры фреймворка. Для разработки веб-фреймворка обычно используются две известные архитектуры:

  • Архитектура на основе промежуточного ПО: Благодаря более удобной кривой обучения, это самая известная архитектура жизненного цикла, но время показало, что она имеет ряд существенных недостатков. При работе с этой архитектурой разработчик приложения должен следить за порядком объявления функций промежуточного ПО, поскольку это влияет на порядок их выполнения во время исполнения. Конечно, в больших и сложных приложениях, состоящих из множества модулей и файлов, отследить порядок выполнения может быть сложно. Кроме того, написание многократно используемого кода может оказаться более сложной задачей, поскольку все движущиеся части более аккуратно связаны между собой. В заключение отметим, что каждая функция промежуточного ПО будет выполняться в каждом цикле, независимо от того, нужна она или нет.
  • Архитектура на основе хуков: Fastify, в отличие от некоторых других известных фреймворков, реализует архитектуру на основе хуков. Подобная архитектура была принята с самого начала, так как система на основе хуков более масштабируема и проще в обслуживании в долгосрочной перспективе. Приятным бонусом является то, что хук выполняется только в случае необходимости, что обычно приводит к ускорению работы приложений! Однако стоит отметить, что ее также сложнее освоить. Как мы уже вкратце упоминали, в архитектуре на основе хуков нам приходится иметь дело с различными и специфическими компонентами.

В оставшейся части этой главы мы подробно поговорим об архитектуре на основе хуков.

Когда речь идет о веб-фреймворках, обычно выделяют как минимум два основных жизненных цикла:

  • Жизненный цикл приложения: Этот жизненный цикл отвечает за различные фазы выполнения сервера приложений. В основном он занимается последовательностью загрузки и выключения сервера. Мы можем подключать «глобальные» функции, которые являются общими для каждого маршрута и плагина. Кроме того, мы можем действовать в определенный момент выполнения жизненного цикла, добавив соответствующую функцию хука. Чаще всего мы выполняем действия после запуска сервера или перед его выключением.
  • Жизненный цикл запроса/ответа: Фаза запроса/ответа является ядром каждого клиент-серверного приложения. Почти все время выполнения проходит в этой единственной последовательности. По этой причине данный жизненный цикл обычно имеет гораздо больше фаз и, соответственно, хуков, которые мы можем добавить к нему. Самые распространенные из них — парсеры контента, сериализаторы и хуки авторизации.

Теперь, когда мы больше знаем о типах жизненного цикла, мы можем потратить остаток главы на изучение того, как Fastify реализует их.

Объявление хуков

В предыдущем разделе мы увидели, что серверное приложение обычно имеет два основных жизненных цикла. Так вот, будучи веб-фреймворком на основе хуков и следуя своей философии предоставления полного контроля разработчикам, Fastify выдает определенное событие каждый раз, когда переходит к следующей фазе. Более того, эти фазы следуют жесткому и четко определенному порядку выполнения. Знание этого позволяет нам добавлять функциональность в определенный момент во время загрузки или выполнения приложения.

Одним из важных и полезных побочных эффектов такого подхода является то, что нам, как разработчикам, не важен порядок объявления наших хуков, поскольку фреймворк гарантирует, что они будут вызваны в нужный момент времени.

Описанный механизм работает потому, что Fastify под капотом определяет «бегунок хуков», который запускает колбек-функции, объявленные для каждого известного события. Как разработчикам, нам нужен метод, чтобы прикрепить наши хуки к экземпляру Fastify. Хук addHook позволяет нам сделать именно это, помимо того, что он является хуком приложения или запроса/ответа.

Хуки на основе обратного вызова против асинхронных хуков

В главе 2 мы видели, что можно объявлять плагины в двух разных стилях: с колбек-функциями и с асинхронными функциями. То же самое применимо и здесь. Опять же, важно выбрать один стиль и придерживаться его. Их смешение может привести к неожиданному поведению. Как уже было решено в этой книге, мы будем использовать только async-функции. И последнее, что следует помнить: некоторые хуки работают только синхронно. Мы уточним это, когда будем говорить о них.

Как видно из следующего фрагмента метода add-hook.cjs, он принимает два аргумента:

  • Имя события, которое мы хотим прослушать
  • колбек-функция:

    1
    2
    3
    // ...
    fastify.addHook('onRequest', (...) => {})
    // ...
    

Здесь мы опустили сигнатуру обратного вызова и возвращаемое значение, поскольку у каждого хука оно свое.

Мы можем вызвать addHook на одно и то же событие несколько раз, чтобы объявить более одного хука. В следующих разделах мы узнаем обо всех событиях, испускаемых Fastify, и подробно опишем каждую колбек-функцию.

Понимание жизненного цикла приложения

Жизненный цикл приложения охватывает процесс загрузки и выполнения нашего сервера приложений. В частности, мы имеем в виду загрузку плагинов, добавление маршрутов, запуск HTTP-сервера и, в конечном итоге, его закрытие. Fastify будет испускать четыре различных события, позволяя нам взаимодействовать с поведением каждой фазы:

  • onRoute
  • onRegister
  • onReady
  • onClose

Теперь для каждого события из предыдущего списка проверим соответствующую сигнатуру обратного вызова и наиболее распространенные случаи использования.

Хук onRoute

Событие хука onRoute запускается каждый раз, когда к экземпляру Fastify добавляется маршрут. Этот колбек-функция представляет собой синхронную функцию, которая принимает один аргумент, обычно называемый routeOptions. Этот аргумент представляет собой изменяемую ссылку на объект объявления маршрута, и мы можем использовать его для изменения свойств маршрута.

Этот хук инкапсулирован и не возвращает никакого значения. Поэтому одним из наиболее распространенных вариантов его использования является добавление хуков на уровне маршрута или проверка наличия определенного значения опции и соответствующие действия.

Тривиальный пример мы можем увидеть в файле on-route.cjs, где мы добавляем в свойства маршрута пользовательскую функцию preHandler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onRoute', (routeOptions) => {
    // [1]
    async function customPreHandler(request, reply) {
        request.log.info('Hi from customPreHandler!');
    }
    app.log.info('Adding custom preHandler to the route.');
    routeOptions.preHandler = [
        ...(routeOptions.preHandler ?? []),
        customPreHandler,
    ]; // [2]
});
app.route({
    // [3]
    url: '/foo',
    method: 'GET',
    schema: {
        200: {
            type: 'object',
            properties: {
                foo: {
                    type: 'string',
                },
            },
        },
    },
    handler: (req, reply) => {
        reply.send({ foo: 'bar' });
    },
});
app.listen({ port: 3000 })
    .then(() => {
        app.log.info('Application is listening.');
    })
    .catch((err) => {
        app.log.error(err);
        process.exit();
    });

После настройки экземпляра Fastify мы добавляем новый хук onRoute ([1]). Единственная цель этого хука — добавить хук preHandler уровня маршрута ([2]) в определение маршрута, даже если ранее для этого маршрута не было определено никаких хуков ([3]).

Здесь мы можем узнать два важных результата, которые помогут нам при работе с хуками на уровне маршрута:

  • Объект routeOptions является мутабельным, и мы можем изменять его свойства. Однако если мы хотим сохранить прежние значения, нам нужно явно добавить их заново ([2]).
  • Хуки на уровне маршрута представляют собой массивы хук-функций (подробнее об этом мы поговорим позже в главе).

Теперь мы можем проверить вывод фрагмента, открыв новый терминал и выполнив следующую команду:

1
$> node on-route.cjs

Эта команда запустит наш сервер на порту 3000.

Теперь мы можем использовать curl в другом окне терминала, чтобы запросить сервер и проверить результат в консоли сервера:

1
$> curl localhost:3000/foo

Мы можем найти сообщение "Привет от customPreHandler!" в логах, чтобы проверить, был ли выполнен наш customHandler:

1
2
{"level":30,"time":1635765353312,"pid":20344,"hostname":"localhost","r
eqId":"req-1","msg":"Hi from customPreHandler!"}

Этот пример лишь поверхностно описывает возможные варианты использования. Полное определение свойств routeOptions можно найти в официальной документации (https://www.fastify.io/docs/latest/Reference/Routes/#routes-options).

Хук onRegister

Хук onRegister похож на предыдущий по принципу работы, но мы можем использовать его при работе с плагинами. Фактически, для каждого зарегистрированного плагина, который создает новый контекст инкапсуляции, хук onRegister выполняется до регистрации.

Мы можем использовать этот хук для обнаружения момента создания нового контекста и добавления или удаления функциональности; как мы уже узнали, во время регистрации плагина и благодаря надежной инкапсуляции Fastify создает новый экземпляр с дочерним контекстом. Обратите внимание, что колбек-функция этого хука не будет вызвана, если зарегистрированный плагин обернут в fastify-plugin.

Хук onRegister принимает синхронный обратный вызов с двумя аргументами. Первый параметр — это только что созданный экземпляр Fastify с инкапсулированным контекстом. Второй — объект options, переданный плагину при регистрации.

Следующий фрагмент on-register.cjs показывает простой, но нетривиальный пример, который охватывает случаи использования инкапсулированных и неинкапсулированных плагинов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const Fastify = require('fastify');
const fp = require('fastify-plugin');
const app = Fastify({ logger: true });
app.decorate('data', { foo: 'bar' }); // [1]
app.addHook('onRegister', (instance, options) => {
    app.log.info({ options });
    instance.data = { ...instance.data }; // [2]
});
app.register(
    async function plugin1(instance, options) {
        instance.data.plugin1 = 'hi'; // [3]
        instance.log.info({ data: instance.data });
    },
    { name: 'plugin1' }
);
app.register(
    fp(async function plugin2(instance, options) {
        instance.data.plugin2 = 'hi2'; // [4]
        instance.log.info({ data: instance.data });
    }),
    { name: 'plugin2' }
);
app.ready()
    .then(() => {
        app.log.info('Application is ready.');
        app.log.info({ data: app.data }); // [5]
    })
    .catch((err) => {
        app.log.error(err);
        process.exit();
    });

Сначала мы декорируем экземпляр Fastify верхнего уровня пользовательским объектом данных ([1]). Затем мы подключаем хук onRegister, который регистрирует плагин options и неглубоко копирует объект data ([2]). Это фактически создаст новый объект, наследующий свойство foo, что позволит нам инкапсулировать объект data. В [3] мы регистрируем наш первый плагин, который добавляет свойство plugin1 к объекту. С другой стороны, второй плагин зарегистрирован с помощью fastify-plugin ([4]), и поэтому Fastify не сработает наш хук onRegister. Здесь мы снова модифицируем объект data, добавив в него свойство plugin2.

Неглубокое копирование против глубокого копирования

Поскольку в JavaScript объект — это просто ссылка на выделенный адрес памяти, мы можем копировать объекты двумя разными способами. По умолчанию мы «неглубоко копируем» их: если свойство одного исходного объекта ссылается на другой объект, скопированное свойство будет указывать на тот же адрес памяти. Мы неявно создаем связь между старым и новым свойством. Если мы изменяем его в одном месте, это отражается и в другом. С другой стороны, глубокое копирование объекта означает, что всякий раз, когда свойство ссылается на другой объект, мы будем создавать новую ссылку и, следовательно, выделять память. Поскольку глубокое копирование требует больших затрат, все методы и операторы, входящие в состав JavaScript, создают неглубокие копии.

Давайте выполним этот скрипт в окне терминала и проверим журналы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ node on-register.cjs
{"level":30,"time":1636192862276,"pid":13381,"hostname":"localhost",
"options":{"name":"plugin1"}}
{"level":30,"time":1636192862276,"pid":13381,"hostname":"localhost",
"data":{"foo":"bar","plugin1":"hi"}}
{"level":30,"time":1636192862277,"pid":13381,"hostname":"localhost",
"data":{"foo":"bar","plugin2":"hi2"}}
{"level":30,"time":1636192862284,"pid":13381,"hostname":"localhost",
"msg":"Application is ready."}
{"level":30,"time":1636192862284,"pid":13381,"hostname":"localhost",
"data":{"foo":"bar","plugin2":"hi2"}}

Мы видим, что добавление свойства в plugin1 никак не отразилось на свойстве данных верхнего уровня. С другой стороны, поскольку plugin2 загружается с помощью fastify-plugin, он имеет тот же контекст, что и основной экземпляр Fastify ([5]), и хук onRegister даже не вызывается.

Хук onReady

Хук onReady срабатывает после вызова fastify.ready() и до того, как сервер начнет слушать. Если вызов ready опущен, то listen вызовет его автоматически, и эти хуки будут выполнены в любом случае. Поскольку мы можем определить несколько хуков onReady, сервер будет готов к приему входящих запросов только после того, как все они будут выполнены.

В отличие от двух других хуков, которые мы уже рассмотрели, этот является асинхронным. Поэтому очень важно определить его как асинхронную функцию или вручную вызвать колбек-функцию done для продолжения загрузки сервера и выполнения кода. Кроме того, хук onReady вызывается со значением this, привязанным к экземпляру Fastify.

Связанный контекст

При работе с функциями Fastify, которые имеют явно привязанное значение this, как в случае с хуком onReady, необходимо использовать старый синтаксис функции вместо синтаксиса стрелочной функции. Использование последней предотвратит привязку, что сделает невозможным доступ к экземпляру и добавленным к нему пользовательским данным.

В фрагменте on-ready.cjs мы показываем прямой пример связанного контекста Fastify:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.decorate('data', 'mydata'); // [1]
app.addHook('onReady', async function () {
    // [2]
    app.log.info(this.data);
});
app.ready()
    .then(() => {
        app.log.info('Application is ready.');
    })
    .catch((err) => {
        app.log.error(err);
        process.exit();
    });

В пункте [1] мы декорируем первичный экземпляр фиктивным значением. Затем мы добавляем один хук onReady, используя синтаксис функции async. После этого мы записываем в лог значение data, чтобы показать связанное внутри него значение ([2]).

Запустив этот сниппет, мы получим короткий результат:

1
2
3
4
{"level":30,"time":1636284966854,"pid":3660,"hostname":"localhost",
"msg":"mydata"}
{"level":30,"time":1636284966855,"pid":3660,"hostname":" localhost ",
"msg":"Application is ready."}

Мы можем проверить, что mydata регистрируется до того, как приложение будет готово, и что, действительно, у нас есть доступ к экземпляру Fastify через this.

Хук onClose

В то время как хуки, о которых мы узнали в предыдущих трех разделах, используются в процессе загрузки, onClose срабатывает на этапе выключения сразу после вызова fastify.close(). Таким образом, он удобен, когда плагинам нужно сделать что-то непосредственно перед остановкой сервера, например, очистить соединения с базой данных. Этот хук асинхронный и принимает один аргумент — экземпляр Fastify. Как обычно, при работе с асинхронными функциями, есть необязательный колбек-функция done (второй аргумент), если асинхронная функция не используется.

В примере on-close.cjs мы решили использовать асинхронную функцию для записи сообщения в журнал:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onClose', async (instance) => {
    // [1]
    instance.log.info('onClose hook triggered!');
});
app.ready()
    .then(async () => {
        app.log.info('Application is ready.');
        await app.close(); // [2]
    })
    .catch((err) => {
        app.log.error(err);
        process.exit();
    });

Добавив фиктивный хук onClose ([1]), мы явно вызываем app.close() на [2], чтобы запустить его.

После выполнения примера мы видим, что последнее, что записывается в журнал, действительно является строкой хука:

1
2
3
4
{"level":30,"time":1636298033958,"pid":4257,"hostname":"localhost",
"msg":"Application is ready."}
{"level":30,"time":1636298033959,"pid":4257,"hostname":"localhost ",
"msg":"onClose hook triggered!"}

С хуком onClose мы закончили разговор о жизненном цикле на уровне приложения. Теперь мы перейдем к более многочисленным и, следовательно, захватывающим хукам запроса-ответа.

Понимание жизненного цикла запроса и ответа

При выполнении серверного приложения Fastify подавляющее большинство времени проходит в цикле «запрос-ответ». Как разработчики, мы определяем маршруты, которые будут вызываться клиентами и выдавать ответ на основе входящих условий. В соответствии с философией Fastify, в нашем распоряжении есть несколько событий для взаимодействия с этим циклом. Как обычно, они будут автоматически запускаться фреймворком только при необходимости. Эти хуки полностью инкапсулированы, так что мы можем контролировать контекст их выполнения с помощью метода register.

Как мы видели в предыдущем разделе, у нас было четыре хука для приложений. Здесь у нас девять хуков для запросов и ответов:

  • onRequest
  • preParsing
  • preValidation
  • preHandler
  • preSerialization
  • onSend
  • onResponse
  • onError
  • onTimeout

Поскольку они являются частью цикла запрос/ответ, порядок срабатывания этих событий имеет решающее значение. Поэтому первые семь элементов списка записываются от первого к последнему. Более того, события onError и onTimeout могут срабатывать без определенного порядка на каждом шаге цикла, поскольку ошибка или таймаут могут произойти в любой момент.

Давайте посмотрим на следующее изображение, чтобы лучше понять жизненный цикл запроса/ответа Fastify:

Рисунок 4.1: Жизненный цикл запроса и ответа

Рисунок 4.1: Жизненный цикл запроса и ответа
.

Для наглядности мы разделили хуки на группы с помощью пузырьков. Как мы уже говорили, существует три основные группы.

Внутри пунктирного пузыря мы видим фазу запроса. Эти хуки обратного вызова вызываются в порядке сверху вниз, следуя направлению стрелки, перед пользовательским обработчиком для текущего маршрута.

Пунктирный пузырь содержит фазу ответа, хуки которой вызываются после определенного пользователем обработчика маршрута. Каждый хук в любой момент может выдать ошибку или перейти в таймаут. Если это произойдет, запрос завершится в сплошном пузыре, оставив нормальный поток.

Мы узнаем больше о хуках запросов и ответов в последующих разделах, начиная с обработки ошибок.

Обработка ошибок внутри хуков

Во время выполнения хука может произойти ошибка. Поскольку хуки — это просто асинхронные колбек-функции, автоматически вызываемые фреймворком, обработка ошибок происходит по тем же правилам, что и для стандартных функций JavaScript. Опять же, единственное основное различие заключается в двух стилях, определяющих их.

Если мы выберем стиль колбек-функции (помните, что в этой книге мы используем только стиль асинхронной функции), то нам придется вручную передать ошибку в выполненную колбек-функцию:

1
2
3
fastify.addHook('onRequest', (request, reply, done) => {
    done(new Error('onRequest error'));
});

С другой стороны, при объявлении async-функции достаточно бросить ошибку:

1
2
3
fastify.addHook('onResponse', async (request, reply) => {
    throw new Error('Some error');
});

Поскольку у нас есть доступ к объекту ответа, мы также можем изменить код ответа и ответить клиенту прямо из хука. Если мы решили не отвечать в случае ошибки или ответить с ошибкой, то Fastify вызовет хук onError.

Теперь, когда мы понимаем, как ошибки изменяют поток запрос-ответ, мы можем наконец-то начать анализировать каждый хук, который Fastify предоставляет в наше распоряжение.

Хук onRequest

Как следует из названия, хук onRequest срабатывает каждый раз, когда поступает входящий запрос. Поскольку это первое событие в списке выполнения, запрос body всегда равен нулю, так как разбор тела еще не произошел. Функция хука является асинхронной и принимает два параметра — объекты Fastify Request и Reply.

Помимо демонстрации работы onRequest, следующий фрагмент on-request.cjs также показывает, как работает инкапсуляция хука (как мы уже говорили, инкапсуляция применима и к любому другому хуку):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onRequest', async (request, reply) => {
    // [1]
    request.log.info(
        'Hi from the top-level onRequest hook.'
    );
});
app.register(async function plugin1(instance, options) {
    instance.addHook(
        'onRequest',
        async (request, reply) => {
            // [2]
            request.log.info(
                'Hi from the child-level onRequest hook.'
            );
        }
    );
    instance.get(
        '/child-level',
        async (_request, _reply) => {
            // [3]
            return 'child-level';
        }
    );
});
app.get('/top-level', async (_request, _reply) => {
    // [4]
    return 'top-level';
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

Первое, что мы делаем, — добавляем хук верхнего уровня onRequest ([1]). Затем мы регистрируем плагин, определяющий другой хук onRequest ([2]) и маршрут GET /child-level ([3]). Наконец, мы добавляем еще один GET-маршрут по пути /top-level ([4]).

Запустим скрипт в терминале и проверим результат:

1
2
3
$ node on-request.cjs
{"level":30,"time":1636444514061,"pid":30137,"hostname":
"localhost","msg":"Server listening at http://127.0.0.1:3000"}

На этот раз наш сервер работает на порту 3000, и он ждет входящих соединений.

Мы можем открыть другой терминал и использовать curl для осуществления наших вызовов.

Прежде всего, давайте получим маршрут /child-level:

1
2
$ curl localhost:3000/child-level
child-level

Мы видим, что curl смог корректно получить ответ маршрута. Снова переключив окно терминала на то, в котором запущен сервер, мы можем проверить журналы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{"level":30,"time":1636444712019,"pid":30137,"hostname":"localhost",
"reqId":"req-1","req":{"method":"GET","url":"/child-level","hostname":
"localhost:3000","remoteAddress":"127.0.0.1","remotePort":56903},
"msg":"incoming request"}
{"level":30,"time":1636444712021,"pid":30137,"hostname":" localhost ",
"reqId":"req-1","msg":"Hi from the top-level onRequest hook."}
{"level":30,"time":1636444712023,"pid":30137,"hostname":" localhost ",
"reqId":"req-1","msg":"Hi from the child-level onRequest hook."}
{"level":30,"time":1636444712037,"pid":30137,"hostname":" localhost ",
"reqId":"req-1","res":{"statusCode":200},"responseTime"
:16.760624945163727,"msg":"request completed"}

Мы сразу видим, что оба хука были вызваны во время цикла «запрос-ответ». Более того, мы также видим порядок вызовов: сначала «Привет от хука верхнего уровня onRequest», а затем «Привет от хука дочернего уровня onRequest».

Чтобы убедиться, что инкапсуляция хуков работает как надо, вызовем маршрут /top-level. Мы можем снова переключиться на терминал, где был запущен curl, и ввести следующую команду:

1
2
$ curl localhost:3000/top-level
top-level

Теперь вернемся к терминалу вывода журнала сервера и увидим следующее:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{"level":30,"time":1636444957207,"pid":30137,"hostname":"
localhost","reqId":"req-2","req":{"method":"GET","url":"/
top-level","hostname":"localhost:3000","remoteAddress":"127.0.0.1",
"remotePort":56910},"msg":"incoming request"}
{"level":30,"time":1636444957208,"pid":30137,"hostname":
" localhost ","reqId":"req-2","msg":"Hi from the top-level onRequest
hook."}
{"level":30,"time":1636444957210,"pid":30137,"hostname":" localhost
","reqId":"req-2","res":{"statusCode":200},"responseTime":
1.8535420298576355,"msg":"request completed"}

На этот раз в логах видно, что сработал только хук верхнего уровня. Это ожидаемое поведение, и мы можем использовать его для добавления хуков, предназначенных только для определенных плагинов.

Хук preParsing

Объявление хука preParsing позволяет нам преобразовывать полезную нагрузку входящего запроса до того, как он будет разобран. Этот обратный вызов является асинхронным и принимает три параметра: Request, Reply и поток полезной нагрузки. Опять же, body запроса является нулевым, так как этот хук срабатывает до preValidation. Поэтому мы должны вернуть поток, если хотим изменить входящую полезную нагрузку. Более того, разработчики также отвечают за добавление и обновление свойства receivedEncodedLength возвращаемого значения.

Пример pre-parsing.cjs показывает, как изменить входящую полезную нагрузку, работая непосредственно с потоками:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const Fastify = require('fastify');
const { Readable } = require('stream');
const app = Fastify({ logger: true });
app.addHook(
    'preParsing',
    async (request, _reply, payload) => {
        let body = '';
        for await (const chunk of payload) {
            // [1]
            body += chunk;
        }
        request.log.info(JSON.parse(body)); // [2]
        const newPayload = new Readable(); // [3]
        newPayload.receivedEncodedLength = parseInt(
            request.headers['content-length'],
            10
        );
        newPayload.push(
            JSON.stringify({ changed: 'payload' })
        );
        newPayload.push(null);
        return newPayload;
    }
);
app.post('/', (request, _reply) => {
    request.log.info(request.body); //[4]
    return 'done';
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

В [1] мы объявляем наш хук preParsing, который потребляет входящий поток payload и создает строку body. Затем мы разбираем это тело ([2]) и выводим содержимое в консоль. В пункте [3] мы создаем новый поток Readable, присваиваем ему правильное значение receivedEncodedLength, заталкиваем в него новое содержимое и возвращаем его. Наконец, мы объявляем фиктивный маршрут ([4]) для регистрации объекта body.

Запуск скрипта в терминале запустит наш сервер на порту 3000:

1
2
3
$ node pre-parsing.cjs
{"level":30,"time":1636532643143,"pid":38684,"hostname":
"localhost","msg":"Server listening at http://127.0.0.1:3000"}

Теперь в другом окне терминала мы можем использовать curl для вызова маршрута и проверки журналов:

1
2
3
4
$ curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"test":"payload"}' localhost:3000
done

Вернувшись на серверный терминал, мы видим следующий вывод:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{"level":30,"time":1636534552994,"pid":39232,"hostname":"localhost",
"reqId":"req-1","req":{"method":"POST","url":"/","hostname":
"localhost:3000","remoteAddress":"127.0.0.1","remotePort":58837},
"msg":"incoming request"}
{"level":30,"time":1636534553005,"pid":39232,"hostname":" localhost
","reqId":"req-1","test":"payload"}
{"level":30,"time":1636534553010,"pid":39232,"hostname":" localhost
","reqId":"req-1","changed":"payload"}
{"level":30,"time":1636534553018,"pid":39232,"hostname":" localhost
","reqId":"req-1","res":{"statusCode":200},"responseTime":23.695625007
152557,"msg":"request completed"}

Эти журналы показывают, как изменилась полезная нагрузка после вызова preParsing. Теперь, если вернуться к фрагменту pre-parsing.cjs, первый вызов логгера ([2]) регистрирует исходное тело, которое мы отправили из curl, а второй вызов ([3]) регистрирует содержимое newPayload.

Хук preValidation

Мы можем использовать хук preValidation для изменения входящей полезной нагрузки до того, как она будет проверена. Поскольку парсинг уже произошел, у нас наконец-то есть доступ к body запроса, который мы можем изменять напрямую.

Хук получает два аргумента, request и reply. В фрагменте pre-validation.cjs видно, что обратный вызов является асинхронным и не возвращает никакого значения:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('preValidation', async (request, _reply) => {
    request.body = {
        ...request.body,
        preValidation: 'added',
    };
});
app.post('/', (request, _reply) => {
    request.log.info(request.body);
    return 'done';
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

В примере добавлен простой хук preValidation, который изменяет разобранный объект body. Внутри хука мы используем оператор spread для добавления свойства к телу, а затем снова присваиваем новое значение свойству request.body.

Как обычно, мы можем запустить наш сервер в окне терминала:

1
2
3
$ node pre-validation.cjs
{"level":30,"time":1636538075248,"pid":39965,"hostname":"localhost",
"msg":"Server listening at http://127.0.0.1:3000"}

Затем, открыв второй терминал, мы можем сделать тот же вызов, что и для хука preParsing:

1
2
3
$ curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"test":"payload"}' localhost:3000

В выводе сервера мы видим, что наша полезная нагрузка изменилась:

1
2
3
4
5
6
7
8
9
"level":30,"time":1636538082315,"pid":39965,"hostname":"localhost","r
eqId":"req-1","req":{"method":"POST","url":"/","hostname":"localhost:
3000","remoteAddress":"127.0.0.1","remotePort":59225},"msg":"incoming
request"}
{"level":30,"time":1636538082326,"pid":39965,"hostname":" localhost
","reqId":"req-1","test":"payload","preValidation":"added"}
{"level":30,"time":1636538082338,"pid":39965,"hostname":" localhost
","reqId":"req-1","res":{"statusCode":200},"responseTime":22.288374960
422516,"msg":"request completed"}

Хук preHandler

Хук preHandlder — это async-функция, которая получает запрос и ответ в качестве аргументов и является последней колбек-функцией, вызываемой перед обработчиком маршрута. Поэтому на данном этапе выполнения объект request.body полностью разобран и проверен. Однако, как видно из примера pre-handler.cjs, мы все еще можем изменять значения тела или запроса с помощью этого хука, чтобы выполнить дополнительные проверки или манипуляции с запросом перед выполнением обработчика:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('preHandler', async (request, reply) => {
    request.body = { ...request.body, prehandler: 'added' };
    request.query = {
        ...request.query,
        prehandler: 'added',
    };
});
app.post('/', (request, _reply) => {
    request.log.info({ body: request.body });
    request.log.info({ query: request.query });
    return 'done';
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

Обычно этот хук наиболее часто используется разработчиками, но это не так. Чаще всего другие хуки, которые идут перед preHandler, лучше подходят для подавляющего большинства целей. Например, часто тело запроса не нуждается в полном разборе и проверке перед выполнением наших действий с входящим запросом. Вместо этого нам следует использовать preHandler только при обращении к проверенным свойствам тела или манипулировании ими.

Поскольку этот последний фрагмент не добавляет ничего нового, мы опустим результаты его выполнения. При необходимости, те же шаги, которые мы использовали для запуска pre-validation.cjs, могут быть использованы и здесь.

Хук preSerialization

Хук preSerialization входит в группу из трех хуков, которые вызываются после обработчика маршрута. Два других — это onSend и onResponse, и мы рассмотрим их в следующих разделах.

Здесь же сосредоточимся на первом. Поскольку мы имеем дело с полезной нагрузкой ответа, хук preSerialization имеет сигнатуру, схожую с сигнатурой хука preParsing. Он принимает объект request, объект reply и третий параметр полезной нагрузки. Мы можем использовать его возвращаемое значение для изменения или замены объекта ответа перед сериализацией и отправкой клиентам.

Об этом хуке нужно помнить две важные вещи:

  • Он не вызывается, если аргументом полезной нагрузки является string, Buffer, stream или null.
  • Если мы изменим полезную нагрузку, она будет изменена для каждого ответа, включая ошибочные.

Следующий пример pre-serialization.cjs показывает, как мы можем добавить этот хук в экземпляр Fastify:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook(
    'preSerialization',
    async (request, reply, payload) => {
        return { ...payload, preSerialization: 'added' };
    }
);
app.get('/', (request, _reply) => {
    return { foo: 'bar' };
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

Внутри хука мы используем оператор spread, чтобы скопировать исходную полезную нагрузку, а затем добавить новое свойство. На этом этапе остается только вернуть этот вновь созданный объект, чтобы изменить тело перед тем, как вернуть его клиенту.

Давайте запустим фрагмент в терминале:

1
2
3
$ node pre-serialization.cjs
{"level":30,"time":1636709732482,"pid":60009,"hostname":"localhost",
"msg":"Server listening at http://127.0.0.1:3000"}

Теперь, как обычно, в другом окне терминала мы можем использовать curl для выполнения нашего обращения к серверу:

1
2
$ curl localhost:3000
{"foo":"bar","preSerialization":"added"}

Хук onSend

Хук onSend — это последний хук, вызываемый перед ответом клиенту. В отличие от preSerialization, хук onSend получает полезную нагрузку, которая уже сериализована. Более того, он вызывается всегда, независимо от типа полезной нагрузки ответа. Даже если это сложнее сделать, мы можем использовать этот хук и для изменения нашего ответа, но на этот раз мы должны вернуть одно из string, Buffer, stream или null. Наконец, сигнатура идентична сигнатуре preSerialization.

Давайте разберем пример в фрагменте on-send.cjs с самой простой полезной нагрузкой — строкой:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onSend', async (request, reply, payload) => {
    const newPayload = payload.replace('foo', 'onSend');
    return newPayload;
});
app.get('/', (request, _reply) => {
    return { foo: 'bar' };
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

Поскольку полезная нагрузка уже сериализована как string, мы можем использовать метод replace для ее модификации перед возвратом клиенту.

Мы можем использовать тот же метод curl, который мы уже знаем, чтобы проверить, заменено ли возвращаемое содержимое foo в onSend.

Хук onResponse

Хук onResponse является последним хуком в жизненном цикле запроса-ответа. Этот обратный вызов происходит после того, как ответ уже отправлен клиенту. Поэтому мы уже не можем изменить полезную нагрузку. Однако мы можем использовать его для выполнения дополнительной логики, например, вызова внешних сервисов или сбора метрик. Он принимает два аргумента, request и reply, и не возвращает никакого значения. Конкретный пример мы можем увидеть в файле on-response.cjs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onResponse', async (request, reply) => {
    request.log.info('onResponse hook'); // [1]
});
app.get('/', (request, _reply) => {
    return { foo: 'bar' };
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

Пример, опять же, прост. Мы добавляем фиктивный хук onResponse, который печатает строку в лог. Запустив его обычным способом, мы увидим журнал.

С другой стороны, поскольку внутри фрагмента on-response.cjs мы немного изменили код и заставили хук вызывать reply.send(), мы получили совсем другой результат:

1
2
3
app.addHook('onResponse', async (request, reply) => {
    reply.send('onResponse');
});

Даже если клиент получит ответ правильно, наш сервер выбросит ошибку "Reply was already sent.". Ошибка не влияет на цикл запрос-ответ, и она только выводится на экран. Как обычно, мы можем попробовать это поведение, запустив сервер и сделав запрос с помощью curl.

Хук onError

Этот хук срабатывает только тогда, когда сервер отправляет клиенту ошибку в качестве полезной нагрузки. Он запускается после customErrorHandler, если он предоставлен, или после стандартного, встроенного в Fastify. Его основное использование — это дополнительное логирование или изменение заголовков ответа. Мы должны сохранять ошибку нетронутой и избегать прямого вызова reply.send. Последнее приведет к той же ошибке, с которой мы столкнулись, пытаясь сделать то же самое внутри хука onResponse. Фрагмент, показанный в примере on-error.cjs, облегчает понимание:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onError', async (request, _reply, error) => {
    // [1]
    request.log.info(`Hi from onError hook:
    ${error.message}`);
});
app.get('/foo', async (_request, _reply) => {
    return new Error('foo'); // [2]
});
app.get('/bar', async (_request, _reply) => {
    throw new Error('bar'); // [3]
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

Во-первых, мы определяем хук onError в [1], который регистрирует входящее сообщение об ошибке. Мы не хотим изменять объект error, чтобы вернуть какое-либо значение из этого хука, как мы уже говорили. Итак, мы определяем два маршрута: /foo ([2]) возвращает ошибку, а /bar ([3]) выбрасывает ошибку.

Мы можем запустить сниппет в терминале:

1
2
3
$ node on-error.cjs
{"level":30,"time":1636719503620,"pid":62791,"hostname":"localhost",
"msg":"Server listening at http://127.0.0.1:3000"}

Теперь в другом окне терминала мы можем сделать два разных вызова нашего сервера:

1
2
3
4
$ curl localhost:3000/bar
{"statusCode":500,"error":"Internal Server Error","message":"bar"}
> curl localhost:3000/foo
{"statusCode":500,"error":"Internal Server Error","message":"foo"}

Проверка журнала сервера покажет нам, что в обоих случаях хук onError сработал правильно.

Хук onTimeout

Это последний хук, который нам еще предстоит обсудить. Он зависит от опции connectionTimeout, значение которой по умолчанию равно 0. Поэтому onTimeout будет вызван, если мы передадим фабрике Fastify пользовательское значение connectionTimeout. В файле on-timeout.cjs мы используем этот хук для отслеживания запросов, которые завершаются по времени. Поскольку он выполняется только тогда, когда сокет соединения завис, мы не можем отправить клиенту никаких данных:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const Fastify = require('fastify');
const { promisify } = require('util');
const wait = promisify(setTimeout);
const app = Fastify({
    logger: true,
    connectionTimeout: 1000,
}); // [1]
app.addHook('onTimeout', async (request, reply) => {
    request.log.info(`The connection is closed.`); // [2]
});
app.get('/', async (_request, _reply) => {
    await wait(5000); // [3]
    return '';
});
app.listen({ port: 3000 }).catch((err) => {
    app.log.error(err);
    process.exit();
});

В [1] мы передаем фабрике Fastify параметр connectionTimeout, устанавливая его значение в 1 секунду. Затем мы добавляем хук onTimeout, который печатает в журналы каждый раз, когда соединение закрывается ([2]). Наконец, добавим маршрут, который будет ждать 5 секунд, прежде чем ответить клиенту ([3]).

Давайте запустим сниппет:

1
2
3
$ node on-timeout.cjs
{"level":30,"time":1636721785344,"pid":63255,"hostname":"localhost",
"msg":"Server listening at http://127.0.0.1:3000"}

Теперь из другого окна терминала мы можем выполнить наш вызов:

1
2
$ curl localhost:3000
curl: (52) Empty reply from server

Соединение было закрыто сервером, и клиент получил пустой ответ.

Мы можем вернуться в терминальное окно журнала приложения и проверить вывод, чтобы увидеть, что наш хук onTimeout был вызван:

1
2
3
4
5
6
{"level":30,"time":1636721844526,"pid":63298,"hostname":"localhost","
reqId":"req-1","req":{"method":"GET","url":"/","hostname":"localhost:
3000","remoteAddress":"127.0.0.1","remotePort":65021},"msg":"incoming
request"}
{"level":30,"time":1636721845536,"pid":63298,"hostname":"
localhost","reqId":"req-1","msg":"The connection is closed."}

Ответ из хука

Кроме выброса ошибки, есть еще один способ досрочно выйти из потока выполнения фазы запроса в любой момент. Прервать цепочку можно, просто отправив ответ от хука: это предотвратит выполнение всего, что идет после текущего хука. Например, это может быть полезно при реализации логики аутентификации и авторизации.

Однако при работе с асинхронными хуками есть некоторые нюансы. Например, после вызова reply.send для ответа из хука нам нужно вернуть объект reply, чтобы показать, что мы отвечаем из текущего хука.

Пример reply-from-hook.cjs все разъяснит:

1
2
3
4
5
6
7
8
app.addHook('preParsing', async (request, reply) => {
    const authorized = await isAuthorized(request); // [1]
    if (!authorized) {
        reply.code(401);
        reply.send('Unauthorized'); //[2]
        return reply; // [3]
    }
});

Мы проверяем, авторизован ли текущий пользователь на доступ к ресурсу [1]. Затем, когда пользователю не хватает нужных прав, мы отвечаем хуку напрямую [2] и возвращаем объект reply, сигнализируя об этом [3]. Мы уверены, что цепочка хуков на этом остановит свое выполнение, а пользователь получит сообщение 'Unauthorized'.

Хуки уровня маршрута

До сих пор мы объявляли наши хуки на уровне приложения. Однако, как мы уже упоминали ранее в этой главе, хуки запросов/ответов могут быть объявлены и на уровне маршрута. Таким образом, как мы видим в файле route-level-hooks.cjs, мы можем использовать определение маршрута для добавления любого количества хуков, позволяющих нам выполнять действия только для определенного маршрута:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
app.route({
    method: 'GET',
    url: '/',
    onRequest: async (request, reply) => {},
    onResponse: async (request, reply) => {},
    preParsing: async (request, reply) => {},
    preValidation: async (request, reply) => {},
    preHandler: async (request, reply) => {},
    preSerialization: async (request, reply, payload) => {},
    onSend: [
        async (request, reply, payload) => {},
        async (request, reply, payload) => {},
    ], // [1]
    onError: async (request, reply, error) => {},
    onTimeout: async (request, reply) => {},
    handler: function (request, reply) {},
});

Если нам нужно объявить более одного хука для каждой категории, мы можем определить массив хуков ([1]). В качестве последнего замечания стоит отметить, что эти хуки всегда выполняются последними в цепочке.

Хуки уровня маршрута завершают этот раздел. Он, несомненно, длиннее, но в нем собраны концепции, составляющие основу фреймворка Fastify.

Резюме

В этой главе мы познакомились с одной из концепций Fastify, которая отличает его от большинства веб-фреймворков. Даже если для многих разработчиков эта концепция может быть новой, освоение хуков откроет путь к высокопроизводительным, поддерживаемым и многократно используемым компонентам приложения. В отличие от промежуточного ПО, определяя хук, мы гарантируем, что наш код будет выполняться только тогда, когда это имеет смысл, что повышает производительность приложения. Кроме того, наличие четко определенного порядка выполнения может помочь при отладке и проверке во время выполнения.

В следующей главе мы узнаем, как сделать пользовательский ввод безопасным и ускорить работу нашего сервера благодаря валидации и сериализации.

Комментарии