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.
| Szenario | Total | OK | KO | %KO | Cnt/s | Min | 50 pct | 75 pct | 95 pct | 99 pct | Max | Mean | StdDev |
| 1 Nutzer | 1 | 1 | 0 | 0% | 0.007 | 25670 | 25670 | 25670 | 25670 | 25670 | 25670 | 25670 | 0 |
| 2 Nutzer | 2 | 2 | 0 | 0% | 0.015 | 28453 | 28580 | 28644 | 28694 | 28704 | 28707 | 28580 | 127 |
| 3 Nutzer | 3 | 3 | 0 | 0% | 0.022 | 32581 | 33020 | 33172 | 33293 | 33317 | 33323 | 32975 | 305 |
| 4 Nutzer | 4 | 4 | 0 | 0% | 0.029 | 39690 | 40244 | 40433 | 40487 | 40497 | 40500 | 40170 | 319 |
In der Variante mit Streaming ergab sich folgende Tabelle:
| Szenario | Total | OK | KO | %KO | Cnt/s | Min | 50 pct | 75 pct | 95 pct | 99 pct | Max | Mean | StdDev |
| 1 Nutzer | 1 | 1 | 0 | 0% | 0.007 | 25094 | 25094 | 25094 | 25094 | 25094 | 25094 | 25094 | 0 |
| 2 Nutzer | 2 | 2 | 0 | 0% | 0.014 | 28789 | 28791 | 28791 | 28792 | 28792 | 28792 | 28791 | 2 |
| 3 Nutzer | 3 | 3 | 0 | 0% | 0.021 | 32847 | 33124 | 33144 | 33159 | 33162 | 33163 | 33045 | 141 |
| 4 Nutzer | 4 | 4 | 0 | 0% | 0.028 | 42015 | 44560 | 44610 | 44661 | 44671 | 44674 | 43952 | 1119 |
Wenn man jetzt die Mittelwerte ohne Streaming und die Mittelwerte mit Streaming nebeneinander legt, zeigen sich kaum Unterschiede.
| Szenario | Mean ohne Streaming | Mean mit Streaming |
| 1 Nutzer | 25670 | 25094 |
| 2 Nutzer | 28580 | 28791 |
| 3 Nutzer | 32975 | 33045 |
| 4 Nutzer | 40170 | 43952 |

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.

Schreibe einen Kommentar