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

Работа с маршрутами

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

Стоит отметить, что Fastify поддерживает обработчики async/await из коробки, и очень важно понимать их последствия. Вы рассмотрите разницу между обработчиками sync и async и узнаете, как избежать основных подводных камней. Кроме того, мы узнаем, как обрабатывать параметры URL, тело HTTP-запроса и строки запроса.

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

В этой главе мы рассмотрим следующие темы:

  • Объявление конечных точек API и управление ошибками
  • Маршрутизация к конечной точке
  • Чтение входных данных клиента
  • Управление областью действия маршрута
  • Добавление новых поведений в маршруты

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

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

  • Работающая установка Node.js 18
  • Текстовый редактор для отработки кода примера
  • HTTP-клиент для тестирования кода, например CURL или Postman.

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

Объявление конечных точек API и управление ошибками

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

Fastify позволяет использовать ту архитектуру программного обеспечения, которая вам больше всего нравится. Фактически, этот фреймворк не ограничивает вас в использовании Representation State Transfer (REST), GraphQL или простых Application Programming Interfaces (API). Первые две архитектуры стандартизируют следующее:

  • Конечные точки приложения: Стандарт показывает, как раскрыть вашу бизнес-логику, определив набор маршрутов.
  • Серверное взаимодействие: Это дает представление о том, как следует определять вход/выход.

В этой главе мы создадим простые API конечные точки с интерфейсами ввода/вывода JSON. Это означает, что у нас есть свобода в определении внутреннего стандарта для нашего приложения; такой выбор позволит нам сосредоточиться на использовании фреймворка Fastify, а не следовать стандартной архитектуре.

В любом случае, в Главе 7 мы узнаем, как построить REST приложение, а в Главе 14 мы узнаем больше об использовании GraphQL с Fastify.

Слишком много стандартов

Обратите внимание, что существует также стандарт JSON:API: https://jsonapi.org/. Кроме того, Fastify позволяет использовать эту архитектуру, но эта тема не будет обсуждаться в данной книге. Загляните на https://backend.cafe/, чтобы найти больше информации о Fastify и этой книге!

В следующих разделах мы предполагаем, что вы уже понимаете анатомию HTTP-запроса и различия между его частями, такими как параметры запроса и тело запроса. Отличным ресурсом для повторения этих понятий является сайт Mozilla.

Варианты деклараций

В предыдущих главах мы узнали, как создать сервер Fastify, поэтому мы будем считать, что вы можете создать новый проект (или, если у вас возникли трудности, вы можете прочитать Глава 1).

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

  • Общее объявление, используя app.route(routeOptions).
  • Сокращенный синтаксис: app.<HTTP method>(url[, routeOptions], handler).

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

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

Опции маршрута

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

Опции перечислены следующим образом:

  • method: Это HTTP-метод, который нужно отобразить.
  • url: Это конечная точка, которая будет принимать входящие запросы.
  • handler: Это бизнес-логика маршрута. Мы уже встречались с этим свойством в предыдущих главах.
  • logLevel: Это определенный уровень журнала для одного маршрута. Насколько полезным может быть это свойство, мы узнаем в главе 11.
  • logSerializer: Позволяет настроить вывод логов для одного маршрута, в сочетании с предыдущей опцией.
  • bodyLimit: Ограничивает полезную нагрузку запроса, чтобы избежать возможного злоупотребления вашими конечными точками. Это должно быть целое число, представляющее собой максимальное количество принимаемых байт, которое перезаписывает настройки корневого экземпляра.
  • constraints: Эта опция улучшает маршрутизацию конечной точки. Подробнее о том, как использовать эту опцию, мы узнаем в разделе Маршрутизация к конечной точке.
  • errorHandler: Это свойство принимает специальную функцию-обработчик для настройки обработки ошибок для одного маршрута. В следующем разделе будет показана эта конфигурация.
  • config: Это свойство позволяет специализировать конечную точку, добавляя новые поведения.
  • prefixTrailingSlash: Эта опция управляет некоторыми специальными функциями при регистрации маршрута плагинами. Мы поговорим об этом в разделе Маршрутизация к конечной точке.
  • exposeHeadRoute: Это булево значение добавляет или удаляет маршрут HEAD при регистрации маршрута GET. По умолчанию он имеет значение true.

Далее, существует множество узкоспециализированных опций для управления проверкой запросов: schema, attachValidation, validatorCompiler, serializerCompiler, и schemaErrorFormatter. Все эти настройки будут рассмотрены в главе 5.

Наконец, вы должны знать, что каждый маршрут может иметь дополнительные хуки, которые будут выполняться только для самого маршрута. Вы можете просто использовать имена хуков info и объект routeOptions для их подключения. Пример мы рассмотрим в конце этой главы. Хуки те же, что мы перечислили в Глава 1: onRequest, preParsing, preValidation, preHandler, preSerialization, onSend и onResponse, и они будут действовать во время жизненного цикла запроса.

Пришло время увидеть эти опции в действии, так что давайте начнем определять конечные точки!

Массовая загрузка маршрутов

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

Давайте начнем с понимания возможностей этой функции:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const routes = [
    {
        method: 'POST',
        url: '/cat',
        handler: function cat(request, reply) {
            reply.send('cat');
        },
    },
    {
        method: 'GET',
        url: '/dog',
        handler: function dog(request, reply) {
            reply.send('dog');
        },
    },
];
routes.forEach((routeOptions) => {
    app.route(routeOptions);
});

Мы определили массив routes, каждый элемент которого является объектом Fastify routeOptions. Итерируя переменную routes, мы можем добавлять маршруты программно. Это будет полезно, если мы разделим массив по контексту на cat.cjs и dog.cjs. Здесь вы можете увидеть пример кода cat.cjs:

1
2
3
4
5
6
7
8
9
module.exports = [
    {
        method: 'POST',
        url: '/cat',
        handler: function cat(request, reply) {
            reply.send('cat');
        },
    },
];

Проделав то же самое с конфигурацией конечной точки /dog, можно изменить настройку сервера следующим образом:

1
2
3
4
5
6
7
const catRoutes = require('./cat.cjs');
const dogRoutes = require('./dog.cjs');
catRoutes.forEach(loadRoute);
dogRoutes.forEach(loadRoute);
function loadRoute(routeOptions) {
    app.route(routeOptions);
}

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

Мы видели, как с помощью общего метода app.route() можно настроить приложение с множеством маршрутов, централизовав загрузку в определении сервера и переместив логику конечных точек в специальный файл, чтобы улучшить читаемость проекта.

