当前位置:Java -> 使用Spring Boot 进行验证

使用Spring Boot 进行验证

在构建Spring Boot应用程序时,您需要验证Web请求的输入,服务的输入等。在本博客中,您将学习如何为Spring Boot应用程序添加验证。享受吧!

介绍

为了验证输入,将使用Jakarta Bean Validation规范。Jakarta Bean Validation规范是一个Java规范,允许您通过注解验证输入、模型等。规范的实现之一是Hibernate Validator。使用Hibernate Validator并不意味着您也会使用Hibernate ORM(对象关系映射)。它只是Hibernate旗下的另一个项目。在Spring Boot中,您可以添加spring-boot-starter-validation依赖项,该依赖项使用Hibernate Validator进行验证。

在本博客的其余部分,您将创建一个基本的Spring Boot应用程序,并在控制器和服务中添加验证。

本博客使用的资源可以在GitHub上找到:GitHub

先决条件

本博客的先决条件包括:

  • 基本的Java知识,使用Java 21;
  • 基本的Spring Boot知识;
  • 对OpenAPI的基本了解,如果您对OpenAPI没有任何经验,建议先阅读之前的博客。

基本应用程序

在本博客中要构建的项目是一个基本的Spring Boot项目。该领域是一个具有id、firstName和lastName的Customer。

public class Customer {
    private Long customerId;
    private String firstName;
    private String lastName;
    ...
}


通过Rest API,可以创建和检索客户。为了使API规范和源代码保持一致,您将使用openapi-generator-maven-plugin。首先,编写OpenAPI规范,插件将根据规范为您生成源代码。OpenAPI规范包含两个端点,一个用于创建客户(POST),一个用于检索客户(GET)。OpenAPI规范包含一些约束:

  • 在POST请求中使用的Customer模式限制了firstName和lastName的字符数。至少需要提供一个字符,且允许最多20个字符。
  • GET请求需要customerId作为输入参数。
openapi: "3.1.0"
info:
  title: API Customer
  version: "1.0"
servers:
  - url: https://localhost:8080
tags:
  - name: Customer
    description: Customer specific data.
paths:
  /customer:
    post:
      tags:
        - Customer
      summary: Create Customer
      operationId: createCustomer
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Customer'
      responses:
        '200':
          description: OK
          content:
            'application/json':
              schema:
                $ref: '#/components/schemas/CustomerFullData'
  /customer/{customerId}:
    get:
      tags:
        - Customer
      summary: Retrieve Customer
      operationId: getCustomer
      parameters:
        - name: customerId
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: OK
          content:
            'application/json':
              schema:
                $ref: '#/components/schemas/CustomerFullData'
        '404':
          description: NOT FOUND
components:
  schemas:
    Customer:
      type: object
      properties:
        firstName:
          type: string
          description: First name of the customer
          minLength: 1
          maxLength: 20
        lastName:
          type: string
          description: Last name of the customer
          minLength: 1
          maxLength: 20
    CustomerFullData:
      allOf:
        - $ref: '#/components/schemas/Customer'
        - type: object
          properties:
            customerId:
              type: integer
              description: The ID of the customer
              format: int64
      description: Full data of the customer.


生成的代码会生成一个接口,CustomerController实现该接口。

  • createCustomer将API模型映射到领域模型并调用CustomerService;
  • getCustomer调用CustomerService并将领域模型映射到API模型。
@RestController
class CustomerController implements CustomerApi {
 
    private final CustomerService customerService;
 
    CustomerController(CustomerService customerService) {
        this.customerService = customerService;
    }
 
    @Override
    public ResponseEntity<CustomerFullData> createCustomer(Customer apiCustomer) {
        com.mydeveloperplanet.myvalidationplanet.domain.Customer customer = new com.mydeveloperplanet.myvalidationplanet.domain.Customer();
        customer.setFirstName(apiCustomer.getFirstName());
        customer.setLastName(apiCustomer.getLastName());
 
        return ResponseEntity.ok(domainToApi(customerService.createCustomer(customer)));
    }
 
    @Override
    public ResponseEntity<CustomerFullData> getCustomer(Long customerId) {
        com.mydeveloperplanet.myvalidationplanet.domain.Customer customer = customerService.getCustomer(customerId);
        return ResponseEntity.ok(domainToApi(customer));
    }
 
    private CustomerFullData domainToApi(com.mydeveloperplanet.myvalidationplanet.domain.Customer customer) {
        CustomerFullData cfd = new CustomerFullData();
        cfd.setCustomerId(customer.getCustomerId());
        cfd.setFirstName(customer.getFirstName());
        cfd.setLastName(customer.getLastName());
        return cfd;
    }
 
}


