1. 序章

前の記事では、ダッシュボードを拡張して、 ApacheCassandraを使用したサーバーレスDBaaSであるDataStaxAstraを使用してアベンジャーズからの個々のイベントを保存および表示する方法について説明しました。 Stargate は、それを操作するための追加のAPIを提供します。

この記事では、まったく同じデータを別の方法で利用します。 ユーザーが表示するアベンジャーズと関心のある期間を選択し、インタラクティブマップにこれらのイベントを表示できるようにします。前の記事とは異なり、これによりユーザーは次のことができるようになります。地理と時間の両方で相互作用するデータを確認してください。

この記事を続けるために、このシリーズの最初のおよび 2番目のの記事をすでに読んでおり、Java 16、Spring、少なくとも、Cassandraがデータの保存とアクセスに何を提供できるかを理解していること。 また、 GitHub のコードを記事の横に開いて、フォローする方が簡単な場合もあります。

2. サービスのセットアップ

Cassandraクエリ言語のクエリを使用して、CQL APIを使用してデータを取得します。これには、サーバーと通信できるようにするための追加のセットアップが必要です。

2.1. SecureConnectBundleをダウンロードします。

DataStax AstraがホストするCassandraデータベースにCQL経由で接続するには、「Secure ConnectBundle」をダウンロードする必要があります。これは、この正確なデータベースのSSL証明書と接続の詳細を含むzipファイルです。安全に接続する必要があります。

これは、正確なデータベースの[接続]タブの下にあるAstraダッシュボードから利用でき、[ドライバーを使用して接続する]の下の[Java]オプションから利用できます。

実用的な理由から、このファイルを src / main / resources に配置して、クラスパスからアクセスできるようにします。 通常の展開状況では、さまざまなデータベースに接続するためにさまざまなファイルを提供できる必要があります。たとえば、開発環境と本番環境にさまざまなデータベースを用意する必要があります。

2.2. クライアントクレデンシャルの作成

データベースに接続するには、クライアントの資格情報も必要です。アクセストークンを使用する以前の記事で使用したAPIとは異なり、CQLAPIには「ユーザー名」と「パスワード”。 これらは実際には、「組織」の下の「トークンの管理」セクションから生成するクライアントIDとクライアントシークレットです。

これが完了したら、生成されたクライアントIDとクライアントシークレットをapplication.propertiesに追加する必要があります。

ASTRA_DB_CLIENT_ID=clientIdHere
ASTRA_DB_CLIENT_SECRET=clientSecretHere

2.3. GoogleMapsAPIキー

地図をレンダリングするために、Googleマップを使用します。 このAPIを使用するには、GoogleAPIキーが必要になります。

Googleアカウントにサインアップした後、 GoogleCloudPlatformダッシュボードにアクセスする必要があります。 ここで、新しいプロジェクトを作成できます。

次に、このプロジェクトでGoogle MapsJavaScriptAPIを有効にする必要があります。 これを検索して有効にします。

最後に、これを使用できるようにするためのAPIキーが必要です。 このためには、サイドバーの[資格情報]ペインに移動し、上部の[資格情報の作成]をクリックして、[APIキー]を選択する必要があります。

次に、このキーをapplication.propertiesファイルに追加する必要があります。

GOOGLE_CLIENT_ID=someRandomClientId

3. AstraとCQLを使用したクライアントレイヤーの構築

CQLを介してデータベースと通信するには、クライアントレイヤーを作成する必要があります。これは、DataStax CQL APIをラップし、接続の詳細を抽象化するCqlClientというクラスになります。

@Repository
public class CqlClient {
  @Value("${ASTRA_DB_CLIENT_ID}")
  private String clientId;

  @Value("${ASTRA_DB_CLIENT_SECRET}")
  private String clientSecret;

  public List<Row> query(String cql, Object... binds) {
    try (CqlSession session = connect()) {
      var statement = session.prepare(cql);
      var bound = statement.bind(binds);
      var rs = session.execute(bound);

      return rs.all();
    }
  }

  private CqlSession connect() {
    return CqlSession.builder()
      .withCloudSecureConnectBundle(CqlClient.class.getResourceAsStream("/secure-connect-baeldung-avengers.zip"))
      .withAuthCredentials(clientId, clientSecret)
      .build();
  }
}

これにより、データベースに接続して任意のCQLクエリを実行する単一のパブリックメソッドが提供され、いくつかのバインド値をデータベースに提供できるようになります。

データベースへの接続には、以前に生成したSecureConnectBundleとクライアント資格情報が使用されます。SecureConnectBundleはsrc/ main / resources/secure-connect-baeldung-に配置されている必要があります。 avengers.zip 、およびクライアントIDとシークレットは、適切なプロパティ名を使用してapplication.propertiesに配置されている必要があります。

