構建 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: <font>"3.1.0" info: title: API Customer version: "1.0" servers: - url: https://localhost:8080<i> 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 = <font>"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 = <font>""" { "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(<font>"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(<font>"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 <font>"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(<font>"\\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(<font>"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 驗證器提供了許多可使用的約束條件,如果需要,您還可以建立自己的自定義約束條件。