Статьи

Отображение хода запуска приложения Spring в браузере

Когда вы перезапускаете свое  корпоративное  приложение, что видят ваши клиенты при открытии веб-браузера?

  1. Они ничего не видят, сервер еще не отвечает, поэтому отображается веб-браузер  ERR_CONNECTION_REFUSED.
  2. Веб-прокси (если есть) перед вашим приложением замечает, что оно не работает, и отображает «дружественное» сообщение об ошибке
  3. Веб-сайт загружается вечно — он принимает сокет-соединение и HTTP-запрос, но ожидает ответа, пока приложение фактически не загрузится.
  4. Ваше приложение масштабируется так, что другие узлы быстро принимают запросы, и никто их не замечает (и сеанс все равно реплицируется).
  5. … Или приложение запускается так быстро, что никто не замечает сбоев. (Привет, простому  приложению Spring Boot  Hello world требуется менее 3 секунд,  java -jar ... [Enter] чтобы начать обслуживать запросы.) Кстати, проверьте  SPR-8767: параллельная инициализация bean-компонента во время запуска .

Определенно лучше быть в ситуациях 4 и 5, но в этой статье мы рассмотрим более надежную обработку ситуаций 1 и 3.

Типичное приложение Spring Boot запускает веб-контейнер (например, Tomcat) в самом конце, когда загружены все bean-компоненты (ситуация 1). Это очень разумное значение по умолчанию, поскольку оно не позволяет клиентам достигать наших конечных точек, пока они не будут полностью настроены. Однако это означает, что мы не можем различать приложения, которые запускаются на несколько секунд, и приложения, которые не работают. Таким образом, идея состоит в том, чтобы приложение показывало значимую стартовую страницу во время загрузки, подобно веб-прокси, показывающему « Служба недоступна».«. Однако, поскольку такая стартовая страница является частью нашего приложения, она потенциально может лучше понять ход запуска. Мы хотим запустить Tomcat раньше в жизненном цикле инициализации, но обслуживать специальную стартовую страницу после полной загрузки Spring. Это специальное предложение. страница должна перехватывать все возможные запросы — таким образом, это звучит как фильтр сервлета.

Начиная Tomcat с нетерпением и рано

В сервлете Spring Boot инициализируется контейнер,  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 принимает соединения с еще не инициализированным контекстом. Итак, давайте установим фильтр сервлета, перехватывающий все запросы, пока не будет создан контекст.

Перехват запросов во время запуска

Я начал просто с добавления  FilterRegistrationBean в контекст Spring, надеясь, что он перехватит входящий запрос, пока не будет запущен контекст. Это было бесплодно: мне пришлось ждать долгую секунду, пока фильтр не был зарегистрирован и готов, поэтому с точки зрения пользователя приложение зависало. Позже я даже попытался зарегистрировать фильтр непосредственно в Tomcat, используя сервлет API ( javax.servlet.ServletContext.addFilter()), но, видимо, все  DispatcherServlet должно было быть загружено заранее. Помните, все, что я хотел, — это чрезвычайно быстрая обратная связь с приложением, которое оно собирается инициализировать.

Так что я в конечном итоге с запатентованным API для Tomcat:  org.apache.catalina.ValveValve похож на фильтр сервлетов, но он является частью архитектуры 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());
        }
    }
}

Клапаны обычно делегируют следующему клапану в цепочке, но на этот раз мы просто возвращаем статическую  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, поскольку он может привести к блокировке или даже к тупику, если я запрошу какой-нибудь компонент в неподходящий момент времени из случайного потока. Когда мы завершаем  promise, Valve отменяет регистрацию себя:

public class ProgressValve extends ValveBase {

    public ProgressValve() {
        Listener
                .initialization()
                .thenRun(this::removeMyself);
    }

    private void removeMyself() {
        getContainer().getPipeline().removeValve(this);
    }

    //...

}

Это удивительно чистое решение: когда  Valve больше не нужно, вместо того, чтобы платить за каждый отдельный запрос, мы просто удаляем его из конвейера обработки. Я не собираюсь демонстрировать, как и почему это работает, давайте перейдем непосредственно к целевому решению.

Мониторинг прогресса

Отслеживать ход запуска контекста приложения Spring на удивление просто. Кроме того, я поражен тем, насколько «взломанный» Spring, в отличие от API-ориентированных и спекулятивных сред, таких как EJB или JSF. В Spring я могу просто реализовать BeanPostProcessor уведомление о создании и инициализации каждого компонента ( полный исходный код ):

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;
    }
}

Каждый раз, когда инициализируется новый бин, я публикую его имя в заметке 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 сейчас намного сложнее, и мы еще не закончили. Он может обрабатывать несколько разных запросов, например, я намеренно возвращаю 503  /health и  /info конечные точки привода, чтобы приложение выглядело так, как будто оно не работало во время запуска. Все остальные просьбы, кроме  init.stream показа знакомых  loading.html/init.stream особенный. Это  отправленная сервером  конечная точка событий, которая будет отправлять сообщения при каждой инициализации нового компонента (извините за стену кода):

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);
    }
}

This means we can track the progress of Spring’s application context startup using simple HTTP interface (!):

$ 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

This endpoint will stream in real-time (see also: Server-sent events with RxJava and SseEmitter) every single bean name being initialized. Having such an amazing tool we’ll build more robust (reactive — there, I said it) loading.html page.

Fancy Progress Front-end

First we need to identify which Spring beans represent which subsystems, high-level components (or maybe even bounded contexts) in our system. I encoded this inside HTML using data-bean custom attribute:

<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" means that a given module is not yet initialized, i.e. the given bean hasn’t yet appeared in the SSE stream. Initially all components are in "waiting" state. I then subscribe to init.stream and changed the CSS class to reflect the module state changes:

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';
    }
});

Simple, huh? Apparently one can write front-end without jQuery in pure JavaScript. When all beans are loaded,Observable is completed on the server side and SSE emits event: complete. Let’s handle that:

source.addEventListener('complete', function (e) {
    window.location.reload();
});

Because front-end is notified on application context startup, we can simply reload the current page. At that point in time, ourProgressValve will have already deregistered itself, so reloading will open the true application, not the loading.html placeholder. Our job is done. Additionally, I count how many beans started and knowing how many beans are in total (I hardcoded it in JavaScript, forgive me), I can calculate startup progress as a percentage. A picture is worth a thousand words; let this screencast show you the result we achieved:

Subsequent modules are starting up nicely and we no longer look at a browser error. Progress measured in percentage makes the whole startup progress feels very smooth. Last but not least, when the application starts, we are automatically redirected. Hope you enjoyed this proof-of-concept. The whole working sample application is available on GitHub.