(误)使用 Spring 代理魔法 将 Http 请求 注入业务层——你应该这么做吗?

发布: (2026年2月1日 GMT+8 22:22)
7 min read
原文: Dev.to

Source: Dev.to

在单例中使用请求作用域的 Bean – 为什么感觉不对

几周前,在审查一个 Spring Boot 代码库时,我发现有一个服务把 HttpServletRequest 注入为单例 Bean 的字段。该 Bean 在服务层被调用,用来构造并发布领域事件,而这些事件需要来自传入请求的信息。

@Component
public class EventFactory {

    @Autowired
    private HttpServletRequest request;

    public void prepareAndSendEvent() {
        OrderEvent ordEvent = new OrderEvent();
        ordEvent.setSentClientIP(request.getRemoteAddr());
    }
}

乍一看代码 能工作,而且看起来很方便,但一个请求作用域的关注点悄悄地潜伏在单例 Bean 中,传输层的抽象直接泄漏到了业务逻辑里。这一小小的设计选择引发了更大的讨论:如果不小心,Spring 的魔法很容易模糊架构边界。

为什么会有违和感?

  • Bean 的作用域不匹配。
    EventFactorysingleton —— Spring 在启动时创建一次,并在每个 HTTP 请求中复用同一个实例。
    HttpServletRequestrequest‑scoped —— 每个进入的请求都会创建一个新实例。

  • Spring 如何把请求作用域的 Bean 注入到单例中?
    启动时根本没有 HTTP 请求,但 Spring 仍然能够完成注入。它是通过注入一个 scoped proxy 来实现的,该代理在运行时委托给实际的请求对象。

弥合作用域差异

当 Spring 在单例中看到请求作用域的依赖时,它会为该依赖创建一个 代理。该代理持有一个指向 ObjectFactory 的引用,ObjectFactory 会在每次调用代理的方法时从 ThreadLocal(或在新版本中从作用域上下文)中获取当前的 HttpServletRequest

因此,当 prepareAndSendEvent() 访问 request.getRemoteAddr() 时,代理会查找 当前 请求并将调用转发给它。这是 Spring 处理作用域不匹配的标准方式。

为什么这是一种“味道”?

Spring 的 “每个请求一个线程” 模型以及它的 stereotype 注解(@Component@Service@Repository)让三层架构的实现变得轻而易举:

  1. Controller 层 —— 接收 HTTP 请求,构建 DTO,并委派给 Service 层。
  2. Service 层 —— 包含无状态业务逻辑,只操作 DTO。
  3. Repository / Gateway 层 —— 与持久化或外部系统交互。

直接在 Service(或任何业务 Bean)中注入 HttpServletRequest 破坏了关注点分离。传输层泄漏到了业务层,使代码更难理解、测试和演进。

我们为此付出的代价是代码整洁性的悄然退化,以及层次化架构的妥协,而收获的仅是极少的便利。

这会导致哪些问题?

1. 在异步执行时会崩溃

代理之所以有效,是因为调用发生在拥有请求作用域 ThreadLocal同一线程 上。如果该方法随后被 异步 执行(例如通过 @AsyncCompletableFuture 或任务执行器),ThreadLocal 将不可用,进而抛出 IllegalStateException

2. 测试复杂度上升

EventFactory 进行单元测试现在必须提供一个模拟的 HttpServletRequest。对请求对象的 Mock 通常只在 Controller 测试中出现,而不应出现在纯业务逻辑测试里。这迫使测试关注于“管线”而非行为,削弱了测试价值。

推荐做法

在业务层之外 提取所需的请求数据,并将其作为方法参数(或专用 DTO)传入。这样业务 Bean 完全不需要感知 HTTP 层。

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    @GetMapping
    public List getOrders(HttpServletRequest request) {
        // ... 业务代码将在这里使用从 request 中提取的信息
    }
}
String clientIp = request.getRemoteAddr();
return orderService.getOrders(clientIp);
}
}
@Service
public class OrderService {

    private final EventFactory eventFactory;

    public List getOrders(String clientIp) {
        // business logic …
        eventFactory.prepareAndSendEvent(clientIp);
        // …
    }
}
@Component
public class EventFactory {

    public void prepareAndSendEvent(String clientIp) {
        OrderEvent ordEvent = new OrderEvent();
        ordEvent.setSentClientIP(clientIp);
        // publish event …
    }
}

现在:

  • 遵守作用域边界 – 服务层仅接收普通数据。
  • 异步执行安全 – 没有隐藏的 ThreadLocal 依赖。
  • 单元测试保持简洁 – 你可以在不使用任何 servlet API mock 的情况下测试 EventFactory

可视化概览

Layered architecture diagram

TL;DR

  • 注入一个请求作用域的 bean (HttpServletRequest) 到单例中在技术上是可能的,得益于 Spring 的作用域代理,但它 违反了分层架构
  • 它会引入隐藏的运行时依赖,在迁移到异步执行时会出错,并且会使单元测试更加困难。
  • 在控制器(或专用拦截器)中提取所需的数据,并显式地将其传递给服务层。这可以保持代码整洁、可测试且具备未来兼容性。

代码示例

@RequestHeader("X-Tenant-Id") String tenantId) {

    String userAgent = request.getHeader("User-Agent");
    String clientIp = request.getRemoteAddr();

    RequestContext ctx = new RequestContext(tenantId, clientIp);

    return orderService.getOrders(ctx);
}
}
@Component
public class EventFactory {

    public void prepareAndSendEvent(EventInfo eventInfo, RequestContext requestContext) {
        // requestContext object has the needed information 
    }

}

TL;DR

将请求作用域的 HttpServletRequest 注入业务层能够工作是因为 Spring 的魔法,但它带来了隐藏的成本和风险。开发者有责任在有更清洁的替代方案时避免使用它。

Back to Blog

相关文章

阅读更多 »

Java 虚拟线程 — 快速指南

Java 虚拟线程 — 快速指南 Java 21+ · Spring Boot 3.2+ · Project Loom 一个简明、面向生产的 Java 虚拟线程指南 — 它们是什么,如何…

适用于 Unity 的 MVP 架构

简介 本文解释了我们如何为 Unity 游戏采用 MVP(Model–View–Presenter)架构。我将逐步介绍整体结构,解释主要…

Spring Boot 异常处理

Java 与 Spring Boot 异常处理笔记 1. 什么是 Exception? Exception = 打破程序正常流程的不期望情况。 异常处理的目标……