リアクティブアプリケーションにおけるSpring AMQP

リアクティブアプリケーションでのSpring AMQP

1. 概要

このチュートリアルでは、AMQPメッセージング標準の一般的な実装であるRabbitMQメッセージングサーバーと統合する簡単なSpring Boot Reactive Applicationを作成する方法を示します。

ポイントツーポイントとパブリッシュ/サブスクライブの両方のシナリオをカバーし、両方のパターンの違いを強調する分散セットアップを使用します。

AMQP、RabbitMQ、Spring Bootの基本的な知識、特にExchange、キュー、トピックなどの主要な概念を前提としていることに注意してください。 これらの概念の詳細については、以下のリンクをご覧ください。

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:宣言されたすべてのExchangeを一覧表示します

  • list_queues:未読メッセージの数を含む、宣言されたすべてのキューを一覧表示します

  • list_bindings:ルーティングキーも含め、取引所とキュー間のバインディングを定義するすべてのリスト

3. Spring AMQPプロジェクトのセットアップ

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サーバーを実装するために使用されるコア依存関係です。

注:MavenCentralでSpringBoot StarterAMQPおよびWebfluxモジュールの最新バージョンを確認できます。

4. シナリオ1:ポイントツーポイントメッセージング

この最初のシナリオでは、クライアントからメッセージを受信するブローカー内の論理エンティティである直接交換を使用します。

A Direct Exchange will route all incoming messages to one – and only one – queue。これからクライアントが使用できるようになります。 複数のクライアントが同じキューにサブスクライブできますが、特定のメッセージを受信するのは1つだけです。

4.1. 交換とキューのセットアップ

このシナリオでは、交換名とルーティングキーをカプセル化する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);
        });
}

このメソッドは、Springによって作成されたadminAmqp beanを使用して、Exchange、キューを宣言し、指定されたルーティングキーを使用してそれらをバインドします。

すべての宛先は、DestinationsConfig beanから取得されます。これは、この例で使用されている@ConfigurationPropertiesクラスです。

このクラスには、application.yml構成ファイルから読み取られたマッピングから構築されたDestinationInfoオブジェクトが入力されるプロパティがあります。

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

まず、nameパラメータが有効な宛先に対応しているかどうかを確認し、対応している場合は、自動配線されたamqpTemplateインスタンスを使用して、ペイロード(単純なStringメッセージ)をRabbitMQに実際に送信します。

4.3. MessageListenerContainerファクトリ

メッセージを非同期で受信するために、Spring AMQPは、アプリケーションによって提供されるAMQPキューとリスナーからの情報フローを仲介するMessageContainerListener抽象クラスを使用します。

メッセージリスナをアタッチするには、このクラスの具体的な実装が必要なので、コントローラコードを実際の実装から分離するファクトリを定義します。

この場合、ファクトリメソッドはcreateMessageListenerContainerメソッドを呼び出すたびに新しいSimpleMessageContainerListenerを返します。

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

宛先名の最初のチェック後、コンシューマエンドポイントはMessageListenerContainerFactoryを使用してMessageListenerContainerを作成し、キュー名はレジストリから復元されます。

MessageListenerContainerを取得したら、そのcreate()ビルダーメソッドの1つを使用してメッセージFluxを作成します。

特定のケースでは、FluxSink引数をとるラムダを取るものを使用します。これを使用して、SpringAMQPのリスナーベースの非同期APIをリアクティブアプリケーションにブリッジします。

また、エミッタのonRequest() onDispose()のコールバックに2つの追加のラムダをアタッチして、MessageListenerContainer Fluxのライフサイクルに従って内部リソースを割り当て/解放できるようにします。

最後に、結果のFlux interval(), で作成された別のFlux とマージし、5秒ごとに新しいイベントを作成します。 Those dummy messages play an important function in our case:これらがないと、メッセージを受信して​​送信に失敗した場合にのみクライアントの切断が検出されます。これは、特定のユースケースによっては時間がかかる場合があります。

4.5. テスト

コンシューマエンドポイントとパブリッシャエンドポイントの両方をセットアップしたら、サンプルアプリケーションでいくつかのテストを実行できるようになりました。

RabbitMQのサーバー接続の詳細と、application.ymlに少なくとも1つの宛先を定義する必要があります。これは、次のようになります。

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

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

spring.rabbitmq.*プロパティは、ローカルDockerコンテナで実行されているRabbitMQサーバーに接続するために必要な基本プロパティを定義します。 上記のIPは単なる例であり、特定のセットアップでは異なる場合があることに注意してください。

キューはdestinations.queues.<name>.*を使用して定義されます。ここで、<name>は宛先名として使用されます。 ここでは、「NYSE」というルーティングキーを使用して、RabbitMQ上の「nyse」交換にメッセージを送信する「NYSE」という名前の単一の宛先を宣言しました。

コマンドラインまたはIDEからサーバーを起動すると、メッセージの送受信を開始できます。 curlユーティリティを使用します。これは、Windows、Mac、LinuxOSの両方で使用できる共通のユーティリティです。

次のリストは、宛先にメッセージを送信する方法とサーバーからの予期される応答を示しています。

$ 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:パブリッシュ/サブスクライブ

メッセージングアプリケーションのもう1つの一般的なシナリオは、単一のメッセージを複数のコンシューマに送信する必要があるパブリッシュ/サブスクライブパターンです。

RabbitMQは、これらの種類のアプリケーションをサポートする2種類の交換を提供します:ファンアウトとトピック。

これら2種類の主な違いは、後者では、ルーティングキーパターンに基づいて、受信するメッセージをフィルタリングできることです(例: 「alarm.mailserver。*」)は登録時に提供されますが、前者は着信メッセージをすべてのバインドされたキューに単純に複製します。

RabbitMQはヘッダー交換もサポートしています。これにより、より複雑なメッセージフィルタリングが可能になりますが、その使用はこの記事の範囲外です。

5.1. 宛先設定

ポイントツーポイントシナリオで行ったように、起動時に別の@PostConstruct メソッドを使用してPub / Sub宛先を定義します。

唯一の違いは、Exchangesのみを作成し、Queuesは作成しないことです。排他的なQueueが必要なため、これらはオンデマンドで作成され、後でExchangeにバインドされます。クライアントごとに:

@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は新しいものを作成しないことに注意してください。

2つ目の違いは、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    []

2つのコマンドシェルを開き、各シェルで次のコマンドを発行することにより、宛先にサブスクライブするサブスクライバーをいくつか起動します。

$ 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

メッセージを送信すると、各サブスクライバのシェルに「ハリケーンが近づいています!」というメッセージがほぼ即座に表示されます。

利用可能なバインディングを確認すると、サブスクライバごとに1つのキューがあることがわかります。

$ 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. 結論

この記事では、spring-amqpモジュールを使用してRabbitMQサーバーと対話する単純なリアクティブアプリケーションを作成する方法を示しました。

ほんの数行のコードで、ポイントツーポイントとパブリッシュ/サブスクライブの両方の統合パターンをサポートする機能的なHTTP-to-AMQPゲートウェイを作成することができました。標準のSpring機能の追加。

この記事に示されているコードは利用可能ですover on Github.