草庐IT

全局异常处理及参数校验-SpringBoot 2.7.2 实战基础 (建议收藏)

程序员优雅哥 (youyacoder) 2023-04-16 原文

优雅哥 SpringBoot 2.7 实战基础 - 08 - 全局异常处理及参数校验

前后端分离开发非常普遍,后端处理业务,为前端提供接口。服务中总会出现很多运行时异常和业务异常,本文主要讲解在 SpringBoot 实战中如何进行异常统一处理和请求参数的校验。

1 异常统一处理

所有异常处理相关的类,咱们都放到 com.yygnb.demo.common包中。

当后端发生异常时,需要按照一个约定的规则(结构)返回给前端,所以先定义一个发生异常时固定的结构。

1.1 错误响应结构

发生异常时的响应结构约定两个字段:code——错误编码;msg——错误消息。创建类:

com.yygnb.demo.common.domain.ErrorResult

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResult implements Serializable {

    private static final long serialVersionUID = -8738363760223125457L;

    /**
     * 错误码
     */
    private String code;

    /**
     * 错误消息
     */
    private String msg;
  
    public static ErrorResult build(ErrorResult commonErrorResult, String msg) {
        return new ErrorResult(commonErrorResult.getCode(), commonErrorResult.getMsg() + " " + msg);
    }
}

1.2 通用错误响应常量

有些异常返回的 ErrorResult 是一样的,如参数校验错误、未查询到对象、系统异常等,可以定义一些错误响应的常量:

com.yygnb.demo.common.exception.DefaultErrorResult

public interface DefaultErrorResult {

    ErrorResult SYSTEM_ERROR = new ErrorResult("C00001", "系统异常");
    ErrorResult CUSTOM_ERROR = new ErrorResult("C99999", "自定义异常");
    ErrorResult PARAM_BIND_ERROR = new ErrorResult("C00003", "参数绑定错误:");
    ErrorResult PARAM_VALID_ERROR = new ErrorResult("S00004", "参数校验错误:");
    ErrorResult JSON_PARSE_ERROR = new ErrorResult("S00005", "JSON转换异常");
    ErrorResult CODE_NOT_FOUND = new ErrorResult("S00006", "根据编码没有查询到对象");
    ErrorResult ID_NOT_FOUND = new ErrorResult("S00007", "根据ID没有查询到对象");
}

1.3 通用异常类定义

定义一个通用的异常类 CommonException,继承自 RuntimeException,当程序中捕获到编译时异常或业务异常时,就抛出这个通用异常,交给全局来处理。(随着业务复杂度的增加,可以细分自定义异常,如 AuthExceptionUserExceptionCouponException 等,让这些细分异常都继承自 CommonException。)

com.yygnb.demo.common.exception.CommonException

@EqualsAndHashCode(callSuper = true)
@Data
public class CommonException extends RuntimeException {

    protected ErrorResult errorResult;

    public CommonException(String message) {
        super(message);
    }

    public CommonException(String message, Throwable cause) {
        super(message, cause);
    }

    public CommonException(Throwable cause) {
        super(cause);
    }

    protected CommonException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }

    public CommonException(String code, String msg) {
        super(msg + "(" + code + ")");
        this.errorResult = new ErrorResult(code, msg);
    }

    public CommonException(ErrorResult errorResult) {
        super(errorResult.getMsg() + "(" + errorResult.getCode() + ")");
        this.errorResult = errorResult;
    }
}

这个自定义异常类复写了父类的构造函数,同时定义了一个成员变量 ErrorResult,便于在同一异常处理时快速构造返回信息。

1.4 全局异常处理

Spring MVC 中提供了全局异常处理的注解:@ControllerAdvice@RestControllerAdvice。由于前后端分离开发 RESTful 接口,我们这里就使用 @RestControllerAdvice

com.yygnb.demo.common.exception.CommonExceptionHandler

@Slf4j
@RestControllerAdvice
public class CommonExceptionHandler {

