西凉的悲伤头像

InheritableThreadLocal 实现下文工具类

前言

本文使用 Spring Boot + Spring MVC + Servlet,重点讲清楚为什么需要上下文工具类、如何实现 InheritableThreadLocal、如何编写拦截器、如何在请求结束后清理,以及异步线程池中的上下文传递问题。

一、为什么需要 ThreadLocal 上下文工具类?

在 Spring Boot 项目中,我们经常需要在一次 HTTP 请求的多个业务层之间共享一些信息。

例如,用户请求一个订单接口:

GET /api/orders
Authorization: Bearer token-123

在整个请求处理过程中,可能有多个类需要使用 Token 和用户信息:

OrderController
    ↓
OrderService
    ↓
OrderManager
    ↓
RemoteService

如果每个方法都需要 Token,就可能变成下面这样:

public List<Order> list(String token) {
    return orderService.list(token);
}

public List<Order> list(String token) {
    return orderManager.list(token);
}

public List<Order> list(String token) {
    return remoteService.list(token);
}

这里展示的是示意代码,实际方法签名需要根据业务调整。

随着业务复杂度增加,Token、用户 ID、租户 ID 等参数可能需要在很多方法之间传递。这会让方法签名变得臃肿,而且这些参数并不一定是每个方法的核心业务输入。

1.1 使用 ThreadLocal 改善上下文传递

我们可以在请求处理开始时,将 Token 和用户信息保存到当前线程:

AuthContext.setToken(token);
AuthContext.setUserContext(userContext);

后续业务代码直接读取:

String token = AuthContext.getToken();

UserContext user = AuthContext.getUserContext();

这样就不必为了获取当前用户而层层传递参数。

不过,ThreadLocal 不是全局业务数据仓库。它存储的数据与当前线程绑定,只有在相应的线程上下文中才能直接读取。

1.2 为什么还需要拦截器?

如果每个 Controller 都手动执行 setToken(),很快又会产生大量重复代码。

因此,可以让 Spring MVC 拦截器统一完成以下工作:

  1. 在请求进入 Controller 之前,获取请求头中的 Token。
  2. 调用认证服务,验证 Token 并解析用户信息。
  3. 将 Token 和用户信息保存到上下文。
  4. 允许请求继续处理。
  5. 在请求完成后,清理当前线程中的上下文。

这样,业务代码只需要关注业务逻辑。

二、整体设计与执行流程

整个实现包含四个核心部分:

组件职责
UserContext保存当前登录用户的信息
AuthContext通过两个 InheritableThreadLocal 保存 Token 和用户上下文
AuthInterceptor建立请求上下文,并在请求完成后清理
ContextTaskDecorator将请求上下文显式复制到线程池任务,并在任务结束后恢复原状态

其中,ContextTaskDecorator 是为了解决异步线程池的上下文传递问题,不是普通 HTTP 请求必须使用的组件。

2.1 整体流程图

在这里插入图片描述

如果业务中提交了线程池任务,则会额外经过上下文复制流程:

在这里插入图片描述

注意:HTTP 请求线程清理与线程池任务清理是两个独立的生命周期,不能相互替代。

三、第一步:定义用户上下文对象

首先创建 UserContext,用于存储当前登录用户的基本信息。

为了让示例具有通用性,这里定义用户 ID、用户名和租户 ID 三个字段。

文件:UserContext.java

/**
 * 当前登录用户上下文。
 *
 * 该对象只保存用户的基本身份信息。
 * 实际项目可以根据业务需要增加其他字段。
 *
 * 这里设计为不可变对象,降低多个线程共享对象时
 * 因字段被修改而产生的数据一致性问题。
 */
public final class UserContext {

    private final String userId;

    private final String username;

    private final String tenantId;

    public UserContext(String userId, String username, String tenantId) {
        this.userId = userId;
        this.username = username;
        this.tenantId = tenantId;
    }

    public String getUserId() {
        return userId;
    }

    public String getUsername() {
        return username;
    }

    public String getTenantId() {
        return tenantId;
    }
}

