StreamResults mit OpenApi und SpringBoot

von

In einem Kundenprojekt werden Dokumentationsbögen (Datensatz für eine Dialysebehandlung) von Dialysezentren eingelesen und einer Qualitätsanalyse unterzogen. Die quartalsweise Abrechnung gegenüber den Zentren geschieht auf der Anzahl der verarbeiteten Dokumentationsbögen pro Quartal und Zentrum.

Für die Abrechnung wurde eine extra Schnittstelle implementiert. Im ersten Entwurf wurden die Daten intern abgefragt, zusammengefasst und als fertige CSV-Datei zurück gegeben. Das Controlling wollte eine CSV-Datei, um diese via Excel intern für weitere Auswertungen nutzen zu können.

Für einen zweite Implementierung sollte untersucht werden, ob eine Implementierung auf Streaming-Basis mit der vorhandenen Architektur umsetzbar ist und ob das Vorteile für den Anwender bringt.

Verwendete Frameworks und Architektur

Die Anwendung läuft mit Spring Boot Version 3 und einer Elasticsearch-Installation als Backend. Die Account-Informationen werden von der Anwendung über eine REST-Schnittstelle abgefragt. Diese Schnittstelle wurde in OpenApi 3.0.1 formuliert. Aus der Schnittstellen-Beschreibung werden per maven und dem OpenApi-Generator für Spring (https://openapi-generator.tech/docs/generators/spring/) die Server-Stubs generiert.

Vorgehen

Ausgangslage

Die ursprüngliche Definition der Schnittstelle in openapi.yml sah wie folgt aus:

/v2/dokumentationsboegen:
  get:
    tags:
      - accounting-controller
    summary: liefert alle verarbeiteten Dokumentationsbögen
    description: liefert alle verfügbaren Quartale (für die übergebenen BSNRs und Quartale)
    operationId: dokumentationsboegen
    parameters:
      - name: quarter
        in: query
        description: Welche Quartale
        required: false
        schema:
          type: array
          items:
            type: string
      - name: bsnr
        in: query
        description: Welche BSNRs
        required: false
        schema:
          type: array
          items:
            type: string
    responses:
      '200':
        description: Dokubögen im System
        content:
          text/csv:
            schema:
              type: string
      '422':
        description: keine gültigen Quartale oder BSNRs
        content:
          application/json:
            schema:
              type: string

Die Erzeugung der Stubs wurde im pom.xml definiert:

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.1.0</version>
    <executions>
        <execution>
            <id>generate interface</id>
            <phase>process-resources</phase>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <inputSpec>
                    ${project.build.directory}/classes/openapi/openapi.yml</inputSpec>
                <generatorName>spring</generatorName>
                <configOptions>
                    <modelPackage>de.akquinet.tas.qmd.rest.model</modelPackage>
                    <apiPackage>de.akquinet.tas.qmd.rest.api</apiPackage>
                    <interfaceOnly>true</interfaceOnly>
                    <useSpringBoot3>true</useSpringBoot3>
                </configOptions>
            </configuration>
        </execution>
    </executions>
</plugin>

Die Implementierung im Controller holte die notwendigen Daten aus ElasticSearch, erzeugt mittels OpenCSV das Ergebnis als String und schrieb diesen in die Response.

List accountingBeans = getAccountingBeansFromElasticSearch(...);
String content = convertToCSVString(accountingBeans);
return ResponseEntity.ok()
          .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=accounting.csv")
          .body(content);

Endergebnis

Das sollte jetzt durch ein auf Streaming basierendes Interface umstellt werden. SpringBoot stellt dazu das Interface StreamingResponseBody  zur Verfügung. Die OpenApi-Beschreibung dahin anzupassen, stellte sich jedoch als etwas schwierig heraus. Nach einigen Fehlschlägen erwies sich die Steuerung der Erzeugung der Stubs via Type- und Import-Mapping der richtige Weg.

So wurde in der Schnittstellenbeschreibung nur das Format der Response ergänzt. Die Angabe „streaming“ beinhaltet dabei keine(!) Semantik, sondern ist nur eine „Markierung“.

responses:
  '200':
    description: Dokubögen im System
    content:
      text/csv:
        schema:
          type: string
          format: streaming

und in der Generierung der Stubs wurde dieses Format speziell behandelt:

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.1.0</version>
    <executions>
 
        <execution>
            <id>generate interface</id>
            <phase>process-resources</phase>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <inputSpec>
                    ${project.build.directory}/classes/openapi/openapi.yml</inputSpec>
                <generatorName>spring</generatorName>
                <typeMappings>
                    <typeMapping>
                        string+streaming=StreamingResponseBody
                    </typeMapping>
                </typeMappings>
                <importMappings>
                    <importMapping>
                        StreamingResponseBody=org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
                    </importMapping>
                </importMappings>
                <configOptions>
                    <modelPackage>de.akquinet.tas.qmd.rest.model</modelPackage>
                    <apiPackage>de.akquinet.tas.qmd.rest.api</apiPackage>
                    <interfaceOnly>true</interfaceOnly>
                    <useSpringBoot3>true</useSpringBoot3>
                </configOptions>
            </configuration>
        </execution>
    </executions>
</plugin>

Dann musste natürlich noch die Implementierung angepasst werden:

List accountingBeans = getAccountingBeansFromElasticSearch(...);
StreamingResponseBody stream = out -> writeBeansToOutStream(accountingCsvBeans, out);
 
return ResponseEntity.ok()
    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=accounting.csv")
    .body(stream);

Benchmarks

Jetzt war es natürlich interessant, beide Varianten bezüglich auf ihre Laufzeit zu untersuchen. Dafür wurde die Anwendung als Docker-Container gestartet, Testdaten für 5 Betriebsstätten mit jeweils 5000 Bögen generiert und eingespielt und dann die Account-Daten abgerufen. Zuerst mit der alten Schnittstellen und dann mit der neuen. Da das Controlling nur einmal im Quartal und von wenigen Personen durchgeführt wird, wird der Lasttest mit maximal 4 Nutzer gleichzeitig durchgeführt.

Der Benchmark wurde mit Gatling https://gatling.io/) umgesetzt. Er umfasst 4 Szenarien mit 1, 2, und 4 Nutzern, die parallel die Accounting-Daten zogen. Abgerufen wurden die CSV-Daten für fünf Betriebsstätten und zwei Quartale. Die CSV-Datei hat eine Größe von 7,1 MB. Der Benchmark liefen komplett auf einem Laptop mit 32GB Hauptspeicher und einer Intel  i9 CPU mit 2.60GHz.

In der ersten Variante ohne Streaming lieferte Gatling folgende Tabelle. Anmerkung: Wer Gatling noch nicht kennt, unter https://gatling.io/2022/01/my-first-gatling-test-report/ findet sich im Abschnitt „reports“ eine gute Beschreibung der Tabelle.

SzenarioTotalOKKO%KOCnt/sMin50 pct75 pct95 pct99 pctMaxMeanStdDev
1 Nutzer1100%0.007256702567025670256702567025670256700
2 Nutzer2200%0.01528453285802864428694287042870728580127
3 Nutzer3300%0.02232581330203317233293333173332332975305
4 Nutzer4400%0.02939690402444043340487404974050040170319

In der Variante mit Streaming ergab sich folgende Tabelle:

SzenarioTotalOKKO%KOCnt/sMin50 pct75 pct95 pct99 pctMaxMeanStdDev
1 Nutzer1100%0.007250942509425094250942509425094250940
2 Nutzer2200%0.014287892879128791287922879228792287912
3 Nutzer3300%0.02132847331243314433159331623316333045141
4 Nutzer4400%0.028420154456044610446614467144674439521119

Wenn man jetzt die Mittelwerte ohne Streaming und die Mittelwerte mit Streaming nebeneinander legt, zeigen sich kaum Unterschiede.

SzenarioMean ohne StreamingMean mit Streaming
1 Nutzer2567025094
2 Nutzer2858028791
3 Nutzer3297533045
4 Nutzer4017043952

Schlußfolgerungen

Es ist also möglich, mit openapi-eigenen Mitteln für REST-Schnittstelle eine StreamingResponse zu spezifizieren. In unserem Fall ergaben sich dadurch keine Performance-Vorteile. Im Interesse einer schnelleren Rückmeldung an den Nutzer wurde dann entschieden, das Streaming beizubehalten.

Für eine architektonisch vollständige und komplett auf Streaming basierende Lösung müsste jetzt noch der Aufruf von getAccountingBeansFromElasticSearch() auf Streaming umgestellt werden. Das ist jedoch nicht der Fokus dieses Beitrags.

WordPress Cookie Hinweis von Real Cookie Banner