weblog/doc/7、Gateway 网关搭建与接口鉴权/7.9 过滤器 + ThreadLocal 实现上下文传递:方便的获取登录用户 ID.md
2025-02-17 11:57:55 +08:00

9.4 KiB
Raw Blame History

上小节 中,我们完成了退出登录接口的开发工作。但是,获取当前请求对应的用户 ID是通过在 Controller 层的方法上添加 @RequestHeader 注解来完成的,难道每次都需要手动添加它,那也太不方便了!

本小节中,我们就将使用 过滤器 + ThreadLocal ,更加方便地获取当前用户 ID 。

添加全局变量

首先,编辑 xiaoha-common 公共模块,在 /constant 包下创建一个 GlobalConstants 全局常量类,用于放置一些各个模块中常用的变量,如请求头用户 ID 键 userId 网关服务中有用到,等会认证服务中也需要用到,干脆提取到公共模块中,省得到处都定义一份。代码如下:

package com.quanxiaoha.framework.common.constant;

public interface GlobalConstants {

    /**
     * 用户 ID
     */
    String USER_ID = "userId";
}

Tip

: 该变量创建完毕后,就可以将网关中的变量重构一下,统一用上面这个了。

创建过滤器

接着,编辑 xiaohashu-auth 认证服务,创建 /filter 包,并创建过滤器类 HeaderUserId2ContextFilter , 代码如下:

package com.quanxiaoha.xiaohashu.auth.filter;

import com.quanxiaoha.framework.common.constant.GlobalConstants;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * @author: 犬小哈
 * @date: 2024/4/15 14:01
 * @version: v1.0.0
 * @description: 提取请求头中的用户 ID 保存到上下文中,以方便后续使用
 **/
@Component
@Slf4j
public class HeaderUserId2ContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
		
		// 从请求头中获取用户 ID
        String userId = request.getHeader(GlobalConstants.USER_ID);

        log.info("## HeaderUserId2ContextFilter, 用户 ID: {}", userId);
		
		// 将请求和响应传递给过滤链中的下一个过滤器。
        chain.doFilter(request, response);
    }
}

解释一波:

  • HeaderUserId2ContextFilter 继承自 OncePerRequestFilter,确保每个请求只会执行一次过滤操;
  • request.getHeader(GlobalConstants.USER_ID); : 从请求头中获取用户 ID
  • 打印一行日志,看看等会测试的时候,能否拿的到请求头中的用户 ID;
  • chain.doFilter(request, response); : 将请求和响应传递给过滤链中的下一个过滤器。如果没有下一个过滤器,则请求会到达控制器。

测试一波

过滤器添加完毕后,重启认证服务。通过 Apipost 工具请求登出接口,看看过滤器是否工作正常:

如上图所示,成功打印了请求头中的用户 ID说明过滤器工作正常。

封装上下文工具类

然后,在 /filter 包下创建 LoginUserContextHolder 类,用于设置与获取上下文数据,代码如下:

package com.quanxiaoha.xiaohashu.auth.filter;

import com.quanxiaoha.framework.common.constant.GlobalConstants;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * @author: 犬小哈
 * @date: 2024/4/9 18:19
 * @version: v1.0.0
 * @description: 登录用户上下文
 **/
public class LoginUserContextHolder {

    // 初始化一个 ThreadLocal 变量
    private static final ThreadLocal<Map<String, Object>> LOGIN_USER_CONTEXT_THREAD_LOCAL
            = ThreadLocal.withInitial(HashMap::new);


    /**
     * 设置用户 ID
     *
     * @param value
     */
    public static void setUserId(Object value) {
        LOGIN_USER_CONTEXT_THREAD_LOCAL.get().put(GlobalConstants.USER_ID, value);
    }

    /**
     * 获取用户 ID
     *
     * @return
     */
    public static Long getUserId() {
        Object value = LOGIN_USER_CONTEXT_THREAD_LOCAL.get().get(GlobalConstants.USER_ID);
        if (Objects.isNull(value)) {
            return null;
        }
        return Long.valueOf(value.toString());
    }

