Spring Boot中實現輸入引數驗證教程

banq發表於2024-06-10

構建 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 驗證器提供了許多可使用的約束條件,如果需要,您還可以建立自己的自定義約束條件。

相關文章