必看!Spring Boot 项目新老版本 Controller 低侵入式切换实战秘籍
myzbx 2025-09-09 07:25 2 浏览
在当今快速迭代的软件开发环境中,项目的迁移重构是许多开发团队都绕不开的工作。最近,业务方的一个项目就面临着这样的挑战,而在迁移重构的过程中,如何确保下游系统对接无感知成为了重中之重。具体来说,他们需要实现这样一个需求:读请求访问老版本 Controller 时,能够无缝跳转到新版本 Controller,并返回新版本数据;写请求则需要进行双写操作,即同时写入新老版本,以便在新版本出现问题时能够快速切回旧版本。这一需求的实现不仅关系到项目的顺利迁移,还对系统的稳定性和兼容性有着重要影响。本文将深入探讨这一功能的实现方法,为大家提供切实可行的解决方案。
一、背景介绍
该项目在进行迁移重构时,考虑到大部分业务逻辑雷同,为了降低系统复杂度和维护成本,并没有新开服务,而是在原来的项目中添加新的 Controller。这就意味着所有的操作都要在同一个 JVM 进程项目的前提下进行,如何在不影响现有系统正常运行的情况下,实现新老版本 Controller 的低侵入式切换,成为了摆在开发团队面前的一道难题。
二、技术实现
方案一:自定义注解 + AOP 实现
这是业务部门研发团队最初采用的实现方案,通过自定义注解和 AOP(面向切面编程)的方式,实现了新老版本 Controller 的切换。
1、自定义注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Version {
Class clz();
VersionEnum version() default VersionEnum.NEW;
}
上述注解用于标注需要跳转的 Controller,通过指定clz属性,明确跳转的目标 Controller。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface VersionMethod {
String methodName() default "";
ModeEnum mode() default ModeEnum.READ;
}
该注解则用于实现执行跳转方法的具体逻辑,通过methodName属性指定要调用的方法名,mode属性则区分读操作和写操作。
2、定义切面
@Aspect
@Component
public class VersionSwitchAspect implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Around("@within(version)")
public Object around(ProceedingJoinPoint pjp, Version version){
VersionEnum versionEnum = version.version();
if(versionEnum == VersionEnum.OLD){
return returnOriginResult(pjp);
}
return returnNewResultIfNew(version,pjp);
}
private Object returnNewResultIfNew(Version version,ProceedingJoinPoint pjp){
Signature signature = pjp.getSignature();
if(signature instanceof MethodSignature){
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
VersionMethod versionMethod = method.getAnnotation(VersionMethod.class);
if(versionMethod != null){
ModeEnum mode = versionMethod.mode();
switch (mode){
case WRITE:
return returnWriteResult(version,versionMethod,pjp);
case READ:
return returnReadResult(version,versionMethod,pjp);
default:
return returnOriginResult(pjp);
}
}
}
return returnOriginResult(pjp);
}
/**
* 如果是切换到新版本,要进行双写(即写新又写旧,为了如果新版本有问题,能切回旧版本)
* @param version
* @param pjp
* @return
*/
private Object returnWriteResult(Version version,VersionMethod versionMethod, ProceedingJoinPoint pjp){
try {
writeOldResultAsync(pjp);
return returnNewResult(version, versionMethod, pjp);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private Object returnReadResult(Version version,VersionMethod method,ProceedingJoinPoint pjp){
return returnNewResult(version, method, pjp);
}
private Object returnNewResult(Version version, VersionMethod versionMethod, ProceedingJoinPoint pjp) {
MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
Method newMethod = getMethod(version.clz(), versionMethod,methodSignature);
ReflectionUtils.makeAccessible(newMethod);
return ReflectionUtils.invokeMethod(newMethod, applicationContext.getBean(version.clz()), pjp.getArgs());
}
private void writeOldResultAsync(ProceedingJoinPoint pjp){
CompletableFuture.runAsync(()-> returnOriginResult(pjp));
}
private Method getMethod(Class targetClz,VersionMethod versionMethod, MethodSignature methodSignature){
String methodName = versionMethod.methodName();
if(StringUtils.isEmpty(methodName)){
methodName = methodSignature.getName();
}
return ReflectionUtils.findMethod(targetClz,methodName,methodSignature.getParameterTypes());
}
private Object returnOriginResult(ProceedingJoinPoint pjp){
try {
return pjp.proceed();
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
该切面是实现新老版本 Controller 切换的核心逻辑所在,通过@Around注解,在方法执行前后进行拦截处理,根据注解的配置决定是执行原方法还是跳转至新版本的方法。
3、测试
a、 创建老版本controller,并加上相应切换注解
@RestController
@RequestMapping("old/v1")
@Version(clz = NewEchoController.class)
public class OldEchoController {
@RequestMapping("read")
public String mockRead(String msg){
System.out.println("old read msg:" + msg);
return "old echo msg:" + msg;
}
@PostMapping("write")
@VersionMethod(mode = ModeEnum.WRITE)
public String mockWrite(String msg){
System.out.println("old write msg:" + msg);
return "old write msg:" + msg;
}
}
b、创建新版本controller
@RestController
@RequestMapping("new/v2")
public class NewEchoController {
@RequestMapping("read")
public String mockRead(String msg){
System.out.println("new read msg:" + msg);
return "new echo msg:" + msg;
}
@PostMapping("write")
public String mockWrite(String msg){
System.out.println("new write msg:" + msg);
return "new write msg:" + msg;
}
}
通过postman访问老版本接口
观察控制台
说明已经切换到新版本,同时进行双写
方案二:拦截器 + 新旧 URL 映射实现
在排查业务部门线上环境出现的元空间溢出问题时,发现方案一在并发情况下存在性能瓶颈,于是提出了第二种实现思路,通过拦截器和新旧 URL 映射的方式来实现新老版本 Controller 的切换。
1、定义映射实体
@Data
@AllArgsConstructor
@NoArgsConstructor
public class VersionSwitchDTO implements Serializable {
private String source;
private String target;
private ModeEnum modeEnum;
}
该实体类用于存储新旧 URL 的映射关系以及操作模式(读或写)。
2、绑定映射逻辑
@Slf4j
public class LocalVersionSwitchRepository implements VersionSwitchRepository {
/**
* key: source
*/
private final Map<String, VersionSwitchDTO> versionSwitchMap = new ConcurrentHashMap<>();
@Override
public boolean addVersionSwitch(VersionSwitchDTO versionSwitchDTO) {
try {
versionSwitchMap.put(versionSwitchDTO.getSource(),versionSwitchDTO);
return true;
} catch (Exception e) {
log.error("add version switch error",e);
}
return false;
}
}
通过
LocalVersionSwitchRepository类,将新旧 URL 的映射关系存储在一个ConcurrentHashMap中,方便后续查询和使用。
3、定义转发以及双写拦截器
@Slf4j
@RequiredArgsConstructor
public class VersionSwitchInterceptor implements HandlerInterceptor, ApplicationContextAware {
private final VersionSwitchService versionSwitchService;
private ApplicationContext applicationContext;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String sourceUrl = request.getRequestURI();
if(!StringUtils.hasText(sourceUrl)){
return false;
}
VersionSwitchDTO dto = versionSwitchService.getVersionSwitch(sourceUrl);
if(dto != null){
if(ModeEnum.WRITE == dto.getModeEnum()){
RequestMappingHandlerAdapter requestMappingHandlerAdapter = getRequestMappingHandlerAdapter();
if(requestMappingHandlerAdapter != null){
// 创建新的reponse,解决Cannot forward after response has been committed
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
doOldBizAsync(requestMappingHandlerAdapter,request, responseWrapper, handler);
}
}
request.getRequestDispatcher(dto.getTarget()).forward(request, response);
return false;
}
return true;
}
private void doOldBizAsync(RequestMappingHandlerAdapter requestMappingHandlerAdapter,HttpServletRequest request, HttpServletResponse response, Object handler) {
CompletableFuture.runAsync(()->{
try {
requestMappingHandlerAdapter.handle(request, response, handler);
} catch (Exception e) {
log.error("handle error",e);
}
});
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
private RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() {
try {
return applicationContext.getBean(RequestMappingHandlerAdapter.class);
} catch (BeansException e) {
}
return null;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
拦截器在请求处理前进行拦截,根据 URL 映射关系判断是否需要进行版本切换。如果是写操作,则异步执行旧版本的业务逻辑,并创建新的response对象,以避免Cannot forward after response has been committed的问题。
4、配置拦截器
public class VersionSwitchWebAutoConfiguration implements WebMvcConfigurer {
private final VersionSwitchInterceptor versionSwitchInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(versionSwitchInterceptor).addPathPatterns("/**");
}
}
通过
VersionSwitchWebAutoConfiguration类,将拦截器注册到 Spring MVC 的拦截器链中,使其能够对所有请求进行拦截处理。
5、测试
加载映射数据
@Component
@RequiredArgsConstructor
public class LocalVersionSwitchDataInit implements CommandLineRunner {
private final VersionSwitchService versionSwitchService;
private final static String OLD_URL_V1 = "/old/v1";
private final static String NEW_URL_V2 = "/new/v2";
@Override
public void run(String... args) throws Exception {
VersionSwitchDTO readVersionSwitchDTO = new VersionSwitchDTO();
readVersionSwitchDTO.setSource(OLD_URL_V1 + "/read");
readVersionSwitchDTO.setModeEnum(ModeEnum.READ);
readVersionSwitchDTO.setTarget(NEW_URL_V2 + "/read");
VersionSwitchDTO writeVersionSwitchDTO = new VersionSwitchDTO();
writeVersionSwitchDTO.setSource(OLD_URL_V1 + "/write");
writeVersionSwitchDTO.setModeEnum(ModeEnum.WRITE);
writeVersionSwitchDTO.setTarget(NEW_URL_V2 + "/write");
versionSwitchService.addVersionSwitch(readVersionSwitchDTO);
versionSwitchService.addVersionSwitch(writeVersionSwitchDTO);
}
}
测试 Controller 与方案一中的样例相同,通过浏览器访问老版本接口,观察控制台输出,验证切换和双写功能是否正常。
通过浏览器访问老版本接口
观察控制台
说明已经切换到新版本,同时进行双写
方案二的坑点及解决方法
1、不能直接注入
RequestMappingHandlerAdapter
因为会存在循环依赖问题,因此需要通过延迟加载实现,即示例中通过getBean获取
2、不能重用response
在执行旧版本业务逻辑后,response已经输出提交,此时进行转发会报错。为了解决这个问题,可以创建一个新的
ContentCachingResponseWrapper对象来替代原来的response
总结
本文分享的两种实现方案都是基于业务部门的实际场景定制的,虽然存在一定的局限性,但具有较高的借鉴价值。在实际开发中,实现切面逻辑并不一定非要使用 Spring AOP,拦截器和过滤器在大多数场景下也能实现相同的功能,并且在并发场景下,可能具有更好的性能表现。希望本文的内容能够帮助到正在进行 Spring Boot 项目迁移重构的开发人员,为大家提供一些新的思路和方法。
demo链接
为了方便大家学习和实践,本文提供了完整的 demo 代码,链接如下:
github.com/lyb-geek/sp…
相关推荐
- 前端工程师养成计划 专区_前端工程师技能要求
-
前端工程师必修课本课程从最基本的概念开始讲起,步步深入,带领大家学习HTML、CSS样式基础知识,了解各种常用标签的意义以及基本用法,后半部分讲解CSS样式代码添加,为后面的案例课程打下基础。本课程让...
- 深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别
-
因为Diff算法,计算的就是虚拟DOM的差异,所以先铺垫一点点虚拟DOM,了解一下其结构,再来一层层揭开Diff算法的面纱,深入浅出,助你彻底弄懂Diff算法原理认识虚拟DOM虚拟...
- css 布局简述_css布局的几种方式
-
本篇简单介绍了css布局体系。包括Flowlayout、display、floats、positionFlowlayout(NormalFlow)CSSFormattingContext...
- dart系列之:HTML的专属领域,除了javascript之外,dart也可以
-
简介虽然dart可以同时用作客户端和服务器端,但是基本上dart还是用做flutter开发的基本语言而使用的。除了andorid和ios之外,web就是最常见和通用的平台了,dart也提供了对HTML...
- 原来隐藏一个DOM元素可以有这么多种方式,最后一种你肯定不知道
-
我们在日常编码的时候,隐藏一个dom元素有很多种方式,今天我们来盘点一下隐藏dom元素有哪些方式,最后一种,你绝对没有用过。display:none作为经常用来隐藏元素的css属性,di...
- JavaScript精通到深入_javascript进阶书籍推荐
-
前几天教大家从入门到精通,当然仅靠那一篇文章是不足以带领大家精通JavaScript的,今天给大家带来第二讲!BOM和DOM简介BOM,BrowserObjectModel,浏览器对象模型。BO...
- 巧克力:从一朵花开始的华丽变身_巧克力花束教程视频
-
世界上几乎所有的巧克力产品,都出自四五家大公司大型工厂里的流水线。然而,“手工制作巧克力”正在成为一种潮流,吸引着越来越多的人沉醉其中。这些娇嫩的花朵,是你吃过的每一块巧克力的开始。可可花直接生长在...
- browser-use:AI 驱动的浏览器自动化神器——DOM识别与交互详解
-
browser-use可以识别网页中可交互DOM内容,并能与之进行交互。本文将详细介绍browser-use实现这一核心功能的技术细节。一、可交互元素识别browser-use是通过DOMS...
- HTML DOM Progress 对象_html中的对象
-
Progress对象Progress对象是HTML5新增的。Progress对象表示一个HTML<progress>元素。<progress>元素表示任务...
- HTML DOM Script 对象_html document对象
-
Script对象Script对象表示一个HTML<script>元素。访问Script对象您可以使用getElementById()来访问<scrip...
- 虚拟DOM真的比操作原生DOM快吗?前端大神提供4个参考观点!收藏
-
尤雨溪:https://www.zhihu.com/question/31809713/answer/53544875VirtualDOM真的比操作原生DOM快吗?1.原生DOM操作v...
- 前沿|一种新的植入药物或可将HIV的预防时间持续一年
-
国外已经批准了一种叫做Truvada(中文名:特鲁瓦达)的药物用于HIV感染的暴露前预防。但是由于该药需要每天服用,因此有些人可能无法坚持,从而使得该药的预防效果降低。最近一项新的研究或许可以改变这种...
- 轻量级埋点sdk搭建,便捷更全面_埋点工具
-
引言借助埋点监控sdk,我们可以统计用户的点击,页面pv、uv,脚本错误、dom上报等关键信息等。一:项目初始化1.技术栈Tsrollup打包工具2.搭建项目npminit-ytsc--in...
- China's Humanoid Robotics Race Heats Up as Tesla's Optimus Hits a Wall
-
TMTPOST--Tesla'sonce-hypedhumanoidrobotproject,Optimus,hashitasnag.Partsprocurementhas...
- 单机训练速度提升640倍!独家解读快手商业广告模型GPU训练平台Persia
-
【导读】:近期,快手宣布将在2020年春节前实现3亿DAU,快手商业化营收步伐也随之加速。快手从2018年“商业化元年”开始推行个性化的广告推荐。截止5月底,快手DAU已经突破2亿。随着用户和使用时长...
- 一周热门
- 最近发表
-
- 前端工程师养成计划 专区_前端工程师技能要求
- 深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别
- css 布局简述_css布局的几种方式
- dart系列之:HTML的专属领域,除了javascript之外,dart也可以
- 原来隐藏一个DOM元素可以有这么多种方式,最后一种你肯定不知道
- JavaScript精通到深入_javascript进阶书籍推荐
- 巧克力:从一朵花开始的华丽变身_巧克力花束教程视频
- browser-use:AI 驱动的浏览器自动化神器——DOM识别与交互详解
- HTML DOM Progress 对象_html中的对象
- HTML DOM Script 对象_html document对象
- 标签列表
-
- HTML 简介 (30)
- HTML 响应式设计 (31)
- HTML URL 编码 (32)
- HTML Web 服务器 (31)
- HTML 表单属性 (32)
- HTML 音频 (31)
- HTML5 支持 (33)
- HTML API (36)
- HTML 总结 (32)
- HTML 全局属性 (32)
- HTML 事件 (31)
- HTML 画布 (32)
- HTTP 方法 (30)
- 键盘快捷键 (30)
- CSS 语法 (35)
- CSS 轮廓宽度 (31)
- CSS 谷歌字体 (33)
- CSS 链接 (31)
- CSS 定位 (31)
- CSS 图片库 (32)
- CSS 图像精灵 (31)
- SVG 文本 (32)
- 时钟启动 (33)
- HTML 游戏 (34)
- JS Loop For (32)