一、简介

在本文中,我们将构建“托尼·斯塔克的复仇者联盟状态仪表板”,复仇者联盟使用它来监控团队成员的状态。

这将使用DataStax Astra构建,DataStax Astra 是一个由Apache Cassandra提供支持的 DBaaS,使用Stargate提供额外的 API 来使用它。 除此之外,我们将使用 Spring Boot 应用程序来呈现仪表板并显示正在发生的情况。

我们将使用 Java 16 构建它,因此在继续之前请确保它已安装并准备好使用。

2.什么是阿斯特拉?

DataStax Astra 是由 Apache Cassandra 提供支持的数据库即服务产品。 这为我们提供了一个完全托管、完全管理的 Cassandra 数据库,我们可以用它来存储数据,其中包括 Cassandra 提供的可扩展性、高可用性和性能的所有功能。

除此之外,Astra 还整合了 Stargate 数据平台,该平台通过不同的 API 公开完全相同的底层数据。这使我们能够使用 REST 和 GraphQL API 访问传统的 Cassandra 表——这两个 API 彼此 100% 兼容,并且与更传统的 CQL API 兼容。只需使用标准 HTTP 客户端(例如 Spring RestTemplate) ,就可以使对数据的访问变得异常灵活。

它还提供 JSON 文档 API,允许更灵活的数据访问。使用此 API 不需要模式,并且如果需要,每条记录都可以是不同的形状。此外,记录可以根据需要变得复杂,支持 JSON 表示数据的全部功能。

但这确实是有代价的——文档 API 不能与其他 API 互换,因此提前决定需要如何对数据进行建模以及哪些 API 最适合访问数据非常重要。

3.我们的应用程序数据模型

我们正在 Cassandra 之上围绕 Astra 系统构建我们的系统。这将直接反映我们对数据建模的方式。

Cassandra 旨在允许以非常高的吞吐量处理大量数据,并以表格形式存储记录。 Astra 添加了一些替代 API(REST 和 GraphQL)以及使用文档 API 表示文档以及简单表格数据的能力。

这仍然得到 Cassandra 的支持,它以不同的方式进行模式设计。在现代系统中,空间不再是一个限制。复制数据不再是问题,无需跨数据集合或分区进行联接。这意味着我们可以对集合中的数据进行非规范化以满足我们的需求。

因此,我们的数据模型将围绕两个集合构建—— 事件状态 事件 集合记录了曾经发生过的每个状态事件——这可能会变得非常大,这对于 Cassandra 来说是理想的选择。这将在下一篇文章中更详细地介绍。

该集合中的记录如下所示:

复仇者

时间戳

2021-04-02T14:23:12Z

纬度

40.714558

经度

-73.975029

地位

0.72

这为我们提供了单个事件更新,给出了更新的确切时间戳和位置以及复仇者状态的百分比值。

状态 集合包含一个包含仪表板数据的文档,仪表板数据是进入 事件 集合的数据的非规范化汇总视图。该文档看起来类似于:

{
    "falcon": {
    "realName": "Sam Wilson",
    "location": "New York",
    "status": "INJURED",
    "name": "Falcon"
    },
    "wanda": {
        "realName": "Wanda Maximoff",
        "location": "New York",
        "status": "HEALTHY"
    }
}

这里我们有一些不会改变的一般数据 - namerealName 字段 - 我们还有一些从该 Avenger 的最近事件生成的摘要数据 - 位置 是从 纬度经度 值得出的, 状态 是事件 状态 字段的一般摘要。

本文重点介绍 状态 集合以及使用文档 API 访问它。我们的下一篇文章将展示如何使用基于行的数据的 事件 集合。

4. 如何设置 DataStax Astra

在启动应用程序之前,我们需要一个数据存储区。我们将使用 DataStax Astra 的 Cassandra 产品。 首先,我们需要在 Astra 注册一个免费帐户并创建一个新数据库。 需要为数据库和键空间提供合理的名称:

(注 - 屏幕在发布时是准确的,但此后可能已发生变化)

设置需要几分钟时间。完成此操作后,我们将需要创建一个访问令牌。

为此,我们需要访问新创建的数据库的“设置”选项卡并生成一个令牌:

完成所有这些后,我们还需要数据库详细信息。这包括:

  • 数据库ID
  • 地区
  • 键空间

这些可以在“连接”选项卡上找到。

最后,我们需要一些数据。出于本文的目的,我们使用一些预先填充的数据。这可以在此处的shell 脚本中找到。

5. 如何设置 Spring Boot

我们将使用Spring Initializr创建新应用程序; 我们还将使用 Java 16 – 允许我们使用Records 这反过来意味着我们需要 Spring Boot 2.5——目前这意味着 2.5.0-M3。

此外,我们还需要 Spring Web 和 Thymeleaf 作为依赖项:

