1. 소개

이전 기사 에서 DataStax Astra를 사용하여 Avengers의 현재 상태를 보기 위한 대시보드를 구축하는 방법을 살펴보았습니다 . DataStax Astra는 Stargate 를 사용하여 작업을 위한 추가 API를 제공하는 Apache Cassandra 로 구동되는 DBaaS입니다 .

cassandra avengers 상태 대시보드 1

Cassandra와 Stargate로 구축된 어벤져스 상태 대시보드

이 문서에서는 롤업된 요약 대신 개별 이벤트를 저장하도록 이를 확장할 것입니다. 이렇게 하면 UI에서 이러한 이벤트를 볼 수 있습니다. 우리는 사용자가 단일 카드를 클릭하고 우리를 이 지점으로 이끈 이벤트 표를 얻을 수 있도록 할 것입니다. 요약과 달리 이러한 이벤트는 각각 하나의 어벤저와 하나의 개별 시점을 나타냅니다. 새 이벤트가 수신될 때마다 다른 모든 이벤트와 함께 테이블에 추가됩니다.

이를 위해 Cassandra를 사용하는 이유 는 읽는 것보다 훨씬 더 자주 쓰는 시계열 데이터를 저장하는 매우 효율적인 방법을 허용하기 때문입니다 . 여기서 목표는 예를 들어 30초마다 자주 업데이트할 수 있는 시스템을 만들고 사용자가 가장 최근에 기록된 이벤트를 쉽게 볼 수 있도록 하는 것입니다.

2. 데이터베이스 스키마 구축

이전 기사에서 사용한 문서 API와 달리 RESTGraphQL API를 사용하여 빌드됩니다. 이들은 Cassandra 테이블 위에서 작동하며 이러한 API는 서로 및 CQL API와 완벽하게 협력할 수 있습니다.

이를 사용하려면 데이터를 저장하는 테이블에 대한 스키마를 이미 정의 해야 합니다. 우리가 사용하고 있는 테이블은 특정 스키마와 함께 작동하도록 설계되었습니다. 주어진 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초마다 모든 Avenger로부터 새 이벤트를 받을 수 있습니다. 이러한 방식으로 테이블을 구성하면 새 이벤트를 올바른 파티션의 올바른 위치에 삽입하는 것이 매우 효율적입니다.

편의를 위해 데이터베이스를 미리 채우는 스크립트 도 이 스키마를 만들고 채웁니다.

3. Astra, REST 및 GraphQL API를 사용하여 클라이언트 계층 구축

서로 다른 목적으로 REST 및 GraphQL API를 모두 사용하여 Astra와 상호 작용할 것입니다 . REST API는 새 이벤트를 테이블에 삽입하는 데 사용됩니다. GraphQL API는 다시 검색하는 데 사용됩니다.

이를 가장 잘 수행하려면 Astra와의 상호 작용을 수행할 수 있는 클라이언트 계층이 필요합니다. 이들은 다른 두 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토큰 필드는 Astra와 통신하는 방법을 정의하는 속성에서 구성됩니다. 이러한 클라이언트 클래스는 각각 데이터베이스와 상호 작용하는 데 필요한 전체 URL을 빌드하는 방법을 알고 있습니다. 원하는 작업을 수행하기 위해 올바른 HTTP 요청을 만드는 데 사용할 수 있습니다.

이러한 API는 HTTP를 통해 JSON 문서를 교환하는 방식으로 작동하므로 Astra와 상호 작용하는 데 필요한 전부입니다.

4. 개별 이벤트 기록

이벤트를 표시하려면 이벤트를 기록할 수 있어야 합니다. 이것은 상태 테이블을 업데이트하기 위해 이전에 가지고 있던 기능 위에 구축되며 이벤트 테이블 에 새 레코드를 추가로 삽입합니다 .

4.1. 이벤트 삽입