CustomerService将客户放入Map中,不使用数据库或其他内容。

@Service
class CustomerService {
 
    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;
 
    Customer createCustomer(Customer customer) {
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customer;
    }
 
    Customer getCustomer(Long customerId) {
        if (customers.containsKey(customerId)) {
            return customers.get(customerId);
        } else {
            return null;
        }
    }
}


构建应用程序并运行测试。

$ mvn clean verify


控制器验证

现在,控制器验证非常简单。只需将spring-boot-starter-validation依赖项添加到您的pom.xml中即可。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>


仔细查看生成的CustomerApi接口,该接口位于target/generated-sources/openapi/src/main/java/com/mydeveloperplanet/myvalidationplanet/api/。

  • 在类级别上,接口使用@Validated进行注释。这将告诉Spring验证方法的参数。
  • createCustomer方法签名包含@Valid注解,用于RequestBody。这将告诉Spring需要验证此参数。
  • getCustomer方法签名包含了对customerId的required属性。
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-03-30T09:31:30.793931181+01:00[Europe/Amsterdam]")
@Validated
@Tag(name = "Customer", description = "Customer specific data.")
public interface CustomerApi {
    ...
default ResponseEntity<CustomerFullData> createCustomer(
        @Parameter(name = "Customer", description = "") @Valid @RequestBody(required = false) Customer customer
    ) {
        ...
    }
    ...
    default ResponseEntity<CustomerFullData> getCustomer(
        @Parameter(name = "customerId", description = "", required = true, in = ParameterIn.PATH) @PathVariable("customerId") Long customerId
    ) {
        ...
    }
    ...
}


很酷的一点是,基于OpenAPI规范,生成的代码中会自动放置正确的注释。您无需执行任何特殊操作即可为您的Rest API添加验证。

让我们测试一下验证是否正常工作。仅测试控制器,服务被模拟,并且您将使用@WebMvcTest注解以将测试切片到最小。

  • 测试当使用具有太多字符的lastName创建客户时是否返回BadRequest;
  • 测试有效的客户;
  • 测试通过非整数customerId检索客户时是否返回BadRequest;
  • 测试检索有效的客户。
@WebMvcTest(controllers = CustomerController.class)
class CustomerControllerTest {
 
    @MockBean
    private CustomerService customerService;
 
    @Autowired
    private MockMvc mvc;
 
    @Test
    void whenCreateCustomerIsInvalid_thenReturnBadRequest() throws Exception {
        String body = """
                {
                  "firstName": "John",
                  "lastName": "John who has a very long last name"
                }
                """;
 
        mvc.perform(post("/customer")
                .contentType("application/json")
                .content(body))
                .andExpect(status().isBadRequest());
 
    }
 
    @Test
    void whenCreateCustomerIsValid_thenReturnOk() throws Exception {
        String body = """
                {
                  "firstName": "John",
                  "lastName": "Doe"
                }
                """;
        Customer customer = new Customer();
        customer.setCustomerId(1L);
        customer.setFirstName("John");
        customer.setLastName("Doe");
        when(customerService.createCustomer(any())).thenReturn(customer);
 
        mvc.perform(post("/customer")
                        .contentType("application/json")
                        .content(body))
                .andExpect(status().isOk())
                .andExpect(jsonPath("firstName", equalTo("John")))
                .andExpect(jsonPath("lastName", equalTo("Doe")))
                .andExpect(jsonPath("customerId", equalTo(1)));
 
    }
 
    @Test
    void whenGetCustomerIsInvalid_thenReturnBadRequest() throws Exception {
        mvc.perform(get("/customer/abc"))
                .andExpect(status().isBadRequest());
    }
 
    @Test
    void whenGetCustomerIsValid_thenReturnOk() throws Exception {
        Customer customer = new Customer();
        customer.setCustomerId(1L);
        customer.setFirstName("John");
        customer.setLastName("Doe");
        when(customerService.getCustomer(any())).thenReturn(customer);
 
        mvc.perform(get("/customer/1"))
                .andExpect(status().isOk());
    }
 
}


服务验证

向服务添加验证需要更多工作,但仍然非常简单。

将验证约束添加到模型中。验证约束的完整列表可以在Hibernate Validator文档中找到。

添加以下约束:

  • firstName不应为空,必须为1到20个字符;
  • lastName不应为空,必须为1到20个字符。
