Heiko Maaß Software-Entwicklung und Elektronische Musik

Schrittweise Migration von Spring MVC auf Spring Webflux (Teil 2)

Wie im letzten Post beschrieben, hatten wir einen Plan für die kommenden Änderungen bei der kompletten Umstellung auf Webflux. Der Plan ist aufgegangen, jedoch kam es zu einigen unerwarteten Aufwänden. Die beiden Wesentlichen möchte ich kurz zusammenfassen:

Unerwartete Aufgaben: Ersatz der ThreadLocals durch Context

Nachdem wir die spring-boot-starter-web Abhängigkeiten komplett entfernt hatten, mussten wir feststellen, dass der vorherige Workaround für das Übergeben der ThreadLocals via Schedulers.onScheduleHook nicht mehr zuverlässig funktionierte. Von daher mussten wir (wie in einem späteren Schritt geplant) alle vorhandenen ThreadLocals ersetzen und explizit via Mono.contextWrite setzen und über ContextView.getOrDefault auslesen:

Schreiben in den Contexts im WebFilter:

webFilterChain.filter(serverWebExchange)
.contextWrite(context -> context.put(SomeClass.class, someObject));

Lesen aus dem Context:

Mono.deferContextual(context -> Mono.just(context.getOrDefault(SomeClass.class, defaultValue)));

Unerwartete Aufgaben: Ersatz von SpringFox

Des Weiteren war die aktuell eingesetzte Library (springfox 2.9.2) für die Generierung der Swagger-Dokumentation nicht mehr kompatibel. Aus mehreren Gründen hat uns springfox 3.0.0 nicht zugesagt, von daher haben wir es durch springdoc-openapi ersetzt, und alle Swagger 1.1 Annotationen durch Swagger 2.1 ersetzt.

Positiv überraschendes Verhalten

Wenn ein Aufrufer einen Request an den Service abbricht, bricht Spring Webflux alle in diesem Request ausgelösten Subrequests kaskadierent ab. In Spring MVC ist dieses Verhalten nicht möglich, da diese Requests komplett entkoppelt sind. Es macht Sinn, diesen Abbruch explizit zu loggen.

  webClient.post()
    .uri(url.toString())
    .. // weitere Konfigurationen des Webclients
    .doOnCancel(() -> {
                // log that parent request was cancelled
            })

Fazit

Die Umstellung auf Webflux kostet insbesondere dann Zeit, wenn viel mit ThreadLocals gearbeitet wurde und ältere Libraries eingesetzt werden, die nicht kompatibel mit dem Framework sind. Zudem darf auch der Aufwand für den Wissensaufbau und -transfer im Team nicht unterschätzen. Nichtsdestotrotz hat sich der Umstieg aus Performance und Wartungssicht für diesen Anwendungsfall gelohnt. Wo früher eine Parallelisierung von Anfragen mühsam und fehleranfällig orchestriert werden musste, wird dies durch die reaktive Programmierweise vom Framework abgenommen.

Schrittweise Migration von Spring MVC auf Spring Webflux

Motivation für Webflux

Spring Webflux bietet mit seiner reaktiven Arbeitsweise ein Framework für Webservices, die viele parallele, I/O-lastige Tätigkeiten ausführen müssen. Im aktuellen Projekt haben wir so einen Service: Er fragt viele andere WebServices parallel an und kombiniert die Antworten zu einer übergreifenden Antwort. Als wir vor ein paar Jahren mit dem Service begonnen hatten, hatten wir für die Request-Parallelisierung sog. CompletableFutures eingesetzt, die mit dem JDK-8 eingeführt worden sind. Sie vereinfachen die Umsetzung von parallelen Aufgaben, haben aber einen entscheidenden Nachteil: Pro ausgehenden Request wird immer ein eigener Thread benötigt. Je mehr Subservices angebunden und parallel aufgerufen werden, desto mehr parallel laufende Threads werden benötigt. Das ist nicht ressourceneffizient, da jeder Thread zusätzlich RAM für seinen eigenen Stack benötigt.

Spring Webflux kommt mit einem asynchronen HttpClient, dem sog. WebClient. Dieser arbeitet intern mit einem sehr kleinem Pool von Threads und informiert den Aufrufer asychron, sobald eine Http-Antwort verarbeitet wurde. Der nachfolgende Code muss ebenfalls “reaktiv” werden, d.h. er arbeitet nicht mehr mit dem eigentlichen Rückgabewert, sondern mit einem sog. Publisher, der irgendwann den Wert an seinen Subscriber propagiert. Spring Webflux bietet dazu die Publisher-Implementierungen Mono (0-1 Wert) und Flux (0-n Werte) an.

Herausforderungen bei der Migration

