你有没有遇到过这种情况:项目里有几十个 Controller,每个头上都顶着一行:
复制代码@RequestMapping("/v1/banner")
@RequestMapping("/v1/order")
@RequestMapping("/v1/user")
// ... 无穷无尽
某天需求来了——要上线 v2 接口,于是你开始一个一个改注解……
改完之后还得祈祷没有漏掉哪个。这种体验,说白了就是在做重复的体力活,而且还容易出错。
有没有更聪明的办法?
仔细想想,其实版本号在项目里出现了两次:
com.lilianhua.blogtest.api.v1.BannerController@RequestMapping("/v1/banner")两个地方说的是同一件事,这本身就是信息冗余,违反了 DRY 原则(Don't Repeat Yourself,不要重复自己——同一份信息只应该在一个地方维护)。
既然包路径已经声明了版本,注解里还要再写一遍,显然是多此一举。
所以,能不能让框架自己从包名里提取版本号,然后自动加到路由前面?
答案是可以的。
Spring MVC 在启动时会扫描所有 Controller,为每个方法注册路由。这个过程发生在一个叫 RequestMappingHandlerMapping 的类里——你可以把它理解成 Spring 内部的"路由登记员",它负责把 @RequestMapping 注解翻译成实际的 URL 路由。具体干活的是里面的 getMappingForMethod 这个方法,每扫描到一个 Controller 方法就调用一次。
我们只需要继承这个类,重写这个方法,在它登记路由的时候偷偷插一脚——把从包名提取出来的前缀,拼到路由前面去。
第一版代码大概长这样:
复制代码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(".", "/");
}
}
逻辑很直白:
com.lilianhua.blogtest.api).v1 把点换成斜杠,得到 /v1这样 BannerController 只需要写:
复制代码@RestController
@RequestMapping("/banner") // 不再需要写 /v1
public class BannerController { ... }
实际路由就变成了 /v1/banner,版本号由框架自动注入。
光写了这个类还不够,Spring 默认不会用它。
你可能会想:直接给 AutoPrefixUrlMapping 加个 @Component 不就行了?不行。Spring Boot 自己也有一个默认的 RequestMappingHandlerMapping,这样一来容器里就有两个,路由会冲突报错。
正确的做法是实现 Spring Boot 提供的 WebMvcRegistrations 接口——这个接口专门用来"替换"Spring MVC 的核心组件,返回我们自己的实现,Spring Boot 就会用它取代默认的那个:
复制代码@Configuration
public class AutoPrefixConfiguration implements WebMvcRegistrations { @Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new AutoPrefixUrlMapping();
}
}
再在 application.properties 里加一行配置:
复制代码blogtest.api-package=com.lilianhua.blogtest.api
到这里,功能已经可以跑起来了。
上面的代码看起来没问题,但跑起来之后你可能会遇到一些诡异的现象:
/error 接口报 404/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 是不是我们自己的:
复制代码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,避免后续逻辑出现空指针完整的最终版本:
复制代码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 防御性检查 |
这个方案的另一个好处是:多版本共存什么都不用额外配置,包结构本身就是版本管理策略。
复制代码api/
├── v1/
│ └── BannerController.java → GET /v1/banner/...
└── v2/
└── BannerController.java → GET /v2/banner/...
v1 和 v2 完全独立,同时生效,互不干扰。旧客户端继续访问 /v1,新客户端走 /v2。

整个方案的扩展性也很强,包结构不仅可以表达版本,还可以表达其他维度:
复制代码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 就是个典型例子——少了这一行,框架能被你搞崩。
钉钉如何设置群权限-钉钉app群主管理员如何管理群权限
值得推荐的看韩剧的app有什么 便捷的看韩剧app大全
抖音创作者中心信用分如何查看-抖音创作者中心账号违规处罚记录如何查询
Visual Studio Code将markdown文件转换成PDF的方法-Visual Studio Code怎样把markdown文件转化为PDF
小红书手机端网页版怎么在线登录-小红书手机在线登录的办法
人气高的韩剧苦尽甘来哪个app可以看 哪个app可以看韩剧苦尽甘来