JavaRush /Курсы /Docker for Spring /Минимальный путь сообщения

Минимальный путь сообщения

Docker for Spring
20 уровень , 3 лекция
Открыта

1. Минимальный messaging-путь

Подключение к RabbitMQ и Queue bean сами по себе ещё ничего не доказывают. Пока сообщение не прошло путь от HTTP-действия до лога получателя, wiring остаётся только красивой гипотезой. Поэтому в этой лекции мы сознательно соберём максимально маленький, но честный путь сообщения: одна очередь, одна строка и один получатель, который пишет в лог.

Сценарий будет выглядеть примерно так (и это действительно вся «архитектура» на сегодня):

sequenceDiagram
    participant C as "HTTP-клиент (curl/Postman/.http)"
    participant A as "Catalog Service (Spring Boot)"
    participant MQ as RabbitMQ
    C->>A: POST /api/catalog/items
    A->>MQ: send "ITEM_CREATED:42:SKU-42"
    MQ-->>A: deliver message to listener
    A-->>A: log "Received from catalog.events: ITEM_CREATED:42:SKU-42"

Обратите внимание на тонкость: publisher и consumer живут в одном и том же приложении. В «настоящей» жизни это часто два разных сервиса, но для учебной цели это даже плюс. Мы видим весь путь в логах одного контейнера и можем не отвлекаться на межсервисные дебаты, которые лучше оставить другим курсам.

Чтобы не путаться в терминах, зафиксируем мини-карту в виде таблички. Она не ради теории, а чтобы вы могли на следующей неделе открыть логи и понять, где «оборвался провод».

Роль Что это в нашем проекте Какой код/объект это делает
Publisher тот, кто отправляет сообщение AmqpTemplate.convertAndSend(...)
Queue «почтовый ящик» с именем Queue bean, например "catalog.events"
Broker сервис, который хранит и раздаёт сообщения контейнер rabbitmq в Compose
Consumer тот, кто получает сообщение @RabbitListener(...) метод

2. Формат сообщения: строка

Сразу скажу честно: «строка» — не потому что это «как в 2005-м», а потому что это лучший формат для первого шага, когда вы учитесь видеть причинно‑следственную цепочку. Как только мы сделаем сообщение объектом, у нас появится дополнительная ось сложности: DTO-класс, сериализация, конвертеры, совместимость, поля, версии контракта. Это всё полезно, но сейчас оно только мешает понять, что вообще происходит.

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

Например, для события «создан элемент каталога» можно сделать сообщение вида:

ITEM_CREATED:<id>:<sku>

Чтобы не размазывать формат по коду и не ловить «ой, тут двоеточие, а тут тире», удобно вынести сборку сообщения в маленький утилитный класс. Он не «архитектура ради архитектуры», а просто способ держать формат в одном месте.

public final class CatalogEventMessages {
    private CatalogEventMessages() {}

    public static String itemCreated(long itemId, String sku) {
        // Формируем человекочитаемый контракт сообщения: TYPE:<id>:<sku>
        return "ITEM_CREATED:%d:%s".formatted(itemId, sku);
    }
}

Здесь приятный бонус в том, что formatted(...) читается нормально даже новичком: мы подставляем id и sku, и получаем готовую строку. И да, если вы сейчас думаете «а что если sku содержит двоеточие?» — это хорошая мысль. В реальном контракте вы бы выбирали формат аккуратнее, но для учебного smoke-сценария это достаточная дисциплина.

Имя очереди у нас уже зафиксировано в CatalogQueues.CATALOG_EVENTS, поэтому здесь не появляется вторая точка конфигурации. Это тоже часть читаемости: очередь одна, её имя одно, и sender с listener смотрят ровно в него.

3. Publisher: отправка через AmqpTemplate

Самая частая начинающая ошибка в messaging-интеграциях — пытаться «вплести RabbitMQ в бизнес-логику так тесно, чтобы без него ничего не работало». В нашем проекте уже есть правило: профили (standalone, postgres, cache, messaging) должны менять инфраструктурное окружение, а не ломать основные use-case’ы приложения. Значит, код публикации события должен быть подключаемым и выключаемым аккуратно.

Абстракция у нас уже есть: CatalogItemService зависит от CatalogEventPublisher, без messaging работает NoOpCatalogEventPublisher, а с messaging Spring подставляет RabbitCatalogEventPublisher. Теперь этому publisher’у осталось сделать одну конкретную вещь — собрать строку и отправить её в CatalogQueues.CATALOG_EVENTS.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Profile("messaging")
@Component
public class RabbitCatalogEventPublisher implements CatalogEventPublisher {
    private static final Logger log = LoggerFactory.getLogger(RabbitCatalogEventPublisher.class);

