统⼀全局异常处理
1.设计⼀个优秀的异常处理机制
⼀、异常处理的乱象例举
乱象⼀:捕获异常后只输出到控制台
前端 js-ajax 代码
$.ajax({
type: "GET",
url: "/user/add",
dataType: "json",
success: function (data) {
alert("添加成功");
},
});
后端业务代码
try {
// do something
} catch (XyyyyException e) {
e.printStackTrace();
}
问题:
- 后端直接将异常捕获,⽽且只做了⽇志打印。⽤户体验⾮常差,⼀旦后台出错,⽤户没有任何感知, ⻚⾯⽆状态。
- 后端只给出前端异常结果,没有给出异常的原因的描述。⽤户不知道是⾃⼰操作输⼊错误,还是系统 bug。⽤户⽆法判断⾃⼰需要等⼀下再操作?还是继续下⼀步?
- 如果没有⼈去经常关注服务端⽇志,不会有⼈发现系统出现异常。
乱象⼆:混乱的返回⽅式
前端代码
$.ajax({
type: "GET",
url: "/goods/add",
dataType: "json",
success: function(data) {
if (data.flag) {
alert("添加成功");
} else {
alert(data.message);
}
},
error: function(data){
alert("添加失败");
}
});
后端代码
@RequestMapping("/goods/add")
@ResponseBody
public Map add(Goods goods) {
Map map = new HashMap();
try {
// do something
map.put(flag, true);
} catch (Exception e) {
e.printStackTrace();
map.put("flag", false);
map.put("message", e.getMessage());
}
reutrn map;
}
问题:
- 每个⼈返回的数据有每个⼈⾃⼰的规范,你叫 flag 他叫 isOK,你的成功 code 是 0,它的成功 code 是 0000。这样导致后端书写了⼤量的异常返回逻辑代码,前端也随之每⼀个请求⼀套异常处理逻辑。 很多重复代码。
- 如果是前端后端⼀个⼈开发还勉强能⽤,如果前后端分离,这就是系统灾难。
⼆、该如何设计异常处理
⾯向相关⽅友好
- 后端开发⼈员职责单⼀,只需要将异常捕获并转换为⾃定义异常⼀直对外抛出。不需要去想⻚⾯跳转 404,以及异常响应的数据结构的设计。
- ⾯向前端⼈员友好,后端返回给前端的数据应该有统⼀的数据结构,统⼀的规范。不能⼀个⼈⼀个响 应的数据结构。⽽在此过程中不需要后端开发⼈员做更多的⼯作,交给全局异常处理器去处理“异 常”到“响应数据结构”的转换。
- ⾯向⽤户友好,⽤户能够清楚的知道异常产⽣的原因。这就要求⾃定义异常,全局统⼀处理,ajax 接 ⼝请求响应统⼀的异常数据结构,⻚⾯模板请求统⼀跳转到 404 ⻚⾯。
- ⾯向运维友好,将异常信息合理规范的持久化,以⽇志的形式存储起来,以便查询。
为什么要将系统运⾏时异常捕获,转换为⾃定义异常抛出?
因为⽤户不认识 ConnectionTimeOutException 类似这种异常是什么东⻄,但是转换为⾃定义异常就要求 程序员对运⾏时异常进⾏⼀个翻译,⽐如:⾃定义异常⾥⾯应该有 message 字段,后端程序员应该明确 的在 message 字段⾥⾯⽤⾯向⽤户的友好语⾔,说明服务端发⽣了什么。
三、开发规范
- Controller、Service、DAO 层拦截异常转换为⾃定义异常,不允许将异常私⾃截留。必须对外抛出。
- 统⼀数据响应代码,使⽤ http 状态码,不要⾃定义。⾃定义不⽅便记忆,HTTP 状态码程序员都知道。但是太多了程序员也记不住,在项⽬组规定范围内使⽤⼏个就可以。⽐如:200 请求成功,400 ⽤户输⼊错误导致的异常,500 系统内部异常,999 未知异常。
- ⾃定义异常⾥⾯有 message 属性,⽤对⽤户友好的语⾔描述异常的发⽣情况,并赋值给 message。
- 不允许对⽗类 Exception 统⼀ catch,要分⼩类 catch,这样能够清楚地将异常转换为⾃定义异常传递给前端。
2.⾃定义异常和相关数据结构
⼀、该如何设计数据结构
- CustomException ⾃定义异常。核⼼要素包含异常错误编码(400,500)、异常错误信息 message。
- ExceptionTypeEnum 枚举异常分类,将异常分类固化下来,防⽌开发⼈员思维发散。
- AjaxResponse ⽤于响应 HTTP 请求的统⼀数据结构。
⼆、枚举异常的类型
为了防⽌开发⼈员⼤脑发散,每个开发⼈员都不断的发明⾃⼰的异常类型,我们需要规定好异常的类型 (枚举)。⽐如:系统异常、⽤户(输⼊)操作导致的异常、其他异常等。
package top.syhan.boot.exception.enums;
/**
* @description: 异常类型枚举
* @author: syhan
* @date: 2022-04-11
**/
public enum CustomExceptionType {
/**
* 客户端异常
*/
USER_INPUT_ERROR(400, "您输⼊的数据错误或您没有权限访问资源!"),
/**
* 服务器异常
*/
SYSTEM_ERROR(500, "系统出现异常,请您稍后再试或联系管理员!"),
/**
* 未知异常
*/
OTHER_ERROR(999, "系统出现未知异常,请联系管理员!");
CustomExceptionType(int code, String desc) {
this.code = code;
this.desc = desc;
}
/**
* 异常类型状态码
*/
private final int code;
/**
* 异常类型中⽂描述
*/
private final String desc;
public String getDesc() {
return desc;
}
public int getCode() {
return code;
}
}
- 最好不要超过 5 个,否则开发⼈员将会记不住,也不愿意去记。
- 这⾥的 code 表示异常类型的唯⼀编码,为了⽅便⼤家记忆,就使⽤ Http 状态码 400、500。
- 这⾥的 desc 是通⽤的异常描述,在创建⾃定义异常的时候,为了给⽤户更友好的回复,通常异常信 息描述应该更具体更友好。
三、⾃定义异常
- ⾃定义异常有两个核⼼内容,⼀个是 code。使⽤ CustomExceptionType 来限定范围。
- 另外⼀个是 message,这个 message 信息是要最后返回给前端的,所以需要⽤友好的提示来表达异常发⽣的原因或内容
package top.syhan.boot.exception.exception;
import top.syhan.boot.exception.enums.CustomExceptionType;
/**
* @description: ⾃定义异常
* @author: syhan
* @date: 2022-04-11
**/
public class CustomException extends RuntimeException {
/**
* 异常错误编码
*/
private int code;
/**
* 异常信息
*/
private String message;
private CustomException() {
}
public CustomException(CustomExceptionType customExceptionType) {
this.code = customExceptionType.getCode();
this.message = customExceptionType.getDesc();
}
public CustomException(CustomExceptionType customExceptionType, String message) {
this.code = customExceptionType.getCode();
this.message = message;
}
public int getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}
四、请求接⼝统⼀响应数据结构
为了解决不同的开发⼈员使⽤不同的结构来响应给前端,导致规范不统⼀,开发混乱的问题。我们使⽤ 如下代码定义统⼀数据响应结构。
- isok 表示该请求是否处理成功(即是否发⽣异常)。true 表示请求处理成功,false 表示处理失败。
- code 对响应结果进⼀步细化,200 表示请求成功,400 表示⽤户操作导致的异常,500 表示系统异 常,999 表示其他异常。与 CustomExceptionType 枚举⼀致。
- message:友好的提示信息,或者请求结果提示信息。如果请求成功这个信息通常没什么⽤,如果 请求失败,该信息需要展示给⽤户。
- data:通常⽤于查询数据请求,成功之后将查询数据响应给前端。
package top.syhan.boot.exception.utils;
import top.syhan.boot.exception.enums.CustomExceptionType;
import top.syhan.boot.exception.exception.CustomException;
import lombok.Data;
/**
* @description: 请求接⼝统⼀响应数据结构
* @author: syhan
* @date: 2022-04-11
**/
@Data
public class AjaxResponse {
/**
* 请求响应状态码
*/
private int code;
/**
* 请求结果描述信息
*/
private String message;
/**
* 请求结果数据(通常⽤于查询操作)
*/
private Object data;
private AjaxResponse() {
}
/**
* 请求出现异常时的响应数据封装
*
* @param e e
* @return AjaxResponse
*/
public static AjaxResponse error(CustomException e) {
AjaxResponse resultBean = new AjaxResponse();
resultBean.setCode(e.getCode());
resultBean.setMessage(e.getMessage());
return resultBean;
}
/**
* 请求出现异常时的响应数据封装
* @param customExceptionType customExceptionType
* @param errorMessage errorMessage
* @return AjaxResponse
*/
public static AjaxResponse error(CustomExceptionType customExceptionType, String errorMessage) {
AjaxResponse resultBean = new AjaxResponse();
resultBean.setCode(customExceptionType.getCode());
resultBean.setMessage(errorMessage);
return resultBean;
}
/**
* 请求成功的响应,不带查询数据(⽤于删除、修改、新增接⼝)
*
* @return AjaxResponse
*/
public static AjaxResponse success() {
AjaxResponse ajaxResponse = new AjaxResponse();
ajaxResponse.setCode(200);
ajaxResponse.setMessage("请求响应成功!");
return ajaxResponse;
}
/**
* 请求成功的响应,带有查询数据(⽤于数据查询接⼝)
*
* @param obj obj
* @return AjaxResponse
*/
public static AjaxResponse success(Object obj) {
AjaxResponse ajaxResponse = new AjaxResponse();
ajaxResponse.setCode(200);
ajaxResponse.setMessage("请求响应成功!");
ajaxResponse.setData(obj);
return ajaxResponse;
}
/**
* 请求成功的响应,带有查询数据(⽤于数据查询接⼝)
*
* @param obj obj
* @param message message
* @return AjaxResponse
*/
public static AjaxResponse success(Object obj, String message) {
AjaxResponse ajaxResponse = new AjaxResponse();
ajaxResponse.setCode(200);
ajaxResponse.setMessage(message);
ajaxResponse.setData(obj);
return ajaxResponse;
}
}
对于不同的场景,提供了四种构建 AjaxResponse 的⽅法。
- 当请求成功的情况下,可以使⽤ AjaxResponse.success()构建返回结果给前端。
- 当查询请求等需要返回业务数据,请求成功的情况下,可以使⽤ AjaxResponse.success(data)构建 返回结果给前端。携带结果数据。
- 当请求处理过程中发⽣异常,需要将异常转换为 CustomException ,然后在控制层使⽤ AjaxResponse error(CustomException)构建返回结果给前端。
- 在某些情况下,没有任何异常产⽣,我们判断某些条件也认为请求失败。这种使⽤ AjaxResponse error(customExceptionType,errorMessage)构建响应结果。
五、使⽤示例如下:
例如:更新操作,Controller ⽆需返回额外的数据
return AjaxResponse.success();
查询接⼝,Controller 需返回结果数据(data 可以是任何类型数据)
return AjaxResponse.success(data);
3.通⽤全局异常处理逻辑
⼀、通⽤异常处理逻辑
程序员的异常处理逻辑要⼗分的单⼀:⽆论在 Controller 层、Service 层还是什么其他位置,程序员只负 责⼀件事:那就是捕获异常,并将异常转换为⾃定义异常。使⽤⽤户友好的信息去填充。
CustomException 的 message,并将 CustomException 抛出去。
package top.syhan.boot.exception.service;
import top.syhan.boot.exception.consts.MsgConsts;
import top.syhan.boot.exception.enums.CustomExceptionType;
import top.syhan.boot.exception.exception.CustomException;
import org.springframework.stereotype.Service;
/**
* @description: 通⽤异常处理逻辑
* @author: syhan
* @date: 2022-04-11
**/
@Service
public class ExceptionService {
/**
* 服务层,模拟系统异常
*/
public void systemBizError() {
try {
Class.forName("com.mysql.jdbc.cj.Driver");
} catch (ClassNotFoundException e) {
throw new CustomException(
CustomExceptionType.SYSTEM_ERROR,
"在XXX业务,myBiz()⽅法内,出现ClassNotFoundException,请
将该信息告知管理员");
}
}
/**
* 服务层,模拟⽤户输⼊数据导致的校验异常
*
* @param input ⽤户输⼊
*/
public void userBizError(int input) {
//模拟业务校验失败逻辑
if (input < 0) {
throw new CustomException(CustomExceptionType.USER_INPUT_ERROR, MsgConsts.INPUT_ERROR);
}
}
}
⼆、全局异常处理器
通过团队内的编码规范的要求,我们已经知道了:不允许程序员截留处理 Exception,必须把异常转换为 ⾃定义异常 CustomException 全都抛出去。那么程序员把异常跑出去之后由谁来处理?那就是 ControllerAdvice。 ControllerAdvice 注解的作⽤就是监听所有的 Controller,⼀旦 Controller 抛出 CustomException,就会 在@ExceptionHandler(CustomException.class)注解的⽅法⾥⾯对该异常进⾏处理。处理⽅法很简单就是使⽤ AjaxResponse.error(e)包装为通⽤的接⼝数据结构返回给前端。
package top.syhan.boot.exception.handler;
import top.syhan.boot.exception.enums.CustomExceptionType;
import top.syhan.boot.exception.exception.CustomException;
import top.syhan.boot.exception.utils.AjaxResponse;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* @description: 全局异常处理器
* @author: syhan
* @date: 2022-04-11
**/
@ControllerAdvice
public class WebExceptionHandler {
/**
* 处理程序员主动转换的⾃定义异常
*
* @param e 异常
* @return AjaxResponse
*/
@ExceptionHandler(CustomException.class)
@ResponseBody
public AjaxResponse customerException(CustomException e) {
if (e.getCode() == CustomExceptionType.SYSTEM_ERROR.getCode()) {
//400异常不需要持久化,将异常信息以友好的⽅式告知⽤户就可以
//将500异常信息持久化处理,⽅便运维⼈员处理
}
return AjaxResponse.error(e);
}
/**
* 处理程序员在程序中未能捕获(遗漏的)异常
*
* @param e 异常
* @return AjaxResponse
*/
@ExceptionHandler(Exception.class)
@ResponseBody
public AjaxResponse exception(Exception e) {
//TODO 将异常信息持久化处理,⽅便运维⼈员处理
return AjaxResponse.error(new CustomException(CustomExceptionType.OTHER_ERROR));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public AjaxResponse handleBindException(MethodArgumentNotValidException ex) {
FieldError fieldError = ex.getBindingResult().getFieldError();
assert fieldError != null;
return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, fieldError.getDefaultMessage()));
}
@ExceptionHandler(BindException.class)
@ResponseBody
public AjaxResponse handleBindException(BindException ex) {
FieldError fieldError = ex.getBindingResult().getFieldError();
assert fieldError != null;
return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, fieldError.getDefaultMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseBody
public AjaxResponse handleIllegalArgumentException(IllegalArgumentException e) {
return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, e.getMessage()));
}
}
三、测试⼀下
随便找⼀个 API,注⼊ ExceptionService 访问测试⼀下
四、业务状态与 HTTP 协议状态⼀致
不知道⼤家有没有注意到⼀个问题(看上图)?这个问题就是我们的 AjaxResponse 的 code 是 400,但是真正的 HTTP 协议状态码是 200。
- AjaxResponse 的 code 是 400 代表的是业务状态,也就是说⽤户的请求业务失败了
- 但是 HTTP 请求是成功的,也就是说数据是正常返回的。
在很多的公司开发 RESTful 服务时,要求 HTTP 状态码能够体现业务的最终执⾏状态,所以说:我们有必要让业务状态与 HTTP 协议 Response 状态码⼀致。
@Component
@ControllerAdvice
public class GlobalResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
//return returnType.hasMethodAnnotation(ResponseBody.class);
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
//如果响应结果是JSON数据类型
if(selectedContentType.equalsTypeAndSubtype(
MediaType.APPLICATION_JSON)){
//为HTTP响应结果设置状态码,状态码就是AjaxResponse的code,⼆者达到统⼀
response.setStatusCode(
HttpStatus.valueOf(((AjaxResponse) body).getCode())
);
return body;
}
return body;
}
}
- 实现 ResponseBodyAdvice 接⼝的作⽤是:在将数据返回给⽤户之前,做最后⼀步的处理。也就是说,ResponseBodyAdvice 的处理过程在全局异常处理的后⾯。
五、进⼀步优化
我们已经知道了,ResponseBodyAdvice 接⼝的作⽤是:在将数据返回给⽤户之前,做最后⼀步的处理。将上⽂的 GlobalResponseAdvice 中 beforeBodyWrite ⽅法代码优化如下。
- 如果 Controller 或全局异常处理响应的结果 body 是 AjaxResponse,就直接 return 给前端。
- 如果 Controller 或全局异常处理响应的结果 body 不是 AjaxResponse,就将 body 封装为 AjaxResponse 之后再 return 给前端。
我们之前的代码是这样写的,⽐如:某个 controller ⽅法返回值
return AjaxResponse.success(objList);
现在就可以这样写了,因为在 GlobalResponseAdvice ⾥⾯会统⼀再封装为 AjaxResponse。
return objList;
最终代码如下:
package top.syhan.boot.exception.advice;
import top.syhan.boot.exception.utils.AjaxResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* @description: 全局业务状态通知
* @author: syhan
* @date: 2022-04-11
**/
@Component
@ControllerAdvice
public class GlobalResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter methodParameter, Class aClass) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse)
{
//如果响应结果是JSON数据类型
if (mediaType.equalsTypeAndSubtype(
MediaType.APPLICATION_JSON)) {
if (body instanceof AjaxResponse ajaxResponse) {
//999 不是标准的HTTP状态码,特殊处理
if (ajaxResponse.getCode() != 999) {
serverHttpResponse.setStatusCode(HttpStatus.valueOf(
ajaxResponse.getCode()
));
}
return body;
} else {
serverHttpResponse.setStatusCode(HttpStatus.OK);
return AjaxResponse.success(body);
}
}
return body;
}
}
4.服务端数据校验异常处理逻辑
⼀、异常校验的规范及常⽤注解
在 Web 开发时,对于请求参数,⼀般上都需要进⾏参数合法性校验的,原先的写法是⼀个个字段⼀个个 去判断,这种⽅式太不通⽤了,Java 的 JSR 303: Bean Validation 规范就是解决这个问题的。 JSR 303 只是个规范,并没有具体的实现,⽬前通常是⽤ hibernate-validator 进⾏统⼀参数校验。
JSR303 定义的校验类型
Constraint | 详细信息 |
---|---|
@Null | 被注释的元素必须为 null |
@NotNull | 被注释的元素必须不为 null |
@AssertTrue | 被注释的元素必须为 true |
@AssertFalse | 被注释的元素必须为 false |
@Min(value) | 被注释的元素必须是⼀个数字,其值必须⼤于等于指定的最⼩值 |
@Max(value) | 被注释的元素必须是⼀个数字,其值必须⼩于等于指定的最⼤值 |
@DecimalMin(value) | 被注释的元素必须是⼀个数字,其值必须⼤于等于指定的最⼩值 |
@DecimalMax(value) | 被注释的元素必须是⼀个数字,其值必须⼩于等于指定的最⼤值 |
@Size(max, min) | 被注释的元素的⼤⼩必须在指定的范围内 |
@Digits (integer, fraction) | 被注释的元素必须是⼀个数字,其值必须在可接受的范围内 |
@Past | 被注释的元素必须是⼀个过去的⽇期 |
@Future | 被注释的元素必须是⼀个将来的⽇期 |
@Pattern(value) | 被注释的元素必须符合指定的正则表达式 |
Hibernate Validator 附加的 constraint
Constraint | 详细信息 |
---|---|
被注释的元素必须是电⼦邮箱地址 | |
@Length | 被注释的字符串的⼤⼩必须在指定的范围内 |
@NotEmpty | 被注释的字符串的必须⾮空 |
@Range | 被注释的元素必须在合适的范围内 |
⽤法:把以上注解加在 ArticleVO 的属性字段上,然后在参数校验的⽅法上加@Valid 注解 如:
当⽤户输⼊参数不符合注解给出的校验规则的时候,会抛出 BindException 或 MethodArgumentNotValidException。
⼆、Assert 断⾔与 IllegalArgumentException
之前给⼤家讲通⽤异常处理的时候,⽤户输⼊异常判断是这样处理的。这种⽅法也是可以⽤的,但是我 们学了这么多的知识,可以优化⼀下。
//服务层,模拟⽤户输⼊数据导致的校验异常
public void userBizError(int input) {
if(input < 0){ //模拟业务校验失败逻辑
throw new CustomException(
CustomExceptionType.USER_INPUT_ERROR,
"您输⼊的数据不符合业务逻辑,请确认后重新输⼊!");
}
//…… 其他的业务
}
更好的写法是下⾯这样的,使⽤ org.springframework.util.Assert 断⾔ input >= 0,如果不满⾜条件就抛 出 IllegalArgumentException,参数不合法的异常。
//服务层,模拟⽤户输⼊数据导致的校验异常
public void userBizError(int input) {
Assert.isTrue(input >= 0,"您输⼊的数据不符合业务逻辑,请确认后重新输⼊!");
//…… 其他的业务
}
org.springframework.util.Assert 断⾔提供了⼤量的断⾔⽅法,针对各种数据类型进⾏数据合法性校验, 使⽤它我们编写代码更⽅便。
三、友好的数据校验异常处理(⽤户输⼊异常的全局处理)
我们已知当数据校验失败的时候,会抛出异常 BindException 或 MethodArgumentNotValidException。 所以我们对这两种异常做全局处理,防⽌程序员重复编码带来困扰。
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public AjaxResponse handleBindException(MethodArgumentNotValidException ex) {
FieldError fieldError = ex.getBindingResult().getFieldError();
return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, fieldError.getDefaultMessage()));
}
@ExceptionHandler(BindException.class)
@ResponseBody
public AjaxResponse handleBindException(BindException ex) {
FieldError fieldError = ex.getBindingResult().getFieldError();
return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, fieldError.getDefaultMessage()));
}
我们已知使⽤ org.springframework.util.Assert 断⾔,如果不满⾜条件就抛出 IllegalArgumentException。可以使⽤下⾯的全局异常处理函数。
@ExceptionHandler(IllegalArgumentException.class)
@ResponseBody
public AjaxResponse
handleIllegalArgumentException(IllegalArgumentException e) {
return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, e.getMessage())
);
}