Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Модуль 6. Взаимодействие между микросервисами. RabbitMQ #6

Merged
merged 33 commits into from
May 1, 2024

Conversation

AntonovIgor
Copy link
Contributor

No description provided.

Для хранения пользовательских файлов создадим директорию `uploads`. Файлы в этой директории не должны попадать в систему контроля версий, поэтому добавим исключение в `.gitignore`. Чтобы сохранить пустую директорию, добавим в ней файл `.gitkeep` и для этого файла также пропишем исключение в `.gitignore`.
Пакет fs-extra является расширением стандартного модуля `fs` в Node.js и добавляет дополнительные функции для работы с файловой системой, которые не включены в базовый модуль `fs`. Предоставляет дополнительную функциональность  и упрощает ряд задач: копирование файлов и директорий, удаление директорий, работа с JSON файлами и так далее.

Некоторые полезные возможности:

* Копирование файлов и директорий: fs-extra добавляет методы `copy` и `copySync`, упрощают копирование файлов и директорий.

* Удаление директорий. Методы `remove` и `removeSync` позволяют удалять файлы и директории, включая непустые директории.

* Работа с JSON файлами. Методы `readJson`, `readJsonSync`, `writeJson`, и `writeJsonSync` можно легко читать и записывать JSON файлы без необходимости вручную преобразовывать данные в JSON и обратно.

* Обработка путей. Методы `ensureFile`, `ensureFileSync`, `ensureDir`, и `ensureDirSync` убеждаются, что файл или директория существуют, и если нет, то создают их.

И так далее…
Сервису `file-vault` потребуются настройки. Поступим по аналогии с сервисом `account` и вынесем конфигурацию в общую библиотеку Nx. Для создания библиотеки воспользуемся командой:

```
npx nx generate @nx/node:library file-vault-config --directory libs/file-vault/config
```

Опишем модуль `FileVaultConfigModule` и `file-vault.config.ts`. Из настроек пока зафиксируем путь к директории для загрузки файлов, порт, окружение. Позже добавим дополнительные настройки для подключения к базе данных.
Начнём разработку микросервиса `file-vault` с создания сервиса `FileUploaderService`. В этом сервисе мы реализуем несколько методов, которые пригодятся для управления файлами. Начнём с основного — запись файла на диск.

Для этого опишем метод `saveFile`. Он принимает аргументом `file`, полученный от клиента и сохраняет его в директорию, которую мы определили для хранения файлов.

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

Путь, по которому произойдёт сохранение файла мы определим в переменной `destinationFile`. Его получаем из комбинации `uploadDirectoryPath/originalname`.

Для определения пути к директории для сохранения файлов добавим два вспомогательных метода: `getUploadDirectoryPath` и `getDestinationFilePath`. Первый вернёт путь к директории для загрузки файлов из конфигурации, а второй соединит путь и имя файла.

Перед записью файла используем функцию `ensureDir` из пакета `fs-extra`. Она проверяет наличие директории и если её нет (или вложенных директорий), то создаёт. После этого выполняем запись файла. Результатом выполнения метода станет путь к загруженному файлу.

WIP: Разработка сервиса ещё не завершена.
Следующим шагом добавим контроллер. В этом контроллере нам потребуется два обработчика маршрута: `files/upload` (загрузка файлов) и `files/:id` (получение файла). Начнём с первого.

`Multer` — middleware для Express, позволяющая обрабатывать файлы, полученные от клиента.

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

В коде обработчика достаточно вызвать метод `saveFile` у ранее подготовленного сервиса.

WIP: Разработка сервиса ещё не завершена.
Соберём всё вместе для проверки. Добавим модуль `FileUploaderService`. Подключим контроллер `FileUploaderController` и `FileUploaderService`. Затем в `app.module.ts` импортируем
`FileUploaderModule` и `FileVaultConfigModule`, который подготовили для получения настроек сервиса `file-vault`.

Также добавим файл `file-uploader.http`. В нём подготовим запрос для загрузки файла.