为什么这里推荐不可变对象?

因为后续我们可能将这个对象传递给异步线程。如果对象的字段允许随意修改,那么多个线程共享同一个对象引用时,就可能产生数据竞争。

需要注意,不可变对象只能减少对象内部状态被修改的风险,不能自动解决线程上下文传递、权限隔离或 Token 泄露等问题。

四、第二步:编写 AuthContext 工具类

这是整篇博客的核心。

我们创建两个 InheritableThreadLocal 变量:

private static final InheritableThreadLocal<String> TOKEN =
        new InheritableThreadLocal<>();

private static final InheritableThreadLocal<UserContext> USER_CONTEXT =
        new InheritableThreadLocal<>();

其中:

  • TOKEN 用于保存当前请求的认证令牌。
  • USER_CONTEXT 用于保存当前登录用户信息。

这里保留 InheritableThreadLocal,以符合本示例的设计要求。后面会专门解释它的局限,以及如何处理线程池任务。

文件:AuthContext.java

/**
 * 认证上下文工具类。
 *
 * 负责保存、读取和清理当前线程的 Token 与用户上下文。
 *
 * 注意:
 * 1. 本类不负责验证 Token。
 * 2. 本类不负责判断用户是否具有某项业务权限。
 * 3. 请求结束后必须清理上下文。
 * 4. InheritableThreadLocal 不保证线程池上下文正确传递。
 */
public final class AuthContext {

    /**
     * 当前线程的 Token。
     *
     * InheritableThreadLocal 会在创建子线程时继承父线程的值,
     * 但不能保证在线程池提交任务时重新传递上下文。
     */
    private static final InheritableThreadLocal<String> TOKEN =
            new InheritableThreadLocal<>();

    /**
     * 当前线程的用户上下文。
     */
    private static final InheritableThreadLocal<UserContext> USER_CONTEXT =
            new InheritableThreadLocal<>();

    /**
     * 工具类不允许被实例化。
     */
    private AuthContext() {
    }

    /**
     * 设置当前线程的 Token。
     *
     * @param token 认证令牌
     */
    public static void setToken(String token) {
        if (token == null || token.trim().isEmpty()) {
            TOKEN.remove();
        } else {
            TOKEN.set(token);
        }
    }

    /**
     * 获取当前线程的 Token。
     *
     * @return 当前线程的 Token;不存在时返回 null
     */
    public static String getToken() {
        return TOKEN.get();
    }

    /**
     * 设置当前线程的用户上下文。
     *
     * @param userContext 当前登录用户信息
     */
    public static void setUserContext(UserContext userContext) {
        if (userContext == null) {
            USER_CONTEXT.remove();
        } else {
            USER_CONTEXT.set(userContext);
        }
    }

    /**
     * 获取当前线程的用户上下文。
     *
     * @return 当前用户上下文;不存在时返回 null
     */
    public static UserContext getUserContext() {
        return USER_CONTEXT.get();
    }

    /**
     * 清理当前线程中的所有认证上下文。
     *
     * 必须在请求处理结束或线程池任务结束时,
     * 根据对应生命周期调用。
     */
    public static void clear() {
        TOKEN.remove();
        USER_CONTEXT.remove();
    }

    /**
     * 捕获当前线程上下文快照。
     *
     * 用于在提交异步任务前,显式获取需要传递的数据。
     */
    public static Snapshot capture() {
        return new Snapshot(TOKEN.get(), USER_CONTEXT.get());
    }

    /**
     * 恢复指定上下文。
     *
     * 参数为 null 时清除当前线程上下文。
     */
    public static void restore(Snapshot snapshot) {
        if (snapshot == null) {
            clear();
            return;
        }

        setToken(snapshot.getToken());
        setUserContext(snapshot.getUserContext());
    }

    /**
     * 上下文快照。
     *
     * 快照中的 UserContext 应保持不可变,
     * 避免任务执行期间通过共享引用修改用户信息。
     */
    public static final class Snapshot {

        private final String token;

        private final UserContext userContext;

