1. 概述

如今,云托管托管数据库越来越受欢迎。其中 Cloud Firestore 就是一个典型例子,这是由 Firebase 和 Google 提供的 NoSQL 文档数据库,它提供按需扩展、灵活的数据建模以及对移动和 Web 应用的离线支持。

本教程将探讨如何在 Spring Boot 应用中使用 Cloud Firestore 实现数据持久化。为了更贴近实际,我们将构建一个基础的任务管理应用,通过 Cloud Firestore 作为后端数据库实现任务的创建、检索、更新和删除功能。

2. Cloud Firestore 基础知识

在深入实现前,先了解 Cloud Firestore 的几个核心概念。

在 Cloud Firestore 中,数据以文档形式存储,文档被分组到集合中。集合是文档的容器,每个文档包含一组键值对,数据结构灵活,类似于 JSON 对象。

Cloud Firestore 使用分层命名约定来标识文档路径。文档路径由集合名称和文档 ID 组成,用正斜杠分隔。例如,tasks/001 表示 tasks 集合中 ID 为 001 的文档。

3. 项目搭建

开始与 Cloud Firestore 交互前,我们需要添加 SDK 依赖并正确配置应用。

3.1. 依赖项

首先在项目的 pom.xml 文件中添加 Firebase Admin 依赖

<dependency>
    <groupId>com.google.firebase</groupId>
    <artifactId>firebase-admin</artifactId>
    <version>9.3.0</version>
</dependency>

此依赖提供了与 Cloud Firestore 交互所需的类。

3.2. 数据模型

定义我们的数据模型:

class Task {

    public static final String PATH = "tasks";

    private String title;

    private String description;

    private String status;

    private Date dueDate;

    // 标准 setter 和 getter 方法

}

Task 类是本教程的核心实体,代表任务管理应用中的任务。

PATH 常量定义了存储任务文档的 Firestore 集合路径

3.3. 定义 Firestore 配置 Bean

要与 Cloud Firestore 数据库交互,需要配置私钥以验证 API 请求。

为便于演示,我们在 src/main/resources 目录创建 private-key.json 文件。⚠️ 生产环境中,私钥应从环境变量加载或通过密钥管理系统获取,以增强安全性

使用 @Value 注解加载私钥,并定义 Firestore bean:

@Value("classpath:/private-key.json")
private Resource privateKey;

@Bean
public Firestore firestore() {
    InputStream credentials = new ByteArrayInputStream(privateKey.getContentAsByteArray());
    FirebaseOptions firebaseOptions = FirebaseOptions.builder()
      .setCredentials(GoogleCredentials.fromStream(credentials))
      .build();

    FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions);
    return FirestoreClient.getFirestore(firebaseApp);
}

Firestore 类是与 Cloud Firestore 数据库交互的主要入口点。

4. 使用 Testcontainers 搭建本地测试环境

为方便本地开发和测试,我们将使用 TestcontainersGCloud 模块 设置 Cloud Firestore 模拟器。需在 pom.xml 中添加其依赖

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>gcloud</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

运行 Firestore 模拟器的前提是已安装 Docker

添加依赖后,创建一个 @TestConfiguration 类定义新的 Firestore bean:

private static FirestoreEmulatorContainer firestoreEmulatorContainer = new FirestoreEmulatorContainer(
    DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:488.0.0-emulators")
);

@TestConfiguration
static class FirestoreTestConfiguration {

    @Bean
    public Firestore firestore() {
        firestoreEmulatorContainer.start();
        FirestoreOptions options = FirestoreOptions
          .getDefaultInstance()
          .toBuilder()
          .setProjectId(RandomString.make().toLowerCase())
          .setCredentials(NoCredentials.getInstance())
          .setHost(firestoreEmulatorContainer.getEmulatorEndpoint())
          .build();
        return options.getService();
    }

}

我们使用 Google Cloud CLI Docker 镜像 创建模拟器容器。在 firestore() bean 方法中启动容器,并配置 Firestore bean 连接到模拟器接口。

此设置允许我们启动一个可丢弃的 Cloud Firestore 模拟器实例,使应用连接到模拟器而非真实的 Cloud Firestore 数据库

5. 执行 CRUD 操作

测试环境就绪后,我们来探索如何对 Task 数据模型执行 CRUD 操作。

5.1. 创建文档

先创建一个新的任务文档:

Task task = Instancio.create(Task.class);

DocumentReference taskReference = firestore
  .collection(Task.PATH)
  .document();
taskReference.set(task);

String taskId = taskReference.getId();
assertThat(taskId).isNotBlank();

使用 Instancio 创建包含随机测试数据的 Task 对象。然后在 tasks 集合上调用 document() 方法获取 DocumentReference 对象(表示文档在 Cloud Firestore 中的位置)。最后在 DocumentReference 上设置任务数据创建新文档。

调用无参的 document() 方法时,Firestore 会自动生成唯一文档 ID。可通过 getId() 方法获取此 ID。

也可以使用自定义 ID 创建任务文档

Task task = Instancio.create(Task.class);
String taskId = Instancio.create(String.class);

firestore
  .collection(Task.PATH)
  .document(taskId)
  .set(task);

Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
    DocumentSnapshot taskSnapshot = firestore
      .collection(Task.PATH)
      .document(taskId)
      .get().get();
    assertThat(taskSnapshot.exists())
      .isTrue();
});

这里生成随机 taskId 并传递给 document() 方法创建文档。使用 Awaitility 等待文档创建并验证其存在。

5.2. 检索和查询文档

虽然前文已间接展示如何通过 ID 检索任务文档,我们再详细说明:

Task task = Instancio.create(Task.class);
String taskId = Instancio.create(String.class);
// ... 在 Firestore 中保存任务

DocumentSnapshot taskSnapshot = firestore
  .collection(Task.PATH)
  .document(taskId)
  .get().get();

Task retrievedTask = taskSnapshot.toObject(Task.class);
assertThat(retrievedTask)
  .usingRecursiveComparison()
  .isEqualTo(task);

通过调用 DocumentReferenceget() 方法检索任务文档。此方法返回 ApiFuture<DocumentSnapshot> 表示异步操作。调用返回的 future 的 get() 方法阻塞等待操作完成,得到 DocumentSnapshot 对象。

使用 toObject() 方法将 DocumentSnapshot 转换为 Task 对象。

此外,还可以根据特定条件查询文档

// 设置测试数据
Task completedTask = Instancio.of(Task.class)
  .set(field(Task::getStatus), "COMPLETED")
  .create();
Task inProgressTask = // ... 状态为 IN_PROGRESS 的任务
Task anotherCompletedTask = // ... 状态为 COMPLETED 的任务
List<Task> tasks = List.of(completedTask, inProgressTask, anotherCompletedTask);
// ... 在 Firestore 中保存所有任务

// 检索已完成的任务
List<QueryDocumentSnapshot> retrievedTaskSnapshots = firestore
  .collection(Task.PATH)
  .whereEqualTo("status", "COMPLETED")
  .get().get().getDocuments();

// 验证仅返回匹配的任务
List<Task> retrievedTasks = retrievedTaskSnapshots
  .stream()
  .map(snapshot -> snapshot.toObject(Task.class))
  .toList();
assertThat(retrievedTasks)
  .usingRecursiveFieldByFieldElementComparator()
  .containsExactlyInAnyOrder(completedTask, anotherCompletedTask);

上例创建多个不同 status 值的任务文档并保存到 Cloud Firestore。使用 whereEqualTo() 方法仅检索状态为 COMPLETED 的任务文档。

还可以组合多个查询条件:

List<QueryDocumentSnapshot> retrievedTaskSnapshots = firestore
  .collection(Task.PATH)
  .whereEqualTo("status", "COMPLETED")
  .whereGreaterThanOrEqualTo("dueDate", Date.from(Instant.now()))
  .whereLessThanOrEqualTo("dueDate", Date.from(Instant.now().plus(7, ChronoUnit.DAYS)))
  .get().get().getDocuments();

这里查询所有 dueDate 在未来 7 天内的 COMPLETED 状态任务

5.3. 更新文档

更新 Cloud Firestore 中的文档与创建过程类似。如果指定的文档 ID 不存在,Cloud Firestore 会创建新文档;否则更新现有文档

// 在 Firestore 中保存初始任务
String taskId = Instancio.create(String.class);
Task initialTask = Instancio.of(Task.class)
  .set(field(Task::getStatus), "IN_PROGRESS")
  .create();
firestore
  .collection(Task.PATH)
  .document(taskId)
  .set(initialTask);

// 更新任务
Task updatedTask = initialTask;
updatedTask.setStatus("COMPLETED");
firestore
  .collection(Task.PATH)
  .document(taskId)
  .set(initialTask); // 注意:原文此处有误,应为 set(updatedTask)

// 验证任务更新正确
Task retrievedTask = firestore
  .collection(Task.PATH)
  .document(taskId)
  .get().get()
  .toObject(Task.class);
assertThat(retrievedTask)
  .usingRecursiveComparison()
  .isNotEqualTo(initialTask)
  .ignoringFields("status")
  .isEqualTo(initialTask);

先创建状态为 IN_PROGRESS 的新任务文档。然后调用 set() 方法更新其状态为 COMPLETED。最后从数据库获取文档并验证更改已正确应用。

5.4. 删除文档

最后看如何删除任务文档:

// 在 Firestore 中保存任务
Task task = Instancio.create(Task.class);
String taskId = Instancio.create(String.class);
firestore
  .collection(Task.PATH)
  .document(taskId)
  .set(task);

// 确保任务已创建
Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
    DocumentSnapshot taskSnapshot = firestore
      .collection(Task.PATH)
      .document(taskId)
      .get().get();
    assertThat(taskSnapshot.exists())
      .isTrue();
});

// 删除任务
firestore
  .collection(Task.PATH)
  .document(taskId)
  .delete();

// 断言任务已删除
Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
    DocumentSnapshot taskSnapshot = firestore
      .collection(Task.PATH)
      .document(taskId)
      .get().get();
    assertThat(taskSnapshot.exists())
      .isFalse();
});

先创建新任务文档并验证其存在。然后调用 DocumentReferencedelete() 方法删除任务,并验证文档不再存在。

6. 总结

本文探讨了在 Spring Boot 应用中使用 Cloud Firestore 实现数据持久化的方法。

我们介绍了必要的配置步骤,包括使用 Testcontainers 搭建本地测试环境,并演示了对任务数据模型的 CRUD 操作。

本文所有代码示例均可在 GitHub 获取。


原始标题:Using Google Cloud Firestore Database in Spring Boot | Baeldung