四时宝库

程序员的知识宝库

精通Spring Boot 3 : 6. 使用 Spring Boot 的 Spring Data NoSQL (2)

我复古的应用程序,基于 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 文档与类之间的映射变得简单。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言
    友情链接