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

本章将介绍 Spring Data 项目及其三个子项目:Spring Data JDBC、Spring Data JPA 和 Spring Data REST。我们将探讨所有 Spring Data 的功能,以及 Spring Boot 如何帮助我们在两个应用中使用这些功能。让我们开始吧。

Spring 数据

第四章介绍了 Spring 框架数据访问所提供的功能,以便创建持久化数据应用程序。Spring 框架的另一个扩展是 Spring Data 技术,它提供了更多功能,使数据持久化变得更加简单。

Spring Data 提供了一种一致的编程模型,能够在与持久化引擎交互时处理繁重的任务,包括创建连接和会话、将类映射到表和行、执行数据库操作,以及提交或回滚数据。如果在任何操作过程中出现问题,它还会向您显示更清晰的错误信息,从而使开发人员的工作变得更加轻松。Spring Data 不仅简化了对关系型和非关系型数据库的访问,还支持 MapReduce 框架(如 Apache CouchDB、Apache Hadoop、Infinispan 和 Riak 等)、反应式数据库以及基于云的数据服务。 Spring Data 项目(其核心是 Spring Framework 数据访问)是一个包含多个子项目的综合项目,包括本章所述的 Spring Data JDBC、Spring Data JPA 和 Spring Data REST 子项目。

Spring Data 提供了我们将在本章及后续章节中讨论的所有这些功能(还有更多功能):

  • 一种简单的扩展接口的方法(利用领域模型和 ID,Spring Data 提供了 Repository和 CrudRepository接口),为 CRUD 操作奠定了基础
  • 这些接口的实现包含基本属性
  • 基于领域类字段的查询衍生
  • 支持基于事件和回调的审计功能(如创建日期和最后修改日期)
  • 一种可以覆盖接口中任何实现的方式
  • JavaConfig 对配置的支持
  • 实验性地支持跨商店的持久化,这意味着您可以使用相同的模型,并能够使用多个引擎

那么,让我们开始探索 Spring Data JDBC 为开发者提供的特性。

Spring 数据 JDBC

Spring Data JDBC 提供了对基于 JDBC 的数据访问的增强支持,使其成为一个简单且明确的对象关系映射(ORM)工具。Spring Data JDBC 基于 Eric Evans 在《领域驱动设计》一书中阐述的聚合根设计模式(Addison-Wesley Professional, 2003)。以下是您将在 Spring Data JDBC 中发现的一些众多功能:

  • 一种基于 CrudRepository 的命名策略实现,提供根据您的领域类构建的特定表结构。您可以通过使用如@Table 和@Column 等注解来覆盖此设置。
  • 当你需要执行一个特定的 SQL 查询,而这个查询并不是默认实现的一部分时,可以使用 @Query 注解来自定义你的查询。
  • 对遗留 MyBatis 框架的支持。
  • 对回调和事件的支持功能。
  • 支持 JavaConfig 类,您可以通过使用 @EnableJdbcRepositories 注解来配置存储库。通常,这个注解(与 Spring Boot 中的 @ComponentScan 类似)仅在 Spring 应用程序中使用。如果您使用的是 Spring Boot,则无需使用 @EnabledJdbcRepositories 注解,因为它会通过自动配置自动激活。
  • 实体状态检测策略。默认情况下,Spring Data JDBC 会检查标识符;如果标识符为 null 或 0,Spring Data JDBC 会识别该实体为新实体。
  • 扩展 CrudRepository 时默认具有事务性,因此您无需使用 @Transactional 注解来标记接口。不过,您可以覆盖某些方法,并为注解添加非常具体的参数。
  • 使用注解进行审计,包括 @CreatedBy、@LastModifiedBy、@CreatedDate 和 @LastModifiedDate。

基于 Spring Boot 的 Spring Data JDBC

当您使用 Spring Boot 并将 spring-boot-starter-data-jdbc 启动器依赖项添加到应用程序时,自动配置会添加 @EnableJdbcRepositories 注解,并根据您所使用的驱动程序及为应用程序设置的属性进行相应配置。接下来的部分将展示用户应用程序和我的复古应用程序如何使用 Spring Boot 和 Spring Data JDBC。

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

现在是时候实际体验一下使用 Spring Boot 和 Spring Data JDBC 的用户应用程序了。您可以通过访问 Spring Initializr (https://start.spring.io) 从零开始,选择一个基础项目。在项目元数据部分,请确保将组字段设置为 com.apress,将工件和名称字段设置为 users。对于依赖项,请添加 Web、验证、JDBC、H2、PostgreSQL 和 Lombok。下载项目后,解压缩并导入到您喜欢的 IDE 中。在本节结束时,您应该能够看到如图 5-1 所示的结构。

在 Spring Initializr 中点击“添加依赖”后,您可以通过在面板顶部的输入框中输入名称,轻松找到长列表中的每个依赖项。

打开 build.gradle 文件,按照列表 5-1 的示例添加 spring-boot-starter-data-jdbc 启动依赖。

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-jdbc'
    runtimeOnly 'com.h2database:h2'
    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()
}
test {
    testLogging {
        events "passed", "skipped", "failed"
        showExceptions true
        exceptionFormat "full"
        showCauses true
        showStackTraces true
        showStandardStreams = false
    }
}

列表 5-1 build.gradle 文件

添加 spring-boot-starter-data-jdbc 将会引入所有的 Spring Data 依赖项。请注意,我们有两个驱动程序,分别是 h2(嵌入式内存)和 PostgreSQL。你已经在第 4 章了解到,当 Spring Boot 发现有两个驱动程序而没有连接参数时会发生什么。

接下来,创建 UserRole 枚举(参见列表 5-2)和 User 类(参见列表 5-3)。

package com.apress.users;
public enum UserRole {
    USER, ADMIN, INFO
}

5-2 src/main/java/apress/com/users/UserRole.java

package com.apress.users;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.util.List;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Table("USERS")
public class User {
    @Id
    Long id;
    @NotBlank(message = "Email can not be empty")
    private String email;
    @NotBlank(message = "Name can not be empty")
    private String name;
    private String gravatarUrl;
    @Pattern(message = "Password must be at least 8 characters long and contain at least one number, one uppercase, one lowercase and one special character",
            regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\\S+$).{8,}#34;)
    private String password;
    @Singular("role")
    private List<UserRole> userRole;
    private boolean active;
}

5-3 src/main/java/apress/com/users/User.java

正如您在列表 5-2 中看到的,UserRole 枚举与前面的章节相同。然而,列表 5-3 显示 User 类与之前的章节有所不同。如下面所示,我们需要扩展 CrudRepository接口,该接口要求提供实体(T)及其标识符(ID)。通过这个接口,Spring Data 使用一种命名策略逻辑,将实体名称作为表名,标识符作为主键/索引键。在这个例子中,表名将是 USER,id(类型为 Long)将作为主键。 但在这种情况下,这种逻辑会将表名设为 USER,而在列表 5-3 中,我们使用@Table 注解标记了这个类,并将名称 USERS 作为参数传递,从而覆盖了默认策略,使得表名为 USERS。此外,我们还使用了@Id 注解,将 Long 类型标记为我们的标识符,也就是数据库中的主键。这里需要注意的是,这两个注解都来自 Spring Data 包(org.springframework.data.*)。

接下来,创建 UserRepository 接口。请查看第 5-4 号列表。

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

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

请注意,UserRepository 接口是全新的。我们来分析一下它的内容:

  • CrudRepository:我们的接口扩展了公共接口 CrudRepository,该接口又扩展了 Repository。CrudRepository 声明了多个方法:
public interface CrudRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S entity);
    <S extends T> Iterable<S> saveAll(Iterable<S> entities);
    Optional<T> findById(ID id);
    boolean existsById(ID id);
    Iterable<T> findAll();
    Iterable<T> findAllById(Iterable<ID> ids);
    long count();
    void deleteById(ID id);
    void delete(T entity);
    void deleteAllById(Iterable<? extends ID> ids);
    void deleteAll(Iterable<? extends T> entities);
    void deleteAll();
}
  • 这里最令人印象深刻的细节之一是,我们无需自己实现任何内容——Spring Data 会为我们完成所有工作,因此我们可以直接在服务或控制器中使用这个 UserRepository 接口。
  • findBy 和 deleteBy:我们在 UserRepository 中声明这些方法。借助 Spring Data,我们可以利用命名约定创建查询方法,这些方法根据字段名称和一些关键字(通常在 SQL 语句中使用)来执行查询。在这个例子中,我们使用关键字 findBy,并添加字段名称 Email,形成 findByEmail;同时,我们也有 deleteBy,并添加字段 Email,因此 Spring Data 会生成相应的实现来执行类似的操作:
select * from users where email = ?
delete from users where email = ?
  • 如果你使用类似 findByMyAwesomeName 的方法,Spring Data 将不会执行任何操作,因为它必须遵循实体中声明的字段。换句话说,字段名称必须与您创建的查询方法相匹配。其他关键字包括 After、GreaterThan、Before、In、NotIn、Like、Containing、IsTrue 和 IsFalse 等等。要查看所有可用的关键字,请访问 https://docs.spring.io/spring-data/jdbc/docs/current/reference/html/#jdbc.query-methods。如果你使用的 IDE 支持 IntelliSense,你将能够看到所有可能的组合。

Spring Data 还引入了一种查询查找策略,您可以通过使用 @Query 注解来扩展一些默认查询,该注解接受一个字符串(SQL 语句)作为参数。有时,您会发现 CRUD 的默认实现无法满足我们的需求。如果您只想从用户中获取 Gravatar,您可以在 UserRepository 接口中声明一个如下的方法:

@Query("SELECT GRAVATAR_URL FROM USERS WHERE EMAIL = :email")
    String getGravatarByEmail(@Param("email") String email);

就这么简单!

用户仓库接口是一个极好的新功能,可以加快开发过程!

接下来,创建 UserGravatar 和 UserConfiguration 类。请参考列表 5-5 和 5-6。需要注意的是,UserGravatar 类与第 4 章(列表 4-3)中的内容相同。

package com.apress.users;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class UserGravatar {
    public static String getGravatarUrlFromEmail(String email){
        return String.format("https://www.gravatar.com/avatar/%s?d=wavatar", md5Hex(email));
    }
    private static String hex(byte[] array) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < array.length; ++i) {
            sb.append(Integer.toHexString((array[i]
                    & 0xFF) | 0x100).substring(1, 3));
        }
        return sb.toString();
    }
    private static String md5Hex(String message) {
        try {
            MessageDigest md =
                    MessageDigest.getInstance("MD5");
            return hex(md.digest(message.getBytes("CP1252")));
        } catch (NoSuchAlgorithmException e) {
        } catch (UnsupportedEncodingException e) {
        }
        return "23bb62a7d0ca63c9a804908e57bf6bd4";
    }
}

5-5 src/main/java/apress/com/users/UserGravatar.java

package com.apress.users;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class UserConfiguration {
    @Bean
    ApplicationListener<ApplicationReadyEvent> init(UserRepository userRepository) {
        return applicationReadyEvent -> {
            userRepository.save(User.builder()
                    .email("ximena@email.com")
                    .name("Ximena")
                    .password("aw2s0meR!")
                    .role(UserRole.USER)
                    .active(true)
                    .build());
            userRepository.save(User.builder()
                    .email("norma@email.com")
                    .name("Norma")
                    .password("aw2s0meR!")
                    .role(UserRole.USER)
                    .role(UserRole.ADMIN)
                    .active(true)
                    .build());
        };
    }
}

5-6 src/main/java/apress/com/users/UserConfiguration.java

UserGravatar 类将根据电子邮件摘要生成 Gravatar。列表 5-6 显示了相关配置。如您所见,这与上一章是一样的。当应用程序准备就绪时,将执行 init 方法。

如果你关注代码(清单 5-6),我们并没有设置 gravatarUrl 字段;我们该如何计算并将其添加到用户实体中呢?在第 4 章中,我们将用户作为记录类型,并使用紧凑构造函数来设置 gravatarUrl 字段,因此每当有一个新实例时,我们都可以获取该用户的 Gravatar。为了解决这个问题,我们可以利用 Spring Data 的回调功能。因此,让我们创建 UserBeforeSaveCallback 类来实现 BeforeSaveCallback;请参见清单 5-7。

package com.apress.users;
import org.springframework.data.relational.core.conversion.MutableAggregateChange;
import org.springframework.data.relational.core.mapping.event.BeforeSaveCallback;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class UserBeforeSaveCallback implements BeforeSaveCallback<User> {
    @Override
    public User onBeforeSave(User aggregate, MutableAggregateChange<User> aggregateChange) {
        if (aggregate.getGravatarUrl()==null)
            aggregate.setGravatarUrl(UserGravatar.getGravatarUrlFromEmail(aggregate.getEmail()));
        if (aggregate.getUserRole() == null)
            aggregate.setUserRole(List.of(UserRole.INFO));
        return aggregate;
    }
}

5-7 src/main/java/apress/com/users/UserBeforeSaveCallback.java

UserBeforeSaveCallback 类被标记为 @Component,以便 Spring 能够找到并将其添加到在将 User 实体保存到数据库之前执行的回调逻辑中。我们正在实现类型为 User 的 BeforeSaveCallback,这使我们能够实现带有 User 和 MutableAggreateChange 参数的 onBeforeSave 方法。在这里,我们可以访问实体,以添加我们想要的默认值,这里是 gravatarUrl。

Spring Data 提供了更多的实体回调,包括在删除时的 BeforeDeleteCallback 和 AfterDeleteCallback,保存时的 BeforeConvertCallback、BeforeSaveCallback 和 AfterSaveCallback,以及加载时的 AfterConvertCallback。

接下来,按照清单 5-8 的示例创建 UsersController 类。

package com.apress.users;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
@AllArgsConstructor
@RestController
@RequestMapping("/users")
public class UsersController {
    private UserRepository userRepository;
    @GetMapping
    public ResponseEntity<Iterable<User>> getAll(){
        return ResponseEntity.ok(this.userRepository.findAll());
    }
    @GetMapping("/{email}")
    public ResponseEntity<User> findUserByEmail(@PathVariable String email) throws Throwable {
        return ResponseEntity.of(this.userRepository.findByEmail(email));
    }
    @RequestMapping(method = {RequestMethod.POST,RequestMethod.PUT})
    public ResponseEntity<User> save(@RequestBody User user){
       this.userRepository.save(user);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{email}")
                .buildAndExpand(user.getId())
                .toUri();
        return ResponseEntity.created(location).body(this.userRepository.findByEmail(user.getEmail()).get());
    }
    @DeleteMapping("/{email}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable String email){
        this.userRepository.deleteByEmail(email);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return errors;
    }
}

5-8 src/main/java/apress/com/users/UsersController.java

正如你所看到的,UsersController 类与前一章有所不同,因为我们在仓库中使用了 findByEmail 和 deleteByEmail 方法,最重要的是我们直接使用了 UserRepository 接口。

接下来,打开 application.properties 文件,并使用列表 5-9 中的内容。正如你所看到的,这与第 4 章的内容是一样的。

spring.h2.console.enabled=true
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db

5-9 src/main/resources/application.properties

接下来,创建 schema.sql 文件,详见列表 5-10。

DROP TABLE IF EXISTS USERS CASCADE;
CREATE TABLE USERS (
    ID LONG NOT NULL AUTO_INCREMENT,
    EMAIL VARCHAR(255) NOT NULL UNIQUE,
    NAME VARCHAR(100) NOT NULL,
    GRAVATAR_URL VARCHAR(255) NOT NULL,
    PASSWORD VARCHAR(255) NOT NULL,
    USER_ROLE VARCHAR(5) ARRAY NOT NULL DEFAULT ARRAY['INFO'],
    ACTIVE BOOLEAN NOT NULL,
    PRIMARY KEY (ID));

5-10 src/main/resources/schema.sql

当我们的应用程序启动时,schema.sql 文件将被执行——这就是 Spring Boot 的强大之处。请注意,这仅适用于 H2、Derby 或 HSQL 等嵌入式数据库。由于我们没有指定任何连接参数,Spring Boot 会将 H2 设置为内存数据库。因此,我们已经准备好了!

用户应用测试

为了测试用户应用程序,请在测试文件夹中创建 UsersHttpRequestTests 类。请参见第 5-11 号列表。

package com.apress.users;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import java.util.Collection;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UsersHttpRequestTests {
    @Value("${local.server.port}")
    private int port;
    private final String BASE_URL = "http://localhost:";
    private final String USERS_PATH = "/users";
    @Autowired
    private TestRestTemplate restTemplate;
    @Test
    public void indexPageShouldReturnHeaderOneContent() throws Exception {
        assertThat(this.restTemplate.getForObject(BASE_URL + port,
                String.class)).contains("Simple Users Rest Application");
    }
    @Test
    public void usersEndPointShouldReturnCollectionWithTwoUsers() throws Exception {
        Collection<User> response = this.restTemplate.
                getForObject(BASE_URL + port + USERS_PATH, Collection.class);
        assertThat(response.size()).isGreaterThanOrEqualTo(2);
    }
    @Test
    public void userEndPointPostNewUserShouldReturnUser() throws Exception {
        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();
        User response =  this.restTemplate.postForObject(BASE_URL + port + USERS_PATH,user,User.class);
        assertThat(response).isNotNull();
        assertThat(response.getEmail()).isEqualTo(user.getEmail());
        Collection<User> users = this.restTemplate.
                getForObject(BASE_URL + port + USERS_PATH, Collection.class);
        assertThat(users.size()).isGreaterThanOrEqualTo(2);
    }
    @Test
    public void userEndPointDeleteUserShouldReturnVoid() throws Exception {
        this.restTemplate.delete(BASE_URL + port + USERS_PATH + "/norma@email.com");
        Collection<User> users = this.restTemplate.
                getForObject(BASE_URL + port + USERS_PATH, Collection.class);
        assertThat(users.size()).isLessThanOrEqualTo(2);
    }
    @Test
    public void userEndPointFindUserShouldReturnUser() throws Exception{
        User user = this.restTemplate.getForObject(BASE_URL + port + USERS_PATH + "/ximena@email.com",User.class);
        assertThat(user).isNotNull();
        assertThat(user.getEmail()).isEqualTo("ximena@email.com");
    }
}

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

你可以在 IDE 中运行测试类,或者使用以下命令:

./gradlew clean test

启动用户应用程序

您可以在 IDE 中运行用户应用,或者使用以下命令:

./gradlew clean bootRun

你可以选择打开浏览器访问 http://localhost:8080/users,或者在命令行中执行以下 curl 命令:

curl -s http://localhost:8080/users | jq .
[
  {
    "id": 1,
    "email": "ximena@email.com",
    "name": "Ximena",
    "gravatarUrl": "https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar",
    "password": "aw2s0meR!",
    "userRole": [
      "USER"
    ],
    "active": true
  },
  {
    "id": 2,
    "email": "norma@email.com",
    "name": "Norma",
    "gravatarUrl": "https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar",
    "password": "aw2s0meR!",
    "userRole": [
      "USER",
      "ADMIN"
    ],
    "active": true
  }
]