必看!Spring Boot 项目新老版本 Controller 低侵入式切换实战秘籍

news/2025/4/2 19:07:14/文章来源:https://www.cnblogs.com/linyb-geek/p/18470786

在当今快速迭代的软件开发环境中,项目的迁移重构是许多开发团队都绕不开的工作。最近,业务方的一个项目就面临着这样的挑战,而在迁移重构的过程中,如何确保下游系统对接无感知成为了重中之重。具体来说,他们需要实现这样一个需求:读请求访问老版本 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);}}@Overridepublic 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<>();@Overridepublic 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;@Overridepublic 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 committedContentCachingResponseWrapper 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);}});}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {}@Overridepublic 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;}@Overridepublic 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;@Overridepublic 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";@Overridepublic 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 代码,链接如下:

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-multi-controller-switch

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/909365.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

第6章 异步原理

第6章 异步原理 6.1 生成代码的结构 异步模式的实现原理是基于 状态机 的,它负责追踪 async 方法当前的执行进度。从逻辑上讲,可以分为以下 4 种状态:未启动 正在执行 暂停 完成(成功或 faulted)Eureka 这里的“暂停”,指程序运行至 await 处,任务未完成时,当前方法在此…

用Arduino开发STM32

参考文档: https://blog.csdn.net/weixin_42880082/article/details/121619101 qq:505645074

【重点】文件摆渡系统如何简化跨平台跨网络的文件传输?

在当今数字化时代,企业数据的流动性和安全性变得愈发重要。随着业务规模的扩大和全球化协作的深入,跨平台、跨网络的文件传输需求日益增长。然而,传统的文件传输方式往往面临效率低下、安全性不足、兼容性差等问题。文件摆渡系统作为一种专门设计用于简化跨平台、跨网络文件…

想让你多爱自己一些的开源计时器

我用过 Pomotroid、Reminders MenuBar、Timer 等众多优秀、开源的番茄时钟和计时工具,它们帮我更高效地管理时间,让我更专注地投入工作。 但有一个问题始终困扰着我:每当计时结束时,我总是习惯性地忽略休息提醒,继续沉浸在手头的事情中,直到第二天身体不适才追悔莫及。 最…

VMware ESXi 8.0U3d macOS Unlocker OEM BIOS 标准版和厂商定制版,已适配主流品牌服务器

VMware ESXi 8.0U3d macOS Unlocker & OEM BIOS 标准版和厂商定制版,已适配主流品牌服务器VMware ESXi 8.0U3d macOS Unlocker & OEM BIOS 标准版和厂商定制版 ESXi 8.0U3d 标准版,Dell (戴尔)、HPE (慧与)、Lenovo (联想)、Inspur (浪潮)、Cisco (思科)、Hitachi (日…

AMS1117-LDO(线性稳压器)稳压电路

AMS1117-LDO(线性稳压器)稳压电路 原理图引脚说明编号 名称 功能描述1 GND GND2 VOUT 输出3 VIN 输入4 VOUT 输出拓展C8和C7为输出滤波电容,用于抑制自激振荡。如果这两个电容不接,线性稳压器的输出通常会是一个振荡波形。 电容C5和C6是输入电容。对于交流电压整流输入,它…

Avalonia 界面效果 滚动的渐变矩形边框

本文将和大家介绍一个 Avalonia 界面效果,制作一个滚动的渐变矩形边框本文代码基于 Avalonia 11.2.x 版本实现,预期在其他 Avalonia 版本也能正常使用 本文效果由 晓嗔戈 提供,我只是记录此实现方法的工具人 界面效果如下图所示,录制的gif中颜色存在一些偏差,动画有些卡顿…

Avalonia 界面效果 三个圆实现模糊界面动效背景

本文将和大家介绍一个 Avalonia 动效界面效果,由三个圆带模糊效果实现的模糊界面动效背景,适合用在各种 AIGC 主题的应用里面本文代码基于 Avalonia 11.2.x 版本实现,预期在其他 Avalonia 版本也能正常使用 本文效果由 晓嗔戈 提供,我只是记录此实现方法的工具人 界面效果如…

儿子的画

昨天儿子在幼儿园学习了自制小册子,并在上面画画。 晚上睡觉前,心血来潮想要再展示一下他在学校是怎么弄的,于是又一顿操作起来,动作还算麻利,只是完成之后都已过十点了,非要我们帮他配上文字,我一开始不明就理,以为写个标题就好了..., 但最后终于搞懂他是要我帮忙下一…

团队协作管理:贝尔宾团队角色模型学习

“没有完美的个人,只有完美的团队。” 任何企业的领导者要想使自己的企业能够快速地发展和成长,就必须对团队建设的重要性有正确的认识。团队角色理论 团队角色理论是管理学中用于分析和优化团队协作的重要理论,可以帮助管理者认识人才、选拔人才,组建高效率合作团队。也可…

钉钉 + AI 网关给 DeepSeek 办入职

通过 Open-WebUI 在企业内部部署一套 DeepSeek 只是第一步,给 DeepSeek 办理入职,在钉钉等企业通讯工具上和 DeepSeek 对话才是真时尚。通过 Open-WebUI 在企业内部部署一套 DeepSeek 只是第一步,给 DeepSeek 办理入职,在钉钉等企业通讯工具上和 DeepSeek 对话才是真时尚。…

网络工程师修仙指北---STP(Spanning Tree Protocol)

网络工程师修仙指北---STP(Spanning Tree Protocol) Hello哇,欢迎来到《网络工程是修仙指北系列》,今天我们接着上一篇VLAN的内容,继续为大家介绍网络交换二层技术中另一个重要的内容---STP 一口小酒🍸,一首歌📻,阿轩带你修成仙! 上一篇中我们讲到,通过VLAN的技术…