Первая часть сервиса готова. Можно запустить `nx run file-vault:serve` и
протестировать его в работе.
Файлы успешно загружаются, теперь нужно позаботиться об их доступности снаружи.

В `production` для этого обычно применяется веб-сервер nginx. Он настраивается на директорию с загруженными файлами и отвечает за их доставку клиентам. Такой подход наиболее оптимален с точки зрения производительности.

Чтобы не усложнять решение и не заниматься задачами, связанными с администрированием, решим её с помощью встроенных средств в Nest. Установим пакет `@nestjs/serve-static` и будем раздавать статичные файлы с его помощью.

Пакет установим в основные зависимости.
Воспользуемся модулем `ServeStaticModule` в модуле `FileModule`. Подключение выполняется стандартным способом: либо методом `forRoot`, либо `forRootAsync`.

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

В метод `forRootAsync` передадим объект: укажем сервис, который требуется внедрить в качестве зависимости (`inject:[ConfigService]`). Им мы воспользуемся при создании `ServerStaticModuleOptions`. Чтобы подготовить этот объект, мы опишем функцию `useFactory`. Это функция фабрики. В Nest она используется для создания и настройки экземпляров сервисов, провайдеров и других компонентов.

Сигнатура функции `useFactory` выглядит следующим образом:

```
useFactory: (...args: any[]) => T | Promise<T>
```

Параметр типа `T` обозначает тип создаваемого экземпляра компонента, который будет возвращать фабрика. Аргументом `useFactory` принимает другие провайдеры, которы могут потребоваться для создания компонента.

Мы передаём `ConfigService`, который позволит нам получить доступ
к извлечённым настройкам. Сервис будет внедрён с помощью DI (см. `inject`).

Результатом функции должен стать экземпляр компонента или промис, который зарезолвится в экземпляр компонента.

Внутри функции мы извлекаем настройку к директории со статичными файлами. Затем возвращаем массив настроек для `ServerStaticModule`.

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

* `rootPath` — путь к директории со статичными файлами;
* `serveRoot` — задаёт корневую директорию статических файлов. По умолчание в настройке пустая строка. Это означает, что для получения статического файла необходимо обратиться к корневому маршруту сервиса: `http://localhost:3000/file.txt`.

Чтобы задать дополнительный сегмент, например, `static`, и предназначена опция `serveRoot`.
Помимо основных настроек мы можем задать дополнительные. Их следует перечислить в ключе `serveStaticOptions`. Мы укажем две, но их
больше. Список поддерживаемых настроек:

* `etag` — включает или отключает отправку заголовка ETag в ответ на запросы к статическим файлам. Значение по умолчанию - `true`.

* `lastModified` — включает или отключает отправку заголовка Last-Modified в ответ на запросы к статическим файлам. Значение по умолчанию - true.

* `maxAge` — устанавливает время жизни кэша для статических файлов, выраженное в миллисекундах. Значение по умолчанию - 0 (кэширование отключено).

* `immutable` — включает или отключает отправку заголовка `Cache-Control: immutable`, который указывает на то, что статический файл никогда не будет изменен. Это позволяет клиентам кэшировать файлы более длительное время. Значение по умолчанию - `false`.

* `index` - имя файла, который должен использоваться в качестве индексного при запросе к корневой папке. Например, если установить `index` в 'index.html', то при запросе к корневой папке будет отображаться файл `index.html`. Значение по умолчанию - `false` (отключено).

* `redirect` — включает или отключает перенаправление при запросе к директории. Если установлено значение `true`, то при запросе к директории будет перенаправление на соответствующий URL с добавлением /. Значение по умолчанию - `false`.

