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

От монолита к микросервисам

Наше небольшое приложение начало набирать обороты, и бизнес попросил нас переделать API, сохранив при этом предыдущую версию, чтобы облегчить переход. Пока что мы реализовали «монолит» — все наше приложение развернуто как единый элемент. Наша команда очень занята эволюционным обслуживанием, которое мы не можем отложить. И тут у нашего руководства случается момент «Эврика!»: давайте добавим еще сотрудников.

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

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

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

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

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

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

  • Реализация версионности API
  • Разделение монолита
  • Выставление нашего микросервиса через API-шлюз Реализация распределенного лога

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

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

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

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

Реализация версионности API

Fastify предоставляет два механизма для поддержки нескольких версий одного и того же API:

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

1
2
3
4
5
6
// server.js
const fastify = require('fastify')();
fastify.get('/posts', async (request, reply) => {
    return [{ id: 1, title: 'Hello World' }];
});
fastify.listen({ port: 3000 });

Ограничения версий

Система ограничений Fastify позволяет нам открывать несколько маршрутов на одном и том же URL, различая их по HTTP-заголовку. Это продвинутая методология, которая изменяет то, как пользователь должен вызывать наш API: мы должны указать заголовок Accept-Version, содержащий семантический шаблон версионности.

Чтобы наши маршруты учитывали версии, мы должны добавить constraints: { version: '2.0.0' } в определения наших маршрутов, например, так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const fastify = require('fastify')();
async function getAllPosts() {
    // Call a database or something
    return [{ id: 1, title: 'Hello World' }];
}
fastify.get(
    '/posts',
    {
        constraints: { version: '1.0.0' },
    },
    getAllPosts
);
fastify.get(
    '/posts',
    {
        constraints: { version: '2.0.0' },
    },
    async () => {
        posts: await getAllPosts();
    }
);
app.listen({ port: 3000 });

Мы можем вызвать наш API версии 1.0.0 следующим образом:

1
2
$ curl -H 'Accept-Version: 1.x' http://127.0.0.1:3000/posts
[{"id":1,"title":"Hello World"}]

Мы можем вызвать наш API версии 2.0.0 следующим образом:

1
2
$ curl -H 'Accept-Version: 2.x' http://127.0.0.1:3000/posts
{"posts":[{"id":1,"title":"Hello World"}]}

Вызов API без заголовка Accept-Version приведет к ошибке 404, которую вы можете проверить следующим образом:

