最近在做单体应用的微服务拆分,对用户上下文传递这一块还不是很熟悉,写个笔记小小记录一下方便回看。

数据流对比:

架构 数据流
单体架构 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)。

实现步骤:

  1. 构造一个基于 ThreadLocal 的上下文工具类 UserContext
  2. 构造一个上下文拦截器 UserContextInterceptor 从请求头里读取信息并存入 UserContext
  3. 在 MVC 中注册这个拦截器
  4. 在业务层面直接通过 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;
    }
}

更多推荐