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


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


В этой статье будет написан простейший микросервис на Nodejs & RabbitMQ, а также показан процесс миграции монолита на микросервисы.


Что есть в микросервисной архитектуре?


  1. Gateway. Главный сервер, который принимает запросы и перенаправляет их нужному микросервису. Чаще всего, в gateway нет никакой бизнес-логики.
  2. Microservice. Сам микросервис, который обрабатывают запросы пользователей с четко заданной бизнес-логикой.
  3. Транспорт. Это та часть, через которую будут общаться Gateway & Microservice. В качестве транспорта могут выступать HTTP, gRPC, RabbitMQ и т.д.

Почему именно RabbitMQ?


Разумеется, можно не использовать RabbitMQ, есть другие варианты общения между микросервисами. Самый простой — HTTP, есть gRPC от Google.


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


Начало


Для начала реализуем простой gateway, который будет принимать запросы по HTTP, слушая определенный порт.


Разворачиваем RabbitMQ (через него наши микросервисы и gateway будут общаться):


$ docker run -d -p 5672:5672 rabbitmq

Инициализируем проект и устанавливаем NPM-пакет micromq:


$ npm init -y
$ npm i micromq -S 

Пишем gateway


// импортируем класс Gateway из раннее установленного пакета micromq
const Gateway = require('micromq/gateway');

// создаем экземпляр класса Gateway
const app = new Gateway({
  // названия микросервисов, к которым мы будем обращаться
  microservices: ['users'],
  // настройки rabbitmq
  rabbit: {
    // ссылка для подключения к rabbitmq (default: amqp://guest:guest@localhost:5672)
    url: process.env.RABBIT_URL,
  },
});

// создаем два эндпоинта /friends & /status на метод GET
app.get(['/friends', '/status'], async (req, res) => {
  // делегируем запрос в микросервис users
  await res.delegate('users');
});

// начинаем слушать порт
app.listen(process.env.PORT);

Как это будет работать:


  1. Запускается сервер, он начинает слушать порт и получать запросы
  2. Пользователь отправляет запрос на https://mysite.com/friends
  3. Gateway, согласно логике, которую мы описали, делегирует запрос:
    3.1 Идет отправка сообщения (параметры запроса, заголовки, информация о коннекте и др.) в очередь RabbitMQ
    3.2. Микросервис слушает эту очередь, обрабатывает новый запрос
    3.3. Микросервис отправляет ответ в очередь
    3.4. Gateway слушает очередь ответов, получает ответ от микросервиса
    3.5. Gateway отправляет ответ клиенту
  4. Пользователь получает ответ

Пишем микросервис


// импортируем класс MicroService из раннее установленного пакета micromq
const MicroMQ = require('micromq');

// создаем экземпляр класса MicroService
const app = new MicroMQ({
  // название микросервиса (оно должно быть таким же, как указано в Gateway)
  name: 'users',
  // настройки rabbitmq
  rabbit: {
    // ссылка для подключения к rabbitmq (default: amqp://guest:guest@localhost:5672)
    url: process.env.RABBIT_URL,
  },
});

// создаем эндпоинт /friends для метода GET
app.get('/friends', (req, res) => {
  // отправляем json ответ
  res.json([
    {
      id: 1,
      name: 'Mikhail Semin',
    },
    {
      id: 2,
      name: 'Ivan Ivanov',
    },
  ]);
});

// создаем эндпоинт /status для метода GET
app.get('/status', (req, res) => {
  // отправляем json ответ
  res.json({
    text: 'Thinking...',
  });
});

// начинаем слушать очередь запросов
app.start();

Как это будет работать:


  1. Микросервис запускается, начинает слушать очередь запросов, в которую будет писать Gateway
  2. Микросервис получает запрос, обрабатывает его, прогоняя через все имеющиеся middlewares
  3. Микросервис отправляет ответ в Gateway
    3.1. Идет отправка сообщения (заголовки, HTTP-код тело ответа) в очередь RabbitMQ
    3.2. Gateway слушает эту очередь, получает сообщение, находит клиента, которому нужно отправить ответ
    3.3 Gateway отправляет ответ клиенту

