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

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

github.com/lyb-geek/sp...

相关推荐
小杨40422 分钟前
springboot框架项目实践应用十四(扩展sentinel错误提示)
spring boot·后端·spring cloud
冬天豆腐2 小时前
Springboot集成Dubbo和Zookeeper框架搭建
spring boot·dubbo·java-zookeeper
风象南2 小时前
Spring Boot 实现文件秒传功能
java·spring boot·后端
橘猫云计算机设计2 小时前
基于django优秀少儿图书推荐网(源码+lw+部署文档+讲解),源码可白嫖!
java·spring boot·后端·python·小程序·django·毕业设计
黑猫Teng2 小时前
Spring Boot拦截器(Interceptor)与过滤器(Filter)深度解析:区别、实现与实战指南
java·spring boot·后端
星河浪人2 小时前
Spring Boot启动流程及源码实现深度解析
java·spring boot·后端
工业互联网专业3 小时前
基于springboot+vue的动漫交流与推荐平台
java·vue.js·spring boot·毕业设计·源码·课程设计·动漫交流与推荐平台
数据攻城小狮子3 小时前
Java Spring Boot 与前端结合打造图书管理系统:技术剖析与实现
java·前端·spring boot·后端·maven·intellij-idea
计算机程序设计开发4 小时前
宠物医院管理系统基于Spring Boot SSM
java·spring boot·后端·毕业设计·计算机毕业设计
eternal__day5 小时前
Spring Boot 快速入手
java·spring boot·后端·spring·java-ee·maven