1. 序章

以前の記事では、 ApacheCassandraを搭載したDataStaxAstraを使用してを使用してアベンジャーズの現在のステータスを表示するダッシュボードを構築する方法について説明しました。 ] Stargate は、それを操作するための追加のAPIを提供します。

カサンドラとスターゲイトで構築されたアベンジャーズステータスダッシュボード

この記事では、これを拡張して、ロールアップされた要約ではなく、個別のイベントを格納します。 これにより、UIでこれらのイベントを表示できるようになります。 ユーザーが1枚のカードをクリックして、この時点に至ったイベントの表を取得できるようにします。 要約とは異なり、これらのイベントはそれぞれ1つのアベンジャーと1つの個別の時点を表します。 新しいイベントを受信するたびに、他のすべてのイベントとともにテーブルに追加されます。

これにはCassandraを使用しています。これは、時系列データを非常に効率的に保存できるためです。この場合、読み取りよりもはるかに頻繁に書き込みを行います。 ここでの目標は、頻繁に(たとえば、30秒ごとに)更新でき、ユーザーが記録された最新のイベントを簡単に確認できるようにするシステムです。

2. データベーススキーマの構築

前の記事で使用したDocumentAPIとは異なり、これはRESTおよびGraphQLAPIを使用して構築されます。 これらはCassandraテーブル上で機能し、これらのAPIは相互におよびCQLAPIと完全に連携できます。

これらを操作するには、データを格納するテーブルのスキーマで定義しておく必要があります。 使用しているテーブルは、特定のスキーマで機能するように設計されています。特定のAvengerのイベントを、発生した順に検索します。

このスキーマは次のようになります。

CREATE TABLE events (
    avenger text,
    timestamp timestamp,
    latitude decimal,
    longitude decimal,
    status decimal,
    PRIMARY KEY (avenger, timestamp)
) WITH CLUSTERING ORDER BY (timestamp DESC);

これに似たデータの場合:

復讐者 タイムスタンプ 緯度 経度 状態
 ファルコン  2021-05-16 09:00:30.000000 + 0000  40.715255  -73.975353  0.999954
 ホークアイ  2021-05-16 09:00:30.000000 + 0000  40.714602  -73.975238  0.99986
 ホークアイ  2021-05-16 09:01:00.000000 + 0000  40.713572  -73.975289  0.999804

これにより、テーブルが複数行パーティションになり、パーティションキーが「avenger」、クラスタリングキーが「timestamp」になるように定義されます。 パーティションキーは、データが保存されているノードを決定するためにCassandraによって使用されます。 クラスタリングキーは、データがパーティション内に格納される順序を決定するために使用されます。

「アベンジャー」がパーティションキーであることを示すことにより、同じアベンジャーのすべてのデータが一緒に保持されるようになります。 「タイムスタンプ」がクラスタリングキーであることを示すことにより、データをこのパーティション内に最も効率的な順序で格納し、取得できるようにします。 このデータのコアクエリは、単一のAvengerのすべてのイベント(パーティションキー)をイベントのタイムスタンプ(クラスタリングキー)の順に選択することであるため、Cassandraを使用するとこれに非常に効率的にアクセスできます。

さらに、アプリケーションが使用されるように設計されているということは、ほぼ継続的にイベントデータを書き込んでいることを意味します。 たとえば、30秒ごとにすべてのアベンジャーから新しいイベントを取得する場合があります。 このようにテーブルを構成すると、新しいイベントを正しいパーティションの正しい位置に挿入するのが非常に効率的になります。

便宜上、データベースを事前入力するための script も、このスキーマを作成して入力します。

3. Astra、REST、およびGraphQLAPIを使用したクライアントレイヤーの構築

さまざまな目的で、RESTAPIとGraphQLAPIの両方を使用してAstraと対話します。 REST APIは、テーブルに新しいイベントを挿入するために使用されます。 GraphQL APIは、それらを再度取得するために使用されます。

これを最適に行うには、Astraとの対話を実行できるクライアントレイヤーが必要です。 これらは、これらの他の2つのAPIについて、前の記事で作成したDocumentClientクラスと同等です。

3.1. RESTクライアント

まず、RESTクライアントです。 これを使用して新しいレコード全体を挿入しますので、挿入するデータを取得する単一のメソッドのみが必要です。

@Repository
public class RestClient {
  @Value("https://${ASTRA_DB_ID}-${ASTRA_DB_REGION}.apps.astra.datastax.com/api/rest/v2/keyspaces/${ASTRA_DB_KEYSPACE}")
  private String baseUrl;

  @Value("${ASTRA_DB_APPLICATION_TOKEN}")
  private String token;

  private RestTemplate restTemplate;

  public RestClient() {
    this.restTemplate = new RestTemplate();
    this.restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
  }