Миграция монолита на микросервисную архитектуру


Предположим, что у нас уже есть приложение на express, и мы хотим начать его переносить на микросервисы.


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


const express = require('express');

const app = express();

app.get('/balance', (req, res) => {
  res.json({
    amount: 500,
  });
});

app.get('/friends', (req, res) => {
  res.json([
    {
      id: 1,
      name: 'Mikhail Semin',
    },
    {
      id: 2,
      name: 'Ivan Ivanov',
    },
  ]);
});

app.get('/status', (req, res) => {
  res.json({
    text: 'Thinking...',
  });
});

app.listen(process.env.PORT);

Мы хотим вынести из него 2 эндпоинта: /friends и /status. Что нам для этого нужно сделать?


  1. Вынести бизнес-логику в микросервис
  2. Реализовать делегирование запросов на эти два эндпоинта в микросервис
  3. Получать ответ из микросервиса
  4. Отправлять ответ клиенту

В примере выше, когда мы создавали микросервис users, мы реализовали два метода /friends и /status, который делают то же самое, что делает наш монолит.


Для того, чтобы проксировать запросы в микросервис из gateway, мы воспользуемся тем же пакетом, подключив middleware в наше express приложение:


const express = require('express');

// импортируем класс Gateway из раннее установленного пакета micromq
const Gateway = require('micromq/gateway');

const app = express();

// создаем экземпляр класса Gateway
const gateway = new Gateway({
  // название микросервиса (оно должно быть таким же, как указано в Gateway)
  name: 'users',
  // настройки rabbitmq
  rabbit: {
    // ссылка для подключения к rabbitmq (default: amqp://guest:guest@localhost:5672)
    url: process.env.RABBIT_URL,
  },
});

// подключаем middleware в монолит, который позволит нам делегировать запросы
app.use(gateway.middleware());

// не трогаем этот эндпоинт, потому что мы не планируем переносить его в микросервис
app.get('/balance', (req, res) => {
  res.json({
    amount: 500,
  });
});

// создаем два эндпоинта /friends & /status на метод GET
app.get(['/friends', '/status'], async (req, res) => {
  // делегируем запрос в микросервис users
  // метод res.delegate появился благодаря middleware, которую мы подключили выше
  await res.delegate('users');
});

// слушаем порт
app.listen(process.env.PORT);

Это работает так же, как в примере выше, где мы писали чистый Gateway. В этом примере разница лишь в том, что запросы принимает не Gateway, а монолит, написанный на express.


Что дальше


  1. RPC (удаленный вызов действия) из микросервиса в монолит/gateway (например, для авторизации)
  2. Общаться между микросервисами через очереди RabbitMQ для получения дополнительной информации, ибо у каждого микросервиса своя база данных

Это я уже рассказал в статье «Учимся общаться между микросервисами на Node.js через RabbitMQ».


