掘金 后端 ( ) • 2024-04-21 17:14

theme: fancy highlight: atelier-heath-light


思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。
作者:毅航😜


从问题出发,逐步讲透在SpringMVC中使用RequestContextHolder对象在多线程情况下无法获取请求的真实原因。

前言

众所周知,在SpringMVC中如果我们期待获取当前请求的HttpServletRequest对象,通常有如下几种方式:

  1. 通过方法参数注入:在Controller的方法中,可以直接声明HttpServletRequest类型的参数,Spring MVC会自动将当前请求的HttpServletRequest对象注入进来。例如:
@Controller
public class MyController {

    @RequestMapping("/example")
    public String handleRequest(HttpServletRequest request) {
        // 使用request对象
        return "example";
    }
}
  1. 通过RequestContextHolderSpring MVC提供了一个RequestContextHolder类,例如:
HttpServletRequest request = ((ServletRequestAttributes) 
RequestContextHolder.getRequestAttributes()).getRequest();

而本次我们重点分析当使用RequestContextHolder在多线程环境下获取请求所可能导致的一些问题。

(注:在获取当前请求的HttpServletRequest我们会利用RequestContextHolder先获取到ServletRequestAttributes后,再通过ServletRequestAttributes来获取对应的httpServletRequest对象)

问题复现

为了直观的理解RequestContextHolder在多线程使用下所导致的问题,我们先来通过一个业务中的真实场景来进行分析。

在国际化功能开发中,我们通常会将用户当前的语言信息存放在Request请求中,这样后端通过获取请求头的中的语言信息就能成功获取到用户所支持的语言。

进一步,对于一些涉及到国际化的导入,导出的耗时操作来说,我们通常会将其放在异步线程中进行执行,以提升程序性能。代码逻辑大致如下:

@GetMapping("/missing-request-header")
public String getMissingRequestHeader() {
    // 主线程获取请求头信息
    String mainThreadLanguages = ServletUtils.getLanguagesExistProblem();
    log.info("主线程获取请求头信息:{}", mainThreadLanguages);
    new Thread(() -> {
        // 子线程获取请求头信息 模拟执行耗时操作
        String subThreadLanguages = ServletUtils.getLanguagesExistProblem();
        log.info("子线程获取请求头信息:{}", subThreadLanguages);
       
    }).start();
    return "success";
}

上述程序的逻辑相对来说比较简单,唯一可能让你困惑的可能在于ServletUtils.getLanguagesExistProblem()方法的调用。

该方法是笔者所写的一个工具类,其主要作用就是获取当前请求头中的Lang属性,方法内部具体逻辑如下所示:


    /**
     * 获取客户端请求头中的语言信息。
     * 其会从当前的HTTP请求中提取客户端所设置的语言信息。
     * 主要通过读取请求头中的"X_CLIENT_LANG"字段来获取客户端语言偏好。
     *
     * @return String 客户端请求头中指定的语言信息。
     * 如果不存在该字段则返回默认的zh-cn。
     */
    public static String  getLanguagesExistProblem() {
        HttpServletRequest request = getRequest();
        Assert.notNull(request);

        String lang =  request.getHeader(X_CLIENT_LANG);

        if (StrUtil.isNotBlank(lang)) {
            return lang;
        }

        return "zh-cn";
    }

可以看到,在getLanguagesExistProblem方法内部又会通过getRequest获取到当前请求的HttpServletRequest信息,而getRequest内部逻辑如下所示:


public static HttpServletRequest getRequest() {
    HttpServletRequest httpServletRequest = null;
    try {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes instanceof ServletRequestAttributes) {
            ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
            httpServletRequest = servletRequestAttributes.getRequest();
        }
    } catch (Exception e) {
        // 记录异常,但不向外抛出,以避免可能的业务逻辑中断
        log.error("获取HttpServletRequest时发生异常:", e);
    }

    // 返回获取到的请求对象,如果失败则返回null
    return httpServletRequest;
}

整体来看上述代码调用逻辑如下:

image.png

至此,相信你对于示例代码的逻辑其实已经清楚了。其无非就是会首先会在主线程中获取当前请求头中的语言信息,接着,又会新建一个子线程尝试去获取到请求头中的语言信息。看起来似乎代码似乎没什么问题,尝试执行代码,你会发现有如下提示:

image.png

image.png

追踪溯源

通过错误提示不难发现是子线程在调用getLanguagesExistProblem()方法时所提示的错误。具体来看,是因为其内部Assert.notNull(request);断言所提示的错误,而导致问题发生的原因是因为传入的request对象为null,进而导致不满足断言条件notNull从而提示异常信息。

