精通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 创建了两个出色的网络应用!