Use Spring Validation to verify parameters

I don’t know how you write the parameter verification of the controller layer in your usual business development process? Is there such a direct judgment as below?

public String add(UserVO userVO) {<!-- -->

    if(userVO.getAge() == null){<!-- -->

        return "Age cannot be empty";

    }

    if(userVO.getAge() > 120){<!-- -->

        return "Age cannot exceed 120";

    }

    if(userVO.getName().isEmpty()){<!-- -->

        return "Username cannot be empty";

    }

    // Omit a bunch of parameter verification...

    return "OK";

}

The business code has not even started to be written yet, and a lot of judgments have been written just for parameter verification. Although there is nothing wrong with writing this way, it gives people the impression that it is not elegant and unprofessional.

In fact, the Spring framework has encapsulated a set of verification components for us: validation. It is characterized by simplicity, ease of use and high degree of freedom. Next, the class representative uses springboot-2.3.1.RELEASE to build a simple Web project and explains step by step how to elegantly perform parameter verification during the development process.

  1. Environment setup

Starting from springboot-2.3, the verification package has been independently transformed into a starter component, so the following dependencies need to be introduced:

<!--Verification component-->

<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-validation</artifactId>

</dependency>

<!--web components-->

<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-web</artifactId>

</dependency>

Versions before springboot-2.3 only need to introduce web dependencies.

2. Test your skills in a small way

Parameter verification is very simple. First, add verification rule annotations to the fields to be verified.

public class UserVO {<!-- -->

    @NotNull(message = "age cannot be empty")

    private Integer age;

}

Then just add @Validated and BindingResult for receiving error information in the controller method, so there is the first version:

public String add1(@Validated UserVO userVO, BindingResult result) {<!-- -->

    List<FieldError> fieldErrors = result.getFieldErrors();

    if(!fieldErrors.isEmpty()){<!-- -->

        return fieldErrors.get(0).getDefaultMessage();

    }

    return "OK";

}

Use the tool (postman) to request the interface. If the parameters do not comply with the rules, the corresponding message information will be returned:

age cannot be empty

There are many built-in verification annotations, listed as follows:

Annotation verification function

@AssertFalse must be false

@AssertTrue must be true

@DecimalMax is less than or equal to the given value

@DecimalMin is greater than or equal to the given value

@Digits can set the maximum number of integer digits and the maximum number of decimal digits

@Email Verify whether it conforms to the Email format

@Future must be in the future

@FutureOrPresent current or future time

@Max maximum value

@Min minimum value

@Negative negative number (excluding 0)

@NegativeOrZero negative number or 0

@NotBlank is not null and contains at least one non-blank character

@NotEmpty is not null and is not empty

@NotNull is not null

@Null is null

@Past must be the past time

@PastOrPresent must be in the past, including now

@PositiveOrZero positive number or 0

@Size checks the number of elements in the container
  1. Specification return value

After there are too many verification parameters, we hope to return all verification failure information at once to facilitate the interface caller to make adjustments. This requires a unified return format. A common one is to encapsulate a result class.

public class ResultInfo<T>{<!-- -->

    private Integer status;

    private String message;

    private T response;

    //Omit other code...

}

Transform the controller method, second version:

public ResultInfo add2(@Validated UserVO userVO, BindingResult result) {<!-- -->

    List<FieldError> fieldErrors = result.getFieldErrors();

    List<String> collect = fieldErrors.stream()

            .map(o -> o.getDefaultMessage())

            .collect(Collectors.toList());

    return new ResultInfo<>().success(400,"Request parameter error",collect);

}

When this method is requested, all error parameters are returned:

{<!-- -->

    "status": 400,

    "message": "Request parameter error",

    "response": [

        "Age must be between [1,120]",

        "The bg field has a maximum of 3 integer digits and a maximum of 1 decimal place",

        "name cannot be empty",

        "email format error"

    ]

}
  1. Global exception handling

If the processing of BindingResult information is written in each Controller method, it will still be very cumbersome to use. Verification exceptions can be handled uniformly through global exception handling.

When we write the @validated annotation and do not write BindingResult, Spring will throw an exception. Therefore, a global exception handling class can be written to uniformly handle such verification exceptions, thereby eliminating the need to repeatedly organize exception information code.

The global exception handling class only needs to mark @RestControllerAdvice on the class, and use the @ExceptionHandler annotation on the method that handles the corresponding exception to indicate which exception to handle.

@RestControllerAdvice

public class GlobalControllerAdvice {<!-- -->

    private static final String BAD_REQUEST_MSG = "Client request parameter error";

    // <1> Handle the exception thrown when the interface verification fails when calling the form data method

    @ExceptionHandler(BindException.class)

    public ResultInfo bindExceptionHandler(BindException e) {<!-- -->

        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();

        List<String> collect = fieldErrors.stream()

                .map(o -> o.getDefaultMessage())

                .collect(Collectors.toList());

        return new ResultInfo().success(HttpStatus.BAD_REQUEST.value(), BAD_REQUEST_MSG, collect);

    }

    // <2> Handle the exception thrown by the json request body calling interface verification failure

    @ExceptionHandler(MethodArgumentNotValidException.class)

    public ResultInfo methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {<!-- -->

        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();

        List<String> collect = fieldErrors.stream()

                .map(o -> o.getDefaultMessage())

                .collect(Collectors.toList());

        return new ResultInfo().success(HttpStatus.BAD_REQUEST.value(), BAD_REQUEST_MSG, collect);

    }