        private Snapshot(String token, UserContext userContext) {
            this.token = token;
            this.userContext = userContext;
        }

        public String getToken() {
            return token;
        }

        public UserContext getUserContext() {
            return userContext;
        }
    }
}

4.1 为什么提供 capture() 和 restore()?

这两个方法主要是为后续的线程池任务准备的。

例如,在请求线程中:

AuthContext.setToken("token-123");

AuthContext.setUserContext(new UserContext("1001", "zhangsan", "tenant-01"));

AuthContext.Snapshot snapshot = AuthContext.capture();

capture() 会生成一个快照,包含当前 Token 和用户上下文的引用。

之后,即使请求线程清理了自己的上下文,快照中的值仍然可以供后续任务使用。前提是快照被安全地保存并传递给任务。

这里保存的是用户上下文对象的引用,而不是深拷贝,因此应当使用不可变对象,并避免把不适合跨线程传递的请求级对象放进用户上下文。

4.2 为什么清理时使用 remove()?

推荐:

TOKEN.remove();
USER_CONTEXT.remove();

而不是:

TOKEN.set(null);
USER_CONTEXT.set(null);

remove() 会移除当前线程中对应的 ThreadLocal 条目,更适合用于请求结束或任务完成后的上下文清理。

需要注意,remove() 只影响当前线程,不会清理其他线程中保存的副本。

五、第三步:定义 Token 认证接口

拦截器不能仅仅从请求头读取 Token,然后就认定用户已经登录。

正确的流程是:

  1. 读取 Token。
  2. 校验 Token 是否有效。
  3. 从 Token 或认证服务中获取用户身份。
  4. 校验失败则拒绝请求。
  5. 校验成功后才将用户上下文写入 AuthContext。

由于不同项目的认证方式不同,这里定义一个接口,由实际项目实现。

文件:UserContextResolver.java

/**
 * Token 认证与用户上下文解析接口。
 *
 * 实际项目可以通过 JWT 工具类、认证服务或用户中心实现。
 */
public interface UserContextResolver {

    /**
     * 验证 Token 并解析用户上下文。
     *
     * @param token 请求携带的认证令牌
     * @return 认证成功返回用户上下文
     *         认证失败返回 null,或者由实现类抛出约定的异常
     */
    UserContext resolve(String token);
}

例如,项目中可能已经有 JWT 解析服务,可以将它适配为这个接口。

这里有一个安全要求:不能仅仅解码 JWT 就认为认证成功。应当根据项目的认证机制验证签名、有效期以及必要的其他约束。

接下来编写拦截器时,会通过构造器注入这个接口,而不是把具体的 JWT 实现写死在拦截器中。

六、第四步:编写 Spring MVC 拦截器

拦截器负责在请求处理前建立上下文,并在请求完成后清理上下文。

这里使用 Spring Boot 2 常见的 javax.servlet 包。如果你的项目使用 Spring Boot 3,需要将 Servlet 相关导入改为 jakarta.servlet。

文件:AuthInterceptor.java

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 认证上下文拦截器。
 *
 * 职责:
 * 1. 从请求头获取 Token。
 * 2. 调用认证组件验证 Token。
 * 3. 将 Token 和用户信息写入 AuthContext。
 * 4. 认证失败时拒绝请求并清理上下文。
 * 5. 请求完成后清理上下文。
 */
@Component
public class AuthInterceptor implements HandlerInterceptor {

    private final UserContextResolver userContextResolver;

    public AuthInterceptor(UserContextResolver userContextResolver) {
        this.userContextResolver = userContextResolver;
    }