  public <T> void createRecord(String table, T record) {
    var uri = UriComponentsBuilder.fromHttpUrl(baseUrl)
      .pathSegment(table)
      .build()
      .toUri();
    var request = RequestEntity.post(uri)
      .header("X-Cassandra-Token", token)
      .body(record);
    restTemplate.exchange(request, Map.class);
  }
}

3.2. GraphQLクライアント

次に、GraphQLクライアント。 今回は完全なGraphQLクエリを取得し、フェッチしたデータを返します

@Repository
public class GraphqlClient {
  @Value("https://${ASTRA_DB_ID}-${ASTRA_DB_REGION}.apps.astra.datastax.com/api/graphql/${ASTRA_DB_KEYSPACE}")
  private String baseUrl;

  @Value("${ASTRA_DB_APPLICATION_TOKEN}")
  private String token;

  private RestTemplate restTemplate;

  public GraphqlClient() {
    this.restTemplate = new RestTemplate();
    this.restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
  }

  public <T> T query(String query, Class<T> cls) {
    var request = RequestEntity.post(baseUrl)
      .header("X-Cassandra-Token", token)
      .body(Map.of("query", query));
    var response = restTemplate.exchange(request, cls);
  
    return response.getBody();
  }
}

以前と同様に、baseUrlおよびtokenフィールドは、Astraとの通信方法を定義するプロパティから構成されます。 これらのクライアントクラスはそれぞれ、データベースとの対話に必要な完全なURLを構築する方法を知っています。 それらを使用して、必要なアクションを実行するための正しいHTTP要求を行うことができます。

これらのAPIはHTTPを介してJSONドキュメントを交換するだけで機能するため、Astraと対話するために必要なのはこれだけです。

4. 個々のイベントの記録

イベントを表示するには、イベントを記録できる必要があります。 これは、 statuses テーブルを更新するために以前に持っていた機能に基づいて構築され、さらにeventsテーブルに新しいレコードを挿入します。

4.1. イベントの挿入

最初に必要なのは、このテーブルのデータの表現です。 これは、Javaレコードとして表されます。

public record Event(String avenger, 
  String timestamp,
  Double latitude,
  Double longitude,
  Double status) {}

これは、前に定義したスキーマに直接相関します。実際にAPI呼び出しを行うときに、JacksonはこれをRESTAPIの正しいJSONに変換します。

次に、これらを実際に記録するためのサービスレイヤーが必要です。 これにより、外部から適切な詳細が取得され、タイムスタンプでそれらが拡張され、RESTクライアントを呼び出して新しいレコードが作成されます。

@Service
public class EventsService {
  @Autowired
  private RestClient restClient;

  public void createEvent(String avenger, Double latitude, Double longitude, Double status) {
    var event = new Event(avenger, Instant.now().toString(), latitude, longitude, status);

    restClient.createRecord("events", event);
  }
}

4.2. APIを更新

最後に、イベントを受信するためのコントローラーが必要です。 これは、前の記事で書いたUpdateControllerを拡張して、新しいEventsServiceに接続し、updateメソッドから呼び出します。

@RestController
public class UpdateController {
  ......
  @Autowired
  private EventsService eventsService;

  @PostMapping("/update/{avenger}")
  public void update(@PathVariable String avenger, @RequestBody UpdateBody body) throws Exception {
    eventsService.createEvent(avenger, body.lat(), body.lng(), body.status());
    statusesService.updateStatus(avenger, lookupLocation(body.lat(), body.lng()), getStatus(body.status()));
  }
  ......
}

この時点で、Avengerのステータスを記録するためのAPIの呼び出しは、ステータスドキュメントを更新し、イベントテーブルに新しいレコードを挿入します。 これにより、発生するすべての更新イベントを記録できます。

これは、アベンジャーのステータスを更新するための呼び出しを受信するたびに、このテーブルに新しいレコードを追加することを意味します。 実際には、プルーニングまたはパーティションの追加によって保存されるデータの規模をサポートする必要がありますが、それはこの記事の範囲外です。

5. GraphQLAPIを介してユーザーがイベントを利用できるようにする

テーブルにイベントができたら、次のステップはそれらをユーザーが利用できるようにすることです。 これは、GraphQL APIを使用して実現し、特定のアベンジャーのイベントのページを一度に取得します。常に最新のものが最初に来るように順序付けられます

GraphQLを使用すると、すべてではなく、実際に関心のあるフィールドのサブセットのみを取得することもできます。 多数のレコードをフェッチしている場合、これはペイロードサイズを抑え、パフォーマンスを向上させるのに役立ちます。

5.1. イベントの取得

最初に必要なのは、取得するデータの表現です。これは、テーブルに格納されている実際のデータのサブセットです。 そのため、それを表す別のクラスが必要になります。

