精通Spring Boot 3 : 5. 使用 Spring Boot 的 Spring Data (4)

Spring 数据 REST

Spring Data REST 是 Spring Data 旗下的一个项目,它提供了一种简单的方法来构建基于超媒体的 RESTful 网络服务,使用接口仓库。当你引入 Spring Data REST 时,它会分析你所有的领域模型,并为模型中包含的聚合创建所需的所有 HTTP 资源;换句话说,它会生成 REST 控制器(以超文本应用语言作为媒体类型),使你的应用能够使用 HTTP 方法(如 POST、GET、PUT、PATCH 等)。

以下是 Spring Data REST 的一些常见特性:

  • 根据您的领域模型,提供一个可被发现的 REST API
  • 默认使用 HAL+JSON(超文本应用语言)
  • 显示集合、项目及任何与您的模型相关的资源
  • 通过导航链接实现分页功能
  • 为您在仓库中定义的查询方法创建搜索资源
  • 公开关于被发现的模型的元数据,该模型被称为应用程序级别配置文件语义(ALPS)和 JSON 模式
  • 将 HAL Explorer 引入到公开的元数据中
  • 目前支持 JPA、MongoDB、Neo4j、Solr、Cassandra 和 Gemfire 数据库
  • 允许对默认资源的自定义设置

基于 Spring Boot 的 Spring Data REST

如果您想在 Spring Boot 中使用 Spring Data REST,您需要添加 spring-boot-starter-data-rest 启动器以及相关的技术支持。所有的自动配置将为您准备好一切,以便您可以直接从模型中使用 REST API。您无需做其他任何事情——就是这么简单。

基于 Spring Boot 和 Spring Data REST 的用户应用程序

再次提醒,你可以访问代码,如果想从头开始,可以前往 Spring Initializr (https://start.spring.io)并接受默认设置。将 Group 字段设置为 com.apress,Artifact 和 Name 字段设置为 users。依赖项中添加 Web、Validation、JPA、Data REST、H2 和 Lombok。下载项目,解压后导入到你喜欢的 IDE 中。我将只展示与之前部分不同的类。在本节结束时,你应该能够看到图 5-5 所示的结构。

请注意,它不再包含 UsersController 类或静态内容!

首先打开 build.gradle 文件,并将其内容替换为列表 5-29 中所示的内容。

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-data-jpa'
          implementation 'org.springframework.boot:spring-boot-starter-data-rest'
          implementation 'org.springframework.data:spring-data-rest-hal-explorer'
    runtimeOnly 'com.h2database:h2'
    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()
}
test {
    testLogging {
        events "passed", "skipped", "failed"
        showExceptions true
        exceptionFormat "full"
        showCauses true
        showStackTraces true
        showStandardStreams = false
    }
}

5-29 build.gradle

我们添加了 spring-boot-starter-data-rest、spring-data-rest-hal-explorer,以及 spring-boot-starter-data-jpa 这些启动依赖。

接下来,打开或创建 UserRepository 接口。请参阅第 5-30 号列表。

package com.apress.users;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Optional;
public interface UserRepository extends CrudRepository<User,Long>, PagingAndSortingRepository<User,Long> {
    Optional<User> findByEmail(String email);
}

5-30 src/main/java/apress/com/users/UserRepository.java

我们仅删除了删除方法,并扩展了 PaginAndSortingRepository 接口。这个接口也是 Spring Data(核心)的一部分,可以与 JPA 一起使用。

用户、用户配置、用户头像和用户角色类与 Spring Data JPA 项目没有变化,application.properties 文件也没有变化(是的,我们删除了 UsersController 类)。虽然不需要修改 UserRepository,但我想向你展示分页和排序的效果。就这样,仅仅是一个小的改动!

当你运行用户应用时,Spring Data REST 会检查你的应用程序并获取你的仓库。根据领域类,它会创建多个接受任何 HTTP 请求的端点,访问将使用领域模型,这里是用户类,并生成 /users(小写复数,感谢 Evo Inflector 库)端点。响应将始终基于 HAL+JSON 媒体类型/内容类型,因此你需要了解如何处理这些响应。

启动用户应用程序

通常我们会在运行应用程序之前进行测试,但我想先运行它,以向您展示添加 spring-data-rest-hal-explorer 启动依赖项后的结果,并讨论它为应用程序带来的好处。

您可以选择在 IDE 中运行应用程序,或者使用以下命令来运行它:

./gradlew clean bootRun

打开浏览器,访问 http://localhost:8080,您将看到 HAL Explorer,如图 5-6 所示。


