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
中的值。 尤其是使用 CompletableFuture
、ExecutorService
或类似的异步任务执行框架时,父线程(例如请求线程)需要在异步回调或者后续的任务中传递上下文信息(如 userId
、traceId
等),以便在整个操作链中能够追踪到同一请求的上下文。
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);
}