    /**
     * 删除 ThreadLocal 
     */
    public static void remove() {
        LOGIN_USER_CONTEXT_THREAD_LOCAL.remove();
    }

}

解释一下上述核心的部分代码:

  • ThreadLocal.withInitial(HashMap::new):使用 ThreadLocalwithInitial 方法,传入一个 HashMap 的构造方法引用,初始化每个线程的 ThreadLocal 变量时都会创建一个新的 HashMap 实例。之所以用 HashMap 数据类型,是为了方便后续扩展,如果还有新的数据,只接往里面添加即可。

    什么是 ThreadLocal ?

    ThreadLocal 是 Java 中用于创建线程局部变量的工具,每个线程都有自己的独立变量副本,不会相互干扰。

  • remove() : 移除当前线程的 ThreadLocal 变量。

过滤器中设置用户 ID 到上下文

工具类编写完毕后,继续完善 HeaderUserId2ContextFilter 过滤器的逻辑,代码如下:

package com.quanxiaoha.xiaohashu.auth.filter;

import com.quanxiaoha.framework.common.constant.GlobalConstants;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * @author: 犬小哈
 * @date: 2024/4/15 14:01
 * @version: v1.0.0
 * @description: 提取请求头中的用户 ID 保存到上下文中,以方便后续使用
 **/
@Component
@Slf4j
public class HeaderUserId2ContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        // 从请求头中获取用户 ID
        String userId = request.getHeader(GlobalConstants.USER_ID);

        log.info("## HeaderUserId2ContextFilter, 用户 ID: {}", userId);

        // 判断请求头中是否存在用户 ID
        if (StringUtils.isBlank(userId)) {
            // 若为空,则直接放行
            chain.doFilter(request, response);
            return;
        }

        // 如果 header 中存在 userId则设置到 ThreadLocal 中
        log.info("===== 设置 userId 到 ThreadLocal 中, 用户 ID: {}", userId);
        LoginUserContextHolder.setUserId(userId);

        try {
            chain.doFilter(request, response);
        } finally {
            // 一定要删除 ThreadLocal ,防止内存泄露
            LoginUserContextHolder.remove();
            log.info("===== 删除 ThreadLocal userId: {}", userId);
        }
    }
}

解释一波:

  • 拿到请求头中的用户 ID 后,先判空,若为空,则直接放行;
  • 如果 Header 头中存在 userId,则设置到 ThreadLocal 中;
  • 最后,执行完请求后,通过 remove(); 方法删除 ThreadLocal ,防止内存泄露;

重构代码

删除掉 /logout 登出接口中的 @RequestHeader 注解,如下图所示:

代码如下:

    // 省略...
    
    @PostMapping("/logout")
    @ApiOperationLog(description = "账号登出")
    public Response<?> logout() {
        return userService.logout();
    }
    
    // 省略...

相对应的,之前servicelogout() 方法的入参也需要删除掉:

public interface UserService {

	// 省略...

    /**
     * 退出登录
     * @return
     */
    Response<?> logout();

}

在其实现方法中,就可以直接通过封装好的工具方法来获取用户 ID 啦:

    // 省略...
	
	/**
     * 退出登录
     *
     * @return
     */
    @Override
    public Response<?> logout() {
        Long userId = LoginUserContextHolder.getUserId();

        log.info("==> 用户退出登录, userId: {}", userId);

        // 退出登录 (指定用户 ID)
        StpUtil.logout(userId);

        return Response.success();
    }
    
    // 省略...

更方便了,有木有~ 重启项目,自测一波,看看 logout() 方法中添加的日志,是否能够正确打印当前登录用户的 ID, 如下图所示,一切正常,并且在请求执行完成后,删除了 ThreadLocal 变量:

思考

最后,我们来想一下,使用 ThreadLocal 来传递上下文,就真的能一劳永逸吗?它有什么样的问题呢?小伙伴们可以先思考一下,下小节中来着重说一说~

本小节源码下载

https://t.zsxq.com/CrriB