Preis: 9,80 €
Erhältlich ab: März 2019
Umfang: 100
Wie viele wissen, werden in der Entwicklung oft mehrere Stages verwendet, etwa dev, test und prod. Wenn man nun die Transformation in die Cloud angeht, stellt sich oft die Frage, wie viele Kubernetes-Cluster installiert werden sollen. Soll für jede Stage ein eigener Kubernetes-Cluster installiert werden, oder kann man mehrere Stages zusammenführen und sich somit Installations- und Konfigurationsaufwand sparen? Im Folgenden wird aufgezeigt, wie innerhalb eines Kubernetes-Clusters voneinander isolierte Stages erstellt werden können. Im Anschluss werden die Vor- und Nachteile mehrere Stages innerhalb eines Kubernetes-Clusters diskutiert.
Während der Entwicklung einer Anwendung werden unterschiedliche Anforderungen an die jeweiligen Stages gestellt. Während in der dev-Stage alles auf den Entwickler ausgelegt ist – dieser also viele Rechte hat, wenig Rücksicht auf andere nehmen muss und somit die Anwendung auch mal nicht funktionieren kann bzw. nicht ansprechbar sein darf –, sieht es in der prod-Stage komplett anders aus. Auf dieser Stage laufen die Anwendungen, die für die Nutzer gedacht sind. Das heißt, diese Anwendungen müssen hochverfügbar sein (Zero Downtime). Außerdem bringen diese Nutzer produktive, personenbezogene Daten mit, die nicht für jedermanns Augen gedacht sind. Mehrere Stages sind also nicht nur eine Best Practice, sondern unabdingbar.
Wenn man beim Sprung in die Cloud nun Kubernetes einsetzt, muss man fast unweigerlich Namespaces zur Gruppierung und Sortierung der Ressourcen verwenden. Die offizielle Dokumentation schreibt dazu: „Kubernetes supports multiple virtual clusters backed by the same physical cluster. These virtual clusters are called namespaces“ [1]. Kubernetes bietet hierfür ein RESTful API an. Dementsprechend findet jede Konfiguration über eine Ressource statt, diese werden in Namespaces abgelegt und gruppiert. Vorstellbar – wenn auch zu grobgranular – wäre ein Namespace pro Stage. Empfehlenswerter wäre ein Namespace pro Stage und Produkt bzw. Anwendung, vor allem wenn der Kubernetes-Cluster von mehreren Teams genutzt wird.
Pro Namespace können unterschiedliche Konfigurationen vorgenommen werden. Es ist unter anderem möglich, Quotas zu definieren. Dadurch wird der Ressourcenverbrauch (RAM und CPU) aller laufenden Pods innerhalb des Namespace gedeckelt. Den dev-Namespaces können z. B. weniger Ressourcen zur Verfügung gestellt werden als den test-Namespaces. Des Weiteren ist es möglich, auch Limits auf Anzahl von beispielsweise Pods zu setzen. So kann man die ReplicaSets bzw. AutoScaler eingrenzen. Übrigens: Sollte auf dem Namespace Quotas auf Ressourcen gesetzt werden, lohnt sich ein Blick in die LimitRanges, da ansonsten jeder Pod angeben muss, welche Ressourcen er benötigt (was aber sowieso empfehlenswert ist).
Neben Quotas können auch die Zugriffsmöglichkeiten auf Namespace-Ebene eingeschränkt werden. Dank RBAC kann feingranular pro Ressource und Zugriffsmethode definiert werden, wer (sowohl LDAP-Nutzer als auch technische Service-Accounts) was machen darf. Das gibt Entwicklern die Möglichkeit, auf der dev-Stage nahezu Admin-Rechte und ab test-Stage eingeschränkte Rechte zu haben.
Namespaces lösen zwar die ersten Probleme, aber noch nicht alle. Standardmäßig kann in Kubernetes jede Applikation auf jede andere zugreifen – auch außerhalb des Namespace. Kubernetes unterstützt Pods und Services mit einem eigenen DNS. Der Zugriff funktioniert über die Angabe des Service-Namens und gegebenenfalls des Namespace. Dies ist bei der Aufteilung in Stages – mit wenigen Ausnahmen – so nicht erwünscht. Applikationen in der dev-Stage sollen nicht Applikationen aus der test-Stage aufrufen können und vice versa. Bei Kubernetes bieten die Network Policies eine Lösung an. Um Network Policies zu verstehen, sind drei Regeln wichtig:
Wie bereits erwähnt: Gibt es keine Regel, ist der Zugriff erlaubt.
Der Zugriff wird verhindert, wenn es für das Ziel-Pod Regeln gibt, das Quell-Pod aber nicht im Ingress-Teil dieser Regel vorkommt.
Der Zugriff wird verhindert, wenn es für das Quell-Pod Regeln gibt, das Ziel-Pod aber nicht im Egress-Teil dieser Regel vorkommt.
Zusammengefasst heißt das: Über Network Policies kann man nur Zugriffe erlauben, nicht aber verbieten. In der Praxis wird in der Regel defensiv sowohl auf Quell- als auch auf Ziel-Pod eine Network Policy definiert. Eine Network Policy ist an einen Namespace gebunden und besteht aus drei Teilen:
Der Information, welche Pods innerhalb eines Namespace von den folgenden Regeln betroffen sind,
Ingress-Regeln, die die eingehende Kommunikation der Pods betreffen,
Egress-Regeln, die die ausgehende Kommunikation der Pods betreffen.
Dabei werden in der Regel die Kommunikationspartner per Labelselektion auf Pod- bzw. Namespace-Ebene ausgesucht. In unserem Fall ist also eine Regel notwendig, die erstens alle Pods innerhalb des eigenen Namespace betrifft, auf die zweitens jeder Pod innerhalb der Namespaces mit dem Label stage: dev zugreifen kann (in den Namespaces muss das entsprechende Label gesetzt sein) sowie drittens jeder Pod Pods in den anderen dev-Namespaces aufrufen kann. Eine kleine Stolperfalle gibt es noch: Bei Teil drei (Egress) muss der Kubernetes-DNS-Port 53 extra freigeschaltet werden, ansonsten können die URLs nicht mehr aufgelöst werden.
Zusammengefasst kann eine Beispielkonfiguration so aussehen wie in Listing 1 dargestellt.
Listing 1
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dev-stage
namespace: my-app-dev
spec:
# 1)
podSelector: {}
# 2)
ingress:
- from:
- namespaceSelector:
matchLabels:
stage: dev
# 3)
egress:
- to:
- namespaceSelector:
matchLabels:
stage: dev
- ports:
- port: 53
protocol: "UDP"
- port: 53
protocol: "TCP"
Eine nicht unwichtige Anmerkung zu den Network Policies: Diese funktionieren nur, wenn das installierte Network-Plug-in diese auch unterstützt, wie das z. B. bei Calico der Fall ist.
Wenn die obige Policy angewendet wird, kann man von außerhalb nicht mehr zugreifen. Das liegt darin begründet, dass die Verbindung vom Ingress-Controller, der im Namespace kube-system liegt, geblockt wird. Hierfür muss also auch eine entsprechende Regel erstellt werden. Das geschieht idealerweise über die Kombination eines Namespace- und eines PodSelectors.
Wie eben gezeigt, haben Labels in Kubernetes einen sehr hohen Stellenwert. Über Labels können Ressourcen gruppiert und selektiert werden. Unter anderem werden die Deployments/Pods mit Labels markiert, die dann in Services zusammengeführt werden. Sollten innerhalb eines Clusters mehrere Stages verwendet werden, ist es empfehlenswert, jede Ressource mit einem stage-Label zu versehen – auch, wenn das im ersten Moment gegebenenfalls etwas übertrieben erscheinen mag. Sollte diese Information später doch noch einmal benötigt werden, wird es schwer, dies nachzupflegen.
Der bisherige Ansatz bringt eine Lösung für Netzwerkisolation. Wer noch eine physikalische Trennung der CPU-/RAM-Ressourcen haben möchte, kann die Stages auch auf unterschiedliche Worker Nodes aufteilen. Schauen wir uns dazu zuerst die Kubernetes-Architektur an (Abb. 1).
Kubernetes besteht aus einem Master Node und mehreren Worker Nodes. Alle Anfragen an Kubernetes gehen über den API-Server, der auf dem Master Node installiert ist. Der Master Node überwacht die Gesundheitszustände der Worker Nodes. Des Weiteren verteilt der Master Node die Pods auf die jeweiligen Worker Nodes. Und genau in diese Logik kann man eingreifen. Man kann Kubernetes anweisen, auf welchen Nodes die Pods gestartet werden sollen.
Das bringt interessante Optionen mit sich. So kann man gezielt Worker Nodes für Stages isoliert zur Verfügung stellen. Außerdem ist es auch möglich, der dev-Stage beispielsweise schwächere Maschinen zur Verfügung zu stellen als der test-Stage. Oder es kann eine explizite Performance-Stage erschaffen werden, auf der nur die Anwendung installiert ist, deren Performance wiederholbar und mit minimierten äußeren Einflussfaktoren ausführbar und messbar ist.
In Kubernetes gibt es dafür zwei Möglichkeiten, um ans Ziel zu kommen. Die erste Variante ist der Node Selector, der allerdings von der Node Affinity abgelöst wird, sobald diese Produktreife erhält. Auch Nodes können mit Labels ausgestattet werden. Für uns heißt das, dass alle Nodes einer Stage mit demselben stage-Label versehen werden. Anschließend kann über Pods (bzw. Pod-Template im Deployment) die Node Affinity angegeben werden. Eine auf das Wesentliche gekürzte Konfiguration könnte so aussehen wie in Listing 2.
Listing 2
apiVersion: apps/v1beta1
kind: Deployment
spec:
template:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: stage
operator: In
values:
- dev
Die eben vorgestellte Lösung bringt einen Nachteil mit sich: Vergisst man die Affinity im Deployment, wird der Pod auf irgendeinem Node installiert. In diesem Fall gibt es noch eine dritte Lösung, die stattdessen angewandt werden kann: Taints. Bei Taints ist die Logik umgekehrt. Ein Pod kann auf einem Node nur dann installiert werden, wenn dieser den Taint ausdrücklich toleriert. Wenn nun jeder Node mit einem stage Taint versehen ist, kann ein Pod nicht mehr aus Versehen auf einem falschen Node deployt werden. Vergisst ein Deployment/Pod die Angabe, kann dieser auf keinem Node installiert werden.
Dazu muss jeder Node per kubectl taint nodes <node-name> stage=<stage>:NoSchedule gekennzeichnet werden. Anschließend sieht ein Deployment beispielsweise so aus wie in Listing 3. Es ist nur eine der beiden Lösungen (Node Affinity oder Taints) notwendig.
Listing 3
apiVersion: apps/v1beta1
kind: Deployment
spec:
template:
spec:
tolerations:
- key: "stage"
operator: "Equal"
value: "dev"
effect: "NoSchedule"
Die Zuteilung auf einzelne Nodes bringt Vor- und Nachteile. So hat man eine harte Aufteilung auf unterschiedliche Maschinen, die man über Firewalls voneinander trennen kann, allerdings wird dadurch etwas von der Flexibilität genommen. Wenn beispielswiese auf der test-Stage noch Ressourcen verfügbar wären, auf der dev aber nicht mehr, kann so auf der dev-Stage nichts mehr installiert werden.
Der Artikel zeigt auf, wie man mit Hilfe von Namespaces, RBAC, Quotas, Network Policies und Node Affinities/Taints mehrere Stages innerhalb eines Clusters erstellen kann. Geht man den vollen Weg, sieht die Übersichtsgrafik aus wie in Abbildung 2.
Auf den ersten Blick ist zu erkennen, dass die dev- und test-Nodes zwar voneinander getrennt sind, beide aber immer noch einen gemeinsamen Master haben. Das bringt die Kubernetes-Architektur mit sich. Möchte man auch diese trennen, hat man zwei Kubernetes-Cluster. Das führt uns auch zu einem Angriffspunkt der Lösung. Gibt es Probleme mit dem Master bzw. dem API-Server – wie es bereits Ende 2018 der Fall war (CVE-2018–1002105) –, kann wieder auf die anderen Stages zugegriffen werden. Das bedeutet, dass die vorgestellte Lösung nur verwendet werden sollte, wenn die einzelnen Parteien sich freundlich gegenüberstehen. Bei einer Aufteilung auf Stages kann in der Regel davon ausgegangen werden. Diese Ansätze aber für Multi-Tenancy zu verwenden, ist nicht ratsam.
Es ist empfehlenswert, für die prod-Stage einen eigenen, isolierten Cluster aufzusetzen. Nur so kann man sichergehen, dass bei einer fehlerhaften Konfiguration in einer der vorherigen Stages die prod-Stage nicht in Mitleidenschaft gezogen wird. Außerdem können dadurch Updates des Kubernetes-Clusters auf der dev-Stage getestet und verifiziert werden.
Die Lösung ist, wie bereits angedeutet, auch fehleranfällig, wenn nicht alle Labels sauber gesetzt werden. Wird beispielsweise in einem Namespace ein Label falsch oder nicht gesetzt, wird dieser von der Network Policy abgewiesen. Es ist also auch hier wichtig, dass sorgfältig gearbeitet wird und die Prozesse möglichst automatisiert werden. Dafür bringt die Möglichkeit, mehrere Stages in einem Kubernetes-Cluster zu realisieren, eine Erleichterung des Kubernetes-Betriebs mit sich, da dieser weniger Cluster betreiben, installieren, verwalten und pflegen muss.
Michael Frembs ist Teil des Softwarearchitektenteams bei der ARS Computer und Consulting GmbH. Seit mehreren Monaten liegt sein Fokus auf Cloud-Native-Entwicklung und Kubernetes.
[1] https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
In der vorigen Folge haben wir Ihnen drei Zutaten vorgestellt, die Sie als Architekt(in) von anderen auf jeden Fall einfordern sollten, bevor Sie mit Designentscheidungen loslegen: klare Ziele, die Kenntnis der Stakeholder und die Festlegung Ihres Scopes. Starten wir mit dem Scope.
Der Scope ist der Bereich, in dem Sie freie Entscheidungen treffen dürfen (Abb. 1). Im Kontext liegen die Schnittstellen zu Umsystemen oder Nutzergruppen. Änderungen daran müssen Sie mit den dafür Zuständigen verhandeln. Dinge im Kontext liegen also außerhalb Ihres direkten Einflussbereichs.
Erfahrungsgemäß tun sich viele Projekte und/oder Teams schwer damit, diese einfache Abgrenzung präzise vorzunehmen: Was gehört in unseren Scope und mit wem müssen wir verhandeln? Deshalb wollen wir in diesem Teil unserer Kolumne genauer auf die Feinheiten der Scope-Festlegung eingehen.
Wenn man von Produkt oder System spricht, ist meist ein IT-Produkt oder ein IT-System gemeint. Sollte Ihre Aufgabe also darin bestehen, ein (einziges) neues IT-System zu schaffen, so sind Produkt-Scope und Projekt-Scope identisch. In der Praxis betreffen Projekte manchmal auch mehrere vorhandene IT-Systeme. Möglicherweise müssen Sie ein System neu entwickeln oder kräftig modifizieren und in diesem Rahmen auch notwendige Anpassungen anderer IT-Systeme mit erledigen (Abb. 2).
Wie Sie in Abbildung 2 erkennen, müssen Sie die Schnittstellen des neuen (oder zu modifizierenden) Systems zu den Benutzern und zu IT-System 2 festlegen. Außerdem gilt es, auch die Leistungen, Funktionalität und Schnittstellen innerhalb der IT-Systeme 1, 3 und 4 zu identifizieren, die angepasst werden müssen. Sollten Sie als Projektverantwortlicher keine Entscheidungsgewalt über die notwendigen Änderungen an den IT-Systemen 1, 3 und 4 haben, so ist Ihr Projekterfolg vom guten Willen dieser drei Nachbarsysteme abhängig: Sie brauchen dort Änderungen, dürfen diese aber nicht selbst ausführen oder anordnen, sondern müssen mit den Verantwortlichen dieser Systeme verhandeln.
Nutzen Sie für die Scope-Festlegung Ihres Projekts eine visuelle Gesamtübersicht (Kontextdiagramm) des neuen oder zu modifizierenden Systems, zusammen mit den Nachbarsystemen 1 bis 4.
Requirements-Analysten können Schnittstellen einfach vorgeben – in der Entwicklung bereiten diese den Entwicklungsteams möglicherweise viel Aufwand und beinhalten hohe Risiken.
Zur Festlegung der Grenze zwischen Scope und Kontext reicht anfangs die Betrachtung der ein- und ausgehenden Daten Ihres Systems. Die klassische Darstellungsweise dafür ist ein sogenanntes fachliches Kontextdiagramm [1], wie Sie es als Beispiel für den Bordcomputer eines PKWs in Abbildung 3 sehen. Das System soll den Fahrer sowohl mit typischen Informationen wie Durchschnittsgeschwindigkeit, Treibstoffverbrauch, Außentemperatur etc. versorgen als auch Navigation ermöglichen, einen Tempomaten zur Verfügung stellen, Wartungsintervalle überwachen und den Fahrer über Radiosender und Telefonanrufe informieren.
Sie sollten in einem Kontextdiagramm alle Nachbarsysteme identifizieren und für jedes davon die Ein- und Ausgaben benennen. Eine Aufzählung von Funktionen (oder Features und Epics) genügt meist nicht, um den Scope des Produkts festzulegen!
Falls Sie übrigens Diagramme nicht mögen, so wird unter [1] eine ganze Menge an alternativen Notationen dafür vorgeschlagen, im einfachsten Fall eine Tabelle mit allen Nachbarsystemen und deren Schnittstellen. Wichtig ist, dass Sie erstens Ihr System klar identifiziert haben, zweitens alle Nachbarn kennen und drittens die komplette Ein- und Ausgabe auf fachlicher Ebene verstanden haben.
Als Ergebnis einer Anforderungsanalyse genügt es, Ein- und Ausgaben von und zu den Nachbarn zu erkennen. Diese Schnittstellen explizit identifiziert zu haben, bedeutet mehr als die halbe Miete.
Bei Entwurf und Entwicklung des Systems müssen Sie bei jeder dieser externen Schnittstellen alle notwendigen Details entweder hinterfragen oder entscheiden. Unter [2] finden sich dazu viele pragmatische Hinweise. Sie müssen zum Beispiel festlegen, wer der aktive Partner ist (Push oder Pull), wie die Handshakes oder Protokolle aussehen, die an der Schnittstelle einzuhalten sind, welche zeitlichen, technischen oder organisatorischen Randbedingungen einzuhalten sind etc.
Im arc42-Termplate [3] haben wir Abschnitt 3 (Kontextabgrenzung) für diese wichtigen Informationen vorgesehen. Abschnitt 3.1 enthält das fachliche Kontextdiagramm. Falls nötig, können Sie in Abschnitt 3.2. noch das technische Kontextdiagramm aufnehmen, das die technischen Kanäle zeigt, über die fachliche Informationen fließen. In obigem Beispiel würde man für das Fahrerinterface vielleicht sowohl Spracheingabe als auch Tastatureingabe technisch zulassen. Viele der anderen Schnittstellen laufen vielleicht über den CAN-Bus. arc42-Abschnitt 3.2 enthält dann auch ein Mapping, welcher fachliche Input/Output über welchen technischen Kanal läuft.
Alternativ können Sie Details von Schnittstellen auch als technische oder querschnittliche Konzepte in Abschnitt 8 beschreiben, falls Sie beispielsweise viele Schnittstellen nach demselben Schema behandeln möchten.
Falls Sie auf die grafische Variante stehen: Die UML bietet Ihnen viele Möglichkeiten, Schnittstellen genauer festzulegen. Abbildung 4 zeigt zu obigem Beispiel jetzt die Verwendung von Ball- und Socket-Notation beziehungsweise die Einführung von Ports.
Wir Autoren vertreten diesbezüglich unterschiedliche Meinungen: Peter mag UML, Gernot eher die text- oder tabellenorientierte Beschreibung von Schnittstellen. Beides funktioniert.
Abbildung 4 zeigt noch eine Empfehlung: Wenn ein Produkt viele Schnittstellen aufweist, könnten Sie diese als Analyseergebnis bündeln. Die Abbildung zeigt nur zwei Sensoren (Temp- und Durchfluss). Stellen Sie sich aber vor, dass Sie mehrere Dutzend Sensoren als Schnittstellen haben. Dann lohnt es sich, anfänglich in der Analyse nur über ein Sensorinterface zu sprechen und dieses erst in der Entwicklung detailliert aufzuspalten. Ein weiteres Beispiel wären im Telekommunikationsbereich die Schnittstellen zu Roamingpartnern. Dabei handelt es sich vielleicht um einige Hunderte, die teilweise ganz unterschiedliche Protokolle nutzen oder unterschiedliche Formate liefern. Trotzdem kann man sie anfangs zu einem „Roamingpartnerinterface“ zusammenfassen. Wie gesagt: Schnittstelle erkannt, Gefahr halbwegs gebannt.
Damit sind Sie in den weitaus meisten Fällen mit Scope und Kontext fertig. Ein i-Tüpfelchen aber hätten wir noch für Sie.
Gründliche Requirements Engineers unterscheiden zwischen Business-Scope und Produkt-Scope: Der Business-Scope ist der Bereich Ihres Unternehmens oder Ihrer Organisation, in dem Sie im Zuge Ihrer Software- oder Systementwicklung Entscheidungen treffen oder vorschlagen dürfen – also beispielsweise Ihr Fachbereich oder Ihre Abteilung. Normalerweise ist der Business-Scope um einiges größer als der Produkt-Scope, weil Sie vielleicht nicht alles, was in Ihren Entscheidungsbereich fällt, auch automatisieren wollen. Sie können also in Zusammenarbeit von Analytikern und Architekten festlegen, welche Teile von Geschäftsprozessen automatisiert und welche Schritte vielleicht noch längere Zeit manuell durchgeführt werden sollen.
Abbildung 5 zeigt eine solche Situation. User 1 und User 2a sowie IT-System 1 befinden sich außerhalb Ihres Business-Scopes. Dort haben Sie keinen direkten Einfluss. User 2b und User 3 sowie IT-System 2 gehören in Ihren Business-Scope. Daher sollte es relativ leicht sein, diese bei der Neuentwicklung eines Produkts zu berücksichtigen. IT-System n gehört Ihnen nicht allein, sondern es sind auch andere Verantwortliche im Business-Kontext mit im Spiel.
Für User 2a können Sie zum Beispiel entscheiden, dass Anfragen zunächst an User 2b in Ihrer Abteilung gehen und dieser mit dem neuen Produkt diesen Request erfüllt. Später erhält User 2a vielleicht einen direkteren Zugriff zu dem neuen System.
Unsere Empfehlung ist es, in der Anforderungsanalyse die Scheuklappen grundsätzlich etwas weiter aufzumachen und an die Schnittstellen Ihres Business zu denken, statt an die möglicherweise eingeschränkten Schnittstellen eines Produkts.
Sie sehen schon: Scope und Kontextabgrenzung sind in vielen Fällen nicht trivial. Und wenn Sie diesen Input nicht von Requirements Engineering oder Business Analysts bekommen, dann ist das ein ganz wichtiger, früher Schritt bei Ihrer Architekturarbeit.
Nehmen Sie die Festlegung von Scope und Kontext ernst. Im Entwicklungsteam müssen Sie manchmal nacharbeiten, weil die Anforderungsanalyse oder Ihre Product Owner Sie diesbezüglich im Stich gelassen haben.
Nutzen Sie bereits frühzeitig in Ihrem Projekt oder Vorhaben ein Kontextdiagramm als Kommunikationshilfsmittel, um Feedback Ihrer Stakeholder über die wichtigen Außenschnittstellen ihres Systems einzuholen – lange bevor Sie interne Entscheidungen treffen. Legen Sie besonderes Augenmerk auf volatile oder kritische Schnittstellen, die sich oft und ohne ihr Zutun ändern können.
In der nächsten Folge beschäftigen wir uns mit funktionalen Anforderungen. Bis dahin alles Gute, mit hoffentlich geklärtem Scope und Kontext.
Dr. Peter Hruschka (www.b-agile.de) und Dr. Gernot Starke (innoQ-Fellow, www.gernotstarke.de) haben vor einigen Jahren www.arc42.de ins Leben gerufen, das freie Portal für Softwarearchitektur, und nun auch www.req42.de, das freie Portal für agiles Requirements Engineering. Sie sind als Gründungsmitglieder sowohl im IREB (www.ireb.org) als auch im iSAQB (www.isaqb.org) vertreten sowie Autoren mehrerer Bücher rund um Requirements Engineering, Softwarearchitektur und Entwicklungsprozesse.
© Bogenberger Autorenfotos
[1] Hruschka, Peter: „Business Analysis und Requirements Engineering“, 2. Auflage, Hanser 2019
[2] Starke, Gernot; Hruschka, Peter: „arc42 in Aktion – Praktische Tipps zur Architekturdokumentation“, Hanser 2016; viele Tipps auch online unter https://docs.arc42.org
[3] https://arc42.de und https://arc42.org
Zum ersten Mal in Erscheinung getreten ist die Blockchain-Technologie wohl 2008 durch den Artikel „Bitcoin: A Peer-to-Peer Electronic Cash System“. Es folgte eine Zeit, in der sich vor allem Nerds, Freaks, Insider, aber auch zwielichtige Gestalten damit beschäftigten, bevor 2017 der Mainstream die virtuelle Währung für sich entdeckte. Während die klassischen Medien den Bitcoins stets eine Aura des Mysteriösen andichten, muss man sich klar machen, dass die Preisentwicklung dieser virtuellen Währung auf ganz gewöhnlicher Spekulation beruht – ganz wie bei Weizen, Kupfer oder Unternehmensanteilen: Angebot knapp, Nachfrage hoch, Preise hoch.
Auf unserer eigenen Blockchain-Konferenz, die wir Ende letzten Jahres veranstalteten, konnte ich folgende Dinge lernen:
Mit virtuellen Währungen wie Bitcoin hat das meiste, worüber im Kontext von Blockchains debattiert wird, wenig zu tun. Bitcoins bezeichnen zwar den bekanntesten Anwendungsfall, die Implementierung von Währungen ist aber nur eine Möglichkeit von vielen, die die Blockchain bietet.
Die „Blockchain-Bewegung“ befindet sich noch in den Kinderschuhen; sowohl auf der Business- als auch der Entwicklerseite hat man bislang nur eine grobe Ahnung davon, was mit der Blockchain alles machbar sein könnte.
Die allerwichtigste Frage lautet: Habe ich wirklich ein Problem, das zur Blockchain passt? Oder verhält es sich eher umgekehrt: Suche ich mit der Blockchain als Lösung nach einem passenden Problem?
Die vielleicht noch wichtigere Frage: Will ich wirklich eine radikal dezentrale Lösung? Bin ich mit meinem Business bereit, die Kontrolle über „meine“ Lösung im herkömmlichen Sinne aufzugeben?
Öffentliche und private Blockchains sind zwei unterschiedliche Paar Stiefel; die geschäftlich relevanten Anwendungsfälle jenseits des Hypes spielen sich fast alle in privaten Blockchains ab, d. h. dort, wo Konsortien von Organisationen ihre Zusammenarbeit über Blockchain-Anwendungen miteinander regeln.
Die Blockchain bedeutet keineswegs, dass zwangsläufig sinnlos Energie verbrannt wird. Nur in den öffentlichen Blockchains wie etwa Bitcoin gilt der sog. Proof of Work, d. h. Mitspieler legitimieren sich durch aufwändige und kostspielige Rechenoperationen. In den privaten Blockchains hingegen gilt der Proof of Authority, d. h. Mitglieder eines Konsortiums einigen sich auf gemeinsame Regeln der Zugehörigkeit und des Ausschlusses – ohne das berühmte Mining.
In dieser Ausgabe des Java Magazins haben wir uns vorgenommen, die Technologie ein wenig genauer anzusehen und zu prüfen, was aus der Perspektive von Java-Entwicklern damit machbar ist.
Reactive Programming verspricht eine höhere Performance von Enterprise-Java-Anwendungen bei geringerem Speicherbedarf. Erreicht wird dieses Versprechen, indem blockierende Aufrufe vermieden werden. Blockierende Aufrufe führen im Betriebssystem immer zu Prozess- und damit zu Kontextwechseln. Solche Kontextwechsel haben einen hohen CPU- und Speicher-Overhead. Dieser Overhead wird durch weniger Kontextwechsel reduziert. Erkauft wird dieser Performancegewinn von Reactive Programming allerdings durch schlechtere Wartbarkeit der Software. Ist die höhere Performance aber diesen Preis wert, und was sind die Alternativen? Dieses Thema wollen wir uns einmal genauer anschauen.
In den Anfängen von Java war die Threading-Abstraktion ein großer Pluspunkt gegenüber den anderen damaligen Programmiersprachen. Sie bietet den Entwicklern noch heute leichten Zugriff auf parallele Programmierung und Synchronisation. Web-Frameworks konnten damals auf dieser Basis sehr leicht implementiert werden, da durch die Bindung eines Web-Requests an einen Thread im Servlet API die Abarbeitung eines Requests quasi imperativ realisiert werden konnte, ohne sich um Nebenläufigkeit und Synchronisation zu kümmern. Bevor es Tabbed Browsing und Ajax gab, konnte man sich auch quasi per (Webseiten-)Design sicher sein, dass niemals zwei Requests derselben User-Session parallel ausgeführt wurden. Dadurch musste sich der normale Entwickler auch auf User-Session-Ebene praktisch keine Gedanken um parallele Verarbeitung machen.
Die oben erwähnte Realisierung der Java-Threading-Abstraktion hat aktuell allerdings einen gravierenden Nachteil: Java-Threads sind als Betriebssystemprozesse realisiert, sodass jeder Threadwechsel einen (sehr teuren) Kontextwechsel im Betriebssystem bedeutet. Zu den Zeiten, als Webapplikationen nur mehrere tausend Requests pro Minute verarbeiten mussten, war das noch kein Problem. Mittlerweile sind die Anforderungen an Webapplikationen allerdings deutlich höher. Steigende Nutzerzahlen und interaktivere SPAs (mit mehr Client-Server-Kommunikation) sorgen dafür, dass eine heutige Enterprise-Applikation deutlich mehr Requests pro Minute verarbeiten muss als noch vor fünfzehn Jahren. Das Modell, bei dem ein Request von einem Betriebssystemthread abgearbeitet wird, der womöglich zwischendurch noch blockiert, wenn zum Beispiel eine Datenbankabfrage abgesetzt oder ein weiterer Microservice aufgerufen wird, stößt an seine Grenzen.
Der Grad der Parallelität ist deutlich höher als zu den Zeiten, als die Entscheidung getroffen wurde, Java-Threads als Betriebssystemprozesse zu realisieren. Dafür sind die Requests bzw. ist der darin ausgeführte Code mittlerweile deutlich kürzer. Das passt nicht zu den teuren Kontextwechseln der Betriebssystemprozesse.
Hier setzt Reactive Programming an. Das Paradigma ist genau entgegengesetzt zum Java-Threading-Modell. Während das Threading-Modell versucht, Asynchronität vom Benutzer fernzuhalten („Alles passiert in einem Thread“) ist bei Reactive Programming die Asynchronität quasi das Prinzip. Der Programmablauf wird als eine Sequenz von Ereignissen angesehen, die natürlich asynchron auftreten können. Jedes dieser Ereignisse wird von einem Publisher veröffentlicht. Auf welchem Thread der Publisher das tut, ist dabei unerheblich. Der Programmcode besteht in einer reaktiven Anwendung aus Funktionen, die auf diese asynchrone Veröffentlichung von Ereignissen hören, sie verarbeiten und gegebenenfalls neue Ereignisse veröffentlichen.
Dieses Vorgehen ist vor allem dann sinnvoll, wenn mit externen Ressourcen wie z. B. einer Datenbank gearbeitet wird. Bei einer klassischen Enterprise-Java-Anwendung würde auf ein abgesetztes SQL Statement blockierend gewartet, bis die Datenbank das Ergebnis liefert. Bei Reactive Programming ist es so, dass das Statement abgesetzt wird, ohne auf das Ergebnis zu warten. Die Methode zum Absetzen der Datenbankabfrage liefert stattdessen unmittelbar (also ohne zu blockieren) einen Publisher zurück, auf den sich der Aufrufer registrieren kann, um informiert zu werden, wenn das Datenbankergebnis verfügbar ist. Das Datenbankergebnis wird dann später als Ereignis auf dem Publisher veröffentlicht. Dieses API ist eine sinnvolle Alternative zu den Callback-Höllen, die sonst bei asynchroner Programmierung üblich sind.
Der Vorteil von Reactive Programming liegt also darin, dass eine Entkopplung von auszuführendem Code und dem ausführenden Thread entsteht. Damit gibt es weniger teure Kontextwechsel auf Betriebssystemebene.
Noch essenzieller ist ein solches Vorgehen bei Serverarchitekturen, in denen es nur genau einen Thread gibt, der den Applikationscode verarbeitet, wie das bei NodeJS der Fall ist. Wenn dieser blockiert ist, steht der ganze Server still und kann keine Requests mehr verarbeiten. Daher wird in JavaScript jeder Call von vornherein asynchron realisiert. Ein Reactive API oder eine andere sinnvolle Abstraktion für die sonst entstehende Callback-Hölle ist da also auf jeden Fall sinnvoll.
Reactive Programming hat allerdings, wie einleitend angedeutet, ein paar gravierende Nachteile. Die Entkopplung von geschriebenen Funktionen und ausgeführtem Code führt zu einem erhöhten Schwierigkeitsgrad beim Lesen und Schreiben des Codes. Außerdem ist es kompliziert, Unit-Tests für solch asynchronen Code zu schreiben. Noch schwieriger wird es, den Code zu debuggen.
Weitergehende Probleme entstehen bei der Integration in klassische Enterprise-Anwendungen. Dort hängen die klassischen Themen wie Security, Transaktionen oder Tracing bisher noch immer am aktuellen Thread. Beginnt man mit Reactive Programming, funktioniert dieses Konstrukt nicht mehr und es müssen andere Lösungen gefunden werden.
Project Reactor, die Basis des Web-Reactive-Frameworks von Spring, hat hierzu bereits einige Hilfskonstrukte, die Testing, Debugging und Kontext-Propagation ermöglichen ([1], Abschnitte Testing, Debugging und Kontext). Allerdings zeigt allein die Tatsache, dass solche Hilfskonstrukte nötig sind, bereits die Komplexität von Reactive Programming. Es muss also die Frage erlaubt sein, ob es nicht sinnvollere Alternativen gibt, die das Problem der teuren Kontextwechsel von Java-Threads beheben.
Wie oben erwähnt, passt der Grad der Parallelität von aktuellen Webanwendungen in Kombination mit der geringen Größe der auszuführenden Codeschnipsel nicht zur aktuellen Threading-Implementierung von Java, bei der jedem Java-Thread eins zu eins ein Betriebssystemprozess zugeordnet ist. Zwar gibt es in Java Threadpools, mit denen es möglich ist, mehrere dieser Codeschnipsel auf demselben Thread auszuführen. Diese entschärfen das Problem auch, letztendlich sind sie aber nur ein unschöner Workaround für das Problem.
Andere Programmiersprachen wie C#, JavaScript oder Kotlin sind hier bereits einen Schritt weiter und haben Konstrukte in die Sprache aufgenommen, um solche kurzen Codeschnipsel asynchron (nach dem Warten auf ein blockierendes Ereignis) auszuführen. In C# und JavaScript sind es die Konstrukte async and await [2], [3], in Go und Kotlin gibt es das Konzept der Coroutines [4], [5].
Die Idee ist hier überall dieselbe. Wenn ich weiß, dass ich einen langlaufenden Aufruf ausführe (z. B. ein Datenbank-Statement absetze), soll mein Code nicht blockieren. Vielmehr möchte ich auf eine einfach zu schreibende und zu lesende Art und Weise den Code angeben können, der ausgeführt werden soll, wenn der langlaufende Aufruf beendet ist und das Ergebnis zur Verfügung steht. Der entstehende Code sollte zudem einfach zu debuggen und zu testen sein.
Betrachtet man den Aufwand, der betrieben werden muss, nur um dafür zu sorgen, dass der Entwickler sein reaktives Programm genauso leicht schreiben, testen, warten und debuggen kann, wie er es aus der imperativen Welt mit der Standard-Threading-Abstraktion gewohnt ist, stellt sich eine Frage. Und zwar die Frage, ob die bessere Laufzeitperformance den Einsatz von Reactive Programming überhaupt rechtfertigt. Wenn man dann noch einen Blick auf die gerade genannten alternativen Ansätze wirft, scheint es umso fraglicher, ob Reactive Programming die richtige Lösung für das Problem ist. Allerdings muss man zugeben, dass es sich bei den vorgestellten Alternativen anderer Programmiersprachen (also async/await und Coroutines) jeweils um Sprachkonstrukte handelt und nicht um Third-Party-Bibliotheken. Würden also solche alternativen Sprachkonstrukte in Java Reactive Programming überflüssig machen? Ein Blick in die Vergangenheit von Java fördert in diesem Zusammenhang einen interessanten Aspekt zutage:
In Java 1.1 war das gesamte Threading-Modell als sogenannte „Green Threads“ implementiert, d. h. die gesamte Java VM lief in nur einem Betriebssystemprozess. Java-Threads wurden innerhalb der VM mit einem eigenen Scheduling-Algorithmus realisiert. Threadwechsel und damit Kontextwechsel innerhalb von Java konnten aufgrund des virtuellen Speichermanagements extrem schnell und mit wenig Speicheroverhead durchgeführt werden.
Vorteil dieser Lösung war zudem, dass die Synchronisation von Datenzugriffen innerhalb der Java-Applikationen nicht so kompliziert war. Ein „echter“ paralleler Zugriff auf eine Variable konnte gar nicht passieren, da alles in einem Betriebssystemprozess ausgeführt wurde und daher nur „virtuell parallel“ war.
Der Nachteil dieser Implementierung ist allerdings, dass Java mit Green Threads bei Mehrkern- oder Mehrprozessorsystemen eben auch nur einen Kern bzw. Prozessor verwenden konnte. Mit Java-Programmen war es also nie möglich, die komplette Leistung der Rechner auszunutzen. Im Praxiseinsatz wurde schnell klar, dass dieser Nachteil gravierender als der genannte Vorteil dieser Implementierung war.
Daher wurde die Verwendung von Green Threads schnell beendet. In Java 1.2 konnte man per Command-Line-Schalter zwischen Green und Native Threads wechseln [6]. In Java 1.3 wurden nur noch Native Threads unterstützt. Nun konnten zwar alle Kerne und Prozessoren eines Rechners verwendet werden. Der Aufwand für Threadwechsel (die ja nun Prozesswechsel waren) ist seither aber wie beschrieben deutlich erhöht. So kommt es, dass Programmierparadigmen wie Reactive Programming deutlich höhere Performancewerte erzielen – eben weil sie nicht blockieren und daher deutlich weniger Kontextwechsel erzeugen.
Im letzten Jahr wurde Projekt Loom ins Leben gerufen [7]. Die Idee hinter diesem JVM-Projekt ist die erneute Entwicklung von Java-Support für Green Threads. Diese sollen im Gegensatz zu früher die bisherigen Betriebssystemthreads nicht ersetzen, sondern ergänzen. Beide Threading-Modelle sollen also parallel auf der JVM existieren und gleichzeitig im Programmablauf zum Einsatz kommen.
Demzufolge werden Betriebssystemthreads weiterhin durch die Java-Klasse thread implementiert und Green Threads durch die Klasse fiber. Gegebenenfalls wird es eine gemeinsame Basisklasse geben. Der Plan ist, dass der meiste existierende Code ohne Änderungen in einem Fiber laufen kann, ohne den dahinter liegenden Thread zu kennen. Kontextwechsel zwischen Fibers sollen dank des virtuellen Speichermanagements von Java nahezu ohne Overhead erfolgen können. Auch Synchronisation soll dann in Fibers deutlich performanter werden. Eine Idee dazu ist, dass der Scheduler dafür sorgt, dass zwei Fibers, die voneinander abhängen (also z. B. auf dieselbe Variable zugreifen), auf demselben Native Thread ausgeführt werden. So ist sichergestellt, dass sie niemals parallel laufen können. Eine Synchronisation ist dann praktisch nicht mehr nötig.
Zur Realisierung von Fibers soll die Ausführung von Threads in Java in zwei Teile aufgeteilt werden: in die Continuation und den Scheduler. Eine Continuation repräsentiert dabei einen Ausführungszustand, also den auszuführenden Code inklusive des Ausführungskontexts wie Aufrufparameter, Stack usw. Der Scheduler sorgt dann für eine gleichmäßige Ausführung aller Continuations.
Die Trennung von Scheduler und Continuation hat mehrere Vorteile. Bisher werden sowohl Scheduling als auch Ausführungskontext vom Betriebssystem gemanagt. Durch die Trennung ist es nun möglich, eines von beidem (oder beides) in der JVM auszuführen. Green Threads (die Fibers) können so komplett in Java implementiert werden, und auch existierende Java-Scheduler wie z. B. der Fork-Join-Pool können wiederverwendet werden.
Die Trennung von Scheduler und Continuation hat noch einen weiteren interessanten Aspekt: Die dadurch separierten Continuations könnten als eigenes Java-API jedem Entwickler zur Verfügung gestellt werden. Continuation könnte als Sprachfeature (z. B. unter dem Namen Coroutines) in Java einfließen. Wie oben geschrieben, gibt es bereits mehrere Sprachen (auch auf der JVM), in denen dieses Sprachfeature bereits existiert. Project Loom würde es quasi als „Abfallprodukt“ auch für Java mitbringen.
Mit Reactive Programming werden Performanceprobleme gelöst, die durch die Verwendung von Native Threads und das Paradigma „Ein Thread pro Request“ entstehen. Man erkauft sich diese Lösung allerdings mit höherer Entwicklungs- und Wartungskomplexität, da unter anderem Testing und Debugging komplizierter werden.
Green Threads sind eine Möglichkeit, um die Performanceeinbußen, die durch den Prozesswechsel im Betriebssystem entstehen, zu vermeiden. Diese gab es in Java 1.1, doch bereits in Java 1.3 wurden sie wieder verworfen, da mit ihnen die Vorteile von Mehrkern- oder Mehrprozessorsystemen nicht genutzt werden konnten. Mit Project Loom gibt es nun einen neuen Versuch, eine neue Variante von Green Threads (sogenannte Fibers) im JDK einzuführen. Damit ginge, quasi als Abfallprodukt, der Support für Continuations in Java einher. Dieses Feature ist aus anderen Programmiersprachen wie Kotlin und Go unter dem Namen Coroutines bekannt. Auch diese würden mit Project Loom also Einzug in die Java-Welt erhalten.
Man darf gespannt sein, ob und wenn ja, wann Project Loom in das JDK einfließt und welche Auswirkungen das auf die Verbreitung von Reactive Programming haben wird. Aus Performancesicht würde es dadurch vermutlich überflüssig.
In diesem Sinne, stay tuned.
Arne Limburg ist Softwarearchitekt bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.
[1] https://projectreactor.io/docs/core/release/reference/
[2] https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/
[3] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
[4] http://www.golangpatterns.info/concurrency/coroutines
[5] https://kotlinlang.org/docs/reference/coroutines-overview.html
[6] http://www.sco.com/developers/java/j2sdk122-001/ReleaseNotes.html#THREADS
[7] https://cr.openjdk.java.net/~rpressler/loom/Loom-Proposal.html
In der funktionalen Programmierung sind unveränderliche Daten ein unverzichtbares Werkzeug. Und auch Java hat in der Vergangenheit das eine oder andere funktionale Konzept adaptiert. Eine native Unterstützung für Unveränderlichkeit fehlt jedoch nach wie vor. Mit Hilfe geeigneter Drittbibliotheken lässt sich aber einiges erreichen. In diesem Artikel wollen wir daher die Generierung von unveränderlichen Klassen mit Hilfe der Bibliothek Immutables vorstellen.
Unter unveränderlichen Daten versteht man Datenstrukturen, die nach dem initialen Anlegen nicht mehr verändert werden können. Einmal erzeugt, können nur noch Kopien abgeleitet, aber keine Daten mehr „in-place“ verändert werden. Dies hat verschiedene Vor- und Nachteile. In funktionalen Sprachen wird diese Eigenschaft u. a. dazu genutzt, ungewollte Seiteneffekte zu verhindern, denn eine Veränderung eines Werts außerhalb der Funktion wäre ein solcher Seiteneffekt. In Kombination mit einem funktionalen Programmierstil können unveränderliche Datenstrukturen die Lesbarkeit, Verständlichkeit und Testbarkeit von Code erhöhen, indem die Menge der möglichen Einflussfaktoren begrenzt wird. Unveränderbare Objekte können beispielsweise ohne Bedenken an fremde Methoden als Parameter übergeben werden, ohne dass man Angst haben muss, dass der Fremdcode das eigene Objekt unvorhergesehen manipuliert. Auch beim Thema Multithreading können unveränderliche Daten eine Erleichterung sein und viele Fehlerquellen von vornherein ausschließen.
Nachteilig kann sich das in manchen Situationen auf die Performance auswirken, da eben häufiger Kopien der Daten erzeugt werden müssen. Auch hierfür gibt es geeignete Hilfsmittel und Techniken zur Vermeidung von Problemen, die aber sicherlich nicht in jedem Anwendungsgebiet ausreichend sind. Letztlich ist also wie bei fast allen technischen Themen eine Abwägung nötig.
Hat man sich dann einmal für die funktionale Programmierung und Immutability entschieden, stellt sich die Frage der konkreten Umsetzung.
Java ist keine funktionale Sprache, bringt in der Standardbibliothek aber dennoch bereits ein paar unveränderliche Klassen mit, beispielsweise die Klassen aus dem neuen Date and Time API. Im Allgemeinen ist die Unterstützung für diesen Programmierstil in Java aber begrenzt.
Zwar können in Java Variablen als final markiert werden, das wirkt sich aber nur auf die Referenz selbst aus, sodass diese nicht mehr neu belegt werden kann. Über die Veränderbarkeit der Objekte selbst, auf die eine Referenz zeigt, sagt das final-Schlüsselwort nichts aus. Nur bei flachen Datentypen wie int, double oder boolean sorgt das Schlüsselwort effektiv für die Unveränderlichkeit des Werts.
Um unveränderliche Datenstrukturen nutzen zu können, müssen wir diese also entweder selbst implementieren oder aus entsprechenden Drittbibliotheken beziehen. Beispielsweise bringt die Bibliothek vavr [1] neben zahlreichen nützlichen funktionalen Helferlein auch einen Satz von unveränderlichen Collections mit. Diese gibt es in verschiedenen Ausprägungen von Listen über Queues hin zu Sets und Maps mit unterschiedlichen Eigenschaften und Performancecharakteristiken.
Allerdings sind Collections allein noch nicht wirklich zufriedenstellend. Wir möchten auch in der Lage sein, beliebige Datenstrukturen zu definieren. Wir bräuchten also ein unveränderliches Pendant zu Plain Old Java Objects (POJOs), und prinzipiell ist es auch nicht schwer, solche Klassen selbst zu programmieren. Nimmt man ein klassisches POJO als Ausgangspunkt, also eine Java-Klasse mit Feldern, die per Getter und Setter verfügbar gemacht wurden, dann genügen einige wenige Schritte, um die Klasse unveränderlich zu machen.
Zunächst entfernt man die Setter und legt stattdessen einen Konstruktor für sämtliche Felder an. Die Felder können anschließend als final markiert werden. Um veränderbare Instanzen ausschließen zu können, muss auch die Klasse selbst als final markiert werden, wodurch abgeleitete Klassen verhindert werden. Das ist notwendig, da andernfalls in der abgeleiteten Klasse wiederum veränderlicher Zustand eingeführt werden könnte. Somit könnte man für eine Instanz der Elternklasse nicht mehr zweifelsfrei sicherstellen, dass sie unveränderlich ist. Außerdem muss man sich bei den Typen aller Felder auf flache Datentypen oder ebenfalls unveränderliche Typen beschränken. Zum Schluss implementiert man noch die equals- und hashCode-Methoden, sodass zwei Instanzen mit gleichen Inhalten auch als identisch erkannt werden.
Prinzipiell genügt eine solche Implementierung bereits, jedoch ist die Arbeit damit nicht gerade angenehm. Der Grund dafür ist, dass wir es für die Umsetzung unserer eigentlichen Anforderungen in der Regel eben doch mit veränderbaren Dingen zu tun haben. Aus funktionaler Perspektive handelt es sich bei geänderten Daten lediglich um eine neue Version der entsprechenden Entität. Die alte Version bleibt unverändert, jedoch wird eine Kopie erzeugt, die den neuen Zustand repräsentiert. Diese Denkweise mag zunächst ungewohnt erscheinen, ist jedoch auch ungemein praktisch. Funktionale Sprachen bieten geeignete Sprachmittel zum einfachen Kopieren der Objekte. In Java müssen wir das jedoch wiederum selbst implementieren.
Ein übliches Pattern dazu sind sogenannte Wither-Methoden als Pendant zu Settern. Beispielsweise könnte eine unveränderliche Person-Klasse eine Methode withLastname enthalten. Genau wie ein Setter nimmt die Methode den zukünftigen Wert als Parameter entgegen. Anstatt aber das existierende Objekt zu verändern, wird stattdessen eine Kopie erzeugt, die sämtliche Werte des alten Objekts übernimmt und nur für das eine Feld (in unserem Beispiel der Nachname) einen neuen Wert vergibt. Listing 1 zeigt eine beispielhafte Implementierung.
Listing 1
public final class Person {
private final String firstname;
private final String lastname;
public Person(String firstname, String lastname) {
this.firstname = firstname;
this.lastname = lastname;
}
public Person withLastname(String newLastname) {
return new Person(this.firstname, newLastname);
}
// ...
}
Diese Wither-Methoden können noch optimiert werden: Wie oben bereits beschrieben, sollten die Klassen mit vernünftigen Equals-Methoden ausgestattet werden, die auch alle enthaltenen Felder einschließen. Da unveränderliche Klassen keinen Lebenszyklus besitzen, spielt der Zeitpunkt für die Prüfung auf Gleichheit keine Rolle. Zwei Instanzen, die einmal als gleich erkannt wurden, werden auch zu allen späteren Zeitpunkten gleich sein. Das ist bei normalen Objekten nicht der Fall, da sich die Felder der Objekte dort in der Zwischenzeit hätten ändern können. Da wir das für unsere Klasse aber ausschließen können, müssen wir in der Wither-Methode nur dann eine Kopie erzeugen, wenn sich der neue Wert vom bisherigen unterscheidet. Wir können also eine entsprechende Prüfung einbauen und in dem Fall direkt this zurückgeben. Für den konkreten Anwendungsfall der Person-Klasse sei noch angemerkt, dass in der Realität zwei Personen mit gleichem Vor- und Nachnamen natürlich nicht ein und dieselbe Person sind. Das spielt für die Betrachtung der Prüfung auf Gleichheit aber keine Rolle, sondern zeigt nur, dass für die korrekte Modellierung der Realität noch weitere Attribute notwendig wären, beispielsweise ein tatsächlich eindeutiger (eventuell rein technischer) Identifier.
Auf den ersten Blick sind Wither-Methoden nicht besonders kompliziert. Allerdings steigt der Aufwand schnell an, wenn neue Felder hinzugefügt werden sollen. Nicht nur müssen für jedes neue Feld ein Getter und Wither ergänzt, auch alle bisherigen Wither müssen erneut angefasst werden, da ja der neue Konstruktor dann nicht mehr zum bisherigen Aufruf passt. Wirklich praxistauglich ist dieses Vorgehen daher nicht.
Ein Ansatz zur Lösung des Problems sind Codegeneratoren, wie sie beispielsweise die Bibliothek Immutables [2] liefert.
Die Idee ist dabei folgende: Als Entwickler/in legt man lediglich ein Interface an, das die gewünschten Getter enthält. Dieses Interface stattet man mit einigen Annotationen aus und den Rest erledigt die Bibliothek. Diese erzeugt daraus eine Klasse, die das Interface implementiert und zusätzlich für alle Felder die entsprechenden Wither-Methoden, eine sinnvolle toString-Implementierung sowie equals und hashCode generiert. In Listing 2 ist beispielhaft unser Person-Interface mit der @Value.Immutable-Annotation zu sehen.
Die Codegenerierung selbst basiert auf dem Java Annotation Processing API. Durch die Nutzung dieses Standards ist die Integration in die üblichen Build-Werkzeuge vergleichsweise einfach möglich. In einem Maven- oder Gradle-Projekt reicht es beispielsweise, die Bibliothek in die Liste der Abhängigkeiten aufzunehmen. Der Scope kann dabei auf provided (bei Maven) bzw. compileOnly (bei Gradle) gestellt werden, da die Bibliothek nur zum Compile-Zeitpunkt benötigt wird und nicht zur Laufzeit.
Bei der Nutzung von IntelliJ als Entwicklungsumgebung muss der Annotation Processor in den Einstellungen aktiviert werden. Zum Ausführen der Codegenerierung genügt dann ein Klick auf Build Project und einen Moment später sind die generierten Dateien für die Verwendung bereit. Ähnliche Einstellungen existieren auch in Eclipse und anderen IDEs.
Listing 2
import org.immutables.value.Value;
@Value.Immutable
public interface Person {
String getFirstname();
String getLastname();
}
Im folgenden Beispiel ist zu sehen, wie die generierte Klasse ImmutablePerson benutzt werden kann. Dabei fällt auf, dass auch ein Builder generiert wurde, der für die Erzeugung von Objekten genutzt wird. Damit wird zunächst eine Instanz angelegt und später mit der withLastname-Methode eine Kopie erzeugt. Das ursprüngliche Objekt wird dabei nicht verändert, auch wenn wir hier im Beispiel die Referenz des ursprünglichen Objekts überschreiben:
ImmutablePerson luise = ImmutablePerson.builder()
.firstname("Luise")
.lastname("Müller")
.build();
luise = luise.withLastname("Meier");
Die Bibliothek benutzt einen Standardstil bei der Generierung der Klassen. Beispielsweise wird standardmäßig die Bezeichnung des Interface genommen und das Präfix Immutable davor gesetzt, um den Klassennamen der zu generierenden Klasse zu erhalten.
Dieser Stil kann mittels der Annotation @Value.Style auch den eigenen Wünschen angepasst werden. Diese erlaubt eine recht umfangreiche Anpassung der Namensgebung – sowohl für die Klasse selbst als auch für die generierten Methoden. Beispielsweise können wir die Namensgebung für den Klassennamen umkehren, sodass das Interface ImmutablePerson lauten muss und die daraus generierte Klasse nur noch Person heißt. Dazu wird in der Style-Annotation das Attribut typeAbstract auf Immutable* gesetzt und das Attribut typeImmutable auf *. In Listing 3 sind diese und einige weitere Style-Änderungen zu sehen. Die Angabe init = set* führt dazu, dass die Methoden des Builders wie normale Setter aussehen. Das Attribut depluralization kommt bei Feldern zum Tragen, die einen Collection Type haben.
Für die Person-Klasse wäre es beispielsweise vorstellbar, dass diese eine Liste von Spitznamen enthält. Immutables generiert für Collection-Felder automatisch Methoden zum direkten Hinzufügen und Entfernen von Elementen. Bei einer Methode List<String> getNicknames() im Interface würde also zusätzlich auch die Methode addNicknames zum Builder hinzugefügt werden. Mit dem depluralization-Attribut kann dies so konfiguriert werden, dass stattdessen die Einzahl im Namen benutzt wird. Die Methode würde dann also addNickname lauten. Ob einem dieses Detail wichtig ist oder nicht, muss aber jeder selbst entscheiden. Es soll an dieser Stelle nur zeigen, dass die Codegenerierung bei Bedarf stark angepasst werden kann.
Listing 3
@Value.Style(
typeAbstract = "Immutable*",
typeImmutable = "*",
init = "set*",
depluralization = true
)
public interface ImmutablePerson {
...
}
Im Beispiel wurde die Annotation @Value.Style direkt auf das Interface gesetzt. In der Regel ist es aber praktischer, projektweit einen einheitlichen Stil festzulegen und dann wiederzuverwenden. Dazu können eigene Annotationen definiert und mit der gewünschten Style-Annotation versehen werden. Anschließend muss nur noch die eigene Style-Annotation ohne weitere Konfiguration verwendet werden. Sowohl selbstdefinierte Style-Annotationen als auch die vorgegebene Annotation können nicht nur an Klassen, sondern auch an Packages gesetzt werden. Dazu legt man eine package-info.java-Datei im jeweiligen Package an und setzt die Annotation an das darin beschriebene Package. Der Style bezieht sich dann auf alle in diesem Package enthaltenen Klassen sowie alle Unter-Packages.
Neben Immutable-Klassen lassen sich auch Modifiable-Klassen generieren. Dies mag zunächst eigenartig klingen, da doch der eigentliche Zweck der Bibliothek die Vermeidung von Mutationen ist. Tatsächlich kann dieses Feature aber in manchen Fällen ungemein praktisch sein. Zunächst setzt man neben der @Value.Immutable-Annotation auch noch @Value.Modifiable. Als Ergebnis erhält man eine ModifiablePerson-Klasse, die einem normalen POJO recht ähnlich ist, also neben den Getter-Methoden auch Setter enthält, mit denen sich die Instanz selbst auch verändern lässt.
Interessant wird es im Zusammenspiel mit der unveränderlichen Version, denn eine Transformation ist in beide Richtungen möglich. Ein Anwendungsfall dafür wäre beispielsweise eine Funktion, die als Argument ein unveränderliches Objekt bekommt und als Ergebnis eine neue veränderte Version des Objekts liefern soll. Dafür können – wie oben beschrieben – die generierten Wither-Methoden genutzt werden. Mit den Modifiable-Klassen wäre es aber auch möglich, aus dem Parameter zunächst eine modifizierbare Version zu erzeugen, die Änderungen direkt im klassischen Setter-Stil durchzuführen und anschließend für das Ergebnis wieder in eine unveränderliche Kopie umzuwandeln. Damit kann lokal bei Bedarf ein imperativer Stil gewählt werden, während von außen betrachtet trotzdem die Korrektheit der funktionalen Programmierung gewahrt bleibt.
Ein anderer Anwendungsfall für dieses Feature ist die Interaktion mit anderen Bibliotheken und Frameworks, die die übliche Struktur aus Gettern und Settern erwarten. Listing 4 zeigt die beispielhafte Benutzung der modifizierbaren Klassen.
Listing 4
ImmutablePerson person = ...
ModifiablePerson modifiablePerson = ModifiablePerson.create().from(person);
modifiablePerson.setFirstname("Luise");
modifiablePerson.setLastname("Müller");
ImmutablePerson newPerson = modifiablePerson.toImmutable();
Ein wichtiges Ziel bei der Benutzung der Immutables-Bibliothek ist die Sicherstellung von konsistenten und validen Objekten. Bei veränderbaren Objekten kann es sinnvoll sein, zu verschiedenen Zeitpunkten die Gültigkeit der Objekte zu prüfen, da diese ja durch Modifikationen in einen gültigen oder ungültigen Zustand gebracht worden sein könnten. Bei unveränderlichen Objekten ist dies nicht der Fall. Ein einmal ungültig erzeugtes Objekt wird nie wieder valide. Daher führt der generierte Code verschiedene Prüfungen durch, die verhindern sollen, dass überhaupt ungültige Objekte erzeugt werden können. Beispielsweise prüft der generierte Builder beim Aufruf der abschließenden build-Methode, ob alle Felder einen Wert erhalten haben. Nicht belegte Felder oder null als Wert führen zu einer Exception. Dies gilt übrigens auch bei der Umwandlung eines Modifiable-Objekts in die entsprechende unveränderliche Variante.
Eine einfache Prüfung auf Vorhandensein von Werten reicht aber nicht immer aus, um wirklich konsistente Objekte zu erhalten. Die Immutables-Bibliothek erlaubt es daher, zusätzliche Prüfungen zu implementieren, die bei der Erzeugung des Objekts ausgeführt werden. Dazu wird einfach eine Methode zum Interface hinzugefügt, die das Objekt auf Gültigkeit prüft und im Fehlerfall eine Exception wirft. Die Methode muss anschließend mit der Annotation @Check versehen werden, wie in Listing 5 zu sehen ist. Hier wird geprüft, dass der Vorname nicht leer ist.
Listing 5
@Value.Immutable
public interface Person {
String getFirstname();
String getLastname();
@Value.Check
default void check() {
if(getFirstname().trim().isEmpty()) {
throw new IllegalStateException("Firstname may not be empty");
}
}
}
In manchen Fällen kann es aber durchaus gültig sein, wenn bestimmte Werte nicht vorhanden sind. Seit Java 8 steht hierfür die Optional-Klasse zur Verfügung, und auch die Immutables-Bibliothek bringt Unterstützung dafür mit. Liefert eine Getter-Methode als Rückgabewert ein Optional zurück, wird dieses durch den erzeugten Builder standardmäßig mit Optional.empty() initialisiert. Somit kann der Wert beim Erzeugen von Objekten weggelassen werden.
Des Weiteren ist es auch möglich, für bestimmte Felder Defaultwerte anzugeben. Hierzu wird eine Defaultimplementierung des Getters im Interface angelegt, die den Standartwert liefert. Außerdem muss auch hier eine spezielle Annotation vergeben werden. Damit verlangt der Builder nun auch hier nicht mehr zwingend einen Wert für dieses Feld. Listing 6 zeigt die Implementierung eines optionalen Feldes und eines Feldes mit Standardwert.
Listing 6
@Value.Immutable
public interface Person {
//...
@Value.Default
default String getSpecies() {
return "human";
}
Optional<Soh jtring> getNickname();
}
Die Arbeit mit unveränderlichen Werten mag für viele Entwickler zunächst ungewohnt erscheinen. Wie so oft geht die Arbeit aber leichter von der Hand, wenn man ein wenig Übung hat. Besonders die andere Denkweise der funktionalen Programmierung, die Seiteneffekte und Mutationen zu vermeiden versucht, dürfte vor allem am Anfang schwierig sein. Leider macht es einem Java dabei nicht so angenehm wie echte funktionale Programmiersprachen wie zum Beispiel Haskell. Mit den richtigen Hilfsmitteln lassen sich aber auch in Java ganz brauchbare Ergebnisse erzielen. Die Bibliothek Immutables stellt dazu einen Codegenerator zur Verfügung, der auf der einen Seite relativ schnell einsatzbereit ist und ohne viel Konfiguration bereits gute Ergebnisse liefert. Auf der anderen Seite steckt die Bibliothek aber auch voller zusätzlicher Features, die für viele Problemstellungen interessante Lösungen bieten und eine tiefgreifende Anpassung an die eigenen Wünsche erlauben. Die hier gezeigten Funktionen stellen dabei nur einen kleinen Teil der Möglichkeiten dar. Beispielsweise unterstützt Immutables auch von Haus aus die Serialisierung und Konvertierung zu JSON mittels Jackson oder GSON. Damit eignet sich der Codegenerator auch für die Erzeugung von Message-Objekten, zum Beispiel in JAX-RS Services. Die ausführliche Dokumentation der Bibliothek ist ein weiterer Pluspunkt.
Ob die Verwendung von unveränderlichen Daten für das eigene Projekt in Frage kommt, hängt natürlich weiterhin von den jeweiligen Gegebenheiten und auch den Vorlieben der Projektbeteiligten ab. Aber wenn die Entscheidung auf einen funktionalen Stil fällt, sind Bibliotheken wie Immutables sicherlich eine Bereicherung.
Manuel Mauky arbeitet als Softwareentwickler bei Saxonia Systems AG in Görlitz. Er programmiert hauptsächlich mit Java und JavaScript, interessiert sich aber auch für funktionale Programmierung. Manuel ist Organisator der JUG Görlitz und regelmäßiger Sprecher auf Konferenzen.
Der Begriff Blockchain ist fast schon allgegenwärtig. Doch was genau ist das eigentlich? In diesem Artikel wollen wir die Technologie verständlich und nachvollziehbar vorstellen. Dazu erläutern wir zunächst den historischen Ursprung und die gesellschaftliche Bedeutung, bevor anschließend prägnante technologische Merkmale der Blockchain dargestellt werden. Zum Abschluss stellen wir, um die praktische Relevanz der Technologie herauszustellen, zwei Anwendungsbeispiele vor, in denen die Blockchain-Technologie bereits eingesetzt wird.
Erstmals wurde die Blockchain innerhalb des Bitcoins eingesetzt, einer digitalen Kryptowährung, die keinen Fundamentalwert aufweist [1]. Das heißt, dass die Benutzer nicht im physischen Besitz der Währung oder eines Gegenwerts sind. Zusätzlich wird der Preis des Bitcoins ausschließlich aus dem Verhältnis von Angebot und Nachfrage generiert [2].
Der Bitcoin und die zugrunde liegende Blockchain-Technologie wurden im Jahr 2008 in dem Artikel: „Bitcoin: A Peer-to-Peer Electronic Cash System“ veröffentlicht, verfasst von einer Einzelperson oder einer Gruppe, die unter dem Pseudonym „Satoshi Nakamoto“ bekannt ist. Das Ziel lag in der Entwicklung eines elektronischen Zahlungssystems, innerhalb dessen die dezentralen Benutzer ohne eine übergeordnete Instanz vertrauenswürdige Transaktionen untereinander abwickeln können. Außerdem wurden zentrale Problemstellungen aus dem Bereich des „Double Spending“ und der „Byzantinischen Fehler“ der zeitlich früher implementierten Zahlungssysteme in der Anwendung Bitcoin eliminiert.
Das Double Spending beschreibt ein Problem, bei dem die Benutzer mehr Transaktionen ausführen als dem Gesamtwert ihres Vermögens in der jeweiligen Kryptowährung entspricht. Ansonsten wäre es möglich, dass die Gesamtanzahl der auf dem Markt gehandelten Währungseinheiten überproportional zunimmt und somit der Wert der einzelnen Währungseinheit reduziert wird.
Nach dem Problem der Byzantinischen Generäle müssen sich die teilnehmenden Benutzer des Systems auf eine gemeinsame Strategie im Umgang mit den zu bestätigenden Transaktionen einigen, um inhärente Systemausfälle zu vermeiden. Allerdings können diese ein widersprüchliches Verhalten entgegen ihren anfänglich getroffenen Entscheidungen aufweisen. Darüber hinaus können die Benutzer bewusst falsche Entscheidungen verkünden, um andere in ihrer Entscheidungsfindung zu beeinflussen. Deshalb ist in der Realität der vermehrte Transfer fehlerhafter und manipulierter Informationen zu beobachten, die die gemeinsame Entscheidungsfindung erschweren.
Im Rahmen des Entscheidungsprozesses werden die illegalen Transaktionen als fehlgeschlagen anerkannt und aus dem zugrunde liegenden Netzwerk entfernt. Das geschieht nur, wenn ein gemeinsamer Konsens bezüglich der illegalen Transaktion gefunden wird. Daher folgt aus dem Problem der Byzantinischen Generäle die Zielsetzung, ein zuverlässiges Datenverarbeitungssystem unter Berücksichtigung eines entsprechenden Algorithmus zu entwickeln, das eine ungewisse Anzahl fehlerhafter Komponenten und die damit zusammenhängenden widersprüchlichen Informationen bewältigen kann. Die zwei Problemstellungen werden in der Anwendung Bitcoin durch das offene Peer-to-Peer-Netzwerk (P2P-Netzwerk) und den integrierten Proof-of-Work-(PoW-)Konsensmechanismus behoben.
Das öffentliche Interesse am Bitcoin und der damit verbundenen Blockchain-Technologie in den letzten Jahren kann durch den in Abbildung 1 dargestellten Verlauf veranschaulicht werden. Dazu wird die Entwicklung der eindeutig zuordenbaren Wallet-Adressen innerhalb der Anwendung Bitcoin zwischen September 2009 und Juni 2018 betrachtet.
Um in einem weiteren Schritt das Interesse am Bitcoin von dem an der Blockchain-Technologie zu unterscheiden, wird die Arbeit von Zhao et al. aus dem Jahr 2016 angeführt [3]. Der Untersuchungsschwerpunkt lag in der Analyse des Google Trend Search Volume, bezugnehmend auf die Suchbegriffe Bitcoin und Blockchain. Dabei konnten die Autoren ermitteln, dass bis etwa August 2014 dem Bitcoin ein höheres öffentliches Interesse entgegengebracht wurde als der Blockchain. Jedoch stellt sich seit diesem Zeitpunkt ein Trendwechsel ein. So wächst das gesellschaftliche Interesse an der Blockchain überproportional im Vergleich zum Interesse am Bitcoin. Allerdings erstreckt sich der Untersuchungszeitraum von November 2011 bis November 2016 und gibt keine Auskunft über die vergangenen zwei Jahre. Trotzdem verdeutlicht die Arbeit ein erhöhtes gesellschaftliches Interesse an der Blockchain-Technologie sowie die Anerkennung als technisch innovativer Hintergrund des Bitcoins.
Damit ein hoher Sicherheitsstandard bei der Datenverarbeitung gewährleistet ist, existieren unterschiedliche kryptografische Verfahren. Diese vermeiden, dass ein potenzieller Angreifer des Netzwerks die Transaktionen einsehen und nachträglich verändern kann. Die erforderlichen digitalen Schlüssel und Signaturen für die Kryptografie werden nicht im Netzwerk der Blockchain hinterlegt, sondern von den Benutzern in einer Datei oder einer Datenbank gespeichert. Diese werden als Wallet bezeichnet. Um die gewünschte Transaktion abzuwickeln, muss eine gültige Signatur in die Blockchain aufgenommen werden, die nur durch den zugehörigen digitalen Schlüssel authentifiziert werden kann. Dabei liegt der Unterschied zwischen der asymmetrischen und der symmetrischen Kryptografie in der Anzahl der verwendeten Schlüssel. Diesbezüglich werden in der asymmetrischen Kryptografie der Public Key und der Private Key genutzt. Generell ist der Public Key für die Verschlüsselung der Transaktionen und die Authentifizierung der digitalen Signaturen verantwortlich, während der Private Key die Entschlüsselung der Transaktionen und die Erzeugung der Signaturen übernimmt (Abb. 2).
Wenn nun zwischen zwei Akteuren eine Nachricht transferiert werden soll, erhält der Sender vom Empfänger dessen Public Key, um die Nachricht zu verschlüsseln. Danach wird die verschlüsselte Nachricht vom Sender an den Empfänger versendet und durch den Private Key des Senders entschlüsselt.
Demgegenüber verwendet die symmetrische Kryptografie ausschließlich einen Schlüssel für die Ver- und Entschlüsselung der Transaktionen. Dieser muss vor der Entschlüsselung über einen sicheren Datenkanal zwischen Absender und Empfänger transferiert werden. Um eine sichere Übertragung zu ermöglichen, wird in der Praxis vermehrt die hybride Verschlüsselung angewandt. Diese stellt eine Kombination aus der asymmetrischen und der symmetrischen Kryptografie dar. Der Ausgangspunkt des Verfahrens liegt in der Ermittlung des Session Keys, durch den anschließend die Transaktion verschlüsselt wird. Danach wird dieser selbst durch ein asymmetrisches Verfahren codiert und der Transaktion hinzugefügt. Nach erfolgreicher Übertragung der Transaktion kann der Empfänger den angehängten Session Key mit seinem Private Key entschlüsseln und ihn nutzen, um die ursprüngliche Transaktion einzusehen.
Grundsätzlich besteht die Blockchain aus der chronologischen Verkettung einzelner kryptografischer Blöcke. Die einzelnen Blöcke umfassen die Transaktionen und werden im P2P-Netzwerk durch die anderen Benutzer verifiziert. Anschließend werden sie in der Blockchain gespeichert und vertraulich archiviert. Dabei zählen zu den bedeutenden Eigenschaften der zugrunde liegenden Technologie der dezentrale Datentransfer ohne die Notwendigkeit, einen zusätzlichen Intermediär einbeziehen zu müssen, die Anonymität der Benutzer und die erschwerte nachträgliche Veränderung der Transaktionshistorie. In Bezug auf die erstgenannte Eigenschaft wird zwischen drei verschiedenen Ansätzen unterschieden: Private Blockchain, Public Blockchain und Hybrid Blockchain.
Der Ansatz der Public Blockchain basiert auf der Idee, dass eine undefinierte Benutzeranzahl dezentral auf das gemeinsame Netzwerk zugreifen kann. Innerhalb des Ansatzes herrschen für die Benutzer in jedem Netzknoten die gleichen Nutzungsrechte. Zudem wird die Blockchain durch die Gesamtheit der einzelnen Netzknoten fortlaufend aktualisiert und verifiziert. Die wesentlichen Vorteile, die sich aus diesem Ansatz ergeben, liegen in einem erhöhten Sicherheitsstandard, den geringeren Kosten und der schnelleren Transaktionsabwicklung für die Benutzer. Hingegen stellt die begrenzte Skalierbarkeit des Netzwerks einen wesentlichen Nachteil des Ansatzes dar.
Demgegenüber erhält im Rahmen des Ansatzes der Private Blockchain eine begrenzte Benutzeranzahl die Möglichkeit, das gemeinsame Netzwerk zu nutzen. Die Benutzer interagieren unter einer zentral übergeordneten Instanz. Abweichend vom Ansatz der Public Blockchain werden den einzelnen Netzknoten unterschiedliche Nutzungsrechte zugeordnet. Der Vorteil dieses Ansatzes geht mit den vorher festgelegten Zugriffsrechten ausgewählter Benutzer einher. Dadurch wird es einem vorab definierten Benutzerkreis erlaubt, bestimmte Transaktionen im Netzwerk durchzuführen. Als generelle Nachteile lassen sich eine erhöhte Manipulationsgefahr und eine verstärkte Abhängigkeit gegenüber der übergeordneten Instanz identifizieren.
Der Ansatz der Hybrid Blockchain kombiniert die Ansätze der Public und Private Blockchain miteinander. Innerhalb dessen entscheiden einzelne weisungsbefugte Benutzer oder übergeordnete Instanzen, welche Transaktionen öffentlich bleiben und welche nur von einer bestimmten Benutzergruppe einsehbar sind. Außerdem besteht die Möglichkeit, dass den einzelnen Benutzern nachträglich die Zugriffsrechte innerhalb des Netzwerks wieder entzogen werden. Eine weitere zentrale Eigenschaft ist die Anonymität der Benutzer. Zwar sind die Transaktionswerte und -salden der Benutzer öffentlich für Dritte einsehbar, jedoch ist es praktisch ausgeschlossen, durch die Betrachtung einer zufällig ausgewählten Transaktion potenzielle Rückschlüsse auf das dahinterstehende Individuum bzw. das Unternehmen ziehen zu können. Durch die Gesamtbetrachtung aller Transaktionen, die von der gleichen Wallet-Adresse ausgeführt wurden, wären hingegen personenbezogene Rückschlüsse vereinfacht möglich [4].
Abschließend bleibt die Eigenschaft der nachträglich erschwert zu verändernden Transaktionshistorie. Dabei ist die Blockchain generell vergleichbar mit einem Bestandsbuch, innerhalb dessen eine nachträgliche Veränderung nur möglich ist, wenn eine neue Transaktion dem Bestandsbuch hinzugefügt wird. Allerdings sind die vergangenen Blöcke, die die Historie aller Transaktionen abbilden, nur durch einen hohen Programmieraufwand im Nachhinein veränderbar. Ergänzend dient das Bestandsbuch hauptsächlich der Vermeidung des Double Spendings. Somit wird die gesicherte Übertragung eines Vermögenswerts zwischen zwei Benutzern gewährleistet, da vorher der Besitzanspruch des veräußernden Benutzers geprüft wird. Außerdem besteht nach der Transaktionsverifizierung für die Benutzer keine Möglichkeit, die Transaktion zu kopieren und wiederholt auszuführen.
Grundsätzlich beschreibt der Konsensmechanismus einen Prozess, in dem sich die Benutzer des Netzwerks über die einzelnen Transaktionen und den Zustand der Blockchain einigen. Dieser wird in regelmäßigen Zeitintervallen ausgeführt und gewährleistet die fortlaufende Überprüfung der Blockchain. Dabei existieren unterschiedliche Konsensmechanismen, die je nach technischer Ausgestaltung der Blockchain eingesetzt werden.
Der am häufigsten verwendete Konsensmechanismus innerhalb der bereits praktisch implementierten Blockchain-Anwendungen ist der Proof of Work (PoW). Er verfolgt die Zielsetzung, dass jeder Benutzer seine individuelle Rechenleistung für die Verifizierung der Transaktionen und für die Erzeugung der entsprechenden Blöcke zur Verfügung stellt. Dadurch wird der aktuelle Stand der Blockchain fortlaufend mit dem Bestandsbuch abgeglichen. Die benötigte Rechenkapazität innerhalb des im nachfolgenden Kapitel dargestellten P2P-Netzwerks wird somit nicht von einem zentralen Rechner zur Verfügung gestellt, sondern von allen teilnehmenden Endgeräten.
In dem angesprochenen Netzwerk sind die Blockgröße und der zugrunde liegende Informationsverarbeitungsmechanismus für den PoW von besonderer Bedeutung. Dabei wird die maximale Blockgröße auf Protokollebene durch den Speicherwert festgelegt. Der Schwierigkeitsgrad in der Ausführung des PoW wird somit von der Größe des Blockintervalls beeinflusst. Das bedeutet, je kleiner das Blockintervall ist, desto schneller kann die Verifizierung der neu hinzukommenden Transaktionen durch die teilnehmenden Benutzer vorgenommen werden. Demnach verfolgen die Benutzer das übergeordnete Ziel der effizienten und kompakten Definition einzelner Transaktionen. Dadurch besteht die Möglichkeit, eine größere Anzahl an Blöcken innerhalb kurzer Zeit der Blockchain hinzuzufügen und die Rate der veralteten Blöcke zu reduzieren.
Von veralteten Blöcken wird gesprochen, wenn diese nicht in der längsten Blockchain enthalten sind. Die Existenz von veralteten Blöcken beeinträchtigt den Sicherheitsstandard und die Leistungsfähigkeit der Blockchain, denn die Blöcke führen zu einer erhöhten Serverbelastung und schütten im Rahmen des Mining-Prozesses keine Entschädigung bzw. Prämie an die Miner aus. Der Informationsverarbeitungsmechanismus bestimmt hingegen, wie die Informationen an die gleichberechtigten Benutzer innerhalb des Netzwerks übermittelt werden.
Aufgrund der Vielzahl an Informationen in den einzelnen Blöcken ist ein Protokoll erforderlich. Die Struktur des zugrunde liegenden Protokolls wiederum hat Auswirkungen auf die Robustheit und Skalierbarkeit des entsprechenden Netzwerks. Um die Sicherheit im PoW zu garantieren, dürfen nicht mehr als 50 Prozent der Rechenleistung von einer bestehenden Einheit zur Verfügung gestellt werden. Diese könnte ansonsten die Blockchain effektiv steuern. Darüber hinaus sind in der Literatur viele Manipulationsversuche des PoW im Bereich des Double Spending und des Selfish Mining dokumentiert.
Beim Double Spending handelt es sich, wie bereits angesprochen, um einen Angriff, in dem der Benutzer mehr Währungseinheiten innerhalb einer Transaktion ausgibt als er tatsächlich besitzt. Hingegen beschreibt das Selfish Mining eine Strategie im Rahmen des Mining-Prozesses, in der sich die dezentralen Miner zu Einheiten zusammenschließen, um ihre Einnahmen aus dem dadurch zentralisierteren Mining-Prozess zu steigern. Damit die Manipulationsversuche in diesen Bereichen ausgeschlossen werden, muss eine strikte Synchronisierung zwischen den einzelnen Blöcken bestehen.
Um dem hohen Energieverbrauch im PoW entgegenzuwirken, wurde der Proof of Stake (PoS) als energiesparendere Alternative entwickelt. Dieser benötigt im Vergleich zum PoW weniger Rechenleistung innerhalb des Mining-Prozesses und zielt darauf ab, einen gleichmäßig verteilten Benutzerkonsens zu gewährleisten. Grundsätzlich unterscheidet sich der technische Hintergrund zwischen den beiden Alternativen. Im Rahmen des PoS-Netzwerks besitzt jeder Benutzer einen bestimmten Anteil an der digitalen Kryptowährung. Für das Hinzufügen eines weiteren Blocks zur Blockchain wird ein bestimmter Benutzer durch einen Zufallsprozess ausgewählt. Die Wahrscheinlichkeit, der zufällig ausgewählte Benutzer zu sein, variiert mit der Höhe des Anteils an der digitalen Kryptowährung oder dem Münzalter. Um ein höheres Münzalter gegenüber den anderen Benutzern aufzuweisen, müssen die Münzen über einen längeren Zeitraum gehalten und nicht im Rahmen einer Transaktion ausgegeben werden. Im PoS-Netzwerk interagieren alle Stakeholder als Miner. Diese müssen ihre Wallets für die Bestätigung der Transaktionen zur Verfügung stellen und mit dem Netzwerk dauerhaft verbunden sein. Solche über eine längere Periode verfügbaren Wallets werden als „vollständige Knoten“ bezeichnet. Durch den beschriebenen Ansatz wird der hohe Energiebedarf im Validierungsprozess reduziert.
Als weiteres technologisches Merkmal der Blockchain wird das P2P-Netzwerk betrachtet. Beispielsweise verwendet Nakamoto in der Anwendung Bitcoin ein öffentliches P2P-Netzwerk. Dadurch hat eine unbegrenzte Benutzeranzahl die Möglichkeit, dezentral auf das Netzwerk zuzugreifen. Die Benutzer sind untereinander in der Transaktionsabwicklung gleichberechtigt, ohne dass eine übergeordnete Instanz regulierend eingreift. Daher werden die Daten zwischen den Benutzern innerhalb des Netzwerks in verschlüsselter Form direkt transferiert.
Im Netzwerk werden die Transaktionen der Blockchain über den PoW mit einem Zeitstempel versehen. Dabei ordnet der Server jedem einzelnen Blockelement einen Hash-basierten Zeitstempel zu. Der Zeitstempel bestätigt die Existenz der Daten zum aktuellen Zeitpunkt und erfasst sie in einem Hash-Wert. Jeder Zeitstempel enthält den vorherigen Zeitstempel mit seinem Hash-Wert und bildet eine Kette mit jedem zusätzlichen Zeitstempel, der die vorhergehenden ergänzt.
Die grundlegenden Prinzipien des P2P-Netzwerks können schrittweise unterteilt werden. In einem ersten Schritt werden die neuen Transaktionen an alle bestehenden Knoten übertragen. Danach erfasst jeder Knoten die neuen Transaktionen in einem Block und arbeitet an einem PoW für seinen Block. Nachdem ein Knoten einen PoW identifiziert hat, versendet er den Block an die übrigen Knoten. Im Anschluss werden die in dem Block enthaltenen Transaktionen hinsichtlich der Gültigkeit und der bisherigen Verwendung des hinterlegten Hash-Werts überprüft. Dabei treten bisweilen Fehler auf, die eine Ausführung der Transaktionen im P2P-Netzwerk verhindern. Zum Schluss berücksichtigen die Knoten den Block, wenn sie an der Erstellung des nächsten Blocks in der Blockchain mitwirken. Folglich dient der Hash-Wert des akzeptierten Blocks als Grundlage für die nachfolgenden Blöcke. Die Knoten identifizieren die längste Blockchain als korrekt und erweitern sie fortlaufend. Sollten zwei Blöcke dem Knoten parallel hinzugefügt werden, wird der zuerst ankommende Block genutzt. Der verbleibende Block wird gespeichert und für den Fall aufbewahrt, dass die Blockchain länger wird.
Ein praktisches Anwendungsbeispiel der Blockchain-Technologie im Bereich des Zahlungsverkehrs stellt das Zahlungssystem des Finanzdienstleistungsunternehmens Ripple Lab dar. Es wurde im Jahr 2012 gegründet und basiert auf einer digitalen Währung. Bei dieser Währung handelt es sich um einen digitalen Vermögenswert, der den Zahlungsverkehr effizienter gestaltet und die Zahlungssysteme der etablierten Finanzdienstleistungsunternehmen miteinander verbindet.
Durch den Einsatz von Ripple können Transaktionen im Vergleich zu den bereits etablierten Zahlungssystemen innerhalb weniger Sekunden abgewickelt werden, während sie in bei gängigen Verfahren zwischen drei und fünf Tagen dauern. Ergänzend wird Ripple von den Finanzdienstleistungsunternehmen genutzt, um neue Märkte zu erschließen, Transaktionskosten zu reduzieren und eine hohe Skalierbarkeit zu gewährleisten.
Das implementierte Zahlungssystem von Ripple verarbeitet konsistent 1 500 Transaktionen pro Sekunde. Im Vergleich dazu werden im Zahlungssystem des Bitcoins zwischen drei und sechs Transaktionen pro Sekunde durchgeführt. Die digitale Währung wird in einer Private Blockchain gehandelt, in der das Unternehmen Ripple Lab eine regulierende Position einnimmt und damit die Möglichkeit besitzt, im Rahmen der Transaktionsabwicklung einzugreifen.
Im Jahr 2015 wurde Ripple erstmalig im asiatischen Markt eingesetzt, um eine Plattform für die Nachverfolgung von Kundenverbindlichkeiten gegenüber Handelsunternehmen zu liefern, die aus dem Abschluss von Lieferantenkrediten entstehen. Die daraus resultierenden Forderungen der Handelsunternehmen werden in der zugrunde liegenden Plattform von Ripple hinterlegt, um den vertraulichen Umgang mit den Kunden- und Geschäftsinformationen zu gewährleisten. Aufgrund der gemeinsamen Informationsplattform wird das Risiko der doppelten Finanzierung seitens der teilnehmenden Unternehmen ausgeschlossen. Die Plattform liefert für die Unternehmen die Option, sich über den Status der Verbindlichkeiten aller erfassten Kunden zu informieren.
Die US-Börse Nasdaq konzipierte bereits zwei Blockchain-Prototypen im Bereich des Wertpapierhandels und des internen Abstimmungsprozesses. Im Rahmen des ersten Prototyps veröffentlichte Nasdaq zum Jahresende 2015 eine Blockchain-Plattform unter dem Namen Nasdaq Linq. Diese ermöglicht es unter Einsatz der Technologie, private Wertpapiertransaktionen für das Unternehmen Chain abzuwickeln. Dazu wurde die Plattform zunächst auf dem Nasdaq Private Market implementiert, um eine sichere Ausgabe und Übertragung von Wertpapieren innerhalb des geschaffenen Sekundärmarkts für die beteiligten Anleger und Banken zu gewährleisten.
Zusätzlich hat Nasdaq in Kooperation mit der Investmentbank Citigroup ein Blockchain-basiertes Zahlungssystem für die Plattform Nasdaq Linq entwickelt, um den internationalen Handel von Wertpapieren zu ermöglichen. Durch das integrierte Zahlungssystem sollen Liquiditätsengpässe im Wertpapierbereich durch die Rationalisierung von Zahlungstransaktionen zwischen mehreren Akteuren bewältigt werden. Darüber hinaus stellt der zweite Prototyp von Nasdaq eine elektronische Blockchain-Plattform bereit, in der die Aktionäre über potenzielle Fusionen, neue Aktienemissionen und neue Vorstände abstimmen können. Bisher wurde die Plattform nur in Estland und Südafrika in ersten Pilotprojekten getestet.
Daniel Aust studiert seit Oktober 2013 an der Leibniz Universität Hannover im Studiengang Wirtschaftswissenschaften. Dort absolviert er aktuell seinen zweisemestrigen Master im Bereich Banking & Insurance. Im Rahmen seiner Masterarbeit beschäftigte er sich mit der Blockchain-Technologie in der Finanzdienstleistungsbranche und ermittelte den praktischen Fortschritt beim Einsatz der Technologie.
Dr. Christoph Schwarzbach studierte zwischen 2000 und 2006 Betriebswirtschaftslehre an der Humboldt-Universität zu Berlin und an der Universidad de Salamanca (Spanien). 2006 nahm er seine Tätigkeit als wissenschaftlicher Mitarbeiter am Institut für Versicherungsbetriebslehre der Leibniz Universität Hannover und später am Kompetenzzentrum Versicherungswissenschaften auf, an dem er 2015 promovierte. Im Rahmen seiner Forschungsarbeit beschäftigt er sich mit Fragestellungen des Versicherungsvertriebs, des Risikomanagements sowie der Digitalisierung in der Versicherungsbranche.
[1] Nakamoto, S.: „Bitcoin: A Peer-to-Peer Electronic Cash System“
[2] Berentsen, A.; Schär, F.: „Bitcoin, Blockchain und Kryptoassets– Eine umfassende Einführung“
[3] Zhao, J.; Fan, S.; Yan, J.: „Overview of business innovations and research opportunities in blockchain and introduction to the special issue“
[4] Brühl, V.: „Bitcoins, Blockchain und Distributed Ledgers – Funktionsweise, Marktentwicklungen und Zukunftsperspektiven“