Sa-Token 异步与响应式上下文:SaTokenContextMockUtil 与 SaReactorSyncHolder 实战指南
2026/9/14 5:46:14 网站建设 项目流程

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,其内部通过modelBoxThreadLocalThreadLocal<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 上下文:创建SaRequestForMockSaResponseForMockSaStorageForMock三个模拟对象,并通过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()); }

使用要点如下:

  1. 先调用SaTokenContextMockUtil.setMockContext(() -> { ... })Mock 出一个 Web 上下文填充到上下文管理器中;
  2. 然后在 Mock 上下文范围内调用StpUtil.setTokenValueToStorage(tokenValue)指定当前上下文的 Token 值,其效果等同于在 Web 上下文中前端提交了此 Token 值;
  3. 代码块执行完毕后,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 响应应保留状态、响应头和重定向目标:SaResponseForMocksetStatussetHeaderaddHeaderredirect等操作均会保存到内部字段,方便在非 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.instanceexchange的 request、response 创建对应的SaRequestSaResponseSaStorage,再通过SaManager.getSaTokenContext().setContext(...)写入当前线程上下文;
  • clearContext()调用上下文管理器的clearContext()清理 ThreadLocal;
  • setContext(exchange, fun)try-finally中完成写入、执行、清除,保证上下文不会泄漏。

方案三:响应式流中的推荐写法 SaReactorHolder.sync

在响应式流的执行过程中(例如Mono/Flux链的mapflatMap回调中)调用 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 / clearSaReactorSyncHolder.setContext(exchange)+clearContext()
WebFlux Controller 中调用同步 API带回调重载自动清理SaReactorSyncHolder.setContext(exchange, () -> { ... })
响应式流(Mono/Flux)中嵌套调用流式包装器SaReactorHolder.sync(() -> { ... })
仅校验 Token、无需请求对象上下文无关 APIStpUtil.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询