Еще один способ улучшить кодовую базу — использовать async/await в обработчике маршрута, который Fastify поддерживает из коробки. Давайте обсудим это далее.

Синхронные и асинхронные обработчики

В главе 1 мы рассмотрели основную роль обработчика маршрутов и то, как он управляет компонентом Reply.

Кратко напомним, что существует два основных синтаксиса, которые мы можем использовать. Синтаксис sync использует обратные вызовы для управления асинхронным кодом, и он должен вызывать reply.send() для выполнения HTTP-запроса:

1
2
3
4
5
6
7
8
9
function syncHandler(request, reply) {
    readDb(function callbackOne(err, data1) {
        readDb(function callbackTwo(err, data2) {
            reply.send(
                `read from db ${data1} and ${data2}`
            );
        });
    });
}

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

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

1
2
3
4
5
async function asyncHandler(request, reply) {
    const data1 = await readDb();
    const data2 = await readDb();
    return `read from db ${data1} and ${data2}`;
}

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

Ответ — это промис

В обработчике функции async крайне не рекомендуется вызывать reply.send() для отправки ответа обратно клиенту. Fastify знает, что вы можете оказаться в такой ситуации из-за обновления устаревшего кода. В этом случае решением будет возврат объекта reply. Вот быстрый сценарий из реального (плохого) мира:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
async function newEndpoint(request, reply) {
    if (request.body.type === 'old') {
        // [1]
        oldEndpoint(request, reply);
        return reply; // [2]
    } else {
        const newData = await something(request.body);
        return { done: newData };
    }
}

В этом примере конечной точки оператор if в [1] запускает бизнес-логику oldEndpoint, которая управляет объектом reply по-другому, чем в случае else. Фактически, обработчик oldEndpoint был реализован в стиле обратного вызова. Итак, как же сообщить Fastify, что HTTP-ответ был передан другой функции? Нужно просто вернуть объект reply из [2]! Компонент Reply представляет собой интерфейс thenable. Это означает, что он реализует интерфейс .then() точно так же, как и объект Promise! Его возврат подобен созданию промиса, который будет выполнен только после выполнения метода .send().

Удобство чтения и гибкость асинхронных обработчиков — не единственные преимущества: как насчет ошибок? Ошибки могут возникать во время выполнения приложения, и Fastify помогает нам справиться с ними с помощью широко используемых умолчаний.

Как отвечать на ошибки

Как правило, в Fastify ошибка может быть отправлена, если функция обработчика синхронна, или отброшена, если обработчик асинхронен. Давайте проверим это на практике:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function syncHandler(request, reply) {
    const myErr = new Error('this is a 500 error');
    reply.send(myErr); // [1]
}
async function ayncHandler(request, reply) {
    const myErr = new Error('this is a 500 error');
    throw myErr; // [2]
}
async function ayncHandlerCatched(request, reply) {
    try {
        await ayncHandler(request, reply);
    } catch (err) {
        // [3]
        this.log.error(err);
        reply.status(200);
        return { success: false };
    }
}

Как видите, на первый взгляд, различия минимальны: в [1] метод send принимает объект Node.js Error с пользовательским сообщением. Пример [2] очень похож, но мы бросаем ошибку. Пример [3] показывает, как можно управлять ошибками с помощью блоков try/catch и выбирать ответ с HTTP 200 success в любом случае!

Теперь, если мы попробуем добавить управление ошибками в пример syncHandler, как было показано ранее, функция синхронизации станет выглядеть следующим образом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function syncHandler(request, reply) {
    readDb(function callbackOne(err, data1) {
        if (err) {
            reply.send(err);
            return;
        }
        readDb(function callbackTwo(err, data2) {
            if (err) {
                reply.send(err);
                return;
            }
            reply.send(
                `read from db ${data1} and ${data2}`
            );
        });
    });
}

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

До сих пор мы видели, как отвечать на HTTP-запрос с помощью объекта Node.js Error. Это действие отправляет обратно полезную нагрузку JSON с ответом с кодом состояния 500, если вы не установили его с помощью метода reply.code(), который мы рассматривали в Глава 1.

Вывод JSON по умолчанию выглядит следующим образом:

1
2
3
4
5
{
    "statusCode": 500,
    "error": "Internal Server Error",
    "message": "app error"
}

Код new Error('app error') создает объект ошибки, который выдает предыдущее сообщение.

В Fastify есть множество способов настроить ответ на ошибку, и обычно это зависит от того, насколько вы торопитесь. Варианты перечислены ниже:

  • Принять формат вывода Fastify по умолчанию: Это решение готово к использованию и оптимизировано для ускорения сериализации полезной нагрузки ошибки. Оно отлично подходит для быстрого прототипирования.
  • Настроить обработчик ошибок: Эта функция дает вам полный контроль над ошибками приложения.
  • Пользовательское управление ответами: Этот случай включает вызов reply.code(500).send(myJsonError), обеспечивающий вывод JSON.

Теперь мы можем лучше изучить эти возможности.

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

1
2
3
const err = new Error('app error') // [1]
err.code = ERR-001 // [2]
err.statusCode = 400 // [3]

В этом примере конфигурации показано следующее:

  1. Строковое сообщение, которое предоставляется в конструкторе Error в качестве поля message.
  2. Необязательное поле code к тому же ключу вывода JSON.
  3. Параметр statusCode, который изменит код статуса HTTP-ответа и строку error. Строка вывода задается модулем Node.js http.STATUS_CODES по умолчанию.

В результате выполнения предыдущего примера будет получен следующий результат:

1
2
3
4
5
6
{
    "statusCode": 400,
    "code": "ERR001",
    "error": "Bad Request",
    "message": "app error"
}

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

1
2
3
4
5
6
7
8
app.setErrorHandler(function customErrorHandler(
    error,
    request,
    reply
) {
    request.log.error(error);
    reply.send({ ops: error.message });
});

Обработчик ошибок — это функция, которая выполняется всякий раз, когда объект Error или JSON выбрасывается или отправляется; это означает, что обработчик ошибок один и тот же, независимо от реализации маршрута. Ранее мы сказали, что JSON является выброшенным: поверьте мне, и мы объясним, что это значит, позже в этом разделе.

Интерфейс обработчика ошибок имеет три параметра:

  • Первый — это объект, который был выброшен, или объект Error, который был отправлен.
  • Второй — компонент Request, в котором возникла проблема.
  • Третий — объект Reply для выполнения HTTP-запроса в качестве стандартного обработчика маршрута.

