Sa-Token 异步与响应式上下文:SaTokenContextMockUtil 与 SaReactorSyncHolder 实战指南
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
本篇技术指南聚焦 Sa-Token 权限认证框架在非 Web 上下文(异步线程、定时任务、消息队列)与响应式环境(WebFlux / Spring Cloud)中的上下文处理方案。通过阅读本文,你将掌握SaTokenContextMockUtil模拟上下文、SaReactorSyncHolder同步上下文以及SaReactorHolder响应式包装器的完整使用姿势,并理解其底层基于 ThreadLocal 的上下文存储原理,能够在异步与响应式场景下安全调用StpUtil.isLogin()等 API。
一、问题背景:为什么异步上下文会抛异常
Sa-Token 中有一些方法(例如StpUtil.isLogin()、StpUtil.getLoginId())只可以在同步的 Web 上下文中调用。如果在异步上下文中调用,会抛出如下异常:
cn.dev33.satoken.exception.SaTokenContextException: SaTokenContext 上下文尚未初始化出现该异常的根本原因在于:这些方法需要从前端的HttpServletRequest中读取 Token 参数,而异步上下文通常不是一次"请求",不具备HttpServletRequest的概念,所以无法成功调用。
从源码层面看,Sa-Token 的上下文信息存放在基于 ThreadLocal 的存储箱中。核心实现位于 SaTokenContextForThreadLocalStaff.java,其内部通过modelBoxThreadLocal(ThreadLocal<SaTokenContextModelBox>)持有当前线程的上下文对象。当子线程或非请求线程中没有被写入过上下文时,getModelBox()就会抛出上述"上下文尚未初始化"异常。
哪些场景属于异步上下文
凡是不通过 Web 请求调用触发的线程,在 Sa-Token 中均属于异步上下文,也可以称作"非 Web 上下文",常见场景包括:
- 通过
new Thread(() -> { ... }).start()启动子线程; - 通过
taskExecutor.execute(() -> { ... })线程池启动异步任务; - 通过
@Async注解标注的方法; - 通过
@Scheduled(cron = "")启动的定时任务; - 消息队列中消费消息的函数;
- ……
此时调用StpUtil.isLogin()、StpUtil.getLoginId()等需要 Web 上下文的 API,就会抛出上述异常。如果你需要在非 Web 上下文中调用上述 API,则需要手动 Mock 一个上下文,才可以调用成功。
二、异步上下文解决方案:SaTokenContextMockUtil
SaTokenContextMockUtil是 Sa-Token 提供的 Mock 上下文操作工具类(自 1.42.0 版本起提供),完整源码位于 SaTokenContextMockUtil.java。它提供了三个核心方法:
| 方法签名 | 作用 |
|---|---|
setMockContext() | 写入 Mock 上下文:创建SaRequestForMock、SaResponseForMock、SaStorageForMock三个模拟对象,并通过SaManager.getSaTokenContext().setContext(request, response, storage)填充到上下文管理器中 |
setMockContext(SaFunction fun) | 写入 Mock 上下文,执行一段代码,执行完毕后自动清除上下文(finally 中调用clearContext()) |
<T> T setMockContext(SaRetGenericFunction<T> fun) | 写入 Mock 上下文并执行有返回值的代码,执行完毕后自动清除上下文并返回结果 |
此外还提供了clearContext()方法用于手动清除上下文。
典型用法:new Thread 子线程
// 【异步】new Thread @RequestMapping("isLogin2") public SaResult isLogin2() { System.out.println("是否登录:" + StpUtil.isLogin()); String tokenValue = StpUtil.getTokenValue(); new Thread(() -> { SaTokenContextMockUtil.setMockContext(()->{ StpUtil.setTokenValueToStorage(tokenValue); System.out.println("是否登录:" + StpUtil.isLogin()); }); }).start(); return SaResult.data(StpUtil.getTokenValue()); }使用要点如下:
- 先调用
SaTokenContextMockUtil.setMockContext(() -> { ... })Mock 出一个 Web 上下文填充到上下文管理器中; - 然后在 Mock 上下文范围内调用
StpUtil.setTokenValueToStorage(tokenValue)指定当前上下文的 Token 值,其效果等同于在 Web 上下文中前端提交了此 Token 值; - 代码块执行完毕后,
setMockContext会在finally中自动清除上下文,避免 ThreadLocal 泄漏污染其他线程。
更多异步场景示例
仓库中的完整可运行示例位于 sa-token-demo-async 的 TestController.java,覆盖了四种典型异步场景:
场景一:线程池ThreadPoolTaskExecutor
// 【异步】线程池 ThreadPoolTaskExecutor --- http://localhost:8081/test/isLogin3 @RequestMapping("isLogin3") public SaResult isLogin3(HttpServletRequest request, HttpServletResponse response) { System.out.println("是否登录:" + StpUtil.isLogin()); String tokenValue = StpUtil.getTokenValue(); taskExecutor.execute(() -> { SaTokenContextMockUtil.setMockContext(()->{ StpUtil.setTokenValueToStorage(tokenValue); System.out.println("是否登录:" + StpUtil.isLogin()); }); }); return SaResult.data(StpUtil.getTokenValue()); }场景二:@Async异步方法
// 【异步】@Async --- http://localhost:8081/test/isLogin4 @Async @RequestMapping("isLogin4") public SaResult isLogin4(@CookieValue("satoken") String satoken) { SaTokenContextMockUtil.setMockContext(()->{ StpUtil.setTokenValueToStorage(satoken); System.out.println("是否登录:" + StpUtil.isLogin()); }); return SaResult.ok(); }场景三:@Scheduled定时任务
// 【异步】定时任务 @Scheduled(cron = "0 * * * * ?") // 一分钟执行一次 // @Scheduled(cron = "0/10 * * * * ?") // 十秒执行一次 public void scheduledMethod(){ // 错误写法:直接调用 Sa-Token API 会报错 // System.out.println("定时任务,Mock 范围外:是否登录:" + StpUtil.isLogin()); System.out.println(SaFoxUtil.formatDate(new Date())); // 需要先设置模拟上下文 SaTokenContextMockUtil.setMockContext(() -> { // StpUtil.setTokenValueToStorage("f452571f-bfdb-413d-aba9-e26992cf07be"); // 模拟 Token System.out.println("定时任务,Mock 范围内:是否登录:" + StpUtil.isLogin()); // 模拟登录 // StpUtil.login(10066); // 模拟 登录 // System.out.println("定时任务,Mock 范围内:登录账号:" + StpUtil.getLoginId()); }); }从上述示例可以看出:在定时任务等非 Web 场景中,除了 Mock 上下文,还可以通过StpUtil.login(10066)直接模拟登录态,或通过StpUtil.setTokenValueToStorage(...)模拟指定 Token,两种方式都可行。
单元测试验证
核心类 SaTokenContextMockUtilTest.java 中提供了对应行为的验证用例,可帮助你理解其语义:
- 有返回值的 Mock 上下文回调应返回结果并清理上下文:在
setMockContext回调内SaHolder.getRequest()可以正常获取,回调结束后再次获取则会抛出RuntimeException,证明上下文已被正确清除; - Mock 响应应保留状态、响应头和重定向目标:
SaResponseForMock的setStatus、setHeader、addHeader、redirect等操作均会保存到内部字段,方便在非 Web 场景下断言响应行为。
三、踩坑警示:不要用 InheritableThreadLocal 存储上下文
在 sa-token-demo-async 的 TestController.java 的末尾注释中,官方记录了一个非常典型的踩坑案例,值得所有使用者在异步场景下警惕:
@RequestMapping("isLogin2") public SaResult isLogin2() { System.out.println("是否登录:" + StpUtil.isLogin()); new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("是否登录:" + StpUtil.isLogin()); }).start(); return SaResult.data(null); }现象是:
- 如果子线程内不
Thread.sleep(1000):外面 true,里面也 true; - 如果子线程内
Thread.sleep(1000):则外面 true,里面 false。
原因是:SpringBoot 会在请求结束后清除 request 里的数据。此时子线程内部虽然可以"读取到" request,但 request 已经无值,导致代码既能成功运行、逻辑又是错误的,是一种难以排查的隐形 bug。因此官方明确建议:应该避免使用 InheritableThreadLocal 来存储上下文数据。这正是 Sa-Token 在异步场景下需要显式使用SaTokenContextMockUtil手动 Mock 上下文,而不是依赖上下文自动向子线程传递的原因。
四、响应式上下文:WebFlux / Spring Cloud 环境下的处理
在 WebFlux / Spring Cloud 等响应式环境下调用 Sa-Token 的同步 API 也有可能发生上下文异常:
cn.dev33.satoken.exception.SaTokenContextException: SaTokenContext 上下文尚未初始化 at cn.dev33.satoken.context.SaTokenContextForThreadLocalStaff.getModelBox(SaTokenContextForThreadLocalStaff.java:73) ~[classes/:na] Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: Error has been observed at the following site(s): *__checkpoint ⇢ cn.dev33.satoken.reactor.filter.SaReactorFilter [DefaultWebFilterChain] *__checkpoint ⇢ cn.dev33.satoken.reactor.filter.SaFirewallCheckFilterForReactor [DefaultWebFilterChain] *__checkpoint ⇢ cn.dev33.satoken.reactor.filter.SaTokenCorsFilterForReactor [DefaultWebFilterChain] *__checkpoint ⇢ cn.dev33.satoken.reactor.filter.SaTokenContextFilterForReactor [DefaultWebFilterChain] *__checkpoint ⇢ HTTP GET "/test/isLogin" [ExceptionHandlingWebHandler]响应式场景下出现该异常的根本原因与异步场景类似:Reactor 的响应式流是异步、非阻塞执行的,执行线程可能在多个线程间切换,且ServerWebExchange并不存储在当前执行线程的 ThreadLocal 中。因此需要在调用 Sa-Token 同步 API 之前手动绑定上下文。
方案一:自定义 Filter 中手动 set 上下文
如果是在自定义 Filter 中报的这个错,需要你在调用 Sa-Token 的同步 API 之前手动 set 一下上下文:
// 自定义过滤器 @Component public class MyFilter implements WebFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { try { // 先 set 上下文,再调用 Sa-Token 同步 API,并在 finally 里清除上下文 SaReactorSyncHolder.setContext(exchange); System.out.println(StpUtil.isLogin()); } finally { SaReactorSyncHolder.clearContext(); } return chain.filter(exchange); } }该示例的完整可运行版本位于 sa-token-demo-webflux-springboot3 的 MyFilter.java。
方案二:Controller 中两种写法
在 Controller 里同理,既可以使用 try-finally 手动清除,也可以使用带回调的重载方法自动清除:
@RequestMapping("isLogin2") public SaResult isLogin2(ServerWebExchange exchange) { SaResult res = SaReactorSyncHolder.setContext(exchange, ()->{ System.out.println("是否登录:" + StpUtil.isLogin()); return SaResult.data(StpUtil.getTokenInfo()); }); return SaResult.data(res); }SaReactorSyncHolder的完整源码位于 SaReactorSyncHolder.java(sa-token-spring-boot-reactor-v3v4-common模块中另有同一实现)。其底层机制为:
setContext(ServerWebExchange)通过SaStrategy.instance从exchange的 request、response 创建对应的SaRequest、SaResponse、SaStorage,再通过SaManager.getSaTokenContext().setContext(...)写入当前线程上下文;clearContext()调用上下文管理器的clearContext()清理 ThreadLocal;setContext(exchange, fun)在try-finally中完成写入、执行、清除,保证上下文不会泄漏。
方案三:响应式流中的推荐写法 SaReactorHolder.sync
在响应式流的执行过程中(例如Mono/Flux链的map、flatMap回调中)调用 Sa-Token API 时,推荐使用SaReactorHolder.sync(() -> { ... })进行包裹。该工具类源码位于 SaReactorHolder.java,它基于 Reactor 的deferContextual从 Reactor Context 中取出ServerWebExchange(key 为SA_REACTOR_EXCHANGE_KEY),写入同步上下文后执行回调,并在 finally 中清除:
// API测试:自动设置上下文、lambda 表达式形式 --- http://localhost:8081/test/isLogin3 @RequestMapping("isLogin3") public Mono<SaResult> isLogin3() { return SaReactorHolder.sync(() -> { System.out.println("是否登录:" + StpUtil.isLogin()); userService.isLogin(); return SaResult.data(StpUtil.getTokenInfo()); }); }在更复杂的嵌套调用(切换线程、流式延迟)场景下,规则依然是:在流里调用 Sa-Token API 之前,必须用SaReactorHolder.sync( () -> {} )进行包裹。示例可见 webflux-springboot3 的 TestController.java 中的isLogin5接口:
// API测试:切换线程、复杂嵌套调用 --- http://localhost:8081/test/isLogin5 @RequestMapping("isLogin5") public Mono<SaResult> isLogin5() { System.out.println("线程id-----" + Thread.currentThread().getId()); // 要点:在流里调用 Sa-Token API 之前,必须用 SaReactorHolder.sync( () -> {} ) 进行包裹 return Mono.delay(Duration.ofSeconds(1)) .doOnNext(r-> System.out.println("线程id-----" + Thread.currentThread().getId())) .map(r-> SaReactorHolder.sync( () -> userService.isLogin() )) .map(r-> userService.findUserIdByNamePwd("ZhangSan", "123456")) .map(r-> SaReactorHolder.sync( () -> userService.isLogin() )) .flatMap(isLogin -> { System.out.println("是否登录 " + isLogin); return SaReactorHolder.sync(() -> { System.out.println("是否登录 " + StpUtil.isLogin()); return SaResult.data(StpUtil.getTokenInfo()); }); }); }使用上下文无关 API 的另一种思路
在响应式 Controller 中,如果只是为了校验某个 Token 是否有效、获取其对应的登录账号,还可以使用与上下文无关的 API(无需 set 上下文),例如StpUtil.getLoginIdByToken(satoken),因为该方法只依赖 Token 字符串本身而非当前请求对象:
// API测试:使用上下文无关的API --- http://localhost:8081/test/isLogin6 @RequestMapping("isLogin6") public SaResult isLogin6(@CookieValue("satoken") String satoken) { System.out.println("token 为:" + satoken); System.out.println("登录人:" + StpUtil.getLoginIdByToken(satoken)); return SaResult.ok("登录人:" + StpUtil.getLoginIdByToken(satoken)); }这种写法适用于"前端显式携带 Token、且只需按 Token 查询"的场景,可以避免显式管理上下文的负担。
五、总结与选型建议
| 场景 | 推荐方案 | 核心 API |
|---|---|---|
new Thread子线程、线程池、@Async、@Scheduled等非 Web 上下文 | 手动 Mock 上下文 | SaTokenContextMockUtil.setMockContext(() -> { ... }) |
| WebFlux 自定义 Filter 中调用同步 API | 手动 set / clear | SaReactorSyncHolder.setContext(exchange)+clearContext() |
| WebFlux Controller 中调用同步 API | 带回调重载自动清理 | SaReactorSyncHolder.setContext(exchange, () -> { ... }) |
| 响应式流(Mono/Flux)中嵌套调用 | 流式包装器 | SaReactorHolder.sync(() -> { ... }) |
| 仅校验 Token、无需请求对象 | 上下文无关 API | StpUtil.getLoginIdByToken(token) |
需要特别注意的是:无论是SaTokenContextMockUtil还是SaReactorSyncHolder,其上下文都基于 ThreadLocal 存储。在异步或响应式场景中手动写入上下文后,务必在finally中(或使用带回调的重载方法)清除上下文,防止 ThreadLocal 泄漏导致的数据串扰。同时应避免使用InheritableThreadLocal隐式传递上下文,以免产生"能运行但逻辑错误"的隐性 bug。掌握以上方案,即可在 Sa-Token 覆盖的绝大多数异步与响应式场景中放心使用权限校验 API。
更完整的可运行示例请参考仓库中的 sa-token-demo-async 与 sa-token-demo-webflux-springboot3 两个示例模块。
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考