Spring Boot中实现输入参数验证教程

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

为了验证输入,将使用 Jakarta Bean Validation 规范。Jakarta Bean Validation 规范是一种 Java 规范,允许您通过注释来验证输入、模型等。该规范的实现之一是Hibernate [url=https://hibernate.org/validator/]Validator[/url]。使用 Hibernate Validator 并不意味着您还将使用 Hibernate ORM(对象关系映射)。它只是 Hibernate 旗下的另一个项目。使用 Spring Boot,您可以添加spring-boot-starter-validation使用 Hibernate Validator 进行验证的依赖项。

在本博客的剩余部分,您将创建一个基本的 Spring Boot 应用程序并向控制器和服务添加验证。
本博客中使用的源代码可以在GitHub上找到。

先决条件是:

  • 基本的 Java 知识,使用 Java 21;
  • 基本的 Spring Boot 知识;
  • OpenAPI 基础知识。

构建的项目是一个基本的 Spring Boot 项目。领域需求是一个客户Customer :带有id、firstName和 lastName。

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

REST API设计
通过 Rest API,可以创建和检索客户。为了使 API 规范和源代码保持一致,您将使用openapi-generator-maven-plugin。

首先,您编写 OpenAPI 规范,插件将根据规范为您生成源代码。
OpenAPI 规范由两个端点组成:

  • 一个用于创建客户 (POST),
  • 一个用于检索客户 (GET)。

OpenAPI 规范包含一些约束:

  • POST 请求中使用的客户Customer数据结构:数据结构限制了名和姓的字符数。至少需要提供一个字符,最多允许 20 个字符。
  • GET 请求要求将 customerId 作为输入参数。

OpenAPI配置,插件将根据这个配置规范为您生成源代码。:

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 将customer 放入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

控制器验证
控制器验证现在相当简单了。只需在 pom 中添加 spring-boot-starter-validation 依赖关系即可。

<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 方法的签名包含针对 RequestBody 的 @Valid 注解。这将告诉 Spring 需要验证该参数。
  • getCustomer 方法签名包含 customerId 的必填属性。

@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 注解将测试缩至最小。

@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());
    }
 
}

  • 测试在使用过多字符的姓创建客户时是否会返回 BadRequest;
  • 测试有效客户;
  • 测试使用非整数的 customerId 检索客户时是否会返回 BadRequest;
  • 测试检索有效客户。

服务验证
为服务添加验证功能需要花费更多精力,但仍然相当容易。

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

添加的约束条件如下

  • 名(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 应用程序。添加了两个测试:

  • 测试当创建的客户姓氏字符数过多时是否会抛出 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 类验证。该类实现了一个 ConstraintValidator。isValid 方法用于执行检查并返回输入是否有效。在本例中,检查是通过正则表达式实现的。

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();
    }
}

为了使用新约束,您需要添加一个包含街道和邮编的新地址域实体。邮编用 @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 验证器提供了许多可使用的约束条件,如果需要,您还可以创建自己的自定义约束条件。