×
Community Blog A Trick to Get Rid of If-Else: Elegant Parameter Verification

A Trick to Get Rid of If-Else: Elegant Parameter Verification

This article focuses on discussing how to gracefully perform parameter verification.

By Weiwei

1. Preface

In daily development work, to ensure the robustness of the program, most methods require verification of input parameter data. The most direct approach is, of course, to manually verify the data within the corresponding method. However, this may lead to a significant amount of redundant and complex if-else statements in the code.

For example, you can use the following code to save user information:

@RestController
public class TestController {

    private static final Pattern ID_CARD_PATTERN = Pattern.compile("(^\\d{15}$)|(^\\d{18}$)|(^\\d{17}(\\d|X|x)$)");
    private static final Pattern MOBILE_PHONE_PATTERN = Pattern.compile("^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\\d{8}$");

    @RequestMapping(value = "/api/saveUser", method = RequestMethod.POST)
    public Result<Boolean> saveUser(UserRequest user) {
        if (StringUtils.isBlank(user.getUserName())) {
            throw new IllegalArgumentException("User name cannot be empty");
        }
        if (Objects.isNull(user.getGender())) {
            throw new IllegalArgumentException("Gender cannot be empty");
        }
        if (Objects.isNull(GenderType.getGenderType(user.getGender()))) {
            throw new IllegalArgumentException("Gender error");
        }
        if (Objects.isNull(user.getAge())) {
            throw new IllegalArgumentException("Age cannot be empty");
        }
        if (user.getAge() < 0 || user.getAge() > 150) {
            throw new IllegalArgumentException("Age must be 0-150");
        }
        if (StringUtils.isBlank(user.getIdCard())) {
            throw new IllegalArgumentException("ID number cannot be empty");
        }
        if (!ID_CARD_PATTERN.matcher(user.getIdCard()).find()) {
            throw new IllegalArgumentException("ID number format error");
        }
        if (StringUtils.isBlank(user.getMobilePhone())) {
            throw new IllegalArgumentException("The phone number cannot be empty");
        }
        if (!MOBILE_PHONE_PATTERN.matcher(user.getIdCard()).find()) {
            throw new IllegalArgumentException("The format of the phone number is invalid");
        }
        // Other business codes are omitted.
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

The above method has many if-elses because there are many parameters in the request objects. Of course, it's okay to write like this, but it has many problems:

(1) Poor extensibility: If new parameters are added to the subsequent UserRequest, additional verification codes must be incorporated into the method implementation, leading to a mixing of parameter verification and business logic.

(2) Poor readability: Excessive parameter verification leads to lengthy code, violating the Alibaba code protocol.

(3) Difficulty to reuse: Other methods, like updating user information, may require similar parameter verification. Manual verification would result in a significant amount of repetitive code.

So, how do we elegantly verify parameters?

2. Practice

Spring Boot has its Spring validation, so why not use it?

Java proposed the Bean Validation (JSR)[1] specification as early as 2009, which defines a series of verification annotations, such as @NotEmpty and @NotNull, which support the verification of fields through annotations to avoid coupling redundant verification logic in business logic.

However, these annotations cannot do the verification. They are just used to remind developers. Other configurations are required to verify the parameters. Without further ado, let's first look at the usage.

2.1 Controller Method Parameter Verification

2.1.1 Effect Example

Spring provides a corresponding Bean Validation implementation: Java Bean Validation[2], and adds automatic verification in Spring MVC. By default, the Validator will be used for logic verification for method parameters decorated with @Valid/@Validated.

For example:

Firstly, configure the verification annotation on the corresponding element of the input parameter in the method:

@Data
public class UserRequest {
    @NotBlank(message = "The user ID cannot be empty")
    private String userId;
  
    @NotBlank(message = "The phone number cannot be empty")
    @Pattern(regexp = "^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\\d{8}$", message = "电话号码格式错误")
    private String mobilePhone;

    @Min(message = "Age must be greater than 0", value = 0)
    @Max(message = "Age cannot exceed 150", value = 150)
    private Integer age;


    @NotNull(message = "The user details cannot be empty")
    @Valid
    private UserDetail userDetail;
    
    //Other parameters are omitted.
}

Secondly, use @ Valid/@Validated annotation in the corresponding controller method to enable the data verification:

@RestController
public class TestController {

    @RequestMapping(value = "/api/saveUser", method = RequestMethod.POST)
    public ResponseEntity<BaseResult> saveUser(@Validated @RequestBody UserRequest user) {
        // Other business codes are omitted.
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

If the data verification is passed, the business logic in the method continues to execute. Otherwise, a MethodArgumentNotValidException exception is thrown. By default, Spring sends the exception and its information in the error code 400. The following sample result is returned:

{
  "timestamp": 1666777674977,
  "status": 400,
  "error": "Bad Request",
  "exception": "org.springframework.web.bind.MethodArgumentNotValidException",
  "errors": [
    {
      "codes": [
        "NotBlank.UserRequest.mobilePhone",
        "NotBlank.mobilePhone",
        "NotBlank.java.lang.String",
        "NotBlank"
      ],
      "arguments": [
        {
          "codes": [
            "UserRequest.mobilePhone",
            "mobilePhone"
          ],
          "arguments": null,
          "defaultMessage": "mobilePhone",
          "code": "mobilePhone"
        }
      ],
      "defaultMessage": "The phone number cannot be empty.",
      "objectName": "UserRequest",
      "field": "mobilePhone",
      "rejectedValue": null,
      "bindingFailure": false,
      "code": "NotBlank"
    }
  ],
  "message": "Validation failed for object='UserRequest'. Error count: 1",
  "path": "/api/saveUser"
}

However, the returned exception result is not in the required format, so a global exception captor intercepts the exception to get a perfect exception result:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public BaseResult handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        FieldError fieldError = e.getBindingResult().getFieldErrors().get(0);
        return new BaseResult(CommonResultCode.ILLEGAL_PARAMETERS.getErrorCode(),
                "In the input parameter" + fieldError.getField() + fieldError.getDefaultMessage(), EagleEye.getTraceId());
    }

}

If the data verification fails after the above captor is set, the returned result is:

{
  "success": false,
  "errorCode": "ILLEGAL_PARAMETERS",
  "errorMessage": "The mobilePhone in the input parameter cannot be empty",
  "traceId": "1ef9749316674663696111017d73c9",
  "extInfo": {}
}

With Spring and constraint annotations, method parameter verification is done simply and elegantly.

Moreover, if more parameters are added to the input parameter objects in the future, only an annotation needs to be added instead of changing the business code. That would be easy peasy!

2.1.2 @Valid and @Validated

  • @Valid [3] annotation is defined by Bean Validation. It can be added to ordinary methods, construction methods, method parameters, method returns, and member variables to indicate that they need constraint verification.
  • @Validated [4] annotation is defined by Spring Validation. It can be added to classes, method parameters, and common methods to indicate that they need constraint verification.

The difference between the two is that @Validated has the value attribute and supports group verification, that is, different verification mechanisms are used according to different groups. @Valid can be added to member variables to support nested verification. Therefore, the recommended usage method is to use @Validated annotation when starting the verification (at the Controller layer) and use @Valid annotation when nesting the verification, so that the group verification and nested verification functions can be used at the same time.

2.1.3 Group Verification

However, for the same parameter, different scenarios may require different verification. Therefore, you can use group verification.

For example, when you create a user, the userId value is empty. However, when you update a user, the userId value cannot be empty. For example:

@Data
public class UserRequest {
    @NotBlank(message = "The user ID cannot be empty", groups = {UpdateUser.class})
    private String userId;
  
    @NotBlank(message = "The phone number cannot be empty", groups = {UpdateUser.class, InsertUser.class})
    @Pattern(regexp = "^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\\d{8}$", message = "The format of the phone number is invalid")
    private String mobilePhone;

    @Min(message = "Age must be greater than 0", value = 0, groups = {UpdateUser.class, InsertUser.class})
    @Max(message = "Age cannot exceed 150", value = 150, groups = {UpdateUser.class, InsertUser.class})
    private Integer age;


    @NotNull(message = "The user details cannot be empty", groups = {UpdateUser.class, InsertUser.class})
    @Valid
    private UserDetail userDetail;
    
    // Other parameters are omitted.
}
@RestController
public class TestController {

    @RequestMapping(value = "/api/saveUser", method = RequestMethod.POST)
    public ResponseEntity<BaseResult> saveUser(@Validated(value = InsertUser.class) @RequestBody UserRequest user) {
        // Other business codes are omitted.
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

2.1.4 Custom Verification Annotations

Also, if the existing basic verification annotations do not meet the verification requirements, you can use custom annotations [5]. They consist of two kinds:

  • Annotations annotated by @Constraint.
  • Validator which implements javax.validation.ConstraintValidator.

The two are linked together by @Constraint.

Assume that a user's gender is enumerated and you need to check whether the user's gender falls within this range. For example:

public enum GenderEnum implements BasicEnum {
    male("male", "男"),
    female("female", "女");

    private String code;

    private String desc;
    // Other settings are omitted.
}

Firstly, customize the constraint annotation InEnum. You can refer to the definition of NotNull:

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = InEnumValidator.class)
public @interface InEnum {
    /**
     * Enumeration
     *
     * @return
     */
    Class<? extends BasicEnum> enumType();

    String message() default "Enumeration mismatch";

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

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

Secondly, customize the constraint validator InEnumValidator. If the verification is passed, true is returned. Otherwise, false is returned:

public class InEnumValidator implements ConstraintValidator<InEnum, Object> {
    private Class<? extends BasicEnum> enumType;

    @Override
    public void initialize(InEnum inEnum) {
        enumType = inEnum.enumType();
    }

    @Override
    public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
        if (object == null) {
            return true;
        }

        if (enumType == null || !enumType.isEnum()) {
            return false;
        }

        for (BasicEnum basicEnum : enumType.getEnumConstants()) {
            if (basicEnum.getCode().equals(object)) {
                return true;
            }
        }
        return false;
    }
}

Thirdly, add @InEnum verification annotation to the parameter:

@Data
public class UserRequest {
    @InEnum(enumType = GenderEnum.class, message = "The user's gender is not in the enumeration range")
    private String gender;
    
    // Other parameters are omitted.
}

If the verification fails, the following result is returned:

{
  "success": false,
  "errorCode": "ILLEGAL_PARAMETERS",
  "errorMessage": "The gender of the user in the input parameter is not in the enumeration range",
  "traceId": "1ef9749316674663696111017d73c9",
  "extInfo": {}
}

2.1.5 Verification Principle

(1) MethodValidationPostProcessor: the core processor provided by Spring to implement method-based JSR verification, which allows constraints to be applied to method input parameters and return values. The logic of the verification is in the aspect MethodValidationInterceptor.

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
    implements InitializingBean {

  private Class<? extends Annotation> validatedAnnotationType = Validated.class;

    // This is javax.validation.Validator.
  private Validator validator;


  // You can specify custom annotations.
  public void setValidatedAnnotationType(Class<? extends Annotation> validatedAnnotationType) {
    Assert.notNull(validatedAnnotationType, "'validatedAnnotationType' must not be null");
    this.validatedAnnotationType = validatedAnnotationType;
  }

    // By default, a LocalValidatorFactoryBean is used for verification. You can also specify a custom validator.
  public void setValidator(Validator validator) {
    // Unwrap to the native Validator with forExecutables support
    if (validator instanceof LocalValidatorFactoryBean) {
      this.validator = ((LocalValidatorFactoryBean) validator).getValidator();
    }
    else if (validator instanceof SpringValidatorAdapter) {
      this.validator = validator.unwrap(Validator.class);
    }
    else {
      this.validator = validator;
    }
  }

    // You can specify a custom ValidatorFactory.
  public void setValidatorFactory(ValidatorFactory validatorFactory) {
    this.validator = validatorFactory.getValidator();
  }


  @Override
  public void afterPropertiesSet() {
    Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
    this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
  }

    // Use the aspect MethodValidationInterceptor.
  protected Advice createMethodValidationAdvice(Validator validator) {
    return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
  }

}

(2) MethodValidationInterceptor: data verification for processing methods

public class MethodValidationInterceptor implements MethodInterceptor {

    //xxx

  @Override
  @SuppressWarnings("unchecked")
  public Object invoke(MethodInvocation invocation) throws Throwable {
    // If it is the FactoryBean.getObject() method, do not verify it.
    if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
      return invocation.proceed();
    }

        // Obtain the group information in the @Validated annotation on the method.
    Class<?>[] groups = determineValidationGroups(invocation);

    if (forExecutablesMethod != null) {
      // Standard Bean Validation 1.1 API
      Object execVal;
      try {
        execVal = ReflectionUtils.invokeMethod(forExecutablesMethod, this.validator);
      }
      catch (AbstractMethodError err) {
        Validator nativeValidator = this.validator.unwrap(Validator.class);
        execVal = ReflectionUtils.invokeMethod(forExecutablesMethod, nativeValidator);
        this.validator = nativeValidator;
      }

      Method methodToValidate = invocation.getMethod();
      Set<ConstraintViolation<?>> result;
            
            // Verify the parameters of the method. The verification result is stored in the result. If the verification fails, the result is not empty. 
            // An exception ConstraintViolationException is thrown here.
      try {
        result = (Set<ConstraintViolation<?>>) ReflectionUtils.invokeMethod(validateParametersMethod,
            execVal, invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
      }
      catch (IllegalArgumentException ex) {
        // Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
        // Let's try to find the bridged method on the implementation class...
        methodToValidate = BridgeMethodResolver.findBridgedMethod(
            ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()));
        result = (Set<ConstraintViolation<?>>) ReflectionUtils.invokeMethod(validateParametersMethod,
            execVal, invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
      }
      if (!result.isEmpty()) {
        throw new ConstraintViolationException(result);
      }

            // Call the target method.
      Object returnValue = invocation.proceed();

            // Verify the return value of the method execution. The verification result is stored in the result. If the verification fails, the result is not empty. 
            // An exception ConstraintViolationException is thrown here.
      result = (Set<ConstraintViolation<?>>) ReflectionUtils.invokeMethod(validateReturnValueMethod,
          execVal, invocation.getThis(), methodToValidate, returnValue, groups);
      if (!result.isEmpty()) {
        throw new ConstraintViolationException(result);
      }
      return returnValue;
    }

    else {
      // Hibernate Validator 4.3's native API
      return HibernateValidatorDelegate.invokeWithinValidation(invocation, this.validator, groups);
    }
  }

  //xxx

}

(3) LocalValidatorFactoryBean: It is finally used to perform the verification, and it is also the default validator for Spring MVC. By default, the LocalValidatorFactoryBean configures a SpringConstraintValidatorFactory instance. If there is a specified ConstraintValidatorFactory, the specified one will be used. Therefore, when the custom constraint annotation is encountered, the associated Validator specified by @Constraint will be automatically instantiated, thus completing the data verification process.

1

(4) SpringValidatorAdapter: the adaptation of javax.validation.Validator to Spring's validator, through which it can be connected to Bean Validation to complete verification.

public class SpringValidatorAdapter implements SmartValidator, javax.validation.Validator {

  // The three member attributes necessary for the constraint annotation.
  private static final Set<String> internalAnnotationAttributes = new HashSet<String>(3);

  static {
    // The error message indicating that the constraint is hit.
    internalAnnotationAttributes.add("message");
    // Use group verification.
    internalAnnotationAttributes.add("groups");
    // Load.
    internalAnnotationAttributes.add("payload");
  }

  private javax.validation.Validator targetValidator;


  // Create an adapter.
  public SpringValidatorAdapter(javax.validation.Validator targetValidator) {
    Assert.notNull(targetValidator, "Target Validator must not be null");
    this.targetValidator = targetValidator;
  }

  SpringValidatorAdapter() {
  }

  void setTargetValidator(javax.validation.Validator targetValidator) {
    this.targetValidator = targetValidator;
  }

  @Override
  public boolean supports(Class<?> clazz) {
    return (this.targetValidator != null);
  }

  // Call the validator to verify the target object and put the result of verification—ConstraintViolations error message—in the BindingResult of Errors.
  // In short, it is to convert the failure information object to the verification failure information object inside Spring.
  @Override
  public void validate(Object target, Errors errors) {
    if (this.targetValidator != null) {
      processConstraintViolations(this.targetValidator.validate(target), errors);
    }
  }
  // Other settings are omitted.
}

2.2 Service Method Parameter Verification

2.2.1 Effect Example

In most cases, it is necessary to verify the parameters of the Service layer interface, so how to configure it?

When verifying the constraints of a method input parameter, if the method is of @Override parent class /interface, then this input parameter constraint can only be written on the parent class /interface.

(The reason why it can only be written at the interface is related to the implementation product of Bean Validation. Refer to this class: OverridingMethodMustNotAlterParameterConstraints)

If the input parameters are tiled:

First, add annotation constraints to the method input parameters of the parent class /interface, and then decorate our implementation class with @Validated.

public interface SchedulerServiceClient {
    /**
     * Obtain all scheduled tasks in different environments of the application
     * @param appName The name of the application
     * @param env The environment
     * @param status The status of the task
     * @param userId The ID of the user
     * @return
     */
    List<JobConfigInfo> queryJobList(@NotBlank(message = "The application name cannot be empty")String appName,
                                     @NotBlank(message = "The environment cannot be empty")String env,
                                     Integer status,
                                     @NotBlank(message = "The user ID cannot be empty")String userId);
}
@Component
@Slf4j(topic = "BIZ-SERVICE")
@HSFProvider(serviceInterface = SchedulerServiceClient.class, clientTimeout = 3000)
@Validated
public class SchedulerServiceClientImpl implements SchedulerServiceClient {

    @Override
    @Log(type = LogSourceEnum.SERVICE)
    public List<JobConfigInfo> queryJobList(String appName, String env, Integer status, String userId) {
        // The business code is omitted.
    }

If the data verification is passed, the business logic in the method continues to execute. Otherwise, a ConstraintViolationException exception is thrown.

If the input parameters are objects:

In actual development, in most cases, the input parameters of our method are objects, not just tiled parameters.

First, we need to add annotation constraints such as @NotNull to the method input class, then add @Valid to the method input parameter of the parent class /interface (to facilitate nested verification), and finally decorate our implementation class with @Validated.

public interface ProcessControlDingService {

    /**
    * Send DingTalk notifications
    * @param request
    * @return
    **/
    void createDingNotification(@Valid CreateDingNotificationRequest request);

}
public interface ProcessControlDingService {

    /**
    * Send DingTalk notifications
    * @param request
    * @return
    **/
    void createDingNotification(@Valid CreateDingNotificationRequest request);

}
@Component
@HSFProvider(serviceInterface = ProcessControlDingService.class, clientTimeout = 5000)
@Validated
public class ProcessControlDingServiceImpl implements ProcessControlDingService {

    private static final Logger LOGGER = LoggerFactory.getLogger(LoggerNames.BIZ_SERVICE);

    @Autowired
    private ProcessControlTaskService processControlTaskService;

    @Override
    @Log(type = LogSourceEnum.SERVICE)
    public void createDingNotification(CreateDingNotificationRequest request) {
        // The business code is omitted.
    }
}

If you need to format the error result, you can have another exception handling aspect to get a perfect exception result.

2.2.2 A Simpler Way - FastValidatorUtils

// Return the verification results of all constraint violations in the bean.
Set<ConstraintViolation<T>> violationSet = FastValidatorUtils.validate(bean);

For more information, see FastValidator Principles and Best Practices [6].

Configuration example:

Customize the annotation @RequestValid and the corresponding aspect RequestValidAspect. Annotate on a specific method. For the annotated method, all input parameters are scanned and verified in AOP.

@Aspect
@Component
@Slf4j(topic = "BIZ-SERVICE")
public class RequestValidAspect {

    @Around("@annotation(requestValid)")
    public Object around(ProceedingJoinPoint joinPoint, RequestValid requestValid) throws Throwable {
        // Obtain the input parameters, input parameter type, and output parameter type of the method.
        Object[] args = joinPoint.getArgs();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Class<?>[] parameterTypes = signature.getParameterTypes();
        Class<?> returnType = signature.getMethod().getReturnType();

        // Verify each input parameter before the call.
        for (Object arg : args) {
            if (arg == null) {
                continue;
            }
            try {
                if (arg instanceof List && ((List<?>) arg).size() > 0) {
                    for (int j = 0; j < ((List<?>) arg).size(); j++) {
                        validate(((List<?>) arg).get(j));
                    }
                } else {
                    validate(arg);
                }
            } catch (AlscBoltBizValidateException e) {
                // Handle exceptions and return them in the required format.
            }
        }

        // Verify whether the input parameter constraints are included after the method is run.
        Object result;
        try {
            result = joinPoint.proceed();
        } catch (ConstraintViolationException e) {
            // Handle exceptions and return them in the required format.
        }
        return result;
    }

    public static <T> void validate(T t) {
        try {
            Set<ConstraintViolation<T>> res = FastValidatorUtils.validate(t);
            if (!res.isEmpty()) {
                ConstraintViolation<T> constraintViolation = res.iterator().next();
                FastValidatorHelper.throwFastValidateException(constraintViolation);
            }

            LoggerUtil.info(log, "validator,verification successful");
        } catch (FastValidatorException e) {
            LoggerUtil.error(log, "validator,a verification error,request=[{}],result=[{}]", JSON.toJSONString(t),
                    e.getMessage());
            throw new AlscBoltBizValidateException(CommonResultCode.ILLEGAL_PARAMETERS, e.getMessage());
        }
    }
}

Finally, add the custom annotation @RequestValid to the method of the parent class /interface.

    @Override
    @RequestValid
    public boolean saveCheckResult(List<CheckResultInfoModel> models) {
        //xxxx
    }

3. Summary

Stop using if-elses all the time. Let's work with codes more elegantly!

Please share with us if you have a more elegant way to deal with it!

References

[1] https://beanvalidation.org/specification/
[2] https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#validation-beanvalidation
[3] https://docs.oracle.com/javaee/7/api/javax/validation/Valid.html
[4] https://github.com/spring-projects/spring-framework/blob/master/spring-context/src/main/java/org/springframework/validation/annotation/Validated.java
[5] https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#validation-beanvalidation-spring-constraints

Disclaimer: The views expressed herein are for reference only and don't necessarily represent the official views of Alibaba Cloud.

0 1 0
Share on

Alibaba Cloud Community

876 posts | 198 followers

You may also like

Comments