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 окружения, строка выигрывает, потому что по логам её можно прочитать без дополнительной подготовки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