架构师技能8:springboot全局handler处理http 404错误引发登录失效的问题

Posted hguisu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了架构师技能8:springboot全局handler处理http 404错误引发登录失效的问题相关的知识,希望对你有一定的参考价值。

开篇语录:以架构师的能力标准去分析每个问题,过后由表及里分析问题的本质,复盘总结经验,并把总结内容记录下来。当你解决各种各样的问题,也就积累了丰富的解决问题的经验,解决问题的能力也将自然得到极大的提升。

励志做架构师的撸码人,认知很重要,可以订阅:架构设计专栏

一、背景


国庆前我们线上出现一次故障:用户无法登录某个微服务,后面一段时间后就自动恢复了,然后我持续跟踪和分析这个问题好久找到原因,顺便在此记录下来。

二、问题定位


出现这种问题,肯定是需要通过日志来定位,由于业务服务日志没有异常日常,因此通过外围日志来分析:

1、通过nginx日志检查http的请求异常信息:

在接入层的其中一台nginx 统计日志:

grep "xx/Sep/2022:1[8|9]" access.202209xx.log | awk 'x[$8]++; ENDfor(i in x) print(i ":" x[i])'

发现发生故障的时间段(晚上18xx~19:xx)内http 404错误特别多,这是一个异常的情况。

 2、初步判断http 404请求导致cookie失效。

当前时间段的nginx的404日志突增这么多,这是一个诡异的初步判断可能是404请求引起cookie失效的问题。

3、验证问题:

我们通过反复请求404的url,确实存在服务无法登录的问题。

三、问题原因分析


1、了解springboot2.x处理http 404机制

springBoot 默认提供了一个全局的 handler 来处理所有的 HTTP 错误, 并把它映射为 /error。当发生一个 HTTP 错误:例如 404 错误时, SpringBoot 内部的机制会将页面转发向到 /error 中。

由于Spring MVC会根据不同的请求URL,匹配到不同的RequestMapping。当没有匹配到相应的RequestMapping,请求是不会经过controller处理。因此我们自己定义的全局异常处理GlobalExceptionHandler类中的@ControllerAdvice注解只处理经过Controller的异常,不经过Controller的异常不进行处理。

对于404的请求,在springboot1.x与springboot2.x中的处理方式不一样:
在springboot1.5.10中:当存在请求没有controller匹配请求后404,同时会直接转发到/error,这个时候我们可以直接判断request中的uri是否包含/error,如果有抛出异常,再@ControllerAdvice处理即可。

对于springboot2.0:当发生http 404时,不仅原始请求会来一次,同时会转发到/error再次请求。这时候如果有拦截器,则会拦截两次,比如请求/api/123,原始请求会拦截一次,发生404后重定向到/api/error,会再拦截一次。

我们在拦截器打印日志就能印证会看到两次日志:

@Component
public class ClusterParamInterceptor implements HandlerInterceptor 

    private static final Logger logger = LoggerFactory.getLogger(ClusterParamInterceptor.class);


    @Override
    public boolean preHandle(HttpServletRequest httpRequest,
            HttpServletResponse response, Object handler) throws Exception 
        logger.info("httpRequest:", httpRequest.getRequestURL());
        return true;

    

    @Override
    public void afterCompletion(HttpServletRequest httpRequest,
            HttpServletResponse response, Object handler,
            Exception ex) throws Exception 

    
    

/error接口的默认是由BasicErrorController处理器处理:org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController

 BasicErrorController是Spring默认配置的一个Controller,默认处理/error请求。BasicErrorController提供两种返回错误:

一种是页面返回,浏览器访问显示如下错误页面;

另外一种是json请求的时候就会返回json错误:


    "timestamp": "2022-10-06T14:46:33.686+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/213df/sfasd"

2、我们项目cookie失效的原因

我们token的处理机制是:拦截器、threadlocal、过滤器+AutoCloseable接口

1、使用拦截器拦截用户请求:使用拦截器拦截用户请求,当拦截器判断controller实例的方法包含TokenRequire注解,表示需要登录token才能访问。

2、threadlocal缓存:通过getTokenBySession()获取用户cookie信息,然后缓存到ContextLocal类的threadlocal变量。确保上下文可以获取到用户登录信息。

3、过滤器+AutoCloseable接口实现请求结束后清除ThreadLocal变量内容:ContextLocal通过实现AutoCloseable接口的close方法,在继承OncePerRequestFilter的过滤器里面通过try (resource)  ...结构保证能释放ThreadLocal关联的实例。

public class GlobalFilter extends OncePerRequestFilter 

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException 
  
        try (ContextLocal ignored = new ContextLocal()) 
            filterChain.doFilter(request, response);
        
    

线程ThreadLocal详解_hguisu的博客-CSDN博客