가장 먼저 필요한 것은 이 테이블의 데이터 표현입니다. 이는 Java 레코드로 표시됩니다.

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

이는 앞에서 정의한 스키마와 직접적으로 관련이 있습니다. Jackson은 실제로 API를 호출할 때 이를 REST API에 대한 올바른 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 에 연결 한 다음 업데이트 메서드 에서 호출하도록 하는 것입니다 .

@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를 호출하면 상태 문서가 업데이트되고 이벤트 테이블에 새 레코드가 삽입됩니다. 이렇게 하면 발생하는 모든 업데이트 이벤트를 기록할 수 있습니다.

즉, Avenger의 상태를 업데이트하라는 호출을 받을 때마다 이 테이블에 새 레코드를 추가하게 됩니다. 실제로 우리는 가지치기나 추가 분할을 추가하여 저장되는 데이터의 규모를 지원해야 하지만 이 기사의 범위를 벗어납니다.

5. GraphQL API를 통해 사용자에게 이벤트 제공

테이블에 이벤트가 있으면 다음 단계는 사용자가 이벤트를 사용할 수 있도록 하는 것입니다. 우리는 GraphQL API를 사용하여 주어진 Avenger에 대해 한 번에 이벤트 페이지를 검색하고 가장 최근 항목이 먼저 오도록 정렬하여 이를 달성할 것입니다 .

GraphQL을 사용하면 필드 전체가 아니라 실제로 관심이 있는 필드의 하위 집합만 검색할 수도 있습니다. 많은 수의 레코드를 가져오는 경우 페이로드 크기를 줄여 성능을 향상시키는 데 도움이 될 수 있습니다.

5.1. 이벤트 검색

가장 먼저 필요한 것은 검색 중인 데이터의 표현입니다. 이것은 테이블에 저장된 실제 데이터의 하위 집합입니다. 따라서 우리는 그것을 표현하기 위해 다른 클래스를 원할 것입니다:

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

또한 이러한 List에 대한 GraphQL 응답을 나타내는 클래스가 필요합니다. 여기에는 이벤트 요약 List과 다음 페이지로 커서를 이동하는 데 사용할 페이지 상태가 포함됩니다.

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

이제 실제로 검색을 수행하기 위해 Events Service 내에서 새 메서드를 생성할 수 있습니다.

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 구조를 우리에게 흥미로운 부분까지 순전히 나타내기 위해 존재하는 두 개의 내부 클래스가 있습니다. 이들은 전적으로 GraphQL API의 인공물입니다.

그런 다음 원하는 세부 정보에 대한 GraphQL 쿼리를 구성하는 메서드가 있습니다. avenger 필드별로 필터링하고 타임스탬프 필드별로 내림차순으로 정렬합니다. 실제 데이터를 얻기 위해 GraphQL 클라이언트에 전달하기 전에 사용할 실제 Avenger ID와 페이지 상태를 여기로 대체합니다.

5.2. UI에 이벤트 표시

이제 데이터베이스에서 이벤트를 가져올 수 있으므로 이를 UI에 연결할 수 있습니다.

먼저 이벤트 가져오기를 위한 UI 엔드포인트를 지원하기 위해 이전 기사에서 작성한 StatusesController 를 업데이트합니다 .

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 파일 에 추가합니다 .

......
    <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>
......

이제 응용 프로그램을 시작하고 카드에서 클릭하여 이 상태로 이어지는 가장 최근 이벤트를 볼 수 있습니다.

cassandra avengers 상태 대시보드 이벤트

GraphQL을 사용하여 상태 업데이트로 확장된 어벤져스 상태 대시보드

6. 요약

여기서 우리는 Astra REST 및 GraphQL API를 사용하여 행 기반 데이터로 작업하는 방법과 함께 작동하는 방법을 살펴보았습니다 . 우리는 또한 Cassandra와 이러한 API가 대규모 데이터 세트에 얼마나 잘 사용될 수 있는지 보기 시작했습니다.

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