浙江省建设培训中心网站首页,扁平化网站特效,做网站建设的技巧,专业做网站的公司文章目录一、前言1、概述2、过滤器与拦截器异同2.1 简介2.2 异同2.3 总结3、Filters vs HandlerInterceptors二、过滤器1、概述2、生命周期2.1 生命周期概述2.2 基于函数回调实现原理3、自定义过滤器两种实现方式3.1 WebFilter注解注册3.2 过滤器#xff08;配置类注册过滤器WebFilter注解注册3.2 过滤器配置类注册过滤器4、实战OncePerRequestFilter三、拦截器1、概述2、自定义拦截器2.1 生命周期2.2 代码示例2.3 多拦截器示例3.4 静态资源被拦截问题4、实战demo一、前言 常用项目编写规范参考Spring Boot后端接口规范 1、概述
前面讲到数据统一响应、全局异常等常用后端框架那么随着项目的开发需要对请求进行校验(参数校验、前面校验等)不符合的不进入后端业务逻辑提前返回并抛出异常。一般实现方法有拦截器和过滤器这两者都可以实现对应的功能可以根据自己喜好进行编写。
过滤器一般完成通用的操作。如登录验证、统⼀编码处理、敏感字符过滤常见的过滤器用途主要包括对用户请求进行统一认证、对用户的访问请求进行记录和审核、对用户发送的数据进行过滤或替换、转换图象格式、对响应内容进行压缩以减少传输量、对请求或响应进行加解密处理、触发资源访问事件等拦截器采用AOP的设计思想 它跟过滤器类似 用来拦截处理方法在之前和之后执行一些 跟主业务没有关系的一些公共功能比如权限控制、日志、异常记录、记录方法执行时间
下面来讲讲这两者的异同和代码demo。
2、过滤器与拦截器异同
2.1 简介
过滤器(filter)和拦截器(Inteceptor)的执行顺序概览 2.2 异同
过滤器和拦截器触发时机不一样过滤器是在请求进入容器后但请求进入servlet之前进行预处理的。请求结束返回也是是在servlet处理完后返回给前端之前拦截器可以获取IOC容器中的各个bean而过滤器就不行因为拦截器是spring提供并管理的spring的功能可以被拦截器使用在拦截器里注入一个service可以调用业务逻辑。而过滤器是JavaEE标准只需依赖servlet api 不需要依赖spring过滤器的实现基于回调函数。而拦截器代理模式的实现基于反射Filter是依赖于Servlet容器属于Servlet规范的一部分而拦截器则是独立存在的可以在任何情况下使用Filter的执行由Servlet容器回调完成而拦截器通常通过动态代理反射的方式来执行Filter的生命周期由Servlet容器管理而拦截器则可以通过IoC容器来管理因此可以通过注入等方式来获取其他Bean的实例因此使用会更方便
2.3 总结
过滤器可以修改request而拦截器不能 过滤器需要在servlet容器中实现拦截器可以适用于javaEEjavaSE等各种环境拦截器可以调用IOC容器中的各种依赖而过滤器不能过滤器只能在请求的前后使用而拦截器可以详细到每个方法
具体的执行调用流程如下 过滤器Filter 可以拿到原始的http请求但是拿不到你请求的控制器和请求控制器中的方法的信息拦截器Interceptor可以拿到你请求的控制器和方法却拿不到请求方法的参数切片Aspect: 可以拿到方法的参数但是却拿不到http请求和响应的对象 这里说一下为什么spring security使用过滤器而不是拦截器。因为作为一个通用的安全框架不应该耦合其他web框架的元素。很显然拦截器是spring mvc或struts等框架提供的如果基于拦截器势必耦合这些框架就做不到通用了
3、Filters vs HandlerInterceptors
Filter 是 Servlet 规范中的而 HandlerInterceptor 是 Spring 中的一个概念拦截器位置相对于过滤器更靠后精细的预处理任务适用于拦截器如授权检查等内容处理相关或通用的流程非常适合用过滤器如上传表单、zip 压缩、图像处理、日志记录请求、身份验证等HandlerInterceptor 的 postHandle 方法允许我们向视图添加更多模型对象但不能更改 HttpServletResponse因为它已经被提交了过滤器的 doFilter 方法比拦截器的 postHandle 更通用。我们可以在过滤器中改变请求或响应并将其传递给链甚至阻止请求的处理HandlerInterceptor 提供了比过滤器更精细的控制因为我们可以访问实际的目标 handler甚至可以检查 handler 方法是否有某个特定的注解
二、过滤器
1、概述 过滤器Filter是处于客户端与服务器目标资源之间的⼀道过滤技术当访问服务器的资源时过滤器可以将请求拦截下来完成⼀些特殊的功能 执行是在Servlet之前客户端发送请求时会先经过Filter再到达目标Servlet中响应时 会根据执行流程再次反向执行Filter⼀般用于完成通用的操作。如登录验证、统⼀编码处理、敏感字符过滤。常见的过滤器用途主要包括对用户请求进行统一认证、对用户的访问请求进行记录和审核、对用户发送的数据进行过滤或替换、转换图象格式、对响应内容进行压缩以减少传输量、对请求或响应进行加解密处理、触发资源访问事件等
2、生命周期
2.1 生命周期概述
过滤器的配置比较简单直接实现Filter 接口即可也可以通过WebFilter注解实现对特定URL拦截看到Filter 接口中定义了三个方法。
init() 该方法在容器启动初始化过滤器时被调用它在 Filter 的整个生命周期只会被调用一次。注意这个方法必须执行成功否则过滤器会不起作用doFilter() 容器中的每一次请求都会调用该方法比如定义一个 Filter 拦截 /path/*那么每一个匹配 /path/* 访问资源的请求进来时都会执行此方法 FilterChain 用来调用下一个过滤器 Filter。不同的过滤器通过Order()排序注解执行顺序destroy() 当容器销毁 过滤器实例时调用该方法一般在方法中销毁或关闭资源在过滤器 Filter 的整个生命周期也只会被调用一次
2.2 基于函数回调实现原理
在我们自定义的过滤器中都会实现一个 doFilter()方法这个方法有一个FilterChain 参数而实际上它是一个回调接口。ApplicationFilterChain是它的实现类 这个实现类内部也有一个 doFilter() 方法就是回调方法。
public interface FilterChain {void doFilter(ServletRequest var1, ServletResponse var2) throws IOException, ServletException;
}
ApplicationFilterChain里面能拿到我们自定义的xxxFilter类在其内部回调方法doFilter()里调用各个自定义xxxFilter过滤器并执行 doFilter() 方法
public final class ApplicationFilterChain implements FilterChain {Overridepublic void doFilter(ServletRequest request, ServletResponse response) {...//省略internalDoFilter(request,response);}private void internalDoFilter(ServletRequest request, ServletResponse response){if (pos n) {//获取第pos个filter ApplicationFilterConfig filterConfig filters[pos]; Filter filter filterConfig.getFilter();...filter.doFilter(request, response, this);}}}
而每个xxxFilter 会先执行自身的 doFilter() 过滤逻辑最后在执行结束前会执行filterChain.doFilter(servletRequest, servletResponse)也就是回调ApplicationFilterChain的doFilter() 方法以此循环执行实现函数回调
3、自定义过滤器两种实现方式
不论是注解配置还是Java配置都需要在启动类上加上ServletComponentScan(过滤器路径)注解过滤路径可以不写(或者直接注入容器交给spring管理)。注解注册和Java配置类注册它们的自定义过滤器类都是一样的只不过注册过程一个是通过WebFilter注解一个是通过Java配置类注册Bean。
3.1 WebFilter注解注册
/*** 自定义注解过滤器实现* Filter的包是javax.servlet.Filter的* filterName过滤器名称需要唯一不能重复* urlPatterns要拦截的url资源路径注意通配符是一个星号**/
Order(2)//排序注解执行顺序
WebFilter(filterName filterAnnotation,urlPatterns {/study/interfaces/v1/user})
public class filterAnnotation implements Filter {//初始化操作只会执行一次Overridepublic void init(FilterConfig filterConfig) throws ServletException {System.out.println(filterAnnotation--初始化Filter);}//进入到过滤资源之前和之后做的事情Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {System.out.println(filterAnnotation--进入Target Resource之前做的事情);filterChain.doFilter(servletRequest,servletResponse);System.out.println(filterAnnotation--处理返回的Response);}//销毁只会在项目停止或者重新部署的时候才会执行Overridepublic void destroy() {System.out.println(filterAnnotation--销毁Filter);}
}
再举一个例子
/*** 检查用户是否已经完成登录*/
WebFilter(filterName loginCheckFilter,urlPatterns /*)
Slf4j
public class LoginCheckFilter implements Filter{//路径匹配器支持通配符public static final AntPathMatcher PATH_MATCHER new AntPathMatcher();Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {HttpServletRequest request (HttpServletRequest) servletRequest;HttpServletResponse response (HttpServletResponse) servletResponse;//1、获取本次请求的URIString requestURI request.getRequestURI();// /backend/index.htmllog.info(拦截到请求{},requestURI);//定义不需要处理的请求路径String[] urls new String[]{/employee/login,/employee/logout,/backend/**,/front/**,/common/**};//2、判断本次请求是否需要处理boolean check check(urls, requestURI);//3、如果不需要处理则直接放行if(check){log.info(本次请求{}不需要处理,requestURI);filterChain.doFilter(request,response);return;}//4、判断登录状态如果已登录则直接放行if(request.getSession().getAttribute(employee) ! null){log.info(用户已登录用户id为{},request.getSession().getAttribute(employee));Long empId (Long) request.getSession().getAttribute(employee);BaseContext.setCurrentId(empId);filterChain.doFilter(request,response);return;}log.info(用户未登录);//5、如果未登录则返回未登录结果通过输出流方式向客户端页面响应数据response.getWriter().write(JSON.toJSONString(R.error(NOTLOGIN)));return;}/*** 路径匹配检查本次请求是否需要放行*/public boolean check(String[] urls,String requestURI){for (String url : urls) {boolean match PATH_MATCHER.match(url, requestURI);if(match){return true;}}return false;}
}
3.2 过滤器配置类注册过滤器
public class BaseFilter implements Filter {Logger logger LoggerFactory.getLogger(BaseFilter.class);static final String TOKEN 20220423344556abac;//内部接口集合public static ListString INSIDE_URLS Lists.newArrayList(/index,/inside);//白名单接口集合public static ListString WHITE_PATH Lists.newArrayList(/white,/login);Overridepublic void init(FilterConfig filterConfig) throws ServletException {logger.info(初始化数据);}Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {HttpServletResponseWrapper wrapper new HttpServletResponseWrapper((HttpServletResponse)servletResponse);HttpServletRequest request (HttpServletRequest) servletRequest;String requestURI request.getRequestURI();if(INSIDE_URLS.contains(requestURI)){//内部接口直接通过filterChain.doFilter(servletRequest,servletResponse);return;}if(WHITE_PATH.contains(requestURI)){//白名单接口直接通过filterChain.doFilter(servletRequest,servletResponse);return;}//进行校验如token校验String token request.getHeader(token);if(TOKEN.equals(token)){filterChain.doFilter(servletRequest,servletResponse);}else {//token校验不通过重定向到登录页面wrapper.sendRedirect(/login);}}Overridepublic void destroy() {}
}
然后设置配置类
/*** 作用相当于WebFilter这个注解* 过滤器配置类进过滤器配置到bean中* Filter的包是javax.servlet.Filter的*/
Configuration//这个注解的目的是被IOC容器获取到
public class FilterConfig {/*** 基础过滤器* return*/Beanpublic FilterRegistrationBeanFilter baseFilter(){FilterRegistrationBeanFilter filterRegistrationBean new FilterRegistrationBean();filterRegistrationBean.setFilter(new BaseFilter());//注册自定义过滤器类//过滤资源的路径或者静态资源注意通配符是一个星号*filterRegistrationBean.setUrlPatterns(Lists.newArrayList(/*));filterRegistrationBean.setOrder(1);//排序return filterRegistrationBean;}
}
4、实战OncePerRequestFilter
自定义配置类
Data
Configuration
ConfigurationProperties(prefix security.checker)
public class SecurityCheckerConfig {private Boolean enable;/*** 存放accessKey和accessSecurity*/private MapString, String maps;/*** sign的过期时间*/private Integer signExpireTime;
}下面继承了OncePerRequestFilter 来实现我们自己的自定义过滤器OncePerRequestFilter 特点是请求进入后只会过滤一次不会重复过滤(有些情况请求可能会两次进入相同的过滤器)同时在不符合要求的请求需要即使抛出异常返回或者重定向到其他接口
Component
Slf4j
public class ParamCheckFilter extends OncePerRequestFilter {AutowiredQualifier(handlerExceptionResolver)private HandlerExceptionResolver resolver;// 自己在application.yaml定义的字段Resourceprivate SecurityCheckerConfig securityCheckerConfig;Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {if(!securityCheckerConfig.getEnable()){filterChain.doFilter(request,response);return;}String timestamp request.getHeader(timestamp);String accessKey request.getHeader(accesskey);String sign request.getHeader(sign);//根据key在配置文件拿取accessSecretString accessSecret securityCheckerConfig.getMaps().get(accessKey);//检查时间戳合法性if(!StringUtils.isNumeric(timestamp)){// 异常类自定义的resolver.resolveException(request,response,null,new TruckException(CustomCodeEnum.TIMESTAMP_IS_WRONGFUL));return;}//禁止超时签名Long ts Long.valueOf(timestamp);if (System.currentTimeMillis() - ts (securityCheckerConfig.getSignExpireTime() * CommonConstant.SECOND_TO_MILLIS)) {resolver.resolveException(request,response,null,new TruckException(CustomCodeEnum.SIGN_OVERTIME));return;}// 检查KEY是否合理if (StringUtils.isBlank(accessKey) || StringUtils.isBlank(accessSecret)) {resolver.resolveException(request,response,null,new TruckException(CustomCodeEnum.ACCESSKEY_WRONGFUL));return;}if(!checkSign(getBody(request),accessSecret, sign)){resolver.resolveException(request,response,null,new TruckException(CustomCodeEnum.SIGN_ERROR));return;}filterChain.doFilter(request,response);}private boolean checkSign(MapString, Object params, String accessSecret, String originSign) {String sign createSign(params, accessSecret);if (!sign.equals(originSign)) {log.error(sign 校验不通过 params: {}, ours sign : {}, theirs : {}, params, sign, originSign);return false;}return true;}/*** 获取请求体去除空值*/private LinkedHashMapString, Object getBody(HttpServletRequest request) {MapString, String[] requestParameterMap request.getParameterMap();JSONObject params new JSONObject();if(!CollectionUtils.isEmpty(requestParameterMap)){requestParameterMap.forEach((k,v) - params.put(k,v[0]));}return sortFields(params);}/*** 将请求参数按照ASCII码排序方便校验sign*/private LinkedHashMapString, Object sortFields(JSONObject params) {// 将请求参数按照ASCII码排序方便校验signString json JSON.toJSONString(params, SerializerFeature.SortField);return JSONObject.parseObject(json, LinkedHashMap.class, Feature.OrderedField);}/*** 生成sign* param params 所有字段按照ASCII码排序否则签名不一样*/public String createSign(MapString, Object params, String accessSecret) {SetString keysSet params.keySet();Object[] keys keysSet.toArray();Arrays.sort(keys);// 拼接所有一级字段二级字段不处理但是字段按ASCII码排序ListString paramList new ArrayList();for (Object key : keys) {String value String.valueOf(params.get(key));String str key value.replaceAll([\| ],).replaceAll(:, ).trim();paramList.add(str);}paramList.add(accessSecret accessSecret);String paramStr String.join(, paramList);return DigestUtils.md5DigestAsHex(paramStr.getBytes()).toUpperCase();
// return DigestUtils.md5Hex(paramStr).toUpperCase();}
}这里要注意的是我们用了HandlerExceptionResolver 因为在Spring Boot由于全局异常处理RestControllerAdvice只会去捕获所有Controller层抛出的异常所以在filter当中抛出的异常GlobalExceptionHandler类是没有感知的所以在filter当中抛出的异常最终会被Spring框架自带的全局异常处理类BasicErrorController捕获会返回基础格式的Json响应
一种方法是继承上面所说的BasicErrorController类并重写error()方法另一种就是在filter当中引入HandlerExceptionResolver类通过该类的resolveException方法抛出自定义异常通过resolveException方法抛出的自定义异常可以被RestControllerAdvice捕获从而满足我们的需求最终得到的响应格式
三、拦截器
1、概述
拦截器采用AOP的设计思想 它跟过滤器类似 用来拦截处理方法在之前和之后执行一些 跟主业务没有关系的一些公共功能比如可以实现权限控制、日志、异常记录、记录方法执行时间等等
2、自定义拦截器
2.1 生命周期
SpringMVC提供了拦截器机制允许运行目标方法之前进行一些拦截工作或者目标方法运行之后进行一下其他相关的处理。自定义的拦截器必须实现 HandlerInterceptor接口。
HandlerInterceptor 接口中定义了三个方法
preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 这个方法将在请求处理之前进行调用。注意如果该方法的返回值为false 将视为当前请求结束不仅自身的拦截器会失效还会导致其他的拦截器也不再执行。postHandle(HttpServletRequest request, HttpServletResponse response, Object handler)只有在 preHandle() 方法返回值为true 时才会执行。会在Controller 中的方法调用之后DispatcherServlet 返回渲染视图之前被调用。 此时我们可以通过modelAndView模型和视图对象对模型数据进行处理或对视图进行处理modelAndView也可能为nullafterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler)只有在 preHandle() 方法返回值为true 时才会执行。在整个请求结束之后 DispatcherServlet 渲染了对应的视图之后执行。如性能监控中我们可以在此记录结束时间并输出消耗时间还可以进行一些资源清理类似于try catch finally中的finally但仅调用处理器执行链中preHandle返回true的拦截器才会执行
2.2 代码示例
首先创建自定义拦截器
public class MyFirstInterceptor implements HandlerInterceptor {/*** 在处理方法之前执 日志、权限、 记录调用时间* param request 可以在方法请求进来之前更改request中的属性值* param response* param handler 封装了当前处理方法的信息* return true 后续调用链是否执行/ false 则中断后续执行* throws Exception*/Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 在请求映射到对应的处理方法映射实现类才是HandlerMethod。// 如果是视图控制器实现类ParameterizableViewControllerif(handler instanceof HandlerMethod ) {HandlerMethod handMethod (HandlerMethod) handler;}/*System.out.println(-------类[handMethod.getBean().getClass().getName()] 方法名[handMethod.getMethod().getName()] 参数[ Arrays.toString(handMethod.getMethod().getParameters()) ]前执行--------preHandle);*/System.out.println(this.getClass().getName()---------方法后执行在渲染之前--------------preHandle);return true;}/*** 如果preHandle返回false则会不会允许该方法* 在请求执行后执行, 在视图渲染之前执行* 当处理方法出现了异常则不会执行方法* param request* param response 可以在方法执行后去更改response中的信息* param handler 封装了当前处理方法的信息* param modelAndView 封装了model和view.所以当请求结束后可以修改model中的数据或者新增model数据也可以修改view的跳转* throws Exception*/Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {System.out.println(this.getClass().getName()---------方法后执行在渲染之前--------------postHandle);}/*** 如果preHandle返回false则会不会允许该方法* 在视图渲染之后执行相当于try catch finally 中finally出现异常也一定会执行该方法* 如果执行的时候核心的业务代码出问题了那么已经通过的拦截器的 afterCompletion会接着执行* param ex Exception对象在该方法中去做一些记录异常日志的功能或者清除资源* throws Exception*/Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {System.out.println(this.getClass().getName()---------在视图渲染之后--------------afterCompletion);}
}然后在spring boot 项目中配置实现 WebMvcConfigurer 接口 并重写 addInterceptors方法
Configuration
public class InterceptorAdapter implements WebMvcConfigurer {Beanpublic MyFirstInterceptor myInterceptor(){return new MyFirstInterceptor();}public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(myInterceptor()).addPathPatterns(/**).excludePathPatterns(/*.html);}
}
RequestMapping(/test01)public String test01(){System.out.println(请求方法执行中...);return admin;}
}2.3 多拦截器示例
拦截顺序取决于配置的顺序
Configuration
public class InterceptorAdapter implements WebMvcConfigurer {Beanpublic MyFirstInterceptor myInterceptor(){return new MyFirstInterceptor();}Beanpublic MySecondInterceptor mySecondInterceptor(){return new MySecondInterceptor();}public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(myInterceptor()).addPathPatterns(/**).excludePathPatterns(/*.html);registry.addInterceptor(mySecondInterceptor()).addPathPatterns(/**).excludePathPatterns(/*.html).order(1);}
}看到输出结果发现先声明的拦截器 preHandle() 方法先执行而postHandle()方法反而会后执行。但注意postHandle() 方法被调用的顺序跟 preHandle() 是相反的。我们要知道controller 中所有的请求都要经过核心组件DispatcherServlet路由都会执行它的 doDispatch() 方法而拦截器postHandle()、preHandle()方法便是在其中调用的。查看源码可知发现两个方法中在调用拦截器数组 HandlerInterceptor[] 时循环的顺序竟然是相反的
3.4 静态资源被拦截问题
配置拦截器会导致静态资源被拦截比如在 resources/static/ 目录下放置一个图片资源或者 html 文件然后启动项目直接访问即可看到无法访问的现象。也就是说虽然 Spring Boot 2.0 废弃了WebMvcConfigurerAdapter但是 WebMvcConfigurationSupport 又会导致默认的静态资源被拦截这就需要我们手动将静态资源放开
除了在 MyInterceptorConfig 配置类中重写 addInterceptors 方法外还需要再重写一个方法addResourceHandlers将静态资源放开
/*** 用来指定静态资源不被拦截否则继承WebMvcConfigurationSupport这种方式会导致静态资源无法直接访问* param registry*/
Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {registry.addResourceHandler(/**).addResourceLocations(classpath:/static/);super.addResourceHandlers(registry);
}这样配置好之后重启项目静态资源也可以正常访问了。
另一种方法是不继承 WebMvcConfigurationSupport 类直接实现 WebMvcConfigurer 接口然后重写 addInterceptors 方法将自定义的拦截器添加进去即可上面讲到
Configuration
public class MyInterceptorConfig implements WebMvcConfigurer {Overridepublic void addInterceptors(InterceptorRegistry registry) {// 实现WebMvcConfigurer不会导致静态资源被拦截registry.addInterceptor(new MyInterceptor()).addPathPatterns(/**);}
}4、实战demo
判断用户有没有登录一般用户登录功能我们可以这么做要么往 session 中写一个 user要么针对每个 user 生成一个 token第二种要更好一点那么针对第二种方式如果用户登录成功了每次请求的时候都会带上该用户的 token如果未登录则没有该 token服务端可以检测这个 token 参数的有无来判断用户有没有登录从而实现拦截功能。我们改造一下 preHandle 方法
Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {HandlerMethod handlerMethod (HandlerMethod) handler;Method method handlerMethod.getMethod();String methodName method.getName();logger.info(拦截到了方法{}在该方法执行之前执行, methodName);// 判断用户有没有登陆一般登陆之后的用户都有一个对应的tokenString token request.getParameter(token);if (null token || .equals(token)) {logger.info(用户未登录没有权限执行……请登录);return false;}// 返回true才会继续执行返回false则取消当前请求return true;
}最后还有一个监听器可以参考Spring事件监听 https://zhuanlan.zhihu.com/p/340397290
https://zhuanlan.zhihu.com/p/484289805
https://www.zhihu.com/question/443466900/answer/2509838187
https://blog.csdn.net/qq_45534061/article/details/106266747
https://blog.csdn.net/m0_37731470/article/details/116754395
https://blog.csdn.net/xinzhifu1/article/details/106356958/