正如我们前面所说,我们的request对象是通过RequestContextHolder来获取的。具体我们的代码来看,其本质是通过ServletRequestAttributesgetRequest来完成这一操作。那为什么会在多线程情况下有这样的问题呢?初看这一问题你可能会有点摸不到头脑,不知该如何下手。没有思路也别慌,接下来,不妨听一听笔者是如何对这一问题进行分析的。

首先,既然RequestContextHolder可以实现获取当前请求的功能,其一定会把请求进行一层缓存,以确保我们无论在程序的任何位置都能获取到请求,顺着这一思路,如果要你来实现这一需求,你会如何设计呢?

我想你大概率会将此处的逻辑设计在程序公共的入口位置,那SpringMVC中请求第一次进入时公共的会首先在哪处理呢?显示是Servlet中的service方法。具体到DispatcherServlet来看,其内部逻辑如下所示:


@Override
protected void service(HttpServletRequest request, 
                        HttpServletResponse response)
        {

      // ... 省略其他无关代码
 
     // 处理请求核心代码,点击该方法进入
     processRequest(request, response); 
 
}

(Ps: 这里需要读者有一点Servlet相关知识,简单来看,所有请求进入Servlet后都会先通过Service方法的处理~~~)

不难发现,service方法其内部的核心逻辑会委托processRequest进行处理,而processRequest其内部逻辑如下:

protected final void processRequest(HttpServletRequest request, 
    // ... 省略其他无关逻辑
    // 初始化ContextHolders, 便于访问上下文信息
    initContextHolders(request, localeContext, requestAttributes);
  
    doService(request, response);
  }

通过initContextHolders方法的名称我们不难猜出,其大概的作用微在于初始化一个ContextHolder相关属性。事实上,在该方法内其会将 requestAttributes与当前线程进行绑定。具体逻辑如下:

private void initContextHolders(HttpServletRequest request,
                                @Nullable LocaleContext localeContext, @Nullable RequestAttributes requestAttributes) {
    // 将参数localeContext设置为当前线程的LocaleContext,并设置参数threadContextInheritable为true,表示上下文对象可以在子线程中继承
    if (localeContext != null) {
        LocaleContextHolder.setLocaleContext(localeContext, this.threadContextInheritable);
    }
    // 将参数requestAttributes设置为当前线程的RequestAttributes,并设置参数threadContextInheritable为true,表示上下文对象可以在子线程中继承
    if (requestAttributes != null) {
        RequestContextHolder.setRequestAttributes(requestAttributes, this.threadContextInheritable);
    }
}

(Ps:RequestAttributesSpring框架中的一个接口,用于表示一个请求的属性集合。它允许应用程序在线程中存储和访问请求的属性,这些属性可以用于在请求的不同阶段共享数据或传递数据。)

而在RequestContextHolder内部的setRequestAttributes方法中,其会根据inheritable属性的不同来将request属性选择性的放入requestAttributesHolderinheritableRequestAttributesHolder两个不同的ThreadLocal

而两者的区别在于子线程是否可以共享父线程属性而默认情况下inheritable的取值为false,也就是说在SpringMVC默认情况下requestAttributes是不会线程共享的

(Ps:此处的RequestAttributes是我们之前提及ServletRequestAttributes的父接口)

进一步,setRequestAttributes的内部逻辑如下:

public static void setRequestAttributes(@Nullable RequestAttributes attributes, boolean inheritable) {
   if (attributes == null) {
      resetRequestAttributes();
   }
   else {
      if (inheritable) {
         inheritableRequestAttributesHolder.set(attributes);
         requestAttributesHolder.remove();
      }
      else {
         requestAttributesHolder.set(attributes);
         inheritableRequestAttributesHolder.remove();
      }
   }
}

总结

回到我们之前的问题,我们以多线程下无法从从请求头中获取相关属性为入口,逐步深入剖析了其在多线程情况下无法失效的原因。具体来看,在SpringMVC中,如果我们想获取当前请求的Request对象,通常我们会通过RequestContextHolder进行获取。进一步RequestContextHolder获取Request对象需要先获取ServletRequestAttributes对象,进而通过其getRequest方法来获取到当前的请求信息。

换言之,如果想获取当前请求的Request对象,我们首先需要确保能获取到ServletRequestAttributes这一中间信息,因为其内部会维护相关的请求对象。而在SpringMVC内部ServletRequestAttributes在保存在RequestContextHolder中的ThreadLocal

而默认情况下,其实不支持父子线程间传递的,所以在多线程环境下当我们通过RequestContextHolder获取请求时会出现请求无法获取的现象,而导致这一问题本质发生的本质原因在于ServletRequestAttributes并未实现父子线程间的共享!