精通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
}
]