    /**
     * Controller 执行前调用。
     *
     * 返回 true:允许请求继续执行。
     * 返回 false:终止当前请求的后续拦截器和 Controller 处理。
     */
    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) throws Exception {

        // 先清理当前线程可能遗留的上下文。
        // 这不是正常清理流程的替代品,而是额外的防御措施。
        AuthContext.clear();

        // 读取请求头中的 Token。
        String token = resolveToken(
                request.getHeader(HttpHeaders.AUTHORIZATION));

        // 本示例要求受保护接口必须携带 Token。
        if (!StringUtils.hasText(token)) {
            rejectRequest(response, HttpStatus.UNAUTHORIZED);
            return false;
        }

        try {
            // 验证 Token,并解析当前登录用户。
            UserContext userContext =
                    userContextResolver.resolve(token);

            // 没有获取到用户上下文,视为认证失败。
            if (userContext == null) {
                rejectRequest(response, HttpStatus.UNAUTHORIZED);
                return false;
            }

            // 认证成功后,将信息绑定到当前请求线程。
            AuthContext.setToken(token);
            AuthContext.setUserContext(userContext);

            // 允许 Spring MVC 继续调用 Controller。
            return true;

        } catch (Exception ex) {
            // 认证过程中出现异常时,清理上下文。
            AuthContext.clear();

            // 生产项目应根据异常类型区分 Token 无效、
            // 认证服务不可用等情况,并记录不含敏感信息的日志。
            rejectRequest(response, HttpStatus.UNAUTHORIZED);
            return false;
        }
    }

    /**
     * 解析 Authorization 请求头。
     *
     * 支持:
     * Authorization: Bearer abc123
     *
     * 如果你的项目约定 Authorization 直接存储原始 Token,
     * 可以相应调整这里的逻辑。
     */
    private String resolveToken(String authorization) {
        if (!StringUtils.hasText(authorization)) {
            return null;
        }

        String value = authorization.trim();

        if (value.regionMatches(true, 0, "Bearer ", 0, 7)) {
            value = value.substring(7).trim();
        }

        return StringUtils.hasText(value) ? value : null;
    }

    /**
     * 返回统一的认证失败响应。
     *
     * 不向客户端暴露 Token、密钥或内部异常详情。
     */
    private void rejectRequest(
            HttpServletResponse response,
            HttpStatus status) throws IOException {

        response.setStatus(status.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
                "{\"code\":401,\"message\":\"Unauthorized\"}");
    }

    /**
     * 请求完成后执行。
     *
     * 仅当本拦截器的 preHandle() 返回 true 时,
     * Spring MVC 才会在该请求处理流程中调用本拦截器的
     * afterCompletion()。
     *
     * 因此,preHandle() 返回 false 的分支必须自行清理。
     */
    @Override
    public void afterCompletion(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex) {

        AuthContext.clear();
    }
}

6.1 为什么在 preHandle() 开始时先调用 clear()?

AuthContext.clear();

这是额外的防御措施。

正常情况下,上一个请求结束时,afterCompletion() 应当已经清理上下文。但如果某段代码遗漏清理、出现特殊执行路径,或者未来维护时修改了调用流程,线程复用就可能带来残留数据。

请求开始时先清理一次,可以降低使用旧上下文的风险。

不过,开始时清理不能代替结束时清理。如果请求执行过程中产生了异步任务,仍然需要单独处理任务上下文。

6.2 为什么认证失败时要主动清理?

重点看这段代码:

if (!StringUtils.hasText(token)) {
    rejectRequest(response, HttpStatus.UNAUTHORIZED);
    return false;
}

当 preHandle() 返回 false 时,Spring MVC 不会继续调用 Controller,而且当前拦截器的 afterCompletion() 也不会因此自动执行。

所以不能依赖 afterCompletion() 清理所有情况。

本例在 preHandle() 开始时先清理上下文,认证异常时再次清理。对于缺少 Token 的分支,虽然尚未写入新上下文,但前面的清理已经执行。

此外,生产环境不应把所有认证异常都简单视为 401。例如,认证服务故障可能需要返回 5xx,而不是错误地告诉客户端 Token 无效。可以根据项目的异常体系进一步细化处理。

七、第五步:注册拦截器

拦截器编写完成后,还需要注册到 Spring MVC 中,否则它不会自动生效。

文件:WebMvcConfig.java

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    public WebMvcConfig(AuthInterceptor authInterceptor) {
        this.authInterceptor = authInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns(
                        "/api/login",
                        "/api/public/**",
                        "/error"
                );
    }
}

