前言
在個人或者企業服務器上,總歸有要更新代碼的時候,普通的做法必須先終止原來進程,因為新進程和老進程端口是一個,新進程在啟動時候,必定會出現端口占用的情況,但是,還有黑科技可以讓兩個SpringBoot進程真正的共用同一個端口,這是另一種解決辦法,我們下回分解。
那么就會出現一個問題,如果此時有大量的用戶在訪問,但是你的代碼又必須要更新,這時候如果采用上面的做法,那么必定會導致一段時間內的用戶無法訪問,這段時間還取決于你的項目啟動速度,那么在單體應用下,如何解決這種事情?
一種簡單辦法是,新代碼先用其他端口啟動,啟動完畢后,更改nginx的轉發地址,nginx重啟非常快,這樣就避免了大量的用戶訪問失敗,最后終止老進程就可以。
但是還是比較麻煩,端口換來換去,即使你寫個腳本,也是比較麻煩,有沒有一種可能,新進程直接啟動,自動處理好這些事情?
答案是有的。
設計思路
這里涉及到幾處源碼類的知識,如下。
- SpringBoot內嵌Servlet容器的原理是什么
- DispatcherServlet是如何傳遞給Servlet容器的
先看第一個問題,用Tomcat來說,這個首先得Tomcat本身支持,如果Tomcat不支持內嵌,SpringBoot估計也沒辦法,或者可能會另找出路。
Tomcat本身有一個Tomcat類,沒錯就叫Tomcat,全路徑是org.apache.catalina.startup.Tomcat,我們想啟動一個Tomcat,直接new Tomcat(),之后調用start()就可以了。
并且他提供了添加Servlet、配置連接器這些基本操作。
public class Main {
public static void main(String[] args) {
try {
Tomcat tomcat =new Tomcat();
tomcat.getConnector();
tomcat.getHost();
Context context = tomcat.addContext("/", null);
tomcat.addServlet("/","index",new HttpServlet(){
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().append("hello");
}
});
context.addServletMappingDecoded("/","index");
tomcat.init();
tomcat.start();
}catch (Exception e){}
}
}
在SpringBoot源碼中,根據你引入的Servlet容器依賴,通過下面代碼可以獲取創建對應容器的工廠,拿Tomcat來說,創建Tomcat容器的工廠類是TomcatServletWebServerFactory
。
private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
String[] beanNames = context.getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
return context.getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
}
調用ServletWebServerFactory.getWebServer
就可以獲取一個Web服務,他有start、stop方法啟動、關閉Web服務。
而getWebServer方法的參數很關鍵,也是第二個問題,DispatcherServlet是如何傳遞給Servlet容器的。
SpringBoot并不像上面Tomcat的例子一樣簡單的通過tomcat.addServlet
把DispatcherServlet傳遞給Tomcat,而是通過個Tomcat主動回調來完成的,具體的回調通過ServletContainerInitializer
接口協議,它允許我們動態地配置Servlet、過濾器。
SpringBoot在創建Tomcat后,會向Tomcat添加一個此接口的實現,類名是TomcatStarter
,但是TomcatStarter
也只是一堆SpringBoot內部ServletContextInitializer
的集合,簡單的封裝了一下,這些集合中有一個類會向Tomcat添加DispatcherServlet
在Tomcat內部啟動后,會通過此接口回調到SpringBoot內部,SpringBoot在內部會調用所有ServletContextInitializer
集合來初始化,
而getWebServer的參數正好就是一堆ServletContextInitializer
集合。
那么這時候還有一個問題,怎么獲取ServletContextInitializer
集合?
非常簡單,注意,ServletContextInitializerBeans
是實現Collection
的。
protected static Collection<ServletContextInitializer> getServletContextInitializerBeans(ConfigurableApplicationContext context) {
return new ServletContextInitializerBeans(context.getBeanFactory());
}
到這里所有用到的都準備完畢了,思路也很簡單。
- 重新創建容器實例并且關聯DispatcherServlet
在第三步和第四步之間,速度很快的,這樣就達到了無縫更新代碼的目的。
實現代碼
@SpringBootApplication()
@EnableScheduling
public class WebMainApplication {
public static void main(String[] args) {
String[] newArgs = args.clone();
int defaultPort = 8088;
boolean needChangePort = false;
if (isPortInUse(defaultPort)) {
newArgs = new String[args.length + 1];
System.arraycopy(args, 0, newArgs, 0, args.length);
newArgs[newArgs.length - 1] = "--server.port=9090";
needChangePort = true;
}
ConfigurableApplicationContext run = SpringApplication.run(WebMainApplication.class, newArgs);
if (needChangePort) {
String command = String.format("lsof -i :%d | grep LISTEN | awk '{print $2}' | xargs kill -9", defaultPort);
try {
Runtime.getRuntime().exec(new String[]{"sh", "-c", command}).waitFor();
while (isPortInUse(defaultPort)) {
}
ServletWebServerFactory webServerFactory = getWebServerFactory(run);
((TomcatServletWebServerFactory) webServerFactory).setPort(defaultPort);
WebServer webServer = webServerFactory.getWebServer(invokeSelfInitialize(((ServletWebServerApplicationContext) run)));
webServer.start();
((ServletWebServerApplicationContext) run).getWebServer().stop();
} catch (IOException | InterruptedException ignored) {
}
}
}
private static ServletContextInitializer invokeSelfInitialize(ServletWebServerApplicationContext context) {
try {
Method method = ServletWebServerApplicationContext.class.getDeclaredMethod("getSelfInitializer");
method.setAccessible(true);
return (ServletContextInitializer) method.invoke(context);
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
private static boolean isPortInUse(int port) {
try (ServerSocket serverSocket = new ServerSocket(port)) {
return false;
} catch (IOException e) {
return true;
}
}
protected static Collection<ServletContextInitializer> getServletContextInitializerBeans(ConfigurableApplicationContext context) {
return new ServletContextInitializerBeans(context.getBeanFactory());
}
private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
String[] beanNames = context.getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
return context.getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
}
}
測試
我們先寫一個小demo。
@RestController()
@RequestMapping("port/test")
public class TestPortController {
@GetMapping("test")
public String test() {
return "1";
}
}
并且打包成jar,然后更改返回值為2,并打包成v2版本的jar包,此時有兩個代碼,一個新的一個舊的。
image.png我們先啟動v1版本,并且使用IDEA中最好用的接口調試插件Cool Request測試,可以發現此時都正常。
image.png好的我們不用關閉v1的進程,直接啟動v2的jar包,并且啟動后,可以一直在Cool Request測試接口時間內的可用程度。
稍等后,就會看到v2代碼已經生效,而在這個過程中,服務只有極短的時間不可用,不會超過1秒。
image.png妙不妙?