* `setHeaders - функция для установки пользовательских заголовков в ответ на запросы к статическим файлам. Функция принимает два аргумента: `res` - объект `http.ServerResponse`, и `path` - строка, указывающая на путь к запрашиваемому файлу.

* `fallthrough` — должен ли сервер продолжать обработку запроса, если статический файл не найден. Если свойство установлено в `true`, то сервер продолжит обработку запроса и передаст его следующему обработчику маршрута. Если свойство установлено в `false`, то сервер вернет ответ с ошибкой `404`.
Пока все файлы загружаются в корень директории `uploads`. Неплохой вариант для начала, но если продолжать в том же духе, то рано или поздно в ней будет собираться много файлов. Это некритично, но доступ к той директории будет затруднён (например,
при переходе в файловом менеджере, выводе списка файлов и так далее).

Улучшить решение поможет дополнительное структурирование файлов внутри директории.
Например: сохранять файлы с привязкой к году и месяцу. Тогда в директории будет примерно такая структура: `upload/2024/04` — файлы, загруженные в апреле 2024.

Реализуем на практике. Для получения текущего года и даты воспользуемся `dayjs`. Получим текущий месяц и год. Затем добавим их к формированию пути для директории `uploads`.

Вот здесь вновь пригождается функция `ensureDir` из `fa-extra`. В случае отсутствия директорий, она создаст их автоматически, включая вложенные.
Добавление внутренней структуры для директории `uploads` улучшила опыт хранение файлов. Однако, до сих пор есть проблема с перезаписью файлов. Например, два клиента загружают файл с одним и тем же именем. Что в этом случае произойдёт?

Последний запрос перезатрёт то, что сделал первый. Файл будет один. Причина этому — в одной директории не может быть двух файлов с одинаковым именем.

Давайте решим эту проблему. Для этого воспользуемся встроенным модулем `crypto`. С его помощью будем генерировать случайное имя для файла. Расширение для файла получим на основании mime/type с помощью одноимённого пакета (`mime-types`). Этот пакет также необходимо установить в основные зависимости.

В качестве имени файла будем использовать UUID (Universally Unique Identifier). Он состоит из 128 бит. Формировать UUID будем с помощью встроенного модуля `crypto`.
В настоящий момент сервис `file-vault` возвращает полный путь к сохранённому файлу.

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

Во-вторых, нам нужно подумать как мы будем использовать информация о файлах. Например:
пользователь загружает аватарку. Мы получаем от сервиса путь к файлу. Что с этим делать?
Можно, например, сразу сохранить в базу данных сервиса `users`. Добавив сразу путь с
учётом раздачи статики. Однако, это не лучший вариант. Так возможно файл в будущем
будет перемещаться, например, в другое хранилище или директорию.

Мы попробуем сделать сервис `file-vault` более самостоятельным. Он будет хранить сам
всю информацию о загруженных файлах, которую можно будет получить из других сервисов
Для этого нам потребуется снабдить сервис `uploader` собственной базой данных.

Воспользуемся MongoDB и подготовим `docker-compose`:

```
docker compose \
--file ./apps/file-vault/file-vault.compose.dev.yml \
--env-file ./apps/file-vault/file-vault.env \
--project-name "typoteka-file-vault" \
up \
-d
```
Для подключения к MongoDB, базе, в которой будет хранится информация о загруженных файлах, потребуются параметры. Добавим их в конфигурацию сервиса `file-vault`.
Подключим сохранение информации о загруженных файлах в MongoDB. Для этого создадим Entity, Model и репозиторий.
Отправка писем осуществляется через SMTP-сервер. Для тестирования удобно развернуть локальный smtp-сервер. Для этой задачи хорошо подойдёт сервер `fake-smtp-server`. Он предоставляет услуги сервера, а также простой интерфейс (в том числе и REST) для просмотра писем, отправленных через сервер.

Эта возможность пригодится во время отладки.

По традиции создадим `notify.compose.dev.yml` в корневой директории сервиса `notify` и опишем в нём установку первого сервиса. В качестве образа воспользуемся `gessnerfl/fake-smtp-server`.

Обратите внимание на секцию `ports`. Мы пробрасываем два порта: `8025` и `1083`.

Первый (`8025`) — порт SMTP-сервера. Именно с ним в будущем мы будем соединяться из нашего сервиса. На втором порту (`1085`) доступен веб-интерфейс для проверки отправленных писем (например, http://localhost:1085). Если добавить к адресу `/api/emails` (http://localhost:1085/api/emails) вы попадёте на REST-интерфейс. Полное описание REST-интерфейса доступно в Swagger — `http://localhost:1085/swagger-ui/index.html`.

