SpringBoot - 动态端口切换黑魔法

文章目录


关键技术点

利用 Spring Boot 内嵌 Servlet 容器动态端口切换 的方式实现平滑更新的方案,关键技术点如下:

  • Servlet 容器重新绑定端口 :Spring Boot 使用 ServletWebServerFactory 动态设置新端口。
  • 零停机切换:通过先启动备用服务、释放主端口,再切换新服务到主端口,实现服务的无缝切换。
  • 端口检测和进程终止 :使用 ServerSocket 和系统命令来检测和操作端口。

这种设计允许服务在不完全停止的情况下切换到更新的版本,从而极大地缩短了不可用时间,实现了接近于零停机的效果。


核心原理

  1. 内嵌 Tomcat 容器动态启动:

    • 使用 TomcatServletWebServerFactory 实现容器的动态创建和启动。
    • 动态绑定 DispatcherServlet 通过 ServletContextInitializer 集合完成 Servlet 注册。
  2. 端口检查和动态切换:

    • 通过 ServerSocket 判断端口是否占用。
    • 如果占用,则先用备用端口启动新服务,再通过关闭老服务释放主端口,最后切换新服务到主端口。
  3. 运行时自动处理:

    • 利用 Runtime.exec 执行系统命令,释放端口并终止旧进程。
    • 在极短时间内完成新旧服务切换,避免长时间的停机。

Code

java 复制代码
package com.artisan;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.ServletContextInitializerBeans;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.ConfigurableApplicationContext;

import java.io.IOException;
import java.net.ServerSocket;
import java.util.Collections;

@SpringBootApplication()
public class BootMainApplication {
    public static void main(String[] args) {
        // 默认端口设置
        int defaultPort = 8080;
        // 备选端口设置
        int alternativePort = 9090;
        // 检查默认端口是否已被占用
        boolean isPortOccupied = isPortInUse(defaultPort);

        // 动态端口分配
        int portToUse = isPortOccupied ? alternativePort : defaultPort;
        // 创建Spring Boot应用实例
        SpringApplication app = new SpringApplication(WebMainApplication2.class);
        // 设置端口配置
        app.setDefaultProperties(Collections.singletonMap("server.port", portToUse));
        // 运行应用并获取上下文
        ConfigurableApplicationContext context = app.run(args);

        // 如果默认端口被占用,则尝试切换回默认端口
        if (isPortOccupied) {
            switchToDefaultPort(context, defaultPort, portToUse);
        }
    }

