构建 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 验证器提供了许多可使用的约束条件,如果需要,您还可以创建自己的自定义约束条件。