Spring AMQP в реактивных приложениях

Spring AMQP в реактивных приложениях

1. обзор

В этом руководстве показано, как создать простое реактивное приложение Spring Boot, которое интегрируется с сервером обмена сообщениями RabbitMQ, популярной реализацией стандарта обмена сообщениями AMQP.

Мы рассмотрим сценарии «точка-точка» и «публикация-подписка», используя распределенную настройку, которая подчеркивает различия между обоими шаблонами.

Обратите внимание, что мы предполагаем базовые знания AMQP, RabbitMQ и Spring Boot, в частности, такие ключевые понятия, как обмены, очереди, темы и т. Д. Более подробную информацию об этих концепциях можно найти по ссылкам ниже:

2. Установка сервера RabbitMQ

Хотя мы могли бы настроить локальный RabbitMQ локально, на практике мы, скорее всего, будем использовать выделенную установку с дополнительными функциями, такими как высокая доступность, мониторинг, безопасность и т. Д.

Чтобы смоделировать такую ​​среду на нашей машине разработки, мы будем использовать Docker для создания сервера, который будет использовать наше приложение.

Следующая команда запустит автономный сервер RabbitMQ:

$ docker run -d --name rabbitmq -p 5672:5672 rabbitmq:3

Мы не объявляем постоянный том, поэтому непрочитанные сообщения будут потеряны между перезапусками. Служба будет доступна через порт 5672 на хосте.

Мы можем проверить журналы сервера с помощью командыdocker logs, которая должна выдать примерно такой результат:

$ docker logs rabbitmq
2018-06-09 13:42:29.718 [info] <0.33.0>
  Application lager started on node [email protected]
// ... some lines omitted
2018-06-09 13:42:33.491 [info] <0.226.0>
 Starting RabbitMQ 3.7.5 on Erlang 20.3.5
 Copyright (C) 2007-2018 Pivotal Software, Inc.
 Licensed under the MPL.  See http://www.rabbitmq.com/

  ##  ##
  ##  ##      RabbitMQ 3.7.5. Copyright (C) 2007-2018 Pivotal Software, Inc.
  ##########  Licensed under the MPL.  See http://www.rabbitmq.com/
  ######  ##
  ##########  Logs: 

              Starting broker...
2018-06-09 13:42:33.494 [info] <0.226.0>
 node           : [email protected]
 home dir       : /var/lib/rabbitmq
 config file(s) : /etc/rabbitmq/rabbitmq.conf
 cookie hash    : CY9rzUYh03PK3k6DJie09g==
 log(s)         : 
 database dir   : /var/lib/rabbitmq/mnesia/[email protected]

// ... more log lines

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

Например, мы можем получить информацию о состоянии сервера с помощью следующей команды:

