第三章. common-core 工具类(一):ServletUtils 与 ThreadLocal 详解
第三章. common-core 工具类(一):ServletUtils 与 ThreadLocal 详解
Prorise第三章. common-core 工具类(一):ServletUtils 与“封装”的艺术
摘要:本章我们将深入 ruoyi-common-core 的 utils 包,精解 ServletUtils 客户端工具类。本章的重点不是罗列 API,而是通过“痛点分析”与“封装艺术”的对比,揭示其“静态获取”请求对象的魔法——ThreadLocal 的核心原理。
本章学习路径
我们将按照“原理 -> 痛点 -> 封装 -> 实战”的闭环路径,逐个击破 ServletUtils 的核心功能:

3.1. ServletUtils 概览:Hutool 的“增强包装”
在 common-core 模块中,ServletUtils 负责处理所有与 Web 请求(Request)和响应(Response)相关的操作。
3.1.1. 源码定位与设计目标
我们首先在 ruoyi-common/ruoyi-common-core 模块中找到这个类:
文件路径:ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ServletUtils.java
1 |
|
@NoArgsConstructor(access = AccessLevel.PRIVATE):这是一个 Lombok 注解,用于生成一个 私有的无参构造方法。这是一种工具类的标准写法,防止 开发者在外部通过new ServletUtils()来创建实例,强制所有方法都必须通过类名 静态调用(例如ServletUtils.getRequest())。extends JakartaServletUtil:它继承了 Hutool 扩展包(hutool-extra)中的JakartaServletUtil。
3.1.2. 继承关系:为何继承 JakartaServletUtil
继承 JakartaServletUtil 意味着 RVP 的 ServletUtils 自动拥有了 Hutool 提供的所有 Web 工具方法,例如:
fillBean/toBean:将请求参数自动填充到 Java Bean 对象中。getClientIP:获取客户端的真实 IP 地址。getBody/getBodyBytes:获取请求体(Request Body)。isGet/isPost/isMultipart:判断请求方法。write:向客户端(浏览器)返回文件流。
RVP 通过继承它,避免了重复造轮子,专注于实现 Hutool 未提供或不满足需求的 增强功能,例如:
getParameterToInt()/getParameterToBool():带默认值和自动类型转换的参数获取。getRequest()/getResponse():在任何地方静态获取请求和响应对象(这是本章的绝对核心)。isAjaxRequest():更精准的 Ajax 判断。renderString():RVP 风格的 JSON 响应渲染。
3.2. [核心原理] getRequest() 的封装细节
在 RVP 的业务代码中,我们经常看到 Service 层(明明没有 HttpServletRequest 参数)却能直接调用 ServletUtils.getRequest() 来获取请求信息。这是如何实现的?
3.2.1. ServletUtils.getRequest() 如何实现“静态获取”?
我们来看 getRequest() 的源码:
1 | // 位于 ServletUtils.java |
源码非常清晰,它所有的魔法都指向了一个 Spring 框架的类:RequestContextHolder(请求上下文持有者)。
3.2.2. 答案揭晓:Spring 的 RequestContextHolder
RequestContextHolder 是 Spring Web 提供的一个工具类,它允许我们在 线程级别 上持有 Request 和 Response 对象。我们按住 Ctrl 点击 RequestContextHolder,下载源码后查看其核心代码:
1 | // 位于 Spring-web 的 RequestContextHolder.java |
谜底揭晓了:RequestContextHolder 的核心就是对 ThreadLocal 的 set、get、remove 操作。
3.2.3. 深入 ThreadLocal:原理与数据隔离
在多线程编程中,多个线程共享同一个资源(如一个变量)时,为了防止数据错乱,我们通常需要加锁(如 synchronized),但这会带来性能开销。
ThreadLocal 提供了另一种思路:空间换时间。
- 它是什么?
ThreadLocal允许我们为 每个线程 创建一个 独立的变量副本。 - 它为什么存在? 为了避免线程安全问题。当使用
ThreadLocal时,每个线程操作的都是自己的那个副本,互不干扰。 - 它是如何实现的?
ThreadLocal的实现依赖于每个Thread对象内部的一个名为ThreadLocalMap的数据结构(一个类似HashMap的东西)。ThreadLocal对象本身充当key,而我们存入的值(比如Request对象)充当value。
我们来看一个经典的使用示例:
1 | // 1. 创建一个 ThreadLocal 变量 |
在这个例子中,虽然 threadLocal 变量只有一个,但 set(10) 和 set(20) 的值分别存储在了 thread1 和 thread2 各自的 ThreadLocalMap 中。
3.2.4. ThreadLocal 的内存泄漏问题与 remove()
ThreadLocal 虽然强大,但如果使用不当,极易导致 内存泄漏。
- 问题根源:
ThreadLocalMap中的key(即ThreadLocal对象)是一个 弱引用,当ThreadLocal对象没有其他强引用时,GC 会回收它,此时key变为null。但value依然被ThreadLocalMap强引用,导致value无法被回收。 - 在线程池中问题加剧:在 Spring Boot 中,Web 服务器(如 Undertow、Tomcat)都使用 线程池 来处理请求。线程是复用的,如果上一个请求在
ThreadLocal中设置了值,但没有在请求结束时清理,那么下一个请求(复用同一个线程)就会读到上一个请求的“脏数据”。
因此,ThreadLocal 的最佳实践是:在使用完毕后,必须显式调用 remove() 方法来清理当前线程的 ThreadLocalMap。
3.2.5. 总结:ThreadLocal 在 RVP 中的应用与“线程切换”陷阱
现在我们彻底明白了 ServletUtils.getRequest() 的工作流程:
- 请求开始时:Spring Boot 的核心过滤器(如
RequestContextFilter)会调用RequestContextHolder.setRequestAttributes(),将当前的Request和Response对象 存入 到 当前处理请求的线程 的ThreadLocalMap中。 - 业务处理中:在 Controller、Service 或任何地方,
ServletUtils.getRequest()内部调用RequestContextHolder.getRequestAttributes(),实质上是从 当前线程 的ThreadLocalMap中 取出 了Request对象。 - 请求结束时:过滤器在
finally块中,会调用RequestContextHolder.resetRequestAttributes()(内部就是remove()),清空ThreadLocal,防止内存泄漏和线程复用时的脏数据。
核心陷阱:ThreadLocal 与异步线程
正因为 Request 对象是绑定在 当前线程 上的,如果你在业务代码中切换了线程(例如 new Thread().start() 或使用了自定义的线程池 CompletableFuture.runAsync()),在 新线程 中调用 ServletUtils.getRequest() 将会 返回 null!
3.2.6. 【实战闭环(一)】:构建 ServletUtilsController,验证 ThreadLocal 机制
步骤一:引入问题
我们如何验证 ServletUtils.getRequest() 真的等同于在 Controller 方法参数里写 HttpServletRequest?
步骤二:复用与改造(创建 Demo Controller)
我们在 ruoyi-demo 模块中创建 ServletUtilsController。
文件路径:ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/ServletUtilsController.java
1 | package org.dromara.demo.controller; |
步骤三:独立验证
注意: 在这之前需要定位到 application.yml 文件开放我们的测试接口白名单
1 | # security配置 |
- 重启后端:重启
DromaraApplication。 - 访问
/s1:在浏览器打开http://localhost:8080/servlet/s1。 - 验证
/s1:打开浏览器开发者工具(F12),切换到“网络(Network)”面板,查看s1请求。在“响应标头(Response Headers)”中,可以看到我们设置的X-Test-Header: S1-Header。 - 访问
/s2:在浏览器打开http://localhost:8080/servlet/s2。 - 验证
/s2:查看s2请求,同样可以在响应标头中找到X-Test-Header: S2-Header。
结论:s2 的方式完全可行,它让我们的方法签名更简洁,这都归功于 ThreadLocal。
3.3. [封装(一)] “参数获取”:告别 try-catch 与手动转换
我们已经掌握了如何获取 Request 对象,但 原生 Request 的 API 非常难用。
3.3.1. 【痛点分析】:如果不用工具类,我们如何安全地获取 Integer 参数?
假设我们要获取 URL ?age=18 中的 age 参数,如果用原生 API,代码是这样的:
1 | // 痛点代码 (Bad Practice) |
这段代码非常繁琐、丑陋,充满了防御性 if 和 try-catch。
3.3.2. 【RVP 封装】:源码解析 getParameterToInt() 与 Hutool Convert
RVP 的 ServletUtils 帮我们解决了这个痛点:
1 | // 位于 ServletUtils.java |
RVP 的封装很简单,它把真正的“脏活累活”交给了 Hutool 的 Convert 类。cn.hutool.core.convert.Convert.toInt() 这个方法极其强大,它能:
- 自动处理
null值。 - 自动处理
NumberFormatException。 - 如果传入
null或转换失败,就 自动返回你设置的defaultValue。
这就是“封装的艺术”:把复杂的、易错的流程,封装成一个“一定能用”的简单方法。
3.3.3. 【实战闭环(二)】:添加 /s3,演示优雅获取 name 和 age
我们在 ServletUtilsController 中添加 /s3:
1 | // ... 继续在 ServletUtilsController 中添加 ... |
验证效果:
- 重启后端。
- 访问(1):
http://localhost:8080/servlet/s3?name=小明&age=18- 控制台输出:
name: 小明,age: 18
- 控制台输出:
- 访问(2):
http://localhost:8080/servlet/s3?name=小红(不传 age)- 控制台输出:
name: 小红,age: 0(默认值生效)
- 控制台输出:
- 访问(3):
http://localhost:8080/servlet/s3?age=abc(错误格式)- 控制台输出:
name: 默认名,age: 0(默认值生效)
- 控制台输出:
3.3.4. 【延伸对比】:ServletUtils 与 Spring MVC @RequestParam 有何区别?
聪明的你肯定会问:Spring MVC 的 @RequestParam 注解不是也能实现同样的功能吗?为什么还需要 ServletUtils 这种方式?
说得没错!@RequestParam 是 Spring MVC 在 框架层 提供的、更优雅的解决方案。我们来看一下对比:
1 | // Spring MVC 的方式,更加声明式、更解耦 |
它们的核心区别在于 抽象层次 和 适用场景:
| 特性 | ServletUtils.getParameterXxx() | Spring MVC @RequestParam |
|---|---|---|
| 抽象层次 | Servlet API 层 | MVC 框架层 |
| 使用方式 | 在方法体内 主动调用(过程式) | 在方法签名上 声明绑定(声明式) |
| 耦合关系 | 代码逻辑依赖 Servlet 上下文 | 控制器与 Servlet API 完全解耦,更易于单元测试 |
| 适用场景 | 1. 非 Controller 环境:如 Filter、Interceptor、Listener 中。2. 需要动态、批量获取参数时。 | 绝大多数 Controller 方法。这是在 Spring MVC 中的 首选 和 最佳实践。 |
小结一下:
@RequestParam是上层建筑:它是 Spring MVC 框架的一部分,通过HandlerMethodArgumentResolver(处理器方法参数解析器) 在调用你的 Controller 方法 之前,就帮你完成了参数的获取、转换、赋默认值等所有“脏活累活”。你的业务代码完全不用关心Request对象,非常干净。ServletUtils是底层工具:它并没有改变“从 Request 中获取参数”这个动作本身,而是对这个 过程 进行了封装和简化。它的价值在于,当你 必须 要和底层ServletAPI 打交道时(例如在过滤器里记录日志),它能让你写出更健壮、更简洁的代码。
所以,结论是:在编写 Spring MVC Controller 时,请优先使用 @RequestParam 注解。 而学习 ServletUtils 的封装思想,能帮助我们理解底层原理,并在框架无法覆盖的场景(如 Filter)中写出同样优雅的代码。
3.4. [封装艺术(二)] “参数绑定”:从 request 自动“填充”到 Bean
获取两三个参数还行,如果一个表单有 10 个字段(比如我们在第一大章通过代码生成的 GoodsBo),我们难道要写 10 次 getParameter 吗?
3.4.1. 【痛点分析】:如果不用工具类,如何将 10 个参数填充到 GoodsBo?
1 | // 痛点代码 (Bad Practice) |
这简直是“体力活”,是重复劳动的重灾区。
3.4.2. 【Hutool 封装】:源码解析 toBean() 与 fillBean 的区别
ServletUtils 继承了 Hutool 的 ServletUtil,其中的 toBean 和 fillBean 方法通过 反射 和 约定(请求参数名与 Java 字段名一致)解决了这个痛点。
ServletUtils.toBean(request, GoodsBo.class, true)- toBean:你只给它一个
Class,它会 内部帮你new GoodsBo(),然后自动把request中的参数set进去。
- toBean:你只给它一个
ServletUtils.fillBean(request, bo, true)- fillBean:你必须 自己先
new GoodsBo(),它只负责“填充”你传入的这个bo实例。
- fillBean:你必须 自己先
3.4.3. 【实战闭环(三)】:添加 /s4 (Hutool) 和 /s5 (Spring),对比异同
我们在 ServletUtilsController 中添加 /s4 和 /s5:
1 | // ... 继续在 ServletUtilsController 中添加 ... |
验证效果:
- 重启后端。
- 访问
/s4:http://localhost:8080/demo/servlet/s4?classify=1&goodsName=电饭煲- 控制台输出:
toBean 结果: GoodsBo(classify=1, goodsName=电饭煲, ...)
- 控制台输出:
- 访问
/s5:http://localhost:8080/demo/servlet/s5?classify=2&goodsName=洗衣机- 控制台输出:
Spring MVC 自动绑定结果: GoodsBo(classify=2, goodsName=洗衣机, ...)
- 控制台输出:
3.4.4. 【延伸对比】:Hutool toBean 与 Spring MVC 自动绑定的本质区别
和上一节的结论类似,这两种方式再次体现了 底层工具封装 与 框架层声明式绑定 的差异。
| 特性 | Hutool toBean/fillBean | Spring MVC 自动绑定 |
|---|---|---|
| 抽象层次 | Servlet API 层 | MVC 框架层 |
| 工作时机 | 在 Controller 方法体内部 被 主动调用 | 在调用 Controller 方法 之前,由框架 自动完成 |
| 代码形态 | 过程式:GoodsBo bo = ServletUtils.toBean(...) | 声明式:直接将 GoodsBo bo 作为方法参数 |
| 耦合关系 | 业务代码依然需要感知和传入 HttpServletRequest 对象 | 业务代码与 Servlet API 完全解耦,更符合 MVC 分层思想 |
| 适用场景 | 1. 非 Controller 环境:在 Filter 或 Interceptor 中需要将请求参数封装为对象时。2. 需要对原生 Request 进行某些特殊预处理后,再进行绑定。 | 所有 Controller 方法。这是 Spring MVC 中处理表单参数的 首选 和 最佳实践。 |
一句话总结:
- Spring MVC 自动绑定 是 Controller 开发的“阳关道”,它通过强大的参数解析器机制,让我们以最优雅、最解耦的方式接收参数。
- Hutool 的
toBean则是“过河的桥”,当我们在Filter等底层组件中,框架的“阳关道”还没铺设好时,它能帮助我们快速、安全地将请求参数转换为 Java Bean,避免了手动set的繁琐和风险。
在 Controller 层,我们永远优先使用 Spring 自动绑定的方式。理解 Hutool 的实现,是为了深入理解参数绑定的原理,并拥有在更底层环境下解决同类问题的能力。
3.5. [封装艺术(三)] “响应渲染”:告别手写 Header 与 IO 流
我们再来看看 Response 响应对象。
3.5.1. 【痛点分析】:如果不用工具类,如何手动返回 JSON 字符串?
如果我们不使用 @RestController 或 R.ok(),而是想手动返回一个 JSON 字符串,原生 API 会是这样:
1 | // 痛点代码 (Bad Practice) |
返回一个 JSON 需要 6 个步骤,极易出错(比如忘了设置 ContentType,浏览器就会当成普通文本下载)。
3.5.2. 【RVP 封装】:源码解析 renderString() (返回 JSON)
RVP 提供的 renderString 完美解决了这个痛点:
1 | // 位于 ServletUtils.java |
ServletUtils.renderString() 把 6 个步骤封装成了 1 行调用,这在 RVP 的认证、鉴权失败处理中被广泛使用。
3.5.3. 【Hutool 封装】:源码解析 write() (返回文件)
Hutool 的 write() 封装更“贴心”。当我们想让浏览器下载一个文件时,最关键的是设置一个响应头:Content-Disposition: attachment; filename="1.txt"
Hutool 的 write(response, file) 不仅帮我们处理了文件 IO 流的读写,还 自动帮我们设置了 Content-Disposition 响应头,触发浏览器下载。
3.5.4. 【实战闭环(四)】:添加 /s7 (JSON), /s8 (File), /s9 (Stream) 并验证
我们在 ServletUtilsController 中添加这三个方法。注意:由于我们是手动渲染响应,方法返回值必须是 void!
1 | // ... 继续在 ServletUtilsController 中添加 ... |
验证效果:
- 重启后端。
- 访问
/s7:http://localhost:8080/demo/servlet/s7。页面显示{"name":"小明", "age":20}。F12 查看网络,响应类型(Content-Type)为application/json。 - 访问
/s8:http://localhost:8080/demo/servlet/s8。浏览器 自动下载 一个名为1.txt的文件,内容为 “OK”。 - 访问
/s9:http://localhost:8080/demo/servlet/s9。浏览器 自动下载 一个名为2.txt的文件,内容为 “OK”。
3.5.5. 【延伸对比】:ServletUtils.render 与 Spring MVC 响应机制
这再一次引出了一个核心问题:既然 Spring MVC 提供了 @RestController 和 ResponseEntity,为什么还需要 ServletUtils.renderString 或 write 呢?
答案依然是 抽象层次 和 适用场景 的不同。
| 特性 | ServletUtils.renderXxx() / write() | Spring MVC (@ResponseBody / ResponseEntity) |
|---|---|---|
| 抽象层次 | Servlet API 层 | MVC 框架层 |
| 编程模型 | 命令式/过程式:你必须主动获取 response 对象,并命令它写入数据、设置头。 | 声明式:你只需返回一个数据对象 (POJO) 或 ResponseEntity,声明你的意图,框架负责后续所有渲染工作。 |
| 核心思想 | 自己动手:手动控制响应流。 | 控制反转 (IoC):将响应的控制权交给框架,业务代码只关心“生产数据”。 |
| 适用场景 | 1. 非 Controller 环境:在 Filter、Interceptor 或 Spring Security 的 AuthenticationEntryPoint 中,需要提前终止请求并返回错误信息(如 JSON)时,这是唯一且正确的方式。2. 某些需要极限性能或绕开 Spring 默认机制的特殊场景。 | 所有 Controller 方法。这是返回 JSON 或文件下载的绝对首选和最佳实践。 |
简单来说:
- Spring MVC 就像一个全自动餐厅:你在 Controller 里只需要“下单”(返回一个
R对象或ResponseEntity),Spring 这个“大厨”就会自动帮你“烹饪”(序列化成 JSON)、“装盘”(设置ContentType和status)、并“端给客人”(写入响应流)。 ServletUtils就像餐厅的后厨工具:当“餐厅”还没开门(请求在Filter就被拦截),或者你需要做一道“菜单上没有的菜”时,你就得亲自去后厨,用ServletUtils这些封装好的“厨具”来手动完成烹饪和装盘。
结论:在日常的 Controller 开发中,我们应当 100% 优先使用 Spring MVC 的响应机制。学习 ServletUtils.renderString 的意义在于,当我们需要在更底层的组件中(如安全框架的异常处理器)手动构造响应时,它能提供一个安全、便捷的强大工具。
3.6. [封装艺术(四)] “信息获取”:IP、Header 与请求类型
最后,我们来看几个看似简单,实则“水很深”的封装。
3.6.1. 【源码解析】:getClientIP() (为何它比 request.getRemoteAddr() 更可靠)
痛点:在生产环境中,我们的 Spring Boot 应用几乎 100% 跑在 Nginx 或 F5 负载均衡后面。此时,你调用 request.getRemoteAddr() 获取到的 IP,永远是 Nginx 的 IP(如 127.0.0.1),而不是真实用户的 IP。
Hutool 封装:getClientIP() 解决了这个问题。它会依次检查 Nginx、Squid 等各种代理服务器设置的 真实 IP 请求头:
X-Forwarded-For(Nginx)Proxy-Client-IP(Apache)WL-Proxy-Client-IP(Weblogic)X-Real-IP(Nginx)- …
最后,如果都找不到,才会使用request.getRemoteAddr()。
3.6.2. 【源码解析】:isAjaxRequest() (判断 Ajax 的多种依据)
痛点:我们如何判断一个请求是“异步 Ajax”还是“普通页面跳转”?HTTP 协议本身没有这个标准。
RVP 封装:isAjaxRequest() 通过 经验主义 进行判断,它会检查:
Accept头部是否包含application/json。X-Requested-With头部是否包含XMLHttpRequest(JQuery、Axios 等框架默认会带)。- URI 后缀是否为
.json或.xml。 - 请求参数
__ajax是否为json或xml。
3.6.3. 【实战闭环(五)】:添加 /s6 演示获取 IP 和 isGet()
我们在 ServletUtilsController 中添加 /s6:
1 | // ... 继续在 ServletUtilsController 中添加 ... |
验证效果:
- 重启后端。
- 访问
/s6:http://localhost:8080/servlet/s6。- 控制台输出:
IP: 0:0:0:0:0:0:0:1,isGet: true,isAjax: false(因为浏览器地址栏是同步请求)。
- 控制台输出:
3.7. 本章总结
在本章中,我们彻底告别了“代码搬运工”的视角,而是站在“封装者”和“使用者”的双重角度,重新剖析了 ServletUtils。
我们不再把它当做一个 API 列表,而是理解了它背后的 四大“封装艺术”:
- 上下文的封装:通过
ThreadLocal实现了getRequest()的静态调用,解耦了 Service 层对Request的依赖。 - 参数获取的封装:通过
getParameterToInt()告别了繁琐的null判断和try-catch。 - 参数绑定的封装:通过
toBean解决了“体力活”式的set操作。 - 响应渲染的封装:通过
renderString和write,把 6 个步骤的 IO 操作和 Header 设置压缩为 1 行代码。
这些封装的背后,是 RVP 框架追求“简洁”、“健壮”和“高效率”的设计哲学。









