在当今快速迭代的软件开发环境中,项目的迁移重构是许多开发团队都绕不开的工作。最近,业务方的一个项目就面临着这样的挑战,而在迁移重构的过程中,如何确保下游系统对接无感知成为了重中之重。具体来说,他们需要实现这样一个需求:读请求访问老版本 Controller 时,能够无缝跳转到新版本 Controller,并返回新版本数据;写请求则需要进行双写操作,即同时写入新老版本,以便在新版本出现问题时能够快速切回旧版本。这一需求的实现不仅关系到项目的顺利迁移,还对系统的稳定性和兼容性有着重要影响。本文将深入探讨这一功能的实现方法,为大家提供切实可行的解决方案。
一、背景介绍
该项目在进行迁移重构时,考虑到大部分业务逻辑雷同,为了降低系统复杂度和维护成本,并没有新开服务,而是在原来的项目中添加新的 Controller。这就意味着所有的操作都要在同一个 JVM 进程项目的前提下进行,如何在不影响现有系统正常运行的情况下,实现新老版本 Controller 的低侵入式切换,成为了摆在开发团队面前的一道难题。
二、技术实现
方案一:自定义注解 + AOP 实现
这是业务部门研发团队最初采用的实现方案,通过自定义注解和 AOP(面向切面编程)的方式,实现了新老版本 Controller 的切换。
1、自定义注解
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Version {
Class clz();
VersionEnum version() default VersionEnum.NEW;
}
上述注解用于标注需要跳转的 Controller,通过指定clz属性,明确跳转的目标 Controller。
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface VersionMethod {
String methodName() default "";
ModeEnum mode() default ModeEnum.READ;
}
该注解则用于实现执行跳转方法的具体逻辑,通过methodName属性指定要调用的方法名,mode属性则区分读操作和写操作。
2、定义切面
java
@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,并加上相应切换注解
java
@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
java
@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、定义映射实体
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class VersionSwitchDTO implements Serializable {
private String source;
private String target;
private ModeEnum modeEnum;
}
该实体类用于存储新旧 URL 的映射关系以及操作模式(读或写)。
2、绑定映射逻辑
java
@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、定义转发以及双写拦截器
java
@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、配置拦截器
java
public class VersionSwitchWebAutoConfiguration implements WebMvcConfigurer {
private final VersionSwitchInterceptor versionSwitchInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(versionSwitchInterceptor).addPathPatterns("/**");
}
}
通过VersionSwitchWebAutoConfiguration类,将拦截器注册到 Spring MVC 的拦截器链中,使其能够对所有请求进行拦截处理。
5、测试
加载映射数据
java
@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 代码,链接如下: