Spring AMQP em aplicações reativas

Spring AMQP em aplicações reativas

1. Visão geral

Este tutorial mostra como criar um Aplicativo Reativo Spring Boot simples que se integra ao servidor de mensagens RabbitMQ, uma implementação popular do padrão de mensagens AMQP.

Abordamos os cenários - ponto a ponto e publicação / assinatura - usando uma configuração distribuída que destaca as diferenças entre os dois padrões.

Observe que assumimos um conhecimento básico de AMQP, RabbitMQ e Spring Boot, em particular, conceitos-chave como trocas, filas, tópicos e assim por diante. Mais informações sobre esses conceitos podem ser encontradas nos links abaixo:

2. Configuração do servidor RabbitMQ

Embora possamos configurar um RabbitMQ local localmente, na prática, é mais provável que usemos uma instalação dedicada com recursos adicionais, como alta disponibilidade, monitoramento, segurança, etc.

Para simular esse ambiente em nossa máquina de desenvolvimento, usaremos o Docker para criar um servidor que será usado por nosso aplicativo.

O comando a seguir iniciará um servidor RabbitMQ independente:

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

Não declaramos nenhum volume persistente, portanto, as mensagens não lidas serão perdidas entre as reinicializações. O serviço estará disponível na porta 5672 no host.

Podemos verificar os logs do servidor com o comandodocker logs, que deve produzir uma saída como esta:

$ 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

Como a imagem inclui o utilitáriorabbitmqctl, podemos usá-lo para executar tarefas administrativas no contexto de nossa imagem em execução.

Por exemplo, podemos obter informações de status do servidor com o seguinte comando:

$ 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

Outros comandos úteis incluem:

  • list_exchanges: Lista todas as trocas declaradas

  • list_queues: lista todas as filas declaradas, incluindo o número de mensagens não lidas

  • list_bindings: lista todas as ligações de definições entre trocas e filas, incluindo também chaves de roteamento

3. Configuração do projeto Spring AMQP

Assim que tivermos o servidor RabbitMQ em funcionamento, podemos continuar a criar nosso projeto Spring. Este projeto de amostra permitirá que qualquer cliente REST publique e / ou receba mensagens no servidor de mensagens, usando o módulo Spring AMQP e o iniciador correspondente do Spring Boot para se comunicar com ele.

As principais dependências que precisamos adicionar ao nosso arquivo de projetopom.xml são:


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


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

Ospring-boot-starter-amqp traz todas as coisas relacionadas ao AMQP, enquanto ospring-boot-starter-webflux é a dependência principal usada para implementar nosso servidor REST reativo.

Observação: você pode verificar a versão mais recente dos módulosAMQPeWebflux do Spring Boot Starter no Maven Central.

4. Cenário 1: Mensagens ponto a ponto

É neste primeiro cenário, usaremos um Direct Exchange, que é a entidade lógica no corretor que recebe mensagens dos clientes.

A Direct Exchange will route all incoming messages to one – and only one – queue, a partir do qual estará disponível para consumo pelos clientes. Vários clientes podem se inscrever na mesma fila, mas apenas um receberá uma determinada mensagem.

4.1. Configuração de Trocas e Filas

Em nosso cenário, usamos um objetoDestinationInfo que encapsula o nome da central e a chave de roteamento. Um mapa digitado pelo nome do destino será usado para armazenar todos os destinos disponíveis.

O seguinte método@PostConstruct method será responsável por esta configuração inicial:

@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);
        });
}

Este método usa oadminAmqp bean criado pelo Spring para declarar Trocas, Filas e vinculá-los usando uma determinada chave de roteamento.

Todos os destinos vêm de umDestinationsConfig bean, que é uma classe@ConfigurationProperties usada em nosso exemplo.

Esta classe possui uma propriedade que é preenchida com objetosDestinationInfo construídos a partir de mapeamentos lidos do arquivo de configuraçãoapplication.yml.

4.2. Ponto final do produtor

Os produtores enviarão mensagens enviando umHTTP POST para o local/queue/{name}.

