浅谈web项目中如何优雅的进行异常处理三:异常处理实践及扩展

分类: WEB开发 0人评论 selfly 1月前发布

前言

上一篇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 {};
}

codespages是一对一的关系。

当然需要在异常处理中判断:

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之后设置了一堆的argumentResolversreturnValueHandlers用来处理参数和返回值,我们要扩展的就是这里的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数据,都能够进行完美的支持了!