思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。
作者:毅航😜
从问题出发,逐步讲透在SpringMVC
中使用RequestContextHolder
对象在多线程情况下无法获取请求的真实原因。
前言
众所周知,在SpringMVC
中如果我们期待获取当前请求的HttpServletRequest
对象,通常有如下几种方式:
- 通过方法参数注入 :在
Controller
的方法中,可以直接声明HttpServletRequest
类型的参数,Spring MVC
会自动将当前请求的HttpServletRequest
对象注入进来。例如:
java
@Controller
public class MyController {
@RequestMapping("/example")
public String handleRequest(HttpServletRequest request) {
// 使用request对象
return "example";
}
}
- 通过
RequestContextHolder
:Spring MVC
提供了一个RequestContextHolder
类,例如:
java
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
而本次我们重点分析当使用RequestContextHolder
在多线程环境下获取请求所可能导致的一些问题。
(注:在获取当前请求的HttpServletRequest
我们会利用RequestContextHolder
先获取到ServletRequestAttributes
后,再通过ServletRequestAttributes
来获取对应的httpServletRequest
对象)
问题复现
为了直观的理解RequestContextHolder
在多线程使用下所导致的问题,我们先来通过一个业务中的真实场景来进行分析。
在国际化功能开发中,我们通常会将用户当前的语言信息存放在Request
请求中,这样后端通过获取请求头的中的语言信息就能成功获取到用户所支持的语言。
进一步,对于一些涉及到国际化的导入,导出的耗时操作来说,我们通常会将其放在异步线程中进行执行,以提升程序性能。代码逻辑大致如下:
java
@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
属性,方法内部具体逻辑如下所示:
java
/**
* 获取客户端请求头中的语言信息。
* 其会从当前的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
内部逻辑如下所示:
java
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;
}
整体来看上述代码调用逻辑如下:
至此,相信你对于示例代码的逻辑其实已经清楚了。其无非就是会首先会在主线程中获取当前请求头中的语言信息,接着,又会新建一个子线程尝试去获取到请求头中的语言信息。看起来似乎代码似乎没什么问题,尝试执行代码,你会发现有如下提示:
追踪溯源
通过错误提示不难发现是子线程 在调用getLanguagesExistProblem()
方法时所提示的错误。具体来看,是因为其内部Assert.notNull(request);
断言所提示的错误,而导致问题发生的原因是因为传入的request
对象为null
,进而导致不满足断言条件notNull
从而提示异常信息。
正如我们前面所说,我们的request
对象是通过RequestContextHolder
来获取的。具体我们的代码来看,其本质是通过ServletRequestAttributes
的getRequest
来完成这一操作。那为什么会在多线程情况下有这样的问题呢?初看这一问题你可能会有点摸不到头脑,不知该如何下手。没有思路也别慌,接下来,不妨听一听笔者是如何对这一问题进行分析的。
首先,既然RequestContextHolder
可以实现获取当前请求的功能,其一定会把请求进行一层缓存
,以确保我们无论在程序的任何位置都能获取到请求,顺着这一思路,如果要你来实现这一需求,你会如何设计呢?
我想你大概率会将此处的逻辑设计在程序公共的入口位置,那SpringMVC
中请求第一次进入时公共的会首先在哪处理呢?显示是Servlet
中的service
方法。具体到DispatcherServlet
来看,其内部逻辑如下所示:
java
@Override
protected void service(HttpServletRequest request,
HttpServletResponse response)
{
// ... 省略其他无关代码
// 处理请求核心代码,点击该方法进入
processRequest(request, response);
}
(Ps: 这里需要读者有一点Servlet
相关知识,简单来看,所有请求进入Servlet
后都会先通过Service
方法的处理~~~)
不难发现,service
方法其内部的核心逻辑会委托processRequest
进行处理,而processRequest
其内部逻辑如下:
java
protected final void processRequest(HttpServletRequest request,
// ... 省略其他无关逻辑
// 初始化ContextHolders, 便于访问上下文信息
initContextHolders(request, localeContext, requestAttributes);
doService(request, response);
}
通过initContextHolders
方法的名称我们不难猜出,其大概的作用微在于初始化一个ContextHolder
相关属性。事实上,在该方法内其会将 requestAttributes
与当前线程进行绑定。具体逻辑如下:
java
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:RequestAttributes
是Spring
框架中的一个接口,用于表示一个请求的属性集合。它允许应用程序在线程中存储和访问请求的属性,这些属性可以用于在请求的不同阶段共享数据或传递数据。)
而在RequestContextHolder
内部的setRequestAttributes
方法中,其会根据inheritable
属性的不同来将request
属性选择性的放入requestAttributesHolder
和inheritableRequestAttributesHolder
两个不同的ThreadLocal
。
而两者的区别在于子线程是否可以共享父线程属性 。而默认情况下inheritable
的取值为false
,也就是说在SpringMVC
默认情况下requestAttributes
是不会线程共享的。
(Ps:此处的RequestAttributes
是我们之前提及ServletRequestAttributes
的父接口)
进一步,setRequestAttributes
的内部逻辑如下:
java
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
并未实现父子线程间的共享!