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

相关推荐
计算机-秋大田2 小时前
基于微信小程序的电子竞技信息交流平台设计与实现(LW+源码+讲解)
spring boot·后端·微信小程序·小程序·课程设计
customer085 小时前
【开源免费】基于SpringBoot+Vue.JS景区民宿预约系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
精通HelloWorld!9 小时前
使用HttpClient和HttpRequest发送HTTP请求
java·spring boot·网络协议·spring·http
拾忆,想起11 小时前
如何选择Spring AOP的动态代理?JDK与CGLIB的适用场景
spring boot·后端·spring·spring cloud·微服务
customer0813 小时前
【开源免费】基于SpringBoot+Vue.JS美食推荐商城(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
一 乐13 小时前
基于微信小程序的酒店管理系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·微信小程序·酒店管理系统
小万编程18 小时前
【2025最新计算机毕业设计】基于SpringBoot+Vue家政呵护到家护理服务平台(高质量源码,可定制,提供文档,免费部署到本地)
java·vue.js·spring boot·计算机毕业设计·java毕业设计·web毕业设计
XYu123011 天前
Spring Boot 热部署实现指南
java·ide·spring boot·intellij-idea
是小崔啊1 天前
Spring Boot - 数据库集成07 - 数据库连接池
数据库·spring boot·oracle
细心的莽夫1 天前
SpringBoot 基础(Spring)
spring boot·后端·spring