【SpringBoot】优化慢启动应用的用户体验

通过深入分析SpringBoot中WebServer的启动流程,插入自定义的Loading页面展示逻辑,优化软件使用时的用户体验。

背景

Java本身的特点,再加上开发人员能力差,软件开发工程化程度低等等问题,经过一段时间的迭代之后,经常会出现的一个问题就是应用启动越来越慢。

过往我也是收集了不少相关资料,例如 云音乐服务端应用启动时间下降40%实践分享7min到40s: SpringBoot 启动优化实践!等等。

本文跳出与上述文章类似的技术角度,借鉴Jenkins的启动流程 ------ Jenkins也是Java开发的,而且其启动速度虽然经过不少优化,但依然有着非常明显的延迟,但Jenkins通过加入Loading页面的方式,极大提升了用户在等待系统就绪期间的用户体验 ------ 启动之后马上呈现给用户一个Loading页面,待后台服务能够正常提供服务之后冲向到常规Index页面。

实现

Jenkins默认适用的是winstone 容器,所以我在一开始所设想的"拿来主义"被掐死在了摇篮里,最后经过一番摸索,基于SpringBoot + Undertow实现了类似的效果。

思路:

  1. SpringBoot项目中,单单一个WebServer的启动是很快的,只是SpringBoot的启动流程里,其会在WebServer启动之前初始化所有的Bean,而这是整个启动流程里最耗时的。
  2. 所以,我们将在SpringBoot容器中的Bean实例化之前启动我们自定义的"minimal Undertow Server"来向用户提供对于 loading页面的响应。
  3. 待Spring容器准备进行WebServer的启动时,停止我们的"minimal Undertow Server",以避免端口占用。
  4. 前端loading页面将定期轮询后端服务启动情况,待其正常响应时跳转到相应的主体服务页面。

直接上代码:

java 复制代码
// ====================== 1. 应用启动入口类
  // 应用启动入口
  public static void main(String[] args) {
    final SpringApplication springApplication = new SpringApplication(SpringBootTestApplication.class);
    // 读取用户配置, 决定启动方式
    final Boolean humanableStart = Convert.toBool(CommonUtil.getProperty("START_HUMANABLE", "false"));
    if (humanableStart) {
      springApplication.setApplicationContextClass(AnnotationConfigServletWebServerApplicationContextEx.class); // 自定义
    }
    
    // 启动Spring容器
    springApplication.run(args);
  }
  
// ======================== 2. SpringBoot不同版本实现方式不同
// 这里以 SpringBoot 2.3.3为例, 更高版本需要实现 ApplicationContextFactory 接口
class AnnotationConfigServletWebServerApplicationContextWithHumanableStart extends AnnotationConfigServletWebServerApplicationContext {

  Undertow minimalUndertowserver;

  @Override
  protected void prepareRefresh() {
    // 尽量提前"minimal Undertow Server"的创建, 优化用户体验.
    String property = this.getEnvironment().getProperty("server.port");
    minimalUndertowserver = minimalUndertowserver(Convert.toInt(property));
    super.prepareRefresh();
  }
  
  @Override
  protected void onRefresh() {
    // ServletWebServerApplicationContext正是通过覆写本方法来实现 WebServer 创建的
    // 同时会向容器中注入webServerStartStop Bean,借助Spring的生命周期回调接口SmartLifecycle来负责将webServer的开启和关闭;
    super.onRefresh();
  }

  @Override
  protected void finishRefresh() {
    // 关键流程:  AbstractApplicationContext.refresh()
    // super.finishRefresh()中将触发 WebServerStartStopLifecycle.start() 以启动webserver, 所以我们得在它之前将我们的轻量级webserver关闭掉.
    minimalUndertowserver.stop();
    super.finishRefresh();
  }