这里的配置表示:

  • /api/** 下的请求默认进入认证拦截器。
  • /api/login、/api/public/** 和 /error 不执行该拦截器。

实际项目需要根据接口权限设计调整路径。排除路径应当只用于确实不需要该认证的接口。

还需要注意,Spring MVC 拦截器主要处理进入 Spring MVC 的请求。如果项目需要对静态资源、其他 Servlet 或整个应用入口实施统一安全控制,应结合 Spring Security Filter Chain 等机制进行设计。

八、第六步:编写线程池上下文传递装饰器

到这里,普通 HTTP 请求已经可以正常使用上下文了。

但是,如果在 Service 中提交异步任务:

executor.execute(() -> {
    String token = AuthContext.getToken();
});

就不能保证任务获取到正确的 Token。

我们接下来使用 TaskDecorator,在提交任务时捕获上下文,在执行任务前设置上下文,并在任务结束后恢复执行线程原来的状态。

8.1 实现 TaskDecorator

文件:ContextTaskDecorator.java

import org.springframework.core.task.TaskDecorator;

/**
 * 为线程池任务传递认证上下文。
 *
 * 提交任务时,在提交线程中捕获上下文快照。
 * 执行任务时,先保存执行线程原来的上下文,
 * 再设置提交任务时捕获的上下文。
 * 任务执行完成后,恢复执行线程原来的上下文。
 */
public class ContextTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {

        // 这段代码在任务提交时执行,
        // 捕获的是提交线程当时的上下文。
        final AuthContext.Snapshot captured =
                AuthContext.capture();

        return new Runnable() {
            @Override
            public void run() {

                // 保存工作线程原有上下文,以便任务结束后恢复。
                AuthContext.Snapshot previous =
                        AuthContext.capture();

                try {
                    // 设置当前任务需要的上下文。
                    AuthContext.restore(captured);

                    // 执行真正的业务任务。
                    runnable.run();

                } finally {
                    // 恢复工作线程原来的上下文,
                    // 防止任务之间相互污染。
                    AuthContext.restore(previous);
                }
            }
        };
    }
}

这个装饰器与简单的 finally { AuthContext.clear(); } 有一个区别。

假设任务执行前,工作线程中存在某些合法的上下文,任务结束后直接清空就会把这些上下文也删除。

而本例选择:

  1. 先保存工作线程原来的上下文。
  2. 设置当前任务的上下文。
  3. 执行任务。
  4. 恢复原来的上下文。

这样可以减少任务之间的相互影响。

不过,在正常设计的线程池中,工作线程不应长期保留请求认证上下文。这里的恢复机制是为了正确处理任务执行前后的状态,而不是鼓励在线程池线程中长期保存用户身份。

8.2 配置线程池

文件:AsyncExecutorConfig.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class AsyncExecutorConfig {

    @Bean("contextAwareExecutor")
    public Executor contextAwareExecutor() {

        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        // 核心线程数。
        executor.setCorePoolSize(4);

        // 最大线程数。
        executor.setMaxPoolSize(8);

        // 等待队列容量。
        executor.setQueueCapacity(200);

        // 线程名称前缀,方便排查问题。
        executor.setThreadNamePrefix("context-task-");

        // 关键:配置上下文传递装饰器。
        executor.setTaskDecorator(new ContextTaskDecorator());

        // 队列满且线程池达到上限时的拒绝策略。
        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.AbortPolicy());

        // Spring 容器关闭时等待任务完成。
        executor.setWaitForTasksToCompleteOnShutdown(true);

        executor.setAwaitTerminationSeconds(30);

        executor.initialize();

        return executor;
    }
}

这里将线程池作为 Spring Bean 注册,并指定 ContextTaskDecorator。

业务代码通过 @Qualifier("contextAwareExecutor") 注入该线程池,就可以在提交任务时使用上下文快照。

线程池参数只是示例值,应根据实际任务耗时、并发量、资源使用情况进行调整。