    /**
     * 通用业务异常
     *
     * @param e
     * @return
     */
    @ExceptionHandler(value = CommonException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResult handleCommonException(CommonException e) {
        log.error("{}, {}", e.getMessage(), e);
        if (e.getErrorResult() != null) {
            return e.getErrorResult();
        }
        return new ErrorResult(DefaultErrorResult.CUSTOM_ERROR.getCode(), e.getMessage());
    }

    /**
     * 其他运行时异常
     * @param e
     * @return
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResult handleDefaultException(Exception e) {
        log.error("{}, {}", e.getMessage(), e);
        return DefaultErrorResult.SYSTEM_ERROR;
    }
}

上面捕获了 CommonException 和 Exception,匹配顺序为从上往下。假设 handleDefaultException 在前面,当发生一个异常 CommonException 时,一来就会被 handleDefaultException 捕获,因为无论什么异常,都属于 Exception 的实例,不会执行 handleCommonException。所以越具体的异常处理,越要写在前面。

1.5 测试统一异常处理

在 DemoController 中抛出一个 CommonException:

@GetMapping("hello")
public String hello(String msg) {
    String result = "Hello Spring Boot ! " + msg;
    if ("demo".equals(msg)) {
        throw new CommonException("发生错误----这是自定义异常");
    }
    return result;
}

启动服务,访问:

http://localhost:9099/demo/hello?msg=demo

结果返回:

{
  "code": "C99999",
  "msg": "发生错误----这是自定义异常"
}

可以看出全局统一异常处理已经生效了。

2 参数校验

传统参数校验方式是通过多个 if/else 来进行,代码量大,很没有意义。Spring Boot 中有个 starter spring-boot-starter-validation 可以帮助咱们很方便的实现参数校验。

2.1 添加依赖

有些文章中说 spring boot 2.3 还是多少版本以后不用手动加入这个 starter,我试了以后不行,需要手动引入该依赖才行。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

这个 starter 定义 Validator 以及 SmartValidator 接口,提供 @Validated,支持 spring 环境,支持验证组的规范, 提供了一系列的工厂类以及适配器。底层依赖 hibernate-validator 包。

2.2 完善异常处理类

在 1.4 中只捕获了 CommonException 和 Exception,此处要完善参数绑定、校验等异常。补充后 CommonExceptionHandler 如下:

@Slf4j
@RestControllerAdvice
public class CommonExceptionHandler {

    @ExceptionHandler(value = BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResult handleBindException(BindException e) {
        log.error("{}", e.getMessage(), e);
        List<String> defaultMsg = e.getBindingResult().getAllErrors()
                .stream()
                .map(ObjectError::getDefaultMessage)
                .collect(Collectors.toList());
        return ErrorResult.build(DefaultErrorResult.PARAM_BIND_ERROR, defaultMsg.get(0));
    }

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("{}", e.getMessage(), e);
        List<String> defaultMsg = e.getBindingResult().getFieldErrors()
                .stream()
                .map(fieldError -> "【" + fieldError.getField() + "】" + fieldError.getDefaultMessage())
                .collect(Collectors.toList());
        return ErrorResult.build(DefaultErrorResult.PARAM_VALID_ERROR, defaultMsg.get(0));
    }

    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResult handleMissingServletRequestParameterException(MissingServletRequestParameterException e) {
        log.error("{}", e.getMessage(), e);
        log.error("ParameterName: {}", e.getParameterName());
        log.error("ParameterType: {}", e.getParameterType());
        return ErrorResult.build(DefaultErrorResult.PARAM_VALID_ERROR, e.getMessage());
    }

    @ExceptionHandler(value = ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResult handleBindGetException(ConstraintViolationException e) {
        log.error("{}", e.getMessage(), e);
        List<String> defaultMsg = e.getConstraintViolations()
                .stream()
                .map(ConstraintViolation::getMessage)
                .collect(Collectors.toList());
        return ErrorResult.build(DefaultErrorResult.PARAM_VALID_ERROR, defaultMsg.get(0));
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResult error(HttpMessageNotReadableException e){
        log.error("{}", e.getMessage(), e);
        return DefaultErrorResult.JSON_PARSE_ERROR;
    }

    /**
     * 通用业务异常
     *
     * @param e
     * @return
     */
    @ExceptionHandler(value = CommonException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResult handleCommonException(CommonException e) {
        log.error("{}, {}", e.getMessage(), e);
        if (e.getErrorResult() != null) {
            return e.getErrorResult();
        }
        return new ErrorResult(DefaultErrorResult.CUSTOM_ERROR.getCode(), e.getMessage());
    }

    /**
     * 其他运行时异常
     * @param e
     * @return
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResult handleDefaultException(Exception e) {
        log.error("{}, {}", e.getMessage(), e);
        return DefaultErrorResult.SYSTEM_ERROR;
    }
}

2.3 自定义校验注解

javax.validation 中提供了很多用于校验的注解,常见的如:@NotNull、@Min、@Max 等等,但可能这些注解不够,需要自定义注解。例如咱们自定义一个注解 @OneOf,该注解对应字段的值只能从 value 中选择:使用方式为:

@OneOf(value = {"MacOS", "Windows", "Linux"})

首先定义一个注解

com.yygnb.demo.common.validator.OneOf

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = OneOfValidator.class)
public @interface OneOf {

    String message() default "只能从备选值中选择";

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

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

    String[] value();
}

定义注解时,@Constraint(validatedBy = OneOfValidator.class)表示校验使用 OneOfValidator 类进行校验,我们需要编写这个类。

com.yygnb.demo.common.validator.OneOfValidator

public class OneOfValidator implements ConstraintValidator<OneOf, String> {

    private List<String> list;

    @Override
    public void initialize(OneOf constraintAnnotation) {
        list = Arrays.asList(constraintAnnotation.value());
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        if (s == null) {
            return true;
        }
        return list.contains(s);
    }
}

这样便实现一个自定义校验注解了。

2.4 RequestBody 参数校验

新增电脑接口为例,首先需要在方法的参数前面使用注解 @Valid@Validated 修饰。在此处使用两者之一都可以,前者是 javax.validation 中提供的,后者是 springframework 中提供的:

@Operation(summary = "新增电脑")
@PostMapping()
public Computer save(@RequestBody @Validated Computer computer) {
    computer.setId(null);
    this.computerService.save(computer);
    return computer;
}

在 RequestBody 参数对应的 DTO / 实体中,对需要校验的字段加上校验注解。例如 操作系统operation 只能从 "MacOS", "Windows", "Linux" 中选择;年份year不能为空且长度为4:

@OneOf(value = {"MacOS", "Windows", "Linux"})
@Schema(title = "操作系统")
private String operation;

@NotNull(message = "不能为空")
@Length(min = 4, max = 4)
@Schema(title = "年份")
private String year;

此时重启服务,调用新增电脑接口时,就会进行校验。

2.5 路径参数和RequestParam校验

路径参数和没有封装为实体的 RequestParam 参数,首先需要在参数前面加上校验注解,然后需要在 Controller 类上面加上注解 @Validated 才会生效。如在分页列表接口中,要求参数当前页 page 大于 0:

public Page<Computer> findPage(@PathVariable @Min(1) Integer page, @PathVariable @Max(10) Integer size) {
...
}

本文简单介绍了统一异常处理和参数校验,本节的代码还有很多优化空间,在后面的实战部分逐一完善。


\/ 程序员优雅哥(youyacoder),今日学习到此结束~~~

有关全局异常处理及参数校验-SpringBoot 2.7.2 实战基础 (建议收藏)的更多相关文章

  1. ruby - 如何指定 Rack 处理程序 - 2

    Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack

  2. ruby-on-rails - 如何在 ruby​​ 中使用两个参数异步运行 exe? - 2

    exe应该在我打开页面时运行。异步进程需要运行。有什么方法可以在ruby​​中使用两个参数异步运行exe吗?我已经尝试过ruby​​命令-system()、exec()但它正在等待过程完成。我需要用参数启动exe,无需等待进程完成是否有任何ruby​​gems会支持我的问题? 最佳答案 您可以使用Process.spawn和Process.wait2:pid=Process.spawn'your.exe','--option'#Later...pid,status=Process.wait2pid您的程序将作为解释器的子进程执行。除

  3. ruby - RSpec - 使用测试替身作为 block 参数 - 2

    我有一些Ruby代码,如下所示:Something.createdo|x|x.foo=barend我想编写一个测试,它使用double代替block参数x,这样我就可以调用:x_double.should_receive(:foo).with("whatever").这可能吗? 最佳答案 specify'something'dox=doublex.should_receive(:foo=).with("whatever")Something.should_receive(:create).and_yield(x)#callthere

  4. ruby - 如何在 Ruby 中拆分参数字符串 Bash 样式? - 2

    我正在为一个项目制作一个简单的shell,我希望像在Bash中一样解析参数字符串。foobar"helloworld"fooz应该变成:["foo","bar","helloworld","fooz"]等等。到目前为止,我一直在使用CSV::parse_line,将列分隔符设置为""和.compact输出。问题是我现在必须选择是要支持单引号还是双引号。CSV不支持超过一个分隔符。Python有一个名为shlex的模块:>>>shlex.split("Test'helloworld'foo")['Test','helloworld','foo']>>>shlex.split('Test"

  5. ruby - 检查方法参数的类型 - 2

    我不确定传递给方法的对象的类型是否正确。我可能会将一个字符串传递给一个只能处理整数的函数。某种运行时保证怎么样?我看不到比以下更好的选择:defsomeFixNumMangler(input)raise"wrongtype:integerrequired"unlessinput.class==FixNumother_stuffend有更好的选择吗? 最佳答案 使用Kernel#Integer在使用之前转换输入的方法。当无法以任何合理的方式将输入转换为整数时,它将引发ArgumentError。defmy_method(number)

  6. ruby-on-rails - 在默认方法参数中使用 .reverse_merge 或 .merge - 2

    两者都可以defsetup(options={})options.reverse_merge:size=>25,:velocity=>10end和defsetup(options={}){:size=>25,:velocity=>10}.merge(options)end在方法的参数中分配默认值。问题是:哪个更好?您更愿意使用哪一个?在性能、代码可读性或其他方面有什么不同吗?编辑:我无意中添加了bang(!)...并不是要询问nobang方法与bang方法之间的区别 最佳答案 我倾向于使用reverse_merge方法:option

  7. ruby-on-rails - Rails - 乐观锁定总是触发 StaleObjectError 异常 - 2

    我正在学习Rails,并阅读了关于乐观锁的内容。我已将类型为integer的lock_version列添加到我的articles表中。但现在每当我第一次尝试更新记录时,我都会收到StaleObjectError异常。这是我的迁移:classAddLockVersionToArticle当我尝试通过Rails控制台更新文章时:article=Article.first=>#我这样做:article.title="newtitle"article.save我明白了:(0.3ms)begintransaction(0.3ms)UPDATE"articles"SET"title"='dwdwd

  8. ruby - #之间? Cooper 的 *Beginning Ruby* 中的错误或异常 - 2

    在Cooper的书BeginningRuby中,第166页有一个我无法重现的示例。classSongincludeComparableattr_accessor:lengthdef(other)@lengthother.lengthenddefinitialize(song_name,length)@song_name=song_name@length=lengthendenda=Song.new('Rockaroundtheclock',143)b=Song.new('BohemianRhapsody',544)c=Song.new('MinuteWaltz',60)a.betwee

  9. ruby - 定义方法参数的条件 - 2

    我有一个只接受一个参数的方法:defmy_method(number)end如果使用number调用方法,我该如何引发错误??通常,我如何定义方法参数的条件?比如我想在调用的时候报错:my_method(1) 最佳答案 您可以添加guard在函数的开头,如果参数无效则引发异常。例如:defmy_method(number)failArgumentError,"Inputshouldbegreaterthanorequalto2"ifnumbereputse.messageend#=>Inputshouldbegreaterthano

  10. ruby - 在 Ruby 中重新分配常量时抛出异常? - 2

    我早就知道Ruby中的“常量”(即大写的变量名)不是真正常量。与其他编程语言一样,对对象的引用是唯一存储在变量/常量中的东西。(侧边栏:Ruby确实具有“卡住”引用对象不被修改的功能,据我所知,许多其他语言都没有提供这种功能。)所以这是我的问题:当您将一个值重新分配给常量时,您会收到如下警告:>>FOO='bar'=>"bar">>FOO='baz'(irb):2:warning:alreadyinitializedconstantFOO=>"baz"有没有办法强制Ruby抛出异常而不是打印警告?很难弄清楚为什么有时会发生重新分配。 最佳答案

随机推荐