第三章. common-core 工具类(一):ServletUtils 与 ThreadLocal 详解


第三章. common-core 工具类(一):ServletUtils 与“封装”的艺术

摘要:本章我们将深入 ruoyi-common-coreutils 包,精解 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
2
3
4
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ServletUtils extends JakartaServletUtil {
// ...
}
  • @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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 位于 ServletUtils.java
public static HttpServletRequest getRequest() {
try {
// 关键代码
return getRequestAttributes().getRequest();
} catch (Exception e) {
return null;
}
}

public static ServletRequestAttributes getRequestAttributes() {
try {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
} catch (Exception e) {
return null;
}
}

源码非常清晰,它所有的魔法都指向了一个 Spring 框架的类:RequestContextHolder(请求上下文持有者)。

3.2.2. 答案揭晓:Spring 的 RequestContextHolder

RequestContextHolder 是 Spring Web 提供的一个工具类,它允许我们在 线程级别 上持有 RequestResponse 对象。我们按住 Ctrl 点击 RequestContextHolder,下载源码后查看其核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 位于 Spring-web 的 RequestContextHolder.java
private static final ThreadLocal<RequestAttributes> requestAttributesHolder = new ThreadLocal<>();

public static RequestAttributes getRequestAttributes() {
// 从一个 ThreadLocal 变量中获取
RequestAttributes attributes = requestAttributesHolder.get();
// ...
return attributes;
}

public static void setRequestAttributes(@Nullable RequestAttributes attributes) {
// ...
// 向一个 ThreadLocal 变量中设置
requestAttributesHolder.set(attributes);
}

public static void resetRequestAttributes() {
// 从一个 ThreadLocal 变量中移除
requestAttributesHolder.remove();
}

谜底揭晓了:RequestContextHolder 的核心就是对 ThreadLocalsetgetremove 操作。

3.2.3. 深入 ThreadLocal:原理与数据隔离

在多线程编程中,多个线程共享同一个资源(如一个变量)时,为了防止数据错乱,我们通常需要加锁(如 synchronized),但这会带来性能开销。

ThreadLocal 提供了另一种思路:空间换时间

  • 它是什么? ThreadLocal 允许我们为 每个线程 创建一个 独立的变量副本
  • 它为什么存在? 为了避免线程安全问题。当使用 ThreadLocal 时,每个线程操作的都是自己的那个副本,互不干扰。
  • 它是如何实现的? ThreadLocal 的实现依赖于每个 Thread 对象内部的一个名为 ThreadLocalMap 的数据结构(一个类似 HashMap 的东西)。ThreadLocal 对象本身充当 key,而我们存入的值(比如 Request 对象)充当 value

我们来看一个经典的使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 创建一个 ThreadLocal 变量
ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

// 2. 创建线程一
Thread thread1 = new Thread(() -> {
// 3. 线程一设置自己的值
threadLocal.set(10);
System.out.println("线程1 获取到的值: " + threadLocal.get()); // 输出 10
});

// 4. 创建线程二
Thread thread2 = new Thread(() -> {
// 5. 线程二设置自己的值
threadLocal.set(20);
System.out.println("线程2 获取到的值: " + threadLocal.get()); // 输出 20
});

thread1.start();
thread2.start();

在这个例子中,虽然 threadLocal 变量只有一个,但 set(10)set(20) 的值分别存储在了 thread1thread2 各自的 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() 的工作流程:

  1. 请求开始时:Spring Boot 的核心过滤器(如 RequestContextFilter)会调用 RequestContextHolder.setRequestAttributes(),将当前的 RequestResponse 对象 存入当前处理请求的线程ThreadLocalMap 中。
  2. 业务处理中:在 Controller、Service 或任何地方,ServletUtils.getRequest() 内部调用 RequestContextHolder.getRequestAttributes(),实质上是从 当前线程ThreadLocalMap取出Request 对象。
  3. 请求结束时:过滤器在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package org.dromara.demo.controller;
