Springboot:更好地处理错误消息

Springboot: Better handling of error messages

我正在开发 API 和 Spring 引导,目前,我正在考虑如何以易于国际化的方式处理错误消息。我的目标如下:

  1. 在资源中定义错误消息 files/bundles
  2. 以声明的方式将约束注释与错误消息(例如,@Length)连接起来[=44​​=]
  3. 错误消息包含占位符,例如 {min},这些占位符将替换为注释中的相应值(如果可用),例如 @Length(min = 5, message = msg) 将导致类似 msg.replace("{min}", annotation.min()).replace("{max}", annotation.max()) 的结果。
  4. JSON 属性 路径也可用作占位符,并在发生验证错误时自动插入到错误消息中。
  5. 首选错误处理程序之外的解决方案,即当异常到达错误处理程序时,它们已经包含所需的错误消息。
  6. 来自资源包的错误消息会自动注册为 Java 中的常量。

目前,我自定义错误处理程序 class 的 methodArgumentNotValidHandler 以从 e.getBindingResult().getAllErrors() 读取 ObjectErrors,然后尝试提取它们的参数和错误代码来决定从我的资源包中选择哪个错误消息并相应地格式化它。我的代码的粗略草图如下所示:

输入:

@Data
@RequiredArgsConstructor
public class RequestBody {
  @NotNull
  @NotBlank(message = ErrorConstants.NOT_BLANK)
  @Length(min = 5, max = 255, message = ErrorConstants.LENGTH_MIN_MAX) // LENGTH_MIN_MAX = validation.length.min-max
  private String greeting;
}

错误处理程序:

@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorMessage methodArgumentNotValidHandler(MethodArgumentNotValidException e) {
  ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
  Object[] arguments = objectError.getArguments();
  String messageCode = objectError.getDefaultMessage(); // e.g., "validation.length.min-max" (key in resource bundle)
  ResourceBundle errMsgBundle = ResourceBundle.getBundle("errorMsg");
  String message;
  if (objectError.getCode().equals("Length")) {
    String messageTemplate = errMsgBundle.getString(messageCode);
    message = String.format(messageTemplate, arguments[2], arguments[1]);
  } else {
    message = "Bad input, but I cannot tell you the problem because the programmer hasn't handled this yet. Sorry :'(";
  }
  return new ErrorMessage(message);
}

不幸的是,我想这种方法是不可维护的。在错误处理程序中,我将得到一个巨大的 if-else 块,它必须探测几种不同的情况(错误代码、参数数量……)并相应地格式化错误消息。更改错误消息可能会导致必须更改代码(例如,参数的顺序)。每个 属性 键必须作为常量出现在 ErrorConstants 中,我认为这是不受欢迎的。此代码也不会查询错误 属性 的名称或路径,例如“name”。

因此,

  1. 是否有解决方案可以满足上述部分或全部需求?
  2. 我应该在哪个地方实现这个?
  3. 至少有更好的解决方案吗?
  4. SpringBoot 中是否有处理验证错误的方法或模式(我绝对不是第一个想到这个的人)?

如果我正确理解你的问题....

下面是更好的异常处理示例

Microsoft Graph API - 错误响应 - 示例:

{
      "Error": {
        "Code": "401",
        "Message": "Unauthorized",
        "Target": null,
        "InnerError": {
          "Code": "System.UnauthorizedAccessException",
          "Message": "Exception occured in AppAssertedAuth Handler",
          "Target": "MoveNext"
        }
      }

}

下面的代码可能会帮助您以类似的方式实现消息翻译[预计'accept-language']。

ErrorCodeEnum.java

public enum ErrorCodeEnum {

    USER_NOT_SIGNED_IN(HttpStatus.BAD_REQUEST, "UserNotSignedIn"),
    FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "FileSizeExceeded"),
    FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FileType"),
    SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ServiceUnavailable"),
    SERVICE_TEMPORARY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ServiceTemporaryUnavailable"),
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "OrderNotFound"),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized"),
    UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "UnexpectedError"),
    HEADER_MISSING(HttpStatus.BAD_REQUEST, "HeaderMissing"),

ErrorResponse.java

public class ErrorResponse {

    @JsonIgnore
    private String timestamp;
    private String error;
    private String message;
    private String code;
    @JsonIgnore
    private Integer status;
    @JsonIgnore
    private String path;
    @JsonAlias("error_description")
    private String errorDescription;
    //Create constructer as per your requirements 

GlobalExceptionHandler.java

    @ControllerAdvice
    public class GlobalExceptionHandler {
    
        private final MessageSource messageSource;
    
        public GlobalExceptionHandler(MessageSource messageSource) {
            this.messageSource = messageSource;
        }
        @ExceptionHandler(MissingRequestHeaderException.class)
        public ResponseEntity<ErrorResponse> handleMissingHeader(ResponseStatusException ex) {
            Sentry.captureException(ex);
            return ResponseEntity.badRequest().body(new ErrorResponse(ex.getMessage(), ex));
        }
        
