精通Spring Boot 3 : 3. Spring Boot 网络开发 (3)
用户应用程序项目
现在是时候对我们在第一章中开始的用户应用项目进行改造了。在这一部分,我们将对其进行修改,使其更加实用。图 3-4 展示了您在完成本节后将得到的目录结构。
打开 build.gradle 文件,并将现有内容替换为列表 3-15 中的内容。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.4'
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'
// Lombok
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
}
}
列表 3-15 的 build.gradle 文件
这个新的 build.gradle 文件集成了验证功能和 Lombok。
接下来,打开用户类,并根据列表 3-16 中的代码修改其内容。
package com.apress.users;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;
import lombok.Data;
import lombok.Singular;
import java.util.List;
@Builder
@Data
public class User {
@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;
}
文件 3-16 源代码:src/main/java/com/apress/users/User.java
以下列表详细描述了新用户类中的注释内容:
- 我们正在使用 Lombok 库中的@Builder 和@Data 注解。@Builder 注解创建了一个流式 API,帮助我们构建 User 实例,而@Data 注解则为类生成所有的 setter、getter 以及 toString()、equals(Object)和 hashCode()方法。使用这些注解不会影响应用程序的性能。
- 这些注解用于网络请求验证的开始。@NotBlank 是较早引入的,而 @Pattern 注解用于模式匹配验证,如果匹配失败,将会产生错误信息。
- 这个 Lombok 库的注解可以帮助将单个角色添加到用户角色列表里。
正如你所看到的,我们正在为我们的应用程序添加一些新功能,比如验证。接下来,创建 UserRole 枚举、Repository 接口和 UserRepository 类。列表 3-17 显示了用户角色的枚举。
package com.apress.users;
public enum UserRole {
USER, ADMIN, INFO
}
用户角色.java 文件(路径:src/main/java/com/apress/users)
列表 3-18 展示了 Repository 接口,实际上与我的复古应用程序中的接口是一样的(参见列表 3-5)。
package com.apress.users;
import java.util.Optional;
public interface Repository<D,ID>{
D save(D domain);
Optional<D> findById(ID id);
Iterable<D> findAll();
void deleteById(ID id);
}
列表 3-18 源代码:src/main/java/com/apress/users/Repository.java
列表 3-19 展示了 UserRepository 的实现类。
package com.apress.users;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository
.gravatarUrl("https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar")
.role(UserRole.USER)
.active(true)
.build());
put("norma@email.com",User.builder()
.name("Norma")
.email("norma@email.com")
.gravatarUrl("https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar")
.password("aw2s0meR!")
.role(UserRole.USER)
.role(UserRole.ADMIN)
.active(true)
.build());
}};
@Override
public User save(User user) {
if (user.getGravatarUrl()==null)
user.setGravatarUrl("https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar");
if (user.getUserRole() == null)
user.setUserRole(Collections.emptyList());
return this.users.put(user.getEmail(),user);
}
@Override
public Optional<User> findById(String id) {
return Optional.of(this.users.get(id));
}
@Override
public Iterable<User> findAll() {
return this.users.values();
}
@Override
public void deleteById(String id) {
this.users.remove(id);
}
}
用户仓库.java 文件中的列表 3-19
仔细查看列表,你会发现更新后的用户应用程序与我的复古应用程序非常相似,并且我们使用的是内存中的数据持久化。
Spring Web 功能端点
Spring MVC 还提供了函数式编程来定义 Web 端点。通过函数来定义路由和处理请求。每个 HTTP 请求都由一个 HandlerFunction(RouterFunction)处理,该函数接收一个 ServerRequest 并返回一个 ServerResponse。
这些请求会被路由到一个 RouterFunction,该函数接收 ServerRequest 并返回一个可选的 HandlerFunction。你可以将这个 RouterFunction 看作是 @RequestMapping 注解的等价物,但它的优势在于不仅处理数据,还处理行为。
接下来,添加 UsersRoutes 类。请查看第 3-20 号列表。
package com.apress.users;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.servlet.function.RequestPredicates;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.servlet.function.RequestPredicates.accept;
import static org.springframework.web.servlet.function.RouterFunctions.route;
@Configuration
public class UsersRoutes {
@Bean
public RouterFunction<ServerResponse> userRoutes(UsersHandler usersHandler) {
return route().nest(RequestPredicates.path("/users"), builder -> {
builder.GET("",accept(APPLICATION_JSON), usersHandler::findAll);
builder.GET("/{email}",accept(APPLICATION_JSON), usersHandler::findUserByEmail);
builder.POST("", usersHandler::save);
builder.DELETE("/{email}", usersHandler::deleteByEmail);
}).build();
}
@Bean
public Validator validator() {
return new LocalValidatorFactoryBean();
}
}
文件 3-20 源代码路径:src/main/java/com/apress/users/UsersRoutes.java
UsersRoutes 类包含以下组件:
- @Configuration:Spring Boot 在应用程序启动时会查找这个注解,它有助于识别任何可能的 Spring bean,这些 bean 是用 @Bean 注解标记的。在这个类中,我们定义了两个 bean,分别是 userRoutes 和 validator。
- RouterFunction:这是一个接口,定义了多个方法,用于构建 RouterFunction。通常的做法是使用定义 RouterFunction 接口的流式 API。请注意 userRoutes(UsersHandler)方法,它期望一个 Spring bean UsersHandler;为了让 Spring 识别这个 bean,必须进行声明(使用@Bean 注解或通过@Component 注解标记类)。
- route(): 这是一个流畅的 API,根据定义的端点设置必要的路由。在这种情况下,我们使用 RequestPredicates 抽象类创建了一个公共路径 /users。
- 我们正在使用一个构建器(java.util.function.Consumer),它允许我们定义哪些 HTTP 方法将路由到处理程序,这里是 UsersHandler 类。在这里,我们声明了 GET、POST 和 DELETE HTTP 方法,并定义了将从处理程序中使用的方法。
- @Bean:这个注解用于声明我们应用程序中使用的 Spring Bean。
- 验证器/本地验证器工厂 Bean:这个 Bean 验证器返回一个 LocalValidatorFactoryBean 类,该类在 User 类中使用约束(@NotBlank 和 @Pattern)时使用。这是通过功能性方式声明 Web API 端点进行验证的唯一方法。这个验证器将在接下来的 UsersHandler 类中使用。
接下来,添加 UsersHandler 类。请查看第 3-21 号列表。
package com.apress.users;
import jakarta.servlet.ServletException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.validation.DataBinder;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.IOException;
import java.net.URI;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@RequiredArgsConstructor
@Component
public class UsersHandler {
private final Repository userRepository;
private final Validator validator;
public ServerResponse findAll(ServerRequest request) {
return ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(this.userRepository.findAll());
}
public ServerResponse findUserByEmail(ServerRequest request) {
return ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(this.userRepository.findById(request.pathVariable("email")));
}
public ServerResponse save(ServerRequest request) throws ServletException, IOException {
User user = request.body(User.class);
BindingResult bindingResult = validate(user);
if (bindingResult.hasErrors()) {
return prepareErrorResponse(bindingResult);
}
this.userRepository.save(user);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{email}")
.buildAndExpand(user.getEmail())
.toUri();
return ServerResponse.created(location).body(user);
}
public ServerResponse deleteByEmail(ServerRequest request) {
this.userRepository.deleteById(request.pathVariable("email"));
return ServerResponse.noContent().build();
}
private BindingResult validate(User user) {
DataBinder binder = new DataBinder(user);
binder.addValidators(validator);
binder.validate();
return binder.getBindingResult();
}
private ServerResponse prepareErrorResponse(BindingResult bindingResult) {
Map<String, Object> response = new HashMap<>();
response.put("msg","There is an error");
response.put("code", HttpStatus.BAD_REQUEST.value());
response.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(fieldError -> {
errors.put(fieldError.getField(), fieldError.getDefaultMessage());
});
response.put("errors",errors);
return ServerResponse.badRequest().body(response);
}
}
列表 3-21 源代码:src/main/java/com/apress/users/UsersHandler.java
UsersHandler 类具有以下组成部分:
- @RequiredArgsConstructor:这个来自 Lombok 库的注解会根据 Repository 和 Validator 字段创建一个构造函数。通过这个构造函数,Spring 会注入与每个字段对应的 bean。其中一个是我们的 UserRepository,另一个是 UsersRoutes 类中声明的验证器 bean(LocalValidationFactoryBean)。
- @Component:这个注解用于标识该类为 Spring bean,以便在需要时在 web 应用中使用。在这种情况下,它是处理每个 web 请求的处理器。
- 服务器请求:这是一个表示服务器端 HTTP 请求的接口,由 HandlerFunction 进行处理。它可以访问请求的头部和主体。方法 save(ServerRequest)在使用 request.body()时可以获取 HTTP 主体。在后台,它使用所有必要的 HTTP 消息转换器来获取正确的类类型(在此情况下为 User 类)。
- 该接口表示由 HandlerFunction 返回的服务器端 HTTP 响应。与本章前面讨论的 ResponseEntity 类似,它可以通过多个实用方法构建完整的响应。
- 绑定结果:在保存方法中,我们使用 validate(user); 这将返回一个 BindingResult 接口,该接口会为正在验证的类中每个设置的约束调用验证器。请查看 prepareErrorResponse;如果验证过程中捕获了任何错误,我们可以遍历这些错误,并使用 ServerResponse 准备相应的消息。
用户应用测试
现在是测试我们的用户应用程序的时候了。请在 src/main/test/java/com/apress/users 文件夹中添加 UsersHttpRequestTests 类。请参见第 3-22 号列表。
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 java.util.Map;
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).isNotNull();
assertThat(response).isNotEmpty();
}
@Test
public void userEndPointPostNewUserShouldReturnUser() throws Exception {
User user = User.builder().email("dummy@email.com").name("Dummy").password("aw2s0m3R!").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");
}
@Test
public void userEndPointPostNewUserShouldReturnBadUserResponse() throws Exception {
User user = User.builder().email("dummy@email.com").name("Dummy").password("aw2s0m").build();
Map response = this.restTemplate.postForObject(BASE_URL + port + USERS_PATH,user, Map.class);
assertThat(response).isNotNull();
assertThat(response.get("errors")).isNotNull();
Map errors = (Map) response.get("errors");
assertThat(errors.get("password")).isNotNull();
assertThat(errors.get("password")).isEqualTo("Password must be at least 8 characters long and contain at least one number, one uppercase, one lowercase and one special character");
}
}
列表 3-22 源代码:src/test/java/com/apress/users/UsersHttpRequestTests.java
UsersHttpRequestTests 类包含以下内容:
- @SpringBootTest。这个注解用于设置我们的集成测试,使你能够与一个正在运行的 Web 应用程序进行交互。正如你所看到的,它使用了一个参数 webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,这指示 Spring Boot 启动应用程序,并在随机选择的可用端口上监听一个真实的 Web 服务器。
- 这个注解用于将值注入到 Spring 管理的 bean 的字段、方法参数或构造函数参数中,提供了一种方便的方式来外部化配置值,并直接在需要的地方进行注入。
- @Autowired:这个注解用于启用自动依赖注入。它指示 Spring 自动解析并将协作的 bean 注入到其他 bean 中,从而实现自动连接。
- TestRestTemplate:这个类是标准 RestTemplate 的一个便捷替代方案,专为 RESTful 网络服务的集成测试而设计。它提供了多种功能,使您能够在受控环境中更轻松地测试应用程序的端点。它会自动配置一个 HTTP 客户端(Apache HttpClient 或 OkHttp)用于测试,省去了手动设置的麻烦。与 RestTemplate 不同,后者在遇到 4xx 和 5xx 状态码时会抛出异常,而 TestRestTemplate 则通过返回 ResponseEntity 对象优雅地处理这些错误。这使您能够轻松检查响应状态并在测试代码中处理错误。它还提供了方便的方法来处理基本身份验证(withBasicAuth)和 OAuth2 身份验证(withOAuth2Client)。
如果你查看最后一个方法(userEndPointPostNewUserShouldReturnBadUserResponse()),我们正在测试实际的响应。在这种情况下,不需要抛出异常,实际上我们收到了一个 JSON(基于 Map 接口。换句话说,Map 接口可以毫无问题地转换为 JSON),我们可以断言密码不符合约束。如果你想看到使用 REST 客户端(如 VS Code 或 IntelliJ 插件)的实际情况,可以在 src/http 文件夹中找到 users.http 文件。
恭喜你!你已经成功用 Spring Boot 创建了两个出色的网络应用!