Die bisherigen synchronen Aufrufe müssen durch asychrone Aufrufe ersetzt werden, dazu gehört auch das komplette Exception-Handling. Das FAQ des Project Reactor liefert eine gute Hilfe für die Migration Durch den Wegfall der expliziten, gebundenen Threads funktionieren sämtliche Konstrukte nicht mehr, die auf ThreadLocals angewiesen sind. Dazu gehören u.a. die SpringMVC-Features wie “RequestScoped Beans”, oder auch der ThreadContext von Log4J2. Auch bisherige Servlet-Filter und ClientHttpRequestInterceptor greifen nicht mehr. Zudem müssen sämtliche Spring-WebMVC-Integrationstests und Cloud Contract Tests auf die Webflux-API angepasst werden. Und damit nicht genug: Auch die Spring Security-Configuration muss ebenfalls angefasst werden.

In der Summe also ein sehr großer Rewrite. Da der Service bereits in Produktion ist und parallel noch zwei weitere Teams an dem Service arbeiten, konnte die Migration von Spring MVC zu Spring Webflux nur in (stets mergebaren) Teilschritten erfolgen.

Einzige Option: Schrittweises Vorgehen

Hier unsere durchgeführten Schritte. Jeder Schritt wurde in einem Sprint umgesetzt, mit abschliessendem Merge auf den Master-Branch. Die ersten drei Schritte sind schon umgesetzt, der vierte eingeplant.

1. Umstellung aller verwendeten RestClient auf blockierende WebClients

Alle RestTemplates wurden durch WebClients ersetzt, die blockierend auf die Antwort warten. Dazu mussten alle ClientHttpRequestInterceptor in ExchangeFilterFunctions umgeschrieben werden. Zudem musste ein sog. ClientHttpConnector konfiguriert werden, der die Brücke zu der eigentlichen Http-Client-Implementierung bildet (z.B. Reactor-Netty oder Http-Commons HttpClient 5). Hier gab es einigen Aufwand, unsere angepasste Retry-Mechanik des vorherigen Http-Clients nachzubauen.

2. Verprobung eines kleinen reaktiven Controllers

Alle Klassen unterhalb eines kleinen Controllers werden reaktiven Code umgestellt. Der in der Aufrufhierarchie verwendete WebClient ist nun nicht mehr blockierend.

Damit die Schichten oberhalb der Controller-Klassen nicht angepasst werden müssen (Servlet-Filter, Spring Security, Spring MVC-Tests, Cloud Contract Tests), wurde im Controller selbst der reaktive Aufruf initiiert und mit einem expliziten block-Aufruf wieder eingefangen, so dass er weiterhin in das SpringMVC-Modell “passt”. Zudem mussten alle ThreadLocals bei einem Reactor- und WebClient-Threadwechsel kopiert werden. Erstes löst man mit der Registrierung eines sog. ScheduleHook mittels der statischen Schedulers#onScheduleHook Methode.

Schedulers.onScheduleHook("thread-local-compatibilty", threadLocalsCopyingHook);

Für den WebClient-Threadwechsel wurde der gleiche Kopiermechanismus in einer eigenen ExchangeFilterFunction realisiert.

3. Umstellung aller Controller auf (internen) reaktiven Code

Alle WebClients werden auf reaktiven Code umgestellt und RequestScoped-Beans entfernt. Wie beim ersten Verprobungs-Controller subscriben und blockieren alle Controller den reaktiven Aufruf selber, um die Kompatibilität mit aufrufenden Klassen aufrecht zu erhalten. Zudem wurden alle eigen gebauten CompletableFuture-Parallelisierungen entfernt. Für uns vorteilhaft war die bereits bestehende funktionale Architektur des Services (Kein Zustand, Objekte waren alle Immutable). Fachlicher Code musste kaum angefasst werden, mit Aussnahme des expliziten Durchreichens der Informationen aus eines abgebauten RequestScoped-Bean.

4. Letzter Schritt: Rückbau SpringMVC

Als letzten Schritt, der noch offen ist, sollen die reaktiven Typen (Mono oder Flux) in die Signatur der Controller-Methoden aufgenommen und der block-Aufruf entfernt werden. Zudem wird die Annotation @SpringBootTest durch @WebFluxTest ersetzt, sowie @EnableWebSecurity durch @EnableWebFluxSecurity.

Zusammenfassung

Eine Migration eines bestehenden Services von klassischem SpringMVC auf Spring Webflux ist mit einigem Aufwand verbunden, da alle Schichten betroffen sind und bestimmte Mechanismen (z.B. RequestScoped-Beans) nicht mehr funktionieren. Die Migration konnte in unserem Fall jedoch in Teilschritten und schichtweise erfolgen. Von Vorteil war die bereits die funktionale Architektur des Services (Immutable-Objects, keinen Zustand), so dass nur wenig fachlicher Code umgeschrieben werden musste. Wir versprechen uns davon einen deutlich ressourceneffizienteren WebService, den wir auch über auschliesslich über die CPU-Last skalieren können.

TCP- und Http-Retries mit Apache Commons HttpClient (Teil 2)

Nachdem die Antwort des aufgerufenen Services erfolgreich angekommen ist, kommt die ServiceUnavailableRetryStrategy ins Spiel. Über dieses Interface kann der Apache HttpClient instruiert werden, den Request zu wiederholen.