  static Undertow minimalUndertowserver(int port) {
    // 这个loading.html是从jenkins里扒过来了, 也算是实现了部分"拿来主义"
    final String loadingHtml = ResourceUtil.readStr("static/loading.html", CharsetUtil.CHARSET_UTF_8);
    // Start the minimal Undertow server
    Undertow undertow = Undertow.builder()
        .addHttpListener(port, "0.0.0.0")
        .setHandler(new HttpHandler() {

          @Override
          public void handleRequest(HttpServerExchange exchange) throws Exception {
            exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html");
            exchange.getResponseSender().send(loadingHtml);
          }
        }).build();

    undertow.start();

    return undertow;
  }
}

原理分析

Spring容器之所以启动慢,主要原因肯定就是各类Bean的实例化耗时叠加

java 复制代码
// AbstractApplicationContext.java
// Spring核心启动流程
@Override
public void refresh() throws BeansException, IllegalStateException {
	synchronized (this.startupShutdownMonitor) {
		// Prepare this context for refreshing.
		prepareRefresh();  // 我们在这里启动 minimal Undertow Server

		// Tell the subclass to refresh the internal bean factory.
		ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

		// Prepare the bean factory for use in this context.
		prepareBeanFactory(beanFactory);

		try {
			// Allows post-processing of the bean factory in context subclasses.
			postProcessBeanFactory(beanFactory);

			// Invoke factory processors registered as beans in the context.
			invokeBeanFactoryPostProcessors(beanFactory);

			// Register bean processors that intercept bean creation.
			registerBeanPostProcessors(beanFactory);

			// Initialize message source for this context.
			initMessageSource();

			// Initialize event multicaster for this context.
			initApplicationEventMulticaster();

			// Initialize other special beans in specific context subclasses.
			onRefresh();

			// Check for listener beans and register them.
			registerListeners();

			// Instantiate all remaining (non-lazy-init) singletons.
			finishBeanFactoryInitialization(beanFactory);

			// Last step: publish corresponding event.
			finishRefresh();  // 在这里关闭 minimal Undertow Server.
		} catch (BeansException ex) {
			if (logger.isWarnEnabled()) {
				logger.warn("Exception encountered during context initialization - " +
						"cancelling refresh attempt: " + ex);
			}

			// Destroy already created singletons to avoid dangling resources.
			destroyBeans();

			// Reset 'active' flag.
			cancelRefresh(ex);

			// Propagate exception to caller.
			throw ex;
		}

		finally {
			// Reset common introspection caches in Spring's core, since we
			// might not ever need metadata for singleton beans anymore...
			resetCommonCaches();
		}
	}
}

相关

  1. Jenkins源码 - loading页面
  2. Jenkins源码 - loading后端代码
  3. GitHub - jenkinsci-winstone
相关推荐
架构源启21 分钟前
文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取
java·人工智能·spring
大模型码小白4 小时前
JAVA 集合框架进阶:List 与 Set 的深度解析与实战
java·开发语言·人工智能·windows·语言模型·list·ai编程
名字还没想好☜5 小时前
Go 的 time.Ticker 陷阱:定时任务里被忽略的内存泄漏与正确关闭
java·数据库·golang·go·定时器
音符犹如代码5 小时前
后端视角看 EventBus:发布订阅总线的原理、场景与用法
java·spring boot·guava
前端炒粉5 小时前
手撕小汇总
java·前端·javascript
唐青枫6 小时前
Java Kafka 实战指南:从 Topic、分区到 Spring Boot 可靠消息处理
java
脱胎换骨-军哥7 小时前
C++零成本抽象理论深度拆解:现代C++如何在不牺牲性能的前提下提供高级语法封装
java·开发语言·c++
我是唐青枫7 小时前
Java Netty 实战指南:从 NIO 线程模型到 TCP 编解码和心跳机制
java·tcp/ip·nio
小白说大模型7 小时前
从向量嵌入到复杂 Agent:LLM、LangChain、LangGraph 完整科普
java·开发语言·人工智能·gpt·深度学习·langchain
风起洛阳@不良使8 小时前
springIOC创建对象的方式--spring容器中的注入
java·spring