public record EventSummary(String timestamp,
  Double latitude,
  Double longitude,
  Double status) {}

これらのリストのGraphQL応答を表すクラスも必要です。 これには、イベントの要約のリストと、次のページへのカーソルに使用するページの状態が含まれます。

public record Events(List<EventSummary> values, String pageState) {}

これで、イベントサービス内に新しいメソッドを作成して、実際に検索を実行できます。

public class EventsService {
  ......
  @Autowired
  private GraphqlClient graphqlClient;

  public Events getEvents(String avenger, String offset) {
    var query = "query {" + 
      "  events(filter:{avenger:{eq:\"%s\"}}, orderBy:[timestamp_DESC], options:{pageSize:5, pageState:%s}) {" +
      "    pageState " +
      "    values {" +
      "     timestamp " +
      "     latitude " +
      "     longitude " +
      "     status" +
      "   }" +
      "  }" +
      "}";

    var fullQuery = String.format(query, avenger, offset == null ? "null" : "\"" + offset + "\"");

    return graphqlClient.query(fullQuery, EventsGraphqlResponse.class).data().events();
  }

  private static record EventsResponse(Events events) {}
  private static record EventsGraphqlResponse(EventsResponse data) {}
}

ここに、GraphQL APIによって返されるJSON構造を、私たちにとって興味深い部分まで表すためだけに存在する2つの内部クラスがあります。これらは、完全にGraphQLAPIのアーティファクトです。

次に、必要な詳細のGraphQLクエリを作成するメソッドがあり、 avenger フィールドでフィルタリングし、timestampフィールドで降順で並べ替えます。 これに、実際のデータを取得するためにGraphQLクライアントに渡す前に、使用する実際のAvengerIDとページ状態を置き換えます。

5.2. UIでのイベントの表示

データベースからイベントをフェッチできるようになったので、これをUIに接続できます。

まず、前の記事で書いた StatusesController を更新して、イベントをフェッチするためのUIエンドポイントをサポートします。

public class StatusesController {
  ......

  @Autowired
  private EventsService eventsService;

  @GetMapping("/avenger/{avenger}")
  public Object getAvengerStatus(@PathVariable String avenger, @RequestParam(required = false) String page) {
    var result = new ModelAndView("dashboard");
    result.addObject("avenger", avenger);
    result.addObject("statuses", statusesService.getStatuses());
    result.addObject("events", eventsService.getEvents(avenger, page));

    return result;
  }
}

次に、テンプレートを更新してイベントテーブルをレンダリングする必要があります。 dashboard.html ファイルに新しいテーブルを追加します。このテーブルは、eventsオブジェクトがコントローラーから受け取ったモデルに存在する場合にのみレンダリングされます。

......
    <div th:if="${events}">
      <div class="row">
        <table class="table">
          <thead>
            <tr>
              <th scope="col">Timestamp</th>
              <th scope="col">Latitude</th>
              <th scope="col">Longitude</th>
              <th scope="col">Status</th>
            </tr>
          </thead>
          <tbody>
            <tr th:each="data, iterstat: ${events.values}">
              <th scope="row" th:text="${data.timestamp}">
                </td>
              <td th:text="${data.latitude}"></td>
              <td th:text="${data.longitude}"></td>
              <td th:text="${(data.status * 100) + '%'}"></td>
            </tr>
          </tbody>
        </table>
      </div>

      <div class="row" th:if="${events.pageState}">
        <div class="col position-relative">
          <a th:href="@{/avenger/{id}(id = ${avenger}, page = ${events.pageState})}"
            class="position-absolute top-50 start-50 translate-middle">Next
            Page</a>
        </div>
      </div>
    </div>
  </div>
......

これには、次のページに移動するための下部のリンクが含まれます。このリンクは、イベントデータと表示している復讐者のIDからページの状態を通過します。

最後に、ステータスカードを更新して、このエントリのイベントテーブルにリンクできるようにする必要があります。 これは、 status.html:でレンダリングされた、各カードのヘッダー周辺のハイパーリンクです。

......
  <a th:href="@{/avenger/{id}(id = ${data.avenger})}">
    <h5 class="card-title" th:text="${data.name}"></h5>
  </a>
......

これで、アプリケーションを起動し、カードをクリックして、このステータスにつながる最新のイベントを確認できます。

GraphQLを使用したステータス更新で拡張されたアベンジャーズステータスダッシュボード

6. 概要

ここでは、AstraRESTおよびGraphQLAPIを使用して行ベースのデータを処理する方法と、それらを連携させる方法を確認しました。 また、CassandraとこれらのAPIが大規模なデータセットにどれだけうまく使用できるかを見始めています。

この記事のすべてのコードは、GitHubにあります。