1. Введение
В нашей предыдущей статье мы рассмотрели создание дашборда для просмотра текущего состояния Мстителей с помощью DataStax Astra , DBaaS на базе Apache Cassandra с использованием Stargate , чтобы предложить дополнительные API для работы с ним.
Панель статуса Мстителей, созданная с помощью Кассандры и Звездных врат
В этой статье мы расширим это, чтобы хранить отдельные события вместо сводной сводки. Это позволит нам просматривать эти события в пользовательском интерфейсе. Мы позволим пользователю нажать на одну карту и получить таблицу событий, которые привели нас к этому моменту. В отличие от сводки, каждое из этих событий будет представлять одного Мстителя и один отдельный момент времени. Каждый раз, когда будет получено новое событие, оно будет добавлено в таблицу вместе со всеми остальными.
Мы используем Cassandra для этого, потому что это позволяет очень эффективно хранить данные временных рядов , где мы записываем гораздо чаще, чем читаем. Целью здесь является система, которая может часто обновляться — например, каждые 30 секунд — и затем может позволить пользователям легко просматривать самые последние записанные события.
2. Создание схемы базы данных
В отличие от Document API, который мы использовали в предыдущей статье, он будет создан с использованием REST и GraphQL API. Они работают поверх таблицы Cassandra, и эти API могут полностью взаимодействовать друг с другом и CQL API.
Чтобы работать с ними, нам нужно уже определить схему для таблицы, в которой мы храним наши данные. Таблица, которую мы используем, разработана для работы с определенной схемой — поиск событий для данного Мстителя в порядке их возникновения.
Эта схема будет выглядеть следующим образом:
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 секунд. Структурирование нашей таблицы таким образом позволяет очень эффективно вставлять новые события в правильную позицию в правильном разделе.
Для удобства наш скрипт для предварительного заполнения нашей базы данных также создаст и заполнит эту схему.
3. Создание уровня клиента с использованием API-интерфейсов Astra, REST и GraphQL.
Мы собираемся взаимодействовать с Astra, используя API REST и GraphQL для разных целей. REST API будет использоваться для вставки новых событий в таблицу. API GraphQL будет использоваться для их повторного получения.
Чтобы сделать это наилучшим образом, нам понадобится клиентский уровень, который может выполнять взаимодействие с Astra. Это эквивалент класса DocumentClient
, который мы создали в предыдущей статье для этих двух других API.
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-запросы для выполнения желаемых действий.
Это все, что нужно для взаимодействия с Astra, поскольку эти API работают, просто обмениваясь документами JSON через HTTP.
4. Запись отдельных событий
Чтобы отображать события, нам нужно иметь возможность их записывать. Это будет основываться на функциональности, которая была у нас раньше для обновления таблицы статусов
, и дополнительно вставит новые записи в таблицу событий .
4.1. Вставка событий
Первое, что нам нужно, это представление данных в этой таблице. Это будет представлено в виде записи Java:
public record Event(String avenger,
String timestamp,
Double latitude,
Double longitude,
Double status) {}
Это напрямую коррелирует со схемой, которую мы определили ранее. Джексон преобразует это в правильный JSON для REST API, когда мы на самом деле будем делать вызовы API.
Затем нам нужно, чтобы наш сервисный слой действительно записывал их. Это возьмет соответствующие данные извне, дополнит их отметкой времени и вызовет наш 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()));
}
......
}
На этом этапе вызовы нашего API для записи статуса Avenger обновят документ статусов и вставят новую запись в таблицу событий. Это позволит нам записывать каждое происходящее событие обновления.
Это означает, что каждый раз, когда мы получаем запрос на обновление статуса Avenger, мы будем добавлять новую запись в эту таблицу. На самом деле нам нужно будет поддерживать масштаб хранимых данных либо путем сокращения, либо путем добавления дополнительных разделов, но это выходит за рамки данной статьи. **
**
5. Предоставление пользователям доступа к событиям через GraphQL API
Когда у нас есть события в нашей таблице, следующим шагом будет сделать их доступными для пользователей. Мы добьемся этого с помощью GraphQL API, извлекая страницу событий за раз для данного Avenger, всегда упорядоченную так, чтобы самые последние были первыми .
Используя 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) {}
}
Здесь у нас есть пара внутренних классов, которые существуют исключительно для представления структуры JSON, возвращаемой GraphQL API, вплоть до интересующей нас части — это полностью артефакт GraphQL API.
Затем у нас есть метод, который строит запрос GraphQL для нужных нам деталей, фильтруя по полю мстителя
и сортируя по полю метки времени
в порядке убывания. В него мы подставляем фактический идентификатор Avenger и состояние страницы для использования, прежде чем передать его нашему клиенту GraphQL для получения фактических данных.
5.2. Отображение событий в пользовательском интерфейсе
Теперь, когда мы можем получать события из базы данных, мы можем подключить их к нашему пользовательскому интерфейсу.
Во-первых, мы обновим 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>
......
Это включает в себя ссылку внизу для перехода на следующую страницу, которая проходит через состояние страницы из наших данных о событиях и идентификатор мстителя, на которого мы смотрим.
И, наконец, нам нужно обновить карточки состояния, чтобы мы могли ссылаться на таблицу событий для этой записи. Это просто гиперссылка вокруг заголовка в каждой карточке, отображаемая в status.html:
......
<a th:href="@{/avenger/{id}(id = ${data.avenger})}">
<h5 class="card-title" th:text="${data.name}"></h5>
</a>
......
Теперь мы можем запустить приложение и щелкнуть по карточкам, чтобы увидеть самые последние события, которые привели к этому статусу:
Панель мониторинга статуса Avengers дополнена обновлениями статуса с использованием GraphQL
6. Резюме
Здесь мы увидели, как API-интерфейсы Astra REST и GraphQL можно использовать для работы с данными на основе строк и как они могут работать вместе . Мы также начинаем понимать, насколько хорошо Cassandra и эти API можно использовать для больших наборов данных.
Весь код из этой статьи можно найти на GitHub .