Функция обработчика ошибок может быть асинхронной или простой. Что касается обработчика маршрута, то в случае асинхронной функции вы должны вернуть полезную нагрузку ответа, а в случае синхронной реализации — вызвать reply.send(). В этом контексте нельзя бросать или отправлять объект экземпляра Error. Это создаст бесконечный цикл, которым управляет Fastify. В этом случае он пропустит ваш собственный обработчик ошибок и вызовет обработчик ошибок родительского диапазона или обработчик по умолчанию, если он не установлен. Вот быстрый пример:

 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
app.register(async function plugin(pluginInstance) {
    pluginInstance.setErrorHandler(function first(
        error,
        request,
        reply
    ) {
        request.log.error(error, 'an error happened');
        reply.status(503).send({ ok: false }); // [4]
    });
    pluginInstance.register(async function childPlugin(
        deep,
        opts
    ) {
        deep.setErrorHandler(async function second(
            error,
            request,
            reply
        ) {
            const canIManageThisError =
                error.code === 'yes, you can'; // [2]
            if (canIManageThisError) {
                reply.code(503);
                return { deal: true };
            }
            throw error; // [3]
        });
        // This route run the deep's error handler
        deep.get('/deepError', () => {
            throw new Error('ops');
        }); // [1]
    });
});

В предыдущем фрагменте кода у нас есть функция plugin, которая имеет контекст childPlugin. Оба этих инкапсулированных контекста имеют по одной пользовательской функции-обработчику ошибок. Если вы попытаетесь запросить GET /deep [1], будет выброшена ошибка. Она будет обработана второй функцией-обработчиком ошибок, которая решит, обработать ли ее или выбросить повторно [2]. Когда ошибка будет повторно выброшена [3], родительская область видимости перехватит ошибку и обработает ее [4]. Как видите, вы можете реализовать ряд функций, которые будут обрабатывать подмножество ошибок приложения.

Важно помнить, что при реализации обработчика ошибок необходимо позаботиться о коде статуса ответа, иначе по умолчанию он будет 500 — Server Error.

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

Давайте посмотрим на быстрый пример:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async function errorTrigger(request, reply) {
    throw new Error('ops');
}
app.register(async function plugin(pluginInstance) {
    pluginInstance.setErrorHandler(function (
        error,
        request,
        reply
    ) {
        request.log.error(error, 'an error happened');
        reply.status(503).send({ ok: false });
    });
    pluginInstance.get('/customError', errorTrigger); // [1]
});
app.get('/defaultError', errorTrigger); // [2]

Мы определили хэндл плохого маршрута, errorTrigger, который всегда будет выбрасывать Error. Затем мы зарегистрировали два маршрута:

  • Маршрут GET /customError [1] находится внутри плагина, поэтому он находится в новом контексте Fastify.
  • Корневой экземпляр приложения регистрирует вместо него маршрут GET /defaultError [2].

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

На этом этапе, сделав HTTP-запрос к этим конечным точкам, мы получим два разных результата, как и ожидалось:

  • Маршрут GET /customError вызывает ошибку, и она управляется пользовательским обработчиком ошибок, поэтому на выходе будет {"ok":false}.
  • Конечная точка GET /defaultError отвечает в формате JSON по умолчанию Fastify, который был показан в начале этого раздела.

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

Давайте добавим новую конечную точку к предыдущему примеру:

1
2
3
4
5
6
7
app.get('/routeError', {
    handler: errorTrigger,
    errorHandler: async function (error, request, reply) {
        request.log.error(error, 'a route error happened');
        return { routeFail: false };
    },
});

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

Наконец, последний вариант возврата ошибки — вызов reply.send(), как при отправке данных обратно клиенту:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
app.get('/manualError', (request, reply) => {
    try {
        const foo = request.param.id.split('-'); // this line
        throws;
        reply.send('I split the id!');
    } catch (error) {
        reply
            .code(500)
            .send({ error: 'I did not split the id!' });
    }
});

Это тривиальное решение, но вы должны помнить о возможностях, которые предлагает вам Fastify. Важно понимать, что это решение является управляемой ошибкой, поэтому Fastify не знает, что ошибка была поймана в вашем обработчике. В этом случае необходимо задать код состояния ответа HTTP, иначе по умолчанию он будет 200 — Success. Это связано с тем, что маршрут был успешно выполнен с точки зрения фреймворка. В этом случае свойство errorHandler также не выполняется. Это может повлиять на логирование приложения и мониторинг системы, а также ограничить количество кода, который вы сможете повторно использовать в своей кодовой базе, поэтому используйте это осознанно.

В предыдущем коде мы вызвали функцию split в undefined переменной в синхронной функции. Это приведет к возникновению TypeError. Если мы опустим блок try/catch, Fastify обработает ошибку, предотвратив падение сервера.

Если же перенести эту ошибку реализации в обратный вызов, то результат будет следующим:

1
2
3
4
5
6
app.get('/fatalError', (request, reply) => {
    setTimeout(() => {
        const foo = request.param.id.split('-');
        reply.send('I split the id!');
    });
});

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

В этом разделе мы рассмотрели множество тем: узнали об объявлении маршрутов, различиях между синхронными и асинхронными обработчиками, а также о том, как они отвечают клиентам при возникновении ошибки.

Основные выводы можно сформулировать следующим образом:

Асинхронный обработчик Синхронный обработчик
Интерфейс async function handler(request, reply) {} Function handler(request,reply) {}
Как ответить Вернуть полезную нагрузку Вызов reply.send()
Особое использование для ответа Если вы вызываете reply.send(), вы должны вернуть объект reply Вы можете безопасно вернуть объект ответа
Как ответить на ошибку Выбросить ошибку Вызов reply.send(errorInstance)
Специальное использование для ошибок Нет Можно бросать только в функции-обработчике основной области видимости

Рисунок 3.1 — Различия между асинхронными и синхронными обработчиками

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

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

Маршрутизация к конечной точке

Маршрутизация — это этап, на котором Fastify получает HTTP-запрос и должен решить, какая функция-обработчик должна выполнить этот запрос. Вот и все! Это кажется простым, но даже эта фаза оптимизирована для производительности и ускорения работы вашего сервера.

Для реализации логики маршрутизатора Fastify использует внешний модуль под названием find-my-way. Все его возможности раскрываются через интерфейс Fastify, так что Fastify будет получать пользу от каждого обновления этого модуля. Сильной стороной этого маршрутизатора является алгоритм, реализованный для поиска правильного обработчика для выполнения.

