Gateway + Filter + ThreadLocal:登录用户 ID 是如何在微服务中传递的
在微服务项目中,很多业务都需要知道:
当前正在操作系统的用户是谁?
例如:
修改密码
退出登录
发布笔记
发表评论
修改个人信息
这些操作都需要拿到当前登录用户的 userId。
如果每个接口都手动获取、再一层层往下传,就会非常麻烦。
因此,可以通过:
Gateway + Header + Filter + ThreadLocal
实现登录用户 ID 的自动传递。
一、我们要解决什么问题?
Gateway 已经可以通过 Token 获取当前登录用户。
例如:
Token
↓
Gateway
↓
userId = 10001
然后 Gateway 会把用户 ID 放到请求头中:
userId: 10001
再把请求转发给下游服务。
但是问题来了:
下游服务每个接口都要这样获取:
@RequestHeader("userId")
如果几十个接口都需要当前用户 ID,就需要写几十遍。
而且 Controller 获取之后,还可能继续传给 Service:
Controller(userId)
↓
Service(userId)
↓
其他业务方法(userId)
代码会越来越繁琐。
这也是引入 Filter + ThreadLocal 的原因:让当前登录用户 ID 在服务内部可以直接获取,而不用层层传参。
二、整体解决思路
整个流程可以分成两段。
第一段是服务之间传递用户 ID:
Gateway
↓
Header
↓
下游服务
第二段是下游服务内部传递用户 ID:
Filter
↓
ThreadLocal
↓
Controller
↓
Service
所以可以简单记住:
Header 负责跨服务传递,ThreadLocal 负责服务内部传递。
三、Gateway 负责传递 userId
用户请求首先携带 Token:
Authorization: Bearer xxx
Gateway 通过 Sa-Token 获取当前登录用户:
userId = 10001
然后在转发请求时加入:
userId: 10001
最终请求进入下游服务。
这一步解决的是:
Gateway 已经知道当前用户是谁,怎么把这个信息告诉后面的微服务?
答案就是通过 HTTP Header 透传。
四、Filter 自动获取 userId
请求进入下游服务以后,会先经过 Filter。
Filter 自动读取:
Header
↓
userId = 10001
这样就不需要每个 Controller 自己去获取请求头了。
文章中创建的 HeaderUserId2ContextFilter 就负责从 Header 中读取 userId。
但是 Filter 拿到 userId 之后,还需要让后面的业务代码能够方便使用。
这时候就需要 ThreadLocal。
五、ThreadLocal 保存当前用户
可以把 ThreadLocal 简单理解成:
当前请求线程专属的临时存储空间。
例如张三的请求由线程 A 处理:
线程 A
└── userId = 10001
李四的请求由线程 B 处理:
线程 B
└── userId = 20002
两个线程之间的数据互不影响。
Filter 获取到 userId 后,就把它存进去:
Header
↓
userId = 10001
↓
Filter
↓
ThreadLocal
↓
userId = 10001
文章中通过 LoginUserContextHolder 对 ThreadLocal 进行了统一封装,用来设置、获取以及删除当前用户 ID。
六、业务代码直接获取 userId
有了 ThreadLocal 后,Controller 就不需要这样:
Controller 获取 userId
↓
再传给 Service
而是:
Controller
↓
Service
↓
直接从 ThreadLocal 获取 userId
例如退出登录业务:
Service
↓
LoginUserContextHolder.getUserId()
↓
10001
↓
执行用户 10001 的退出登录
因此 Controller 可以保持非常干净,只负责调用业务层。
文章最终也是把 /logout 中的 @RequestHeader 删除,然后在 Service 中直接通过 LoginUserContextHolder.getUserId() 获取当前用户。
七、完整业务流程
假设用户 10001 请求退出登录。
第一步,客户端携带 Token:
Authorization: Bearer abc123
第二步,请求进入 Gateway:
客户端
↓
Gateway
Gateway 通过 Token 获取:
userId = 10001
第三步,Gateway 把 userId 放到 Header:
userId: 10001
第四步,请求进入认证服务:
Gateway
↓
认证服务 Filter
Filter 获取:
userId = 10001
然后存入:
ThreadLocal
└── userId = 10001
第五步,请求继续执行:
Controller
↓
Service
Service 需要当前用户时直接:
LoginUserContextHolder.getUserId()
得到:
10001
于是就可以继续完成退出登录、修改密码等业务。
八、为什么请求结束后必须删除?
ThreadLocal 中的数据不能一直保留。
因为服务器线程通常会被重复利用。
例如:
线程 A
↓
第一次处理张三请求
↓
userId = 10001
请求结束以后,线程 A 后面可能继续处理李四的请求。
所以当前请求结束以后,需要把 ThreadLocal 中的数据删除:
请求开始
↓
设置 userId
↓
执行业务
↓
请求结束
↓
remove userId
因此通常会把清理操作放到 finally 中,确保无论业务是否出现异常,都能清除当前线程的数据。文章同样在过滤器执行结束时调用了 remove()。
九、总结
整个设计最终形成这样一条链路:
客户端
↓
Token
↓
Gateway
↓
解析 userId
↓
Header
↓
下游服务 Filter
↓
ThreadLocal
↓
Controller
↓
Service
↓
获取当前登录用户 ID
可以简单总结为:
Gateway 负责识别用户,Header 负责跨服务传递用户 ID,Filter 负责接收用户 ID,ThreadLocal 负责让当前服务中的业务代码方便获取用户 ID。
这样就不需要在每个 Controller 中重复获取 userId,也不需要把用户 ID 一层一层传递到 Service 中。
不过 ThreadLocal 主要适合当前线程内的数据传递,如果业务切换到了异步线程,普通 ThreadLocal 还会遇到上下文无法自动传递的问题,这也是后续需要进一步解决的地方。