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