Маршрутизатор под капотом

Вам может показаться интересным, что find-my-way реализует структуру данных Radix-tree, начиная с URL-адресов маршрута. Маршрутизатор обходит это дерево, чтобы найти совпадающую строку URL, когда сервер получает HTTP-запрос. Каждый маршрут, отслеживаемый в дереве, несет всю информацию об экземпляре Fastify, которому он принадлежит.

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

1
2
app.get('/', async () => {});
app.get('/', async () => {}); // [1]

Когда вы пишете один и тот же маршрут во второй раз, [1], вы можете ожидать ошибки. Это произойдет только при выполнении одного из методов ready, listen или inject:

1
2
3
app.listen(8080, (err) => {
    app.log.error(err);
});

Двойная регистрация маршрута заблокирует запуск сервера:

1
2
3
$ node server.mjs
AssertionError [ERR_ASSERTION]: Method 'GET' already declared for
route '/' with constraints '{}'

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

Маршрутизатор построил Radix-дерево, содержащее обработчик маршрутов и контекст Fastify. Затем Fastify полагается на неизменяемость контекста. Именно поэтому невозможно добавить новые маршруты, когда сервер уже запущен. Это может показаться ограничением, но к концу этой главы вы увидите, что это не так.

Мы видели, как маршрутизатор загружает URL и ищет обработчик для выполнения, но что произойдет, если он не найдет URL HTTP-запроса?

Обработчик 404

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

1
2
3
4
5
6
app.setNotFoundHandler(function custom404(request, reply) {
    const payload = {
        message: 'URL not found',
    };
    reply.send(payload);
});

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

По умолчанию обработчик 404 отвечает клиенту в том же формате, что и обработчик ошибок по умолчанию:

1
2
3
4
5
{
    "message": "Route GET:/ops not found",
    "error": "Not Found",
    "statusCode": 404
}

Этот вывод JSON сохраняет согласованность между двумя событиями: ошибка и маршрут не найден, отвечая клиенту одинаковыми полями ответа.

Как обычно, эта функция также инкапсулируется, поэтому вы можете установить один обработчик Not Found для каждого контекста:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
app.register(
    async function plugin(instance, opts) {
        instance.setNotFoundHandler(function html404(
            request,
            reply
        ) {
            reply
                .type('application/html')
                .send(niceHtmlPage);
        });
    },
    { prefix: '/site' }
); // [1]
app.setNotFoundHandler(function custom404(request, reply) {
    reply.send({ not: 'found' });
});

В этом примере мы установили корневой 404-обработчик custom404 и экземпляр плагина html404. Это может быть полезно, когда ваш сервер управляет несколькими содержимыми, например, статический сайт, который показывает милую и забавную HTML-страницу, когда запрашивается несуществующая страница, или показывает JSON, когда запрашивается отсутствующий API.

Предыдущий пример кода указывает Fastify искать обработчик для выполнения в экземпляре плагина, когда запрашиваемый URL начинается со строки /site. Если Fastify не находит подходящего варианта в данном контексте, он использует обработчик Not Found этого контекста. Итак, для примера рассмотрим следующие URL:

  • URL http://localhost:8080/site/foo будет обслуживаться обработчиком html404
  • URL http://localhost:8080/foo будет обслуживаться обработчиком custom404.

Параметр prefix (отмеченный как [1] в предыдущем блоке кода) является обязательным для установки нескольких обработчиков 404; в противном случае Fastify не запустит сервер, потому что не знает, когда выполнить какой из них, и это вызовет ошибку запуска:

1
2
Error: Not found handler already set for Fastify instance with prefix:
'/'

Еще один важный аспект обработки Not Found заключается в том, что она запускает хуки жизненного цикла запроса, зарегистрированные в контексте, к которому она принадлежит. Мы получили краткое представление о хуках в Глава 1, и далее мы расскажем об этой функции Fastify в Глава 4.

Здесь же следует сказать, как узнать, был ли хук вызван HTTP-запросом с обработчиком маршрута или без него? Ответ — по флагу is404, который можно проверить следующим образом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
app.addHook('onRequest', function hook(
    request,
    reply,
    done
) {
    request.log.info(
        'Is a 404 HTTP request? %s',
        request.is404
    );
    done();
});

Компонент Request знает, был ли HTTP-запрос выполнен обработчиком маршрута или обработчиком Not Found, поэтому вы можете пропустить некоторые ненужные процессы обработки запроса в своих функциях хуков, фильтруя те запросы, которые не будут обработаны.

Итак, вы узнали, как управлять ответом, когда URL не соответствует ни одной из конечных точек вашего приложения. Но что произойдет, если клиент попадет в обработчик 404 из-за неправильного слеша в конце?

Настройка приложений маршрутизатора

Fastify очень хорошо настраивается во всех компонентах, и маршрутизатор — один из них. Вы узнаете, как изменить настройки маршрутизатора, чтобы сделать его более гибким и справиться с типичными проблемами клиента. Важно понимать эти настройки, чтобы предвидеть общие проблемы и создать отличный набор API с первой попытки!

Косая черта

Слэш в конце страницы — это символ /, когда он является последним символом URL, без учета параметров запроса.

Fastify считает, что URL /foo и /foo/ отличаются, и вы можете зарегистрировать их и позволить им отвечать на два совершенно разных вывода:

1
2
3
4
5
6
app.get('/foo', function (request, reply) {
    reply.send('plain foo');
});
app.get('/foo/', function (request, reply) {
    reply.send('foo with trailin slash');
});

Часто этот интерфейс может быть неправильно понят клиентами. Поэтому вы можете настроить Fastify так, чтобы эти URL воспринимались как одно и то же:

1
2
3
const app = fastify({
    ignoreTrailingSlash: true,
});

Параметр ignoreTrailingSlash заставляет маршрутизатор Fastify игнорировать завершающий слэш для всех маршрутов приложения. Из-за этого вы не сможете зарегистрировать URL /foo и /foo/ и получите ошибку при запуске. Это приведет к затратам на работу вашего API, но вам не придется бороться с 404 ошибкой, если URL был неправильно напечатан с конечным символом /.

URL с учетом регистра

Еще одна распространенная проблема, с которой вы можете столкнуться, — это необходимость поддерживать оба URL /fooBar и /foobar в качестве одной конечной точки (обратите внимание на регистр символа B). Как и в примере с косой чертой, Fastify будет управлять этими маршрутами как двумя разными элементами; фактически, вы можете зарегистрировать оба маршрута в двух разных функциях-обработчиках, если только вы не настроите код следующим образом:

