spring 启动进度
重新启动企业应用程序时,客户打开Web浏览器时会看到什么?
- 他们什么也没看到,服务器还没有响应,因此Web浏览器显示
ERR_CONNECTION_REFUSED
- 应用程序前面的Web代理(如果有)注意到它已关闭,并显示“友好”错误消息
- 该网站需要永久加载-它接受了套接字连接和HTTP请求,但是等待响应,直到应用程序实际启动
- 您的应用程序进行了扩展,以便其他节点可以快速接收请求,而不会有人通知(并且会话始终得以复制)
- …或应用程序启动速度如此之快,以至于没有人注意到任何中断(嘿,普通的Spring Boot Hello world应用程序从点击
java -jar ... [Enter]
开始服务请求不到3秒)。 顺便说一句,请检出SPR-8767:启动过程中并行bean初始化 。
处于情况4和5.绝对更好。但是在本文中,我们将介绍对情况1和3的更强大的处理。
典型的Spring Boot应用程序会在所有Bean都加载完毕时(状态1),在最后启动Web容器(例如Tomcat)。这是一个非常合理的默认值,因为它会阻止客户端在完全配置之前无法访问我们的端点。 但是,这意味着我们无法区分启动了几秒钟的应用程序和关闭了的应用程序。 因此,其想法是要使一个应用程序在加载时显示一些有意义的启动页面,类似于显示“ 服务不可用 ”的Web代理。 但是,由于此类启动页面是我们应用程序的一部分,因此它可能会更深入地了解启动进度。 我们希望在初始化生命周期中更早地启动Tomcat,但要保留特殊目的的启动页面,直到Spring完全引导为止。 这个特殊页面应该拦截所有可能的请求-因此听起来像一个servlet过滤器。
渴望并尽早启动Tomcat。
在Spring启动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过滤器类似,但它是Tomcat体系结构的一部分。 Tomcat自己捆绑了多个阀门,以处理各种容器功能,例如SSL,会话群集和X-Forwarded-For
处理。 Logback Access也使用此API,因此我不会感到内。 阀门看起来像这样:
package com.nurkiewicz.progress;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import javax.servlet.ServletException;
import java.io.IOException;
import java.io.InputStream;
public class ProgressValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
try (InputStream loadingHtml = getClass().getResourceAsStream("loading.html")) {
IOUtils.copy(loadingHtml, response.getOutputStream());
}
}
}
阀门通常委托给链中的下一个阀门,但是这次我们只为每个单个请求返回static loading.html
页面。 注册这样的阀门非常简单,Spring Boot为此提供了一个API!
if (factory instanceof TomcatEmbeddedServletContainerFactory) {
((TomcatEmbeddedServletContainerFactory) factory).addContextValves(new ProgressValve());
}
定制阀门原来是个好主意,它从Tomcat立即开始并且非常易于使用。 但是,您可能已经注意到,即使在应用程序启动后,我们也不会放弃提供loading.html
。 那很糟。 Spring上下文可以通过多种方式发出初始化信号,例如,使用ApplicationListener<ContextRefreshedEvent>
:
@Component
class Listener implements ApplicationListener<ContextRefreshedEvent> {
private static final CompletableFuture<ContextRefreshedEvent> promise = new CompletableFuture<>();
public static CompletableFuture<ContextRefreshedEvent> initialization() {
return promise;
}
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
promise.complete(event);
}
}
我知道您的想法是“ static
”吗? 但是在Valve
内部,我根本不想接触Spring上下文,因为如果我在错误的时间点从随机线程请求某个bean,它可能会引入阻塞甚至死锁。 完成promise
, Valve
注销自身:
public class ProgressValve extends ValveBase {
public ProgressValve() {
Listener
.initialization()
.thenRun(this::removeMyself);
}
private void removeMyself() {
getContainer().getPipeline().removeValve(this);
}
//...
}
这是一个令人惊讶的干净解决方案:当不再需要Valve
我们无需从处理管道中删除它,而不必为每个请求支付费用。 我不会演示它如何工作以及为什么起作用,让我们直接转到目标解决方案。
监控进度
监视Spring应用程序上下文启动的进度非常简单。 另外,与EJB或JSF等API和规范驱动的框架相比,Spring框架的“可破解性”也让我感到惊讶。 在Spring中,我可以简单地实现BeanPostProcessor
,以通知每个正在创建和初始化的bean( 完整的源代码 ):
package com.nurkiewicz.progress;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import rx.Observable;
import rx.subjects.ReplaySubject;
import rx.subjects.Subject;
class ProgressBeanPostProcessor implements BeanPostProcessor, ApplicationListener<ContextRefreshedEvent> {
private static final Subject<String, String> beans = ReplaySubject.create();
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
beans.onNext(beanName);
return bean;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
beans.onCompleted();
}
static Observable<String> observe() {
return beans;
}
}
每次初始化新bean时,我都会将其名称发布到RxJava的可观察对象中。 整个应用程序初始化后,我完成了Observable
。 任何人都可以使用此Observable
,例如我们的自定义ProgressValve
( 完整的源代码 ):
public class ProgressValve extends ValveBase {
public ProgressValve() {
super(true);
ProgressBeanPostProcessor.observe().subscribe(
beanName -> log.trace("Bean found: {}", beanName),
t -> log.error("Failed", t),
this::removeMyself);
}
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
switch (request.getRequestURI()) {
case "/init.stream":
final AsyncContext asyncContext = request.startAsync();
streamProgress(asyncContext);
break;
case "/health":
case "/info":
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
break;
default:
sendHtml(response, "loading.html");
}
}
//...
}
ProgressValve
现在变得更加复杂,我们还没有完成。 它可以处理多个不同的请求,例如,我有意在/health
和/info
Actuator端点上返回503,以便该应用程序看起来像在启动期间处于关闭状态。 除了所有其他请求init.stream
表明熟悉loading.html
。 /init.stream
是特殊的。 这是服务器发送的事件端点,它将在每次初始化新bean时推送消息(很抱歉,上面没有代码):
private void streamProgress(AsyncContext asyncContext) throws IOException {
final ServletResponse resp = asyncContext.getResponse();
resp.setContentType("text/event-stream");
resp.setCharacterEncoding("UTF-8");
resp.flushBuffer();
final Subscription subscription = ProgressBeanPostProcessor.observe()
.map(beanName -> "data: " + beanName)
.subscribeOn(Schedulers.io())
.subscribe(
event -> stream(event, asyncContext.getResponse()),
e -> log.error("Error in observe()", e),
() -> complete(asyncContext)
);
unsubscribeOnDisconnect(asyncContext, subscription);
}
private void complete(AsyncContext asyncContext) {
stream("event: complete\ndata:", asyncContext.getResponse());
asyncContext.complete();
}
private void unsubscribeOnDisconnect(AsyncContext asyncContext, final Subscription subscription) {
asyncContext.addListener(new AsyncListener() {
@Override
public void onComplete(AsyncEvent event) throws IOException {
subscription.unsubscribe();
}
@Override
public void onTimeout(AsyncEvent event) throws IOException {
subscription.unsubscribe();
}
@Override
public void onError(AsyncEvent event) throws IOException {
subscription.unsubscribe();
}
@Override
public void onStartAsync(AsyncEvent event) throws IOException {}
});
}
private void stream(String event, ServletResponse response) {
try {
final PrintWriter writer = response.getWriter();
writer.println(event);
writer.println();
writer.flush();
} catch (IOException e) {
log.warn("Failed to stream", e);
}
}
这意味着我们可以使用简单的HTTP接口(!)来跟踪Spring应用程序上下文启动的进度:
$ curl -v localhost:8090/init.stream
> GET /init.stream HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:8090
> Accept: */*
< HTTP/1.1 200 OK
< Content-Type: text/event-stream;charset=UTF-8
< Transfer-Encoding: chunked
data: org.springframework.boot.autoconfigure.web.EmbeddedServletContainerAutoConfiguration$EmbeddedTomcat
data: org.springframework.boot.autoconfigure.websocket.WebSocketAutoConfiguration$TomcatWebSocketConfiguration
data: websocketContainerCustomizer
data: org.springframework.boot.autoconfigure.web.ServerPropertiesAutoConfiguration
data: toStringFriendlyJsonNodeToStringConverter
data: org.hibernate.validator.internal.constraintvalidators.bv.NotNullValidator
data: serverProperties
data: org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration
...
data: beanNameViewResolver
data: basicErrorController
data: org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration$JpaWebConfiguration$JpaWebMvcConfiguration
该端点将实时初始化(请参阅: 使用RxJava和SseEmitter的服务器发送的事件 )每个初始化的单个bean名称。 有了如此出色的工具,我们将建立更强大的( React性的 ,在这里,我说过) loading.html
页面。
花式进度前端
首先,我们需要确定哪些Spring bean代表了系统中的哪些子系统 ,高级组件(甚至可能是有限的上下文 )。 我使用data-bean
自定义属性在HTML内部对此进行了编码:
<h2 data-bean="websocketContainerCustomizer" class="waiting">
Web socket support
</h2>
<h2 data-bean="messageConverters" class="waiting">
Spring MVC
</h2>
<h2 data-bean="metricFilter" class="waiting">
Metrics
</h2>
<h2 data-bean="endpointMBeanExporter" class="waiting">
Actuator
</h2>
<h2 data-bean="mongoTemplate" class="waiting">
MongoDB
</h2>
<h2 data-bean="dataSource" class="waiting">
Database
</h2>
<h2 data-bean="entityManagerFactory" class="waiting">
Hibernate
</h2>
CSS class="waiting"
表示给定的模块尚未初始化,即给定的bean尚未出现在SSE流中。 最初,所有组件都处于"waiting"
状态。 然后,我订阅init.stream
并更改CSS类以反映模块状态更改:
var source = new EventSource('init.stream');
source.addEventListener('message', function (e) {
var h2 = document.querySelector('h2[data-bean="' + e.data + '"]');
if(h2) {
h2.className = 'done';
}
});
简单吧? 显然,无需使用jQuery就可以在纯JavaScript中编写前端。 加载所有bean后, Observable
在服务器端event: complete
,SSE发出event: complete
,让我们处理一下:
source.addEventListener('complete', function (e) {
window.location.reload();
});
因为前端是在应用程序上下文启动时通知的,所以我们可以简单地重新加载当前页面。 那时,我们的ProgressValve
已经注销,因此重新加载将打开真实的应用程序,而不是loading.html
占位符。 我们的工作完成了。 另外,我还计算了启动的bean数量,并知道总共有多少bean(我用JavaScript对其进行了硬编码,请原谅),我可以用百分比来计算启动进度。 图片值一千个字,让此截屏视频向您展示我们取得的成果:
后续模块启动良好,我们不再关注浏览器错误。 以百分比衡量的进度使整个启动进度感觉非常顺利。 最后但并非最不重要的一点是,当应用程序启动时,我们将自动重定向。 希望您喜欢这个概念证明,整个工作示例应用程序都可以在GitHub上找到。
spring 启动进度