/**
* ServletUtils 工具类实战演示
*/
@Slf4j
@RestController
@RequestMapping("/servlet") // 统一路径前缀
public class ServletUtilsController {

/**
* 方式一:传统 Spring MVC 方式,通过方法参数注入
*/
@GetMapping("/s1")
public R<Void> s1(HttpServletRequest request, HttpServletResponse response) {
String uri = request.getRequestURI();
log.info("传统方式获取 URI: {}", uri);

// 设置一个自定义响应头,用于后续验证
response.setHeader("X-Test-Header", "S1-Header");
return R.ok("操作成功");
}

/**
* 方式二:使用 ServletUtils 静态方法
*/
@GetMapping("/s2")
public R<Void> s2() {
// 1. 静态获取 request
HttpServletRequest request = ServletUtils.getRequest();
String uri = request.getRequestURI();
log.info("ServletUtils 方式获取 URI: {}", uri);

// 2. 静态获取 response
HttpServletResponse response = ServletUtils.getResponse();
response.setHeader("X-Test-Header", "S2-Header");
return R.ok("操作成功");
}

// ... 后续 Demo 将在这里继续添加 ...
}

步骤三:独立验证

注意: 在这之前需要定位到 application.yml 文件开放我们的测试接口白名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# security配置
security:
# 排除路径
excludes:
- /*.html
- /**/*.html
- /**/*.css
- /**/*.js
- /favicon.ico
- /error
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
- /servlet/** # <- 添加这一行
  1. 重启后端:重启 DromaraApplication
  2. 访问 /s1:在浏览器打开 http://localhost:8080/servlet/s1
  3. 验证 /s1:打开浏览器开发者工具(F12),切换到“网络(Network)”面板,查看 s1 请求。在“响应标头(Response Headers)”中,可以看到我们设置的 X-Test-Header: S1-Header
  4. 访问 /s2:在浏览器打开 http://localhost:8080/servlet/s2
  5. 验证 /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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 痛点代码 (Bad Practice)
String ageStr = request.getParameter("age"); // 返回 String
Integer age = 0; // 默认值

if (ageStr != null && !ageStr.isEmpty()) {
try {
// 核心痛点:必须手动转换,且必须处理 NumberFormatException
age = Integer.parseInt(ageStr);
} catch (NumberFormatException e) {
// 转换失败,比如 ?age=abc,还得处理异常
log.warn("年龄参数格式错误", e);
}
} else {
// 痛点:必须自己处理 null,才能赋予默认值
}

这段代码非常繁琐、丑陋,充满了防御性 iftry-catch

3.3.2. 【RVP 封装】:源码解析 getParameterToInt() 与 Hutool Convert

RVP 的 ServletUtils 帮我们解决了这个痛点:

1
2
3
4
5
// 位于 ServletUtils.java
public static Integer getParameterToInt(String name, Integer defaultValue) {
// 核心:委托给了 Hutool 的 Convert.toInt()
return Convert.toInt(getRequest().getParameter(name), defaultValue);
}

RVP 的封装很简单,它把真正的“脏活累活”交给了 Hutool 的 Convert 类。cn.hutool.core.convert.Convert.toInt() 这个方法极其强大,它能:

  1. 自动处理 null 值。
  2. 自动处理 NumberFormatException
  3. 如果传入 null 或转换失败,就 自动返回你设置的 defaultValue

这就是“封装的艺术”:把复杂的、易错的流程,封装成一个“一定能用”的简单方法

3.3.3. 【实战闭环(二)】:添加 /s3,演示优雅获取 nameage

我们在 ServletUtilsController 中添加 /s3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... 继续在 ServletUtilsController 中添加 ...

/**
* 方式三:测试【封装艺术(一)】 - 优雅地获取参数
*/
@GetMapping("/s3")
public R<Void> s3() {
String name = ServletUtils.getParameter("name", "默认名");
log.info("获取 name: {}", name);

Integer age = ServletUtils.getParameterToInt("age", 0);
log.info("获取 age: {}", age);

// 3. 顺便测试下获取 Header (RVP 封装了 urlDecode)
String Accept = ServletUtils.getHeader(ServletUtils.getRequest(), "accept");
log.info("获取 X-Test-Header: {}", Accept);
return R.ok("操作成功");
}