1
2
3
const app = fastify({
    caseSensitive: false,
});

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

1
2
3
4
5
6
app.get('/FOOBAR', function (request, reply) {
    reply.send({
        url: request.url, // [1]
        routeUrl: request.routerPath, // [2]
    });
});

Конечная точка /FOOBAR будет отвечать на все возможные комбинации, такие как /FooBar, /foobar, /fooBar и другие. Вывод обработчика будет содержать как URL HTTP-запроса, [1], так и URL маршрута, [2]. Эти два поля будут соответствовать настройкам без изменения их в нижний регистр.

Так, например, при выполнении HTTP-запроса к конечной точке GET /FoObAr будет получен следующий результат:

1
2
3
4
{
    "url": "/FoObAr",
    "routeUrl": "/FOOBAR"
}

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

Путь к URL-адресу

Если вы затрудняетесь с выбором, как назвать конечную точку: /fast-car или /fast_car, знайте, что дефис обычно используется для URL веб-страниц, а подчеркивание — для конечных точек API.

Еще одна ситуация, с которой вы можете столкнуться во время миграции, — это необходимость поддерживать старые маршруты, которые в будущем будут отменены.

Rewrite URL

Эта функция добавляет возможность изменения запрашиваемого HTTP-адреса до того, как произойдет маршрутизация:

1
2
3
4
5
6
7
8
const app = fastify({
    rewriteUrl: function rewriteUrl(rawRequest) {
        if (rawRequest.url.startsWith('/api')) {
            return rawRequest.url;
        }
        return `/public/${rawRequest.url}`;
    },
});

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

Эта техника может быть полезна в качестве расширителя URL или для того, чтобы избежать перенаправления клиента с кодом состояния ответа 302 HTTP.

Регистрация перезаписи URL-адресов

К сожалению, функция rewriteUrl не будет привязана к корневому экземпляру Fastify. Это означает, что вы не сможете использовать ключевое слово this в этом контексте. Fastify будет записывать отладочную информацию, если функция вернет URL, отличный от исходного. В любом случае, вы сможете использовать объект app.log по своему усмотрению.

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

Теперь мы узнаем, как настроить маршрутизатор еще более детально.

Регистрация одинаковых URL

Как мы видели ранее, Fastify не регистрирует один и тот же путь HTTP-маршрута более одного раза. Это ограничение связано с работой маршрутизатора Fastify. Маршрутизатор ищет нужный обработчик для выполнения, сопоставляя его со следующими правилами:

  • HTTP-метод запроса
  • Строка запроса URL

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

Эта возможность — ограничение маршрута. Ограничение — это проверка, выполняемая, когда HTTP-запрос был получен сервером и должен быть направлен на конечную точку. Этот шаг считывает необработанный http.IncomingMessage, чтобы извлечь значение для применения проверки на ограничение. По сути, вы можете видеть два основных логических шага:

  1. Конфигурация ограничений в опции route означает, что конечная точка может быть достигнута только в том случае, если HTTP-запрос удовлетворяет условию.
  2. Оценка ограничений происходит, когда HTTP-запрос направляется в обработчик.

Ограничение может быть обязательным, если оно вытекает из запроса, но мы рассмотрим пример позже.

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
app.get('/user', function (request, reply) {
    reply.send({ user: 'John Doe' });
});
app.get(
    '/user',
    {
        constraints: {
            version: '2.0.0',
        },
    },
    function handler(request, reply) {
        reply.send({ username: 'John Doe' });
    }
);

Мы зарегистрировали один и тот же URL с одним и тем же методом HTTP. Два маршрута отвечают разными объектами ответа, что противоречит обратной совместимости.

Обратная совместимость

Конечная точка является обратно совместимой, если изменения в ее бизнес-логике не требуют обновления клиента.

Другое отличие заключается в том, что у второй конечной точки появился новый ключ опции constraints, указывающий на входной JSON-объект. Это означает, что маршрутизатор должен соответствовать пути URL, методу HTTP и всем ограничениям, чтобы применить этот обработчик.

По умолчанию Fastify поддерживает два ограничения:

  • Ограничение host проверяет заголовок запроса host. Эта проверка не является обязательной, поэтому если запрос имеет заголовок host, но не совпадает ни с одним ограниченным маршрутом, при маршрутизации может быть выбрана общая конечная точка без ограничений.
  • Ограничение version анализирует заголовок запроса accept-version. Если запрос содержит этот заголовок, проверка является обязательной, и типовая конечная точка без ограничения не может быть рассмотрена при маршрутизации.

Чтобы лучше объяснить эти опции, давайте посмотрим на них в действии:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
app.get('/host', func0);
app.get('/host', {
    handler: func2,
    constraints: {
        host: /^bar.*/,
    },
});
app.get('/together', func0);
app.get('/together', {
    handler: func1,
    constraints: {
        version: '1.0.1',
        host: 'foo.fastify.dev',
    },
});

Обработчик /host выполняется только тогда, когда запрос имеет заголовок host, начинающийся с bar, поэтому следующая команда даст ответ func2:

1
2
$ curl --location --request GET "http://localhost:8080/host" --header
"host: bar.fastify.dev"

Вместо этого, если задать в заголовке host значение foo.fastify.dev, будет выполнен обработчик func0; это происходит потому, что ограничение host не является обязательным, и HTTP-запрос со значением может соответствовать маршруту, в котором не настроено ограничение.

Конечная точка /together настраивает два ограничения. Обработчик будет выполнен только в том случае, если в заголовке HTTP-запроса присутствуют оба соответствующих HTTP-заголовка:

1
2
$ curl --location --request GET "http://localhost:8080/together"
--header "accept-version: 1.x" --header "host: foo.fastify.dev"

Соответствие host — это простое строковое соответствие; вместо этого заголовок accept-version представляет собой Semantic Versioning (SemVer) диапазон строкового соответствия.

SemVer — это спецификация для наименования выпуска программного обеспечения в экосистеме Node.js. Благодаря своей ясности она широко используется во многих контекстах. Этот метод именования определяет три числа, обозначаемые как major.minor.patch, например 1.0.1. Каждое число указывает на тип изменений в программном обеспечении, которые были опубликованы:

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

В спецификации также определено, как запрашивать версию строки SemVer. В нашем случае используется диапазон 1.x, который означает последнюю основную версию 1; 1.0.x переводится как последнюю основную версию 1, а второстепенная равна 0. Полный обзор синтаксиса запросов SemVer можно найти на сайте https://semver.org/.