        @ExceptionHandler(MissingServletRequestParameterException.class)
        public ResponseEntity<ErrorResponse> handleMissingParameterException(Exception ex) {
            log.error("Exception: Class {}|{}", ex.getClass().getCanonicalName(), ex.getMessage(), ex);
            return ResponseEntity.badRequest().body(new ErrorResponse(ex.getMessage(), ErrorCodeEnum.PARAMETER_MISSING));
        }
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ResponseEntity<ErrorResponse>  handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
            final var errors = e.getAllErrors();

            return ResponseEntity.badRequest().body(new ErrorMessage(/* error message based errors */);
        }

        @ExceptionHandler(CustomWebClientException.class)
        public ResponseEntity<ErrorResponse> handleSalesAssistantException(CustomWebClientException ex) {
            log.error(
                    "CustomWebClientException from WebClient - Status {}, Body {}",
                    ex.getErrorCodeEnum().getHttpStatus(),
                    ex);
            Sentry.captureException(ex);
            String errorMessage = getTranslatedMessage(ex.getErrorCodeEnum(), ex.getMessage());
    
            return new ResponseEntity<>(
                    new ErrorResponse(errorMessage, ex), ex.getErrorCodeEnum().getHttpStatus());
        }
    
        private String getTranslatedMessage(ErrorCodeEnum errorCodeEnum, String defaultMessage) {
            return messageSource.getMessage(
                    errorCodeEnum.getErrorCode(), null, defaultMessage, LocaleContextHolder.getLocale());
        }
  }

/*  class CustomWebClientException extends WebClientException {
        private final ErrorCodeEnum errorCodeEnum;
        private ErrorResponse errorResponse;
    
        public CustomWebClientException(ErrorCodeEnum errorCodeEnum, ErrorResponse errorResponse) {
            super(errorResponse.getMessage());
            this.errorCodeEnum = errorCodeEnum;
            this.errorResponse = errorResponse;
        }
    } */

也许你可以拥有

@ExceptionHandler(ConstraintViolationException.class)
protected ResponseEntity<Object> handleConstraintViolation(ConstraintViolationException e, WebRequest request){
   return Optional.ofNullable(e).map(ConstraintViolationException::getConstraintViolations).map(this::createException).orElseGet(this::generateGenericError);
}

你可以从中得到

private ErrorBody createException(FieldError fieldError) {
    return ErrorBody.builder()
            .code(fieldError.getCode())
            .message(fieldError.getDefaultMessage())
            .field(fieldError.getField())
            .value(fieldError.getRejectedValue())
            .build();
}

以便您可以使用

fieldError.getCode()

用于映射属性文件中的键值

我不太喜欢 javax.validation 注释。主要是因为 classes 用这些注释的对象不容易进行单元测试。

我建议在您的 @RestController 注释处理程序 class 中注册一个 org.springframework.validation.Validator 实现,如下所示:

@InitBinder
void initBinder(WebDataBinder binder) {
    if (binder.getTarget() == null) {
        return;
    }
    final var validator1 = // your validator1 instance

    //check if specific validator is eligible to validate request and its body
    if (validator1.supports(binget.getTarget().getClass()) {
        binder.setValidator(validator);
    }
}

在这样的注册之后,Spring 调用这样的验证器来匹配请求及其主体,如果验证器拒绝任何给定的对象字段,则抛出 MethodArgumentNotValidException。

在用@ControllerAdvice 注释的异常处理程序中(请记住它的范围仅适用于 http 请求),您可以按如下方式处理此类异常:

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
ErrorMessage handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    final var errors = e.getAllErrors()

    return new ErrorMessage(/* populate your error message based on given errors */);
}

虽然验证器实现可能看起来像这样:

@Override
public void validate(Object target, Errors errors) {
    final var credentials = (Credentials) target;

    //reject username field with given c000 code if it's null
    if (isNull(credentials.getUsername())) {
        errors.rejectValue("username", "c000", "username cannot be null");
        return;
    }

    if (credentials.getUsername().trim().isEmpty()) {
        errors.rejectValue("username", "c001", "username cannot be empty");
        return;
    }

    if (credentials.getUsername().length() > 256) {
        errors.rejectValue("username", "c002", "username cannot be longer than 256 characters");
        return;
    }
}

这种方案的优点是:

  • 您可以 unit-test 这样的验证器而无需设置应用程序的上下文 - 这很快
  • 当验证器拒绝 reuqest 主体时,它(实际上是您提供的 ;))提供错误代码和消息,因此您可以将其直接映射到您的 ErrorMessage 响应,而无需进一步挖掘。
  • 您将验证逻辑外化到专用 class - 对应于 SOLID 中的 S([S] 单一对象责任原则),这是一些开发人员所希望的

如果您有任何问题或疑虑 - 请尽管提问。

