Contents

使用Cassandra,Astra和Stargate构建仪表板

1. 简介

在本文中,我们将构建“Tony Stark 的复仇者联盟状态仪表板”,复仇者联盟使用它来监控团队成员的状态。 **这将使用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 表——这两者与更传统的 CQL API 100% 兼容。这些可以使仅使用标准 HTTP 客户端(例如 Spring RestTemplate )就可以非常灵活地访问我们的数据。

它还提供了一个 JSON 文档 API,允许更灵活的数据访问。有了这个 API,就不需要模式,如果需要,每条记录都可以是不同的形状。此外,记录可以根据需要尽可能复杂,支持 JSON 的全部功能来表示数据。

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

3. 我们的应用数据模型

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

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

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

**因此,我们的数据模型将围绕两个集合构建——eventsstatuses。**events集合是曾经发生过的每个状态事件的记录——这可能会变得非常大,Cassandra 非常适合这种情况。这将在下一篇文章中更详细地介绍。

此集合中的记录将如下所示:

avenger falcon
timestamp 2021-04-02T14:23:12Z
latitude 40.714558
longitude -73.975029
status 0.72

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

statuses集合包含一个包含仪表板数据的文档,它是进入events集合的数据的非规范化汇总视图。该文档将类似于以下内容:

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

这里我们有一些不会改变的通用数据——name realName字段——我们有一些从这个复仇者的最新事件中生成的摘要数据——*location 来自latitude longitude *值,*status 是事件status *字段的一般摘要。

本文重点介绍*status 集合,并使用 Document API 访问它。我们的下一篇文章将展示如何使用基于行的数据的events *集合。

4. 如何设置 DataStax Astra

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

/uploads/cassandra_astra_stargate_dashboard/1.png

(注意 - 屏幕在发布时是准确的,但可能已经改变) 这需要几分钟的时间来设置。完成后,我们将需要创建一个访问令牌。

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

/uploads/cassandra_astra_stargate_dashboard/3.png

一旦所有这些都完成了,我们还需要我们的数据库详细信息。这包括:

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

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

5. 如何设置 Spring Boot

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

另外,我们需要 Spring Web 和 Thymeleaf 作为依赖:

/uploads/cassandra_astra_stargate_dashboard/5.png

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

在继续之前,我们还需要配置我们的 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 公开的Document 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 的 Document API 交互所需的全部内容,因为它通过简单地通过 HTTP 交换 JSON 文档来工作。

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

7. 通过 Document API 获取 Avengers 状态

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

7.1.从 Astra 检索状态

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

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

我们还需要一个 Record 来表示从 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;
  }  
}

**在这里,我们使用我们的客户端从“状态”集合中获取记录,在我们的status 记录中表示。**一旦检索到,我们只提取文档以返回给调用者。请注意,我们确实必须重建Status对象以包含 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/[[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 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/[[email protected]](/cdn_cgi/l/email_protection)/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 来格式化我们的页面,并为每个 Avenger 显示一张卡片,根据状态着色并显示该 Avenger 的当前详细信息:

/uploads/cassandra_astra_stargate_dashboard/7.png

8. 通过 Document API 进行状态更新

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

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

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端点,它采用复仇者 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以更新状态记录。

在以后的文章中,这将被更新为使用这些数据创建一个新的事件记录,这样我们就可以跟踪每个复仇者的整个事件历史。

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