小蔡学Java

项目一总结:(三)用户信息在微服务之间的传递

2024-01-10 17:06 1314 0 项目 拦截器过滤器ThreadLocal

大家好,我是小蔡!今天来总结项目一用户信息的传递这一模块!

前言

总所周知,用户信息在服务之间的传递方式这是一个老生常谈的问题了;在我的在线教育项目中,无论是用户购买课程,还是后续对课程的学习等,我们都需要保证用户信息在服务之间的传递的可靠性;那么,该从哪里获取用户信息呢?

实现思路

Online学习平台是基于JWT实现登录的,登录信息就保存在请求头的token中。因此要获取当前登录用户,只要获取请求头,解析其中的token即可。如果不熟悉可以看我上一篇文章总结:登录鉴权怎么实现的

但是,每个微服务都可能需要登录用户信息,在每个微服务都做token解析就属于重复编码了。因此我们的把token解析的行为放到了网关中,然后由网关把用户信息放入请求头,传递给下游微服务

每个微服务要从请求头拿出用户信息,在业务中使用,也比较麻烦,所以我们定义了一个HandlerInterceptor,拦截进入微服务的请求,并获取用户信息,存入UserContext(底层基于ThreadLocal) 如果对ThreadLocal的原理和实现不了解可以先看这里:ThreadLocal使用面对的问题。这样后续的业务处理时就能直接从UserContext中获取用户了:

具体实现

gateway模块中的AccountAuthFilter#filter()方法

@Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取请求request信息
        ServerHttpRequest request = exchange.getRequest();
        String method = request.getMethodValue();
        String path = request.getPath().toString();
        String antPath = method + ":" + path;

        // 2.判断是否是无需登录的路径
        if(isExcludePath(antPath)){
            // 直接放行
            return chain.filter(exchange);
        }

        // 3.尝试获取用户信息
        List<String> authHeaders = exchange.getRequest().getHeaders().get(AUTHORIZATION_HEADER);
        String token = authHeaders == null ? "" : authHeaders.get(0);
        R<LoginUserDTO> r = authUtil.parseToken(token);

        // 4.如果用户是登录状态,尝试更新请求头,传递用户信息
        if(r.success()){
            exchange.mutate()
                    .request(builder -> builder.header(USER_HEADER, r.getData().getUserId().toString()))
                    .build();
        }

        // 5.校验权限
        authUtil.checkAuth(antPath, r);

        // 6.放行
        return chain.filter(exchange);
    }

关键逻辑

  • 更新请求头之前

  • 更新请求头之后

可以看到,网关将登录的用户信息放入请求头中传递到了下游的微服务。因此,我们只要在微服务中获取请求头,即可拿到登录的用户信息。

用户信息上下文拦截器

@Slf4j
public class UserInfoInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.尝试获取头信息中的用户信息
        String authorization = request.getHeader(JwtConstants.USER_HEADER);
        // 2.判断是否为空
        if (authorization == null) {
            return true;
        }
        // 3.转为用户id并保存到UserContext中
        try {
            Long userId = Long.valueOf(authorization);
            UserContext.setUser(userId);
            return true;
        } catch (NumberFormatException e) {
            log.error("用户身份信息格式不正确,{}, 原因:{}", authorization, e.getMessage());
            return true;
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 清理用户信息
        UserContext.removeUser();
    }
}

在这个拦截器中,获取到用户信息后保存到了UserContext中,这是一个基于ThreadLocal的工具,可以确保不同的请求之间互不干扰,避免线程安全问题发生

package com.tianji.common.utils;

public class UserContext {
    private static final ThreadLocal<Long> TL = new ThreadLocal<>();

    /**
     * 保存用户信息
     * @param userId 用户id
     */
    public static void setUser(Long userId){
        TL.set(userId);
    }

    /**
     * 获取用户
     * @return 用户id
     */
    public static Long getUser(){
        return TL.get();
    }

    /**
     * 移除用户信息
     */
    public static void removeUser(){
        TL.remove();
    }
}

  • 可以看到这个拦截器的核心作用就是从请求头中读取出用户id,然后保存到UserContext中。所以,我们才能在后续的业务逻辑中通过UserContext.getUser()来读取当前登录的用户id。

  • 同时,我们可以发现这个拦截器的作用仅仅是获取用户信息,无论获取成功或者失败,最终都会放行。不会拦截用户请求。

