文章目录
关键技术点
利用 Spring Boot 内嵌 Servlet 容器 和 动态端口切换 的方式实现平滑更新的方案,关键技术点如下:
- Servlet 容器重新绑定端口 :Spring Boot 使用
ServletWebServerFactory
动态设置新端口。 - 零停机切换:通过先启动备用服务、释放主端口,再切换新服务到主端口,实现服务的无缝切换。
- 端口检测和进程终止 :使用
ServerSocket
和系统命令来检测和操作端口。
这种设计允许服务在不完全停止的情况下切换到更新的版本,从而极大地缩短了不可用时间,实现了接近于零停机的效果。
核心原理
-
内嵌 Tomcat 容器动态启动:
- 使用
TomcatServletWebServerFactory
实现容器的动态创建和启动。 - 动态绑定
DispatcherServlet
通过ServletContextInitializer
集合完成 Servlet 注册。
- 使用
-
端口检查和动态切换:
- 通过
ServerSocket
判断端口是否占用。 - 如果占用,则先用备用端口启动新服务,再通过关闭老服务释放主端口,最后切换新服务到主端口。
- 通过
-
运行时自动处理:
- 利用
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 ,观察返回结果是否是修改后的返回值