Die DefaultServiceUnavailableRetryStrategy wiederholt einen Request bei Erhalt des Statuscodes 503 (Service Unavailable). Die maximale Anzahl der Retries sowie das Delay kann konfiguriert werden. Neben einem Retry bei HTTP 503 empfiehlt sich im Kubernetes-Umfeld auch ein Retry bei Erhalt eines HTTP 502 (Bad-Request), der auftreten kann, wenn der aufrufende Service neu deployed wurde, und noch eine persistente Connection zum bereits abgebauten Pod besteht. Hierzu muss man das ServiceUnavailableRetryStrategy Interface selber implementieren. Jedes Retry sollte geloggt werden, um beobachten zu können, wie häufig so Retry stattfindet.

Konfiguration im HttpClient

var httpClient = HttpClientBuilder.create()
  .setServiceUnavailableRetryStrategy(new DefaultServiceUnavailableRetryStrategy(3, 500))
  .build();

Zusammenfassung

  • Retries auf TCP-Ebene: Konfiguration eines HttpRequestRetryHandlers
  • Retries auf HTTP-Ebene: Konfiguration einer ServiceUnavailableRetryStrategy
  • Für den Einsatz in Kubernetes empfiehlt es sich, von der Standardimplementierung abzuweichen und eigene Retry-Konditionen zu definieren.

TCP- und Http-Retries mit Apache Commons HttpClient (Teil 1)

Wenn ein Netzwerk-Request eines Webservices auf einen anderen Webservice scheitert, kann es sinnvoll sein, den Request zu wiederholen. Die Umsetzung sollte dabei wenn möglich auf Ebene der verwendeten HTTP-Library erfolgen, um Mehrfachserialisierungen zu vermeiden. Ansonsten kann das bei besonders großen Objekten viel CPU-Rechenleistung kosten.

Der Apache HttpClient bietet die Konfiguration von Retries auf zwei Ebenen an: Zum einen gibt es den HttpRequestRetryHandler, zum anderen die ServiceUnavailableRetryStrategy. Leider wird aus dem Klassennamen nicht klar, worin sie sich unterscheiden.

In diesem Blogpost sehen wir uns zunächst den HttpRequestRetryHandler an.

Retries auf TCP-Ebene: HttpRequestRetryHandler

Bei einem TCP-Verbindungsaufbau kann es zu vielen Fehlerfällen kommen. Beispielsweise wird eine NoHttpResponseException geworfen, wenn der Server eine sog. “persistent connection” geschlossen hat, die der Client jedoch noch als nutzbar betrachtet. Wenn der Server eine initiale TCP-Verbindung nicht rechtzeitig annimmt, kommt es zur einer ConnectTimeoutException, bei einem Read-Timeout dagegen zu einer SocketTimeoutException. All diese Exceptions erben von java.io.IOException. Der HttpRequestRetryHandler kann aufgrund des Klassentyps entscheiden, ob ein Retry ausgeführt werden soll.

Auszuschließende Exceptions

Die Standardimplementierung DefaultRequestRetryHandler definiert eine Liste von Exceptions für die kein Retry stattfinden soll:

  • java.io.InterruptedIOException (Superklasse der o.g. ConnectTimeoutException),
  • java.net.ConnectException,
  • java.net.UnknownHostException,
  • javax.net.ssl.SSLException

In einem Kubernetes-basiertem Projekt hat es sich als hilfreich erwiesen, diese Liste anzupassen. Auch bei einer java.net.ConnectException und java.io.InterruptedIOException kann ein Retry Sinn machen, da Kubernetes Services über eine virtuelle IP-Adresse angesprochen werden und hinter der IP-Adresse ein IP-Tables basiertes Routing passiert. Um die Antwortzeiten eines Services nicht zu gefährden, sollte man durch Read-Timeouts fehlgeschlagene NICHT wiederholen, somit sollte die java.net.SocketTimeoutException in die Liste der auszuschließenden Exceptions aufgenommen werden.

  • java.net.SocketTimeoutException
  • java.net.UnknownHostException
  • javax.net.ssl.SSLException

Um diese Exception-Liste anzupassen muss eine Subklasse von DefaultRequestRetryHandler erstelle.

Keine Retries bei idempotenten Retries

Retries sollten nur dann ausgeführt werden, wenn der Request als idempotent betrachtet wird (mehrfach ausführbar mit dem gleichen Endergebnis). Ein POST-Request ist normalerweise nicht idempotent, in Sonderfällen kann aber auch er wiederholt werden, wenn die aufzurufende Schnittstelle eher einen Remote-Procedure-Call abbildet.

Konfiguration im HttpClient

var httpClient = HttpClientBuilder.create()
  .setRetryHandler(
    new DefaultRequestRetryHandler(
       1,    // Max. Anzahl der Retries

       false // Retries auch bei idempotenten Requests

      )
   ).build();

Im nächsten Post schauen wir uns die ServiceUnavailableRetryStrategy an.