servlet的过滤器Filter详解_hguisu的博客-CSDN博客

 token类:

@Data
public class GlobalToken implements Serializable 

    public static final GlobalToken EMPTY = new GlobalToken();
    private static final long serialVersionUID = 1L;

    private String id;
    private Date createTime;
    private Date lastAccessTime;/
    private Date lastUpdateTime;
    private Long timeout = 1000 * 60 * 30L;//默认30分钟过期

 拦截器:

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception 
        if (handler != null && handler instanceof HandlerMethod) 
            HandlerMethod method = (HandlerMethod) handler;
            logger.info("api-log:[][][][]",getIp(request), tokenService.getLogAccountCode(), getApiUrl(method));
            TokenRequire tokenRequire = method.getMethod().getAnnotation(TokenRequire.class);
            if (tokenRequire != null) 
                GlobalToken token = tokenService.getTokenBySession();
                if (token != null) 
                    //逻辑处理
                 else 
                    //TODO 判断是否有cookie集成
                    throw new NullTokenException(method.getMethod().getName() + " get token is null");
                
            

        
        return super.preHandle(request, response, handler);
    

ContextLocal的ThreadLocal 变量缓存token:

public class ContextLocal implements AutoCloseable 
    private static final ThreadLocal<GlobalToken> CURRENT_TOKEN = new ThreadLocal<>();

    public static GlobalToken getCurrentToken() 
        return CURRENT_TOKEN.get();
    

    public static void setCurrentToken(GlobalToken globalToken) 
        CURRENT_TOKEN.set(globalToken);
    


    @Override
    public void close() 
        MDC.remove(WebHeaderConstants.REQ_ID);
        CURRENT_TOKEN.remove();
    

获取token逻辑:

    public GlobalToken getTokenBySession() 
        GlobalToken globalTokenLocal = ContextLocal.getCurrentToken();
        //未初始化
        if (galaxyTokenLocal == null) 
            GlobalToken globalToken = getTokenBySessionWithoutCache();
            //获得null时,用empty占位,表示已初始化
            ContextLocal.setCurrentToken(galaxyToken == null ? GlobalToken.EMPTY : galaxyToken);
            return globalToken;
        

        return globalTokenLocal == GlobalToken.EMPTY ? null : globalTokenLocal;
    

1、发生http 404错误的时候:由于handler的对应类型不是Controller实例,即handler instanceof HandlerMethod为false。不会进入拦截器的业务逻辑模块。

2、然后spring boot内部转发向到/error接口,请求再次被拦截器拦截,但是过滤器不会再处理:

     1)转发向到/error接口,再次进入拦截器:由于接口/error的处理器是BasicErrorController,该BasicErrorController是Controller实例,即handler instanceof HandlerMethod为true。

     2)然后进入拦截器的业务逻辑模块。这里打印日志:

logger.info("api-log:[][][][]",getIp(request), tokenService.getLogAccountCode(), getApiUrl(method));

        打印日志地方调用tokenService.getLogAccountCode()获取用户账号,getLogAccountCode()方法又调用getTokenBySession()方法。

         错误的原因就是在这:

         进入getTokenBySession后,galaxyTokenLocal取值null,重新设置变量threadLocal的CURRENT_TOKEN为 GlobalToken.EMPTY。

if (galaxyTokenLocal == null)
            GlobalToken globalToken = getTokenBySessionWithoutCache();
            //获得null时,用empty占位,表示已初始化
            ContextLocal.setCurrentToken(galaxyToken == null ? GlobalToken.EMPTY : galaxyToken);
            return globalToken;
       

3、过滤器不会再处理,导致无法清除CURRENT_TOKEN=GlobalToken.EMPTY的情况:

/error接口是服务内forward转发的请求,继承OncePerRequestFilter的过滤器不会做对请求做处理。因此ContextLocal类不会执行close方法,即不会清除掉CURRENT_TOKEN=GlobalToken.EMPTY的情况。

4、当正确url请求的时候,由于上个threadLocal的CURRENT_TOKEN为GlobalToken.EMPTY没有被清理掉,此时GlobalToken globalTokenLocal = ContextLocal.getCurrentToken()为GlobalToken.EMPTY,最终getTokenBySession()返回null,导致无法获取用户token,一直提示用户token失效。


 

以上是关于架构师技能8:springboot全局handler处理http 404错误引发登录失效的问题的主要内容,如果未能解决你的问题,请参考以下文章

架构师技能8:springboot全局handler处理http 404错误引发登录失效的问题

java后端程序员工作辛苦吗,架构师必备技能

作为一个刚刚入职Android开发的应届生,该如何走向架构师?

[架构之路-87]:《程序员必读之软件架构》-2-软件架构师所需要技能

架构师害怕程序员知道的十项技能的读后感

Java培训之如何成为架构师?