Spring Boot 支持使用 internationalization. To handle Validation errors using i18n we can use MessageSource

处理多种语言的错误信息

每当发生验证异常时,异常中设置的代码消息将传递给 MessageSource 实例以获取解释错误的本地化消息。当前 locale 与消息代码一起传递,以便 Spring Boot 可以插入本地化的最终消息以替换任何占位符。

示例:如果我们将 属性 定义为 @NotNull 并且用户发送包含 null 作为值的实例,则会抛出 MethodArgumentNotValidException 表示这种情况无效.我们可以处理异常并将消息、错误代码设置为 messages.properties 文件

中定义的消息
@ExceptionHandler(MethodArgumentNotValidException.class)
   public ResponseEntity<ErrorMessage> handleArgumentNotValidException(MethodArgumentNotValidException ex, Locale locale) {
      BindingResult result = ex.getBindingResult();
      List<String> errorMessages = result.getAllErrors()
              .stream()
              .map(err-> messageSource.getMessage(err, locale))
              .collect(Collectors.toList());
      return new ResponseEntity<>(new ErrorMessage(errorMessages), HttpStatus.BAD_REQUEST);
}

关于语言环境的信息,请参阅此 link。每当用户想要以特定语言显示消息时,Spring Boot 将搜索 messages.properties(默认)或 messages_<locale-name>.properties(区域设置)文件以获取适当的消息。
示例:message_es_MX.properties 所有 locale 个文件都可以存储在 resources 个文件夹中。

每当验证失败时,Spring Boot 会生成一个以注释名称(例如 NotNull)开头的代码,然后添加验证失败的实体(例如 objectname),最后添加 属性(例如问候)。

属性可以像NotNull.<object-name>.greeting
样本
message.properties

NotNull.obj.greeting=Please, provide a valid greeting message.
Size.obj.greeting=Greeting message  must contain between {2} and {1} characters.

注意:{2}和{1}是最小和最大长度的占位符
message_es_MX.properties

NotNull.obj.greeting=Por favor, proporcione un mensaje de saludo válido.
Size.obj.greeting=El mensaje de bienvenida debe contener entre {2} y {1} caracteres.

我们总是可以从请求中获取语言环境 header Accept-Language
示例:Accept-Language: en-USrequest.getLocale().

curl -X POST -H "Content-Type: application/json" -H "Accept-Language: es-MX" -d '{
  "description": "employee info"
}' http://localhost:8080/api/employee

经过一番挖掘,我发现我正在寻找的东西确实是 built-in 的确认,因为这是一个我希望每个想要好好表现自己的开发人员都会问的问题。事实上,这个问题已经 asked already(如果我能正确地表达我的要求,我本可以更早地找到它)。我只是要求通过资源包自定义我的本地化错误消息。

当我在包含自定义错误消息的资源文件夹中创建资源包并将其命名为“validation_errors.properties”时,我可以通过创建相应的 bean 使验证器使用这些消息:

@Bean
public Validator validatorFactory (MessageSource messageSource) {
    LocalValidatorFactoryBean validator =  new LocalValidatorFactoryBean();
    validator.setValidationMessageSource(messageSource);
    return validator;
}

@Bean
public MessageSource messageSource() {
    ReloadableResourceBundleMessageSource bean = new ReloadableResourceBundleMessageSource();
    bean.addBasenames("classpath:org.hibernate.validator.ValidationMessages", "classpath:validation_errors"); // validation_errors.properties is my resource bundle
    bean.setDefaultEncoding("UTF-8");
    return bean;
}

我的自定义验证器从 ReloadableResourceBundleMessageSource 的实例中检索验证消息,后者又从属性文件中检索它们。

属性文件包含验证注释的“消息”参数的限定路径作为键和值任意字符串,其中大括号中的字符串被验证注释中的参数替换并且 SpEL expressions 被评估.

javax.validation.constraints.NotNull.message = Not null please!
javax.validation.constraints.NotBlank.message = Not empty please!
org.hibernate.validator.constraints.Length.message = String length between {min} and {max} please!

接下来,在我的错误处理程序中,我需要检测并解压 MethodArgumentNotValidException 中的 ObjectError 实例是否包含 ConstraintViolation(为了简化此示例,我忽略了其他错误源):

@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
List<ErrorMessage> methodArgumentNotValidHandler(MethodArgumentNotValidException e) {
    return e.getBindingResult().getAllErrors().stream()
            .filter(objectError -> objectError.contains(ConstraintViolation.class))
            .map(objectError -> objectError.unwrap(ConstraintViolation.class))
            .map(ConstraintViolation::getMessage)
            .map(message -> new ErrorMessage("VE-400", message))
            .collect(Collectors.toList());
}

此解决方案满足要求 1、3、5 和 6。要求 2 被视为无效,因为它与我提出此问题时想到的特定解决方案相关联。要求 4 仍然开放,SpEL 可能有可能进一步研究,否则我会继续探索 .