默认情况下,您可以在根端点 / 访问 HAL Explorer。如果您在用户行中点击 < 符号(位于 HTTP 请求下方的左侧窗格),将会弹出一个对话框,询问您输入参数,如图 5-7 所示。

如果您点击“开始!”按钮,您将看到如图 5-8 所示的更新后的 HAL Explorer 窗口。

请注意,响应现在包含更多的元数据和新的关键字,这就是 HAL 响应。如果你在左侧面板的嵌入资源中点击 users[0],你应该能看到图 5-9 中显示的窗口。


这真是太好了,因为现在你有一个可以被其他客户端发现的 API。请花点时间点击浏览一下,了解 HAL 格式。如果你想查看命令行,请打开终端并执行以下命令:

curl -s  http://localhost:8080/users | jq .
{
  "_embedded": {
    "users": [
      {
        "email": "ximena@email.com",
        "name": "Ximena",
        "gravatarUrl": "https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar",
        "password": "aw2s0meR!",
        "userRole": [
          "USER"
        ],
        "active": true,
        "_links": {
          "self": {
            "href": "http://localhost:8080/users/1"
          },
          "user": {
            "href": "http://localhost:8080/users/1"
          }
        }
      },
      {
        "email": "norma@email.com",
        "name": "Norma",
        "gravatarUrl": "https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar",
        "password": "aw2s0meR!",
        "userRole": [
          "USER",
          "ADMIN"
        ],
        "active": true,
        "_links": {
          "self": {
            "href": "http://localhost:8080/users/2"
          },
          "user": {
            "href": "http://localhost:8080/users/2"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/users?page=0&size=20"
    },
    "profile": {
      "href": "http://localhost:8080/profile/users"
    },
    "search": {
      "href": "http://localhost:8080/users/search"
    }
  },
  "page": {
    "size": 20,
    "totalElements": 2,
    "totalPages": 1,
    "number": 0
  }
}

在继续之前,请分析响应及其所有元素。特别要注意的是,有一个 "_links" 元素,它声明了 "search" 端点。这个端点是通过 UserRepository 中的 findByEmail 方法创建的,因此您可以使用以下命令来搜索电子邮件:

curl -s "http://localhost:8080/users/search/findByEmail?email=ximena@email.com" |  jq .
{
  "email": "ximena@email.com",
  "name": "Ximena",
  "gravatarUrl": "https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar",
  "password": "aw2s0meR!",
  "userRole": [
    "USER"
  ],
  "active": true,
  "_links": {
    "self": {
      "href": "http://localhost:8080/users/1"
    },
    "user": {
      "href": "http://localhost:8080/users/1"
    }
  }
}

它将返回用户和自链接(帮助您导航到单个记录)。如果您在浏览器中,可以访问 http://localhost:8080/users 来获取所有用户记录。如果您安装了 JSON Viewer 插件,可以点击页面上显示的任何链接。请参见图 5-10。

用户应用测试

现在,是时候通过集成测试来测试我们的应用程序了。在这种情况下,与我们在前面的章节中看到的稍有不同。我们将使用支持 HAL/HATEOAS 媒体类型的新类来进行测试。

所以,请打开或创建 UsersHttpRequestTests 类,并根据列表 5-31 中的内容进行替换或添加。

package com.apress.users;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UsersHttpRequestTests {
    private String baseUrl;
    @Autowired
    private TestRestTemplate restTemplate;
    @BeforeEach
    public void setUp() throws Exception {
       baseUrl = "/users";
    }
    @Test
    public void usersEndPointShouldReturnCollectionWithTwoUsers() throws Exception {
        ResponseEntity<CollectionModel<EntityModel<User>>> response =
                restTemplate.exchange(baseUrl, HttpMethod.GET, null, new ParameterizedTypeReference<CollectionModel<EntityModel<User>>>() {});
        assertThat(response).isNotNull();
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getHeaders().getContentType()).isEqualTo(MediaTypes.HAL_JSON);
        assertThat(response.getBody().getContent().size()).isGreaterThanOrEqualTo(2);
    }
    @Test
    public void userEndPointPostNewUserShouldReturnUser() throws Exception {
        HttpHeaders createHeaders = new HttpHeaders();
        createHeaders.setContentType(MediaTypes.HAL_JSON);
        User user =  User.builder()
                .email("dummy@email.com")
                .name("Dummy")
                .gravatarUrl("https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar")
                .password("aw2s0meR!")
                .role(UserRole.USER)
                .active(true)
                .build();
        HttpEntity<String> createRequest = new HttpEntity<>(convertToJson(user), createHeaders);
        ResponseEntity<EntityModel<User>> response =  this.restTemplate.exchange(baseUrl, HttpMethod.POST, createRequest, new ParameterizedTypeReference<EntityModel<User>>() {});
        assertThat(response).isNotNull();
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        EntityModel<User> userResponse = response.getBody();
        assertThat(userResponse).isNotNull();
        assertThat(userResponse.getContent()).isNotNull();
        assertThat(userResponse.getLink("self")).isNotNull();
        assertThat(userResponse.getContent().getEmail()).isEqualTo(user.getEmail());
    }
    @Test
    public void userEndPointDeleteUserShouldReturnVoid() throws Exception {
        this.restTemplate.delete(baseUrl + "/1");
        ResponseEntity<CollectionModel<EntityModel<User>>> response =
                restTemplate.exchange(baseUrl, HttpMethod.GET, null, new ParameterizedTypeReference<CollectionModel<EntityModel<User>>>() {});
        assertThat(response).isNotNull();
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getHeaders().getContentType()).isEqualTo(MediaTypes.HAL_JSON);
        assertThat(response.getBody().getContent().size()).isGreaterThanOrEqualTo(1);
    }
    @Test
    public void userEndPointFindUserShouldReturnUser() throws Exception{
        String email = "ximena@email.com";
        ResponseEntity<EntityModel<User>> response = restTemplate.exchange(baseUrl + "/search/findByEmail?email={email}", HttpMethod.GET, null, new ParameterizedTypeReference<EntityModel<User>>() {}, email);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        EntityModel<User> users = response.getBody();
        assertThat(users).isNotNull();
        assertThat(users.getContent().getEmail()).isEqualTo(email);
    }
    private String convertToJson(User user) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.writeValueAsString(user);
    }
}

5-31 src/test/java/apress/com/users/UsersHttpRequestTests.java

UsersHttpRequestTests 测试类现在使用 org.springframework.hateoas.* 包中的类。我们添加的 spring-boot-starter-data-rest 启动依赖项包含 spring-hateoas 库,该库提供了处理 HAL+JSON 类型所需的类:CollectionModel 和 EntityModel。请注意,我们可以获取响应中的引用、链接以及所有声明。

您可以通过您的 IDE 或使用以下命令来运行测试:

./gradlew clean test
UsersHttpRequestTests > userEndPointFindUserShouldReturnUser() PASSED
UsersHttpRequestTests > userEndPointDeleteUserShouldReturnVoid() PASSED
UsersHttpRequestTests > userEndPointPostNewUserShouldReturnUser() PASSED
UsersHttpRequestTests > usersEndPointShouldReturnCollectionWithTwoUsers() PASSED

我复古的应用程序是基于 Spring Boot 和 Spring Data REST 开发的

现在轮到我的复古应用程序利用 Spring Data REST 了。你可以选择使用现有代码,或者通过访问 Spring Initializr (https://start.spring.io) 从头开始创建一个项目。你可以选择一个空项目,但请确保将 Group 字段设置为 com.apress,将 Artifact 和 Name 字段设置为 myretro。对于依赖项,请添加 Web、Validation、JPA、Docker Compose、Data REST、PostgreSQL 和 Lombok。下载项目后,解压并导入到你喜欢的 IDE 中。在本节结束时,你应该能够看到如图 5-11 所示的项目结构。

你已经知道缺少什么了,对吧?没错,网络和服务包以及类都不见了!我们不再需要它们了。那么,究竟发生了什么变化呢?

打开 build.gradle 文件,并将其内容替换为列表 5-32 中所示的内容。

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 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-rest'
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    runtimeOnly 'org.postgresql:postgresql'
    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()
}

5-32 build.gradle

唯一的新增内容是 spring-boot-starter-data-rest 启动器依赖项。建议、董事会、配置、异常和持久性包及其类保持不变。当然,我们删除了服务和网络包及其类。

启动我的复古应用程序

要运行我的复古应用,请使用您的 IDE 或输入以下命令:

./gradlew clean bootRun

现在,如果你在浏览器中输入 http://localhost:8080/retros,会发生什么呢?你会看到一个 404 - 未找到 的错误,但这是为什么呢?我们一直在使用 /retros 作为端点,那么究竟发生了什么?请记住,Spring Data REST 会检查你的仓库,并将域类的名称转换为小写复数,以创建端点,因此在这种情况下,我们有端点 /retroBoards 和 /cards。

如果你现在访问 http://localhost:8080/retroBoards,你将看到所有的 RetroBoard 记录,或者你可以使用以下命令:

curl -s http://localhost:8080/retroBoards | jq .
{
  "_embedded": {
    "retroBoards": [
      {
        "name": "Spring Boot Conference",
        "_links": {
          "self": {
          },
          "retroBoard": {
          },
          "cards": {
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/retroBoards?page=0&size=20"
    },
    "profile": {
      "href": "http://localhost:8080/profile/retroBoards"
    }
  },
  "page": {
    "size": 20,
    "totalElements": 1,
    "totalPages": 1,
    "number": 0
  }
}

请查看 _embedded 和 _links 数据,它们分别指向 retroBoards(复数)和 retroBoard(单数)。如果我希望保留我的 /retros,以便我的客户端不会因为我现在使用 HAL 响应而发生变化,那该怎么办呢?换句话说,当默认使用 HAL 时,HAL 自动配置会将类名变为复数,并创建一个形式为 /retroBoards(驼峰命名法)的端点,但我的客户端连接的是 /retros,我该如何切换回去呢?其实,这里有一个解决方案。打开 RetroBoardRepository 接口,并添加列表 5-33 中显示的代码。

package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import java.util.UUID;
@RepositoryRestResource(path = "retros",itemResourceRel = "retros", collectionResourceRel = "retros")
public interface RetroBoardRepository extends JpaRepository<RetroBoard,UUID> {
}

5-33 src/main/java/apress/com/myretro/persistence/RetroBoardRepository.java

修改后的 RetroBoardRepository 接口现在包含 @RepositoryRestResource 注解。可以看到它接受三个参数:

  • 这个参数用于创建我们想要的接口,/retros。
  • 该参数将"_links"中的名称更改为"retros"(而不是"retroBoard")。
  • 该参数将"_embedded"中的名称从“retroBoards”更改为“retros”。

现在,可以通过访问 http://localhost:8080/retros 或使用以下命令来重新启动应用程序:

curl -s http://localhost:8080/retros |  jq .
{
  "_embedded": {
    "retros": [
      {
        "name": "Spring Boot Conference",
        "_links": {
          "self": {
            "href": "http://localhost:8080/retros/e800d409-9295-4565-bf4b-f3b95ff32eff"
          },
          "retros": {
            "href": "http://localhost:8080/retros/e800d409-9295-4565-bf4b-f3b95ff32eff"
          },
          "cards": {
            "href": "http://localhost:8080/retros/e800d409-9295-4565-bf4b-f3b95ff32eff/cards"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/retros?page=0&size=20"
    },
    "profile": {
      "href": "http://localhost:8080/profile/retros"
    }
  },
  "page": {
    "size": 20,
    "totalElements": 1,
    "totalPages": 1,
    "number": 0
  }
}

在继续之前,如果您在浏览器中,可以查看浏览器或命令行中的 /profile/retros 端点,这将为您提供 ALPS(应用程序级别配置语义)和 JSON Schema:

curl -s -H 'Accept:application/schema+json' http://localhost:8080/profile/retros |  jq .
{
  "title": "Retro board",
  "properties": {
    "cards": {
      "title": "Cards",
      "readOnly": false,
      "type": "string",
      "format": "uri"
    },
    "name": {
      "title": "Name",
      "readOnly": false,
      "type": "string"
    }
  },
  "definitions": {},
  "type": "object",
  "$schema": "http://json-schema.org/draft-04/schema#"
}

通过这些选项,您可以将您的 API 向公众开放。

概要

在本章中,我们讨论了 Spring Data JDBC、Spring Data JPA 和 Spring Data REST,以及 Spring Boot 如何通过基于我们所使用的依赖项的自动配置来简化所有额外的配置。你学到了以下内容:

  • Spring Data(核心)使用的存储库接口包括 CrudRepository 和 PagingAndSortingRepository,其他接口还有 ListCrudRepository、ListPagingAndSortingRepository、Repository、NoRepositoryBean 和 RepositoryDefinition。
  • 在 Spring Data JDBC 中,您需要关注关系,而在 Spring Data JPA 中,您需要关注对象的组合。
  • JDBC 对多对多关系的支持有限,但您仍然可以通过 SQL 语句来完成这项工作。
  • 你了解到 JDBC 能够初始化数据库,这得益于 Spring Boot,它可以检查类路径,如果找到 schema.sql 或 data.sql,就能创建表并插入数据。
  • JPA 可以根据 spring.jpa.*属性生成数据库表,得益于 Spring Boot 的自动配置,所有接口仓库都已启用。
  • 你还学习了基于 Spring Data REST 的 REST 实现,以及 Spring Data REST 与 Spring Boot 如何帮助配置所有内容,使其看起来就像是即插即用。
  • 使用 Spring Data REST,您可以轻松获得开箱即用的 Web 控制器,提供多个端点。

在第六章中,我们将介绍 NoSQL 数据库。