约定大于配置:基于 Java 包名自动生成 API 版本路由的最佳实践
一、从一个让人抓狂的场景开始
你有没有遇到过这种情况:项目里有几十个 Controller,每个头上都顶着一行:
java
@RequestMapping("/v1/banner")
@RequestMapping("/v1/order")
@RequestMapping("/v1/user")
// ... 无穷无尽
某天需求来了------要上线 v2 接口,于是你开始一个一个改注解......
改完之后还得祈祷没有漏掉哪个。这种体验,说白了就是在做重复的体力活,而且还容易出错。
有没有更聪明的办法?
二、先想清楚问题出在哪
仔细想想,其实版本号在项目里出现了两次:
- 包名 :
com.lin.missyou.api.v1.BannerController - 注解 :
@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(".", "/");
}
}
逻辑很直白:
- 拿到 Controller 的完整包名
- 去掉基准包(
com.lilianhua.blogtest.api) - 剩下的
.v1把点换成斜杠,得到/v1 - 拼到原路由前面
这样 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 就是个典型例子------少了这一行,框架能被你搞崩。