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

1.概要

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

ポイントツーポイントとパブリッシュ - サブスクライブのシナリオ - 両方のパターンの違いを浮き彫りにした分散セットアップを使用して、両方をカバーします。

AMQP、RabbitMQ、およびSpring Bootの基本的な知識、特にExchange、Queues、Topicsなどの重要な概念を前提としています。これらの概念に関する詳細情報は、以下のリンクにあります。

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: <stdout>

              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)         : <stdout>
 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プロジェクトの設定

RabbitMQサーバーを起動して実行すると、Springプロジェクトの作成に進むことができます。このサンプルプロジェクトでは、Spring AMQPモジュールとそれに対応するSpring Bootスターターを使用して通信するために、RESTクライアントがメッセージングサーバーにメッセージを投稿したり受信したりすることができます。

pom.xml プロジェクトファイルに追加する必要がある主な依存関係は次のとおりです。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    <version>2.0.3.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>2.0.2.RELEASE</version>
</dependency>

spring-boot-starter-amqp はAMQP関連のものをすべてもたらしますが、 spring-boot-starter-webflux はリアクティブRESTサーバーを実装するために使用される中心的な依存関係です。

注:Spring Boot Starterの最新バージョンを確認することができます。https://search.maven.org/classic/#search%7Cgav%7C1%7Cg%3A%22org.springframework.amqp%22%20AND%20a%3A% 22spring-amqp%22[AMQP]およびhttps://search.maven.org/classic/#search%7Cgav%7C1%7Cg%3A%22org.springframework.boot%22%20AND%20a%3A%22spring-boot- Maven Centralのstarter-webflux%22[Webflux]モジュール。

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

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

  • 直接交換はすべての入ってくるメッセージを一つの、そしてたった一つの待ち行列** にルーティングし、そこからそれはクライアントによる消費のために利用可能になるでしょう。複数のクライアントが同じキューに登録できますが、特定のメッセージを受信できるのは1人だけです。

4.1. 交換とキューの設定

このシナリオでは、交換名とルーティングキーをカプセル化する DestinationInfo オブジェクトを使用します。目的地名をキーとした地図は、すべての利用可能な目的地を格納するために使用されます。

次の __ @ PostConstruct __methodがこの初期設定を担当します。

@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、Queuesを宣言し、特定のルーティングキーを使用してそれらをバインドします。

すべての宛先は、 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<ResponseEntity<?>> 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 Factory

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

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

この場合、factoryメソッドは、 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<String> f = Flux.<String> 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 引数を取るラムダを取るものを使います。そしてそれを使ってSpring AMQPのリスナーベースの非同期APIをリアクティブアプリケーションにブリッジします。

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

最後に、結果の __Flux interval()で作成された別の Flux と5秒ごとに新しいイベントを作成する __whichをマージします。

  • これらのダミーメッセージは、私たちの場合には重要な機能を果たします。** これらがないと、メッセージを受信して​​送信できなかったときにのみクライアントの切断を検出します。

4.5. テスト

コンシューマエンドポイントとパブリッシャエンドポイントの両方を設定したら、サンプルアプリケーションを使用していくつかのテストを実行できます。

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

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そしてLinux OSの両方で利用可能な共通のユーティリティです。

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

$ 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秒ごとにダミーメッセージを受信し始めます。

キューを一覧表示するコマンドをもう一度実行すると、メッセージが格納されていないことがわかります。

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

5.シナリオ2:発行 - 購読

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

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

これら2つの種類の主な違いは、後者により、ルーティングキーパターン(例:

「alarm.mailserver。** 」は登録時に提供されますが、前者は着信メッセージをすべてのバインド済みキューに複製するだけです。

RabbitMQはより複雑なメッセージフィルタリングを可能にするHeader Exchangesもサポートしますが、その使用はこの記事の範囲外です。

5.1. 目的地の設定

ポイントツーポイントシナリオで行ったように、起動時に別の __ @ PostConstruct __methodを使用して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} の場所にある発行元エンドポイントを使用します。

前のシナリオと同様に、メッセージ送信後に Mono とステータスを返す @ PostMapping を使用します。

@PostMapping(value = "/topic/{name}")
public Mono<ResponseEntity<?>> 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} にあり、接続されたクライアントへのメッセージのフラックスを生成します。

これらのメッセージには、受信メッセージと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<String> f = Flux.<String> 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 を作成します。

これを行うには、 DestinationInfo インスタンスからの情報を使用して排他的で永続的ではないキューを作成する createTopicQueue() メソッドを呼び出します。次に、構成されたルーティングキーを使用して 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シナリオをテストするには、最初にout application.yml でトピックの行き先を次のように定義しなければなりません:

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

ここでは、 /topic/weather の場所で利用可能になるトピックエンドポイントを定義しました。このエンドポイントは、「WEATHER」ルーティングキーを使用してRabbitMQの「alerts」交換にメッセージを投稿するために使用されます。

サーバーを起動したら、 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__コマンドを発行すると、 "alerts"交換に関連するキューがないことがわかります。

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

2つのコマンドシェルを開き、それぞれに対して次のコマンドを発行して、宛先にサブスクライブする2、3のサブスクライバを開始しましょう。

[source,bash,gutter:,true]

$ 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…​