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

相关推荐
武昌库里写JAVA2 小时前
Golang的消息中间件选型
java·开发语言·spring boot·学习·课程设计
小小鸭程序员3 小时前
Spring Boot项目连接MySQL数据库及CRUD操作示例
java·spring boot·python·mysql·spring
字节源流4 小时前
【spring Cloud Netflix】OpenFeign组件
java·spring boot·后端
AntBlack4 小时前
都说 SpringBoot 启动慢 ,你知道慢在哪里吗?
java·spring boot·面试
爱的叹息6 小时前
Spring boot 中QPS(Queries Per Second)与 TPS(Transactions Per Second)详细对比
java·spring boot·后端
小小鸭程序员6 小时前
Spring Boot整合MyBatis-Plus实现CRUD操作教程
java·spring boot·python·mysql·spring
菲兹园长7 小时前
配置文件、Spring日志
java·spring boot·spring
爱的叹息8 小时前
Spring Boot 集成 Redis中@Cacheable 和 @CachePut 的详细对比,涵盖功能、执行流程、适用场景、参数配置及代码示例
spring boot·redis·后端
Mr.wangh8 小时前
Spring Boot 打印日志
java·数据库·spring boot
橘猫云计算机设计9 小时前
基于springboot放松音乐在线播放系统(源码+lw+部署文档+讲解),源码可白嫖!
android·java·spring boot·后端·spring·微信小程序·毕业设计