前言大家好!一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。 作为一名从业已达10几年的老码农,我的工作主要是开发后端Java业务系统,包括各种管理后台和小程序等。在这些项目中,我设计过单/多租户体系系统,对接过许多开放平台,也搞过消息中心这类较为复杂的应用,但幸运的是,我至今还没有遇到过线上系统由于代码崩溃导致资损的情况。这其中的原因有三点:一是业务系统本身并不复杂;二是我一直遵循某大厂代码规约,在开发过程中尽可能按规约编写代码;三是经过多年的开发经验积累,我成为了一名熟练工,掌握了一些实用的技巧。 BUG对于程序员来说实在是不陌生,当代码出现BUG时,异常也会随之出现,但BUG并不等于异常,BUG只是导致异常出现的一个原因。导致异常发生的原因非常多,本篇文章我也主要只讲一下接口相关的异常怎么处理。 一、接口异常的分类在接口设计中,应该尽量避免使用异常来进行控制流程。接口应该尽可能返回明确的错误码和错误信息,而不是直接抛出异常。 1. 业务异常(Business Exception)这是接口处理过程中可能出现的业务逻辑错误,例如参数校验失败、权限不足等。这些异常通常是预期的,并且可以提供相应的错误码和错误信息给调用方。 2. 系统异常(System Exception)这是接口处理过程中可能出现的非预期错误,例如数据库异常、网络异常等。这些异常通常是未知的,并且可能导致接口无法正常响应。这种错误不仅需要记录异常信息通知系统管理员处理,还需要封装起来做好提示,不能直接把错误返回给用户。 3. 客户端异常(Client Exception)这是调用方在使用接口时可能出现的错误,例如请求参数错误、请求超时等。这些异常通常是由于调用方的错误导致的,接口本身没有问题。可以根据具体情况选择是否返回错误信息给调用方。 二、接口异常的常见处理办法1. 异常捕获和处理在接口的实现代码中,可以使用try-catch语句捕获异常,并进行相应的处理。可以选择将异常转化为合适的错误码和错误信息,然后返回给调用方。或者根据具体情况选择是否记录异常日志,并通知系统管理员进行处理。 2. 统一异常处理器可以使用统一的异常处理器来统一处理接口异常。在Spring Boot中,可以使用@ControllerAdvice和@ExceptionHandler注解来定义一个全局的异常处理器。这样可以将所有接口抛出的异常统一处理,例如转化为特定的错误码和错误信息,并返回给调用方。 3. 抛出自定义异常可以根据业务需求定义一些自定义的异常类,继承RuntimeException或其他合适的异常类,并在接口中抛出这些异常。这样可以在异常发生时,直接抛出异常,由上层调用方进行捕获和处理。 4. 返回错误码和错误信息可以在接口中定义一套错误码和错误信息的规范,当发生异常时,返回对应的错误码和错误信息给调用方。这样调用方可以根据错误码进行相应的处理,例如展示错误信息给用户或者进行相应的逻辑处理。例如这样的弹窗提示 5. 跳转到指定错误页比如遇到401、404、500等错误时,SpringBoot框架会返回自带的错误页,在这里我们其实可以自己重写一些更美观、更友好的错误提示页,最好还能引导用户回到正确的操作上来,例如这样 而不是下面这样 三、接口异常的统一处理通过前面两段我们可以发现,造成异常的原因很多,出现异常的地方很多,异常的处理手段也很多。基于以上三多的情况,我们需要一个地方来统一接收异常、统一处理异常,上面提到SpringBoot的@ControllerAdvice注解作为一个全局的异常处理器来统一处理异常。但@ControllerAdvice注解不是万能的,它有一个问题: 对于@ControllerAdvice注解来说,它主要用于处理Controller层的异常情况,即在控制器方法中发生的异常。因为它是基于Spring MVC的控制器层的异常处理机制。而Filter层是位于控制器之前的一层过滤器,它可以用于对请求进行预处理和后处理。当请求进入Filter时,还没有进入到Controller层,所以@ControllerAdvice注解无法直接处理Filter层中的异常。所以对于Filter中的异常,我们需要单独处理。 1. @ControllerAdvice全局异常处理器的使用(1)自定义业务异常由于SpringBoot框架并没有定义业务相关的错误码,所以我们需要自定义业务错误码。该错误码可以根据业务复杂程度进行分类,每个错误码对应一个具体的异常情况。这样前后端统一处理异常时可以根据错误码进行具体的处理逻辑,提高异常处理的准确性和效率。同时,定义错误码还可以方便进行异常监控和日志记录,便于排查和修复问题。 a、定义常见的异常状态码ResponseCodeEnum.java package com.summo.demo.model.response;
public enum ResponseCodeEnum {
/**
* 请求成功
*/
SUCCESS('0000', ErrorLevels.DEFAULT, ErrorTypes.SYSTEM, '请求成功'),
/**
* 登录相关异常
*/
LOGIN_USER_INFO_CHECK('LOGIN-0001', ErrorLevels.INFO, ErrorTypes.BIZ, '用户信息错误'),
/**
* 权限相关异常
*/
NO_PERMISSIONS('PERM-0001', ErrorLevels.INFO, ErrorTypes.BIZ, '用户无权限'),
/**
* 业务相关异常
*/
BIZ_CHECK_FAIL('BIZ-0001', ErrorLevels.INFO, ErrorTypes.BIZ, '业务检查异常'),
BIZ_STATUS_ILLEGAL('BIZ-0002', ErrorLevels.INFO, ErrorTypes.BIZ, '业务状态非法'),
BIZ_QUERY_EMPTY('BIZ-0003', ErrorLevels.INFO, ErrorTypes.BIZ, '查询信息为空'),
/**
* 系统出错
*/
SYSTEM_EXCEPTION('SYS-0001', ErrorLevels.ERROR, ErrorTypes.SYSTEM, '系统出错啦,请稍后重试'),
;
/**
* 枚举编码
*/
private final String code;
/**
* 错误级别
*/
private final String errorLevel;
/**
* 错误类型
*/
private final String errorType;
/**
* 描述说明
*/
private final String description;
ResponseCodeEnum(String code, String errorLevel, String errorType, String description) {
this.code = code;
this.errorLevel = errorLevel;
this.errorType = errorType;
this.description = description;
}
public String getCode() {
return code;
}
public String getErrorLevel() {
return errorLevel;
}
public String getErrorType() {
return errorType;
}
public String getDescription() {
return description;
}
public static ResponseCodeEnum getByCode(Integer code) {
for (ResponseCodeEnum value : values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return SYSTEM_EXCEPTION;
}
}
b、自定义业务异常类BizException.java
(2) 全局异常处理器BizGlobalExceptionHandler package com.summo.demo.exception.handler;
import javax.servlet.http.HttpServletResponse;
import com.summo.demo.exception.biz.BizException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.ModelAndView;
@RestControllerAdvice(basePackages = {'com.summo.demo.controller', 'com.summo.demo.service'})
public class BizGlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ModelAndView handler(BizException ex, HttpServletResponse response) {
ModelAndView modelAndView = new ModelAndView();
switch (ex.getErrorCode()) {
case LOGIN_USER_INFO_CHECK:
// 重定向到登录页
modelAndView.setViewName('redirect:/login');
break;
case NO_PERMISSIONS:
// 设置错误信息和错误码
modelAndView.addObject('errorMsg', ex.getErrorMsg());
modelAndView.addObject('errorCode', ex.getErrorCode().getCode());
modelAndView.setViewName('403');
break;
case BIZ_CHECK_FAIL:
case BIZ_STATUS_ILLEGAL:
case BIZ_QUERY_EMPTY:
case SYSTEM_EXCEPTION:
default:
// 设置错误信息和错误码
modelAndView.addObject('errorMsg', ex.getErrorMsg());
modelAndView.addObject('errorCode', ex.getErrorCode().getCode());
modelAndView.setViewName('error');
}
return modelAndView;
}
}
(3) 测试效果@RestControllerAdvice和@ExceptionHandler使用起来很简单,下面我们来测试一下(由于不写界面截图是在太丑,我麻烦ChatGPT帮我写了一套简单的界面)。 a、普通业务异常捕获第一步、打开登录页访问链接:http://localhost:8080/login输入账号、密码,点击登录进入首页 第二步、登录进入首页第三步、调用一个会报错的接口再服务启动之前我写了一个根据用户名查询用户的方法,如果查询不到用户的话我会抛出一个异常,代码如下:
这时,我们查询一个不存在的用户访问接口:http://localhost:8080/user/query?userName=sss因为数据库中没有用户名为sss的这个用户,会抛出一个异常 b、403权限不足异常捕获第一步、打开登录页访问链接:http://localhost:8080/login登录界面使用小B的账号登录 第二步、登录进入首页第三步、调用删除用户的接口调用接口:http://localhost:8080/user/delete?userId=2由于小B的账号只有查询权限,没有删除权限,所以返回403错误页
2. 自定义Filter中异常的处理由于@ControllerAdvice注解无法捕获自定义Filter中抛出的异常,这里我们就需要使用另外一种方法进行处理:ErrorController接口。 (1) 原理解释Spring Boot的ErrorController是一个接口,用于定义处理应用程序中发生的错误的自定义逻辑。它允许开发人员以更灵活的方式处理和响应异常,而不是依赖于默认的错误处理机制。:
(2) 使用方法使用方法直接看看我的代码就知道了。CustomErrorController.java package com.summo.demo.controller;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class CustomErrorController implements ErrorController {
@RequestMapping('/error')
public ModelAndView handleError(HttpServletRequest request, HttpServletResponse response) {
//获取当前响应返回的状态码
int statusCode = response.getStatus();
//如果响应头中存在statusCode,则默认使用这个statusCode
if (StringUtils.isNotBlank(response.getHeader('statusCode'))) {
statusCode = Integer.valueOf(response.getHeader('statusCode'));
}
if (statusCode == HttpServletResponse.SC_FOUND) {
// 获取Location响应头的值,进行重定向
String redirectLocation = response.getHeader('Location');
return new ModelAndView('redirect:' + redirectLocation);
} else if (statusCode == HttpServletResponse.SC_UNAUTHORIZED) {
// 重定向到登录页
return new ModelAndView('redirect:/login');
} else if (statusCode == HttpServletResponse.SC_FORBIDDEN) {
// 返回403页面
return new ModelAndView('403');
} else if (statusCode == HttpServletResponse.SC_NOT_FOUND) {
// 返回404页面
return new ModelAndView('404');
} else if (statusCode == HttpServletResponse.SC_INTERNAL_SERVER_ERROR) {
// 返回500页面,并传递errorMsg和errorCode到模板
ModelAndView modelAndView = new ModelAndView('500');
modelAndView.addObject('errorMsg', response.getHeader('errorMsg'));
modelAndView.addObject('errorCode', response.getHeader('errorCode'));
return modelAndView;
} else {
// 返回其他错误页面
return new ModelAndView('error');
}
}
}
(3) 测试效果a、404错误页,接口找不到第一步、打开登录页访问链接:http://localhost:8080/login输入账号、密码,点击登录进入首页 第二步、登录进入首页第三步、访问一个不存在的页面访问链接:http://localhost:8080/xxxx由于xxxx接口没有被定义过,界面会返回404 b、401错误,用户身份标识为空或无效这里我做的处理是,如果用户身份标识为空或无效那么我会默认跳转到登录页。测试方法是打开一个无痕界面,随便输入一个链接:http://localhost:8080/user/query由于Cookie中token不存在,所以我不管访问的是哪个链接,直接将状态码改为401,而CustomErrorController遇到401的错误,会默认重定向到登录页。 四、优化无痕窗口下的重新登录体验Filter异常的全局处理除了ErrorController之外,还可以通过自定义拦截器的方式实现,这两个东西会一个就行了。这里我再说一个高级一点的东西,举个例子:我在一个无痕窗口调用接口:http://localhost:8080/user/query?userName=小B因为当前窗口的Cookie中是没有token的,按照401错误的处理方式,我会重定向到登录页去。但这个有一个问题:重新登录之后,进入的是首页,不是调用user/query接口,我还得重新去找这个接口,重新输入参数。而且这要是一个分享页那就尴尬了,登陆完不知道对方分享了啥,用户体验会很差,那么有办法优化这个问题吗?答案是有,如何做,继续看。 1. 在WebFilter中获取当前请求的全路径所谓全路径就是“http://localhost:8080/user/query?userName=小B” ,如何获取,可以用我这个方法
2. 在WebFilter抛出401错误的地方设置httpServletResponse的header如下 httpServletResponse.setHeader('redirectURL',URLEncoder.encode(getRequestURL(httpServletRequest), 'utf-8'));
因为参数有可能是中文,这里需要用URLEncoder转下义。 3. 在CustomErrorController中获取到这个跳转链接
效果如下 可以看到我们在login后面携带了一个redirectURL参数 4. 登录提交时将redirectURL参数一并提交 @PostMapping('/login')
public void userLogin(@RequestParam(required = true) String userName,
@RequestParam(required = true) String password,
@RequestParam(required = false) String redirectURL,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
userService.login(userName, password, redirectURL, httpServletRequest, httpServletResponse);
}
5. 验证通过后重定向到redirectURL
|
|