Springboot + 拦截器 + 过滤器 实现请求头&请求参数的组合验证
一、场景&需求:
开发场景中涉及到请求头的一些值校验,例如经典的Authorization-token令牌鉴权登陆,这种基本借助拦截器就可以快速实现相关功能。
但有些场景,不仅仅是对请求头进行校验,可能还需要对请求体中的参数做校验或处理,例如:请求数据中的敏感敏感数据脱敏。
二、解决方案 (大致2种):
1、直接controller层对参数进行相关处理/封装后,再往下传递来实现
优点:
· 简单且可读性高;
缺点:
· 修改工程量大,影响覆盖面广,假如该处理是针对全局来讲都需要,那么对应修改范围就是整个项目的controller层;
· 扩展性差,假如后续针对该处理有了升级或变更,那么相关的操作又得重新刷一遍到项目中,显得整个动作十分繁杂且重复。
· 涉及到需要请求头跟参数进行结合处理校验的场景,举个栗子:请求头Authorization-token可能并不能确定唯一性,需要与请求体中的字段userId来联合从而确定唯一,该情况需要在controller中额外添加了参数HttpservletRequest(工作量特别夯实)。
2、拦截器实现请求统一拦截
优点:
· 影响代码量小,借助spring的aop思想不需要对业务代码做修改
· 上述三种场景都可以满足,拦截器既获取servletRequest,可以截取请求头信息,也可以对参数进行截取
前提:
· 对springboot的注解具备一定理解,熟悉拦截器和过滤器的使用,以及对servlet框架一定的理解(springboot的webmvc框架底层还是依赖servlet)
三、使用说明(以拦截器实现请求统一拦截) :
1、需要明白请求方式GET和POST分别用什么方式去提供参数,以及在拦截器中怎么获取到请求体参数:
1、请求方式为GET时,参数是通过放在url后面显性发送数据(query’param),在拦截器中可以通过 request.getParameter(arg) 方法来获取到这些请求参数
2、请求方式为POST时,参数是以表单(form-date)的形式隐性发送数据,在拦截器中可以通过 request.getInputStream() 方法来获取到这些请求参数,但是其是以流的形式存在,需要进行解析(框架servlet中,form表单的数据是以流的形式去存储)
2、需要了解以上俩种方式处理对本次请求后续的影响或弊端:
·针对GET请求中,拦截器的处理方式是没有问题的;
·针对POST请求,也就是第二种方式获取参数时,会存在一个问题:当在拦截器中通过 request.getInputStream() 获取了流之后,流程走到接口层会抛出如下异常:
(org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing)
3、POST请求处理方式出现异常的原因在于:
之前也提到,在servlet中,form表单数据是通过流的方式存储,这个输入流类叫ServletInputStream(代码块1),通过代码块可以知道该类继承了 InputStream ,InputStream 类主要就是针对流提供了一套操作方法,其中readLine(xx)是对流进行读取。然后再去观察InputStream中的子类可以发现为了实现流能够重复读取,会用一个文本指针的变量(offset)用于记录当前流被读取的位置,而reset()方法就是专门把offset置为0,实现文本流重头开始读取。
回到本文中的ServletInputStream中,可以发现该流仅仅只是重写了readLIne方法,并没有去重写reset()方法(InputStream的reset方法是直接抛出异常IOException),说明ServletInputStream的流只支持读取一次,在servlet中,ServletInputStream流是专门提供给接口层去获取参数的,当我们在拦截器中把这个流获取并解析了之后,后面的@RequestBody 就会因为该流的特性导致无法正常获取数据,从而抛出了上文所说的异常。
代码块1:ServletInputStream结构
public abstract class ServletInputStream extends InputStream {
protected ServletInputStream() {
}
public int readLine(byte[] b, int off, int len) throws IOException {
if (len <= 0) {
return 0;
} else {
int count = 0;
int c;
while((c = this.read()) != -1) {
b[off++] = (byte)c;
++count;
if (c == 10 || count == len) {
break;
}
}
return count > 0 ? count : -1;
}
}
public abstract boolean isFinished();
public abstract boolean isReady();
public abstract void setReadListener(ReadListener var1);
四、案例代码(以拦截器实现请求统一拦截) :
1、借助过滤器(代码块2)对post请求的request进行拦截并二次封装
代码块2:Filter 结构
@WebFilter(urlPatterns = "/*",filterName = "authRequestFilter")
@Order(99)
public class AuthRequestFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
ServletRequest requestWrapper =null;
if (servletRequest instanceof HttpServletRequest){
HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
String method = httpServletRequest.getMethod();
String contentType = httpServletRequest.getContentType();
// post请求(多媒体表单例外)
if (HttpMethod.POST.equals(method) && StringUtils.isNotEmpty(contentType) && !contentType.equals(MediaType.MULTIPART_FORM_DATA_VALUE)) {
// 将httpServletRequest封装成 RequestWrapper(RequestWrapper里面主要就是针对表单数据流进行解析并改变 ServletInputStream 不能二次读取的特性)
// 拦截器就可以直接通过body该属性去获取了,不需要解析流,避免后续的参数去获取流的时候出现异常 原先就可以对数据流表单进行解析,放到RequestWrapper.body属性中
requestWrapper = new RequestWrapper(httpServletRequest);
}
}
// 继续执行后续的过滤器链,将包装好的servletRequest往下传递
// 过滤器链执行顺序在拦截器之前,因此可以实现偷梁换柱的效果
if (Objects.isNull(requestWrapper)){
filterChain.doFilter(servletRequest, servletResponse);
} else {
filterChain.doFilter(requestWrapper, servletResponse);
}
}
@Override
public void destroy() {
}
}
2、过滤器对request只是做到拦截和封装,真正的数据流处理工作是需要 RequestWrapper 类(代码块3)来处理,RequestWrapper 类要处理的工作主要分为2点:
- a.首先要对流进行解析,因此肯定要有需要实现流读写的方法,这是第一个工作
- b.RequestWrapper 需要继承 HttpServletRequestWrapper,HttpServletRequestWrapper 可以理解为是将request的一些信息进行包装在一起,比如header头、cookie、session、ServletInputStream 等等),其中有2个方法 getInputStream()、getReader() 就是获取流和读取流的关键,而我们的第二步的任务是要为了让数据流能够二次读取
代码块3:RequestWrapper 结构
public class RequestWrapper extends HttpServletRequestWrapper {
// 1MB
private static final int BUFFER_SIZE =1024*1024*1;
private final String body;
public RequestWrapper(HttpServletRequest request) {
super(request);
StringBuilder stringBuilder = new StringBuilder();
BufferedReader bufferedReader = null;
InputStream inputStream = null;
try {
inputStream = request.getInputStream();
if (inputStream != null) {
bufferedReader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
// 设置成1MB的缓冲字节数组,1char = 1byte
char[] charBuffer = new char[BUFFER_SIZE];
int bytesRead = -1;
// 借助 bufferedReader 将数据从缓冲区中读到字节数组中,每次读取1MB位
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
// stringbuilder将char数组进行拼接,bytesRead最大到1MB
stringBuilder.append(charBuffer, 0, bytesRead);
}
} else {
stringBuilder.append("");
}
} catch (IOException ex) {
} finally {
if (inputStream != null) {
try {
// 流进行关闭,避免oom
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 将请求参数从流中转移到stringbuilder中,在通过tostring存到body变量
// body变量设为final,保证只赋值一次
body = stringBuilder.toString();
}
/**
* 重写getInputStream方法就是用于给后续的servlet在@RequestBody获取参数值的时候,去调用获取流的时候从而调用该重写方法
* @return
* @throws IOException
*/
@Override
public ServletInputStream getInputStream() throws IOException {
// 因为之前的inputstream已经被关闭,这里通过已经存储下来的body重新写入到一个新的inputstream中
// 这里采用 byteArrayInputStream 来替换原先的 ServletInputStream
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
/**
* pos重置为0,又可以重复读取
* @throws IOException
*/
@Override
public synchronized void reset() throws IOException {
byteArrayInputStream.reset();
}
};
return servletInputStream;
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8);
}
public String getBody() {
return this.body;
}
}
3、 最后一步,回到拦截器(代码块4)中对数据流的获取
经过RequestWrapper 的将ServletInputStream 换为ByteArrayInputStream后,拦截器中就可以通过 request.getInputStream() 实现对数据进行解析,而解析完之后呢,需要调用 ServletInputStream.reset()方法 将读写的指针重置为0,这样就可以让后续的请求方法再次读取数据了,表单数据流不能二次读写的问题就解决了
但是,聪明的小伙们们不知道有没有发现或思考,因为既然RequestWrapper 已经对流做了一次解析了,再将流转为byte最后封装进ByteArrayInputStream中,那为何不把这个已经解析出来的数据进行缓存呢,然后再拦截器中直接使用,这样就避免了拦截器再次去读取流的内存损耗了
@Component
public class AuthRequestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// instanceof 判断实例是否属于该类或继承至该类,也就是可以是自身或父类,但不能是子类,虽然编译能通过,但是实际运行的时候会抛异常
if (!(handler instanceof HandlerMethod) && request instanceof RequestWrapper) {
// 必须是RequestWrapper实例,否则是无法往下走的,因为过滤器会把符合场景的request进行封装成 RequestWrapper,如果不是该实例说明场景很明显不符合
return true;
}
// 需要验证的请求头场景xxx
if (StringUtils.isEmpty(auth) || Objects.isNull(authorizationUserClass)) {
// 请求头有,但参数不是继承该类,说明没有userId关键字段,直接异常
throw new UnAuthorizationException("假装验证不通过,抛出异常");
}
if (HttpMethod.GET.equals(request.getMethod()) && StringUtils.isEmpty(userId = request.getParameter("xxx"))) {
throw new UnAuthorizationException("假装验证不通过,抛出异常");
}
// (RequestWrapper) request).getBody() 直接获取已解析好的数据缓存直接使用,避免拦截器二次解析流带来的损耗
Map map = JSONObject.parseObject(((RequestWrapper) request).getBody(), Map.class);
if (HttpMethod.POST.equals(request.getMethod()) && !Objects.isNull(map.get("xxx"))) {
throw new UnAuthorizationException("假装验证不通过,抛出异常");
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
最后以上的这些都是我结合框架的个人理解,如有出入,烦请告知,感激不尽!