    private final AmqpTemplate amqpTemplate;

    public RabbitCatalogEventPublisher(AmqpTemplate amqpTemplate) {
        this.amqpTemplate = amqpTemplate;
    }

    @Override
    public void itemCreated(long itemId, String sku) {
        String message = CatalogEventMessages.itemCreated(itemId, sku);

        amqpTemplate.convertAndSend(CatalogQueues.CATALOG_EVENTS, message);
        log.info("Sent to {}: {}", CatalogQueues.CATALOG_EVENTS, message);
    }
}

Тут есть один важный, но дружелюбный момент: convertAndSend(destination, message) в таком виде использует «простой» путь, где destination — это имя очереди. RabbitMQ под капотом умеет доставлять сообщение в очередь по её имени через default exchange. Нам сейчас этого более чем достаточно.

Маркерный лог после отправки не про красоту, а про диагностику. Когда вы потом смотрите docker compose logs app, строка Sent to catalog.events: ... быстро отвечает на вопрос: «Отправка вообще случилась или я сейчас ищу ошибку не там?».

4. Consumer: @RabbitListener и лог

Со стороны получателя нам нужен ровно один эффект: доказательство, что сообщение не «испарилось в космос», а реально дошло до обработчика. Никакой бизнес-логики, никаких изменений БД, никаких «а давайте тут сразу пересчитывать кэш» — иначе мы потеряем прозрачность. Пусть consumer сегодня будет как кассир в супермаркете: «пикнул товар — сказал “пик” — всё».

В Spring Boot достаточно добавить компонент с методом, помеченным @RabbitListener. Spring сам поднимет инфраструктуру слушателей, подключится к брокеру и начнёт забирать сообщения из указанной очереди. Мы специально принимаем String, чтобы не обсуждать сериализацию.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Profile("messaging")
@Component
public class CatalogEventListener {
    private static final Logger log = LoggerFactory.getLogger(CatalogEventListener.class);

    // Слушаем конкретную очередь — это наш "конец провода" для smoke-check
    @RabbitListener(queues = CatalogQueues.CATALOG_EVENTS)
    public void onMessage(String message) {
        // Лог — наше доказательство, что сообщение реально дошло до обработчика
        log.info("Received from {}: {}", CatalogQueues.CATALOG_EVENTS, message);
    }
}

Почему listener тоже под @Profile("messaging")? Потому что без messaging-профиля он не нужен и даже вреден: в режимах без RabbitMQ он будет пытаться подключиться к брокеру, которого нет, и ломать старт приложения. Профиль делает поведение предсказуемым: включили messaging — получили и publisher, и consumer; не включили — приложение живёт без очередей, как и планировалось.

Отдельно замечу: мы пишем лог через slf4j, а не через System.out.println(...), потому что это естественный язык Spring Boot приложения. В контейнере этот лог всё равно будет в stdout/stderr, так что docker logs и docker compose logs увидят его без проблем.

5. Точка вызова publisher

Теперь главный практический вопрос: кто должен вызвать publisher? Сам RabbitMQ не телепат, он не знает, что вы создали CatalogItem. Значит, в коде должен быть момент, где доменное действие завершилось успешно, и мы решаем «да, пора отправить событие».

Самый наглядный (и методически правильный для нашего курса) вариант — вызвать publisher в том же сервисе, где создаётся элемент каталога. Это даёт короткую причинно‑следственную цепочку: «вызвали сервис → сохранили → отправили сообщение». Для учебного проекта это идеально: вы легко находите место отправки и можете быстро связать его с HTTP-эндпоинтом.

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

// 1) Сначала сохраняем (чтобы гарантированно получить id)
CatalogItem saved = repository.save(item);

// 2) Потом публикуем событие с уже известными данными
eventPublisher.itemCreated(saved.getId(), saved.getSku());

return saved;

Чтобы это работало во всех профилях, CatalogItemService должен зависеть не от RabbitCatalogEventPublisher, а от интерфейса CatalogEventPublisher. Тогда Spring сам подставит нужную реализацию по активным профилям. Вы включили messaging — получите отправку в RabbitMQ. Вы не включили messaging — получите NoOp-реализацию, и сервис продолжит жить нормальной жизнью.

