约定大于配置:基于 Java 包名自动生成 API 版本路由的最佳实践

约定大于配置:基于 Java 包名自动生成 API 版本路由的最佳实践

一、从一个让人抓狂的场景开始

你有没有遇到过这种情况:项目里有几十个 Controller,每个头上都顶着一行:

java 复制代码
@RequestMapping("/v1/banner")
@RequestMapping("/v1/order")
@RequestMapping("/v1/user")
// ... 无穷无尽

某天需求来了------要上线 v2 接口,于是你开始一个一个改注解......

改完之后还得祈祷没有漏掉哪个。这种体验,说白了就是在做重复的体力活,而且还容易出错。

有没有更聪明的办法?


二、先想清楚问题出在哪

仔细想想,其实版本号在项目里出现了两次

  1. 包名com.lin.missyou.api.v1.BannerController
  2. 注解@RequestMapping("/v1/banner")

两个地方说的是同一件事,这本身就是信息冗余,违反了 DRY 原则(Don't Repeat Yourself,不要重复自己------同一份信息只应该在一个地方维护)。

既然包路径已经声明了版本,注解里还要再写一遍,显然是多此一举。

所以,能不能让框架自己从包名里提取版本号,然后自动加到路由前面?

答案是可以的。


三、找到正确的扩展点

Spring MVC 在启动时会扫描所有 Controller,为每个方法注册路由。这个过程发生在一个叫 RequestMappingHandlerMapping 的类里------你可以把它理解成 Spring 内部的"路由登记员",它负责把 @RequestMapping 注解翻译成实际的 URL 路由。具体干活的是里面的 getMappingForMethod 这个方法,每扫描到一个 Controller 方法就调用一次。

我们只需要继承这个类,重写这个方法,在它登记路由的时候偷偷插一脚------把从包名提取出来的前缀,拼到路由前面去。

第一版代码大概长这样:

java 复制代码
public class AutoPrefixUrlMapping extends RequestMappingHandlerMapping {

    @Value("${blogtest.api-package}")
    private String apiPackagePath;

    @Override
    protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
        RequestMappingInfo mappingInfo = super.getMappingForMethod(method, handlerType);
        if (mappingInfo != null) {
            String prefix = this.getPrefix(handlerType);
            return RequestMappingInfo.paths(prefix).build().combine(mappingInfo);
        }
        return mappingInfo;
    }

    private String getPrefix(Class<?> handlerType) {
        // handlerType 就是当前 Controller 的 Class 对象,通过它可以拿到包名
        String packageName = handlerType.getPackage().getName();
        // com.lilianhua.blogtest.api.v1 → 去掉基准包 → .v1 → /v1
        String dotPath = packageName.replaceAll(this.apiPackagePath, "");
        return dotPath.replace(".", "/");
    }
}