    /**
     * 切换到默认端口
     *
     * 当默认端口被其他进程占用时,此方法尝试释放该端口,并启动一个新的Web服务器实例绑定到默认端口
     * 同时,它会停止当前的Web服务器实例
     *
     * @param context 当前应用上下文,用于访问Web服务器工厂和停止当前Web服务器
     * @param defaultPort 默认端口号,希望切换到的目标端口
     * @param currentPort 当前Web服务器正在使用的端口号
     */
    private static void switchToDefaultPort(ConfigurableApplicationContext context, int defaultPort, int currentPort) {
        try {
            // 释放默认端口
            terminateProcessUsingPort(defaultPort);

            // 等待端口释放
            while (isPortInUse(defaultPort)) {
                Thread.sleep(100);
            }

            // 启动新容器绑定默认端口
            ServletWebServerFactory webServerFactory = getWebServerFactory(context);
            ((TomcatServletWebServerFactory) webServerFactory).setPort(defaultPort);

            WebServer newServer = webServerFactory.getWebServer(getServletContextInitializers(context));
            newServer.start();

            // 停止当前容器
            ((ServletWebServerApplicationContext) context).getWebServer().stop();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 检查指定的端口是否正在使用
     *
     * @param port 要检查的端口号
     * @return 如果端口正在使用,则返回true;否则返回false
     */
    private static boolean isPortInUse(int port) {
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            // 如果能够成功创建ServerSocket实例,说明端口可用,返回false
            return false;
        } catch (IOException e) {
            // 如果创建ServerSocket实例时抛出IOException,说明端口已被占用,返回true
            return true;
        }
    }

    /**
     * 终止使用指定端口的进程
     *
     * @param port 需要释放的端口号
     * @throws IOException 如果执行命令发生错误
     * @throws InterruptedException 如果线程被中断
     */
    private static void terminateProcessUsingPort(int port) throws IOException, InterruptedException {
        // 构建终止使用指定端口的进程的命令
        String command = String.format("lsof -i :%d | grep LISTEN | awk '{print $2}' | xargs kill -9", port);
        // 执行命令并等待命令执行完成
        Runtime.getRuntime().exec(new String[]{"sh", "-c", command}).waitFor();
    }

    /**
     * 获取ServletContextInitializer实例
     * 该方法用于将Spring应用上下文中的所有ServletContextInitializerBeans实例
     * 转换为ServletContextInitializer接口的实现,以便在应用启动时初始化ServletContext
     *
     * @param context Spring的应用上下文,用于获取BeanFactory
     * @return 返回一个实现了ServletContextInitializer接口的实例
     */
    private static ServletContextInitializer getServletContextInitializers(ConfigurableApplicationContext context) {
        // 使用ApplicationContext中的BeanFactory创建ServletContextInitializerBeans实例
        // 这里将ServletContextInitializerBeans作为ServletContextInitializer的实现类返回
        // ServletContextInitializerBeans将会负责收集应用上下文中所有ServletContextInitializer的实现
        // 并在应用启动时依次调用它们的onStartup方法来初始化ServletContext
        return (ServletContextInitializer) new ServletContextInitializerBeans(context.getBeanFactory());
    }

    /**
     * 获取Servlet Web服务器工厂
     *
     * @param context 可配置的应用上下文,用于获取Bean工厂
     * @return ServletWebServerFactory实例,用于配置和创建Web服务器
     */
    private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
        // 从应用上下文中获取Bean工厂,并从中获取ServletWebServerFactory实例
        return context.getBeanFactory().getBean(ServletWebServerFactory.class);
    }
}

测试

java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController()
@RequestMapping("port/")
public class TestPortController {
    @GetMapping("test")
    public String test() {
        return "artisan-old";
    }
}

启动后,访问 http://localhost:8080/port/test

修改TestPortController 的返回值, 打个jar包, 启动新的jar包,

重新访问 http://localhost:8080/port/test ,观察返回结果是否是修改后的返回值


参考:https://mp.weixin.qq.com/s/_rt1NP_LPfzatb0EYXry9Q

相关推荐
JH30738 小时前
SpringBoot 优雅处理金额格式化:拦截器+自定义注解方案
java·spring boot·spring
qq_124987075311 小时前
基于SSM的动物保护系统的设计与实现(源码+论文+部署+安装)
java·数据库·spring boot·毕业设计·ssm·计算机毕业设计
Coder_Boy_11 小时前
基于SpringAI的在线考试系统-考试系统开发流程案例
java·数据库·人工智能·spring boot·后端
2301_8187320611 小时前
前端调用控制层接口,进不去,报错415,类型不匹配
java·spring boot·spring·tomcat·intellij-idea
汤姆yu15 小时前
基于springboot的尿毒症健康管理系统
java·spring boot·后端
暮色妖娆丶15 小时前
Spring 源码分析 单例 Bean 的创建过程
spring boot·后端·spring
biyezuopinvip16 小时前
基于Spring Boot的企业网盘的设计与实现(任务书)
java·spring boot·后端·vue·ssm·任务书·企业网盘的设计与实现
JavaGuide16 小时前
一款悄然崛起的国产规则引擎,让业务编排效率提升 10 倍!
java·spring boot
figo10tf17 小时前
Spring Boot项目集成Redisson 原始依赖与 Spring Boot Starter 的流程
java·spring boot·后端
zhangyi_viva17 小时前
Spring Boot(七):Swagger 接口文档
java·spring boot·后端