Este é um endpoint reativo, então usamos umMono para retornar uma confirmação simples:

@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();
        });
    }

Primeiro verificamos se o parâmetro name corresponde a um destino válido e, em caso afirmativo, usamos a instânciaamqpTemplate autowired para realmente enviar a carga útil - uma mensagemString simples - para RabbitMQ.

4.3. Fábrica deMessageListenerContainer

Para receber mensagens de forma assíncrona, o Spring AMQP usa uma classe abstrataMessageContainerListener que medeia o fluxo de informações das filas AMQP e ouvintes fornecidos por um aplicativo.

Como precisamos de uma implementação concreta dessa classe para anexar nossos ouvintes de mensagens, definimos uma fábrica que isola o código do controlador de sua implementação real.

Em nosso caso, o método de fábrica retorna um novoSimpleMessageContainerListener cada vez que chamamos seu métodocreateMessageListenerContainer:

@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. Ponto final do consumidor

Os consumidores acessarão o mesmo endereço de endpoint usado pelos produtores (/queue/{name}) para obter mensagens.

Este endpoint retorna umFlux of eventos, onde cada evento corresponde a uma mensagem recebida:

@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);
}

Após a verificação inicial do nome de destino, o endpoint do consumidor criaMessageListenerContainer usandoMessageListenerContainerFactorye o nome da fila recuperado de nosso registro.

Assim que tivermos nossoMessageListenerContainer, criamos a mensagemFlux usando um de seus métodos construtorescreate().

Em nosso caso particular, usamos um que pega um lambda com um argumentoFluxSink, que então usamos para conectar a API assíncrona baseada em listener do Spring AMQP para nosso aplicativo reativo.

Também anexamos dois lambdas adicionais aos retornos de chamadaonRequest() andonDispose() do emissor para que nossa varreduraMessageListenerContainer aloque / libere seus recursos internos seguindo o ciclo de vida deFlux.

Finalmente, mesclamos oFlux resultante com outro criado cominterval(), que cria um novo evento a cada cinco segundos. Those dummy messages play an important function in our case: sem eles, só detectaríamos uma desconexão do cliente ao receber uma mensagem e não enviá-la, o que pode demorar muito dependendo do seu caso de uso específico.

4.5. Teste

Com a configuração dos pontos de extremidade do consumidor e do editor, agora podemos fazer alguns testes com nosso aplicativo de amostra.

Precisamos definir os detalhes de conexão do servidor RabbitMQ e pelo menos um destino em nossoapplication.yml, que deve ser semelhante a este:

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

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

As propriedadesspring.rabbitmq.* definem as propriedades básicas necessárias para se conectar ao nosso servidor RabbitMQ em execução em um contêiner Docker local. Observe que o IP mostrado acima é apenas um exemplo e pode ser diferente em uma configuração específica.

As filas são definidas usandodestinations.queues.<name>.*, onde<name> é usado como o nome de destino. Aqui declaramos um único destino chamado "NYSE" que enviará mensagens para a troca "nyse" no RabbitMQ com uma chave de roteamento "NYSE".

Depois de iniciar o servidor via linha de comando ou a partir do nosso IDE, podemos começar a enviar e receber mensagens. Usaremos o utilitáriocurl, um utilitário comum disponível para sistemas operacionais Windows, Mac e Linux.

A lista a seguir mostra como enviar uma mensagem para o nosso destino e a resposta esperada do servidor:

$ 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

Depois de executar este comando, podemos verificar se a mensagem foi recebida pelo RabbitMQ e está pronta para consumo emitindo o seguinte comando:

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

Agora podemos ler as mensagens com curl com o seguinte comando:

$ 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

Como podemos ver, primeiro recebemos a mensagem armazenada anteriormente e depois começamos a receber nossa mensagem fictícia a cada 5 segundos.

Se executarmos novamente o comando para listar filas, podemos ver agora que não há mensagens armazenadas:

$ docker exec rabbitmq rabbitmqctl list_queues

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

5. Cenário 2: Publicar-Assinar

