java-不携带Token也能"打"自己的接口:一个绕开网关鉴权、用 URL 直接调度 SpringMVC 方法的轻量级定时任务方案
关键词:Spring MVC、定时调度、网关鉴权、URL 路由、反射调用、ServletInvocableHandlerMethod、DB 乐观锁
文章目录
- [java-不携带Token也能"打"自己的接口:一个绕开网关鉴权、用 URL 直接调度 SpringMVC 方法的轻量级定时任务方案](#java-不携带Token也能"打"自己的接口:一个绕开网关鉴权、用 URL 直接调度 SpringMVC 方法的轻量级定时任务方案)
-
- 一、引言
- 二、软件环境
- 三、问题拆解
- [四、核心方案:进程内按 URL 直调 Controller](#四、核心方案:进程内按 URL 直调 Controller)
-
- [4.1 核心思路](#4.1 核心思路)
- [4.2 代码实现:SpringMvcInvokeUtil(src/main 公共工具)](#4.2 代码实现:SpringMvcInvokeUtil(src/main 公共工具))
- 4.3 验证测试:SpringMvcInvokeTest#invokeUrl
- [4.4 为什么这能"绕过网关与用户鉴权"?](#4.4 为什么这能"绕过网关与用户鉴权"?)
- 五、方案边界与局限(必须讲清楚)
- [六、从"单机直调"走向"分布式调度":DB 乐观锁方案设想](#六、从"单机直调"走向"分布式调度":DB 乐观锁方案设想)
-
- [6.1 为什么选 DB 乐观锁 + Redis?](#6.1 为什么选 DB 乐观锁 + Redis?)
- [6.2 核心设计:基于版本号的乐观锁抢占](#6.2 核心设计:基于版本号的乐观锁抢占)
- [6.3 为什么乐观锁够用?](#6.3 为什么乐观锁够用?)
- [6.4 演进路线](#6.4 演进路线)
- 七、总结与结论
一、引言
公司有一套基于 Spring Boot 的 RESTful 微服务(内部自研微服务框架),部署形态是:
前端 / 外部调用
│
▼
网关(统一身份认证,无有效 Token 一律拦截)
│
▼
业务服务(Controller → Service → DB)
这套链路对"人"很友好------所有外部请求都要过网关校验 Token。但当我们想给这套服务加一个轻量级、可配置的定时任务调度功能时,麻烦就来了:
- 定时任务本质是"到点了让系统自己调用某个 RESTful 接口",这和 Postman 手动调接口是同一件事;
- 但定时任务的执行者是系统内部,并没有"人"的身份,没有 Token;
- 网关一旦校验 Token,内部任务要么去伪造/申请 Token(增加复杂度、Token 过期、与网关强耦合),要么干脆走不通。
于是产生了一个朴素且强烈的诉求:
我希望"定时任务的配置"和"Postman 手动测试"走完全相同的路径------配置一个 URL,任务到点就调用这个 URL 对应的后端方法;测试时我复制这个 URL 去 Postman 或直接跑单测,结果必须一致。
本文要讲的方案,就是围绕这个诉求沉淀下来的:绕过网关与框架层用户鉴权,在进程内部通过 URL 直接定位并调用本服务暴露的 Controller 方法,以此实现一个"接口即任务"的轻量级 RESTful 定时调度。
方案核心起源于一个测试类(SpringMvcInvokeTest#invokeUrl),这个测试类验证了一个"用 URL 直调 Controller"的工具 SpringMvcInvokeUtil,随后它被推演为定时任务执行器的核心能力。
二、软件环境
| 类别 | 说明 |
|---|---|
| 语言 / JDK | Java 17 |
| 框架 | Spring Boot 3.x(3.2/3.3 系),Spring MVC |
| ORM | MyBatis 3.0.4 + MyBatis-Plus(spring-boot3) |
| 微服务/平台 | 内部自研微服务框架,Spring Cloud Alibaba Nacos(配置中心/注册中心) |
| 数据库 | 达梦 DM8 / PostgreSQL(驱动均已在依赖中) |
| 中间件 | Redis、Nacos(用于分布式调度缓存与高可用增强) |
| 鉴权 | 网关统一身份认证 + spring-boot-starter-security + java-jwt(4.4.0) |
| 测试 | spring-test(MockHttpServletRequest/Response,此处须为 compile scope)、JUnit 5 |
特别说明:由于
SpringMvcInvokeUtil位于src/main(不是测试目录),它依赖的spring-test的MockHttpServletRequest/MockHttpServletResponse必须从默认的testscope 调整为compilescope,见 pom 中对应注释。
三、问题拆解
把诉求拆开,其实有三个子问题:
- 任务怎么描述? 用 URL 描述(含路径、query、HTTP 方法),这样任务配置与 Postman 手动测试天然同构。
- 到点了怎么调? 正常是发 HTTP 请求 → 过网关 → 被拦截(无 Token)。需要一种"不经过网关身份认证"的调用通道。
- 怎么保证手动测试和定时执行行为一致? 调用入口必须是同一个 Controller 方法、同一套 Spring 参数绑定与返回序列化逻辑,而不是绕开 MVC 直接去调 Service。
方案选择对比:
| 方案 | 是否过网关 | 参数绑定/序列化 | 与业务耦合 | 结论 |
|---|---|---|---|---|
| 定时发 HTTP 请求(HttpClient) | 过,需 Token | 完整 | 低 | Token 管理复杂、与网关强耦合,放弃 |
在任务配置里直接指定 Bean#method |
不过 | 无(裸反射) | 高(参数要自己拼对象) | 与 URL 不一致,放弃 |
| 为内部调用单独开"免鉴权"URL | 部分绕过 | 完整 | 中 | 多一套映射、易被外部误用 |
| 进程内 URL 直调 Controller(本文) | 绕过 | 完整复用 Spring 参数解析+返回值处理器 | 低 | 采纳 |
四、核心方案:进程内按 URL 直调 Controller
4.1 核心思路
我们不真的发 HTTP,而是:
- 用
RequestMappingHandlerMapping.getHandler(request)做 URL → HandlerMethod 的匹配。这一步是整件事的"魔法"来源:Spring 在匹配过程中,已经顺手把{code}这类路径模板变量解析好并写进了请求属性HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE,@PathVariable后续就是从这里取值。 - 手工
new ServletInvocableHandlerMethod(handlerMethod)包装目标方法。 - 把容器中
RequestMappingHandlerAdapter自带的参数解析器 (@PathVariable/@RequestParam/类型转换/@RequestBody)与返回值处理器 (@ResponseBody序列化)注入进去。 - 调用
invokeAndHandle(...)完成"参数解析 + 方法调用 + 响应写出",最后从MockHttpServletResponse拿到序列化后的 JSON 字符串。
整个过程在进程内完成 ,根本不经过网络栈,所以不会触碰网关的身份认证,自然也不需要 Token------但方法执行、参数绑定、JSON 序列化与真实 HTTP 请求完全一致。
4.2 代码实现:SpringMvcInvokeUtil(src/main 公共工具)
java
/***
* 基于 URL 解析参数,并手工构建 ServletInvocableHandlerMethod 调用 Controller 方法的工具类
*
* 实现思路:
* 1. 通过 RequestMappingHandlerMapping.getHandler(url) 完成 URL 匹配,得到 HandlerMethod,
* 匹配过程中 Spring 已经基于 URL 解析出路径模板变量,并写入请求属性
* HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE(即 {"code": "xxx"})
* 2. 手工 new ServletInvocableHandlerMethod(handlerMethod)
* 3. 注入 Spring 自带的参数解析器(含 @PathVariable / @RequestParam / 类型转换等)
* 和返回值处理器,使 @ResponseBody 结果写入 MockHttpServletResponse
* 4. 调用 invokeAndHandle 完成参数解析 + 方法调用,从 response 获取序列化后的 JSON
*/
@Component
public class SpringMvcInvokeUtil {
private final RequestMappingHandlerMapping handlerMapping;
private final RequestMappingHandlerAdapter handlerAdapter;
@Autowired
public SpringMvcInvokeUtil(
@Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping
, @Qualifier("requestMappingHandlerAdapter") RequestMappingHandlerAdapter handlerAdapter) {
this.handlerMapping = handlerMapping;
this.handlerAdapter = handlerAdapter;
}
/**
* 自动解析完整 URL(含协议/主机/端口/query 串)为 invokeByRequest 所需的路径 + query 参数
*/
public String invokeByRequest(String url, String httpMethod) throws Exception {
URI uri = URI.create(url);
String path = uri.getRawPath();
if (path == null || path.isEmpty()) {
path = "/";
}
Map<String, String[]> queryParams = parseQueryParams(uri.getRawQuery());
return invokeByRequest(path, httpMethod, queryParams);
}
/**
* 将 query 串解析为 Map<String, String[]>,自动处理 URL 编码;无 query 时返回空 Map
*/
private Map<String, String[]> parseQueryParams(String query) {
if (query == null || query.isEmpty()) {
return null;
}
Map<String, List<String>> params = new HashMap<>();
for (String pair : query.split("&")) {
if (pair.isEmpty()) {
continue;
}
int idx = pair.indexOf('=');
String key = URLDecoder.decode(idx >= 0 ? pair.substring(0, idx) : pair, StandardCharsets.UTF_8);
String value = URLDecoder.decode(idx >= 0 ? pair.substring(idx + 1) : "", StandardCharsets.UTF_8);
params.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
}
Map<String, String[]> result = new HashMap<>();
params.forEach((k, v) -> result.put(k, v.toArray(new String[0])));
return result;
}
/**
* 基于 URL 调用 Controller 方法,返回 @ResponseBody 序列化后的 JSON 字符串
*/
public String invokeByRequest(String url, String httpMethod, Map<String, String[]> queryParams) throws Exception {
// 1. 构造模拟 Request
MockHttpServletRequest request = new MockHttpServletRequest();
request.setMethod(httpMethod);
request.setRequestURI(url);
if (queryParams != null) queryParams.forEach(request::addParameter);
MockHttpServletResponse response = new MockHttpServletResponse();
ServletWebRequest webRequest = new ServletWebRequest(request, response);
// 2. 找到目标方法(URL 匹配,路径变量已由 Spring 写入请求属性)
HandlerExecutionChain executionChain = handlerMapping.getHandler(request);
Assert.notNull(executionChain,
"未找到 " + httpMethod + " " + url + " 对应的 Handler,请检查 Controller 中是否存在该映射。"
+ " 若返回 404,也可能是映射路径写错或 Controller 未被 Spring 扫描到。");
HandlerMethod handlerMethod = (HandlerMethod) executionChain.getHandler();
// 3. 核心魔法:包装成 ServletInvocableHandlerMethod
ServletInvocableHandlerMethod invocableMethod = new ServletInvocableHandlerMethod(handlerMethod);
// 4. 注入 Spring 自带的参数解析器和返回值处理器
HandlerMethodArgumentResolverComposite argumentResolvers = new HandlerMethodArgumentResolverComposite();
argumentResolvers.addResolvers(handlerAdapter.getArgumentResolvers());
invocableMethod.setHandlerMethodArgumentResolvers(argumentResolvers);
HandlerMethodReturnValueHandlerComposite returnValueHandlers = new HandlerMethodReturnValueHandlerComposite();
returnValueHandlers.addHandlers(handlerAdapter.getReturnValueHandlers());
invocableMethod.setHandlerMethodReturnValueHandlers(returnValueHandlers);
invocableMethod.setDataBinderFactory(new DefaultDataBinderFactory(null));
// 5. 直接执行!Spring 自动处理 @RequestParam、类型转换等所有形参问题
invocableMethod.invokeAndHandle(webRequest, new ModelAndViewContainer(), null);
// 6. 获取返回值(@ResponseBody 场景)
return response.getContentAsString();
}
}
4.3 验证测试:SpringMvcInvokeTest#invokeUrl
java
@SpringBootTest
@ActiveProfiles("dev") // 脱敏:原文为内部开发环境 profile
public class SpringMvcInvokeTest {
@Autowired
private SpringMvcInvokeUtil springMvcInvokeUtil;
/**
* 基于 URL 调用 DemoDictController#getItemsByCode
*/
@Test
public void invokeUrl() throws Exception {
// 1. 目标 URL:GET /api/demo/dict/query/by-code/{code}
String url = "http://127.0.0.1:8080/api/demo/dict/query/by-code/demo_code?k1=11&k2=22";
// 2. 复用 invokeByRequest 完成 URL 匹配 + 参数解析 + 方法调用,拿到 @ResponseBody 序列化后的 JSON
String json = springMvcInvokeUtil.invokeByRequest(url, "GET");
assertNotNull(json, "调用结果不应为 null");
System.out.println("调用成功,返回 JSON: " + json);
// 3. 校验返回结果:外层为 ApiResult 包装,data 为业务类型列表
ObjectMapper objectMapper = new ObjectMapper();
JsonNode root = objectMapper.readTree(json);
assertTrue(root.has("code"), "ApiResult 应包含 code 字段");
assertEquals(0, root.get("code").asInt(), "code 应为 0(成功)");
assertNotNull(root.get("data"), "ApiResult 应包含 data 字段");
assertTrue(root.get("data").isArray(), "data 应为业务类型数组");
}
}
被测的目标方法(脱敏后的示意接口,供读者对照 URL 与路径变量):
java
@RestController // 本例为 @Controller + @ResponseBody
@RequestMapping(value = "/api/demo/dict")
public class DemoDictController {
/**
* 根据编码获取字典项列表
*/
@GetMapping(value = "/query/by-code/{code}")
@ResponseBody
public ApiResult getItemsByCode(@PathVariable String code) {
List<DemoDictItem> items = demoDictService.getItemsByCode(code);
return ApiResultUtil.success(items);
}
}
4.4 为什么这能"绕过网关与用户鉴权"?
关键点在于调用的物理通道:进程内反射式 MVC 调用,请求从不离开 JVM。
- 网关拦截的是"从网卡进来的 HTTP 包",而我们构造的是
MockHttpServletRequest,天然不经过网关,所以不存在 Token 缺失被拦截的问题; - 框架层(
spring-boot-starter-security)若以 Filter/Interceptor 形式挂在 Servlet 链上,同样不会拦截到进程内调用;但需注意 :如果鉴权逻辑写在 AOP 切面或HandlerInterceptor内部(如注解鉴权扫描 HandlerMethod),仍然可能生效,落地时需结合自身框架确认边界,必要时对"内部调度来源"放行。
五、方案边界与局限(必须讲清楚)
这个方案能成立的假设非常明确,把它做进定时任务调度器后,边界如下:
- 只能调度"宿主程序自身暴露的 API"(镜像调度)。能调的 Controller 必须在本进程 Spring 容器内注册过,跨服务、跨部署单元的方法没法这样调。所以它解决的是"任务执行器与业务逻辑在同一个服务里"的场景。
- 适合任务量不大的场景。它是进程内串行/轻量并发的同步调用,没有线程池隔离、超时熔断、限流等重型治理能力,不适合高并发大批量任务调度。
- 绕过了安全边界 ,等于在系统里开了个"内部后门通道"。因此它绝不能开放给外部入口触发,只允许受信任的调度内核使用,否则等于把网关鉴权架空了。
- 依赖变化 :
spring-test的 Mock 对象进入了 main 依赖,对产物体积与类路径有轻微影响,需要团队接受。
一句话:这套方案是"调度内核与业务同进程、任务量不大、以接口即任务为配置范式"场景下的最优解,而不是分布式任务调度的替代品。
六、从"单机直调"走向"分布式调度":DB 乐观锁方案设想
解决了"怎么在进程内按 URL 执行任务"之后,剩下的是"谁在什么时间执行"------即调度内核本身。由于单节点调度无法支撑多实例部署,设想引入分布式协同,用轻量级中间件实现,而非引入 Quartz/XXL-Job 等重框架:
6.1 为什么选 DB 乐观锁 + Redis?
- DB(达梦/PostgreSQL):所有实例共享一张任务表,天然一致性强,可作为"分布式锁 + 任务元数据"的载体,无需额外部署组件;
- Redis:承载高频的"下次触发时间"判断与缓存,减少对 DB 的轮询压力;
- 目标是在不引入重型调度中间件的前提下,拿到基础的"分布式触发 + 不重复执行"能力。
6.2 核心设计:基于版本号的乐观锁抢占
任务表核心字段(示意):
sql
CREATE TABLE SCHEDULE_TASK (
ID BIGINT PRIMARY KEY, -- 任务ID
TASK_NAME VARCHAR(128) NOT NULL, -- 任务名
HTTP_METHOD VARCHAR(8) NOT NULL, -- GET/POST/PUT/DELETE,与 Postman 一致
REQUEST_URL VARCHAR(512) NOT NULL, -- 与手动测试完全一致的 URL
CRON_EXPR VARCHAR(64), -- 或使用 NEXT_FIRE_TIME 方式
NEXT_FIRE_TIME TIMESTAMP, -- 下次触发时间
LAST_FIRE_TIME TIMESTAMP, -- 上次触发时间
STATUS INT NOT NULL, -- 启用/停用
VERSION INT NOT NULL DEFAULT 0, -- 乐观锁版本号
OWNER_INSTANCE VARCHAR(64), -- 抢到本次执行的实例标识
...
);
调度循环(每个实例周期性执行):
while (true) {
1. SELECT * FROM SCHEDULE_TASK
WHERE STATUS = 启用 AND NEXT_FIRE_TIME <= NOW
LIMIT N; -- 捞取到点任务(配合 Redis 缓存降低频次)
2. 对每个任务执行"抢占"(乐观锁 + 条件更新,一次 SQL 原子完成):
UPDATE SCHEDULE_TASK
SET OWNER_INSTANCE = :本实例,
VERSION = VERSION + 1,
NEXT_FIRE_TIME = 下一次触发时间
WHERE ID = :taskId
AND VERSION = :读取到的版本号
AND NEXT_FIRE_TIME <= NOW; -- 关键:防止两个实例并发抢到
3. 若 UPDATE 影响行数 = 1:抢占成功 → 调用 SpringMvcInvokeUtil.invokeByRequest(REQUEST_URL, HTTP_METHOD)
(调用失败的 URL 与 Postman 调试时的表现完全一致)
更新 LAST_FIRE_TIME / 失败次数,进入下一轮;
4. 若影响行数 = 0:说明已被其他实例抢先或任务已过期,跳过;
(由 DB 行锁 + 版本号保证全集群仅一个实例执行成功)
}
6.3 为什么乐观锁够用?
- 多实例同时触发同一任务时,
UPDATE ... WHERE VERSION = ?只有一个实例能影响 1 行,天然形成"抢锁"语义,杜绝重复执行; - DB 是强一致组件,不会像纯 Redis
setnx那样有锁过期、误删等边界问题,失败转移也更简单(实例挂了,锁自然随行记录释放,靠NEXT_FIRE_TIME兜底重触发); - 相比引入 XXL-Job/Quartz 集群,零新增中间件、零运维成本,契合"轻量级"的定位。
6.4 演进路线
| 阶段 | 能力 | 说明 |
|---|---|---|
| 阶段一(已具备) | 进程内 URL 直调 | SpringMvcInvokeUtil 统一了"手动测试"与"调度执行"的入口 |
| 阶段二 | 可配置调度 | URL + Cron 入库,单机定时触发,任务配置 = Postman 的 URL |
| 阶段三 | 分布式去重 | DB 乐观锁抢占,多实例不重复执行 |
| 阶段四(可选) | 高可用增强 | Redis 缓存 NEXT_FIRE_TIME 轮询位点、失败告警、补跑策略 |
七、总结与结论
- 问题的本质 是"系统内部的定时任务没有人的身份,过不了网关鉴权"。与其为内部调用伪造 Token、与网关强耦合,不如让请求不离开 JVM------通过 URL 在进程内直接调度自身暴露的 Controller 方法,从根本上绕开网关与框架层用户鉴权。
- 一条 URL 打通两个场景 :
SpringMvcInvokeUtil让"定时任务的配置"与"Postman 手动测试"完全同构------配置里写 URL,测试里也粘 URL,二者走同一条RequestMappingHandlerMapping → ServletInvocableHandlerMethod → invokeAndHandle链路,参数绑定与 JSON 序列化行为完全一致,杜绝"测试能过、任务执行失败"的路径偏差。 - 方案定位清晰 :适合"任务执行器与业务逻辑同进程、任务量不大、以宿主 API 镜像为任务来源"的场景;它不是分布式任务调度框架的替代品,跨服务调度仍需借助注册中心/消息等外部通道,且内部直调通道必须隔离在受信任的调度内核内,不可对外暴露。
- 单机到分布式的平滑演进 :借助 DB 乐观锁(
UPDATE ... WHERE VERSION = ?的原子抢占)+ Redis 缓存,可在不引入 XXL-Job/Quartz 等重中间件的前提下,用最低成本获得"多实例不重复执行"的分布式能力,匹配"轻量级"这一贯穿始终的架构目标。
附言:这套思路还有一个意外的副产品------它可以反哺日常开发调试:任何接口在本地起服务后,都能用
invokeByRequest写成一个"无 Token 冒烟测试",让接口自测摆脱对 Postman + Token 的依赖,感兴趣的读者可以从SpringMvcInvokeTest#invokeUrl这个测试类开始实践。