Это очень важная дисциплина для «контейнерно-готового» проекта: внешние зависимости добавляются так, чтобы приложение не превращалось в хрупкий домик из карточек. Мы уже видели это на PostgreSQL и Redis, сейчас повторяем тот же стиль на RabbitMQ.

6. Smoke-check в Compose

После того как код готов, хочется сделать короткий и честный smoke-check. Не «я открыл management UI и там что-то мигает», а «я сделал доменное действие и увидел в логах: отправлено и получено». Это тот момент, где система перестаёт быть схемой на бумаге и становится работающим окружением.

Логика проверки очень простая. Вы поднимаете Compose-стек с профилями postgres,cache,messaging, делаете POST /api/catalog/items, и в логах приложения ожидаете увидеть две строки-маркера: Sent to catalog.events: ... и Received from catalog.events: .... Если вы видите обе, значит цепочка app → broker → app действительно работает.

Для запроса можно использовать .http файл или curl. Пример для .http (он маленький и хорошо ложится в репозиторий в requests/):

POST http://localhost:8080/api/catalog/items
Content-Type: application/json

{"sku":"SKU-42","title":"Docker Mug","price":12.50}

А дальше — самое приятное: вы не «угадываете», что произошло, а читаете то, что система сказала вам в логах. Если обе строки есть, у вас уже собран рабочий baseline: rabbitmq поднят как service, приложение стартует с postgres,cache,messaging, SPRING_RABBITMQ_* смотрят на rabbitmq:5672, очередь catalog.events существует, а publisher и listener реально проходят путь сообщения.

7. Типичные ошибки при минимальном messaging

В минимальном messaging-сценарии ошибки особенно поучительны, потому что здесь почти нечему ломаться… и всё равно ломается. Это нормально: новичок в программировании часто думает, что если тема простая, то она должна “просто заработать”. На практике простые темы хороши тем, что быстро показывают, где именно вы ошиблись, и дают шанс научиться читать систему, а не надеяться на удачу.

Ошибка №1: сервис зависит от Rabbit-класса напрямую, и приложение падает без messaging профиля.
Это классика: вы внедрили RabbitCatalogEventPublisher прямо в CatalogItemService, а потом запускаете режим без RabbitMQ — и Spring не может создать bean. Лечится дисциплиной: сервис должен зависеть от интерфейса CatalogEventPublisher, а включаемые реализации делаются через @Profile.

Ошибка №2: разные имена очереди в publisher и consumer.
В отправителе вы написали "catalog.events", а в слушателе — "catalog.event" или "catalog-events". Система при этом может выглядеть «живой», RabbitMQ поднят, приложение не падает, но listener молчит. Решение скучное, но эффективное: вынести имя очереди в одну константу (CatalogQueues.CATALOG_EVENTS) и использовать везде её.

Ошибка №3: consumer работает всегда, даже когда messaging профиль выключен.
Если забыть @Profile("messaging") на listener’е, то в режимах без RabbitMQ приложение начнёт пытаться подключиться к брокеру и будет падать на старте или зависать в ошибках подключения. Важно помнить: listener — это не просто метод, это “активная” часть приложения, которая живёт только там, где есть инфраструктура.

Ошибка №4: событие отправляется “до того, как мы точно создали сущность”, и данные в сообщении странные.
Например, вы отправили сообщение до сохранения, и id ещё null, или отправили сообщение в середине логики, где ещё может случиться ошибка. Для учебного сценария проще всего правило: сначала сохраняем, получаем id, потом отправляем. В “взрослой” жизни тема транзакций и гарантированной доставки намного глубже, но сегодня нам нужна понятная причинность.

Ошибка №5: вы пытаетесь сделать сообщение объектом сразу, и вместо RabbitMQ изучаете сериализацию.
Это не “плохая идея навсегда”, но плохая идея сегодня. Как только вы переключаетесь на объект, у вас появляется вопрос «а как оно превращается в байты?», «а какая кодировка?», «а какой converter?». Если цель дня — проверить wiring окружения, строка выигрывает, потому что по логам её можно прочитать без дополнительной подготовки.

1
Задача
Docker for Spring, 20 уровень, 3 лекция
Недоступна
Одна строка от endpoint до listener
Одна строка от endpoint до listener
1
Задача
Docker for Spring, 20 уровень, 3 лекция
Недоступна
Startup ping через RabbitMQ
Startup ping через RabbitMQ
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