1. 소개

이전 기사 에서는 Apache Cassandra 로 구동되는 서버리스 DBaaS인 DataStax Astra를 사용 하여 어벤저스의 개별 이벤트를 저장하고 표시하기 위해 대시 보드 를 보강하는 방법을 살펴보았습니다 .

이 기사에서는 정확히 동일한 데이터를 다른 방식으로 사용할 것입니다. 우리는 사용자가 표시할 어벤저스, 관심 기간을 선택한 다음 이러한 이벤트를 대화형 Map에 표시할 수 있도록 할 것입니다. 이전 기사와 달리 사용자는 지리와 시간 모두에서 서로 상호 작용하는 데이터를 볼 수 있습니다.

이 기사를 따라하기 위해 이 시리즈의 첫 번째두 번째 기사를 이미 읽었고 Java 16, Spring에 대한 실무 지식이 있고 최소한 Cassandra가 데이터에 제공할 수 있는 기능에 대해 이해하고 있다고 가정합니다. 저장 및 액세스. 따라할 문서와 함께 GitHub 의 코드를 열어두는 것이 더 쉬울 수도 있습니다 .

2. 서비스 설정

Cassandra 쿼리 언어 의 쿼리를 사용하여 CQL API를 사용하여 데이터를 검색합니다 . 이를 위해서는 서버와 통신할 수 있도록 몇 가지 추가 설정이 필요합니다.

2.1. Security 연결 번들을 다운로드하십시오.

CQL을 통해 DataStax Astra가 호스팅하는 Cassandra 데이터베이스에 연결하려면 "Secure Connect Bundle"을 다운로드해야 합니다. 이것은 SSL 인증서와 이 정확한 데이터베이스에 대한 연결 세부 정보가 포함된 zip 파일로 안전하게 연결할 수 있습니다.

이는 정확한 데이터베이스의 "연결" 탭 아래에 있는 Astra 대시보드에서 사용할 수 있으며 "드라이버를 사용하여 연결" 아래의 "Java" 옵션에서 사용할 수 있습니다.

아스트라 Security 연결

실용적인 이유로 클래스 경로에서 액세스할 수 있도록 이 파일을 src/main/resources 에 넣을 것입니다 . 일반적인 배포 상황에서는 서로 다른 데이터베이스에 연결하기 위해 서로 다른 파일을 제공할 수 있어야 합니다. 예를 들어 개발 및 프로덕션 환경을 위한 서로 다른 데이터베이스를 보유해야 합니다.

2.2. 클라이언트 자격 증명 생성

또한 데이터베이스에 연결하려면 일부 클라이언트 자격 증명이 필요합니다. 액세스 토큰을 사용하는 이전 기사에서 사용한 API와 달리 CQL API에는 "사용자 이름"과 "비밀번호"가 필요합니다. 이들은 실제로 "조직" 아래의 "토큰 관리" 섹션에서 생성한 클라이언트 ID 및 클라이언트 암호입니다.

astra 클라이언트 자격 증명

이 작업이 완료되면 생성된 클라이언트 ID와 클라이언트 암호를 application.properties 에 추가해야 합니다 .

ASTRA_DB_CLIENT_ID=clientIdHere
ASTRA_DB_CLIENT_SECRET=clientSecretHere

2.3. Google Map API 키

Map를 렌더링하기 위해 Google Map를 사용할 것입니다. 그러면 이 API를 사용할 수 있으려면 Google API 키가 필요합니다.

Google 계정에 가입한 후 Google Cloud Platform 대시보드를 방문해야 합니다 . 여기에서 새 프로젝트를 만들 수 있습니다.

Map API 키

그런 다음 이 프로젝트에 대해 Google Maps JavaScript API를 활성화해야 합니다. 이것을 검색하고 다음을 활성화하십시오.

맵 js API

마지막으로 이것을 사용하려면 API 키가 필요합니다. 이를 위해 사이드바의 "Credentials" 창으로 이동하여 상단의 "Create Credentials"를 클릭하고 API 키를 선택해야 합니다.

Map 키 생성됨

이제 이 키를 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 쿼리를 실행하여 일부 바인드 값을 제공할 수 있는 단일 공용 메서드가 제공됩니다.

데이터베이스에 연결하면 이전에 생성한 Security 연결 번들 및 클라이언트 자격 증명을 사용합니다. Security 연결 번들은 src/main/resources/secure-connect-baeldung-avengers.zip 에 배치되어야 하고 클라이언트 ID 및 시크릿은 적절한 속성 이름과 함께 application.properties 에 배치되어야 합니다 .

이 구현은 쿼리의 모든 행을 메모리로 로드하고 완료하기 전에 단일 List으로 반환합니다. 이것은 이 문서의 목적을 위한 것일 뿐이지 그렇지 않은 경우만큼 효율적이지는 않습니다. 예를 들어 반환되는 각 행을 개별적으로 가져와 처리하거나 처리할 java.util.streams.Stream 에서 전체 쿼리를 래핑할 수도 있습니다 .

4. 필요한 데이터 가져오기

클라이언트가 CQL API와 상호 작용할 수 있게 되면 표시할 데이터를 실제로 가져올 서비스 계층이 필요합니다.

먼저 데이터베이스에서 가져오는 각 행을 나타내는 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. 복수자 List 생성

첫 번째 기능은 세부 정보를 표시할 수 있는 모든 Avengers List을 가져오는 것입니다.

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

이벤트 테이블 에서 avenger 열의 고유한 값 List을 가져옵니다 . 이것이 파티션 키이기 때문에 매우 효율적입니다. CQL은 파티션 키에 필터가 있는 경우에만 결과를 정렬할 수 있으므로 대신 Java 코드에서 정렬을 수행합니다. 반환되는 행의 수가 적어 정렬 비용이 많이 들지 않는다는 것을 알고 있기 때문에 괜찮습니다.

4.2. 위치 세부 정보 생성

다른 기능은 Map에 표시하려는 모든 위치 세부 정보 List을 가져오는 것입니다. 이것은 어벤저 List과 시작 및 종료 시간을 가져오고 적절하게 그룹화된 이들에 대한 모든 이벤트를 반환합니다.

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. Map 표시

이제 데이터를 가져올 수 있으므로 사용자가 실제로 데이터를 볼 수 있도록 해야 합니다. 이것은 먼저 데이터를 얻기 위한 컨트롤러 작성을 포함합니다:

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

이것은 서비스 계층을 사용하여 어벤저 List을 가져오고 입력이 제공된 경우 해당 입력에 대한 위치 List도 가져옵니다. 또한 사용하기 위해 보기에 Google 클라이언트 ID를 제공하는 ModelAttribute 도 있습니다 .

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는 스크립트 블록 내의 객체를 유효한 JSON으로 변환하는 작업을 자동으로 처리합니다. 이 작업이 완료되면 JavaScript는 Google Maps API를 사용하여 Map를 렌더링하고 일부 경로와 마커를 추가하여 선택한 데이터를 표시합니다.

이 시점에서 완전히 작동하는 애플리케이션이 있습니다. 여기에서 표시할 어벤저, 관심 있는 날짜 및 시간 범위를 선택하고 데이터에 어떤 일이 발생했는지 확인할 수 있습니다.

어벤져스 Map

6. 결론

여기서 우리는 Cassandra 데이터베이스에서 검색된 데이터를 시각화하는 다른 방법을 보았고 이 데이터를 얻기 위해 사용 중인 Astra CQL API를 보여주었습니다.

이 기사의 모든 코드는 GitHub 에서 찾을 수 있습니다 .