public class Customer {
    private Long customerId;
    @Size(min = 1, max = 20)
    @NotEmpty
    private String firstName;
    @Size(min = 1, max = 20)
    @NotEmpty
    private String lastName;
    ...
}


为了在服务中启用验证,有两种方法。一种方法是在服务中注入Validator并显式验证客户。这个Validator由Spring Boot提供。如果发现违例,你可以创建一个错误消息。

@Service
class CustomerService {
 
    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;
    private final Validator validator;
 
    CustomerService(Validator validator) {
        this.validator = validator;
    }
 
    Customer createCustomer(Customer customer) {
        Set<ConstraintViolation<Customer>> violations = validator.validate(customer);
 
        if (!violations.isEmpty()) {
            StringBuilder sb = new StringBuilder();
            for (ConstraintViolation<Customer> constraintViolation : violations) {
                sb.append(constraintViolation.getMessage());
            }
            throw new ConstraintViolationException("Error occurred: " + sb, violations);
        }
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customer;
    }
    ...
}


为了测试服务的验证,你需要添加@SpringBootTest注解。缺点是这将是一个代价高昂的测试,因为它会启动一个完整的Spring Boot应用程序。添加了两个测试:

  • 测试当使用具有过多字符的lastName创建客户时是否会抛出ConstraintViolationException
  • 测试一个有效的客户。
@SpringBootTest
class CustomerServiceTest {
 
    @Autowired
    private CustomerService customerService;
 
    @Test
    void whenCreateCustomerIsInvalid_thenThrowsException() {
        Customer customer = new Customer();
        customer.setFirstName("John");
        customer.setLastName("John who has a very long last name");
 
        assertThrows(ConstraintViolationException.class, () -> {
            customerService.createCustomer(customer);
        });
    }
 
    @Test
    void whenCreateCustomerIsValid_thenCustomerCreated() {
        Customer customer = new Customer();
        customer.setFirstName("John");
        customer.setLastName("Doe");
 
        Customer customerCreated = customerService.createCustomer(customer);
        assertNotNull(customerCreated.getCustomerId());
 
    }
}


添加验证的第二种方法与控制器使用的方法相同。你在类级别添加@Validated注解,以及在要验证的参数上添加@Valid注解。

@Service
@Validated
class CustomerValidatedService {
 
    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;
 
    Customer createCustomer(@Valid Customer customer) {
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customer;
    }
    ...
}


自定义验证器

当标准验证器不足以满足你的需求时,你可以创建自己的验证器。让我们为荷兰邮政编码创建一个自定义验证器。荷兰邮政编码由4位数字后跟两个字符组成。

首先,你需要创建自己的约束注解。在这种情况下,你只需指定要使用的约束违例消息。

@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = DutchZipcodeValidator.class)
@Documented
public @interface DutchZipcode {
 
    String message() default "A Dutch zipcode must contain 4 digits followed by two letters";
 
    Class<?>[] groups() default { };
 
    Class<? extends Payload>[] payload() default { };
 
}


注解由类DutchZipcodeValidator验证。这个类实现了ConstraintValidatorisValid方法用于实现检查并返回输入是否有效。在这种情况下,检查是通过正则表达式实现的。

public class DutchZipcodeValidator implements ConstraintValidator<DutchZipcode, String> {
 
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        Pattern pattern = Pattern.compile("\\b\\d{4}\\s?[a-zA-Z]{2}\\b");
        Matcher matcher = pattern.matcher(s);
        return matcher.matches();
    }
}


为了使用新的约束,你需要添加一个新的Address领域实体,其中包含一个street和一个zipcodezipcode使用@DutchZipcode进行注释。

可以通过基本单元测试来测试新的约束。

class ValidateDutchZipcodeTest {
 
    @Test
    void whenZipcodeIsValid_thenOk() {
        Address address = new Address("street", "2845AA");
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<Address>> violations = validator.validate(address);
        assertTrue(violations.isEmpty());
    }
 
    @Test
    void whenZipcodeIsInvalid_thenNotOk() {
        Address address = new Address("street", "2845");
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<Address>> violations = validator.validate(address);
        assertFalse(violations.isEmpty());
    }
 
}


结论

如果你仔细定义你的OpenAPI规范并通过openapi-generator-maven-plugin生成代码,向控制器添加验证几乎是免费的。通过有限的努力,你也可以向服务添加验证。Spring Boot使用的Hibernate Validator提供了相当多的约束供使用,如果必要,你也可以创建自己的自定义约束。

推荐阅读: 8.已知某个文件内包含一些电话号码,每个号码为 8 位数字,统计不同号码的个数。

本文链接: 使用Spring Boot 进行验证