Статьи

Отображение прогресса запуска приложения 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 следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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.Valve . Valve похож на фильтр сервлетов, но он является частью архитектуры Tomcat. Tomcat объединяет несколько клапанов самостоятельно для обработки различных функций контейнера, таких как SSL, кластеризация сеансов и обработка X-Forwarded-For . Также Logback Access использует этот API, так что я не чувствую себя таким виноватым. Клапан выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
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 для этого!

1
2
3
if (factory instanceof TomcatEmbeddedServletContainerFactory) {
    ((TomcatEmbeddedServletContainerFactory) factory).addContextValves(new ProgressValve());
}

Заказной клапан оказался отличной идеей, он сразу начинается с Tomcat и довольно прост в использовании. Однако вы могли заметить, что мы никогда не прекращаем обслуживать loading.html даже после запуска нашего приложения. Это плохо. Существует несколько способов, которыми Spring-контекст может сигнализировать инициализацию, например, с помощью ApplicationListener<ContextRefreshedEvent> :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
@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 отменяет регистрацию:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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 чтобы получать уведомления о каждом создаваемом и инициализированном bean-компоненте ( полный исходный код ):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 ( полный исходный код ):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 конечных точек привода /info Health и /info чтобы приложение выглядело так, как будто оно было отключено во время запуска. Все остальные запросы, кроме init.stream показывают знакомый init.stream . /init.stream особенный. Это отправленная сервером конечная точка событий, которая будет выдавать сообщение каждый раз при инициализации нового компонента (извините за стену кода):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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);
    }
}

Это означает, что мы можем отслеживать ход запуска контекста приложения Spring, используя простой интерфейс HTTP (!):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ 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 ) при инициализации каждого отдельного имени компонента. Имея такой удивительный инструмент, мы создадим более надежную ( реактивную — там, я сказал) страницу loading.html .

Необычный прогресс

Сначала нам нужно определить, какие bean-компоненты Spring представляют какие подсистемы , компоненты высокого уровня (или, возможно, даже ограниченные контексты ) в нашей системе. Я закодировал это в HTML, используя пользовательский атрибут data-bean :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<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 для отражения изменений состояния модуля:

1
2
3
4
5
6
7
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. Когда все компоненты загружены, Observable завершается на стороне сервера, и SSE генерирует event: complete , давайте разберемся с этим:

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

Поскольку интерфейс запускается при запуске контекста приложения, мы можем просто перезагрузить текущую страницу. На тот момент наш ProgressValve уже сам не зарегистрировался, поэтому при перезагрузке откроется настоящее приложение, а не местозаполнитель loading.html. Наша работа выполнена. Кроме того, я подсчитываю, сколько запущено bean-компонентов, и, зная, сколько всего bean-компонентов (я жестко закодировал их в JavaScript, простите меня), я могу рассчитать прогресс при запуске в процентах. Изображение стоит тысячи слов, пусть этот скринкаст покажет вам результат, которого мы достигли:

Последующие модули хорошо запускаются, и мы больше не смотрим на ошибку браузера. Прогресс, измеряемый в процентах, делает весь процесс запуска очень плавным. И последнее, но не менее важное, когда приложение запускается, мы автоматически перенаправляемся. Надеюсь, вам понравилось это доказательство концепции, на GitHub доступно все рабочее приложение .