$ docker exec rabbitmq rabbitmqctl status
Status of node [email protected] ...
[{pid,299},
 {running_applications,
     [{rabbit,"RabbitMQ","3.7.5"},
      {rabbit_common,
          "Modules shared by rabbitmq-server and rabbitmq-erlang-client",
          "3.7.5"},
// ... other info omitted for brevity

Другие полезные команды включают в себя:

  • list_exchanges: перечислить все объявленные биржи

  • list_queues: список всех объявленных очередей, включая количество непрочитанных сообщений

  • list_bindings: перечислить все определения привязок между обменами и очередями, включая ключи маршрутизации

3. Spring AMQP Project Setup

После того, как наш сервер RabbitMQ будет запущен и запущен, мы можем приступить к созданию проекта Spring. Этот пример проекта позволит любому клиенту REST публиковать и / или получать сообщения на сервер обмена сообщениями, используя модуль Spring AMQP и соответствующий стартовый модуль Spring Boot для связи с ним.

Основные зависимости, которые нам нужно добавить в наш файл проектаpom.xml:


    org.springframework.boot
    spring-boot-starter-amqp
    2.0.3.RELEASE


    org.springframework.boot
    spring-boot-starter-webflux
    2.0.2.RELEASE

spring-boot-starter-amqp содержит все, что связано с AMQP, тогда какspring-boot-starter-webflux - это основная зависимость, используемая для реализации нашего реактивного сервера REST.

Примечание: вы можете проверить последнюю версию модулей Spring Boot StarterAMQP иWebflux на Maven Central.

4. Сценарий 1: обмен сообщениями "точка-точка"

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

A Direct Exchange will route all incoming messages to one – and only one – queue, откуда он будет доступен для потребления клиентами. Несколько клиентов могут подписаться на одну и ту же очередь, но только один получит данное сообщение.

4.1. Настройка Exchange и очередей

В нашем сценарии мы используем объектDestinationInfo, который инкапсулирует имя обмена и ключ маршрутизации. Карта с указанием имени получателя будет использоваться для хранения всех доступных получателей.

За эту начальную настройку отвечает следующий метод@PostConstruct :

@Autowired
private AmqpAdmin amqpAdmin;

@Autowired
private DestinationsConfig destinationsConfig;

@PostConstruct
public void setupQueueDestinations() {
    destinationsConfig.getQueues()
        .forEach((key, destination) -> {
            Exchange ex = ExchangeBuilder.directExchange(
              destination.getExchange())
              .durable(true)
              .build();
            amqpAdmin.declareExchange(ex);
            Queue q = QueueBuilder.durable(
              destination.getRoutingKey())
              .build();
            amqpAdmin.declareQueue(q);
            Binding b = BindingBuilder.bind(q)
              .to(ex)
              .with(destination.getRoutingKey())
              .noargs();
            amqpAdmin.declareBinding(b);
        });
}

Этот метод использует объектadminAmqp , созданный Spring, для объявления обменов, очередей и связывания их вместе с помощью заданного ключа маршрутизации.

Все назначения происходят изDestinationsConfig bean, который является классом@ConfigurationProperties, используемым в нашем примере.

Этот класс имеет свойство, которое заполняется объектамиDestinationInfo, созданными из сопоставлений, считанных из файла конфигурацииapplication.yml.

4.2. Конечная точка продюсера

Производители будут отправлять сообщения, отправляяHTTP POST в расположение/queue/{name}.

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

@SpringBootApplication
@EnableConfigurationProperties(DestinationsConfig.class)
@RestController
public class SpringWebfluxAmqpApplication {

    // ... other members omitted

    @Autowired
    private AmqpTemplate amqpTemplate;

    @PostMapping(value = "/queue/{name}")
    public Mono> sendMessageToQueue(
      @PathVariable String name, @RequestBody String payload) {

        DestinationInfo d = destinationsConfig
          .getQueues().get(name);
        if (d == null) {
            return Mono.just(
              ResponseEntity.notFound().build());
        }

        return Mono.fromCallable(() -> {
            amqpTemplate.convertAndSend(
              d.getExchange(),
              d.getRoutingKey(),
              payload);
            return ResponseEntity.accepted().build();
        });
    }

Сначала мы проверяем, соответствует ли параметр имени допустимому месту назначения, и если да, мы используем автоматически подключенный экземплярamqpTemplate для фактической отправки полезной нагрузки - простого сообщенияString - в RabbitMQ.

4.3. MessageListenerContainer Завод

Чтобы получать сообщения асинхронно, Spring AMQP использует абстрактный классMessageContainerListener, который передает информационный поток из очередей AMQP и слушателей, предоставляемых приложением.

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

В нашем случае фабричный метод возвращает новыйSimpleMessageContainerListener каждый раз, когда мы вызываем его методcreateMessageListenerContainer:

@Component
public class MessageListenerContainerFactory {

    @Autowired
    private ConnectionFactory connectionFactory;

    public MessageListenerContainerFactory() {}

    public MessageListenerContainer createMessageListenerContainer(String queueName) {
        SimpleMessageListenerContainer mlc = new SimpleMessageListenerContainer(connectionFactory);
        mlc.addQueueNames(queueName);
        return mlc;
    }
}

4.4. Конечная точка потребителя

Потребители будут обращаться к тому же адресу конечной точки, который используется производителями (/queue/{name}) для получения сообщений.

Эта конечная точка возвращает событияFlux of, где каждое событие соответствует полученному сообщению:

@Autowired
private MessageListenerContainerFactory messageListenerContainerFactory;

@GetMapping(
  value = "/queue/{name}",
  produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux receiveMessagesFromQueue(@PathVariable String name) {

    DestinationInfo d = destinationsConfig
      .getQueues()
      .get(name);
    if (d == null) {
        return Flux.just(ResponseEntity.notFound()
          .build());
    }

    MessageListenerContainer mlc = messageListenerContainerFactory
      .createMessageListenerContainer(d.getRoutingKey());

    Flux f = Flux. create(emitter -> {
        mlc.setupMessageListener((MessageListener) m -> {
            String payload = new String(m.getBody());
            emitter.next(payload);
        });
        emitter.onRequest(v -> {
            mlc.start();
        });
        emitter.onDispose(() -> {
            mlc.stop();
        });
      });

    return Flux.interval(Duration.ofSeconds(5))
      .map(v -> "No news is good news")
      .mergeWith(f);
}

После первоначальной проверки имени назначения конечная точка потребителя создаетMessageListenerContainer, используяMessageListenerContainerFactory и имя очереди, восстановленное из нашего реестра.

Когда у нас естьMessageListenerContainer, мы создаем сообщениеFlux, используя один из его методов построенияcreate().

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

Мы также присоединяем две дополнительные лямбды к обратным вызовам эмиттераonRequest() иonDispose(), чтобы наше сканированиеMessageListenerContainer выделяло / высвобождало свои внутренние ресурсы в соответствии с жизненным цикломFlux.

Наконец, мы объединяем полученныйFlux w с другим, созданным с помощьюinterval(), w, что создает новое событие каждые пять секунд. Those dummy messages play an important function in our case: без них мы могли бы обнаруживать отключение клиента только после получения сообщения и невозможности его отправки, что может занять много времени в зависимости от вашего конкретного варианта использования.

4.5. тестирование

Настроив конечные точки как для потребителя, так и для издателя, мы можем провести некоторые тесты с нашим примером приложения.

Нам нужно определить детали подключения к серверу RabbitMQ и по крайней мере одно место назначения на нашемapplication.yml, которое должно выглядеть так:

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

destinations:
  queues:
    NYSE:
      exchange: nyse
      routing-key: NYSE

Свойстваspring.rabbitmq.* определяют основные свойства, необходимые для подключения к нашему серверу RabbitMQ, работающему в локальном контейнере Docker. Обратите внимание, что показанный выше IP-адрес является лишь примером и может отличаться в конкретной настройке.

Очереди определяются с использованиемdestinations.queues.<name>.*, где<name> используется в качестве имени назначения. Здесь мы объявили один пункт назначения с именем «NYSE», который будет отправлять сообщения в обмен «nyse» на RabbitMQ с ключом маршрутизации «NYSE».

Как только мы запустим сервер из командной строки или из нашей IDE, мы сможем начать отправку и получение сообщений. Мы будем использовать утилитуcurl, распространенную утилиту, доступную как для ОС Windows, Mac и Linux.

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

$ curl -v -d "Test message" http://localhost:8080/queue/NYSE
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /queue/NYSE HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
> Content-Length: 12
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 12 out of 12 bytes
< HTTP/1.1 202 Accepted
< content-length: 0
<
* Connection #0 to host localhost left intact

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

$ docker exec rabbitmq rabbitmqctl list_queues
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
NYSE    1

Теперь мы можем читать сообщения с помощью curl с помощью следующей команды:

$ curl -v http://localhost:8080/queue/NYSE
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /queue/NYSE HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: text/event-stream;charset=UTF-8
<
data:Test message

data:No news is good news...

... same message repeating every 5 secs

Как мы видим, сначала мы получаем ранее сохраненное сообщение, а затем начинаем получать наше фиктивное сообщение каждые 5 секунд.

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

$ docker exec rabbitmq rabbitmqctl list_queues

Timeout: 60.0 seconds ...
Listing queues for vhost / ...
NYSE    0

5. Сценарий 2: публикация-подписка

Другим распространенным сценарием для приложений обмена сообщениями является шаблон «публикация-подписка», в котором одно сообщение должно отправляться нескольким потребителям.

RabbitMQ предлагает два типа обменов, которые поддерживают такие виды приложений: разветвление и тема.

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

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

5.1. Настройка направлений

Мы определяем назначения Pub / Sub во время запуска с другим методом@PostConstruct , как мы это делали в сценарии точка-точка.

Единственная разница в том, что мы создаем толькоExchanges, но неQueues - они будут созданы по запросу и привязаны кExchange позже, так как нам нужен эксклюзивныйQueue для каждого клиента:

@PostConstruct
public void setupTopicDestinations(
    destinationsConfig.getTopics()
      .forEach((key, destination) -> {
          Exchange ex = ExchangeBuilder
            .topicExchange(destination.getExchange())
            .durable(true)
            .build();
            amqpAdmin.declareExchange(ex);
      });
}

5.2. Конечная точка издателя

Клиенты будут использовать конечную точку издателя, доступную в местоположении/topic/{name}, для публикации сообщений, которые будут отправлены всем подключенным клиентам.

Как и в предыдущем сценарии, мы используем@PostMapping, который возвращаетMono со статусом после отправки сообщения:

@PostMapping(value = "/topic/{name}")
public Mono> sendMessageToTopic(
  @PathVariable String name, @RequestBody String payload) {

    DestinationInfo d = destinationsConfig
      .getTopics()
      .get(name);

    if (d == null) {
        return Mono.just(ResponseEntity.notFound().build());
    }

   return Mono.fromCallable(() -> {
       amqpTemplate.convertAndSend(
         d.getExchange(), d.getRoutingKey(),payload);
            return ResponseEntity.accepted().build();
        });
    }

5.3. Конечная точка подписчика

Наша конечная точка подписчика будет расположена в/topic/{name}, создаваяFlux сообщений для подключенных клиентов.

Эти сообщения включают в себя как полученные, так и фиктивные сообщения, генерируемые каждые 5 секунд:

@GetMapping(
  value = "/topic/{name}",
  produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux receiveMessagesFromTopic(@PathVariable String name) {
    DestinationInfo d = destinationsConfig.getTopics()
        .get(name);
    if (d == null) {
        return Flux.just(ResponseEntity.notFound()
            .build());
    }
    Queue topicQueue = createTopicQueue(d);
    String qname = topicQueue.getName();
    MessageListenerContainer mlc = messageListenerContainerFactory.createMessageListenerContainer(qname);
    Flux f = Flux. create(emitter -> {
        mlc.setupMessageListener((MessageListener) m -> {
            String payload = new String(m.getBody());
            emitter.next(payload);
        });
        emitter.onRequest(v -> {
            mlc.start();
        });
        emitter.onDispose(() -> {
            amqpAdmin.deleteQueue(qname);
            mlc.stop();
        });
      });

    return Flux.interval(Duration.ofSeconds(5))
        .map(v -> "No news is good news")
        .mergeWith(f);
}

Этот код в основном такой же, как мы видели в предыдущем случае, только со следующими отличиями: сначала мы создаем новыйQueue для каждого нового подписчика.

Мы делаем это с помощью вызова методаcreateTopicQueue(), который использует информацию из экземпляраDestinationInfo для создания эксклюзивной, непродолжительной очереди, которую мы затем связываем сExchange, используя настроенный ключ маршрутизации:

private Queue createTopicQueue(DestinationInfo destination) {

    Exchange ex = ExchangeBuilder
      .topicExchange(destination.getExchange())
      .durable(true)
      .build();
    amqpAdmin.declareExchange(ex);
    Queue q = QueueBuilder
      .nonDurable()
      .build();
    amqpAdmin.declareQueue(q);
    Binding b = BindingBuilder.bind(q)
      .to(ex)
      .with(destination.getRoutingKey())
      .noargs();
    amqpAdmin.declareBinding(b);
    return q;
}

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

Второе отличие заключается в лямбде, которую мы передаем методуonDispose(), который на этот раз также удалитQueue при отключении подписчика.

5.3. тестирование

Чтобы протестировать сценарий Pub-Sub, мы должны сначала определить место назначения темы в outapplication.yml следующим образом:

destinations:
## ... queue destinations omitted
  topics:
    weather:
      exchange: alerts
      routing-key: WEATHER

Здесь мы определили конечную точку темы, которая будет доступна в местоположении/topic/weather. Эта конечная точка будет использоваться для отправки сообщений в обмен «оповещениями» на RabbitMQ с ключом маршрутизации «ПОГОДА».

После запуска сервера мы можем проверить, что обмен создан с помощью командыrabbitmqctl:

$ docker exec docker_rabbitmq_1 rabbitmqctl list_exchanges
Listing exchanges for vhost / ...
amq.topic       topic
amq.fanout      fanout
amq.match       headers
amq.headers     headers
        direct
amq.rabbitmq.trace      topic
amq.direct      direct
alerts  topic

Теперь, если мы введем командуlist_bindings, мы увидим, что нет очередей, связанных с обменом «предупреждениями»:

$ docker exec rabbitmq rabbitmqctl list_bindings
Listing bindings for vhost /...
        exchange        NYSE    queue   NYSE    []
nyse    exchange        NYSE    queue   NYSE    []

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

$ curl -v http://localhost:8080/topic/weather
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /topic/weather HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: text/event-stream;charset=UTF-8
<
data:No news is good news...

# ... same message repeating indefinitely

Наконец, мы снова используем curl для отправки некоторых уведомлений нашим подписчикам:

$ curl -v -d "Hurricane approaching!" http://localhost:8080/topic/weather
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /topic/weather HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
> Content-Length: 22
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 22 out of 22 bytes
< HTTP/1.1 202 Accepted
< content-length: 0
<
* Connection #0 to host localhost left intact

После того, как мы отправим сообщение, мы почти сразу увидим сообщение «Ураган приближается!» На оболочке каждого подписчика.

Если мы сейчас проверим доступные привязки, то увидим, что у нас есть одна очередь для каждого подписчика:

$ docker exec rabbitmq rabbitmqctl list_bindings
Listing bindings for vhost /...
        exchange        IBOV    queue   IBOV    []
        exchange        NYSE    queue   NYSE    []
        exchange        spring.gen-i0m0pbyKQMqpz2_KFZCd0g
  queue   spring.gen-i0m0pbyKQMqpz2_KFZCd0g       []
        exchange        spring.gen-wCHALTsIS1q11PQbARJ7eQ
  queue   spring.gen-wCHALTsIS1q11PQbARJ7eQ       []
alerts  exchange        spring.gen-i0m0pbyKQMqpz2_KFZCd0g
  queue   WEATHER []
alerts  exchange        spring.gen-wCHALTsIS1q11PQbARJ7eQ
  queue   WEATHER []
ibov    exchange        IBOV    queue   IBOV    []
nyse    exchange        NYSE    queue   NYSE    []
quotes  exchange        NYSE    queue   NYSE    []

Как только мы нажмем Ctrl-C на оболочке подписчика, наш шлюз в конечном итоге обнаружит, что клиент отключился, и удалит эти привязки.

6. Заключение

В этой статье мы продемонстрировали, как создать простое реактивное приложение, которое взаимодействует с сервером RabbitMQ с помощью модуляspring-amqp.

С помощью всего лишь нескольких строк кода мы смогли создать функциональный шлюз HTTP-AMQP, который поддерживает шаблоны интеграции «точка-точка» и «публикация-подписка», которые мы можем легко расширить, добавив дополнительные функции, такие как безопасность с помощью добавление стандартных функций Spring.

Код, показанный в этой статье, доступенover on Github.