逻辑很直白:

  1. 拿到 Controller 的完整包名
  2. 去掉基准包(com.lilianhua.blogtest.api
  3. 剩下的 .v1 把点换成斜杠,得到 /v1
  4. 拼到原路由前面

这样 BannerController 只需要写:

java 复制代码
@RestController
@RequestMapping("/banner")  // 不再需要写 /v1
public class BannerController { ... }

实际路由就变成了 /v1/banner,版本号由框架自动注入。


四、把自定义 HandlerMapping 注入 Spring

光写了这个类还不够,Spring 默认不会用它。

你可能会想:直接给 AutoPrefixUrlMapping 加个 @Component 不就行了?不行。Spring Boot 自己也有一个默认的 RequestMappingHandlerMapping,这样一来容器里就有两个,路由会冲突报错。

正确的做法是实现 Spring Boot 提供的 WebMvcRegistrations 接口------这个接口专门用来"替换"Spring MVC 的核心组件,返回我们自己的实现,Spring Boot 就会用它取代默认的那个:

java 复制代码
@Configuration
public class AutoPrefixConfiguration implements WebMvcRegistrations {

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

再在 application.properties 里加一行配置:

properties 复制代码
blogtest.api-package=com.lilianhua.blogtest.api

到这里,功能已经可以跑起来了。


五、发现一个隐藏的大坑

上面的代码看起来没问题,但跑起来之后你可能会遇到一些诡异的现象:

  • 访问 /error 接口报 404
  • Swagger 文档打不开
  • Spring Actuator 的 /actuator/health 等接口都挂了

为什么?

因为 getMappingForMethod 不只会处理你自己写的 Controller,它会处理所有 Controller------包括 Spring Boot 内置的 BasicErrorController,以及 Swagger、Actuator 等第三方组件注册的 Controller。

这些内置 Controller 的包名和你的完全不同,比如 org.springframework.boot.autoconfigure.web.servlet.error。当我们的代码执行 packageName.replaceAll(this.apiPackagePath, "") 时,因为不匹配基准包,replaceAll 会原样返回整个包名,然后把所有的 . 都替换成 /,变成一段莫名其妙的路径前缀。

这就是"一刀切处理包名"的问题:对所有 Controller 不加区分地提取前缀,会把框架级别的内置路由搞乱。


六、加一道防线

解决方案很简单,在提取前缀之前,先判断一下这个 Controller 是不是我们自己的:

java 复制代码
private String getPrefix(Class<?> handlerType) {
    String packageName = handlerType.getPackage().getName();

    if (!packageName.startsWith(this.apiPackagePath)) {
        return "";
    }

    String dotPath = packageName.replace(this.apiPackagePath, "");
    return dotPath.replace(".", "/");
}

这一行 startsWith 判断就是一道防火墙,把非业务 Controller 挡在外面。

两处细节也值得注意:

  • replace 替代 replaceAll------这两个方法看起来很像,但 replaceAll 的第一个参数是正则表达式 ,而 . 在正则里表示"匹配任意字符",不是字面的点号,会导致误匹配;replace 才是普通的字符串替换,用在这里更安全
  • 返回 "" 而不是 null,避免后续逻辑出现空指针

完整的最终版本:

java 复制代码
public class AutoPrefixUrlMapping extends RequestMappingHandlerMapping {

    @Value("${blogtest.api-package}")
    private String apiPackagePath;

    @Override
    protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
        RequestMappingInfo mappingInfo = super.getMappingForMethod(method, handlerType);
        if (mappingInfo != null) {
            String prefix = this.getPrefix(handlerType);
            if (prefix != null && !prefix.trim().isEmpty()) {
                return RequestMappingInfo.paths(prefix).build().combine(mappingInfo);
            }
        }
        return mappingInfo;
    }

    private String getPrefix(Class<?> handlerType) {
        String packageName = handlerType.getPackage().getName();

        if (!packageName.startsWith(this.apiPackagePath)) {
            return "";
        }

        String dotPath = packageName.replace(this.apiPackagePath, "");
        String urlPath = dotPath.replace(".", "/");
        return urlPath;
    }
}

七、效果对比

项目 传统写法 AutoPrefix 方案
Controller 注解 @RequestMapping("/v1/banner") @RequestMapping("/banner")
版本信息维护 分散在每个 Controller 注解里 集中在包结构中
版本升级 逐一修改注解,容易漏改 新建 v2 包即可,原有代码不动
信息冗余 包名 + 注解双重声明 包名即唯一事实来源
内置路由安全 不涉及 需要 startsWith 防御性检查

八、版本共存天然支持

这个方案的另一个好处是:多版本共存什么都不用额外配置,包结构本身就是版本管理策略。

bash 复制代码
api/
├── v1/
│   └── BannerController.java   → GET /v1/banner/...
└── v2/
    └── BannerController.java   → GET /v2/banner/...

v1 和 v2 完全独立,同时生效,互不干扰。旧客户端继续访问 /v1,新客户端走 /v2


九、整体架构回顾

整个方案的扩展性也很强,包结构不仅可以表达版本,还可以表达其他维度:

bash 复制代码
api/
├── v1/        → /v1/...
├── v2/        → /v2/...
├── admin/     → /admin/...
└── internal/  → /internal/...

十、总结

整个实现不到 30 行代码,但带来的收益是实实在在的:

  • 消除重复 :版本号只在包结构中出现一次,注解里再也不用写 /v1

  • 升级成本极低 :传统写法从 v1 升级到 v2,每个 Controller 的注解都得改一遍,漏一个就出 bug;这个方案只需要把文件移动到 v2 包下,路由自动跟着变,原来的 v1 接口也继续正常运行

  • 结构即文档:打开包目录就能看出 API 版本全貌,不需要翻每个 Controller

  • 低侵入 :通过扩展 RequestMappingHandlerMapping 实现,没有改动任何框架源码,随时可以去掉

这个思路其实在前端领域早就有了------Next.js 的文件路由就是同样的理念:pages/about.tsx 自动对应 /about 路由,文件放在哪里,路由就是什么,完全不需要额外配置。只不过 Next.js 是用文件路径,我们这里是用 Java 的包路径,本质是一回事。


当然,这个方案也不是没有代价。它最大的隐患是隐式性 ------路由不再写在 Controller 上,新来的同学打开代码,只看到 @RequestMapping("/banner"),完全猜不到最终路由是 /v1/banner,还得知道这个项目有自动前缀机制才能反应过来。如果团队里没有文档说明,或者新人不了解这套约定,排查路由问题时可能会很懵。

所以用这个方案的前提是:团队要达成共识,并且在项目里留下清晰的说明。 约定大于配置的好处是少写代码,代价是增加了隐式的心智负担,这个取舍得想清楚。


还有一点实践经验:凡是 hack 框架核心机制的地方,一定要先想清楚影响范围,加好防御性检查。这次的 startsWith 就是个典型例子------少了这一行,框架能被你搞崩。

相关推荐
Flittly2 小时前
【AgentScope Java新手村系列】(11)中断与恢复
java·spring boot·spring
银卡2 小时前
RAG Embedding 模型选型
后端
用户559822481222 小时前
Claude Code + DeepSeek V4 Pro 说"不行"时,别信
后端
众少成多积小致巨2 小时前
JNI (Java Native Interface) 技术手册中文参考指南
android·java·c++
leeyi2 小时前
Manus Agent:一个全能 AI,和一支研究团队
后端·aigc·agent
东坡白菜3 小时前
破局全栈:前端开发的Java入门实战记录—JPA(2)
java·后端
代码丰3 小时前
RAG 系统如何实现全链路追踪:AOP 埋点与流式调用追踪实践
后端
小码编匠3 小时前
C# 工控上位机必备:数据转换工具类与十个核心模块
后端·c#·.net
神奇小汤圆3 小时前
一文读懂 OpenAI Codex 源码的原理、架构与未来
后端