Перейти к основному содержимому

Создание информационной панели с помощью Cassandra, Astra и CQL — сопоставление данных о событиях

· 9 мин. чтения

1. Введение

В нашей предыдущей статье мы рассмотрели возможность расширения нашей информационной панели для хранения и отображения отдельных событий от Мстителей с помощью DataStax Astra , бессерверной DBaaS на базе Apache Cassandra с использованием Stargate , чтобы предложить дополнительные API для работы с ним.

В этой статье мы будем использовать те же самые данные по-другому. Мы позволим пользователю выбирать, кого из Мстителей отображать, интересующий период времени, а затем отображать эти события на интерактивной карте. В отличие от предыдущей статьи, это позволит пользователю увидеть, как данные взаимодействуют друг с другом как по географии, так и по времени.

Чтобы следовать этой статье, предполагается, что вы уже прочитали первую и вторую статьи в этой серии и у вас есть практические знания Java 16, Spring и, по крайней мере, понимание того, что Cassandra может предложить для данных. хранения и доступа. Также может быть проще открыть код из GitHub рядом со статьей, чтобы следовать ей.

2. Настройка службы

Мы будем извлекать данные с помощью CQL API, используя запросы на языке запросов Cassandra . Это требует дополнительной настройки, чтобы мы могли общаться с сервером.

2.1. Загрузите пакет безопасного подключения.

Чтобы подключиться к базе данных Cassandra, размещенной на DataStax Astra, через CQL, нам необходимо загрузить «Secure Connect Bundle». Это zip-файл, содержащий SSL-сертификаты и сведения о подключении для этой конкретной базы данных, что позволяет установить безопасное подключение.

Это доступно на панели инструментов Astra, которая находится на вкладке «Подключение» для нашей точной базы данных, а затем в опции «Java» в разделе «Подключиться с помощью драйвера»:

./9aaa277a090dfcfeb39485738444592d.png

Из прагматических соображений мы собираемся поместить этот файл в src/main/resources , чтобы мы могли получить к нему доступ из пути к классам. В обычной ситуации развертывания вам потребуется предоставить разные файлы для подключения к разным базам данных, например, чтобы иметь разные базы данных для среды разработки и рабочей среды.

2.2. Создание учетных данных клиента

Нам также необходимо иметь некоторые учетные данные клиента, чтобы подключиться к нашей базе данных. В отличие от API, которые мы использовали в предыдущих статьях и которые используют токен доступа, CQL API требует «имя пользователя» и «пароль». На самом деле это идентификатор клиента и секрет клиента, которые мы генерируем из раздела «Управление токенами» в разделе «Организации»:

./1374c23651653251ee9d730b75868875.png

Как только это будет сделано, нам нужно добавить сгенерированный идентификатор клиента и секрет клиента в наш application.properties :

ASTRA_DB_CLIENT_ID=clientIdHere
ASTRA_DB_CLIENT_SECRET=clientSecretHere

2.3. Ключ API Карт Google

Чтобы отобразить нашу карту, мы будем использовать Google Maps. Затем для использования этого API потребуется ключ Google API.

После регистрации учетной записи Google нам необходимо посетить панель инструментов Google Cloud Platform . Здесь мы можем создать новый проект:

./2ccfe6ff0cff863ae60396d2810fea55.png

Затем нам нужно включить Google Maps JavaScript API для этого проекта. Найдите это и включите это:

./93637a48116c2c93e6afb1f2784a35df.png

Наконец, нам нужен ключ API, чтобы иметь возможность использовать это. Для этого нам нужно перейти на панель «Учетные данные» на боковой панели, нажать «Создать учетные данные» вверху и выбрать ключ API:

./7d813127854189a19d2a17e0c2177817.png

Теперь нам нужно добавить этот ключ в наш файл application.properties :

GOOGLE_CLIENT_ID=someRandomClientId

3. Построение клиентского уровня с использованием Astra и CQL

Чтобы общаться с базой данных через CQL, нам нужно написать наш клиентский слой. Это будет класс с именем CqlClient, который обертывает API DataStax CQL, абстрагируя детали соединения:

@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-foreach-avengers.zip"))
.withAuthCredentials(clientId, clientSecret)
.build();
}
}

Это дает нам единственный общедоступный метод, который будет подключаться к базе данных и выполнять произвольный запрос CQL, позволяя предоставлять ему некоторые значения привязки.

Для подключения к базе данных используется наш Secure Connect Bundle и учетные данные клиента, которые мы создали ранее. Пакет Secure Connect должен быть помещен в src/main/resources/secure-connect-foreach-avengers.zip , а идентификатор клиента и секрет должны быть помещены в application.properties с соответствующими именами свойств.

Обратите внимание, что эта реализация загружает каждую строку из запроса в память и возвращает их в виде единого списка перед завершением. Это только для целей этой статьи, но не так эффективно, как могло бы быть в противном случае. Мы могли бы, например, извлекать и обрабатывать каждую строку по отдельности по мере их возврата или даже дойти до того, чтобы обернуть весь запрос в 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. Создайте список Мстителей

Наша первая функция — получить список всех Мстителей, сведения о которых мы можем отобразить:

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 из нашей таблицы событий . Поскольку это наш ключ раздела, он невероятно эффективен. CQL позволит нам упорядочивать результаты только в том случае, если у нас есть фильтр для ключа раздела, поэтому вместо этого мы выполняем сортировку в коде Java. Это нормально, потому что мы знаем, что у нас есть небольшое количество возвращаемых строк, поэтому сортировка не будет дорогостоящей.

4.2. Генерация сведений о местоположении

Другая наша функция — получить список всех сведений о местоположении, которые мы хотим отобразить на карте. Это берет список мстителей, время начала и окончания и возвращает все события для них, сгруппированные соответствующим образом:

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 для его использования.

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 затем визуализирует карту с использованием API Google Maps и добавляет на нее несколько маршрутов и маркеров, чтобы показать наши выбранные данные.

На данный момент у нас есть полностью работающее приложение. Здесь мы можем выбрать некоторых мстителей для отображения, интересующие диапазоны дат и времени и посмотреть, что происходит с нашими данными:

./abd2b2e07c416bfb1090fefe17c2366a.png

6. Заключение

Здесь мы увидели альтернативный способ визуализации данных, полученных из нашей базы данных Cassandra, и показали, как Astra CQL API используется для получения этих данных.

Весь код из этой статьи можно найти на GitHub .