4. Jänner 2022 von Gabor Meißner
Aller Anfang ist schwer: Ansätze für Green Software-Engineering
Dass alle Bereiche von Gesellschaft und Wirtschaft Verantwortung für einen nachhaltigen Umgang mit unseren Ressourcen tragen, muss heute niemandem mehr erklärt werden. Das gilt auch für die Softwareentwicklung. In diesem Blog-Beitrag zeige ich euch einige Ansätze für die alltägliche Entwicklung von Java-basierten Serveranwendungen, die einen möglichst sparsamen Umgang mit Ressourcen zum Ziel haben.
Effiziente Algorithmen verwenden
Die gute Nachricht zuerst: Für uns Informatikerinnen und Informatiker ist Effizienz eines der wichtigsten Themen, mit denen wir uns seit dem Studium beschäftigen. Wir sind also gut darin ausgebildet, effiziente Problemlösungen zu erkennen und anzuwenden. Die schlechte Nachricht: Die Komplexität der Softwareentwicklung ist mittlerweile so groß, dass es nicht immer einfach ist, alle Auswirkungen zu erkennen und zu überblicken, insbesondere bei der Verwendung von Bibliotheken (siehe Log-Beispiel). Eine gute Strategie ist meiner Meinung nach, sich so viel wie möglich auszutauschen und dies auch “institutionalisiert” zu tun, das heißt, durch Code Reviews, Pair und Mob Programming, den Besuch von Konferenzen etc.
Natürlich ist Merge Sort schneller als Bubble Sort. Im Alltag spielt das Schreiben von Sortieralgorithmen sicher eine untergeordnete Rolle, trotzdem schreiben wir viel Code, der mal effizient und mal weniger effizient ist. In der Regel gibt es leistungsfähige Standardbibliotheken von Java selbst, Apache oder Guava, die uns Standardaufgaben abnehmen und effizient arbeiten.
Ansonsten sollte man gerade bei Operationen auf Mengen und Listen frühzeitig auf starke Filter setzen und so Operationen auf Objekten vermeiden, die später sowieso weggeworfen werden:
fun List<Product>.filterActiveProductsAndMapImages(): List<Product> {
this.map { it.mapImageFormat() }.filter { it.isActive }
}
Im Prinzip sind diese Techniken schon lange bekannt und effizientes Entwickeln war schon wichtig, bevor Green Software Engineering wichtig wurde.
Aber auch effiziente Algorithmen sollten nicht unnötig oft ausgeführt werden. In Zeiten von replizierten Microservices sollten “teure” Tasks nicht einfach von jeder Instanz ausgeführt werden, wenn es auch eine Instanz kann. Im Kubernetes-Umfeld bieten sich dafür beispielsweise Jobs an.
CPU-Last minimieren
Der zweite Punkt ist vielleicht nicht so klar. Die CPU-Auslastung von Webanwendungen hängt in der Regel von den zu verarbeitenden Anfragen ab. Je häufiger und umfangreicher diese Anfragen sind, desto höher ist die CPU-Auslastung. Wenn gerade keine Anfragen eingehen, sollte die Auslastung nahe 0 sein. Neben den Requests an die Webanwendung können auch CRON-Jobs ausgeführt werden, die ebenfalls Last erzeugen.
Wir sollten einen kurzen Blick auf die Rechenleistung werfen, die für die Beantwortung von Requests benötigt wird. Beim Austausch von Objekten zwischen Client und Webanwendung ist unter anderem die Serialisierung und Deserialisierung dieser Objekte interessant, da es hier große Unterschiede zwischen verschiedenen Frameworks und Protokollen geben soll (siehe Vergleich von Gson, Jackson und Moshi und Vergleich von Jackson für Kotlin und Kotlinx Serialization). So sind beispielsweise textbasierte Protokolle per se relativ langsam im Vergleich zu binären Protokollen und auch das populäre Jackson-Framework gilt als nicht CPU-freundlich (siehe Alternativen wie gson oder kotlinx).
Weiterhin sollte auf effiziente Datenbank- oder Datenquellenanbindungen geachtet werden. Dies betrifft sowohl die Speicherung als auch den Zugriff auf die Daten. Beim Zugriff auf die Daten spielen auch Fragen der Serialisierung und Deserialisierung eine wichtige Rolle. Ebenso wichtig kann das Caching von Daten sein. Dies erhöht zwar den Speicherverbrauch, reduziert aber bei geschickter Strategie die CPU-Auslastung.
Microframeworks benötigen in verschiedenen Szenarien (Start, Leerlauf, Beantwortung von Requests) weniger CPU-Kapazität als herkömmliche Webframeworks (siehe Review of Microservices Frameworks).
Netzwerk-Last minimieren
Anfragen an eine Web-Anwendung bedeuten in der Regel, dass Daten über das Internet gesendet werden, und dies sollte so selten und so kurz wie möglich geschehen. Die Bedeutung von Binärprotokollen für den Austausch wurde bereits kurz angesprochen. Das Hauptaugenmerk sollte jedoch darauf liegen, jede Anfrage kritisch zu prüfen. Es sollten nur die Daten an den Client gesendet werden, die dieser auch wirklich benötigt. Wenn andere Dienste angebunden sind, sollte natürlich auch die Webanwendung nur die Daten anfordern und erhalten, die sie tatsächlich benötigt.
Das klingt alles sehr schön und sollte soweit auch klar sein. In der Praxis ist das aber oft schwierig, da andere Dienste vielleicht viele Daten senden, die für unsere Use Cases gar nicht relevant sind und wir keinen Einfluss auf die entsprechende API haben. Eine Alternative, die sich hin und wieder anbietet, ist die Verwendung von GraphQL. Hier kann man explizit die Daten abfragen, die auch benötigt werden und sich eventuell sogar ein aufwendiges Mapping sparen.
adesso arbeitet in vielen eCommercetools-Projekten mit commercetools zusammen, die ein solches GraphQL anbieten. Hier ein kurzer Vergleich, wie viel Daten man einsparen kann, wenn man eine Kategorie mit GraphQL abfragt (um den Beitrag nicht unnötig lang werden zu lassen, die REST-Variante hat über 80 Zeilen):
Anfrage
{
categories(where: "slug(de-DE = \"accessoires-test\")") {
results {
id
slug(locale: "de-DE")
name(locale: "de-DE")
description(locale: "de-DE")
parent {
slug(locale: "de-DE")
}
}
total
}
}
Antwort
{
"data": {
"categories": {
"results": [{
"id": "099f7ea3-6ae6-4a6e-a2dd-6489169887c8",
"slug": "accessoires-test",
"name": "accessoires-test",
"description": null,
"parent": {
"slug": "adesso-shop"
}
}],
"total": 1
}
}
}
Ähnlich verhält es sich mit binären Daten wie Bildern. Diese müssen in der richtigen Auflösung und Qualität effizient heruntergeladen werden. Hier bieten sich Content Delivery Networks an, die diese Aufgabe gut erfüllen.
Skalierbarkeit
Skalierbarkeit wird oft unter dem Gesichtspunkt betrachtet, dass hohe Zugriffszahlen auftreten, die sonst unüblich sind. In der Regel ist man aber damit zufrieden, dass die Anwendung ausreichend schnell reagiert. Im Sinne von Green Software Engineering muss Skalierbarkeit aber auch in die andere Richtung betrachtet werden. Man sollte sich (auch aus Kostengründen) fragen: Sind zwei Instanzen eines Microservices wirklich notwendig, wenn gerade niemand darauf zugreift? Reicht dann eine Instanz oder gar keine?
Das Thema Skalierbarkeit wurde mit dem Aufkommen von Kubernetes (für die breite Masse) in vielerlei Hinsicht gelöst. Hier kann man ganz trivial eine minimale und eine maximale Anzahl von Pods (Instanzen) pro Service definieren, die dann automatisch skalieren. Entscheidend bei dieser Technologie ist jedoch die Startzeit eines Microservices und da sind die auf “klassischen” Webframeworks basierenden Anwendungen leider recht langsam. Mit diesen Technologien muss mit einer Startzeit von über zehn Sekunden gerechnet werden, in vielen Fällen sogar deutlich mehr. Das ist mit dem Skalierungsgedanken nicht wirklich vereinbar. Natürlich spricht immer noch viel für den Einsatz von Spring Boot und Co., aber in diesem Punkt sind andere Frameworks wie Quarkus, Micronaut und Ktor überlegen (siehe Review of Microservices Frameworks). Durch Ansätze wie die Verwendung von GraalVM kann dieses Problem in Spring Boot umgangen und Startzeiten im Millisekundenbereich erreicht werden (siehe Running Spring Boot apps as GraalVM Native Images).
Caching
Webanwendungen verbrauchen zum Beispiel viel Speicher, wenn sie Daten zwischenspeichern müssen. Caching ist aber grundsätzlich zu empfehlen, da es verhindert, dass gleiche Berechnungen, Datenbank- oder API-Anfragen ständig wiederholt werden müssen. Caching, das sich über mehrere Instanzen eines Microservices erstreckt, ist hier der beste Weg und würde (insbesondere bei Skalierung) noch stärker vermeiden, dass Berechnungen mehrfach durchgeführt werden.
Ansonsten ist anzumerken, dass Microframeworks wie Quarkus und Ktor deutlich günstiger im Speicherverbrauch sind als beispielsweise der Platzhirsch Spring Boot.
Reaktiv entwickeln
Die Java-Community hat sich vor einigen Jahren für eine nicht-blockierende Entwicklung entschieden. Der Grund dafür war, dass andere Technologiefamilien wie NodeJS eine bessere Performance bei der Verarbeitung von Requests zeigten, was vor allem auf den nicht-blockierenden Ansatz zurückzuführen war. Gleichzeitig benötigen asynchrone Frameworks mehr Speicher und die “Organisation” der nicht-blockierenden Prozesse gilt als aufwändig und damit nicht als ressourcenschonend. Darüber hinaus hängt der Nutzen stark von der Dauer der Blockierung eines Threads ab (fünf Aspekte reativer Programmierung).
Trotzdem sollte man auch im Java- oder JVM-Umfeld reaktiv entwickeln, da dadurch deutlich schnellere Antwortzeiten möglich sind (siehe hier) und dies dann in der Regel auch Ressourcen spart (weniger Rechenzeit, weniger Speicherverbrauch, weniger Wartezeit für den Client).
Ein großer Nachteil der reaktiven Programmierung in Java ist jedoch die Schreib- und Lesbarkeit des Codes. Viel Code wird mit Funktionen beschrieben, die oft schwer verständlich sind und leicht den Blick auf das Wesentliche, nämlich die Geschäftslogik, verstellen. Der Empfehlung des Autors kann man sich anschließen, Kotlin-Koroutinen wirken deutlich einfacher als die gängigen Java-Bibliotheken.
Zusammenfassung
Die Wahl der Technologie und die Programmierung spielen eine wichtige Rolle für den ökologischen Fußabdruck einer Webanwendung. Anhand von Beispielen haben wir gesehen, wie wir Ressourcen sparen können, indem wir auf Microframeworks, moderne Serialisierungsbibliotheken, skalierbare Architekturen, reaktive Entwicklung und GraphQL setzen. Die Liste ist bei weitem nicht vollständig und es gibt immer noch gute Gründe für Spring Boot, Jackson und REST APIs.