Skip to content

版本路由

版本路由由两个类协作完成:ApiVersionConfig 负责装配,APIVersionHandlerMapping 负责在路由注册阶段注入版本前缀。

整体思路

Spring MVC 在启动时会遍历所有 @Controller Bean,把每个 @RequestMapping 注解解析成 RequestMappingInfo,然后调用 registerHandlerMethod(handler, method, mapping) 注册到内部的路由表。本模块在 registerHandlerMethod 这一步切入,对带 @APIVersion 的方法或类的 RequestMappingInfo 做一次「前缀合并」。

装配链路

WebMvcRegistrations 是 Spring Boot 提供的官方扩展点,允许业务方在不放弃 Spring Boot 默认配置的前提下替换 MVC 内部的几个核心组件。ApiVersionConfig 实现 getRequestMappingHandlerMapping() 返回我们的自定义 mapping,Spring Boot 自动注册。

java
@Configuration
public class ApiVersionConfig implements WebMvcRegistrations {

    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new APIVersionHandlerMapping();
    }
}

registerHandlerMethod 重写

APIVersionHandlerMapping 只重写两个方法:

java
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected boolean isHandler(Class<?> beanType) {
        return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
    }

    @Override
    protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
        APIVersion apiVersion = AnnotationUtils.findAnnotation(method, APIVersion.class);
        if (apiVersion == null) {
            apiVersion = AnnotationUtils.findAnnotation(method.getDeclaringClass(), APIVersion.class);
        }

        if (apiVersion != null) {
            String[] versionPaths = apiVersion.value().path();

            RequestMappingInfo.BuilderConfiguration options = new RequestMappingInfo.BuilderConfiguration();
            if (getPatternParser() != null) {
                options.setPatternParser(getPatternParser());
            } else {
                configureLegacyOptions(options);
            }

            RequestMappingInfo prefixInfo = RequestMappingInfo.paths(versionPaths)
                    .options(options)
                    .build();

            mapping = prefixInfo.combine(mapping);
        }

        super.registerHandlerMethod(handler, method, mapping);
    }
}

三个关键点:

  1. 方法优先于类:先查方法上有没有 @APIVersion,没有再退回类级别。这与 Spring 一贯的「方法覆盖类」语义保持一致
  2. PathPatternParser 兼容:Spring Boot 3 默认用 PathPatternParser,但允许通过 spring.mvc.pathmatch.matching-strategy=ant-path-matcher 回退到 AntPathMatcher。两套策略下构造 RequestMappingInfo 的选项不同,所以代码同时支持两条分支
  3. prefixInfo.combine(mapping):Spring 内置的合并语义会把前缀的路径与原 mapping 的路径做笛卡尔拼接,例如 /v1 combine /user/{id} 得到 /v1/user/{id}。同时方法、参数、消费/生产媒体类型等约束也会按 Spring 规则合并

APIVersionEnum 设计

java
public enum APIVersionEnum {

    V1("/v1", "v1"),
    V2("/v2", "这里可以提供备注,例如:权限模块需要统一迁移到v2,v1版本即将废弃");

    private final String[] path;
    private final String displayName;

    APIVersionEnum(String path, String displayName) {
        this.path = new String[]{path};
        this.displayName = displayName;
    }
}

把版本前缀做成枚举而不是任意字符串,至少带来三个好处:

  • IDE 自动补全可以列出所有合法版本,避免拼写错误
  • 版本号集中管理,要废弃 / 新增版本时一处修改全局生效
  • displayName 字段可以承载备注,例如「v1 即将废弃」之类的提示信息,未来可以接入文档生成

pathString[] 而不是单个 String,是为了和 Spring RequestMappingInfo.paths(String...) 的签名直接对齐,方便后续支持「同一版本多前缀」的需求。

为什么不用 PathPrefix 或 spring 的内置机制

Spring 5 引入的 configurePathMatch().addPathPrefix(predicate) 也能给一批 Controller 加前缀,但它的局限:

  • 谓词是 Class<?> -> Boolean,按类粒度匹配,不支持方法粒度的细分
  • 前缀字符串是硬编码,无法通过枚举约束合法值
  • 在 Spring Boot 3 切到 PathPatternParser 之后部分行为变化,与本框架更复杂的语义不完全契合

所以选择更底层的 RequestMappingHandlerMapping 扩展,自己控制每一步。

扩展指南

新增版本号

只需在 APIVersionEnum 加一行:

java
V3("/v3", "v3");

业务代码 @APIVersion(APIVersionEnum.V3) 即可立刻使用,不需要改 mapping、不需要改 config。

按 Header 路由

如果未来要支持「同一 URL 通过 Accept-Version Header 区分版本」,可以在 registerHandlerMethod 内不修改 path,而是用 RequestMappingInfoheaders(...) 约束,把 Accept-Version=v1 拼进 mapping。逻辑同样收敛在这个方法里,业务代码无感。

想让 @APIVersion 同时打类与方法时报错

可以仿照 MutualExclusionProcessor 的思路(详见 设计文档 · 编译期互斥校验),编写一个 APT 处理器,扫描 @APIVersion 元素并在类与方法同时标注时给出编译错误。