Попробуйте отправить тестовое письмо, например, с помощью `cURL`:

```
curl smtp://localhost:8025 --mail-from [email protected] --mail-rcpt [email protected] --upload-file ./email.txt
```

Соединяемся с локальным почтовым сервером и готовим письмо для `[email protected]`. Текст письма и заголовки в файле `email.txt`:

```
From: Igor Antonov <[email protected]>
To: Keks <[email protected]>
Subject: Hello Keks
Date: Mon, 12 Dec 2022 08:00:00
Content-Type: text/html; charset=utf8

<html>
<body>
Hello, Keks!
</body>
```

Обратите внимание на `docker-compose.dev.yml`. В нём мы воспользовались директивой `hostname`. Она позволяет задать определённое имя хоста для сервиса в контейнере Docker.

По умолчанию Docker автоматически создаёт уникальное имя для каждого сервиса, используя формат `service_number`. Директива `hostname` позволяет задать определённое имя
сервиса.

```
docker compose \--file ./apps/notify/notify.compose.dev.yml \
--project-name "typoteka-notify" \
up \
-d
```
Следующим шагом установим брокер сообщений. В качестве брокера
воспользуемся RabbitMQ. Установку выполним с помощью Docker. Для этого откроем `docker-compose.yml`, расположенный в корневой директории сервиса notify и добавим ещё одну секцию для описания RabbitMQ.

В качестве образа воспользуемся `rabbitmq:3.11-management`. Он включает сам RabbitMQ и веб-интерфейс для администрирования. С помощью веб-интерфейса мы сможем просматривать очереди, создавать новые и выполнить многие другие действия, связанные с администрированием Rabbit.

Обратите внимание на секцию `ports`. Наружу мы пробрасываем два порта `1088` (`15672`) и `5672` (`5672`). На первом доступен веб-интерфейс для администрирования (`http://localhost:1084`), а на втором служба RabbitMQ. С ней установим соединение из сервиса `notify`.

Также обратите внимание на секцию `healthcheck`. Проверять доступность Rabbit мы будем с помощью утилиты `rabbitmq-diagnostics`.
Сервису notify потребуется хранить информацию о подписчиках, которым
будут отправляться email. Для этого сервису Notify потребуется отдельная база данных. Для этого задачи не потребуется сложных выборок, поэтому MongoDB отлично справится с этой задачей. Добавим в `docker-compose` установку MongoDB.

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

Пожалуй, отдельное внимание следует обратить на локальный порт. Служба MongoDB ожидает подключений на порт `27017`. До этого мы всегда мапили порт службы в контейнере на аналогичный локальный порт. В этот раз так поступить не можем.

Локальный порт `27017` уже занят другим сервисом MongoDB. Поэтому сделаем проброс на `27020`.
Нам потребуется интерфейс для описания подписчиков. Объявим его в
библиотеке `shared/core`. В интерфейс предусмотрим поля, которые потребуются для решения задачи отправки уведомлений.

Нам точно потребуется информация о email, имени и фамилии пользователя.
По аналогии с другими сервисами подготовим отдельную библиотеку для
конфигурирования `notify`. Нам потребуются настройки для подключения к MongoDB и Rabbit.
Для взаимодействия с RabbitMQ потребуется отдельный пакет.
В составе Nest (также распространяется в виде пакета) есть официальный
пакет для работы с RabbitMQ в контексте микросервисов.

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

Важные плюсы `@golevelup/nestjs-rabbitmq`:

1. Проще использовать. Декларативный интерфейс.
2. Готовые декораторы для использования разных паттернов взаимодействия
с RabbitMQ.
3. Интеграция с другими модулями.
4. Возможность использовать несколько очередей.
@AntonovIgor AntonovIgor self-assigned this Apr 24, 2024
Реализуем функции `getRabbitMQOptions` и `getRabbitMQConnectionString`.

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

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