Итак, ограничение version поддерживает синтаксис запроса SemVer в качестве значения HTTP-заголовка для соответствия целевой конечной точке.

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

Совпадение нескольких ограничений

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

Как уже упоминалось, вы можете использовать гораздо больше ограничений для маршрутизации HTTP-запроса к вашим обработчикам. На самом деле, вы можете добавить столько ограничений, сколько вам нужно, в ваши маршруты, но это будет стоить вам производительности. Маршрутизация выбирает маршруты, соответствующие методу и пути HTTP, и затем обрабатывает ограничения для каждого входящего запроса. Fastify дает вам возможность реализовать пользовательские ограничения в соответствии с вашими потребностями. Создание новых ограничений не является целью этой книги, но вы можете обратиться к этому модулю по адресу https://github.com/Eomm/header-constraint-strategy, который поддерживает соавтор этой книги. Ваше путешествие с Fastify не ограничится этой практической книгой!

На данном этапе мы поняли, как добавить маршрут и как направлять к нему HTTP-запросы. Теперь мы готовы перейти к управлению вводом.

Чтение входных данных клиента

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

  • Параметры пути — это позиционные данные, основанные на формате URL конечной точки.
  • Строка запроса — это дополнительная часть URL, которую клиент добавляет для предоставления переменных данных
  • Заголовки — это дополнительные пары ключ:значение, которые связывают информацию, передаваемую между клиентом и сервером.
  • Тело — это полезная нагрузка запроса, которая содержит данные, предоставленные клиентом.

Давайте рассмотрим каждый из них более подробно.

Параметры пути

Параметры пути — это переменные части URL, которые могут идентифицировать ресурс на нашем сервере приложений. Мы уже рассматривали этот аспект в главе 1, поэтому не будем повторяться. Вместо этого будет интересно показать вам новый полезный пример, который мы еще не рассматривали; в этом примере задаются два (или более) параметра пути:

1
2
3
4
5
6
app.get('/:userId/pets/:petId', function getPet(
    request,
    reply
) {
    reply.send(request.params);
});

JSON-объект request.params содержит оба параметра, userId и petId, которые объявлены в определении строки URL.

Последнее, что нужно знать о параметрах пути, — это их максимальная длина. По умолчанию параметр URL не может содержать более 100 символов. Это проверка безопасности, которую Fastify устанавливает по умолчанию, и которую вы можете настроить в инициализации корневого экземпляра сервера:

1
2
3
const app = fastify({
    maxParamLength: 40,
});

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

Если клиент превысит лимит длины параметров, он получит ответ 404 Not Found.

Строка запроса

Строка запроса — это дополнительная часть строки URL, которую клиент может добавить после вопросительного знака:

1
http://localhost:8080/foo/bar?param1=one&param2=two

Эти параметры позволяют вашим клиентам отправлять информацию в те конечные точки, которые не поддерживают полезную нагрузку запроса, например, GET или HEAD HTTP. Обратите внимание, что можно получать только входные строки, а не сложные данные, например, файл.

Чтобы прочитать эту информацию, вы можете получить доступ к полю request.query везде, где есть компонент Request: в хуках, декораторах или обработчиках.

Fastify поддерживает базовое отображение отношений 1:1, поэтому параметр запроса foo.bar=42 создает объект запроса {"foo.bar": "42"}. Между тем, мы должны ожидать вложенный объект, подобный этому:

1
2
3
4
5
{
    "foo": {
        "bar": "42"
    }
}

Для этого мы должны изменить стандартный парсер строк запросов с помощью qs, нового внешнего модуля:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const qs = require('qs');
const app = fastify({
    querystringParser: function newParser(
        queryParamString
    ) {
        return qs.parse(queryParamString, {
            allowDots: true,
        });
    },
});

Эта настройка открывает полный набор новых синтаксисов, которые вы можете использовать в строках запросов, таких как массивы, вложенные объекты и пользовательские разделители символов.

Заголовки

Заголовки представляют собой карту ключ-значение, которая может быть прочитана как JSON-объект в свойстве request.headers. Обратите внимание, что по умолчанию Node.js будет применять формат нижнего регистра к ключу каждого заголовка. Поэтому, если ваш клиент отправляет на сервер Fastify заголовок CustomHeader: AbC, вы должны получить к нему доступ с помощью оператора request.headers.customheader.

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

Если вам нужно получить оригинальные заголовки, отправленные клиентом, вы должны обратиться к свойству request.raw.rawHeaders. Учтите, что request.raw дает вам доступ к объекту Node.js http.IncomingMessage, поэтому вы можете свободно читать данные, добавленные в реализацию ядра Node.js, такие как необработанные заголовки.

Тело

Тело запроса можно прочитать через свойство request.body. Fastify обрабатывает два типа входного контента:

  1. Тип application/json создает объект JSON в качестве значения body.
  2. В text/plain создается строка, которая будет установлена в качестве значения request.body.

Обратите внимание, что полезная нагрузка запроса будет прочитана для HTTP-методов POST, PUT, PATCH, OPTIONS и DELETE. Методы GET и HEAD не анализируют тело, согласно спецификации HTTP/1.1.

Fastify устанавливает ограничение на длину полезной нагрузки, чтобы защитить приложение от Denial-of-Service (DOS) атак, посылающих огромную полезную нагрузку, чтобы заблокировать ваш сервер на этапе разбора. Когда клиент превышает установленный по умолчанию 1-мегабайтный лимит, он получает ответ об ошибке 413 — Request body is too large. Например, это может быть нежелательным поведением при загрузке изображения. Поэтому вам следует настроить ограничение размера тела по умолчанию, задав следующие параметры:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const app = fastify({
    // [1]
    bodyLimit: 1024, // 1 KB
});
app.post(
    '/',
    {
        // [2]
        bodyLimit: 2097152, // 2 MB
    },
    handler
);

Конфигурация [1] определяет максимальную длину для каждого маршрута без пользовательского ограничения, например, для маршрута [2].

Безопасность превыше всего

Хорошей практикой является ограничение размера тела по умолчанию минимальным значением, на которое вы рассчитываете, и установка специального ограничения для маршрутов, которым требуется больше входных данных. Обычно для простого пользовательского ввода достаточно 256 КБ.

Пользовательский ввод — это не JSON и не только текст. В главе 4 мы обсудим, как избежать парсинга тела или управлять другими типами содержимого, такими как multipart/form-data.