一旦准备好,我们就可以下载它并将其解压缩到某个地方,然后就可以构建我们的应用程序了。

在继续之前,我们还需要配置 Cassandra 凭据。这些都进入 src/main/resources/application.properties ,取自 Astra 仪表板:

ASTRA_DB_ID=e26d52c6-fb2d-4951-b606-4ea11f7309ba
ASTRA_DB_REGION=us-east-1
ASTRA_DB_KEYSPACE=avengers
ASTRA_DB_APPLICATION_TOKEN=AstraCS:xxx-token-here

像这样管理这些秘密纯粹是为了本文的目的。在实际应用程序中,应该安全地管理它们,例如使用Vault

6. 编写文档客户端

为了与 Astra 交互,我们需要一个可以进行 API 调用的客户端。 这将直接在 Astra 公开的文档 API中工作,从而允许我们的应用程序在丰富的文档中工作。出于我们的目的,我们需要能够通过 ID 获取单个记录并提供对该记录的部分更新。

为了管理这个,我们将编写一个 DocumentClient bean 来封装所有这些:

@Repository
public class DocumentClient {
  @Value("https://${ASTRA_DB_ID}-${ASTRA_DB_REGION}.apps.astra.datastax.com/api/rest/v2/namespaces/${ASTRA_DB_KEYSPACE}")
  private String baseUrl;

  @Value("${ASTRA_DB_APPLICATION_TOKEN}")
  private String token;

  @Autowired
  private ObjectMapper objectMapper;

  private RestTemplate restTemplate;

  public DocumentClient() {
    this.restTemplate = new RestTemplate();
    this.restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
  }

  public <T> T getDocument(String collection, String id, Class<T> cls) {
    var uri = UriComponentsBuilder.fromHttpUrl(baseUrl)
      .pathSegment("collections", collection, id)
      .build()
      .toUri();
    var request = RequestEntity.get(uri)
      .header("X-Cassandra-Token", token)
      .build();
    var response = restTemplate.exchange(request, cls);
    return response.getBody();
  }

  public void patchSubDocument(String collection, String id, String key, Map<String, Object> updates) {
    var updateUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
      .pathSegment("collections", collection, id, key)
      .build()
      .toUri();
    var updateRequest = RequestEntity.patch(updateUri)
      .header("X-Cassandra-Token", token)
      .body(updates);
    restTemplate.exchange(updateRequest, Map.class);
  } 
}

在这里,我们的 baseUrltoken 字段是根据我们之前定义的属性配置的。然后,我们有一个 getDocument() 方法,可以调用 Astra 从所需的集合中获取指定的记录,还有一个 patchSubDocument() 方法,可以调用 Astra 来修补集合中任何单个文档的一部分。

这就是与 Astra 的文档 API 交互所需的全部内容,因为它只需通过 HTTP 交换 JSON 文档即可工作。

请注意,我们需要更改 RestTemplate 使用的请求工厂。这是因为 Spring 使用的默认方法不支持 HTTP 调用上的 PATCH 方法。

7. 通过文档 API 获取复仇者联盟状态

我们的第一个要求是能够检索团队成员的状态。这是我们之前提到的 状态 集合中的文档。 这将构建在我们之前编写的 DocumentClient 之上。

7.1.从 Astra 检索状态

为了表示这些,我们需要一个记录,如下所示:

public record Status(String avenger, 
  String name, 
  String realName, 
  String status, 
  String location) {}

我们还需要一个记录来表示从 Cassandra 检索到的整个状态集合:

public record Statuses(Map<String, Status> data) {}

Statuses 类表示与 Document API 返回的完全相同的 JSON,因此可用于通过 RestTemplate 和 Jackson 接收数据。

然后我们需要一个服务层来从 Cassandra 检索状态并将其返回以供使用:

@Service
public class StatusesService {
  @Autowired
  private DocumentClient client;
  
  public List<Status> getStatuses() {
    var collection = client.getDocument("statuses", "latest", Statuses.class);

    var result = new ArrayList<Status>();
    for (var entry : collection.data().entrySet()) {
      var status = entry.getValue();
      result.add(new Status(entry.getKey(), status.name(), status.realName(), status.status(), status.location()));
    }

    return result;
  }  
}

在这里,我们使用客户端从“statuses”集合中获取记录,在我们的 Statuses 记录中表示。 检索后,我们仅提取文档以返回给调用者。请注意,我们确实必须重建 Status 对象以也包含 ID,因为这些 ID 实际上存储在 Astra 文档中的较高位置。

7.2.显示仪表板

现在我们有了一个服务层来检索数据,我们需要用它做一些事情。 这意味着控制器将处理来自浏览器的传入 HTTP 请求,然后呈现显示实际仪表板的模板。

首先,控制器:

@Controller
public class StatusesController {
  @Autowired
  private StatusesService statusesService;

  @GetMapping("/")
  public ModelAndView getStatuses() {
    var result = new ModelAndView("dashboard");
    result.addObject("statuses", statusesService.getStatuses());

    return result;
  }
}

这会从 Astra 检索状态并将其传递到模板进行渲染。

我们的主要“dashboard.html”模板如下:

<!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 Dashboard</title>
</head>
<body>
  <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container-fluid">
      <a class="navbar-brand" href="#">Avengers Status Dashboard</a>
    </div>
  </nav>

  <div class="container-fluid mt-4">
    <div class="row row-cols-4 g-4">
      <div class="col" th:each="data, iterstat: ${statuses}">
        <th:block th:switch="${data.status}">
          <div class="card text-white bg-danger" th:case="DECEASED" th:insert="~{common/status}"></div>
          <div class="card text-dark bg-warning" th:case="INJURED" th:insert="~{common/status}"></div>
          <div class="card text-dark bg-warning" th:case="UNKNOWN" th:insert="~{common/status}"></div>
          <div class="card text-white bg-secondary" th:case="RETIRED" th:insert="~{common/status}"></div>
          <div class="card text-dark bg-light" th:case="*" th:insert="~{common/status}"></div>
        </th:block>
      </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>
</body>
</html>

这利用了“common/status.html”下的另一个嵌套模板来显示单个 Avenger 的状态:

<div class="card-body">
  <h5 class="card-title" th:text="${data.name}"></h5>
  <h6 class="card-subtitle"><span th:if="${data.realName}" th:text="${data.realName}"></span> </h6>
  <p class="card-text"><span th:if="${data.location}">Location: <span th:text="${data.location}"></span></span> </p>
</div>
<div class="card-footer">Status: <span th:text="${data.status}"></span></div>

这利用Bootstrap来格式化我们的页面,并为每个复仇者显示一张卡片,根据状态着色并显示该复仇者的当前详细信息:

8. 通过文档 API 进行状态更新

我们现在能够显示各个复仇者联盟成员的当前状态数据。我们缺少的是利用现场反馈来更新它们的能力。 这将是一个新的 HTTP 控制器,可以通过文档 API 更新我们的文档以反映最新的状态详细信息。

在下一篇文章中,同一控制器将把最新状态记录到 状态 集合中,还会记录到 事件 集合中。这将使我们能够记录事件的整个历史记录,以便以后从同一输入流进行分析。因此,该控制器的输入将是单个事件,而不是汇总状态。

8.1.更新 Astra 中的状态

因为我们将状态数据表示为单个文档,所以我们只需要更新它的适当部分。 这使用了我们客户端的 patchSubDocument() 方法,指向已识别复仇者的正确部分。

我们使用 StatusesService 类中的一个新方法来执行更新:

public void updateStatus(String avenger, String location, String status) throws Exception {
  client.patchSubDocument("statuses", "latest", avenger, 
    Map.of("location", location, "status", status));
}

8.2.更新状态的 API

我们现在需要一个可以调用的控制器来触发这些更新。 这将是一个新的 RestController 端点,它获取 avengers ID 和最新的事件详细信息:

@RestController
public class UpdateController {
  @Autowired
  private StatusesService statusesService;

  @PostMapping("/update/{avenger}")
  public void update(@PathVariable String avenger, @RequestBody UpdateBody body) throws Exception {
    statusesService.updateStatus(avenger, lookupLocation(body.lat(), body.lng()), getStatus(body.status()));
  }

  private String lookupLocation(Double lat, Double lng) {
    return "New York";
  }

  private String getStatus(Double status) {
    if (status == 0) {
      return "DECEASED";
    } else if (status > 0.9) {
      return "HEALTHY";
    } else {
      return "INJURED";
    }
  }

  private static record UpdateBody(Double lat, Double lng, Double status) {}
}

这使我们能够接受对特定 Avenger 的请求,其中包含该 Avenger 的当前纬度、经度和状态。然后,我们将这些值转换为状态值,并将它们传递给 StatusesService 以更新状态记录。

在以后的文章中,我们将对其进行更新,以使用此数据创建新的事件记录,以便我们可以跟踪每个复仇者的整个事件历史记录。

请注意,我们没有正确查找用于纬度和经度的位置名称 - 它只是硬编码的。有多种选项可以实现此目的,但它们超出了本文的范围。

9. 总结

在这里,我们了解了如何利用 Cassandra 之上的 Astra Document API 来构建状态仪表板。 由于 Astra 是无服务器的,因此您的演示数据库在未使用时将扩展到零,因此您不会继续产生使用费用。在下一篇文章中,我们将使用 Row API,它使我们能够以非常简单的方式处理大量记录。

本文中的所有代码都可以在 GitHub 上找到。