TransmittableThreadLocal维护Token中的userId

许多 Web 应用中,Token(通常是JWT)用于身份验证和授权。每次请求到达后,我们需要从 Token 中提取出用户的身份信息(如 userId),并在请求的生命周期内维护这个信息。一个常见的做法是使用 ThreadLocal 来存储 userId,确保每个请求线程都有自己的用户信息。

1. ThreadLocal

ThreadLocal是 Java 提供的一个用于在多线程环境下存储线程局部变量的类。每个线程都可以独立地存取 ThreadLocal 变量,而不会相互干扰。它是处理多线程并发问题的一种简单有效的方式,常用于存储每个线程独有的数据。

1.1. ThreadLocal 的工作原理

  • 每个线程都会持有一个独立的 ThreadLocal 变量副本,线程之间无法共享这个副本。
  • 通过 ThreadLocal.get()ThreadLocal.set() 方法,可以在当前线程中读取或设置该变量。
  • ThreadLocal 的数据在当前线程执行期间是隔离的,当线程结束时,ThreadLocal 变量会被自动清除。

但是ThreadLocal在异步场景下是无法给子线程共享父线程中创建的线程副本数据的,也就是说当前线程的子线程无法获取到当前线程ThreadLocal中的值。 尤其是使用 CompletableFutureExecutorService 或类似的异步任务执行框架时,父线程(例如请求线程)需要在异步回调或者后续的任务中传递上下文信息(如 userIdtraceId 等),以便在整个操作链中能够追踪到同一请求的上下文。

1.2. 问题展示

假设我们使用 Java 的 ExecutorService 来提交异步任务,而在这些任务中需要访问当前线程的 ThreadLocal 数据(如用户 ID)。

  • 普通 ThreadLocal 示例(会丢失数据)
private static ThreadLocal<String> TL = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        TL.set("parent id");
        ExecutorService executor = Executors.newFixedThreadPool(2);
        executor.submit(() -> {
            // 子线程无法访问父线程的 ThreadLocal 数据
            System.out.println(TL.get()); // null 
        });
    }

  • 使用 TransmittableThreadLocal 示例(数据能够传递)
private static TransmittableThreadLocal<String> TTL = new TransmittableThreadLocal<>();

    public static void main(String[] args) throws Exception {
        TTL.set("parent id");
        ExecutorService executor = Executors.newFixedThreadPool(2);
        executor.submit(() -> {
            // 子线程可以访问父线程的 ThreadLocal 数据
            System.out.println(TTL.get()); // 输出 ParentThreadData
        });
    }

结论

  • 在普通 ThreadLocal 示例中,子线程无法访问父线程的数据。
  • 使用 TransmittableThreadLocal 后,子线程能够继承父线程的 ThreadLocal 数据。

2. TransmittableThreadLocal维护Token中的userId

TransmittableThreadLocal 是由阿里巴巴开源的一个项目(TTL)提供的,称为 transmittable-thread-local。 TTL是一个解决多线程和异步编程中 ThreadLocal 数据传递问题的工具。它特别适用于线程池和异步任务执行的场景,能够确保父线程中的 ThreadLocal 数据在子线程(例如异步任务执行的线程)中可用。

2.1. TransmittableThreadLocal工具类

开发项目总对于一些工具、引入的组件服务,我们自己封装上工具类或者是Service能够帮助我们以后更好的维护、管理项目。如果这些工具或者是组件发生了变动,不需要对业务代码进行修改,只需要对我们自己封装得工具类或者是组件进行调整就行。

这里是我针对TTL封装的工具类。

import cn.hutool.core.util.StrUtil;
import com.alibaba.ttl.TransmittableThreadLocal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * {@code ThreadLocalUtil} 是一个工具类,用于管理线程本地存储的数据。
 * 该类使用 {@link TransmittableThreadLocal} 进行线程局部存储,允许在异步操作中传递数据。
 * 它可以为每个线程维护一个 {@link Map},并通过键值对存储对象。
 * 支持线程内存储的值在子线程中传递,适用于跨线程的任务数据传递。
 * <p>
 * 类中的所有操作都是线程安全的,采用了 {@link ConcurrentHashMap} 作为内部存储。
 * </p>
 */
public class ThreadLocalUtil {

    // 使用 TransmittableThreadLocal 来保证跨线程传递数据
    private static final TransmittableThreadLocal<Map<String, Object>> THREAD_LOCAL = new TransmittableThreadLocal<>();