Мы рассмотрели конфигурацию маршрутов и научились читать источники входных данных HTTP-запросов. Теперь мы готовы более детально рассмотреть организацию маршрутов в плагинах!

Управление областью действия маршрута

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

  1. Конфигурация маршрута
  2. Экземпляр сервера, на котором был зарегистрирован маршрут.

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

Экземпляр сервера маршрутов

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

  • контекст выполнения обработчика
  • События жизненного цикла запроса
  • Конфигурация маршрута по умолчанию.

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
app.get('/private', function handle(request, reply) {
    reply.send({ secret: 'data' });
});
app.register(function privatePlugin(instance, opts, next) {
    instance.addHook('onRequest', function isAdmin(
        request,
        reply,
        done
    ) {
        if (request.headers['x-api-key'] === 'ADM1N') {
            done();
        } else {
            done(new Error('you are not an admin'));
        }
    });
    next();
});

При вызове конечной точки http://localhost:8080/private хук isAdmin не будет выполнен, поскольку маршрут определен в области видимости приложения. Хук isAdmin объявляется только в контексте privatePlugin.

Перемещение объявления конечной точки /private в контекст privatePlugin и замена кода app.get на instance.get приведет к изменению контекста экземпляра сервера маршрута. Перезапуск приложения и новый HTTP-запрос приведет к выполнению функции isAdmin, поскольку область видимости маршрута изменилась.

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

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

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

 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
40
41
42
43
app.addHook('onRequest', function parseUserHook(
    request,
    reply,
    done
) {
    const level = parseInt(request.headers.level) || 0;
    request.user = {
        level,
        isAdmin: level === 42,
    };
    done();
});
app.get('/public', handler);
app.register(rootChildPlugin);
async function rootChildPlugin(plugin) {
    plugin.addHook('onRequest', function level99Hook(
        request,
        reply,
        done
    ) {
        if (request.user.level < 99) {
            done(new Error('You need an higher level'));
            return;
        }
        done();
    });
    plugin.get('/onlyLevel99', handler);
    plugin.register(childPlugin);
}
async function childPlugin(plugin) {
    plugin.addHook('onRequest', function adminHook(
        request,
        reply,
        done
    ) {
        if (!request.isAdmin) {
            done(new Error('You are not an admin'));
            return;
        }
        done();
    });
    plugin.get('/onlyAdmin', handler);
}

Внимательно прочитайте предыдущий код; мы добавили по одному маршруту и одному хуку onRequest в каждый контекст:

  • Маршрут /public в корневом экземпляре приложения
  • URL-адрес /onlyLevel99 в функции rootChildPlugin, которая является дочерней для контекста приложения
  • Конечная точка /onlyAdmin в контексте childPlugin зарегистрирована в функции rootChildPlugin.

Теперь, если мы попытаемся вызвать конечную точку /onlyAdmin, произойдет следующее:

  1. Сервер получает HTTP-запрос и выполняет процесс маршрутизации, находя нужный обработчик.
  2. Обработчик регистрируется в контексте childPlugin, который является дочерним экземпляром сервера.
  3. Fastify обходит дерево контекста до корневого экземпляра приложения и начинает жизненный цикл запроса.
  4. Каждый хук в пройденных контекстах выполняется последовательно. Итак, порядок действий будет следующим:
    1. Хук parseUserHook добавляет объект пользователя в HTTP-запрос.
    2. Хук level99Hook проверяет, имеет ли объект пользователя соответствующий уровень для доступа к маршрутам, определенным в данном контексте и его дочерних контекстах.
    3. Хук adminHook проверяет, является ли объект пользователя администратором.

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

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

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

Печать дерева маршрутов

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

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

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

1
2
3
4
5
app.ready().then(function started() {
    console.log(app.printPlugins()); // [1]
    console.log(app.printRoutes({ commonPrefix: false }));
    // [2]
});

Метод printPlugins() из [1] возвращает строку с древовидным представлением, содержащим все инкапсулированные контексты и время загрузки. В результате вы получаете полный обзор всех созданных плагинов и уровень их вложенности. Вместо этого метод printRoutes() из [2] показывает нам список маршрутов приложения, который мы увидим позже.

Чтобы просмотреть пример функции printPlugins, рассмотрим следующий код:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
app.register(async function pluginA(instance) {
    instance.register(async function pluginB(instance) {
        instance.register(function pluginC(
            instance,
            opts,
            next
        ) {
            setTimeout(next, 1000);
        });
    });
});
app.register(async function pluginX() {});

Мы создали четыре плагина: три вложенных друг в друга и один в корневом контексте. Выполнение printPlugins приводит к следующей выходной строке:

1
2
3
4
5
6
bound root 1026 ms
├── bound _after 3 ms
├─┬ pluginA 1017 ms
│ └─┬ pluginB 1017 ms
│   └── pluginC 1016 ms
└── pluginX 0 ms

Здесь мы видим две интересные вещи:

  1. Имена в выводе — это имена функций плагинов: Это еще раз подтверждает, что важно отдавать предпочтение именованным функциям, а не анонимным. В противном случае этап отладки будет более сложным.
  2. Время загрузки является кумулятивным: Время загрузки корневого контекста складывается из времени загрузки всех его дочерних контекстов. По этой причине время загрузки pluginC влияет на время загрузки родительских контекстов.

Этот вывод поможет нам сделать следующее:

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

Fastify internals В выводе показана строка bound _after. Вы можете просто проигнорировать вывод этой строки, потому что это внутреннее поведение Fastify, и оно не дает нам информации о нашем приложении.

Посмотрев на метод printRoutes() из [2] в начале этого раздела, мы можем получить полный список всех маршрутов, которые были загружены Fastify. Это поможет вам получить легко читаемое дерево вывода:

1
2
3
4
5
6
7
8
9
└── / (GET)
    ├── dogs (GET, POST, PUT)
    │   └── /:id (GET)
    ├── feline (GET)
    │   ├── / (GET)
    │   └── /cats (GET, POST, PUT)
    │       /cats (GET) {"host":"cats.land"}
    │       └── /:id (GET)
    └── who-is-the-best (GET)

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

Как вы помните, в операторе printRoutes() [2] мы использовали опцию commonPrefix. Это необходимо для преодоления внутреннего Radix-дерева, которое мы видели в предыдущем разделе Маршрутизация к конечной точке. Без этого параметра Fastify покажет вам внутреннее представление маршрутов. Это означает, что маршруты сгруппированы по наиболее распространенной строке URL. В следующем наборе маршрутов есть три маршрута с общим префиксом hel:

1
2
3
4
5
6
7
8
app.get('/hello', handler);
app.get('/help', handler);
app.get('/:help', handler);
app.get('/helicopter', handler);
app.get('/foo', handler);
app.ready(() => {
    console.log(app.printRoutes());
});

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

1
2
3
4
5
6
7
└── /
    ├── hel
    │   ├── lo (GET)
    │   ├── p (GET)
    │   └── icopter (GET)
    ├── :help (GET)
    └── foo (GET)

Как подтверждает предыдущий вывод, строка hel является наиболее общей строкой URL, которая группирует три маршрута. Обратите внимание, что маршрут :help не группируется: это происходит потому, что он является параметром пути, а не статической строкой. Как уже упоминалось, этот вывод показывает внутреннюю логику маршрутизатора, и его может быть трудно прочитать и понять. Углубление во внутренние детали маршрута выходит за рамки этой книги, поскольку это касается внутреннего Radix-дерева, о котором мы говорили в разделе Маршрутизация к конечной точке.

Метод printRoutes() поддерживает еще один полезный флаг опции: includeHooks. Давайте добавим следующий булевский флаг:

1
2
3
4
app.printRoutes({
    commonPrefix: false,
    includeHooks: true,
});

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

Для примера приведем следующий пример кода:

 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
app.addHook('preHandler', async function isAnimal() {});
app.get('/dogs', handler);
app.register(async function pluginA(instance) {
    instance.addHook(
        'onRequest',
        async function isCute() {}
    );
    instance.addHook(
        'preHandler',
        async function isFeline() {}
    );
    instance.get(
        '/cats',
        {
            onRequest: async function hasClaw() {}, // [1]
        },
        handler
    );
});
await app.ready();
console.log(
    app.printRoutes({
        commonPrefix: false,
        includeHooks: true,
    })
);

Выведите строку:

1
2
3
4
5
6
└── / (-)
    ├── cats (GET)
    │   • (onRequest) ["isCute()","hasClaw()"]
    │   • (preHandler) ["isAnimal()","isFeline()"]
    └── dogs (GET)
        • (preHandler) ["isAnimal()"]

Дерево вывода можно сразу же прочитать, и оно сообщает нам, какая функция запускается для каждого зарегистрированного хука! Более того, функции упорядочены по выполнению, так что мы уверены, что isFeline запускается после функции isAnimal. В этом фрагменте мы использовали регистрацию хука маршрута [1], чтобы подчеркнуть, как хуки последовательно дополняют друг друга.

Именованные функции

Как упоминалось в разделе Добавление базовых маршрутов главы 1, использование стрелочных функций для определения хуков приложения вернет строку вывода "anonymous()". Это может помешать отладке маршрута.

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

Создание образа схемы вашего приложения

Используя плагин fastify-overview из https://github.com/Eomm/fastify-overview, вы сможете создать графический макет вашего приложения с выделенными хуками, декораторами и контекстами, инкапсулированными в Fastify! Вам стоит попробовать.

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

Добавление новых поведений в маршруты

В начале этой главы мы узнали, как использовать объект routeOptions для настройки маршрута, но мы не говорили об опции config!

Это простое поле дает нам возможность делать следующее:

  • Получить доступ к конфигурации в функциях обработчика и хука
  • Реализовать аспектно-ориентированное программирование (АОП), которое мы увидим позже.

Как это работает на практике? Давайте узнаем!

Доступ к конфигурации маршрута

С помощью параметра routerOption.config вы можете указать JSON, который содержит все, что вам нужно. Впоследствии к нему можно будет обратиться внутри компонента Request в функции обработчиков или хуков через поле context.config:

1
2
3
4
5
6
7
8
9
async function operation(request, reply) {
    return request.context.config;
}
app.get('/', {
    handler: operation,
    config: {
        hello: 'world',
    },
});

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

Например, у нас может быть хук preHandler, который запускается перед функцией-обработчиком chedule в каждом маршруте:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
app.addHook('preHandler', async function calculatePriority(
    request
) {
    request.priority = request.context.config.priority = 10;
});
app.get('/private', {
    handler: schedule,
    config: { priority: 5 },
});
app.get('/public', {
    handler: schedule,
    config: { priority: 1 },
});

Хук calculatePriority добавляет объекту запроса уровень приоритета, основанный на конфигурации маршрута: URL /public имеет меньшее значение, чем /private.

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

AOP

Парадигма AOP фокусируется на изоляции сквозных проблем от бизнес-логики и повышении модульности системы.

Если быть менее теоретичным и более практичным, то AOP в Fastify означает, что вы можете изолировать скучные вещи в хуки и добавлять их в маршруты, которые в них нуждаются!

Вот полный пример:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
app.addHook('onRoute', function hook(routeOptions) {
    if (routeOptions.config.private === true) {
        routeOptions.onRequest = async function auth(
            request
        ) {
            if (request.headers.token !== 'admin') {
                const authError = new Error('Private zone');
                authError.statusCode = 401;
                throw authError;
            }
        };
    }
});
app.get('/private', {
    config: { private: true },
    handler,
});
app.get('/public', {
    config: { private: false },
    handler,
});

Начиная с первой главы, мы мало говорили о хуках. В коде мы представили еще один пример хука: хук приложения onRoute, который прослушивает каждый маршрут, зарегистрированный в контексте приложения (и его дочерних контекстах). Он может изменять объект routeOptions до того, как Fastify инстанцирует конечную точку маршрута.

Маршруты имеют поля config.private, которые указывают функции hook добавить хук onRequest к конечной точке, который будет создан только в том случае, если его значение равно true.

Давайте перечислим все преимущества:

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

Этот пример демонстрирует возможности хука onRoute в сочетании с объектом routeOptions.config. В последующих главах вы увидите другие примеры использования, в которых будет задействован этот шаблон креативного промежуточного ПО, чтобы дать вам больше инструментов и идей для создания приложений быстрее, чем когда-либо.

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

Резюме

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

Теперь вы знаете, как добавлять новые конечные точки и реализовывать эффективные функции-обработчики, как async, так и sync, со всеми различными аспектами, которые могут повлиять на поток запросов. Вы знаете, как получить доступ к входным данным клиента для реализации бизнес-логики и эффективно ответить на запрос, сообщив об успехе или ошибке.

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

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

В следующей главе мы подробно рассмотрим одну из основных концепций Fastify, о которой мы уже вкратце упоминали и много раз видели в наших примерах: хуки и декораторы!

Комментарии