java-不携带Token也能“打“自己的接口:一个绕开网关鉴权、用 URL 直接调度 SpringMVC 方法的轻量级定时任务方案

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-testMockHttpServletRequest/MockHttpServletResponse 必须从默认的 test scope 调整为 compile scope,见 pom 中对应注释。

三、问题拆解

把诉求拆开,其实有三个子问题:

  1. 任务怎么描述? 用 URL 描述(含路径、query、HTTP 方法),这样任务配置与 Postman 手动测试天然同构。
  2. 到点了怎么调? 正常是发 HTTP 请求 → 过网关 → 被拦截(无 Token)。需要一种"不经过网关身份认证"的调用通道。
  3. 怎么保证手动测试和定时执行行为一致? 调用入口必须是同一个 Controller 方法、同一套 Spring 参数绑定与返回序列化逻辑,而不是绕开 MVC 直接去调 Service。

方案选择对比:

方案 是否过网关 参数绑定/序列化 与业务耦合 结论
定时发 HTTP 请求(HttpClient) 过,需 Token 完整 Token 管理复杂、与网关强耦合,放弃
在任务配置里直接指定 Bean#method 不过 无(裸反射) 高(参数要自己拼对象) 与 URL 不一致,放弃
为内部调用单独开"免鉴权"URL 部分绕过 完整 多一套映射、易被外部误用
进程内 URL 直调 Controller(本文) 绕过 完整复用 Spring 参数解析+返回值处理器 采纳

四、核心方案:进程内按 URL 直调 Controller

4.1 核心思路

我们不真的发 HTTP,而是:

  1. RequestMappingHandlerMapping.getHandler(request)URL → HandlerMethod 的匹配。这一步是整件事的"魔法"来源:Spring 在匹配过程中,已经顺手把 {code} 这类路径模板变量解析好并写进了请求属性 HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE@PathVariable 后续就是从这里取值。
  2. 手工 new ServletInvocableHandlerMethod(handlerMethod) 包装目标方法。
  3. 把容器中 RequestMappingHandlerAdapter 自带的参数解析器@PathVariable/@RequestParam/类型转换/@RequestBody)与返回值处理器@ResponseBody 序列化)注入进去。
  4. 调用 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),仍然可能生效,落地时需结合自身框架确认边界,必要时对"内部调度来源"放行。

五、方案边界与局限(必须讲清楚)

这个方案能成立的假设非常明确,把它做进定时任务调度器后,边界如下:

  1. 只能调度"宿主程序自身暴露的 API"(镜像调度)。能调的 Controller 必须在本进程 Spring 容器内注册过,跨服务、跨部署单元的方法没法这样调。所以它解决的是"任务执行器与业务逻辑在同一个服务里"的场景。
  2. 适合任务量不大的场景。它是进程内串行/轻量并发的同步调用,没有线程池隔离、超时熔断、限流等重型治理能力,不适合高并发大批量任务调度。
  3. 绕过了安全边界 ,等于在系统里开了个"内部后门通道"。因此它绝不能开放给外部入口触发,只允许受信任的调度内核使用,否则等于把网关鉴权架空了。
  4. 依赖变化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 轮询位点、失败告警、补跑策略

七、总结与结论

  1. 问题的本质 是"系统内部的定时任务没有人的身份,过不了网关鉴权"。与其为内部调用伪造 Token、与网关强耦合,不如让请求不离开 JVM------通过 URL 在进程内直接调度自身暴露的 Controller 方法,从根本上绕开网关与框架层用户鉴权。
  2. 一条 URL 打通两个场景SpringMvcInvokeUtil 让"定时任务的配置"与"Postman 手动测试"完全同构------配置里写 URL,测试里也粘 URL,二者走同一条 RequestMappingHandlerMapping → ServletInvocableHandlerMethod → invokeAndHandle 链路,参数绑定与 JSON 序列化行为完全一致,杜绝"测试能过、任务执行失败"的路径偏差。
  3. 方案定位清晰 :适合"任务执行器与业务逻辑同进程、任务量不大、以宿主 API 镜像为任务来源"的场景;它不是分布式任务调度框架的替代品,跨服务调度仍需借助注册中心/消息等外部通道,且内部直调通道必须隔离在受信任的调度内核内,不可对外暴露。
  4. 单机到分布式的平滑演进 :借助 DB 乐观锁(UPDATE ... WHERE VERSION = ? 的原子抢占)+ Redis 缓存,可在不引入 XXL-Job/Quartz 等重中间件的前提下,用最低成本获得"多实例不重复执行"的分布式能力,匹配"轻量级"这一贯穿始终的架构目标。

附言:这套思路还有一个意外的副产品------它可以反哺日常开发调试:任何接口在本地起服务后,都能用 invokeByRequest 写成一个"无 Token 冒烟测试",让接口自测摆脱对 Postman + Token 的依赖,感兴趣的读者可以从 SpringMvcInvokeTest#invokeUrl 这个测试类开始实践。

相关推荐
shmily麻瓜小菜鸡1 小时前
JavaScript / TypeScript 易踩坑知识点完全指南 -假值(Falsy Values)完全指南
开发语言·javascript·typescript
Coodor1 小时前
信创系统基于QT5操作NFC读写器
开发语言·qt·智能卡·nfc读写器·yw-607hc
霸道流氓气质1 小时前
Spring AI提示词模板与动态变量替换
java·python·spring
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从鼠标点击到屏幕像素:LibreCAD绘图架构全链路解析之Action与命令系统
linux·开发语言·c++·qt·学习·系统架构
Object_SC1 小时前
java 中的泛型
java
民乐团扒谱机1 小时前
【超详细】PyQt6 实现 AU 风格波形图编辑器:多级缩放与采样点逐级渲染,附全过程代码
开发语言·python·编辑器·pyqt·audition·时域·频谱图
s_w.h1 小时前
【 linux 】线程互斥与同步
java·linux·服务器·开发语言
学长毕业设计1 小时前
基于SpringBoot的校园二手物品交易系统(源码+文档+讲解视频)
java·spring boot·后端
pingglala2 小时前
C++线程安全队列对比
开发语言·c++