この実装は、クエリからメモリにすべての行をロードし、終了する前にそれらを単一のリストとして返すことに注意してください。 これはこの記事の目的のためだけですが、そうでない場合ほど効率的ではありません。 たとえば、各行が返されるときに個別にフェッチして処理したり、クエリ全体をjava.util.streams.Streamでラップして処理したりすることもできます。

4. 必要なデータの取得

クライアントがCQLAPIと対話できるようになったら、表示するデータを実際にフェッチするためのサービスレイヤーが必要です。

まず、データベースからフェッチしている各行を表すJavaレコードが必要です。

public record Location(String avenger, 
  Instant timestamp, 
  BigDecimal latitude, 
  BigDecimal longitude, 
  BigDecimal status) {}

次に、データを取得するためのサービスレイヤーが必要です。

@Service
public class MapService {
  @Autowired
  private CqlClient cqlClient;

  // To be implemented.
}

これに、先ほど記述した CqlClient を使用して、実際にデータベースにクエリを実行する関数を記述し、適切な詳細を返します。

4.1. アベンジャーズのリストを生成する

最初の機能は、以下の詳細を表示できるすべてのアベンジャーズのリストを取得することです。

public List<String> listAvengers() {
  var rows = cqlClient.query("select distinct avenger from avengers.events");

  return rows.stream()
    .map(row -> row.getString("avenger"))
    .sorted()
    .collect(Collectors.toList());
}

これは、イベントテーブルからアベンジャー列の個別の値のリストを取得するだけです。これはパーティションキーであるため、非常に効率的です。 CQLでは、パーティションキーにフィルターがある場合にのみ結果を並べ替えることができるため、代わりにJavaコードで並べ替えを行っています。 ただし、返される行の数が少ないことがわかっているため、これは問題ありません。そのため、並べ替えに費用はかかりません。

4.2. 場所の詳細を生成する

もう1つの機能は、地図に表示したいすべての場所の詳細のリストを取得することです。 これは、復讐者のリストと開始時刻と終了時刻を取得し、必要に応じてグループ化されたそれらのすべてのイベントを返します:

public Map<String, List<Location>> getPaths(List<String> avengers, Instant start, Instant end) {
  var rows = cqlClient.query("select avenger, timestamp, latitude, longitude, status from avengers.events where avenger in ? and timestamp >= ? and timestamp <= ?", 
    avengers, start, end);

  var result = rows.stream()
    .map(row -> new Location(
      row.getString("avenger"), 
      row.getInstant("timestamp"), 
      row.getBigDecimal("latitude"), 
      row.getBigDecimal("longitude"),
      row.getBigDecimal("status")))
    .collect(Collectors.groupingBy(Location::avenger));

  for (var locations : result.values()) {
    Collections.sort(locations, Comparator.comparing(Location::timestamp));
  }

  return result;
}

CQLバインドは、IN句を自動的に拡張して、複数のアベンジャーを正しく処理します。パーティションとクラスタリングキーでフィルタリングしているため、これを効率的に実行できます。 次に、これらを Location オブジェクトに解析し、 avenger フィールドでグループ化し、各グループ化がタイムスタンプでソートされていることを確認します。

5. 地図の表示

データをフェッチできるようになったので、実際にユーザーにデータを表示させる必要があります。これには、最初にデータを取得するためのコントローラーの作成が含まれます。

5.1. マップコントローラー

@Controller
public class MapController {
  @Autowired
  private MapService mapService;

  @Value("${GOOGLE_CLIENT_ID}")
  private String googleClientId;

  @ModelAttribute("googleClientId")
  String getGoogleClientId() {
    return googleClientId;
  }

  @GetMapping("/map")
  public ModelAndView showMap(@RequestParam(name = "avenger", required = false) List<String> avenger,
  @RequestParam(required = false) String start, @RequestParam(required = false) String end) throws Exception {
    var result = new ModelAndView("map");
    result.addObject("inputStart", start);
    result.addObject("inputEnd", end);
    result.addObject("inputAvengers", avenger);
    
    result.addObject("avengers", mapService.listAvengers());

    if (avenger != null && !avenger.isEmpty() && start != null && end != null) {
      var paths = mapService.getPaths(avenger, 
        LocalDateTime.parse(start).toInstant(ZoneOffset.UTC), 
        LocalDateTime.parse(end).toInstant(ZoneOffset.UTC));

      result.addObject("paths", paths);
    }

    return result;
  }
}

これはサービスレイヤーを使用してアベンジャーのリストを取得し、入力が提供されている場合は、それらの入力の場所のリストも取得します。 ModelAttributeも提供します使用するビューへのGoogleクライアントID。

5.1. マップテンプレート