    /**
     * 设置指定键的值到线程本地存储中,如果值为 {@code null},则设置为 {@link StrUtil#EMPTY}。
     *
     * @param key   存储的键
     * @param value 存储的值,可以为 {@code null}
     */
    public static void set(String key, Object value) {
        Map<String, Object> map = getLocalMap();
        map.put(key, value == null ? StrUtil.EMPTY : value);
    }

    /**
     * 获取指定键对应的值。如果该键不存在,则返回 {@code null}。
     *
     * @param key   要获取值的键
     * @param clazz 值的类型,用于类型转换
     * @param <T>   值的类型
     * @return 返回值,类型为 {@code T},如果键不存在则返回 {@code null}
     */
    public static <T> T get(String key, Class<T> clazz) {
        Map<String, Object> map = getLocalMap();
        return (T) map.getOrDefault(key, null);
    }

    /**
     * 获取当前线程的本地存储的 {@link Map} 实例。如果线程本地存储尚未初始化,则创建并返回一个新的 {@link ConcurrentHashMap}。
     *
     * @return 当前线程的本地 {@link Map} 实例
     */
    public static Map<String, Object> getLocalMap() {
        Map<String, Object> map = THREAD_LOCAL.get();
        if (map == null) {
            map = new ConcurrentHashMap<>();
            THREAD_LOCAL.set(map);
        }
        return map;
    }

    /**
     * 移除当前线程的本地存储。清理线程本地存储中的数据。防止内存泄露
     */
    public static void remove() {
        THREAD_LOCAL.remove();
    }
}

2.2. 在controller之前对TTL进行set

这里我选择的是在拦截器中,从token中获取用户的userID,然后通过ThreadLocalUtil将用户的信息存进TTL。也可以选择网关,但是在微服务中,网关通常都是单独的一个服务,和我们的应用基本上不在一个端口上。

@Component
public class TokenInterceptor  implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    @Value("${jwt.secret}")
    private String secret; // 从哪个服务的配置文件中读取,取决于bean对象交给了哪个服务的spring容器进行管理

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = getToken(request); // 从请求头中获取token
        Long userId = tokenService.getUserId(token, secret);
        ThreadLocalUtil.set(Constants.TTL_KEY_USER_ID, userId);
        // 执行其他业务
        //.....
        return true;
    }

    private String getToken(HttpServletRequest request){
        String token = request.getHeader(HttpConstants.AUTHENTICATION);
        if(StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)){
            token = token.replaceFirst(HttpConstants.PREFIX, "");
        }
        return token;
    }
}

2.3. 需要获取到Token中userID的地方直接get

比如,我们在service中需要根据token得到用户的userId,然后根据userID继续业务绑定。这时候就不用一次次的解析token,特别是针对使用JWT的token,因为为了保护JWT的载荷中的用户隐私信息,通常都会只在载荷中只存放一个key,真实的数据信息,存放到redis中。使用TTL直接就可以获取到该线程下用户的id了。十分的方便。

@Override
    public int enter(String token, ExamDTO examDTO) {
        Exam exam = examMapper.selectById(examDTO.getExamId());
        if(exam == null){
            throw new ServiceException(ResultCode.FAILED_NOT_EXISTS);
        }
        if(exam.getStartTime().isBefore(LocalDateTime.now())){
            throw new ServiceException(ResultCode.EXAM_STARTED);
        }
        // 判断用户是否已经报过名
        //Long userId = tokenService.getUserId(token, secret);
        Long userId = ThreadLocalUtil.get(Constants.TTL_KEY_USER_ID, Long.class);
        UserExam userExam = userExamMapper.selectOne(new LambdaQueryWrapper<UserExam>()
                .eq(UserExam::getExamId, examDTO.getExamId())
                .eq(UserExam::getUserId, userId));
        if(userExam != null){
            throw new ServiceException(ResultCode.USER_EXAM_HAS_ENTER);
        }
        // 添加缓存
        examCacheManager.addUserExamCache(userId, examDTO.getExamId());
        // 用户报名 添加进user_exam表
        userExam = new UserExam();
        userExam.setUserId(userId);
        userExam.setExamId(examDTO.getExamId());
        return userExamMapper.insert(userExam);
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小小小小关同学

你的支持就是我的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值