Примеры рефакторинга мы рассмотрим в одном из следующих разделов.
Routing Key — это строка, которая используется для определения, какие очереди в RabbitMQ получат определённые сообщения. Когда producer отправляет сообщение в exchange, он также указывает `Routing Key`. Этот ключ связывает сообщение
с определённой очередью.

Обменник использует Routing Key для определения, какие очереди нужно отправить сообщение. Если Routing Key совпадает с Routing Key, связанным с очередью, сообщение отправляется в эту очередь.

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

Заведём отдельное перечисление `RabbitRouting`. В нём определим все возможные RoutingKey.
Логику по сохранению подписчика в базе данных MongoDB опишем в отдельном модуле
`EmailSubscriber`. Похожую процедуру проделывали уже неоднократно, поэтому
сильно заострять внимание не станем.

WIP: Пока не создан  `EmailSubscriberController`.
Последним шагом добавим `EmailSubscriberController`.

В модуле подключим `RabbitMQModule`. Для получения настроек воспользуемся
функцией `getRabbitMQOptions`.

Контроллер `EmailSubscriberController` выглядит не как обычно. Да, здесь
по-прежнему используется декоратор `@Controller`, но теперь этот контроллер
не обрабатывает HTTP-запросы. Он следит за появлением новых сообщений
в очереди `typoteka.notify.income`. Здесь применяет паттерн взаимодействия с Rabbit — pub/sub.
Чтобы полноценно протестировать добавление подписчиков, добавим в
сервис `accounts` возможность взаимодействовать с RabbitMQ. Для этого обновим конфигурацию приложения и добавим новый модуль `notify`.

Отправку сообщений в RabbitMQ сделаем в сервисе `Notify`.
Для отправки почтовых уведомлений потребуются дополнительные пакеты.
Основной из них: nodemailer. Он предоставляет всю необходимую
функциональность для отправки почты из приложений на Node.js. Для этого
пакета также потребуется установить описания типов — `@types/nodemailer`.
Последним пакетом установим `@nestjs-modules/mailer`. Это обёртка над
пакетом `nodemailer` для интеграции в приложение на Nest с несколькими
дополнительными функциями.

`nodemailer` поддерживает:

* Шаблоны почты. Поддерживает использование шаблонов
почты с помощью различных движков рендеринга, таких как: Handlebars,
Pug, EJS, Nunjucks и др.

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

* Настройка SMTP. Поддерживает различные настройки SMTP: SSL,
авторизация и т.д.

* Кеширование. Поддерживает кеширование, что может повысить
производительность вашего приложения и уменьшить время ответа.
Email может быть оформлен в виде обычного текста или с использованием
HTML. Некоторые письма могут содержать дополнительную информацию из
приложения. Например, какие-то данные из базы (имя и фамилия
пользователя). В таких случаях удобно воспользоваться услугами
шаблонизаторов. С их помощью удобно готовить динамические шаблоны.

Для демонстрации мы воспользуемся шаблонизатором `handlebars`.
Установим его в основные зависимости приложения.
Для разных действий нам потребуются разные шаблоны. Шаблоны удобно
размещать в директории `assets`, которая генерируются автоматически
при создании нового проекта в `nx`.

Добавим первый шаблон `add-subscriber.hbs`. Этот шаблон будем использовать
при добавлении нового подписчика для рассылок.

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

В нашем примере мы определяем переменную `user` и `email`. При подготовке письма на
их место мы сможем подставить значения из приложения.
Для подключения к почтовому серверу потребуются настройки. Их мы передаём через переменные окружения. По аналогии с другими сервисами подготовим конфигурацию.
Добавляет модуль для отправки email в сервис `notify`. На текущем этапе максимально упростим отправку. Реализуем единственный метод `sendNotifyNewSubscriber`.
Внедрим в `EmailSubscriberController` сервис `MailService`. В обработчике `create` добавим отправку уведомлений с помощью вызова метода `sendNotifyNewSubscriber`.
Завершим разработку модуля `email-subscriber` подключением модуля для отправки почты.
@AntonovIgor AntonovIgor merged commit 95dac1f into main May 1, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant