Anwendungen strukturiert aufbauen
Für die Struktur von Code in der objektorientierten Programmierung (OOP) eignet sich der Einsatz der SOLID-Prinzipien. Doch wie steht die Sache bei der Anwendungsarchitektur?
- Golo Roden
Die Frage, wie sich die Codearchitektur strukturieren lässt, scheint zunächst nicht einfach zu beantworten. Zu unterschiedlich sind Anwendungen, zu differenziert deren jeweilige Umgebungen, als dass es das eine pauschale Vorgehensmuster gäbe. Selbstverständlich gibt es Richtlinien, an denen man sich orientieren kann, allerdings beschreiben sie stets nur einen Ausschnitt des Gesamtbilds.
Elementar für die Entwicklung moderner Web- und Cloud-Angebote sind in dem Kontext die als 12 Factor Apps bekannten Regeln des Cloud-Dienstleisters Heroku. Sie sind im Folgenden mit 12FA abgekürzt. Ohne auf all ihre Aspekte im Detail eingehen zu wollen, zeigen die Empfehlungen rasch, in welche Richtung man gedanklich gehen muss: So sind insbesondere die Forderung nach statuslosen Prozessen zentral, die sich schnell starten und sauber beenden lassen. 12FA verlangt das explizite modellieren sämtlicher Abhängigkeiten – sei es mittels Paketverwaltung oder als externe, über eine Konfiguration beschriebene und angeforderte Dienste. Ein so entstandenes Modell ist eine Grundlage für den Einsatz von Microservices.
Auf den ersten Blick scheinen Letztere lediglich eine neue Bezeichnung für das zu sein, was vor rund 15 Jahren der Begriff SOA beschrieb, nämlich Webservices. Tatsächlich geht der Gedanke von Microservices aber weit über die Ideen der Service-oriented Architecture beziehungsweise REST (Representational State Transfer) hinaus, denn er beschreibt Details wie die Infrastruktur und den Umgang mit ihr als Teil des Services.
Der englische Entwickler Martin Fowler beantwortet die Frage nach einer Erklärung der Microservice wie folgt:
"In short, the microservice architectural style is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery. There is a bare minimum of centralized management of these services, which may be written in different programming languages and use different data storage technologies."
Unabhängig von dieser Definition gibt es den Ansatz, vom eigentlichen Wort auszugehen. Ein Service ist eine unabhängige, isolierte Einheit, die eine bestimmte Aufgabe für ihre Umgebung erfüllt. Sie agiert dabei autonom vom Rest ihrer Umwelt und ist idealerweise statuslos. Wenn man so will, ist eine Funktion die kleinste denkbare Einheit eines Service.
Alle bislang genannten Bedingungen erfüllt beispielsweise die in nahezu jeder Plattform verfügbare Funktion zur Berechnung des Sinus: Sie ist unabhängig, isoliert vom Rest der Plattform lauffähig, agiert autonom, ohne Abhängigkeit von anderen Funktionen und ist üblicherweise statuslos implementiert. Dennoch spricht man in dem Fall von einer Funktion und nicht von einem Service oder Dienst.
Dienste sind eigenständige Prozesse
Der Grund dafür ist, dass Dienste eigenständige Prozesse darstellen. Eine Funktion hingegen kommt zusammen mit vielen weiteren Funktionen in seinem Kontext zum Einsatz. Würde man die Funktion als eigenständigen Prozess modellieren, ließe sich tatsächlich von einem Dienst reden.
Der Begriff micro ist dagegen nicht so klar umrissen, allerdings macht er klar, dass es um eine kleine Einheit geht. Mit klein geht üblicherweise der Begriff fokussiert einher, denn wenn sich Entwickler auf wenig beschränken müssen, lassen sich ohnehin nicht mehr zahlreiche Themen zugleich angehen.
Zusammengefasst beschreibt der Gedanke von Microservices also kleine, eigenständige Einheiten, die isoliert und unabhängig voneinander arbeiten, und sich auf eine einzige Aufgabe konzentrieren. Das erinnert ein bisschen an das Konzept der Lego-Bausteine, die für sich genommen klein sind und nur eine einzige Aufgabe erfüllen. In beiden Fällen entsteht das eigentliche Potenzial aus der Kombinierbarkeit der Elemente.
SOLID für Architektur
Schlägt man den Bogen zu den aus der objektorientierten Welt bekannten SOLID-Prinzipien (siehe Exkurs "SOLID im Überblick" am Ende des Artikels), fällt auf, dass sich einige von ihnen hervorragend auf den Architekturansatz übertragen lassen. Am Stärksten gilt das vermutlich für das S, also das Single Responsibility Principle (SRP). Es besagt, dass jede Klasse nur eine einzige Verantwortung beziehungsweise Aufgabe haben sollte. Der US-amerikanische Informatiker Robert C. Martin, der unter anderem das Buch "Clean Code" geschrieben hat, hat das Prinzip wie folgt umrissen:
"There should never be more than one reason for a class to change."
Der Satz lässt sich auf Dienste übertragen: Sie sollten nur eine einzige Verantwortlichkeit im fachlichen Sinne haben und sich nur ändern, wenn es ihre fachliche Kernkompetenz betrifft. Insofern gilt SRP auch für Dienste und ist ein erster Indikator, wie Software zu schneiden sein könnte, um einen Monolithen in kleinere Einheiten zu zerlegen.
Der Fokus auf den fachlichen Aspekten spielt bei all dem eine zentrale Rolle, da ein Dienst primär eine fachliche Einheit darstellt und erst sekundär eine technische. Genau das meint auch Martin Fowler wenn er schreibt "services are built around business capabilities".
SRP allein genügt jedoch nicht. Ein weiterer Grund für schlechten, unüberschaubaren und damit langfristig kaum wartbaren Code ist die typische Redundanz, die man in vielen Projekten vorfindet. Das dafür gültige Rezept ist nicht Bestandteil der SOLID-Prinzipien und heißt "Don't Repeat Yourself". Es besagt, dass man immer, wenn man etwas zum zweiten Mal umsetzt, überlegen sollte, wie sich beide Vorkommen gemeinsam handhaben lassen.
Auf Code einer Klasse bezogen heißt das, dass man sich wiederholenden Code in eine gemeinsame Methode auslagern sollte. Bei der Architektur einer Anwendung bietet es sich an, Funktionen, die an unterschiedlichen Stellen genutzt werden, in einen gesonderten Dienst (oder zumindest ein gemeinsam verwendetes Modul) auszulagern. Man kann sogar soweit gehen zu sagen, dass sich DRY auch auf die Infrastruktur anwenden lässt: In dem Moment, in dem man einen zweiten Server so aufsetzt, dass er einem anderen ähnelt, sollte man darüber nachdenken, den Vorgang zu automatisieren. Damit wird er nicht nur skalierbar, sondern vor allem auch reproduzierbar und ist zudem dokumentiert. Das Vorgehen verhindert Snowflake Server und führt zu einem höheren Grad an automatisiert verwalteter Infrastruktur.
Zu guter Letzt muss noch gelten, dass Dienste unabhängig voneinander fungieren. Das lässt sich mit SRP und DRY noch nicht garantieren, allerdings gibt es dafür einen weiteren Baustein der SOLID-Prinzipien: Das I steht für das Interface Segregation Principle und besagt, dass eine Schnittstelle nur das absolute Minimum an zwingend notwendigem Funktionsumfang bereitstellen sollte, um eine Aufgabe zu erledigen. Alles darüber hinaus gehört in eine gesonderte Schnittstelle.
Bei Diensten verhält es sich genauso: Bietet jeder über seine Schnittstelle nur genau die fachlichen Fähigkeiten an, die zum Bewältigen einer Aufgabe erforderlich sind, werden Dienste unabhängiger voneinander und lassen sich im Bedarfsfall zugleich leichter kombinieren, ohne unnötigen Ballast mitzuschleppen. Robert C. Martin hat das wie folgt formuliert:
"Clients should not be forced to depend upon interfaces that they do not use."
Mit anderen Worten bedeutet das, dass ein Dienst, der auf einen anderen zurückgreift, sich dadurch nicht an weitere Funktionen binden sollte, sondern nur an das, was tatsächlich fachlich relevant ist.
Beispiele für Lego-Denken
In der Praxis gibt es zahlreiche konkrete Beispiele, die die zuvor genannten Entscheidungskriterien demonstrieren. Interessanterweise sind viele von ihnen älter als der Begriff der Microservices, was lediglich zeigt, dass die zugrunde liegenden Ideen und Konzepte wesentlich älter sind, als es der Hype vermuten lässt.
Eins der Beispiele sind die zahlreichen Werkzeuge, die unixoide Betriebssysteme auf der Kommandozeile zur Verfügung stellen. Die Kommandos laufen als eigenständige Prozesse, erfüllen genau eine Aufgabe und lassen sich flexibel kombinieren. Genau das zeichnet die Mächtigkeit der Kommandozeile im Gegensatz zu den meisten grafischen Benutzeroberflächen aus. Ein aktuellere Umsetzung sind unterschiedliche Anwendungen des Toolanbieters HashiCorp, die sich zum Verwalten einer Cloud-Infrastruktur verwenden lassen. Sie erfüllen eine abgesteckte Aufgabe und überlappen sich fachlich nicht. Theoretisch könnte man alle Tools in eine einzige Applikation zusammenpacken. Allerdings ginge so die Flexibilität verloren und man hätte einen riesigen Monolithen, den man entweder ganz oder gar nicht verwenden kann.
Wie hängen Dinge zusammen?
Der Zusammenhang der Elemente ist entscheidend. Als Beispiel sei ein Cloud-Dienst gewählt, auf den sich Dateien hoch- und später von dort wieder herunterladen lassen. Auf den ersten Blick scheint das eine simple Webanwendung zu sein, doch stellen sich rasch zahlreiche Fragen, die das Projekt nicht mehr ganz so einfach erscheinen lassen:
- Wäre es sinnvoll, die Web-UI von der Implementierung des Funktionsumfangs zu trennen? Sollte es folglich eine REST-API für die Funktionen geben, die die UI im Hintergrund verwendet?
- Ist das Backend, das die hochgeladenen Dateien speichert, austauschbar zu gestalten, um beispielsweise zwischen einem lokalen Datenträger und einem anderen Dienst wechseln zu können?
- Wie steht es damit, die Authentifizierung auszulagern und beispielsweise eine mit Tokens arbeitende Anmeldung zu verwenden?
- Bietet es sich an, das Logging auszulagern, und beispielsweise einen gesonderten Logserver einzusetzen, der entsprechende Nachrichten einsammelt und verarbeitet?
Alle Fragen lassen sich mit ja beantworten, wenn man Wert auf kleine, isolierte Einheiten legt, die unabhängig voneinander funktionieren. Selbst ein derart kleines Beispiel lässt sich in unterschiedliche Dienste zerlegen, da viele der genannten Aspekte (Authentifizierung, Logging, Persistenz) gar nicht mit der primären Fachlichkeit zusammenhängen, sondern sich in zahlreichen anderen Anwendungen ebenfalls nutzen ließen.
Eine solche Aufteilung führt zudem dazu, dass die einzelnen Komponenten besser und einfacher testbar sind und unterschiedliche Techniken zur Implementierung dienen können. Damit lässt sich verhindern, dass eine einzelne Technik systembeherrschend wird, weil man einzelne Dienste leicht austauschen kann, sofern die Schnittstelle nach außen gleich bleibt.
Das wiederum wirft die Frage nach einem technikunabhängigen Kommunikationsprotokoll auf. In dem Fall bietet sich HTTP an, über das sich offene Formate wie JSON oder XML austauschen lassen. Ausgeschlossen sind hingegen proprietäre Lösungen, sei es auf Protokoll- oder auf Formatebene.
Fachliche Strukturen schaffen
Nicht genug betont werden kann dabei der Aspekt, dass fachliche Strukturen zu schaffen sind und keine technischen. Ein klassisches Gegenbeispiel ist die übliche Struktur einer Anwendung, die auf dem MVC-Entwurfsmuster basiert. Häufig gibt es dort drei Verzeichnisse namens controller, model und views. Die Gliederung hat sich inzwischen so eingebürgert, dass kaum jemand sie noch hinterfragt – dabei ist sie ein Paradebeispiel für schlechtes Design.
Niemand arbeitet zunächst an sämtlichen Controllern und erst danach an den Views. Stattdessen findet die Arbeit stets übergreifend statt, etwa wenn beide Elemente für den Anmeldedialog zu implementieren sind. Fachlich zusammengehörende Komponenten wie sie trennt die beschriebene Struktur allerdings.
In dem Fall handelt es sich folglich nicht um eine Separation of Concerns, sondern eine Separation of Object Types. In der Praxis ist solch ein Vorgehen in etwa so sinnvoll wie das Gliedern eines Projekts nach classes, interfaces, structs und enums. Den Anbietern von MVC-Frameworks bleibt jedoch nichts anderes übrig, da sie nichts über den Anwendungsfall des Entwicklers wissen, der das Framework verwendet. In Ermangelung dessen geben sie daher die technische als ihnen einzig mögliche Struktur vor.
Für mit cross-cutting bezeichnete technische Belange einer Anwendung ist eine gesonderte Struktur zu schaffen. In der Regel handelt es sich dabei allerdings nur um Hilfsbausteine, die die eigentliche Aufgabe der Software unterstützen. Es gilt daher, nicht die Ausnahme zur Regel zu erklären.
Als häufiges Gegenargument führen Fürsprecher die technische Struktur großer Frameworks wie .NET ins Feld. Bei einem Blick auf sie lässt sich tatsächlich eine entsprechende Hierarchie erkennen (siehe Abbildung). Die Wurzel aller Namensräume trägt die Bezeichnung System, darunter liegen Räume wie System.Configuration, System.Data und System.Deployment.
Bereits der Name erklärt jedoch die Zuständigkeit der Namensräume: Sie behandeln technische und nicht fachliche Aspekte des Codes. Im Kontext von .NET ist das genau richtig, da es sich dabei gerade nicht um ein Anwendungsframework handelt, das auf eine bestimmte Fachdomäne spezialisiert ist, sondern ein Basisframework, dessen Fachlichkeit die Technik ist. Daher ist die von Microsoft gewählte Struktur durchaus richtig und angemessen.
Fazit
Zusammengefasst bleibt die Formel, dass man versuchen sollte, keine großen Anwendungen zu bauen, sondern jede Applikation solange zu zerlegen, bis dabei kleine Bausteine herauskommen, die unabhängig voneinander sind und sich isoliert ausführen lassen. Prinzipien wie das Single Responsibility Principle, Don't Repeat Yourself und das Interface Segregation Principle können dabei helfen.
Allen ist gemein, dass sie keine Garantie für eine sauber strukturierte Architektur bieten, als Richtlinie eigenen sie sich allerdings allemal. Verpassen Entwickler die Chance, etwas rechtzeitig auszulagern oder zu erweitern, wird der Aufwand im Lauf der Zeit größer und der Prozess damit teurer. Um den richtigen Zeitpunkt zu erkennen, hilft nur regelmäßiges Reflektieren und gegebenenfalls das Spiegeln des eigenen Codes mit anderen Entwicklern, um frühzeitig Feedback zu erhalten.
Golo Roden
ist Gründer, CTO und Geschäftsführer der the native web GmbH, eines auf native Webtechnologien spezialisierten Unternehmens. Für die Entwicklung moderner Webanwendungen bevorzugt er JavaScript und Node.js und hat mit "Node.js & Co." das erste deutschsprachige Buch zum Thema geschrieben.
Exkurs: SOLID im Überblick
Die SOLID-Prinzipien sind fünf Regeln für die objektorientierte Programmierung. Sie wurden von Robert C. Martin geprägt und verfolgen das Ziel, die Wartbarkeit von Code zu verbessern.
Sie lassen sich wie folgt zusammenfassen:
- Single-Responsibility-Prinzip (SRP): Die Regel besagt, dass es für jede Klasse nur einen Grund für Veränderung geben darf – anders gesagt, jede Klasse solle nur genau eine fachliche Verantwortlichkeit abdecken.
- Open-Closed-Prinzip (OCP): Klassen sollten sich erweitern lassen, ohne dass sich dadurch ihr Verhalten ändert. Eine Klasse sollte also offen für Erweiterung, aber geschlossen hinsichtlich der Änderungen sein.
- Liskovsches Substitutionsprinzip (LSP): Dass sich eine abgeleitete Klasse stets so zu verhalten habe, wie die zugrunde liegende Basisklasse, fordert das LSP. Das Ziel ist, jederzeit an Stelle der Superklasse eine Subklasse ohne semantische Änderungen verwenden zu können.
- Interface-Segregation-Prinzip (ISP): Große Interfaces sind auf mehrere kleine aufzuteilen, sodass ein Verwender nur genau jene Interfaces implementieren muss, die tatsächlich notwendig sind.
- Dependency-Inversion-Prinzip (DIP): Abhängigkeiten sollten stets von konkreten zu abstrakten Modulen verlaufen, sodass konkrete Implementierungen leichter auszutauschen sind und sich voneinander entkoppeln lassen.
(jul)