适用要求:
1. 自定义error页面,并能对error信息进行封装
2.根据不同的异常返回不同的信息
3. 能根据请求地址(或其他信息)决策返回页面还是json
解决思路:
1. 自定义error页面
这个比较简单,继承ErrorController接口实现自己的Controller即可。
可参见:org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController
代码参照[非最终代码,部分命名可能不规范,参考用]:
package com.miniprogram.pa.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@Controller
public class MyControllerAdvice implements ErrorController {
private static Logger logger = LoggerFactory.getLogger(MyControllerAdvice.class);
private final static String[] apiPathStartSet = {"/api/"};
/**
* 接管 /error 统一处理系统error 一般发生在页面错误及资源不足的情况,这时候需要根据http status判断
* @param e
* @param response
* @param request
* @param session
* @param handler
* @return
* @throws IOException
*/
@RequestMapping("/error")
public ModelAndView error(HttpServletResponse response, HttpServletRequest request, HttpSession session, ModelMap modelMap) throws IOException {
// 此时的http status是正确的,但是request及reponse是转发到/error之后的
logger.error("发生错误在请求:{}",request.getServletPath());
// 返回
return null;
}
@Override
public String getErrorPath() {
return "/error";
}
}
error的设计对于使用要求3需要获取请求的ServletPath,然而,此时获取到的servletPath= ”/error“;
???? 无论请求任何不存在的地址,这里的ServletPath均为/error,并不是实际请求的地址,那么问题出现在哪呢?
在 error() 方法中,我们在返回前(return null;)断点调试一下,观察一下request及response里的内容:
再观察一下response
request及response确实已不是我们当初请求的那一对了,那么问题出现在哪里呢?
【原因】:Spring DispatcherServlet在根据请求path查找handler时没有找到对应的handler,此时会交由ErrorController去处理(执行原理自行解读源码),这也是为什么我们重新定义/error的原因;系统内置的ErrorController是org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController,这里有个假设,如果我们不直接实现接口ErrorController,而是通过继承BasicErrorController,是否可以取到原始的请求路径ServletPath?,取消自定义MyControllerAdvice,在BasicErrorController中断点,并观察:
失望,也就是说即使系统默认Controller也不能快捷的得到原始的ServerPath,那么眼下有两种办法,
- 从coyoteRequest中取
- 在请求处理前或者说dispatcherservlet分发至/error前记录下这个ServerPath。
先来看第一种:
所需要的数据在coyoteRequest里,获取路径:org.apache.catalina.core.ApplicationHttpRequest-->org.apache.catalina.connector.requestFacade(通过getRequest()获得)-->org.apache.catalina.connector.Request().getCoyoteRequest(),
,实际请求地址在((RequestFacade) ((ApplicationHttpRequest) request).request).request.coyoteRequest里。
研究一下获取路径:
RequestFacade facade = ((RequestFacade)(((ApplicationHttpRequest) request).getRequest()));这里可以轻松获取RequestFacade,但是RequestFacade 中并未提供其成员变量request的访问方法,该成员变量访问控制类型为peotect
由于这里并未提供直接访问的方法,需要换种方法获取[比如通过反射、子类继承获取Request],先看一下Request的实现
requestFacade和Request均实现了HttpServletRequest接口,但是两者没有继承关系,好在Request提供了getCoyoteRequest()方法,解决RequestFacade到Request的转化即可。
确定这个方案可行,先暂停反思一下,ApplicationHttpRequest、RequestFacade、Request均为包含在tomcat-embed-core.jar中,也就是说这些跟中间件强相关,如果更换中间件,这里就存在风险。
再来分析另一种方式
在请求处理前或者说dispatcherservlet分发至/error前记录下这个ServerPath。
由于是分发完成之前,如果使用Aop的话需要在Controller之上,Controller之上有什么呢?HandlerInterceptor?但是拦截器是在dispatcherservlet分发之后,Aop?由于这块没有思路设置切点为止,就先搁置,除此之外,有个更好的方式蹦出来,可以通过Filter,Filter在Servlet之前执行,且对Servlet的执行没有干扰。实现思路如下:
Filter里记录请求入口时记录到ThreadLocal里,ErrorController里取出;Filter代码如下:
import com.miniprogram.pa.LogGuidAspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.NamedThreadLocal;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class PathRecordFilter implements Filter {
private static Logger logger = LoggerFactory.getLogger(PathRecordFilter.class);
public static final ThreadLocal<String> servetPath = new NamedThreadLocal<String>("servetPath");
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
LogGuidAspect.init();
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
logger.info("filter :"+request.getServletPath());
servetPath.set(request.getServletPath());
chain.doFilter(servletRequest,servletResponse);
}
}
在自定义的ErrorController里取到这个值:PathRecordFilter.servetPath.get();
根据servletPath及Header里的Accept信息(该思路来源于BasicErrorController)判断是否返回json代码如下:
private final static String[] apiPathStartSet = {"/api/"};
public boolean isJsonRequest(String servletPath, String accept){
// 根据访问路径判断返回类型
if (!StringUtils.isEmpty(servletPath)){
for (String path : apiPathStartSet) {
if(servletPath.startsWith(path)) {
return true;
}
}
}
// 根据Header里的Accept决策返回类型 这里的决策类似org.springframework.boot.autoconfigure.web.servlet.error.BaseErrorController
if (!StringUtils.isEmpty(accept)
&& !accept.toLowerCase().contains(MediaType.TEXT_HTML_VALUE)){
return true;
}
return false;
}
对于自定义信息就简单了,有了原始的访问路径及response,可以知道请求servetPath时发生了什么,也就可以随意自定义错误信息了,当然如果需要其他的可以结合PathRecordFilter取出更多信息。
对于返回结果的处理,比较简单,这里直接给出源码:
/**
* 根据请求类型及数据返回
* @param response 响应
* @param respVo 自定义的返回信息
* @param returnJson 是否返回json true: 返回json,false:返回页面
*/
public ModelAndView dealReturn(boolean returnJson,HttpServletResponse response,BaseRespVo respVo) throws IOException {
// 处理返回
if (returnJson) {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(JSON.toJSONString(respVo));
return null;
}else {
response.setContentType("text/html;charset=utf-8");
return new ModelAndView("error").addObject("errorMsg", respVo);
}
}
至此/error的问题就解决了
再来看统一异常的处理
1. 类加注解:@ControllerAdvice
2. 方法加注解:@ExceptionHandler(Throwable.class)
统一异常获取返回类型可以通过HandlerMethod上的注解判断,
思路:HandlerMethod上有@ResponseBody注解或其所在Class有@ResponseBody或@RestController注解。
参考如下:
/**
* 按异常类型接管系统异常,对于404等异常,并不会接管,也不会进入该方法内(被系统处理至BasicErrorController 返回 getErrorPath()页面)
* @param e 捕获的异常
* @param response
* @param request
* @param session
* @param handler 处理本次请求的handler
* @return
* @throws IOException
*/
@ExceptionHandler(Throwable.class)
public ModelAndView throwableHandler(Throwable e, HttpServletResponse response, HttpServletRequest request, HttpSession session, HandlerMethod handler) throws IOException {
//输出异常信息
logger.error("系统统一处理",e);
// 获取servlet路径
String servletPath = request.getServletPath();
logger.error("发生错误在请求:{}",servletPath);
// 返回json或页面 true:json false:页面,默认页面
boolean returnJson = false;
// 确定返回数据
BaseRespVo respVo = getRespVoByThrowable(e);
// 根据处理的controller及方法上的注解判断判断,注意:找不到Controller(请求或页面)无法通过该方法确认
if (handler != null) {
if (handler.getMethodAnnotation(ResponseBody.class) != null
|| handler.getBeanType().getAnnotation(RestController.class) != null
|| handler.getBeanType().getAnnotation(ResponseBody.class) != null) {
returnJson = true;
}
}else {
// 一般来说 handler不会为null 以下代码不会走进来
returnJson = isJsonRequest(servletPath, request.getHeader("Accept"));
}
// 处理返回
return dealReturn(returnJson,response,respVo);
}
需要注意的是ExceptionHandler的源代码说明上并未指明可以使用HandlerMethod,但实际是可以。
回顾一下,其实/error跟全局异常的处理,可以放到一个方法里(需求该对handler != null 的判断为非当前Controller,或当前Servletpath不能为/error),Servletpath在/error请求里需要从pathRecordFilter里取。
error页面代码参考如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
error page
[[${errorMsg}]]
</body>
</html>
整体效果如下:
访问页面不存在且访问路径api开头(Header中Accept信息不对结果产生影响),返回json
访问页面不存在且访问路径非api开头(Header中Accept信息(text/html)对结果产生影响),返回页面
访问页面不存在且访问路径非api开头(Header中Accept信息(text/json)对结果产生影响),返回json
页面存在且报错
完整代码见附件(免费交流)
说明一下:
BusinessException.java 为自定义异常,可自行实现(继承Exception即可)
ReturnStatusEnum.java 为状态码枚举,结合返回信息要求可自行实现和修改
以上只是一种解决问题的思路和思考过程,仅作为记录和参考一下。