我复古的应用程序,基于 Spring Boot 和 Spring Data MongoDB
让我们切换到 My Retro App,看看如何使用 MongoDB 持久化。如果你在跟着做,可以重用一些之前版本的代码。或者,你也可以从头开始,访问 Spring Initializr (https://start.spring.io),生成一个基础项目(不带依赖),将 Group 字段设置为 com.apress,Artifact 和 Name 字段设置为 myretro。下载项目,解压缩后导入到你喜欢的 IDE 中。在本节结束时,你应该能够看到如图 6-2 所示的结构。
正如你所看到的,一些包及其类与之前的版本保持一致。我想向你展示持久性包,其中包含一个名为 RetroBoardPersistenceCallback 的类,它实现了 BeforeConvertCallback,以便在文档被持久化到 MongoDB 之前执行某个操作。
打开 build.gradle 文件,并将其内容替换为清单 6-8 中的内容。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'com.fasterxml.uuid:java-uuid-generator:4.0.1'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Web
implementation 'org.webjars:bootstrap:5.2.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
列表 6-8 的 build.gradle 文件
正如您所看到的,build.gradle 文件中使用了 spring-boot-starter-data-mongodb 启动依赖。
接下来,创建或打开板块包、卡片类以及卡片类型枚举,具体内容请参见清单 6-9 和 6-10。
package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Card {
@NotNull
private UUID id;
@NotBlank
private String comment;
@NotNull
private CardType cardType;
}
6-9 src/main/java/apress/com/myretro/board/Card.java
package com.apress.myretro.board;
public enum CardType {
HAPPY,MEH,SAD
}
6-10 src/main/java/apress/com/myretro/board/CardType.java
正如你所看到的,Card 和 CardType 类与基础版本没有变化。在这里,我们没有使用任何持久化机制或注解。
接下来,创建或打开 RetroBoard 类。请查看清单 6-11。
package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Document
public class RetroBoard {
@Id
@NotNull
private UUID id;
@NotBlank(message = "A name must be provided")
private String name;
@Singular("card")
private List<Card> cards;
public void addCard(Card card){
if (this.cards == null)
this.cards = new ArrayList<>();
this.cards.add(card);
}
public void addCards(List<Card> cards){
if (this.cards == null)
this.cards = new ArrayList<>();
this.cards.addAll(cards);
}
}
6-11 src/main/java/apress/com/myretro/board/RetroBoard.java
@Document 注解使 RetroBoard 类能够与 MongoDB 兼容,而 @Id 注解则创建了一个标识符,这里使用的是 UUID 类型。列表 6-11 还添加了一些辅助方法,用于添加卡片,包括 addCard 和 addCards 方法。
接下来,创建或打开持久化包,以及 RetroBoardPersistenceCallback 类和 RetroBoardRepository 接口。列表 6-12 展示了 RetroBoardPersistenceCallback 类的内容。
package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import org.springframework.data.mongodb.core.mapping.event.BeforeConvertCallback;
import org.springframework.stereotype.Component;
@Component
public class RetroBoardPersistenceCallback implements BeforeConvertCallback<RetroBoard> {
@Override
public RetroBoard onBeforeConvert(RetroBoard retroBoard, String s) {
if (retroBoard.getId() == null)
retroBoard.setId(java.util.UUID.randomUUID());
return retroBoard;
}
}
6-12 src/main/java/apress/com/myretro/persistence/RetroBoardPersistenceCallback.java
列表 6-12 展示了 RetroBoardPersistenceCallback 类,您可以看到我们正在实现 BeforeConvertCallback 接口。您还记得在用户应用程序中提到过这一点吗?我们在同一个 Java 配置(UserConfiguration 类;见列表 6-5)中进行了声明。不同之处在于这里我们创建了一个独立的类。哪个更好呢?实际上,哪个对您的应用程序更合适就用哪个。当然,为了使这个类正常工作,我们需要使用@Component 注解将其标记为 Spring bean。
列表 6-13 展示了 RetroBoardRepository 接口的内容。
package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.RetroBoardNotFoundException;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
import java.util.UUID;
public interface RetroBoardRepository extends MongoRepository<RetroBoard,UUID> {
@Query("{'id': ?0}")
Optional<RetroBoard> findById(UUID id);
@Query("{}, { cards: { $elemMatch: { _id: ?0 } } }")
Optional<RetroBoard> findRetroBoardByIdAndCardId(UUID cardId);
default void removeCardFromRetroBoard(UUID retroBoardId, UUID cardId) {
Optional<RetroBoard> retroBoard = findById(retroBoardId);
if (retroBoard.isPresent()) {
retroBoard.get().getCards().removeIf(card -> card.getId().equals(cardId));
save(retroBoard.get());
}else {
throw new RetroBoardNotFoundException();
}
}
}
6-13 src/main/java/apress/com/myretro/persistence/RetroBoardRepository.java
在清单 6-13 中,我们遵循 Spring Data 应用程序模型,使用仓库,这样就不需要担心具体的实现。在这种情况下,我们使用一个特定的仓库——MongoRepository,它的签名与传统的 Repository 或 CrudRepository 有所不同。它的定义如下:
public interface MongoRepository<T, ID>
extends ListCrudRepository<T, ID>, ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
<S extends T> S insert(S entity);
<S extends T> List<S> insert(Iterable<S> entities);
<S extends T> List<S> findAll(Example<S> example);
<S extends T> List<S> findAll(Example<S> example, Sort sort);
}
你猜怎么着?由于 RetroBoardRepository 接口扩展了 ListCrudRepository 接口,而 ListCrudRepository 又扩展了 CrudRepository,因此我们可以访问习惯使用的方法。RetroBoardRepository 接口定义了 findById 方法,并使用 @Query 注解,该注解接受一个字符串值,你可以在其中添加查询 MongoDB 的 JavaScript 代码。RetroBoardRepository 接口还声明了 findRetroBoardByIdAndCardId 方法,并附带其 @Query 注解和要执行的查询。正如你所看到的,我们需要这种方法,因为 Card 是 RetroBoard 的一个聚合。再说一次,这只是一个示例,展示了我们如何在 MongoDB 上下文中获取 RetroBoard 和 Card 类之间的关系。 最终,我们为 removeCardFromRetroBoard 方法提供了一个默认实现,这对于处理 Card 实例是必要的。
接下来,按照清单 6-14 的示例创建或打开服务包和 RetroBoardService 类。
package com.apress.myretro.service;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.CardNotFoundException;
import com.apress.myretro.persistence.RetroBoardRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@AllArgsConstructor
@Service
public class RetroBoardService {
RetroBoardRepository retroBoardRepository;
public RetroBoard save(RetroBoard domain) {
if (domain.getCards() == null)
domain.setCards(new ArrayList<>());
return this.retroBoardRepository.save(domain);
}
public RetroBoard findById(UUID uuid) {
return this.retroBoardRepository.findById(uuid).get();
}
public Iterable<RetroBoard> findAll() {
return this.retroBoardRepository.findAll();
}
public void delete(UUID uuid) {
this.retroBoardRepository.deleteById(uuid);
}
public Iterable<Card> findAllCardsFromRetroBoard(UUID uuid) {
return this.findById(uuid).getCards();
}
public Card addCardToRetroBoard(UUID uuid, Card card){
if (card.getId() == null)
card.setId(UUID.randomUUID());
RetroBoard retroBoard = this.findById(uuid);
retroBoard.addCard(card);
retroBoardRepository.save(retroBoard);
return card;
}
public void addMultipleCardsToRetroBoard(UUID uuid, List<Card> cards){
RetroBoard retroBoard = this.findById(uuid);
retroBoard.addCards(cards);
retroBoardRepository.save(retroBoard);
}
public Card findCardByUUID(UUID uuidCard){
Optional<RetroBoard> result = retroBoardRepository.findRetroBoardByIdAndCardId(uuidCard);
if(result.isPresent() && result.get().getCards().size() > 0
&& result.get().getCards().get(0).getId().equals(uuidCard)){
return result.get().getCards().get(0);
}else{
throw new CardNotFoundException();
}
}
public void removeCardByUUID(UUID uuid,UUID cardUUID){
retroBoardRepository.removeCardFromRetroBoard(uuid,cardUUID);
}
}
6-14 src/main/java/apress/com/myretro/service/RetroBoardService.java
正如您所看到的,RetroBoardService 类使用了 RetroBoardRepository,并调用了接口中声明的方法。请注意,这次我们在 save 和 addCardToRetroBoard 方法中处理 UUID。再次强调,这是另一个示例,表明 UUID 可以在回调或事件中进行管理,但您可以自行决定在何处实现这一逻辑更为合适。
接下来,创建或打开配置包以及 MyRetroConfiguration 类。请参阅清单 6-15。
package com.apress.myretro.config;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.CardType;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.service.RetroBoardService;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.UUID;
@EnableConfigurationProperties({MyRetroProperties.class})
@Configuration
public class MyRetroConfiguration {
@Bean
ApplicationListener<ApplicationReadyEvent> ready(RetroBoardService retroBoardService) {
return applicationReadyEvent -> {
UUID retroBoardId = UUID.fromString("9dc9b71b-a07e-418b-b972-40225449aff2");
retroBoardService.save(RetroBoard.builder()
.id(retroBoardId)
.name("Spring Boot Conference")
.card(Card.builder().id(UUID.fromString("bb2a80a5-a0f5-4180-a6dc-80c84bc014c9")).comment("Spring Boot Rocks!").cardType(CardType.HAPPY).build())
.card(Card.builder().id(UUID.randomUUID()).comment("Meet everyone in person").cardType(CardType.HAPPY).build())
.card(Card.builder().id(UUID.randomUUID()).comment("When is the next one?").cardType(CardType.MEH).build())
.card(Card.builder().id(UUID.randomUUID()).comment("Not enough time to talk to everyone").cardType(CardType.SAD).build())
.build());
};
}
}
6-15 src/main/java/apress/com/myretro/config/MyRetroConfiguration.java
请注意,在列表 6-15 中,我们使用了 ready 事件,并通过 RetroBoardService 添加了一些数据。
建议、例外以及网络包和类与之前的版本保持一致。其余的配置包类(MyRetroProperties 和 UsersConfiguration 类)也保持不变。
接下来,创建或打开一个使用 mongo 服务的 docker-compose.yaml 文件,如清单 6-16 所示。
version: "3.1"
services:
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_DATABASE: retrodb
ports:
- "27017:27017"
6-16 docker-compose.yaml
正如您所见,这个文件与用户应用中的文件完全相同。
此时,application.properties 文件仍然是空的,但在我们运行应用程序后,需要添加一个属性,具体内容在下一部分中说明。
运行我的怀旧应用
要运行该应用程序,请使用您的 IDE,或者在终端中输入以下命令:
./gradlew clean bootRun
如果你在浏览器中输入 http://localhost:8080/retros 或执行以下 curl 命令,你将会遇到一个错误:
curl -s http://localhost:8080/retros | jq .
{
"timestamp": "2023-06-06T21:10:08.737+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/retros"
}
如果你查看控制台(My Retro App 正在运行的地方),你会看到这个错误信息:
org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type [org.bson.types.Binary] to type [java.util.UUID]
在解决这个问题之前,让我们连接到正在运行的容器,使用另一个 Mongo 容器和 Mongo 客户端(mongosh)。您可以执行以下命令:
docker run -it --rm --network myretro_default mongo mongosh --host mongo retrodb
首先,我们需要确定 MongoDB 容器运行的网络。如果您使用的是 docker compose,可以通过以下命令获取网络信息:
docker network ls
NETWORK ID NAME DRIVER SCOPE
cee592df7cc9 bridge bridge local
2ac86d937732 educates bridge local
087927a8ef8c host host local
83b09c37d02e kind bridge local
3e5ded7b4095 minikube bridge local
757602f101d2 myretro_default bridge local
38898bbc5491 none null local
3c0967bf62b8 test-scdf_default bridge local
71d99f2d6934 users_default bridge local
你可以看到网络的名称是 myretro_default。接着,我们使用命令 mongosh 连接到 mongo 主机(这个信息来自 docker-compose.yaml)以及数据库名称 retrodb。在 mongosh 客户端中,如果你:
retrodb> db.retroBoard.find({});
[{
_id: Binary(Buffer.from("8b417ea01bb7c99df2af4954224072b9", "hex"), 3),
name: 'Spring Boot Conference',
cards: [
{
_id: Binary(Buffer.from("8041f5a0a5802abbc914c04bc880dca6", "hex"), 3),
comment: 'Spring Boot Rocks!',
cardType: 'HAPPY'
},
{
_id: Binary(Buffer.from("dd448b729c5fa1a0c6ab3919ffc48b84", "hex"), 3),
comment: 'Meet everyone in person',
cardType: 'HAPPY'
},
{
_id: Binary(Buffer.from("094924fee8d3ef9d60bef876439eeeb4", "hex"), 3),
comment: 'When is the next one?',
cardType: 'MEH'
},
{
_id: Binary(Buffer.from("a6401bca1b9f6cda0a1ff95bd2dc61b9", "hex"), 3),
comment: 'Not enough time to talk to everyone',
cardType: 'SAD'
}
],
_class: 'com.apress.myretro.board.RetroBoard'
}
]
输出表明 MongoDB 中 UUID 的表示是 Binary(Buffer.from)对象,而 Spring 对此并不知情,也不知道如何处理。不过,有一个简单的解决方法。您可以将第 6-17 列表中显示的属性添加到 application.properties 文件中。
# MongoDB
spring.data.mongodb.uuid-representation=standard
6-17 src/main/resource/application.properties
MongoDB 的主要格式之一是二进制 JSON(BSON),它通过增加额外的数据类型和二进制编码来扩展 JSON 的功能。在这种情况下,它尝试为 UUID 使用自己的“十六进制”二进制表示,但通过这个属性,我们可以使用标准的 UUID 格式。
在重新运行我的复古应用程序之前,你需要先从集合中删除这些值
retrofb> db.retroBoard.drop({});
true
这是必要的,因为我们使用 Docker Compose,它会在每次运行应用程序和启动容器时创建一个可重复使用的卷。现在,如果您在 application.properties 文件中添加了这个属性后重新运行 My Retro App,您可以执行 curl 命令:
curl -s http://localhost:8080/retros | jq .
[
{
"id": "9dc9b71b-a07e-418b-b972-40225449aff2",
"name": "Spring Boot Conference",
"cards": [
{
"id": "bb2a80a5-a0f5-4180-a6dc-80c84bc014c9",
"comment": "Spring Boot Rocks!",
"cardType": "HAPPY"
},
{
"id": "bf2e263e-b698-43a9-adc7-bec07e94c8fd",
"comment": "Meet everyone in person",
"cardType": "HAPPY"
},
{
"id": "130441b7-6b77-465e-b879-006163de5279",
"comment": "When is the next one?",
"cardType": "MEH"
},
{
"id": "92c7d841-9e2a-40b1-847c-362fb5fe53cc",
"comment": "Not enough time to talk to everyone",
"cardType": "SAD"
}
]
}
]
如果你对 Mongo 中的显示效果感到好奇,请在连接到 Mongo 数据库的 mongo 客户端中执行以下命令:
retrodb> db.retroBoard.find({});
[
{
_id: new UUID("9dc9b71b-a07e-418b-b972-40225449aff2"),
name: 'Spring Boot Conference',
cards: [
{
_id: new UUID("bb2a80a5-a0f5-4180-a6dc-80c84bc014c9"),
comment: 'Spring Boot Rocks!',
cardType: 'HAPPY'
},
{
_id: new UUID("bf2e263e-b698-43a9-adc7-bec07e94c8fd"),
comment: 'Meet everyone in person',
cardType: 'HAPPY'
},
{
_id: new UUID("130441b7-6b77-465e-b879-006163de5279"),
comment: 'When is the next one?',
cardType: 'MEH'
},
{
_id: new UUID("92c7d841-9e2a-40b1-847c-362fb5fe53cc"),
comment: 'Not enough time to talk to everyone',
cardType: 'SAD'
}
],
_class: 'com.apress.myretro.board.RetroBoard'
}
]
现在,您可以通过 UUID 查看数据。您注意到 _class 元素了吗?这是 Spring 提供的元数据,使得 Mongo 文档与类之间的映射变得简单。