在浏览器中显示Spring应用启动进度

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

当您重新启动您的 企业 应用程序时,您的客户在打开 Web 浏览器时会看到什么?

  1. 他们什么也看不到,服务器还没有响应,因此网络浏览器显示 ERR_CONNECTION_REFUSED
  2. 应用程序前面的 Web 代理(如果有)注意到它已关闭并显示“友好”错误消息
  3. 该网站需要很长时间才能加载 - 它接受套接字连接和 HTTP 请求,但等待响应直到应用程序实际启动。
  4. 您的应用程序被横向扩展,以便其他节点快速接收请求并且没有人注意到(无论如何都会复制会话)。
  5. ... 或者应用程序启动速度如此之快,以至于没有人注意到任何中断。 (嘿,一个普通的 Spring Boot Hello world 应用程序从点击 java -jar ... [Enter] 开始服务请求不到 3 秒。)顺便说一句,查看 SPR-8767: Parallel bean initialization during startup

情况 4 和 5 肯定更好,但在本文中,我们将介绍对情况 1 和 3 的更稳健处理。

一个典型的 Spring Boot 应用程序在所有 bean 都加载完毕时(情况 1)在最后启动一个 Web 容器(例如 Tomcat)。这是一个非常合理的默认设置,因为它会阻止客户端在完全配置之前访问我们的端点。但是,这意味着我们无法区分启动几秒钟的应用程序和关闭的应用程序。因此,我们的想法是让应用程序在加载时显示一些有意义的启动页面,类似于显示“ 服务不可用 ”的 Web 代理。然而,由于这样的启动页面是我们应用程序的一部分,它可能更深入地了解启动进度。我们希望在初始化生命周期的早期启动 Tomcat,但在 Spring 完全引导后提供一个特殊用途的启动页面。这个特殊页面应该拦截每一个可能的请求——因此它听起来像一个 servlet 过滤器。

尽早启动 Tomcat

在 Spring Boot servlet 中,容器是通过 EmbeddedServletContainerFactory 初始化的,它创建了 EmbeddedServletContainer 的实例。我们有机会使用 EmbeddedServletContainerCustomizer 拦截这个过程。容器在应用程序生命周期的早期创建,但在整个上下文完成后才 开始 。所以我想我会在我自己的定制器中简单地调用 start() 就是这样。

不幸的是, ConfigurableEmbeddedServletContainer 没有公开这样的 API,所以我不得不像这样装饰 EmbeddedServletContainerFactory


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

您可能认为 BeanPostProcessor 太过分了,但它稍后会变得非常有用。我们在这里所做的是,如果我们遇到从应用程序上下文请求的 EmbeddedServletContainerFactory ,我们将返回一个急切启动 Tomcat 的装饰器。这给我们留下了相当不稳定的设置,其中 Tomcat 接受连接到尚未初始化的上下文。所以让我们放置一个 servlet 过滤器拦截所有请求,直到上下文完成。

在启动期间拦截请求

我开始时只是将 FilterRegistrationBean 添加到 Spring 上下文,希望它能拦截传入的请求,直到上下文启动。这是徒劳的:我不得不等待很长时间,直到过滤器被注册并准备就绪,因此从用户的角度来看,应用程序挂起了。后来我什至尝试使用 servlet API ( javax.servlet.ServletContext.addFilter() ) 直接在 Tomcat 中注册过滤器,但显然整个 DispatcherServlet 必须事先引导。请记住,我想要的只是来自即将初始化的应用程序的极快反馈。

所以我最终得到了 Tomcat 的专有 API: org.apache.catalina.Valve Valve 类似于 servlet filter,但它是 Tomcat 架构的一部分。 Tomcat 自己捆绑了多个阀来处理各种容器功能,如 SSL、会话集群和 X-Forwarded-For 处理。 Logback Access 也使用这个 API,所以我并不感到内疚。阀门看起来像这样:


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

阀门通常委托给链中的下一个阀门,但这次我们只是为每个请求返回一个静态 loading.html 页面。注册这样一个阀门非常简单,Spring Boot 有一个 API!


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

