Eine gute Architektur ist wichtig, um eine Applikation langfristig wartbar und weiterentwickelbar zu halten, darin sind sich wohl alle einig. Aber wie stellt man sicher, dass die einmal überlegten Architekturvorgaben, wie z. B. eine Schichtenarchitektur oder – moderner – eine hexagonale Architektur (aka Clean Architecture) im Projekt auch dauerhaft eingehalten werden?
Die Applikationsarchitektur in der Pipeline
Normalerweise verstoßen Entwickler nicht absichtlich gegen Architekturvorgaben. Meistens passiert es unbemerkt beim Erstellen von neuem Code oder bei Refactorings. Daher ist es sinnvoll, dass diese Verstöße dem Entwickler so schnell wie möglich angezeigt werden, damit er darauf reagieren kann.
Aber was bedeutet „so schnell wie möglich“? Im Idealfall heißt das wohl, dass die Anzeige direkt in der IDE erfolgt. Da die verwendete IDE und auch deren Konfiguration sich häufig von Entwickler zu Entwickler unterscheiden, sollte die Überprüfung zusätzlich im Build-Prozess – und zwar lokal und in der Build Pipeline – erfolgen. Es gibt verschiedene Tools, mit denen man Architekturregeln beim lokalen Bauen oder in der Build Pipeline überprüfen kann. Die verbreitetsten sind wohl Sonarqube [1] und ArchUnit [2]. Mit beiden lassen sich Zugriffsregeln zwischen Klassen und Packages überprüfen, zirkuläre Abhängigkeiten erkennen und Namensregeln für gewisse Arten von Klassen definieren. Beide lassen sich sowohl in der IDE (Sonarqube via Plug-in, ArchUnit als Unit-Test) als auch in der Build Pipeline ausführen, sodass ein schnelles Feedback für den Entwickler gegeben ist.
Vorgaben der Makroarchitektur
Sobald mehrere Services in einem System miteinander kommunizieren, wie es z. B. (aber nicht nur) bei Microservices der Fall ist, werden weitere Architekturvorgaben benötigt. Die beziehen sich nicht auf den inneren Aufbau der einzelnen Services, sondern regeln die Kommunikation der Services untereinander.
Dabei gibt es gewisse Entscheidungen, die für alle Services gemeinsam getroffen werden müssen. Sie können sowohl technologischer als auch architektonischer Natur sein. Zu behandeln sind Themen wie Service Discovery, Authentication, Tracing, ggf. ein gemeinsames Logformat, um das Logging zu zentralisieren, Health Checks, Testing-Strategien (z. B. über Consumer-driven Contract Testing) oder auch eine gemeinsame Dokumentation des API (z. B. über OpenAPI). Sind in den entsprechenden Bereichen einmal Architekturvorgaben entstanden, stellt sich natürlich auch hier die Frage, wie eine Einhaltung dieser Vorgaben sichergestellt werden kann.
LUST AUF MEHR SOFTWARE ARCHITEKTUR?
Zahlreiche aktuelle Themen wie KI, LLMS und Machine Learning, sowie Frontend-Architektur, für Tools zur Softwarearchitektur, Cloudlösungen und Software beweisen.
Einhalten der Makroarchitekturvorgaben in der Pipeline
Im Gegensatz zu den oben erwähnten Tools zur Einhaltung von Architekturvorgaben innerhalb eines Service gestaltet sich die Überprüfung der genannten Makroarchitekturvorgaben deutlich komplizierter. Meistens läuft es darauf hinaus, das spezielle Integrationstests geschrieben werden müssen, die die jeweiligen Vorgaben testen. Das kann z. B. ein Test sein, der überprüft, ob ein ausgehender REST Call auch tatsächlich die korrekten Tracing-Header enthält oder ob die Logausgaben während eines Calls auch tatsächlich den Vorgaben entsprechen.
Für andere Makroarchitekturvorgaben sind spezielle Schritte im Build notwendig. So ist es bei der klassischen Java-Integration von Pact [3] (einem Tool für Consumer-driven Contracts) so, dass zwar die Contracts beim normalen Build entstehen, sofern man Consumer-Contract-Tests geschrieben hat, das Hochladen der Contracts aber in einem separaten Schritt erfolgen muss (z. B. über das Pact-Maven-Plug-in [4]). Zusätzlich kann in einer Continuous Deployment Pipeline über das von Pact mitgelieferte Tool can-i-deploy [5] vor dem Deployment sichergestellt werden, dass der auf eine Stage zu deployende Stand kompatibel zu den anderen Services ist, die aktuell auf der entsprechenden Stage sind.
Architekturvorgaben, wie z. B. das Veröffentlichen der OpenAPI-Dokumentation an einer zentralen Stelle, können natürlich auch als expliziter Schritt in der Pipeline erfolgen. Wir können bereits beim Betrachten dieser wenigen Beispiele feststellen, dass die Einhaltung von Makroarchitekturvorgaben häufig nicht so einfach ist, wie bei der oben beschriebenen Applikationsarchitektur. Das liegt allerdings eher in der Natur der Makroarchitekturvorgaben, weil sie eben nicht nur den eigenen Code betreffen, sondern auch aufrufende und aufgerufene Services und die Kommunikation dazwischen.
Ein weiteres Problem bei der Überprüfung von Vorgaben in der Pipeline ist die Tatsache, dass das Team selbst auch die Art der Überprüfung und damit die Interpretation der Vorgaben vornimmt. Da es sich bei der Makroarchitektur aber um serviceübergreifende und damit in der Regel auch um teamübergreifende Vorgaben handelt, sollten alle Teams die gleiche Sicht auf die Vorgaben haben, und natürlich sollten sich auch alle Teams auf die gleiche Art daran halten. Darüber hinaus müssen natürlich auch alle Teams ein Interesse daran haben, sich an die Vorgaben zu halten.
Überprüfen der Makroarchitektur beim Deployment oder zur Laufzeit
Um das sicherzustellen, muss es eine Instanz außerhalb der einzelnen Service-Teams geben, die die Makroarchitekturvorgaben festlegt und weiterentwickelt. Das kann ein Architekturgremium sein, in das jedes Service-Team eine oder mehrere Personen entsendet oder ein Systemarchitekt, der die Vorgaben initial entwickelt und weiterpflegt. Das Ganze erfolgt dann (hoffentlich) in Abstimmung mit den jeweiligen Service-Teams, sodass auch dort ein Verständnis für die Vorgaben gegeben ist.
Dennoch zeigt die Erfahrung, dass solche Regeln dauerhaft nur von allen Teams eingehalten werden, wenn sie auch überprüft werden. In der Rechtswissenschaft spricht man von Rechtswirksamkeit [5]. Demnach ist eine Rechtsnorm nur dann wirksam, wenn sie auch durchsetzbar ist. In einer Makroarchitektur, in der es unabhängige Services gibt, kann die Durchsetzbarkeit von Regeln ein Problem sein. Die einfachste Variante der Durchsetzung solcher Regeln wäre es, eine bestimmte Deployment Pipeline (die die Regeln überprüft) vorzuschreiben und deren Nutzung sicherzustellen. Das widerspräche aber dem Paradigma der unabhängigen Teams. Wenn ein Team seinen Service nämlich unabhängig entwickeln, testen und deployen können soll, sollte es auch die Macht über die eigene Deployment Pipeline haben. Die Einhaltung der Makroarchitekturregeln läge dann allerdings wieder in der Verantwortung (und damit auch im Ermessen) des Teams.
Was also benötigt wird, ist eine weitere Instanz, die vor, während oder nach dem Deployment überprüft, ob alle Makroarchitekturregeln eingehalten wurden.
Software Architecture Summit vom 11. - 13. März in München
Technische und methodische Themen, Kommunikationstrends, Cloudlösungen, MLOps, Design und Psychologie
Glücklicherweise bietet der Kubernetes-Stack inklusive der verfügbaren Service Meshes verschiedene Hooks, um solche Überprüfungen vorzunehmen.
Wenn man zur Architekturüberprüfung nur informiert werden möchte, sobald eine neue Instanz eines Service (in Kubernetes-Sprache: ein Pod) auf einem Kubernetes-Cluster deployt wird, bietet sich das Event API von Kubernetes an. Kubernetes Events können konsumiert werden, indem man per REST einen GET-Request gegen das Kubernetes API auf dem folgenden Pfad ausführt: /api/v1/events?watch=1 (siehe auch [6]). Man wird dann über alle Änderungen am Cluster informiert, also z. B. auch, dass ein neuer Pod gestartet wurde. Empfängt man ein solches Event, können gewisse Vorgaben am gestarteten Pod überprüft werden: Stellt er eine OpenAPI-Definition bereit? Existieren die zugehörigen Consumer und Provider Contracts beim Consumer-driven Contract Testing usw.? Nicht eingehaltene Regeln könnten in einem zentralen Tool angezeigt werden und so ggf. einen Gamification-Effekt erzielen. Kein Team möchte, dass an zentraler Stelle Regelverstöße des eigenen Service zu sehen sind.
Möchte man einen Schritt weiter gehen und bei gewissen Regelverstößen das Starten des Service komplett verhindern, muss man sich in den Deployment-Prozess einhängen. Auch dafür bietet Kubernetes Möglichkeiten, nämlich via Sidecar Injection [7]. Dabei handelt es sich um dasselbe API, das auch von Service Meshes verwendet wird. Der Deployment Descriptor wird dabei so angepasst, dass innerhalb des Containers beim Start ein Befehl ausgeführt wird. Schlägt dieser fehl, scheitert das Deployment. Indem man dort eigene Befehle einhängt, die gewisse Checks durchführen, könnte man z. B. sicherstellen, dass der gerade startende Service tatsächlich eine OpenAPI-Definition an definierter Stelle zur Verfügung stellt. Weiterhin können auch Dinge wie die Verwendung eines definierten Basis-Docker-Image überprüft werden, oder es kann mittels des bereits erwähnten Tools can-i-deploy (das dann natürlich im besagten Basis-Image installiert sein müsste) festgestellt werden, ob alle Consumer-driven Contracts korrekt validiert sind.
Einige Regeln können von außen erst zur Laufzeit überprüft werden, wie z. B. das korrekte Logformat oder das Setzen des Tracing-Headers. Aber über einen Service Mesh gibt es auch hier die Möglichkeit, die Einhaltung dieser Regeln zur Laufzeit sicherzustellen. Im Service Mesh Istio [8] können z. B. HTTP-Filter konfiguriert werden, die Requests ohne Tracing-Header komplett blockieren. Eine Anwendung, die sich dann nicht an den Tracing-Standard hält, könnte so überhaupt nicht kommunizieren.
Fazit
Um sicher zu sein, dass sich alle Entwickler jederzeit an die Architekturvorgaben halten, ist es sinnvoll, sie im Continuous-Deployment-Prozess zu verankern. Für Mikroarchitekturvorgaben reicht dabei eine Implementierung in der Pipeline, da alle Informationen, die benötigt werden, in der Pipeline zur Verfügung stehen. Bei Makroarchitekturvorgaben ist die Überprüfung komplizierter. Hier ist es nur in Teilen möglich, die Vorgaben in der Pipeline sicherzustellen. Weitere Überprüfungen können im Deployment-Prozess oder zur Laufzeit erfolgen.
Glücklicherweise bieten Kubernetes und Co. in Verbindung mit Service Meshes genügend Eingriffsmöglichkeiten, um auch solche Tests zu implementieren. In diesem Sinne – haltet die Architektur sauber.
Geschrieben von: Arne Limburg
Arne Limburg ist Enterprise Architect bei der open knowledge GmbH in Oldenburg. Er verfügt über mehrjährige Erfahrung als Entwickler, Architekt und Trainer im Enterprise-Umfeld und ist darüber hinaus seit dem ersten Tag im Android-Umfeld tätig. Zu diesen Bereichen spricht er regelmäßig auf Konferenzen und führt Workshops durch. Darüber hinaus ist er im Open-Source-Bereich tätig, unter anderem als PMC Member von Apache OpenWebBeans und Apache DeltaSpike und als Urheber und Projektleiter von JPA Security.
Links & Literatur
[1] https://www.sonarqube.org[2] https://www.archunit.org
[4] https://github.com/DiUS/pact-jvm/tree/master/provider/pact-jvm-provider-maven
[5] https://de.wikipedia.org/wiki/Wirksamkeit_(Recht)#Wirksamkeit_von_Rechtsnormen
[6] https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes
[7] https://medium.com/dowjones/how-did-that-sidecar-get-there-4dcd73f1a0a4
[8] https://istio.io