Outro cenário comum para aplicativos de mensagens é o padrão Publicar-Assinar, em que uma única mensagem deve ser enviada a vários consumidores.

O RabbitMQ oferece dois tipos de trocas que suportam esses tipos de aplicativos: Fan-out e Topic.

A principal diferença entre esses dois tipos é que o último permite filtrar quais mensagens receber com base em um padrão de chave de roteamento (por exemplo, “Alarm.mailserver. *”) Fornecido no momento do registro, enquanto o primeiro simplesmente replica as mensagens recebidas em todas as filas ligadas.

O RabbitMQ também oferece suporte a trocas de cabeçalho, o que permite uma filtragem de mensagens mais complexa, mas seu uso está fora do escopo deste artigo.

5.1. Configuração de destinos

Definimos destinos Pub / Sub no momento da inicialização com outro método@PostConstruct , como fizemos no cenário ponto a ponto.

A única diferença é que criamos apenasExchanges, mas nãoQueues - eles serão criados sob demanda e vinculados aoExchange mais tarde, pois queremos umQueue exclusivo para cada cliente:

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

5.2. Ponto de extremidade do editor

Os clientes usarão o endpoint do editor disponível no local/topic/{name} para postar mensagens que serão enviadas a todos os clientes conectados.

Como no cenário anterior, usamos um@PostMapping que retorna umMono com o status após o envio da mensagem:

@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. Ponto final do assinante

Nosso endpoint de assinante estará localizado em/topic/{name}, produzindoFlux de mensagens para clientes conectados.

Essas mensagens incluem as mensagens recebidas e as mensagens fictícias geradas a cada 5 segundos:

@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);
}

Este código é basicamente o mesmo que vimos no caso anterior, com apenas as seguintes diferenças: primeiro, criamos um novoQueue para cada novo assinante.

Fazemos isso por meio de uma chamada ao métodocreateTopicQueue(), que usa informações da instânciaDestinationInfo para criar uma fila exclusiva e não durável, que então ligamos aExchange usando o chave de roteamento:

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;
}

Observe que, apesar de declararmos oExchange novamente, o RabbitMQ não criará um novo, pois já o declaramos no momento da inicialização.

A segunda diferença está no lambda que passamos para o métodoonDispose(), que desta vez também apagaráQueue quando o assinante se desconectar.

5.3. Teste

Para testar o cenário Pub-Sub, devemos primeiro definir um destino de tópico em outapplication.yml como este:

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

Aqui, definimos um endpoint de tópico que estará disponível no local/topic/weather. Este terminal será usado para postar mensagens na troca de "alertas" no RabbitMQ com uma chave de roteamento "WEATHER".

Depois de iniciar o servidor, podemos verificar se a troca foi criada usando o comandorabbitmqctl:

$ 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

Agora, se emitirmos o comandolist_bindings, podemos ver que não há filas relacionadas à troca de “alertas”:

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

Vamos começar alguns assinantes que se inscreverão em nosso destino, abrindo duas shells de comando e emitindo o seguinte comando em cada uma:

$ 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

Por fim, usamos o curl mais uma vez para enviar alguns alertas aos nossos assinantes:

$ 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

Depois que enviamos a mensagem, podemos ver quase instantaneamente a mensagem "Furacão se aproximando!" no shell de cada assinante.

Se verificarmos agora as ligações disponíveis, podemos ver que temos uma fila para cada assinante:

$ 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    []

Quando pressionar Ctrl-C no shell do assinante, nosso gateway acabará por detectar que o cliente foi desconectado e removerá essas ligações.

6. Conclusão

Neste artigo, demonstramos como criar um aplicativo reativo simples que interage com um servidor RabbitMQ usando o módulospring-amqp.

Com apenas algumas linhas de código, fomos capazes de criar um gateway HTTP-AMQP funcional que suporta os padrões de integração ponto a ponto e publicação-assinatura, que podemos estender facilmente para adicionar recursos adicionais, como segurança pelo adição de recursos padrão do Spring.

O código mostrado neste artigo está disponívelover on Github.