另外,TaskDecorator 主要装饰 Runnable 任务。对于返回结果的 CompletableFuture,应确保任务确实通过这个配置过的执行器运行;不要假设所有异步 API 都会自动使用它。

九、第七步:业务代码如何使用?

前面的工具类和拦截器已经准备完成,现在通过一个完整的业务例子来看它们如何配合工作。

假设我们开发一个订单查询接口:

GET /api/orders/current
Authorization: Bearer token-123

用户请求进入拦截器后,Token 经过认证,系统获取到以下用户信息:

userId   = 1001
username = zhangsan
tenantId = tenant-01

接下来,Controller 和 Service 就可以直接使用 AuthContext。

9.1 Controller 示例

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    /**
     * 查询当前登录用户的订单。
     */
    @GetMapping("/current")
    public Map<String, Object> currentOrders() {

        // 获取当前请求的认证 Token。
        String token = AuthContext.getToken();

        // 获取当前登录用户。
        UserContext userContext =
                AuthContext.getUserContext();

        // 直接使用上下文中的用户 ID 查询业务数据。
        Object orders = orderService.listByUserId(
                userContext.getUserId());

        Map<String, Object> result = new HashMap<>();
        result.put("userId", userContext.getUserId());
        result.put("username", userContext.getUsername());
        result.put("orders", orders);

        // 这里只展示 Token 的存在状态,不返回实际 Token。
        result.put("hasToken", token != null);

        return result;
    }
}

这里的 OrderService 和 listByUserId() 是示例业务代码。

注意:真实项目中不要因为已经获取到了 tenantId 就认为租户隔离自动生效。数据库查询仍然需要正确添加租户条件,并执行必要的数据权限校验。

9.2 Service 示例

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    /**
     * 根据当前用户查询订单。
     */
    public Object listCurrentUserOrders() {

        UserContext userContext =
                AuthContext.getUserContext();

        if (userContext == null) {
            throw new IllegalStateException(
                    "当前线程不存在用户上下文");
        }

        String userId = userContext.getUserId();
        String tenantId = userContext.getTenantId();

        // 实际项目中应当在 SQL 或数据访问层
        // 同时施加必要的用户和租户数据权限约束。
        return queryOrders(userId, tenantId);
    }

    private Object queryOrders(String userId, String tenantId) {
        // 示例:调用 Mapper 查询数据库。
        return "这里替换为实际订单查询结果";
    }

    public Object listByUserId(String userId) {
        return queryOrders(userId, "tenant-01");
    }
}

上面的 listByUserId() 只是为了让 Controller 示例完整。真实项目不应该把租户 ID 硬编码为 "tenant-01",而应从已经认证的上下文或可信的数据权限体系中取得。

从这里可以看出,AuthContext 的作用是减少上下文参数传递,而不是代替业务层的数据权限控制。

十、第八步:在异步业务中使用线程池

假设订单查询后,还需要异步调用内部服务。

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

import java.util.concurrent.Executor;

@Service
public class OrderAsyncService {

    private final Executor contextAwareExecutor;

    public OrderAsyncService(
            @Qualifier("contextAwareExecutor")
            Executor contextAwareExecutor) {
        this.contextAwareExecutor = contextAwareExecutor;
    }

    public void processAsync() {

        contextAwareExecutor.execute(() -> {

            // 通过 ContextTaskDecorator 传递上下文后,
            // 这里可以读取提交任务时捕获的上下文。
            String token = AuthContext.getToken();

            UserContext userContext =
                    AuthContext.getUserContext();

            if (userContext != null) {
                System.out.println(
                        "当前任务用户:" + userContext.getUserId());
            }

            // 示例:将 token 传给内部 HTTP 客户端时,
            // 应使用实际的服务调用逻辑。
        });
    }
}

这段代码依赖前面配置的 contextAwareExecutor。如果改用其他没有配置上下文传递机制的线程池,就不能直接假设上下文可用。

还要注意:把 Token 传递给异步任务,不代表应该把它打印到日志里。Token 属于敏感凭证,应避免输出完整值。

10.1 一个更严格的线程池清理版本