Комментарии (34)


  1. KostaArnorsky
    07.04.2019 15:34

    Учитывая, что микросервисы у вас асинхронные, какое преимущество дает использование RabbitMQ в данном случае?


    1. bifot Автор
      07.04.2019 16:19

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

      Как микросервис включится, клиент получит запрос, так как его запрос не потерялся нигде. Но это зависит от таймаута настроенного (по дефолту он 15 секунд).


      1. KostaArnorsky
        07.04.2019 18:45

        Вариант, но бесшовность и надежность можно достигнуть и другими способами. Я предпочитаю blue/green посредством чего-то вроде Kubernetes+Helm. К тому же RabbitMQ и гейтвей тоже могут навернуться, резервирование наше все.
        Я к тому, что из статьи не совсем очевидно, зачем очередь нужна. Я бы добавил пару предложений, объясняющих назначение RabbitMQ. А так же подчеркнул, что это только одно из возможных решений, чтобы начинающие разработчики не повторяли бездумно, а понимали что они делают и зачем.


        1. bifot Автор
          07.04.2019 23:08

          Спасибо за комментарий, добавил в статью объяснение.


      1. le1ic
        08.04.2019 17:05

        Это все (очередь) можно реализовать на стороне (внутри) отправителя запроса, очевидно. Ещи и надежней будет, т.к. не будет single point of failure (rabbit)


    1. SirEdvin
      07.04.2019 17:42

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


  1. funca
    07.04.2019 21:26

    Спасибо за статью. Какие новые ограничения накладывает архитектура при переходе с express? Будет ли ограничен размер запросов и ответов, возможен ли стриминг? Какова стратегия при сбое в обработчике запроса и можно ли ее кастомизировать?


    1. bifot Автор
      07.04.2019 23:03

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


  1. bifot Автор
    07.04.2019 23:02

    Ограничение запросов и ответов вы можете указать самостоятельно, если нужно. Возможно, эта фича будет в будущих обновлениях. Из ограничений это, конечно, использование только локальной базы данных. Теперь вы не можете, к примеру, получить баланс пользователя из микросервиса пользователей, отправив запрос к базе. Для этого вам придется отправлять запрос по рэббиту в микросервис баланса (см. метод ask).

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

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


  1. PaulMaly
    08.04.2019 00:12

    А в обратную сторону оно работает. Например, у меня часть бизнес-логики, которую я хочу вынести в микросервис завязана на Express. Как тогда поступить?


    1. bifot Автор
      08.04.2019 00:20

      Объясните, пожалуйста, как именно она завязана на экспрессе? Что мешает вынесению в микросервис?


      1. PaulMaly
        08.04.2019 00:33

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


        1. bifot Автор
          08.04.2019 09:51

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

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

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


          1. apapacy
            08.04.2019 10:13

            Это не совсем так. Микросервиснаяя архитектура породила свою вторую часть это средства оркестрами kubernetis, nomad и т.п. Оркестраторы следят за роутингом, а также помогают с деплоями, а ещё делают проверку доступности микросервисов ответ случае его падения запускают реплики.
            Единственная функциональная разница в решении с сообщениями которую в Вашей реализации это правило выбора реплики микросервисов. И.к. микроскруисов сам выбирает очередное событие то он естественно сделает это после своего освобождения. Хотя это тоже может быть не всегда оптимально. Я не знаю в подробностях Вашу реализацию. Но пусть мы допускаем что на реплика могут выполняться о одного до трёх параллельных запросов. Тогда уже не все так просто получается. Если например система загружена слабо то у нас может одна реплика выбрать три запроса а другая простаивать в это время. То есть проблема с дисциплиной выбора реплики не ушла а сместилась в другую плоскость.


            1. bifot Автор
              08.04.2019 10:18

              Понял вас.

              > система загружена слабо то у нас может одна реплика выбрать три запроса а другая простаивать в это время

              Это может быть задано в логике гейтвея, написать веерную балансировку. Например, запускаем три микросервиса пользователей, очереди для запросов: users:requests-1, users:requests-2, users:requests-3. И, соответственно, отправляем первый запрос в первый микросервис, второй во второй, третий в третий, четвертый в первый и т.д.


          1. PaulMaly
            08.04.2019 12:58

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

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

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


            1. bifot Автор
              08.04.2019 13:03
              +1

              >на легаси проектах переписывать микросервисы на другой роутинг и мидлвары может оказаться дороже

              Смотря на каких легаси, если это легаси на экспрессе, то никаких проблем не будет. Роутинг, миддлвари обратно совместимые в моей библиотеки с экспрессом.

              >можно ли как-то организовать общение между 2-ся гейтвеями

              Да, можно. Здесь неважно — гейтвей это или микросервис. Существует метод ask, который отправляет запрос в микросервис по реббиту. Можно сделать какую угодно цепочку делегирования.


              1. PaulMaly
                08.04.2019 18:03

                А, ну вот это ровно то, что я хотел узнать. Спасибо!


  1. Dart_Sergius
    08.04.2019 03:56

    Автор, если вы собираетесь делегировать полностью контроллеры, почему вы не хотите использовать nginx для этого? Или у вас в ТЗ была поставлена задача обрабатывать все запросы независимо от нестабильности бекенда? Или бекенд был настолько нестабилен что такое решение было самым правильным?
    Мне всегда казалось что микросервисная архитектура отличается от монолита тем, как именно ты получаешь доступ к нужным данным. Т.е. ты выделяешь отдельные компоненты, пишешь API — которого тебе должно хватить, и используешь только его, без попыток «хакнуть внутрянку» и подробно документируя все составляющие твоего проекта. Но здесь же — просто балансировка. Подними с той стороны такой же монолит — и ничего не поменяется. Напиши перенаправление по всем контроллерам, подними нужное количество «микросервисов» ( в данном случае просто экземпляры вашего монолита ) — и получите «микросервисную архитектуру»?


    1. bifot Автор
      08.04.2019 09:59

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

      Нджиниксом не проксируется потому, что иногда могут понадобятся какие-то данные (авторизации, к примеру). Гейтвей может делегировать запрос с дополнительной информацией в микросервис. Конечно, с нджиниксом можно сделать так же (nginx lua, к примеру), но здесь разница в удобстве большая.

      > пишешь API — которого тебе должно хватить, и используешь только его, без попыток «хакнуть внутрянку»

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

      > Подними с той стороны такой же монолит — и ничего не поменяется.

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


      1. Dart_Sergius
        08.04.2019 18:42

        Нджиниксом не проксируется потому, что иногда могут понадобятся какие-то данные

        Великолепно проксируется — глобальный session id у вас великолепно пробрасывается через cookie с помощью nginx( в вашем случае все данные по запросу как я понимаю приходится запрашивать отдельно ). А так же вы можете сгенерировать request id для лучшего чтения логов по разным микросервисам — но это уже тонкости логирования. По глобальному session id — вы прекрасно получаете всю информацию об авторизации и прочим параметрам, сохраненных в сессии. Здесь прекрасно подойдет redis или любое подобное key-value решение. Тем более никто не мешает вам из микросервиса обратиться к микросервису. Не в публичный endpoint — а напрямую к требуемому микросервису.

        Ваша бизнес-сущность (например, пользователи) будут изолированы от другой бизнес-сущности (например, товары).

        А кто вам мешает их разделить в монолите? Кто вам навязывает только 1 соединение с базой данных в монолите? К примеру я в монолите великолепно создавал ещё одно соединение, и использовал его после в моделях. Очень многие фреймворки позволяют переопределить в моделях используемую бд.


        1. bifot Автор
          08.04.2019 18:52

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

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

          Вариант с проксированием через NGINX и сессии в микросервисе:

          request -> nginx -> microservice #1 -> microservice #2 (получаем сессию) -> microservice #1 (обрабатываем ответ согласно бизнес-логике) -> response

          Шагов куда больше, чтобы понять, что пользователь неавторизован.

          Вот схема с gateway:

          Неавторизованный пользователь: request -> gateway -> response
          Авторизованный пользователь: request -> gateway -> microservice #1 -> response

          С такой схемой, если пользователь не авторизован, мы сразу отвечаем ему и не отправляем сообщение в раббит (микросервис).


          1. Dart_Sergius
            08.04.2019 20:16

            Вариант с проксированием через NGINX и сессии в микросервисе:

            request -> nginx -> microservice1 ( -> microservice{2,3...} ) -> response
            Ведь никто не говорит вам запрашивать синхронно только с одного микросервиса нужные данные? К тому же безопасность — если у вас появляется где-то уязвимость инжекта запросов, вы ничего не потеряете, потому что ваш микросервис имеет проверки допустимости и не полагается на то, что его вызывают правильно. И если кто-то проникает в вашу сеть, он не сможет без валидной сессии что-то сделать с вашими данными. И отлаживать приятнее — если у вас возникает вопрос — а почему сервис N не отрабатывает — вы ищите проблему только в логах сервиса N ( ну и опционально, если нам ответили ошибкой — тогда идем в нужный микросервис, и соответственно в 1-м месте видим все требования к запросам, и что не было выполнено ).

            С такой схемой, если пользователь не авторизован, мы сразу отвечаем ему и не отправляем сообщение в раббит (микросервис).

            Для оптимизации — без вопросов. Но так ли высоки издержки если мы через rabbitmq пошлем запрос и он в самом начале обработки скажет нам 403? По моему опыту — это больше «преждевременная оптимизация», и это в крайне редких случаях является узким местом. А там, где является — там уже highload, и действуют уже немного другие законы ( и естественно ваша библиотека там наверняка не работает ).


        1. bifot Автор
          08.04.2019 18:54

          >А кто вам мешает их разделить в монолите?

          Если компания захочет взять другой язык для определенного сервиса? Другую версию языка? Что насчет изоляции того же редиса, к примеру? Поднимать несколько штук на разных портах?


          1. Dart_Sergius
            08.04.2019 20:24

            Простите, но вы же не утверждаете что из языка в язык общение с redis как-то меняется? Смена языка в данном случае ваще ни на что не влияет. Оно влияет лишь на то — как будет передан запрос. Будет ли это обычный proxy_pass или ваш rabbitmq. Да и то — вопрос удобства, не более.
            И просто взять и захотеть сменить язык — это требует кадров, которые умеют работать с этим языком как минимум. Если компания имеет специалистов разного уровня и с разным набором языков, почему она не может найти человека, который допишет конфиг в nginx?


            1. bifot Автор
              08.04.2019 20:27

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

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


              1. Dart_Sergius
                08.04.2019 20:40

                Я лишь выступаю против написания балансировщика на node(просто хотя бы потому что express весьма слабо для такого приспособлен, и возможности у того же nginx в плане производительности выше). А у вас пока именно он и описан — вы прозрачно передаете управление по контроллерам.

                Если в проекте есть документация, явно видны связи, явно видно где находится endpoint, который надо поправить — то даже у джуна не возникнет особых проблем его поправить. В случае с микросервисами — у джуна могут возникнуть проблемы плана «а как получить некоторую информацию». Как обработать ошибки при получении этой информации? Надо ли повторять запрос в микросервис? Если надо — то через какое количество времени и сколько раз? Хотя я не спорю что бывают такие проекты, в которых и сениер схватится за голову.


                1. bifot Автор
                  08.04.2019 20:42

                  По поводу прокси через экспресс — я с вами полностью согласен, это плохой вариант. Я рассматриваю вариант поднятия нескольких гейтвеев и проксирования их через апстрим нджиникса: nginx -> gateway upstream -> rabbitmq


                  1. Dart_Sergius
                    08.04.2019 20:48

                    Однако для случая «передать все запросы на несколько контроллеров на другой upstream» — вполне хватит и nginx(а у вас именно такой случай). И без rabbitmq. Без rabbitmq вы конечно не сможете быстро переподнимать бекенд при падении прозрачно для пользователя ( они лишь будет ожидать ответ дольше ), но логику повторного запроса требуемых данных с фронта вроде не так уж и сложно организовать?


                    1. bifot Автор
                      08.04.2019 20:53
                      +1

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

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


                    1. lega
                      09.04.2019 10:21

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


                      1. Dart_Sergius
                        09.04.2019 17:42

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


                        1. lega
                          10.04.2019 13:55

                          Если нода упала, то nginx (балансер) продублирует запрос на другую ноду (зависит от настроек).


                          1. Dart_Sergius
                            10.04.2019 15:03

                            Для запросов на чтение — бесспорно. Для запросов на запись — спорное решение.