Contents

使用Cassandra,Astra和CQL构建仪表板 - 映射事件数据

1. 简介

在我们之前的文章 中,我们研究了如何使用 DataStax Astra 来扩充我们的仪表板以存储和显示来自复仇者联盟的单个事件,DataStax Astra 是一种由Apache Cassandra 提供支持的无服务器 DBaaS,使用Stargate 提供额外的 API 来使用它。

在本文中,我们将以不同的方式使用完全相同的数据。**我们将允许用户选择要显示的复仇者联盟、感兴趣的时间段,然后在交互式地图上显示这些事件。**与上一篇文章不同,这将允许用户查看在地理和时间上相互交互的数据。

2. 服务设置

**我们将使用 CQL API 检索数据,使用Cassandra 查询语言 中的查询。**这需要一些额外的设置才能让我们能够与服务器对话。

2.1. 下载安全连接包

**为了通过 CQL 连接到 DataStax Astra 托管的 Cassandra 数据库,我们需要下载“Secure Connect Bundle”。**这是一个 zip 文件,其中包含该数据库的 SSL 证书和连接详细信息,可以安全地建立连接。 这可以从 Astra 仪表板中找到,在“连接”选项卡下找到我们确切的数据库,然后在“使用驱动程序连接”下的“Java”选项:

/uploads/cassandra_astra_rest_dashboard_map/1.png

出于务实的原因,我们将把这个文件放到src/main/resources中,这样我们就可以从类路径中访问它。在正常的部署情况下,您需要能够提供不同的文件来连接到不同的数据库——例如,为开发和生产环境提供不同的数据库。

2.2. 创建客户凭证

**我们还需要一些客户端凭据才能连接到我们的数据库。**与我们在之前的文章中使用的使用访问令牌的 API 不同,CQL API 需要“用户名”和“密码”。这些实际上是我们从“组织”下的“管理令牌”部分生成的客户端 ID 和客户端密码:

/uploads/cassandra_astra_rest_dashboard_map/3.png

完成后,我们需要将生成的 Client ID 和 Client Secret 添加到我们的application.properties

ASTRA_DB_CLIENT_ID=clientIdHere
ASTRA_DB_CLIENT_SECRET=clientSecretHere

2.3. 谷歌地图 API 密钥

为了渲染我们的地图,我们将使用谷歌地图。这将需要一个 Google API 密钥才能使用此 API。

注册谷歌账户后,我们需要访问谷歌云平台仪表板 。在这里我们可以创建一个新项目:

/uploads/cassandra_astra_rest_dashboard_map/5.png

然后,我们需要为此项目启用 Google Maps JavaScript API。搜索它并启用它:

/uploads/cassandra_astra_rest_dashboard_map/7.png

最后,我们需要一个 API 密钥才能使用它。为此,我们需要导航到侧边栏上的“Credentials”窗格,单击顶部的“Create Credentials”并选择 API Key:

/uploads/cassandra_astra_rest_dashboard_map/9.png

我们现在需要将此键添加到我们的application.properties文件中:

GOOGLE_CLIENT_ID=someRandomClientId

3. 使用 Astra 和 CQL 构建客户端层

**为了通过 CQL 与数据库通信,我们需要编写我们的客户端层。**这将是一个名为 CqlClient 的类,它包装 DataStax CQL API,抽象出连接细节:

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

这为我们提供了一个公共方法,该方法将连接到数据库并执行任意 CQL 查询,允许向其提供一些绑定值。

**连接到数据库会使用我们之前生成的 Secure Connect Bundle 和客户端凭据。**Secure Connect Bundle 需要放置在src/main/resources/secure-connect-blogdemo-avengers.zip中,并且客户端 ID 和机密需要使用适当的属性名称放入application.properties

请注意,此实现将查询中的每一行加载到内存中,并在完成之前将它们作为单个列表返回。这仅用于本文的目的,但效率不如其他方式。例如,我们可以在每行返回时单独获取和处理它们,甚至可以将整个查询包装在要处理的java.util.streams.Stream 中。

4. 获取所需数据

一旦我们的客户端能够与 CQL API 交互,我们就需要我们的服务层来实际获取我们要显示的数据。

首先,我们需要一个 Java Record 来表示我们从数据库中获取的每一行:

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

**这只是从我们的events表中获取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 客户端 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/[[email protected]](/cdn_cgi/l/email_protection)/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/[[email protected]](/cdn_cgi/l/email_protection)/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 呈现地图,并在其上添加一些路线和标记以显示我们选择的数据。

在这一点上,我们有一个完整的工作应用程序。为此,我们可以选择一些复仇者来显示,感兴趣的日期和时间范围,并查看我们的数据发生了什么:

/uploads/cassandra_astra_rest_dashboard_map/11.png