如果这是专门处理业务任务的线程池,我更建议工作线程在每个任务结束后都清理认证上下文,而不是恢复一个可能已经过期的旧上下文。

可以将 ContextTaskDecorator 改成下面的版本:

import org.springframework.core.task.TaskDecorator;

public class ContextTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {

        // 在任务提交线程中捕获上下文。
        final AuthContext.Snapshot captured =
                AuthContext.capture();

        return () -> {
            try {
                // 设置当前任务的上下文。
                AuthContext.restore(captured);

                // 执行业务任务。
                runnable.run();

            } finally {
                // 专用业务线程池的严格清理策略:
                // 任务结束后不保留任何认证上下文。
                AuthContext.clear();
            }
        };
    }
}

推荐使用这一版本作为专用认证上下文线程池的默认实现。

它能够确保任务正常结束或抛出异常时,都会尝试清理工作线程中的 Token 和用户信息,从而降低线程复用导致上下文串用的风险。

这两个版本不应同时使用,选择其中一个即可。

对于专门承载认证上下文的线程池,任务结束后清理通常更简单、更安全。如果你的执行器还承担嵌套任务或需要保留其他合法线程上下文,则需要进一步设计上下文的保存与恢复策略。

另外,前面线程池配置中的:

executor.initialize();

可以删除。将 ThreadPoolTaskExecutor 注册为 Spring Bean 后,通常由 Spring 容器负责初始化和销毁生命周期,不需要手动重复初始化。

十一、请求结束后如何清理 ThreadLocal?

前面在拦截器中已经写了:

@Override
public void afterCompletion(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler,
        Exception ex) {

    AuthContext.clear();
}

这是普通 Spring MVC 同步请求的主要清理入口。

为什么放在 afterCompletion()?

因为它位于请求处理流程的末尾,适合集中清理当前线程中与请求关联的上下文。无论 Controller 正常完成还是处理过程中发生异常,只要进入该拦截器对应的完成回调,都可以执行清理。

不过要区分两种情况:

场景清理位置
HTTP 请求正常完成afterCompletion()
认证失败,preHandle() 返回 false在拒绝请求的分支主动清理
线程池任务完成任务装饰器的 finally
手动创建的独立线程在线程任务的 finally 中清理
定时任务如果定时任务使用了该上下文,也应在任务完成后清理

因此,不能只依赖一个 afterCompletion() 解决所有线程的上下文清理问题。

11.1 额外注意异步 Servlet 请求

Spring MVC 的异步请求,例如使用 Callable、DeferredResult 或 WebAsyncTask,可能涉及多个线程和不同的请求处理阶段。

afterCompletion() 的执行时机与异步生命周期有关,不能简单地认为它会在所有异步工作线程完成后,替所有线程清理上下文。

如果项目大量使用 Spring MVC 异步请求,应根据具体异步机制分别设计上下文的传递与清理方案。

而对于 CompletableFuture、ExecutorService 这类独立线程池任务,应该使用前面介绍的任务装饰器,或显式传递所需的上下文。

十二、为什么 InheritableThreadLocal 不适合直接解决线程池传递问题?

这是整个方案中最容易被误解的地方。

InheritableThreadLocal 名称中有 Inheritable,意思是可以继承父线程中的值。但它的继承时机非常重要。

12.1 普通 ThreadLocal 与 InheritableThreadLocal

普通 ThreadLocal 不会自动继承父线程中的值:

private static final ThreadLocal<String> TOKEN =
        new ThreadLocal<>();

InheritableThreadLocal 则可以在创建子线程时继承父线程中的值:

private static final InheritableThreadLocal<String> TOKEN =
        new InheritableThreadLocal<>();

例如:

TOKEN.set("token-A");

Thread thread = new Thread(() -> {
    System.out.println(TOKEN.get());
});

thread.start();

新创建的子线程通常能获取到 token-A。

但线程池与直接创建线程不同。

12.2 为什么线程池会出现问题?

假设线程池只有一个工作线程。

在这里插入图片描述