其他拦截器

  • 如果在feign调用时ThreadLocal的用户信息不存在了? 我们定义了一个Feign的拦截器

  • 实现RequestInterceptor重写apply方法。如果不存在了我们就从请求头中获取信息,并且set到ThreadLocal

public class FeignRelayUserInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUser();
        if (userId == null) {
            return;
        }
        template.header(JwtConstants.USER_HEADER, userId.toString());
    }
}

用户登录拦截器

@Slf4j
public class LoginAuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.尝试获取用户信息
        Long userId = UserContext.getUser();
        // 2.判断是否登录
        if (userId == null) {
            response.setStatus(401);
            response.sendError(401, "未登录用户无法访问!");
            // 2.3.未登录,直接拦截
            return false;
        }
        // 3.登录则放行
        return true;
    }
}

这个拦截器的作用就是拦截一些需要登录才能操作的页面,提醒用户登录

可以看到,这个拦截器就是判断用户是否登录,未登录会直接拦截并且返回错误码。不过这个拦截器是通过UserContext.getUser()方法来判断用户是否登录的。也就是说它依赖于UserInfoInterceptor,因此两个拦截器是有先后顺序的,不能搞错。

拦截器配置

@Configuration
@EnableConfigurationProperties(ResourceAuthProperties.class)
public class ResourceInterceptorConfiguration implements WebMvcConfigurer {

    private final ResourceAuthProperties authProperties;

    @Autowired
    public ResourceInterceptorConfiguration(ResourceAuthProperties resourceAuthProperties) {
        this.authProperties = resourceAuthProperties;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 1.添加用户信息拦截器
        registry.addInterceptor(new UserInfoInterceptor()).order(0);
        // 2.是否需要做登录拦截
        if(!authProperties.getEnable()){
            // 无需登录拦截
            return;
        }
        // 2.添加登录拦截器
        InterceptorRegistration registration = registry.addInterceptor(new LoginAuthInterceptor()).order(1);
        // 2.1.添加拦截器路径
        if(CollUtil.isNotEmpty(authProperties.getIncludeLoginPaths())){
            registration.addPathPatterns(authProperties.getIncludeLoginPaths());
        }
        // 2.2.添加排除路径
        if(CollUtil.isNotEmpty(authProperties.getExcludeLoginPaths())){
            registration.excludePathPatterns(authProperties.getExcludeLoginPaths());
        }
        // 2.3.排除swagger路径
        registration.excludePathPatterns(
                "/v2/**",
                "/v3/**",
                "/swagger-resources/**",
                "/webjars/**",
                "/doc.html"
        );
    }
}

梳理一下,登录信息传递的过程是这样的:

这里有几个关键的点:

  • 用户信息获取的拦截器一定会生效。
  • 登录拦截器不一定生效,取决于authProperties.getEnable()的值,为true则生效,false则不生效
    • 登录拦截生效的前提下,通过authProperties.getIncludeLoginPaths()配置要拦截的路径
    • 登录拦截生效的前提下,通过authProperties.getExcludeLoginPaths()配置要放行的路径

因此,要不要做登录拦截,要拦截哪些路径,完全取决于authProperties的属性:

@Data
@ConfigurationProperties(prefix = "tj.auth.resource")
public class ResourceAuthProperties {
    private Boolean enable = false;
    private List<String> includeLoginPaths;
    private List<String> excludeLoginPaths;
}

可以看出,这里是一个典型的springboot的配置属性,我们完全可以通过配置文件来修改。我们只要把需要放行的接口路径通过tj.auth.resource.excludeLoginPaths配置进去即可。

为什么我们要把登录用户信息获取、登录拦截分别写到两个拦截器呢?

这是因为并不是所有的接口都对登录用户有需要,有些接口可能登录或未登录都能访问。比如我们的查询发放中的优惠券功能。而有些接口则是要求必须登录才能访问。

如果把所有功能放在一个拦截器,也就意味着所有接口要么做拦截要求必须登录并且可以获取用户信息,要么不做拦截,无法获取登录用户信息。这不符合实际需求,所以我们将两个拦截器分离。

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录