验证效果

  1. 重启后端
  2. 访问(1)http://localhost:8080/servlet/s3?name=小明&age=18
    • 控制台输出:name: 小明, age: 18
  3. 访问(2)http://localhost:8080/servlet/s3?name=小红 (不传 age)
    • 控制台输出:name: 小红, age: 0 (默认值生效)
  4. 访问(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
2
3
4
5
6
7
8
// Spring MVC 的方式,更加声明式、更解耦
@GetMapping("/s4")
public R<Void> s4(@RequestParam(name = "name", defaultValue = "默认名") String name,
@RequestParam(name = "age", defaultValue = "0") Integer age) {
log.info("获取 name: {}", name);
log.info("获取 age: {}", age);
return R.ok("操作成功");
}

它们的核心区别在于 抽象层次适用场景

特性ServletUtils.getParameterXxx()Spring MVC @RequestParam
抽象层次Servlet API 层MVC 框架层
使用方式在方法体内 主动调用(过程式)在方法签名上 声明绑定(声明式)
耦合关系代码逻辑依赖 Servlet 上下文控制器与 Servlet API 完全解耦,更易于单元测试
适用场景1. 非 Controller 环境:如 FilterInterceptorListener 中。
2. 需要动态、批量获取参数时。
绝大多数 Controller 方法。这是在 Spring MVC 中的 首选最佳实践

小结一下

  • @RequestParam 是上层建筑:它是 Spring MVC 框架的一部分,通过 HandlerMethodArgumentResolver (处理器方法参数解析器) 在调用你的 Controller 方法 之前,就帮你完成了参数的获取、转换、赋默认值等所有“脏活累活”。你的业务代码完全不用关心 Request 对象,非常干净。
  • ServletUtils 是底层工具:它并没有改变“从 Request 中获取参数”这个动作本身,而是对这个 过程 进行了封装和简化。它的价值在于,当你 必须 要和底层 Servlet API 打交道时(例如在过滤器里记录日志),它能让你写出更健壮、更简洁的代码。

所以,结论是:在编写 Spring MVC Controller 时,请优先使用 @RequestParam 注解。 而学习 ServletUtils 的封装思想,能帮助我们理解底层原理,并在框架无法覆盖的场景(如 Filter)中写出同样优雅的代码。


3.4. [封装艺术(二)] “参数绑定”:从 request 自动“填充”到 Bean

获取两三个参数还行,如果一个表单有 10 个字段(比如我们在第一大章通过代码生成的 GoodsBo),我们难道要写 10 次 getParameter 吗?

3.4.1. 【痛点分析】:如果不用工具类,如何将 10 个参数填充到 GoodsBo

1
2
3
4
5
6
7
// 痛点代码 (Bad Practice)
GoodsBo bo = new GoodsBo();
bo.setClassify(Convert.toLong(request.getParameter("classify"))); // 还是得用 Convert
bo.setGoodsName(request.getParameter("goodsName"));
bo.setPrice(new BigDecimal(request.getParameter("price"))); // BigDecimal 更麻烦
bo.setStock(Convert.toInt(request.getParameter("stock")));
// ... 还有 6 个 set ...

这简直是“体力活”,是重复劳动的重灾区。

3.4.2. 【Hutool 封装】:源码解析 toBean()fillBean 的区别

ServletUtils 继承了 Hutool 的 ServletUtil,其中的 toBeanfillBean 方法通过 反射约定(请求参数名与 Java 字段名一致)解决了这个痛点。

  • ServletUtils.toBean(request, GoodsBo.class, true)
    • toBean:你只给它一个 Class,它会 内部帮你 new GoodsBo(),然后自动把 request 中的参数 set 进去。
  • ServletUtils.fillBean(request, bo, true)
    • fillBean:你必须 自己先 new GoodsBo(),它只负责“填充”你传入的这个 bo 实例。

3.4.3. 【实战闭环(三)】:添加 /s4 (Hutool) 和 /s5 (Spring),对比异同

我们在 ServletUtilsController 中添加 /s4/s5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ... 继续在 ServletUtilsController 中添加 ...

/**
* 方式四:测试【封装艺术(二)】 - Hutool 自动填充 Bean
*/
@GetMapping("/s4")
public R<GoodsBo> s4() {
HttpServletRequest request = ServletUtils.getRequest();

// 演示 1: toBean (Hutool 内部 new 对象)
// 第三个参数 true 表示忽略注入错误
GoodsBo bo1 = ServletUtils.toBean(request, GoodsBo.class, true);
log.info("toBean 结果: {}", bo1);

// 演示 2: fillBean (传入已 new 好的实例)
GoodsBo bo2 = new GoodsBo();
ServletUtils.fillBean(request, bo2, true);
log.info("fillBean 结果: {}", bo2);

return R.ok(bo1);
}

/**
* 方式五:Spring MVC 自动绑定 (对比学习)
* Spring MVC 会通过参数解析器,自动帮我们完成参数绑定
*/
@GetMapping("/s5")
public R<GoodsBo> s5(GoodsBo goodsBo) { // 核心:直接在方法参数声明 POJO
log.info("Spring MVC 自动绑定结果: {}", goodsBo);
return R.ok(goodsBo);
}

验证效果

  1. 重启后端
  2. 访问 /s4http://localhost:8080/demo/servlet/s4?classify=1&goodsName=电饭煲
    • 控制台输出:toBean 结果: GoodsBo(classify=1, goodsName=电饭煲, ...)
  3. 访问 /s5http://localhost:8080/demo/servlet/s5?classify=2&goodsName=洗衣机
    • 控制台输出:Spring MVC 自动绑定结果: GoodsBo(classify=2, goodsName=洗衣机, ...)

3.4.4. 【延伸对比】:Hutool toBean 与 Spring MVC 自动绑定的本质区别

和上一节的结论类似,这两种方式再次体现了 底层工具封装框架层声明式绑定 的差异。

特性Hutool toBean/fillBeanSpring MVC 自动绑定
抽象层次Servlet API 层MVC 框架层
工作时机在 Controller 方法体内部主动调用在调用 Controller 方法 之前,由框架 自动完成
代码形态过程式:GoodsBo bo = ServletUtils.toBean(...)声明式:直接将 GoodsBo bo 作为方法参数
耦合关系业务代码依然需要感知和传入 HttpServletRequest 对象业务代码与 Servlet API 完全解耦,更符合 MVC 分层思想
适用场景1. 非 Controller 环境:在 FilterInterceptor 中需要将请求参数封装为对象时。
2. 需要对原生 Request 进行某些特殊预处理后,再进行绑定。
所有 Controller 方法。这是 Spring MVC 中处理表单参数的 首选最佳实践

一句话总结

  • Spring MVC 自动绑定 是 Controller 开发的“阳关道”,它通过强大的参数解析器机制,让我们以最优雅、最解耦的方式接收参数。
  • Hutool 的 toBean 则是“过河的桥”,当我们在 Filter 等底层组件中,框架的“阳关道”还没铺设好时,它能帮助我们快速、安全地将请求参数转换为 Java Bean,避免了手动 set 的繁琐和风险。

在 Controller 层,我们永远优先使用 Spring 自动绑定的方式。理解 Hutool 的实现,是为了深入理解参数绑定的原理,并拥有在更底层环境下解决同类问题的能力。


3.5. [封装艺术(三)] “响应渲染”:告别手写 HeaderIO

我们再来看看 Response 响应对象。

3.5.1. 【痛点分析】:如果不用工具类,如何手动返回 JSON 字符串?

如果我们不使用 @RestControllerR.ok(),而是想手动返回一个 JSON 字符串,原生 API 会是这样:

1
2
3
4
5
6
7
8
9
10
11
// 痛点代码 (Bad Practice)
String jsonString = "{\"name\":\"小明\"}";
response.setStatus(HttpStatus.HTTP_OK); // 1. 设置状态码
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 2. 设置内容类型
response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); // 3. 设置编码
try (PrintWriter writer = response.getWriter()) { // 4. 获取输出流
writer.print(jsonString); // 5. 写入数据
} catch (IOException e) {
// 6. 处理 IO 异常
e.printStackTrace();
}

