前言
上一篇Spring MVC中的异常处理我们学习了Spring MVC的异常处理方式,这次我们来探讨如何结合Spring MVC来对异常进行更优雅的处理和扩展。
准备工作
还是基于前面登录的例子,我们一步步来进行完善,当然这里不考虑假定业务场景的逻辑性,只谈实现。
视图层基于FreeMarker,这里我们准备了如下几个页面:
- login.ftl 登录表单页面
- success.ftl 登录成功跳转页面
- biz-error.ftl 已知业务错误页面
- error.ftl 全局错误页面
- username-illegal.ftl 用户名非法页面
- password-illegal.ftl 密码非法页面
自定义业务异常基类BizException
,项目中所有的业务异常类都应该继承它进行扩展。
public class BizException extends RuntimeException {
private String errorCode;
private String errorMessage;
public BizException(String errorCode, String errorMessage) {
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
//......
}
定义了登录业务异常类LoginException
:
public class LoginException extends BizException {
public LoginException(String code, String msg) {
super(code,msg);
}
}
业务层异常模拟
@Service
public class UserServiceImpl implements UserService {
public User login(String username, String password) {
//模拟到用户名非法页面
if (StringUtils.length(username) < 5) {
throw new LoginException("10001", "用户名非法");
}
//模拟到密码非法页面
if (StringUtils.length(password) < 8) {
throw new LoginException("10002", "密码非法");
}
//模拟仍旧回到登录页面
if (StringUtils.length(password) % 2 == 0) {
throw new LoginException("10003", "用户不存在");
}
//模拟未知异常
if (StringUtils.length(username) % 2 == 0) {
throw new RuntimeException("登录错误");
}
return new User();
}
}
Controller调用:
@Controller
public class UserController {
@Autowired
private UserService userService;
@GetMapping("login")
public String login() {
return "login";
}
@PostMapping("login")
public String login(String username, String password, Model model) {
User user = userService.login(username, password);
model.addAttribute("username", username);
return "success";
}
}
全局异常处理
全局异常处理肯定是必不可少的,后面的操作和扩展主要也有这部分来完成。
Spring Boot已经对全局异常进行了拦截,默认是到“Whitelabel Error Page”,并不友好。
自定义的全局异常处理:
@ControllerAdvice
public class GlobalExceptionAdvice {
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionAdvice.class);
@ExceptionHandler(BizException.class)
public ModelAndView handleBizException(HttpServletRequest req, BizException ex) {
LOGGER.info("已知业务异常,URI:" + req.getRequestURI(), ex);
return buildModelAndView(req, ex.getErrorCode(), ex.getErrorMessage(), "biz-error");
}
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public ModelAndView handleException(HttpServletRequest req, Exception ex) {
LOGGER.info("未知的异常,URI:" + req.getRequestURI(), ex);
return buildModelAndView(req, "-1", "出现未知错误", "error");
}
private ModelAndView buildModelAndView(HttpServletRequest req, String errorCode, String errorMsg, String viewName) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("success", false);
modelAndView.addObject("errorCode", errorCode);
modelAndView.addObject("errorMsg", errorMsg);
modelAndView.addObject("requestURI", req.getRequestURI());
modelAndView.setViewName(viewName);
return modelAndView;
}
}
拦截了全局异常,并对自定义的业务异常BizException
做了区别处理。
现在当我们登录:
- admin/123456789 登录成功,到success页面,目标已达成
- admin/123 密码非法,到biz-error页面,需要到password-illegal页面
- adm/12345678 用户名非法,到biz-error页面,需要到username-illegal页面
- admin/1234567890 用户不存在,到biz-error页面,需要回到login页面
- admin2/123456789 未知错误,到error页面,理论目标已达成,但是本处指定回到登录页面比较合适
可见,只有最正常的登录成功流程目标已经达成,其它的还需要我们一步步来实现。
回到登录页(指定页)
要回到登录页或者其它的页面,又不能在controller中显式处理,那么只能找一个地方来指定并统一处理。
通过上一篇文章我们知道Spring的异常处理类ExceptionHandlerExceptionResolver
中的doResolveHandlerMethodException
方法参数中有具体的HandlerMethod
,这个就是当前执行的RequestMapping
的方法,那么在@ControllerAdvice
中能不能拿到这个参数呢?
分析源码我们发现,在doResolveHandlerMethodException
方法中是通过拿到具体的ServletInvocableHandlerMethod
来进行处理的,其中有个对方法参数的处理方法,具体见下图:
我们看到有个HandlerMethod
参数的处理,也就是说对于@ControllerAdvice
中的异常处理方法是支持传入HandlerMethod
参数的。
有了HandlerMethod
那事情就好办多了,我们可以拿到当前RequestMapping
方法的所有信息,也就能添加自定义注解之类的操作。
定义注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface ErrorPage {
//允许为空,后续还有其它参数信息
String value() default "";
}
在方法上加上注解,指定页面:
@PostMapping("login")
@ErrorPage("login")
public String login(String username, String password, Model model) {
User user = userService.login(username, password);
model.addAttribute("username", username);
return "success";
}
修改@ControllerAdvice中异常处理:
private ModelAndView buildModelAndView(HttpServletRequest req, String errorCode, String errorMsg, HandlerMethod handlerMethod, String viewName) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("success", false);
modelAndView.addObject("errorCode", errorCode);
modelAndView.addObject("errorMsg", errorMsg);
modelAndView.addObject("requestURI", req.getRequestURI());
ErrorPage errorPage = handlerMethod.getMethodAnnotation(ErrorPage.class);
if (errorPage != null) {
String value = errorPage.value();
modelAndView.setViewName(value);
} else {
modelAndView.setViewName(viewName);
}
return modelAndView;
}
现在我们再来看:
- admin/123456789 登录成功,到success页面,目标达成
- admin/123 密码非法,回到login页面,需要到password-illegal页面
- adm/12345678 用户名非法,回到login页面,需要到username-illegal页面
- admin/1234567890 用户不存在,回到login页面,目标达成
- admin2/123456789 未知错误,回到login页面,目标达成
参数回显
回到登录页面(表单提交页)是成功了,但是通常来说表单提交不成功都是需要数据回显的,所以这里也要稍微处理下。
Map<String, String[]> parameterMap = request.getParameterMap();
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String[] values = entry.getValue();
if (values.length == 1){
modelAndView.addObject(entry.getKey(),values[0]);
}else {
modelAndView.addObject(entry.getKey(),values);
}
}
这样当表单提交不成功返回时就有数据进行回显了,使用的数据参数名和提交的参数名相同。
但是这样简单的处理虽然能满足大部分情况,但还是会引发一些问题,比如提交的确实是数组但只有一个元素时,回显就会失败。还有因为HTTP提交的特性当有一些是数字数据时全都变成了字符串,可能导致处理上的问题,所以这里我们要定义一个扩展,当有必要时可以让用户自己处理回显的数据。
定义一个处理接口:
public interface FormDataReturnHandler {
void addFormData(HttpServletRequest request, Map<String,Object> data);
}
注解中添加参数:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface ErrorPage {
String value() default "";
Class<? extends FormDataReturnHandler> formDataReturnHandler() default FormDataReturnHandler.class;
}
修改异常处理中对回显参数的处理:
ErrorPage errorPage = handlerMethod.getMethodAnnotation(ErrorPage.class);
if (errorPage != null) {
String value = errorPage.value();
modelAndView.setViewName(value);
Map<String, Object> returnFormData = new HashMap<>();
Class<? extends FormDataReturnHandler> formDataReturnHandlerClass = errorPage.formDataReturnHandler();
if (FormDataReturnHandler.class != formDataReturnHandlerClass) {
FormDataReturnHandler formDataReturnHandler = ClassUtils.newInstance(formDataReturnHandlerClass);
formDataReturnHandler.addFormData(request, returnFormData);
} else {
Map<String, String[]> parameterMap = request.getParameterMap();
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String[] values = entry.getValue();
if (values.length == 1) {
returnFormData.put(entry.getKey(), values[0]);
} else {
returnFormData.put(entry.getKey(), values);
}
}
}
modelAndView.addAllObjects(returnFormData);
} else {
modelAndView.setViewName(viewName);
}
这样就支持用户去自己处理回显的数据了。
不同页面的跳转
其实有了注解,扩展起来很容易,只需要添加相应的错误码和对应的页面就行了。
仍然是在注解中添加参数:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface ErrorPage {
String value() default "";
Class<? extends FormDataReturnHandler> formDataReturnHandler() default FormDataReturnHandler.class;
String[] codes() default {};
String[] pages() default {};
}
codes
和pages
是一对一的关系。
当然需要在异常处理中判断:
String[] codes = errorPage.codes();
String[] pages = errorPage.pages();
if (codes.length != pages.length) {
throw new RuntimeException("异常页面配置错误");
}
for (int i = 0; i < codes.length; i++) {
if (codes[i].equals(errorCode)) {
page = pages[i];
break;
}
}
使用:
@ErrorPage(value = "login", codes = {"10001", "10002"}, pages = {"username-illegal", "password-illegal"})
现在我们再来看:
- admin/123456789 登录成功,到success页面,目标达成
- admin/123 密码非法,到password-illegal页面,目标达成
- adm/12345678 用户名非法,到username-illegal页面,目标达成
- admin/1234567890 用户不存在,回到login页面,目标达成
- admin2/123456789 未知错误,回到login页面,目标达成
目标已经全部达成,但是还有一个没有处理,那就是HTTP状态码
处理HTTP状态码
HTTP状态码,本来是由ResponseStatusExceptionResolver
来处理的,主要代码如下:
protected ModelAndView resolveResponseStatus(ResponseStatus responseStatus, HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) throws Exception {
int statusCode = responseStatus.code().value();
String reason = responseStatus.reason();
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
}
else {
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
在我们自定义了@ControllerAdvice
处理异常之后,就不再进入ResponseStatusExceptionResolver
了,需要我们自己处理,其实也十分简单,判断有没有@ResponseStatus
注解即可:
ResponseStatus responseStatus = AnnotationUtils.findAnnotation(ex.getClass(), ResponseStatus.class);
if (responseStatus != null){
response.setStatus(responseStatus.value().value());
}
对于未知的异常,统一返回500:
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
如果想要对HTTP状态码进行更细粒度的控制,可以在异常类里面添加HTTP状态码的属性,在具体抛出的时候指定。
到这里就差不多了,对于开发者来说只需要一个注解即可搞定,而且支持数据的回显。
当然,如果你的表单页面操作很复杂,需要初始化数据也很多,回显的时候使用FormDataReturnHandler
处理数据可能也会有点麻烦。
但是,不要忘了,这个只是我们解决大部分情况的统一处理,你仍然可以添加@ControllerAdvice
处理你自己想要的异常,还可以像上篇文章中说到的在Controller中添加@ExceptionHandler
处理局部方法异常。甚至,你仍然可以使用try catch
。
RESTFul的处理
上面对于各个流程的处理都是针对有页面的情况,现在REST接口的流行很多Controller方法都是直接返回json数据并没有页面,这时候又该怎么处理呢?
如果只有纯粹的RESTFul接口,那么处理起来也很方便,上一篇文章中已经提到过,像下面这样:
@ExceptionHandler(BizException.class)
@ResponseBody
public ErrorResult handleBizException(HttpServletRequest req, BizException ex) {
LOGGER.info("已知业务异常,URI:" + req.getRequestURI(), ex);
return new ErrorResult(req.getRequestURI(), ex.getErrorCode(), ex.getErrorMessage());
}
只需要在@ExceptionHandler
方法上添加@ResponseBody
注解,跟平时的写法也没什么两样。
但是项目中大部分的情况是即有页面又有返回json数据的接口,那又该如何进行区分呢,总不能app在调用json数据接口的时候出错了也返回一个页面给人家吧!
Spring MVC并没有直接提供这方面的支持,但是提供了扩展,Spring的易扩展性是毋庸置疑的,不过对于新手来说可能就不是那么容易了。
扩展HandlerMethodReturnValueHandler
通过源码分析我们可以发现Spring MVC的异常处理都是在ExceptionHandlerExceptionResolver
中进行,并通过ServletInvocableHandlerMethod
来完成,以下是部分代码:
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod, Exception exception) {
ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);
if (exceptionHandlerMethod == null) {
return null;
}
exceptionHandlerMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
exceptionHandlerMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
//......
}
可以看到在获取了ServletInvocableHandlerMethod
之后设置了一堆的argumentResolvers
和returnValueHandlers
用来处理参数和返回值,我们要扩展的就是这里的returnValueHandlers
。
先来看一下Spring在初始化时提供了哪些默认的HandlerMethodReturnValueHandler
,具体代码也在ExceptionHandlerExceptionResolver
中:
protected List<HandlerMethodReturnValueHandler> getDefaultReturnValueHandlers() {
List<HandlerMethodReturnValueHandler> handlers = new ArrayList<HandlerMethodReturnValueHandler>();
// Single-purpose return value types
handlers.add(new ModelAndViewMethodReturnValueHandler());
handlers.add(new ModelMethodProcessor());
handlers.add(new ViewMethodReturnValueHandler());
handlers.add(new HttpEntityMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyAdvice));
// Annotation-based return value types
handlers.add(new ModelAttributeMethodProcessor(false));
handlers.add(new RequestResponseBodyMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyAdvice));
// Multi-purpose return value types
handlers.add(new ViewNameMethodReturnValueHandler());
handlers.add(new MapMethodProcessor());
// Custom return value types
if (getCustomReturnValueHandlers() != null) {
handlers.addAll(getCustomReturnValueHandlers());
}
// Catch-all
handlers.add(new ModelAttributeMethodProcessor(true));
return handlers;
}
其中ModelAndViewMethodReturnValueHandler
就是用来处理返回值为ModelAndView
类型的,RequestResponseBodyMethodProcessor
是一个参数和返回值处理统一的实现类,在实现HandlerMethodReturnValueHandler
接口的同时也实现了HandlerMethodArgumentResolver
接口,这里用来处理返回@ResponseBody
的结果。
要注意的是,自定义的CustomReturnValueHandlers
加载在默认的之后,也就是说在扩展returnValueHandler
时处理的类型不能是默认已有的,否则永远走不到你的自定义代码。
来看一下HandlerMethodReturnValueHandler
的接口定义:
public interface HandlerMethodReturnValueHandler {
boolean supportsReturnType(MethodParameter returnType);
void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;
}
很简单两个方法,supportsReturnType
判断是否支持,handleReturnValue
具体处理。
再看一下ModelAndViewMethodReturnValueHandler
类中的supportsReturnType
方法实现:
public boolean supportsReturnType(MethodParameter returnType) {
return ModelAndView.class.isAssignableFrom(returnType.getParameterType());
}
这里判断是否是ModelAndView
类使用了isAssignableFrom
方法,所有的子类也都会被匹配,我们就不能继承它来扩展了,必须定义一个全新的类型。
定义返回结果类RestAndView
:
public class RestAndView {
private ModelAndView modelAndView;
private Object restData;
//getter and setter
}
编写自定义的RestAndViewMethodReturnValueHandler
类,继承于ModelAndViewMethodReturnValueHandler
:
public class RestAndViewMethodReturnValueHandler extends ModelAndViewMethodReturnValueHandler {
@Autowired
private HandlerExceptionResolver handlerExceptionResolver;
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return RestAndView.class.isAssignableFrom(returnType.getParameterType());
}
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue == null) {
mavContainer.setRequestHandled(true);
return;
}
RestAndView restAndView = (RestAndView) returnValue;
if (restAndView.getRestData() != null) {
HandlerMethodReturnValueHandler responseBodyReturnValueHandler = this.getResponseBodyReturnValueHandler();
responseBodyReturnValueHandler.handleReturnValue(restAndView.getRestData(), returnType, mavContainer, webRequest);
} else {
ModelAndView mav = restAndView.getModelAndView();
super.handleReturnValue(mav, returnType, mavContainer, webRequest);
}
}
public HandlerMethodReturnValueHandler getResponseBodyReturnValueHandler() {
HandlerExceptionResolverComposite handlerExceptionResolverComposite = (HandlerExceptionResolverComposite) handlerExceptionResolver;
List<HandlerExceptionResolver> exceptionResolvers = handlerExceptionResolverComposite.getExceptionResolvers();
for (HandlerExceptionResolver exceptionResolver : exceptionResolvers) {
if (exceptionResolver instanceof ExceptionHandlerExceptionResolver) {
ExceptionHandlerExceptionResolver exceptionHandlerExceptionResolver = (ExceptionHandlerExceptionResolver) exceptionResolver;
HandlerMethodReturnValueHandlerComposite returnValueHandlers = exceptionHandlerExceptionResolver.getReturnValueHandlers();
List<HandlerMethodReturnValueHandler> handlers = returnValueHandlers.getHandlers();
for (HandlerMethodReturnValueHandler handler : handlers) {
if (handler instanceof RequestResponseBodyMethodProcessor) {
return handler;
}
}
}
}
//正常配置不可能发生
throw new RuntimeException("没有找到RequestResponseBodyMethodProcessor");
}
}
因为内置的RequestResponseBodyMethodProcessor
初始化时有messageConverters
, contentNegotiationManager
, responseBodyAdvice
等参数,我们图方便直接注入HandlerExceptionResolver
获取它里面的RequestResponseBodyMethodProcessor
就好。
修改@ControllerAdvice
中的异常处理代码,主要部分如下:
private RestAndView buildModelAndView(HttpServletRequest request, String errorCode, String errorMsg, HandlerMethod handlerMethod, String viewName) {
String requestURI = request.getRequestURI();
RestAndView restAndView = new RestAndView();
if (handlerMethod.getMethodAnnotation(ResponseBody.class) != null || StringUtils.endsWith(requestURI, ".json")) {
ErrorResult errorResult = new ErrorResult(requestURI, errorCode, errorMsg);
restAndView.setRestData(errorResult);
} else {
ModelAndView modelAndView = new ModelAndView();
//.....
restAndView.setModelAndView(modelAndView);
}
return restAndView;
}
这样代码我们就写完了,怎么让它配置进去生效呢?
前面我们知道Spring允许你重写ExceptionHandlerExceptionResolver
完全实现一套你自己的异常处理方案,这过程中注册我们自己的returnValueHandler
当然也可以,但是这太麻烦。
Spring Boot也提供了一套扩展的API,前面的源码中也有加载CustomReturnValueHandlers
部分,所以我们来看一下Spring Boot提供的WebMvcConfigurerAdapter
,果然发现有以下方法:
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> returnValueHandlers) {
}
这正是我们所需要的,把我们实现的RestAndViewMethodReturnValueHandler
添加进去:
@Bean
public RestAndViewMethodReturnValueHandler restAndViewMethodReturnValueHandler() {
return new RestAndViewMethodReturnValueHandler();
}
@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> returnValueHandlers) {
returnValueHandlers.add(restAndViewMethodReturnValueHandler());
}
这样就生效了,就这么简单!
现在,不管我们的RequestMapping
方法是返回页面还是JSON数据,都能够进行完美的支持了!