用户上下文传递:从单体应用到微服务架构
最近在做单体应用的微服务拆分,对用户上下文传递这一块还不是很熟悉,写个笔记小小记录一下方便回看。
数据流对比:
| 架构 | 数据流 |
|---|---|
| 单体架构 | Gateway(无) → User服务(解析JWT) → SecurityContextHolder(存Authentication) → Controller拿 |
| 微服务架构 | Gateway(解析JWT) → 请求头(X-User-Id + X-User-Roles + X-User-Permissions) → 下游服务拦截器 → UserContext(存userId+roles+permissions) → Controller拿 |
零、模块划分
0.1 单体应用多模块划分
demo(父模块,pom.xml)
├── **common** -- 通用模块(核心工具类、常量、基础配置)
├── **framework** -- 框架模块(安全、日志、异常处理等)
├── **system** -- 系统模块(用户/角色/权限等核心业务)
├── **business** -- 业务模块(可选,存放具体业务功能)
├── **api** -- API模块(可选,存放对外接口DTO和Feign客户端)
└── **app** -- 启动模块(主应用,依赖其他模块)
0.2 微服务应用架构
demo(pom.xml仅做各服务依赖版本的统一管理)
├── common -- 通用模块
│ ├── common-core # 通用核心配置/工具模块
│ ├── common-db # 通用数据库配置模块
│ └── common-jwt # 通用JWT配置/工具模块
│ ├── JwtUtils # JWT 解析工具类(可复用)
│ └── JwtProperties # JWT 配置属性
│
├── auth-service -- 认证服务
├── gateway-service -- 网关服务(负责请求转发)
│ ├── JwtAuthGlobalFilter # 全局过滤器(核心逻辑)
│ ├── JwtAuthenticationException # 自定义异常
│ ├── SkipAuthProperties # 放行路径配置(从 yml 读取)
│ └── GatewayGlobalExceptionHandler # 统一处理 JWT 异常
│
├── log-service -- 异步日志服务
├── ponit-service -- 积分管理服务
├── x-service -- 核心业务服务1
│ ├── UserContext # 用户上下文工具类(存放当前用户信息)
│ ├── UserContextInterceptor # 用户上下文拦截器(解析请求头)
│ └── WebConfig # 注册拦截器
│
├── y-service -- 核心业务服务2
│ ├── UserContext # 用户上下文工具类(存放当前用户信息)
│ ├── UserContextInterceptor # 用户上下文拦截器(解析请求头)
│ └── WebConfig # 注册拦截器
│
├── statistics-service -- 数据统计服务
└── user-service -- 用户管理服务
一、核心问题:网关下游服务如何获取用户上下文?
之前在 JWT过滤器:从单体应用到微服务架构 已经做了网关 JWT 过滤器解析 token 把 userId 存到请求头里,网关下游服务通过以下方式获取:
// user-service 中获取当前用户
String userId = request.getHeader("X-User-Id");
具体实现的主流方案对比如下。
二、方案对比
2.1 方案一:控制器层直接获取(不推荐)
@PostMapping
public Result<XVO> createX(@RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody XDTO dto) {
...
}
缺点:每个方法都这么写,太啰嗦,代码重复、容易遗漏、Controller 签名臃肿。
2.2 方案二:ThreadLocal + 拦截器(推荐)
底层原理和单体 Spring Security 的 SecurityContextHolder 一样(都是 ThreadLocal),区别在于:
- 单体:由 JWT Filter 解析 Token 后填充 SecurityContextHolder
- 微服务:由自定义拦截器从请求头读取后填充 UserContext
注意:每个下游服务都需要独立实现这一套(因为 Gateway 不会帮下游服务存 ThreadLocal)。
实现步骤:
- 构造一个基于 ThreadLocal 的上下文工具类 UserContext
- 构造一个上下文拦截器 UserContextInterceptor 从请求头里读取信息并存入 UserContext
- 在 MVC 中注册这个拦截器
- 在业务层面直接通过
UserContext.getUserId()获取使用
关于模块复用的说明
如果很多服务都会用到这一套或者这一套的涉及变动很频繁的话,可以考虑新建一个 common-web 模块统一管理实现复用(当前 common-core 模块不能含 web 依赖)。
common-core是纯 POJO 模块,被 Gateway(WebFlux)和其他服务(Servlet)共同依赖。如果 UserContextInterceptor 放在 common-core,会引入 spring-web 依赖,导致 Gateway 的 WebFlux 环境出现 Servlet API 冲突。因此拦截器放在各服务自己的模块,或单独抽 common-web 模块。
目前选择是每个服务复制粘贴一套,以后再考虑抽取复用。
完整代码实现
// 1. 写一个拦截器
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String userId = request.getHeader("X-User-Id");
if (userId != null) {
UserContext.setUserId(Long.parseLong(userId));
}
String roles = request.getHeader("X-User-Roles");
if (roles != null && !roles.isEmpty()) {
UserContext.setRoles(Arrays.asList(roles.split(",")));
}
String permissions = request.getHeader("X-User-Permissions");
if (permissions != null && !permissions.isEmpty()) {
UserContext.setPermissions(Arrays.asList(permissions.split(",")));
}
return true;
}
@Override
public void afterCompletion(...) {
UserContext.clear();
}
}
// 2. ThreadLocal 工具类
public class UserContext {
private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();
private static final ThreadLocal<List<String>> rolesHolder = new ThreadLocal<>();
private static final ThreadLocal<List<String>> permissionsHolder = new ThreadLocal<>();
public static void setUserId(Long userId) { userIdHolder.set(userId); }
public static Long getUserId() { return userIdHolder.get(); }
public static void setRoles(List<String> roles) { rolesHolder.set(roles); }
public static List<String> getRoles() { return rolesHolder.get(); }
public static void setPermissions(List<String> permissions) { permissionsHolder.set(permissions); }
public static List<String> getPermissions() { return permissionsHolder.get(); }
// 便捷方法
public static boolean hasRole(String role) {
List<String> roles = getRoles();
return roles != null && roles.contains(role);
}
public static void clear() {
userIdHolder.remove();
rolesHolder.remove();
permissionsHolder.remove();
}
}
// 3. 注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private UserContextInterceptor userContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/actuator/**", "/v3/api-docs/**");
}
}
// 4. Controller 里直接用
@PostMapping
public Result<XVO> createX(@Valid @RequestBody XDTO dto) {
Long userId = UserContext.getUserId();
// 业务逻辑...
}
// 5. 切面鉴权(可选)
@Aspect
@Component
public class RoleAspect {
@Before("@annotation(requireRole)")
public void checkRole(RequireRole requireRole) {
// 实时性要求不高时,直接从 ThreadLocal 拿,不查库
if (!UserContext.hasRole(requireRole.value())) {
throw new ForbiddenException("需要角色:" + requireRole.value());
}
}
}
2.3 方案三:@RequestAttribute(不适用)
在网关服务转发时设置属性,但网关是 reactive,和 servlet 不互通,不太适用。
2.4 方案四:自定义注解 + 参数解析器(方案二的包装)
本质是在上面方案二的基础上包装一层自定义注解。
// 1. 自定义注解
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}
// 2. 参数解析器
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(Long.class)
&& parameter.hasParameterAnnotation(CurrentUser.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
// 从 UserContext 取,而不是直接读请求头
return UserContext.getUserId();
}
}
// 3. 配置注入
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private CurrentUserArgumentResolver currentUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
// 4. Controller 使用(超级优雅!)
@PostMapping
public Result<XVO> createX(@CurrentUser Long userId, // 直接注入!
@Valid @RequestBody XDTO dto) {
// 直接用 userId
}
三、与原单体架构的对比
package org.example.framework.security.util;
import org.example.framework.security.config.CustomUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* 1. 获取当前认证用户信息的工具类
*
* 注意:本工具类假设应用已通过 Spring Security + JWT 过滤器完成认证校验。
* 所有未登录或无效 Token 的请求会在 Security Filter 阶段被拦截(返回 401),
* 因此正常 Web 请求中不会出现 "未认证" 情况。
*/
public class SecurityUtils {
public static final Long SYSTEM_USER_ID = -1L; // -1 表示非用户操作的系统行为(如定时任务、MQ 消费、初始化脚本)
/**
* 获取当前操作用户的 ID
*/
public static Long getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return SYSTEM_USER_ID;
}
Object principal = auth.getPrincipal();
if (principal instanceof CustomUserDetails) {
return ((CustomUserDetails) principal).getId();
}
return SYSTEM_USER_ID;
}
/**
* 获取当前认证用户的详细信息(CustomUserDetails 对象)
*/
public static CustomUserDetails getCurrentUserDetails() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return null;
Object principal = auth.getPrincipal();
if (principal instanceof CustomUserDetails) {
return (CustomUserDetails) principal;
}
return null;
}
}
package org.example.framework.security.filter;
...
/**
* 2. JWT认证过滤器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
...
// 6. 将认证信息存入Security上下文
// 这样后续的过滤器/控制器可以直接获取当前用户信息
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
request.setAttribute("JWT_EXPIRED", e);
} catch (MalformedJwtException | SignatureException e) {
request.setAttribute("JWT_INVALID", e);
} catch (Exception e) {
logger.error("Could not set user authentication in security context", e);
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
...
}
}
对比总结
| 维度 | 原 SecurityUtils | 新 UserContext |
|---|---|---|
| 依赖 | 依赖 Spring Security | 无依赖,纯工具类 |
| 底层存储 | SecurityContextHolder (本质是 ThreadLocal,但由 Spring Security 封装好) | 自定义 ThreadLocal |
| 存储内容 | Authentication 对象(含 userId + authorities + 其他) | userId + roles + permissions(按需扩展) |
| 数据来源 | JWT Filter 解析 JWT 后填充 Authentication | 自定义拦截器从请求头读取后填充 |
| 读取方式 | SecurityUtils.getCurrentUserId() |
UserContext.getUserId() |
| 角色获取 | SecurityUtils.getCurrentUserDetails().getAuthorities() |
UserContext.getRoles() |
| 角色判断 | 通过 @PreAuthorize("hasRole(...)") 或手动调用 |
手动 UserContext.hasRole() 或自定义注解 |
| 适用架构 | 单体 / 微服务中继续使用 Spring Security 的方案 | 微服务(Gateway 统一认证,下游轻量处理) |
四、注意事项
4.1 ThreadLocal 内存泄漏风险
// 一定要在 afterCompletion 中清理!
@Override
public void afterCompletion(...) {
UserContext.clear(); // 忘记 = 内存泄漏
}
4.2 异步方法传递
// 异步线程无法获取主线程的 ThreadLocal
@Async
public void asyncMethod() {
Long userId = UserContext.getCurrentUserId(); // null!
// 解决方案:显式传递
Long userId = UserContext.getCurrentUserId();
asyncService.process(userId);
}
4.3 Feign 调用传递
// Feign 拦截器自动传递请求头
@Configuration
public class FeignConfig {
@Bean
public RequestInterceptor userHeaderInterceptor() {
return template -> {
Long userId = UserContext.getCurrentUserId();
if (userId != null) {
template.header("X-User-Id", String.valueOf(userId));
}
};
}
}
五、扩展:权限实时性问题处理
如果担心 roles/permissions 变了,但 JWT 里的旧数据还没过期:
5.1 方案一:短过期时间 + Refresh Token(常见)
- Access Token 15分钟,Refresh Token 7天
- 最多 15 分钟延迟
5.2 方案二:Redis 存最新权限(实时性要求高)
// 下游服务拦截器里查 Redis
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, ...) {
Long userId = Long.parseLong(request.getHeader("X-User-Id"));
// 从 Redis 获取最新权限(覆盖 JWT 中的旧数据)
List<String> latestRoles = redisTemplate.opsForValue().get("user:roles:" + userId);
if (latestRoles != null) {
UserContext.setRoles(latestRoles);
} else {
// 降级使用 JWT 中的 roles
String roles = request.getHeader("X-User-Roles");
UserContext.setRoles(Arrays.asList(roles.split(",")));
}
List<String> latestPermissions = redisTemplate.opsForValue().get("user:permissions:" + userId);
if (latestPermissions != null) {
UserContext.setPermissions(latestPermissions);
} else {
// 降级使用 JWT 中的 permissions
String permissions = request.getHeader("X-User-Permissions");
UserContext.setPermissions(Arrays.asList(permissions.split(",")));
}
return true;
}
}
更多推荐
所有评论(0)