返回一个 JSON 需要 6 个步骤,极易出错(比如忘了设置 ContentType,浏览器就会当成普通文本下载)。

3.5.2. 【RVP 封装】:源码解析 renderString() (返回 JSON)

RVP 提供的 renderString 完美解决了这个痛点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 位于 ServletUtils.java
public static void renderString(HttpServletResponse response, String string) {
try {
// 1. 设置状态码
response.setStatus(HttpStatus.HTTP_OK);
// 2. 设置内容类型
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 3. 设置编码
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
// 4. 获取输出流并 5. 写入数据
response.getWriter().print(string);
} catch (IOException e) {
// 6. 处理 IO 异常
e.printStackTrace();
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// ... 继续在 ServletUtilsController 中添加 ...

/**
* 方式七:测试【封装艺术(三)】 - 手动渲染 JSON 字符串
* 注意:方法返回值必须是 void
*/
@GetMapping("/s7")
public void s7() {
HttpServletResponse response = ServletUtils.getResponse();
String jsonString = "{\"name\":\"小明\", \"age\":20}";

// RVP 封装:1 行代码搞定 6 个步骤
ServletUtils.renderString(response, jsonString);
}

/**
* 方式八:测试【封装艺术(三)】 - 返回文件下载
*/
@GetMapping("/s8")
public void s8() {
// 1. 准备一个文件 (我们在项目根目录创建 1.txt)
File file = FileUtil.file("1.txt");
if (!file.exists()) {
FileUtil.writeString("OK", file, StandardCharsets.UTF_8);
}

// 2. Hutool 封装:自动设置附件头,触发下载
ServletUtils.write(ServletUtils.getResponse(), file);
}

/**
* 方式九:测试【封装艺术(三)】 - 返回流,并指定文件名
*/
@GetMapping("/s9")
public void s9() {
File file = FileUtil.file("1.txt"); // 还是用 1.txt
InputStream in = FileUtil.getInputStream(file);

HttpServletResponse response = ServletUtils.getResponse();

// Hutool 封装:可以指定文件名和 ContentType
String fileName = "2.txt"; // 下载时显示为 2.txt
String contentType = ContentType.build(ContentType.TEXT_PLAIN.getValue(), StandardCharsets.UTF_8);

ServletUtils.write(response, in, contentType, fileName);
}

验证效果

  1. 重启后端
  2. 访问 /s7http://localhost:8080/demo/servlet/s7。页面显示 {"name":"小明", "age":20}。F12 查看网络,响应类型(Content-Type)为 application/json
  3. 访问 /s8http://localhost:8080/demo/servlet/s8。浏览器 自动下载 一个名为 1.txt 的文件,内容为 “OK”。
  4. 访问 /s9http://localhost:8080/demo/servlet/s9。浏览器 自动下载 一个名为 2.txt 的文件,内容为 “OK”。

3.5.5. 【延伸对比】:ServletUtils.render 与 Spring MVC 响应机制

这再一次引出了一个核心问题:既然 Spring MVC 提供了 @RestControllerResponseEntity,为什么还需要 ServletUtils.renderStringwrite 呢?

答案依然是 抽象层次适用场景 的不同。

特性ServletUtils.renderXxx() / write()Spring MVC (@ResponseBody / ResponseEntity)
抽象层次Servlet API 层MVC 框架层
编程模型命令式/过程式:你必须主动获取 response 对象,并命令它写入数据、设置头。声明式:你只需返回一个数据对象 (POJO) 或 ResponseEntity声明你的意图,框架负责后续所有渲染工作。
核心思想自己动手:手动控制响应流。控制反转 (IoC):将响应的控制权交给框架,业务代码只关心“生产数据”。
适用场景1. 非 Controller 环境:在 FilterInterceptor 或 Spring Security 的 AuthenticationEntryPoint 中,需要提前终止请求并返回错误信息(如 JSON)时,这是唯一正确的方式。
2. 某些需要极限性能或绕开 Spring 默认机制的特殊场景。
所有 Controller 方法。这是返回 JSON 或文件下载的绝对首选最佳实践

简单来说

  • Spring MVC 就像一个全自动餐厅:你在 Controller 里只需要“下单”(返回一个 R 对象或 ResponseEntity),Spring 这个“大厨”就会自动帮你“烹饪”(序列化成 JSON)、“装盘”(设置 ContentTypestatus)、并“端给客人”(写入响应流)。
  • 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 请求头

  1. X-Forwarded-For (Nginx)
  2. Proxy-Client-IP (Apache)
  3. WL-Proxy-Client-IP (Weblogic)
  4. X-Real-IP (Nginx)

  5. 最后,如果都找不到,才会使用 request.getRemoteAddr()

3.6.2. 【源码解析】:isAjaxRequest() (判断 Ajax 的多种依据)

痛点:我们如何判断一个请求是“异步 Ajax”还是“普通页面跳转”?HTTP 协议本身没有这个标准。

RVP 封装isAjaxRequest() 通过 经验主义 进行判断,它会检查:

  1. Accept 头部是否包含 application/json
  2. X-Requested-With 头部是否包含 XMLHttpRequest(JQuery、Axios 等框架默认会带)。
  3. URI 后缀是否为 .json.xml
  4. 请求参数 __ajax 是否为 jsonxml

3.6.3. 【实战闭环(五)】:添加 /s6 演示获取 IP 和 isGet()

我们在 ServletUtilsController 中添加 /s6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ... 继续在 ServletUtilsController 中添加 ...
/**
* 方式六:测试【封装艺术(四)】 - 获取 IP 和请求方法
*/
@GetMapping("/s6")
public R<String> s6() {
HttpServletRequest request = ServletUtils.getRequest();

// 1. 获取客户端 IP (Hutool 封装,能穿透 Nginx)
String ip = ServletUtils.getClientIP(request);
log.info("客户端 IP: {}", ip); // 开发环境通常是 0:0:0:0:0:0:0:1 或 127.0.0.1

// 2. 判断请求方法 (Hutool 基础方法)
boolean isGet = ServletUtils.isGet(request);
log.info("是否是 GET 请求: {}", isGet);

// 3. 判断是否是 Ajax (RVP 封装)
boolean isAjax = ServletUtils.isAjaxRequest(request);
log.info("是否是 Ajax 请求: {}", isAjax); // 浏览器直接访问是 false,但前端 UI 访问是 true

return R.ok("IP: " + ip);
}

验证效果

  1. 重启后端
  2. 访问 /s6http://localhost:8080/servlet/s6
    • 控制台输出:IP: 0:0:0:0:0:0:0:1, isGet: true, isAjax: false (因为浏览器地址栏是同步请求)。

3.7. 本章总结

在本章中,我们彻底告别了“代码搬运工”的视角,而是站在“封装者”和“使用者”的双重角度,重新剖析了 ServletUtils

我们不再把它当做一个 API 列表,而是理解了它背后的 四大“封装艺术”

  1. 上下文的封装:通过 ThreadLocal 实现了 getRequest() 的静态调用,解耦了 Service 层对 Request 的依赖。
  2. 参数获取的封装:通过 getParameterToInt() 告别了繁琐的 null 判断和 try-catch
  3. 参数绑定的封装:通过 toBean 解决了“体力活”式的 set 操作。
  4. 响应渲染的封装:通过 renderStringwrite,把 6 个步骤的 IO 操作和 Header 设置压缩为 1 行代码。

这些封装的背后,是 RVP 框架追求“简洁”、“健壮”和“高效率”的设计哲学。