定制阀门被证明是一个好主意,它可以立即与 Tomcat 一起启动并且相当容易使用。但是,您可能已经注意到,即使在我们的应用程序启动之后,我们也从未放弃提供 loading.html 服务。那很糟。 Spring 上下文可以通过多种方式发出初始化信号,例如使用 ApplicationListener<ContextRefreshedEvent>


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

我知道你怎么想,“ static ”?但在 Valve 内部,我根本不想接触 Spring 上下文,因为如果我在错误的时间点从随机线程请​​求一些 bean,它可能会引入阻塞甚至死锁。当我们完成 promise 时, Valve 将自行注销:


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

这是一个非常干净的解决方案:当不再需要 Valve 时,我们无需为每个请求支付费用,只需将其从处理管道中删除即可。我不打算演示它的工作原理和原因,让我们直接转到目标解决方案。

监控进度

监视 Spring 应用程序上下文启动的进度非常简单。此外,与 EJB 或 JSF 等 API 和规范驱动的框架相比,我对 Spring 的“可破解性”感到惊讶。在 Spring 中,我可以简单地实现 BeanPostProcessor 以通知每个正在创建和初始化的 bean( 完整源代码 ):


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

每次初始化一个新 bean 时,我都会将其名称发布到 RxJava 的可观察对象中。整个应用程序初始化后,我完成 Observable 。这个 Observable 以后可以被任何人使用,例如我们的自定义 ProgressValve 完整源代码 ):


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}


ProgressValve 现在更复杂了,我们还没有完成。它可以处理多个不同的请求,例如,我故意在 /health /info Actuator 端点上返回 503,这样应用程序就好像在启动期间关闭了一样。除了 init.stream 之外的所有其他请求都显示熟悉的 loading.html /init.stream 很特别。这是一个 服务器发送的事件 端点,每次初始化新 bean 时都会推送消息(对不起,代码墙):


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

这意味着我们可以使用简单的 HTTP 接口(!)跟踪 Spring 应用程序上下文启动的进度:


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}


该端点将实时流式传输(另请参阅: 使用 RxJava 和 SseEmitter 的服务器发送的事件 )每个被初始化的 bean 名称。拥有如此出色的工具,我们将构建更健壮的( 反应式 - 我说的是) loading.html 页面。

花式进步前端

首先,我们需要确定哪些 Spring bean 代表我们系统中的哪些 子系统 、高级组件(甚至可能是 有界上下文 )。我使用 data-bean 自定义属性将其编码 在 HTML 中


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

CSS class="waiting" 表示给定的模块尚未初始化,即给定的 bean 尚未出现在 SSE 流中。最初所有组件都处于 "waiting" 状态。然后我订阅了 init.stream 并更改了 CSS 类以反映模块状态的变化:


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

简单吧?显然可以在没有 jQuery 的情况下用纯 JavaScript 编写前端。当所有的 bean 都被加载时, Observable 在服务器端完成并且 SSE 发出 event: complete 。让我们来处理:


 class ProgressBeanPostProcessor implements BeanPostProcessor {
//...

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof EmbeddedServletContainerFactory) {
        return wrap((EmbeddedServletContainerFactory) bean);
    } else {
        return bean;
    }
}

private EmbeddedServletContainerFactory wrap(EmbeddedServletContainerFactory factory) {
    return new EmbeddedServletContainerFactory() {
        @Override
        public EmbeddedServletContainer getEmbeddedServletContainer(ServletContextInitializer... initializers) {
            final EmbeddedServletContainer container = factory.getEmbeddedServletContainer(initializers);
            log.debug("Eagerly starting {}", container);
            container.start();
            return container;
        }
    };
}

}

因为在应用程序上下文启动时会通知前端,所以我们可以简单地重新加载当前页面。届时,我们的 ProgressValve 将自行注销,因此重新加载将打开 真正的 应用程序,而不是 loading.html 占位符。我们的工作完成了。此外,我计算启动了多少个 bean 并知道总共有多少个 bean(我用 JavaScript 硬编码了,请原谅),我可以计算启动进度的百分比。一张图片胜过千言万语;让这个截屏视频向您展示我们取得的成果:


后续模块启动良好,我们不再查看浏览器错误。以百分比衡量的进度,让整个启动进度感觉非常顺畅。最后但同样重要的是,当应用程序启动时,我们会自动重定向。希望您喜欢这个概念验证。 GitHub 上提供了整个 工作示例应用程序