问题在于:

  1. 线程池中的工作线程通常会提前创建。
  2. InheritableThreadLocal 主要在创建线程时继承值。
  3. 后续请求提交新任务时,不会自动为已有工作线程重新继承新请求的值。
  4. 如果之前的任务没有清理上下文,后续任务就可能读取到旧值。

因此,不能将 InheritableThreadLocal 理解为线程池任务上下文的自动传递工具。

12.3 解决方案对比

方案适用场景需要注意
显式传递参数少量、明确的异步任务代码中需要传递参数
TaskDecoratorSpring 管理的线程池必须配置到实际使用的执行器
InheritableThreadLocal某些父子线程场景不保证线程池任务传递正确
专门的上下文传递组件复杂的异步链路需要评估依赖和使用约束

对于你的 Java 后端项目,如果已经使用 CompletableFuture、ThreadPoolTaskExecutor 等组件,我建议优先考虑:

  • 简单场景使用显式参数传递。
  • Spring 管理的线程池使用 TaskDecorator。
  • 任务结束后清理上下文。
  • 对认证信息进行最小化传递,只传递业务真正需要的数据。

还有一个细节:InheritableThreadLocal 默认继承的是值,而对于 UserContext 这样的对象,默认情况下传递的是对象引用,不是自动复制整个对象。因此,将不可变对象作为用户上下文通常更稳妥。

十三、如何让这套代码在真实项目中生效?

前面的代码已经展示了工具类、拦截器、配置类和业务调用。最后还需要把认证接口接入项目。

13.1 实现 UserContextResolver

前面定义的 UserContextResolver 是一个接口,Spring 应用中必须提供对应实现,否则 AuthInterceptor 无法注入该依赖。

例如,你可以基于项目已有的 JWT 认证服务实现:

import org.springframework.stereotype.Component;

@Component
public class ProjectUserContextResolver
        implements UserContextResolver {

    private final JwtAuthService jwtAuthService;

    public ProjectUserContextResolver(
            JwtAuthService jwtAuthService) {
        this.jwtAuthService = jwtAuthService;
    }

    @Override
    public UserContext resolve(String token) {

        // 调用项目现有的认证服务。
        // 该方法必须完成必要的 Token 验证。
        JwtUser user = jwtAuthService.validateAndGetUser(token);

        if (user == null) {
            return null;
        }

        // 将项目内部用户对象转换为通用上下文。
        return new UserContext(
                user.getUserId(),
                user.getUsername(),
                user.getTenantId()
        );
    }
}

这里的 JwtAuthService、JwtUser 和 validateAndGetUser() 是示例名称,需要替换成你项目中真实的认证类、用户对象和方法。

不要为了让示例运行,就直接把任何非空 Token 当成合法 Token。

13.2 完整执行顺序

在这里插入图片描述

十四、最终总结

通过这套实现,我们建立了一个相对完整的请求上下文管理方案。

核心代码的职责可以归纳为:

  • UserContext:定义当前用户的信息结构。
  • AuthContext:管理 Token 和用户上下文的存取。
  • UserContextResolver:负责认证并获取用户信息。
  • AuthInterceptor:在请求进入 Controller 前建立上下文,在请求完成后清理。
  • WebMvcConfig:注册拦截器并设置拦截路径。
  • ContextTaskDecorator:将上下文显式传递到线程池任务,并在任务结束后清理或恢复。

实际使用时,最需要牢记的是三个原则:

  1. 不要把读取 Token 等同于验证 Token。 身份认证必须由可信的认证组件完成。
  2. 不要把 InheritableThreadLocal 等同于线程池上下文传递。 线程池任务需要明确的上下文传递和清理策略。
  3. 不要只在请求结束时清理。 认证失败路径、异步任务、定时任务以及其他独立线程都应考虑自己的生命周期。

如果是普通的 Spring MVC 同步请求,这套方案已经能够满足常见的上下文存取需求;如果项目存在大量异步调用,则应重点完善线程池上下文传递、生命周期隔离和敏感信息管理。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_33697094/article/details/169170363

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--