コントローラを作成したら、実際にHTMLをレンダリングするためのテンプレートが必要です。 これは、以前の記事と同様にThymeleafを使用して記述されます。

<!doctype html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous" />

  <title>Avengers Status Map</title>
</head>

<body>
  <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container-fluid">
      <a class="navbar-brand" href="#">Avengers Status Map</a>
    </div>
  </nav>

  <div class="container-fluid mt-4">
    <div class="row">
      <div class="col-3">
        <form action="/map" method="get">
          <div class="mb-3">
            <label for="avenger" class="form-label">Avengers</label>
            <select class="form-select" multiple name="avenger" id="avenger" required>
              <option th:each="avenger: ${avengers}" th:text="${avenger}" th:value="${avenger}"
                th:selected="${inputAvengers != null && inputAvengers.contains(avenger)}"></option>
            </select>
          </div>
          <div class="mb-3">
            <label for="start" class="form-label">Start Time</label>
            <input type="datetime-local" class="form-control" name="start" id="start" th:value="${inputStart}"
              required />
          </div>
          <div class="mb-3">
            <label for="end" class="form-label">End Time</label>
            <input type="datetime-local" class="form-control" name="end" id="end" th:value="${inputEnd}" required />
          </div>
          <button type="submit" class="btn btn-primary">Submit</button>
        </form>
      </div>
      <div class="col-9">
        <div id="map" style="width: 100%; height: 40em;"></div>
      </div>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous">
    </script>
  <script type="text/javascript" th:inline="javascript">
    /*<![CDATA[*/
    let paths = /*[[${paths}]]*/ {};

    let map;
    let openInfoWindow;

    function initMap() {
      let averageLatitude = 0;
      let averageLongitude = 0;

      if (paths) {
        let numPaths = 0;

        for (const path of Object.values(paths)) {
          let last = path[path.length - 1];
          averageLatitude += last.latitude;
          averageLongitude += last.longitude;
          numPaths++;
        }

        averageLatitude /= numPaths;
        averageLongitude /= numPaths;
      } else {
        // We had no data, so lets just tidy things up:
        paths = {};
        averageLatitude = 40.730610;
        averageLongitude = -73.935242;
      }


      map = new google.maps.Map(document.getElementById("map"), {
        center: { lat: averageLatitude, lng: averageLongitude },
        zoom: 16,
      });

      for (const avenger of Object.keys(paths)) {
        const path = paths[avenger];
        const color = getColor(avenger);

        new google.maps.Polyline({
          path: path.map(point => ({ lat: point.latitude, lng: point.longitude })),
          geodesic: true,
          strokeColor: color,
          strokeOpacity: 1.0,
          strokeWeight: 2,
          map: map,
        });

        path.forEach((point, index) => {
          const infowindow = new google.maps.InfoWindow({
            content: "<dl><dt>Avenger</dt><dd>" + avenger + "</dd><dt>Timestamp</dt><dd>" + point.timestamp + "</dd><dt>Status</dt><dd>" + Math.round(point.status * 10000) / 100 + "%</dd></dl>"
          });

          const marker = new google.maps.Marker({
            position: { lat: point.latitude, lng: point.longitude },
            icon: {
              path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
              strokeColor: color,
              scale: index == path.length - 1 ? 5 : 3
            },
            map: map,
          });

          marker.addListener("click", () => {
            if (openInfoWindow) {
              openInfoWindow.close();
              openInfoWindow = undefined;
            }

            openInfoWindow = infowindow;
            infowindow.open({
              anchor: marker,
              map: map,
              shouldFocus: false,
            });
          });

        });
      }
    }

    function getColor(avenger) {
      return {
        wanda: '#ff2400',
        hulk: '#008000',
        hawkeye: '#9370db',
        falcon: '#000000'
      }[avenger];
    }

    /*]]>*/
  </script>

  <script
    th:src="${'https://maps.googleapis.com/maps/api/js?key=' + googleClientId + '&callback=initMap&libraries=&v=weekly'}"
    async></script>
</body>

</html>

Cassandraから取得したデータとその他の詳細を挿入しています。 Thymeleafは、scriptブロック内のオブジェクトの有効なJSONへの変換を自動的に処理します。 これが完了すると、JavaScriptはGoogle Maps APIを使用して地図をレンダリングし、そこにいくつかのルートとマーカーを追加して、選択したデータを表示します。

この時点で、完全に機能するアプリケーションができました。 これに、表示するアベンジャー、関心のある日付と時刻の範囲を選択し、データで何が起こっているかを確認できます。

6. 結論

ここでは、Cassandraデータベースから取得したデータを視覚化する別の方法を確認し、このデータを取得するために使用されているAstraCQLAPIを示しました。

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