Spring 中获取 request 的几种方法,及其线程安全性分析

44次阅读

共计 9394 个字符,预计需要花费 24 分钟才能阅读完成。

概述在使用 Spring MVC 开发 Web 系统时,经常需要在处理请求时使用 request 对象,比如获取客户端 ip 地址、请求的 url、header 中的属性(如 cookie、授权信息)、body 中的数据等。由于在 Spring MVC 中,处理请求的 Controller、Service 等对象都是单例的,因此获取 request 对象时最需要注意的问题,便是 request 对象是否是线程安全的:当有大量并发请求时,能否保证不同请求 / 线程中使用不同的 request 对象。
这里还有一个问题需要注意:前面所说的“在处理请求时”使用 request 对象,究竟是在哪里使用呢?考虑到获取 request 对象的方法有微小的不同,大体可以分为两类:(1) 在 Spring 的 Bean 中使用 request 对象:既包括 Controller、Service、Repository 等 MVC 的 Bean,也包括了 Component 等普通的 Spring Bean。为了方便说明,后文中 Spring 中的 Bean 一律简称为 Bean。(2) 在非 Bean 中使用 request 对象:如普通的 Java 对象的方法中使用,或在类的静态方法中使用。此外,本文讨论是围绕代表请求的 request 对象展开的,但所用方法同样适用于 response 对象、InputStream/Reader、OutputStream/ Writer 等;其中 InputStream/Reader 可以读取请求中的数据,OutputStream/ Writer 可以向响应写入数据。
最后,获取 request 对象的方法与 Spring 及 MVC 的版本也有关系;本文基于 Spring4 进行讨论,且所做的实验都是使用 4.1.1 版本。
如何测试线程安全性
既然 request 对象的线程安全问题需要特别关注,为了便于后面的讨论,下面先说明如何测试 request 对象是否是线程安全的。测试的基本思路,是模拟客户端大量并发请求,然后在服务器判断这些请求是否使用了相同的 request 对象。判断 request 对象是否相同,最直观的方式是打印出 request 对象的地址,如果相同则说明使用了相同的对象。
然而,在几乎所有 web 服务器的实现中,都使用了线程池,这样就导致先后到达的两个请求,可能由同一个线程处理:在前一个请求处理完成后,线程池收回该线程,并将该线程重新分配给了后面的请求。而在同一线程中,使用的 request 对象很可能是同一个(地址相同,属性不同)。因此即便是对于线程安全的方法,不同的请求使用的 request 对象地址也可能相同。
为了避免这个问题,一种方法是在请求处理过程中使线程休眠几秒,这样可以让每个线程工作的时间足够长,从而避免同一个线程分配给不同的请求;另一种方法,是使用 request 的其他属性(如参数、header、body 等)作为 request 是否线程安全的依据,因为即便不同的请求先后使用了同一个线程(request 对象地址也相同),只要使用不同的属性分别构造了两次 request 对象,那么 request 对象的使用就是线程安全的。本文使用第二种方法进行测试。
客户端测试代码如下(创建 1000 个线程分别发送请求):
public class Test {public static void main(String[] args) throws Exception {String prefix = UUID.randomUUID().toString().replaceAll(“-“, “”) + “::”;for (int i = 0; i < 1000; i++) {final String value = prefix + i;new Thread() {br/>@Overridepublic void run() {try {CloseableHttpClient httpClient = HttpClients.createDefault();HttpGet httpGet = new HttpGet(“http://localhost:8080/test?key=” + value);httpClient.execute(httpGet);httpClient.close();} catch (IOException e) {br/>e.printStackTrace();}}}.start();}}} 服务器中 Controller 代码如下(暂时省略了获取 request 对象的代码):@Controllerpublic class TestController {
// 存储已有参数,用于判断参数是否重复,从而判断线程是否安全 public static Set<String> set = new ConcurrentSkipListSet<>();
@RequestMapping(“/test”)public void test() throws InterruptedException {
// …………………………通过某种方式获得了 request 对象………………………………
// 判断线程安全 String value = request.getParameter(“key”);if (set.contains(value)) {System.out.println(value + “t 重复出现,request 并发不安全!”);} else {System.out.println(value);set.add(value);}
// 模拟程序执行了一段时间 Thread.sleep(1000);}}
补充:上述代码原使用 HashSet 来判断 value 是否重复,经网友批评指正,使用线程不安全的集合类验证线程安全性是欠妥的,现已改为 ConcurrentSkipListSet。如果 request 对象线程安全,服务器中打印结果如下所示:

如果存在线程安全问题,服务器中打印结果可能如下所示:
图片描述
如无特殊说明,本文后面的代码中将省略掉测试代码。br/> 方法 1:Controller 中加参数
代码示例这种方法实现最简单,直接上 Controller 代码:
@Controller
public class TestController {br/>@RequestMapping(“/test”)
public void test(HttpServletRequest request) throws InterruptedException {
// 模拟程序执行了一段时间 br/>Thread.sleep(1000);
}
}

该方法实现的原理是,在 Controller 方法开始处理请求时,Spring 会将 request 对象赋值到方法参数中。除了 request 对象,可以通过这种方法获取的参数还有很多 Controller 中获取 request 对象后,如果要在其他方法中(如 service 方法、工具类方法等)使用 request 对象,需要在调用这些方法时将 request 对象作为参数传入。
线程安全性
测试结果:线程安全分析:此时 request 对象是方法参数,相当于局部变量,毫无疑问是线程安全的。
优缺点
这种方法的主要缺点是 request 对象写起来冗余太多,主要体现在两点:如果多个 controller 方法中都需要 request 对象,那么在每个方法中都需要添加一遍 request 参数 request 对象的获取只能从 controller 开始,如果使用 request 对象的地方在函数调用层级比较深的地方,那么整个调用链上的所有方法都需要添加 request 参数实际上,在整个请求处理的过程中,request 对象是贯穿始终的;也就是说,除了定时器等特殊情况,request 对象相当于线程内部的一个全局变量。而该方法,相当于将这个全局变量,传来传去。方法 2:自动注入代码示例先上代码:
@Controller
public class TestController{

@Autowired
private HttpServletRequest request; // 自动注入 request

@RequestMapping(“/test”)
public void test() throws InterruptedException{
// 模拟程序执行了一段时间
Thread.sleep(1000);
}
}

线程安全性测试结果:线程安全分析:在 Spring 中,Controller 的 scope 是 singleton(单例),也就是说在整个 web 系统中,只有一个 TestController;但是其中注入的 request 却是线程安全的,原因在于:使用这种方式,当 Bean(本例的 TestController)初始化时,Spring 并没有注入一个 request 对象,而是注入了一个代理(proxy);当 Bean 中需要使用 request 对象时,通过该代理获取 request 对象。下面通过具体的代码对这一实现进行说明。在上述代码中加入断点,查看 request 对象的属性,如下图所示:Spring 中获取 request 的几种方法,及其线程安全性分析
在图中可以看出,request 实际上是一个代理:代理的实现参见 AutowireUtils 的内部类 ObjectFactoryDelegatingInvocationHandler:
/**

Reflective InvocationHandler for lazy access to the current target object.br/>*/
@SuppressWarnings(“serial”)
private static class ObjectFactoryDelegatingInvocationHandler implements InvocationHandler, Serializable {
private final ObjectFactory<?> objectFactory;
public ObjectFactoryDelegatingInvocationHandler(ObjectFactory<?> objectFactory) {
this.objectFactory = objectFactory;br/>}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// ……省略无关代码
try {
return method.invoke(this.objectFactory.getObject(), args); // 代理实现核心代码
}
catch (InvocationTargetException ex) {
throw ex.getTargetException();
}
}
}

也就是说,当我们调用 request 的方法 method 时,实际上是调用了由 objectFactory.getObject() 生成的对象的 method 方法;objectFactory.getObject() 生成的对象才是真正的 request 对象。继续观察上图,发现 objectFactory 的类型为 WebApplicationContextUtils 的内部类 RequestObjectFactory;而 RequestObjectFactory 代码如下:
/**
Factory that exposes the current request object on demand.br/>*/
@SuppressWarnings(“serial”)
private static class RequestObjectFactory implements ObjectFactory<ServletRequest>, Serializable {br/>@Override
public ServletRequest getObject() {
return currentRequestAttributes().getRequest();br/>}
@Override
public String toString() {
return “Current HttpServletRequest”;
}
}

其中,要获得 request 对象需要先调用 currentRequestAttributes() 方法获得 RequestAttributes 对象,该方法的实现如下:
/**

Return the current RequestAttributes instance as ServletRequestAttributes.
*/
private static ServletRequestAttributes currentRequestAttributes() {
RequestAttributes requestAttr = RequestContextHolder.currentRequestAttributes();
if (!(requestAttr instanceof ServletRequestAttributes)) {
throw new IllegalStateException(“Current request is not a servlet request”);
}
return (ServletRequestAttributes) requestAttr;
}
生成 RequestAttributes 对象的核心代码在类 RequestContextHolder 中,其中相关代码如下(省略了该类中的无关代码):
public abstract class RequestContextHolder {
public static RequestAttributes currentRequestAttributes() throws IllegalStateException {
RequestAttributes attributes = getRequestAttributes();
// 此处省略不相关逻辑…………
return attributes;
}
public static RequestAttributes getRequestAttributes() {
RequestAttributes attributes = requestAttributesHolder.get();
if (attributes == null) {
attributes = inheritableRequestAttributesHolder.get();
}
return attributes;
}
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<RequestAttributes>(“Request attributes”);
private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
new NamedInheritableThreadLocal<RequestAttributes>(“Request context”);
}

通过这段代码可以看出,生成的 RequestAttributes 对象是线程局部变量(ThreadLocal),因此 request 对象也是线程局部变量;这就保证了 request 对象的线程安全性。优缺点该方法的主要优点:1) 注入不局限于 Controller 中:在方法 1 中,只能在 Controller 中加入 request 参数。而对于方法 2,不仅可以在 Controller 中注入,还可以在任何 Bean 中注入,包括 Service、Repository 及普通的 Bean。2) 注入的对象不限于 request:除了注入 request 对象,该方法还可以注入其他 scope 为 request 或 session 的对象,如 response 对象、session 对象等;并保证线程安全。3) 减少代码冗余:只需要在需要 request 对象的 Bean 中注入 request 对象,便可以在该 Bean 的各个方法中使用,与方法 1 相比大大减少了代码冗余。但是,该方法也会存在代码冗余。考虑这样的场景:web 系统中有很多 controller,每个 controller 中都会使用 request 对象(这种场景实际上非常频繁),这时就需要写很多次注入 request 的代码;如果还需要注入 response,代码就更繁琐了。下面说明自动注入方法的改进方法,并分析其线程安全性及优缺点。方法 3:基类中自动注入代码示例与方法 2 相比,将注入部分代码放入到了基类中。基类代码:
public class BaseController {br/>@Autowired
protected HttpServletRequest request; br/>}

Controller 代码如下;这里列举了 BaseController 的两个派生类,由于此时测试代码会有所不同,因此服务端测试代码没有省略;客户端也需要进行相应的修改(同时向 2 个 url 发送大量并发请求)。
@Controller
public class TestController extends BaseController {

// 存储已有参数,用于判断参数 value 是否重复,从而判断线程是否安全
public static Set<String> set = new ConcurrentSkipListSet<>();

@RequestMapping(“/test”)
public void test() throws InterruptedException {
String value = request.getParameter(“key”);
// 判断线程安全
if (set.contains(value)) {
System.out.println(value + “\t 重复出现,request 并发不安全!”);
} else {
System.out.println(value);
set.add(value);
}
// 模拟程序执行了一段时间
Thread.sleep(1000);
}
}

@Controller
public class Test2Controller extends BaseController {br/>@RequestMapping(“/test2”)
public void test2() throws InterruptedException {
String value = request.getParameter(“key”);
// 判断线程安全(与 TestController 使用一个 set 进行判断)
if (TestController.set.contains(value)) {
System.out.println(value + “\t 重复出现,request 并发不安全!”);
} else {
System.out.println(value);
TestController.set.add(value);
}
// 模拟程序执行了一段时间
Thread.sleep(1000);
}
}

线程安全性 br/> 测试结果:线程安全分析:在理解了方法 2 的线程安全性的基础上,很容易理解方法 3 是线程安全的:当创建不同的派生类对象时,基类中的域(这里是注入的 request)在不同的派生类对象中会占据不同的内存空间,也就是说将注入 request 的代码放在基类中对线程安全性没有任何影响;测试结果也证明了这一点。优缺点与方法 2 相比,避免了在不同的 Controller 中重复注入 request;但是考虑到 java 只允许继承一个基类,所以如果 Controller 需要继承其他类时,该方法便不再好用。无论是方法 2 和方法 3,都只能在 Bean 中注入 request;如果其他方法(如工具类中 static 方法)需要使用 request 对象,则需要在调用这些方法时将 request 参数传递进去。下面介绍的方法 4,则可以直接在诸如工具类中的 static 方法中使用 request 对象(当然在各种 Bean 中也可以使用)。方法 4:手动调用代码示例
@Controller
public class TestController {br/>@RequestMapping(“/test”)
public void test() throws InterruptedException {
HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
// 模拟程序执行了一段时间 br/>Thread.sleep(1000);
}
}

线程安全性测试结果:线程安全分析:该方法与方法 2(自动注入)类似,只不过方法 2 中通过自动注入实现,本方法通过手动方法调用实现。因此本方法也是线程安全的。优缺点优点:可以在非 Bean 中直接获取。缺点:如果使用的地方较多,代码非常繁琐;因此可以与其他方法配合使用。方法 5:@ModelAttribute 方法代码示例 br/> 下面这种方法及其变种(变种:将 request 和 bindRequest 放在子类中)在网上经常见到:
@Controller
public class TestController {
private HttpServletRequest request;br/>@ModelAttribute
public void bindRequest(HttpServletRequest request) {
this.request = request;br/>}
@RequestMapping(“/test”)
public void test() throws InterruptedException {
// 模拟程序执行了一段时间 br/>Thread.sleep(1000);
}
}

线程安全性测试结果:线程不安全分析:@ModelAttribute 注解用在 Controller 中修饰方法时,其作用是 Controller 中的每个 @RequestMapping 方法执行前,该方法都会执行。因此在本例中,bindRequest() 的作用是在 test() 执行前为 request 对象赋值。虽然 bindRequest() 中的参数 request 本身是线程安全的,但由于 TestController 是单例的,request 作为 TestController 的一个域,无法保证线程安全。总结综上所述,Controller 中加参数(方法 1)、自动注入(方法 2 和方法 3)、手动调用(方法 4)都是线程安全的,都可以用来获取 request 对象。如果系统中 request 对象使用较少,则使用哪种方式均可;如果使用较多,建议使用自动注入(方法 2 和方法 3)来减少代码冗余。如果需要在非 Bean 中使用 request 对象,既可以在上层调用时通过参数传入,也可以直接在方法中通过手动调用(方法 4)获得。此外,本文在讨论获取 request 对象的方法时,重点讨论该方法的线程安全性、代码的繁琐程度等;在实际的开发过程中,还必须考虑所在项目的规范、代码维护等问题(此处感谢网友的批评指正)。

正文完
 0