    // <3> Handle the exception thrown by a single parameter verification failure

    @ExceptionHandler(ConstraintViolationException.class)

    public ResultInfo constraintViolationExceptionHandler(ConstraintViolationException e) {<!-- -->

        Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();

        List<String> collect = constraintViolations.stream()

                .map(o -> o.getMessage())

                .collect(Collectors.toList());

        return new ResultInfo().success(HttpStatus.BAD_REQUEST.value(), BAD_REQUEST_MSG, collect);

    }



}

In fact, in the global exception handling class, we can write multiple exception handling methods. The class representative summarized three exceptions that may be caused during parameter verification:

Use the form data method to call the interface, and throw a BindException when verifying the exception.

Use the json request body to call the interface, and the verification exception throws MethodArgumentNotValidException

A single parameter validation exception throws ConstraintViolationException

Note: Single parameter verification requires adding verification annotations on the parameters and marking @Validated on the class.

The global exception handling class can add various exceptions that need to be handled, such as adding an exception handling for Exception.class. When all ExceptionHandlers cannot handle it, it will record the exception information and return a friendly prompt.

5. Group verification

If different verification rules need to be applied to the same parameter in different scenarios, group verification is required. For example: if a newly registered user has not yet given a name, we allow the name field to be empty, but we do not allow the name to be updated to empty characters.

There are three steps for group verification:

Define a grouping class (or interface)

Add the groups attribute to the verification annotation to specify the group

@Validated annotation of Controller method adds grouping class

public interface Update extends Default{<!-- -->

}

public class UserVO {<!-- -->

    @NotBlank(message = "name cannot be blank",groups = Update.class)

    private String name;

    //Omit other code...

}

@PostMapping("update")

public ResultInfo update(@Validated({<!-- -->Update.class}) UserVO userVO) {<!-- -->

    return new ResultInfo().success(userVO);

}

Careful students may have noticed that the custom Update group interface inherits the Default interface. Validation annotations (such as @NotBlank) and @validated belong to the Default.class group by default, which is explained in the javax.validation.groups.Default annotation

/**

* Default Jakarta Bean Validation group.

* <p>

* Unless a list of groups is explicitly defined:

* <ul>

* <li>constraints belong to the {@code Default} group</li>

* <li>validation applies to the {@code Default} group</li>

*</ul>

* Most structural constraints should belong to the default group.

*

* @author Emmanuel Bernard

*/

public interface Default {<!-- -->

}

When writing the Update group interface, if you inherit Default, the following two writing methods are equivalent:

@Validated({<!-- -->Update.class})

@Validated({<!-- -->Update.class,Default.class})

If you request the /update interface, you can see that not only the name field is verified, but also other fields that belong to the Default.class group by default are verified.

{<!-- -->

    "status": 400,

    "message": "Client request parameter error",

    "response": [

        "name cannot be empty",

        "age cannot be empty",

        "email cannot be empty"

    ]

}

If Update does not inherit Default, @Validated({Update.class}) will only verify the parameter fields belonging to the Update.class group. After modification, request the interface again and get the following results. You can see that other fields are not involved in the verification:

{<!-- -->

    "status": 400,

    "message": "Client request parameter error",

    "response": [

        "name cannot be empty"

    ]

}

6. Recursive verification

If an attribute of the OrderVO class is added to the UserVO class, and the attributes in OrderVO also need to be verified, recursive verification will be used. This can be achieved by adding the @Valid annotation to the corresponding attribute (the same applies to collections)

OrderVO class is as follows

public class OrderVO {<!-- -->

    @NotNull

    private Long id;

    @NotBlank(message = "itemName cannot be blank")

    private String itemName;

    //Omit other code...

}

Add an OrderVO type attribute to the UserVO class

public class UserVO {<!-- -->

    @NotBlank(message = "name cannot be blank",groups = Update.class)

    private String name;

//OrderVO that requires recursive verification

    @Valid

    private OrderVO orderVO;

    //Omit other code...

}

The call request verification is as follows:

  1. Custom verification

Spring’s validation provides us with so many features that it can almost meet most parameter verification scenarios in daily development. However, a good framework must be easy to expand. With the ability to expand, you can cope with more complex business scenarios. After all, in the development process, the only constant is the change itself.

Spring Validation allows users to customize verification. The implementation is very simple and consists of two steps:

Custom verification annotations

Write a validator class

The code is also very simple. You can understand it at a glance combined with the comments.

@Target({<!-- -->METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})

@Retention(RUNTIME)

@Documented

@Constraint(validatedBy = {<!-- -->HaveNoBlankValidator.class})//Indicate which class executes the verification logic

public @interface HaveNoBlank {<!-- -->

    //The default message returned when verification error occurs

    String message() default "The string cannot contain spaces";

    Class<?>[] groups() default {<!-- --> };

    Class<? extends Payload>[] payload() default {<!-- --> };

    /**

    * Used when specifying multiple annotations on the same element

    */

    @Target({<!-- --> METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })

    @Retention(RUNTIME)

    @Documented

    public @interface List {<!-- -->

        NotBlank[] value();

    }

}

public class HaveNoBlankValidator implements ConstraintValidator<HaveNoBlank, String> {<!-- -->

    @Override

    public boolean isValid(String value, ConstraintValidatorContext context) {<!-- -->

        // null does not check

        if (value == null) {<!-- -->

            return true;

        }

        if (value.contains(" ")) {<!-- -->

            // Verification failed

            return false;

        }

        //Verification successful

        return true;

    }

}