1
2
3
$ curl http://127.0.0.1:3000/posts
{"message":"Route GET:/posts not found","error":"Not
Found","statusCode":404}

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

Префиксы URL

Префиксы URL очень просто реализовать с помощью инкапсуляции (см. Глава 2). Как вы помните, мы можем добавить опцию prefix при регистрации плагина, и логика инкапсуляции 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
// server.js
const fastify = require('fastify')();
fastify.register(require('./services/posts'));
fastify.register(require('./routes/v1/posts'), {
    prefix: '/v1',
});
fastify.register(require('./routes/v2/posts'), {
    prefix: '/v2',
});
fastify.listen({ port: 3000 });
// services/posts.js
const fp = require('fastify-plugin');
module.exports = fp(async function (app) {
    app.decorate('posts', {
        async getAll() {
            // Call a database or something
            return [{ id: 1, title: 'Hello World' }];
        },
    });
});

// routes/v1/posts.js
module.exports = async function (app, opts) {
    app.get('/posts', (request, reply) => {
        return app.posts.getAll();
    });
};

// routes/v2/posts.js
module.exports = async function (app, opts) {
    app.get('/posts', async (request, reply) => {
        return {
            posts: await app.posts.getAll(),
        };
    });
};

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

  1. server.js запускает наше приложение.
  2. services/posts.js создает декоратор, который читает все объекты posts из нашей базы данных; обратите внимание на использование утилиты fastify-plugin для нарушения инкапсуляции.
  3. routes/v1/posts.js реализует API v1.
  4. routes/v2/posts.js реализует API v2.

В маршрутах с префиксом нет ничего особенного; мы можем вызывать их обычным способом, используя CURL или Postman:

1
2
3
4
$ curl http://127.0.0.1:3000/v1/posts
[{"id":1,"title":"Hello World"}]
$ curl http://127.0.0.1:3000/v2/posts
{"posts":[{"id":1,"title":"Hello World"}]}

Общая бизнес-логика или код

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

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

Префиксы маршрутизации на основе файловой системы

Чтобы не регистрировать и не требовать все файлы вручную, мы разработали @fastify/autoload. Этот плагин будет автоматически загружать плагины из файловой системы и применять префикс, основанный на имени текущей папки.

В следующем примере мы загрузим две директории, services и routes:

1
2
3
4
5
6
7
8
const fastify = require('fastify')();
fastify.register(require('@fastify/autoload'), {
    dir: `${__dirname}/services`,
});
fastify.register(require('@fastify/autoload'), {
    dir: `${__dirname}/routes`,
});
fastify.listen({ port: 3000 });

Этот новый server.js загрузит все плагины Fastify в папки services и routes, отображая наши маршруты следующим образом:

  • routes/v1/posts.js будет автоматически иметь префикс v1/
  • routes/v2/posts.js будет автоматически иметь префикс v2/.

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

Разделение монолита

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

В нашем примере приложения есть три основных файла: маршруты для v1 и v2, а также один внешний сервис для загрузки постов. Учитывая сходство между v1 и v2 и нашим сервисом, мы объединим сервис с v2, построив поверх него «старый» v1.

Мы собираемся разделить монолит по границам этих трех компонентов: создадим микросервис «v2», микросервис «v1» и «шлюз» для их координации.

Создание нашего v2-сервиса

Обычно самый простой способ извлечь микросервис - это скопировать код монолита и удалить ненужные части. Поэтому сначала мы структурируем наш v2-сервис на основе монолита, повторно используя папки routes/ и services/. Затем мы удаляем папку routes/v1/ и перемещаем содержимое v2 внутрь routes/. Наконец, мы изменим порт, который прослушивает сервер, на 3002.

Теперь мы можем запустить сервер и проверить, что наш URL http://127.0.0.1:3002/posts работает так, как ожидалось:

1
2
$ curl http://127.0.0.1:3002/posts
{"posts":[{"id":1,"title":"Hello World"}]}

Настало время разработать наш микросервис v1.

Построение сервиса v1 поверх v2

Мы можем построить сервис v1, используя API, открытые в v2. Аналогично нашему сервису v2, мы можем структурировать наш сервис v1 на основе монолита, используя папки routes/ и services/. Затем мы удалим папку routes/v1/ и переместим содержимое v1 внутрь routes/. Теперь пришло время изменить реализацию `services/posts.js, чтобы вызвать наш сервис v2.

Наш плагин использует undici, новый HTTP-клиент от Node.js.

История создания undici (от Маттео)

Компания undici родилась в 2016 году. В то время я консультировал несколько организаций, которые страдали от серьезных узких мест при выполнении HTTP-вызовов в Node.js. Они рассматривали возможность смены времени выполнения, чтобы повысить пропускную способность. Я принял вызов и создал proof of-concept для нового HTTP-клиента для Node.js. Результаты меня ошеломили.

Как undici работает быстро? Во-первых, он осуществляет преднамеренный пул соединений с помощью Keep-Alive. Во-вторых, он минимизирует количество микроциклов цикла событий, необходимых для отправки запроса. И наконец, ему не нужно соответствовать тем же интерфейсам, что и серверу.

А почему он называется «недирективным»? Вы можете прочитать HTTP/1.1 как 11, а undici означает 11 на итальянском (но важнее то, что я в это время смотрел Stranger Things).

Мы создаем новый объект undici.Pool для управления пулом соединений с нашим сервисом. Затем мы декорируем наше приложение новым объектом, который соответствует интерфейсу, необходимому для других маршрутов нашего сервиса:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const fp = require('fastify-plugin');
const undici = require('undici');
module.exports = fp(async function (app, opts) {
    const { v2URL } = opts;
    const pool = new undici.Pool(v2URL);
    app.decorate('posts', {
        async getAll() {
            const { body } = await pool.request({
                path: '/posts',
                method: 'GET',
            });
            const data = await body.json();
            return data.posts;
        },
    });
    app.addHook('onClose', async () => {
        await pool.close();
    });
});

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

После создания наших микросервисов v2 и v1 мы теперь будем экспонировать их через API-шлюз.

Экспонирование нашего микросервиса через API-шлюз

Мы разделили наш монолит на два микросервиса. Однако нам все равно нужно выставить их под единым origin (в веб-терминологии origin страницы - это комбинация имени хоста/IP и порта). Как мы можем это сделать? Мы рассмотрим стратегию на основе Nginx, а также стратегию на основе Fastify.

docker-compose для эмуляции производственной среды

Чтобы продемонстрировать наш сценарий развертывания, мы будем использовать установку docker-compose. Следуя той же схеме, что и в Глава 10, создадим Dockerfile для каждого сервиса (v1 и v2). Единственным существенным изменением будет замена оператора CMD в конце файла, как показано ниже:

1
CMD ["node", "server.js"]

Нам также нужно будет создать соответствующий файл package.json для каждого микросервиса.

Когда все будет готово, мы сможем собрать и запустить только что созданные v1 и v2. Чтобы запустить их, мы создадим файл docker-compose-two-services.yml, как показано ниже:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
version: '3.7'
services:
    app-v1:
        build: ./microservices/v1
        environment:
            - 'V2_URL=http://app-v2:3002'
        ports:
            - '3001:3001'
    app-v2:
        build: ./microservices/v2
        ports:
            - '3002:3002'

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

1
2
$ docker-compose -f docker-compose-two-services.yml up
...

Этот файл docker-compose открывает app-v1 на порту 3001 и app-v2 на порту 3002. Обратите внимание, что мы должны установить V2_URL в качестве переменной окружения app-v1, чтобы указать нашему приложению, где находится app-v2.

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

1
2
3
4
$ curl localhost:3001/posts
[{"id":1,"title":"Hello World"}]
$ curl localhost:3002/posts
{"posts":[{"id":1,"title":"Hello World"}]}

После докеризации двух сервисов мы можем создать наш шлюз.

Nginx как шлюз API

Nginx - самый популярный веб-сервер в мире. Он невероятно быстр и надежен и используется всеми организациями, независимо от их размера.

Мы можем настроить Nginx как обратный прокси для префиксов /v1 и /v2 для наших микросервисов, например, так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
events {
  worker_connections 1024;
}
http {
  server {
    listen       8080;
    location /v1 {
      rewrite /v1/(.*)  /$1  break;
      proxy_pass        http://app-v1:3001;
    }
    location /v2 {
      rewrite /v2/(.*)  /$1  break;
      proxy_pass        http://app-v2:3002;
    }
  }
}

Давайте разберем конфигурацию Nginx:

  • Блоки events определяют, сколько соединений может быть открыто рабочим процессом.
  • Блок http настраивает наш простой HTTP-сервер.
  • Внутри блока http->server мы настраиваем порт для прослушивания, а также два расположения /v1 и /v2. Как вы можете видеть, мы переписали URL, чтобы удалить /v1/ и /v2/, соответственно.
  • Затем мы используем директиву proxy_pass для перенаправления HTTP-запроса на целевой хост.

Конфигурация Nginx

Правильно настроить Nginx непросто. Множество его настроек могут существенно изменить профиль производительности приложения. Подробнее об этом можно узнать из документации.

После подготовки конфигурации Nginx мы хотим запустить его через Docker, создав файл Dockerfile:

1
2
FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf

Затем мы можем запустить нашу сеть микросервисов, создав файл docker-compose-nginx.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
version: '3.7'
services:
    app-v1:
        build: ./microservices/v1
        environment:
            - 'V2_URL=http://app-v2:3002'
    app-v2:
        build: ./microservices/v2
    gateway:
        build: ./nginx
        ports:
            - '8080:8080'

В этой конфигурации мы определяем три сервиса Docker: app-v1, app-v2 и gateway. Мы можем начать со следующего:

1
$> docker-compose -f docker-compose-nxing.yml up

Теперь мы можем убедиться, что наши API правильно отображаются на http://127.0.0.1:8080/v1/posts и http://127.0.0.1:8080/v2/posts.

Использование Nginx для предоставления нескольких сервисов — отличная стратегия, которую мы часто рекомендуем. Однако она не позволяет нам настраивать шлюз: что, если мы захотим применить пользовательскую логику авторизации? Как мы будем преобразовывать ответы от сервиса?

@fastify/http-proxy в качестве API-шлюза

Экосистема Fastify предлагает способ реализации обратного прокси с помощью JavaScript. Это @fastify/http-proxy.

Здесь представлена быстрая реализация той же логики, которую мы реализовали в Nginx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const fastify = require('fastify')({ logger: true });
fastify.register(require('@fastify/http-proxy'), {
    prefix: '/v1',
    upstream: process.env.V1_URL || 'http://127.0.0.1:3001',
});
fastify.register(require('@fastify/http-proxy'), {
    prefix: '/v2',
    upstream: process.env.V2_URL || 'http://127.0.0.1:3002',
});
fastify.listen({ port: 3000, host: '0.0.0.0' });

Построение API-шлюза на базе Node.js и Fastify позволяет нам полностью настроить логику работы шлюза на JavaScript — это очень эффективная техника для выполнения централизованных операций, таких как проверка аутентификации или авторизации до того, как запрос достигнет микросервиса. Более того, мы можем составлять таблицу маршрутизации динамически, получая ее из базы данных (и кэшируя ее!). Это дает явное преимущество по сравнению с подходом обратного прокси.

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

После написания прокси на Node.js нам следует создать соответствующий Dockerfile и package.json. Как и в предыдущем разделе, мы будем использовать docker-compose, чтобы проверить, что наша сеть микросервисов работает должным образом. Мы создадим файл docker-compose-fhp.yml для этого решения со следующим содержанием:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
version: '3.7'
services:
    app-v1:
        build: ./microservices/v1
        environment:
            - 'V2_URL=http://app-v2:3002'
    app-v2:
        build: ./microservices/v2
    app-gateway:
        build: ./fastify-http-proxy
        ports:
            - '3000:3000'
        environment:
            - 'V1_URL=http://app-v1:3001'
            - 'V2_URL=http://app-v2:3002'

В этой конфигурации мы определяем три сервиса Docker: app-v1, app-v2 и app-gateway. Мы можем запустить их следующим образом:

1
$> docker-compose -f docker-compose-fhp.yml up

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

Реализация распределенного лога

Как только мы создали распределенную систему, все становится сложнее. Одним из таких усложняющихся моментов является ведение лога и отслеживание запроса в нескольких микросервисах. В Главе 11 мы рассмотрели распределенное протоколирование — это техника, которая позволяет нам отслеживать все строки лога, относящиеся к определенному потоку запросов, с помощью идентификаторов корреляции (reqId). В этом разделе мы применим эту технику на практике.

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

1
2
3
4
5
6
7
8
9
const crypto = require('crypto');
const fastify = require('fastify')({
    logger: true,
    genReqId(req) {
        const uuid = crypto.randomUUID();
        req.headers['x-request-id'] = uuid;
        return uuid;
    },
});

Обратите внимание, что мы генерируем новый UUID при каждом запросе и присваиваем его обратно объекту headers. Таким образом, @fastify/http-proxy автоматически распространит его для всех нисходящих сервисов.

Следующим шагом будет модификация файла server.js во всех микросервисах, чтобы они распознавали заголовок x-request-id:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const crypto = require('crypto');
const fastify = require('fastify')({
    logger: true,
    genReqId(req) {
        return (
            req.headers['x-request-id'] ||
            crypto.randomUUID()
        );
    },
});

Последний шаг — убедиться, что вызов сервиса v2 из v1 проходит через заголовок (в microservices/v1/services/posts.js):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
app.decorate('posts', {
    async getAll({ reqId }) {
        const { body } = await pool.request({
            path: '/posts',
            method: 'GET',
            headers: {
                'x-request-id': reqId,
            },
        });
        const data = await body.json();
        return data.posts;
    },
});

Здесь мы обновили декоратор getAll, чтобы перенаправить пользовательский заголовок x-request-id в вышестоящий микросервис.

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

Резюме

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

Вы готовы перейти к главе 13, в которой вы узнаете, как оптимизировать ваше приложение Fastify.

Комментарии