Erstellen von wirklich modularem Code ohne Abhängigkeiten
Veröffentlicht: 2022-03-11Software zu entwickeln ist großartig, aber … ich denke, wir sind uns alle einig, dass es eine emotionale Achterbahnfahrt sein kann. Am Anfang ist alles super. Sie fügen innerhalb von Tagen, wenn nicht Stunden, nacheinander neue Funktionen hinzu. Sie sind auf einer Rolle!
Spulen Sie ein paar Monate vor, und Ihre Entwicklungsgeschwindigkeit nimmt ab. Liegt es daran, dass Sie nicht mehr so hart arbeiten wie früher? Nicht wirklich. Lassen Sie uns noch ein paar Monate vorspulen, und Ihre Entwicklungsgeschwindigkeit sinkt weiter. Die Arbeit an diesem Projekt macht keinen Spaß mehr und ist zu einer Belastung geworden.
Es wird schlimmer. Sie beginnen, mehrere Fehler in Ihrer Anwendung zu entdecken. Oft schafft die Lösung eines Fehlers zwei neue. An diesem Punkt können Sie mit dem Singen beginnen:
99 kleine Fehler im Code. 99 kleine Fehler. Nimm einen runter, flicke ihn herum,
…127 kleine Fehler im Code.
Wie fühlt es sich jetzt an, an diesem Projekt zu arbeiten? Wenn Sie wie ich sind, verlieren Sie wahrscheinlich Ihre Motivation. Es ist nur mühsam, diese Anwendung zu entwickeln, da jede Änderung an bestehendem Code unvorhersehbare Folgen haben kann.
Diese Erfahrung ist in der Softwarewelt weit verbreitet und kann erklären, warum so viele Programmierer ihren Quellcode wegwerfen und alles neu schreiben wollen.
Gründe, warum sich die Softwareentwicklung im Laufe der Zeit verlangsamt
Was ist also der Grund für dieses Problem?
Hauptursache ist die steigende Komplexität. Aus meiner Erfahrung trägt am meisten zur Gesamtkomplexität bei, dass bei der überwiegenden Mehrheit der Softwareprojekte alles miteinander verbunden ist. Aufgrund der Abhängigkeiten, die jede Klasse hat, können sich Ihre Benutzer plötzlich nicht mehr registrieren, wenn Sie Code in der Klasse ändern, die E-Mails sendet. Warum ist das so? Weil Ihr Registrierungscode von dem Code abhängt, der E-Mails sendet. Jetzt können Sie nichts ändern, ohne Fehler einzuführen. Es ist einfach nicht möglich, alle Abhängigkeiten nachzuvollziehen.
Da haben Sie es also; Die eigentliche Ursache unserer Probleme ist die zunehmende Komplexität, die von all den Abhängigkeiten herrührt, die unser Code hat.
Big Ball of Mud und wie man ihn reduziert
Komischerweise ist dieses Problem schon seit Jahren bekannt. Es ist ein weit verbreitetes Anti-Muster, das als „großer Schlammball“ bezeichnet wird. Ich habe diese Art von Architektur in fast allen Projekten gesehen, an denen ich im Laufe der Jahre in mehreren verschiedenen Unternehmen gearbeitet habe.
Also, was ist dieses Anti-Pattern genau? Einfach gesagt, Sie bekommen einen großen Schlammball, wenn jedes Element eine Abhängigkeit mit anderen Elementen hat. Unten sehen Sie ein Diagramm der Abhängigkeiten vom bekannten Open-Source-Projekt Apache Hadoop. Um den großen Matschknäuel (oder besser gesagt den großen Wollknäuel) zu visualisieren, zeichnest du einen Kreis und platzierst Klassen aus dem Projekt gleichmäßig darauf. Ziehen Sie einfach eine Linie zwischen jedem Paar von Klassen, die voneinander abhängen. Jetzt können Sie die Ursache Ihrer Probleme erkennen.
Eine Lösung mit modularem Code
Also stellte ich mir eine Frage: Wäre es möglich, die Komplexität zu reduzieren und trotzdem Spaß zu haben wie zu Beginn des Projekts? Um ehrlich zu sein, Sie können nicht die gesamte Komplexität eliminieren. Wenn Sie neue Funktionen hinzufügen möchten, müssen Sie immer die Codekomplexität erhöhen. Dennoch lässt sich Komplexität verschieben und trennen.
Wie andere Branchen dieses Problem lösen
Denken Sie an die mechanische Industrie. Wenn eine kleine mechanische Werkstatt Maschinen herstellt, kauft sie eine Reihe von Standardelementen, erstellt ein paar kundenspezifische Elemente und stellt sie zusammen. Sie können diese Komponenten komplett separat herstellen und am Ende alles zusammenbauen, indem sie nur ein paar Änderungen vornehmen. Wie ist das möglich? Sie wissen, wie jedes Element durch festgelegte Industriestandards wie Schraubengrößen und Vorabentscheidungen wie die Größe der Befestigungslöcher und den Abstand zwischen ihnen zusammenpasst.
Jedes Element in der obigen Baugruppe kann von einer separaten Firma bereitgestellt werden, die keinerlei Kenntnis über das Endprodukt oder seine anderen Teile hat. Solange jedes modulare Element gemäß den Spezifikationen hergestellt wird, können Sie das endgültige Gerät wie geplant erstellen.
Können wir das in der Softwarebranche nachahmen?
Sicher können wir! Durch die Verwendung von Schnittstellen und Umkehrung des Kontrollprinzips; Das Beste daran ist, dass dieser Ansatz in jeder objektorientierten Sprache verwendet werden kann: Java, C#, Swift, TypeScript, JavaScript, PHP – die Liste ließe sich beliebig fortsetzen. Sie brauchen kein ausgefallenes Framework, um diese Methode anzuwenden. Sie müssen sich nur an ein paar einfache Regeln halten und diszipliniert bleiben.
Umkehrung der Kontrolle ist dein Freund
Als ich zum ersten Mal von der Umkehrung der Kontrolle hörte, war mir sofort klar, dass ich eine Lösung gefunden hatte. Es ist ein Konzept, bestehende Abhängigkeiten zu nehmen und sie durch die Verwendung von Schnittstellen umzukehren. Schnittstellen sind einfache Deklarationen von Methoden. Sie liefern keine konkrete Umsetzung. Daher können sie als Vereinbarung zwischen zwei Elementen verwendet werden, wie sie zu verbinden sind. Sie können, wenn Sie so wollen, als modulare Steckverbinder verwendet werden. Solange ein Element die Schnittstelle und ein anderes Element die Implementierung dafür bereitstellt, können sie zusammenarbeiten, ohne etwas voneinander zu wissen. Es ist brilliant.
Sehen wir uns an einem einfachen Beispiel an, wie wir unser System entkoppeln können, um modularen Code zu erstellen. Die folgenden Diagramme wurden als einfache Java-Anwendungen implementiert. Sie finden sie in diesem GitHub-Repository.
Problem
Nehmen wir an, wir haben eine sehr einfache Anwendung, die nur aus einer Main
-Klasse, drei Diensten und einer einzigen Util
-Klasse besteht. Diese Elemente hängen auf vielfältige Weise voneinander ab. Unten sehen Sie eine Implementierung mit dem „Big Ball of Mud“-Ansatz. Die Klassen rufen sich einfach an. Sie sind eng miteinander verbunden, und Sie können nicht einfach ein Element herausnehmen, ohne andere zu berühren. Anwendungen, die mit diesem Stil erstellt wurden, ermöglichen Ihnen zunächst ein schnelles Wachstum. Ich glaube, dass dieser Stil für Proof-of-Concept-Projekte geeignet ist, da man leicht mit Dingen herumspielen kann. Dennoch ist es für produktionsreife Lösungen nicht geeignet, da selbst die Wartung gefährlich sein kann und jede einzelne Änderung unvorhersehbare Fehler verursachen kann. Das folgende Diagramm zeigt diese große Lehmballenarchitektur.
Warum die Abhängigkeitsinjektion alles falsch gemacht hat
Auf der Suche nach einem besseren Ansatz können wir eine Technik namens Dependency Injection verwenden. Diese Methode geht davon aus, dass alle Komponenten über Schnittstellen verwendet werden sollen. Ich habe Behauptungen gelesen, dass es Elemente entkoppelt, aber tut es das wirklich? Nein. Sehen Sie sich das Diagramm unten an.
Der einzige Unterschied zwischen der aktuellen Situation und einem großen Schlammball ist die Tatsache, dass wir Klassen jetzt nicht mehr direkt, sondern über ihre Schnittstellen aufrufen. Es verbessert geringfügig das Trennen von Elementen voneinander. Wenn Sie beispielsweise Service A
in einem anderen Projekt wiederverwenden möchten, können Sie dies tun, indem Sie Service A
selbst zusammen mit Interface A
sowie Interface B
und Interface Util
. Wie Sie sehen können, hängt Service A
noch von anderen Elementen ab. Infolgedessen haben wir immer noch Probleme, Code an einer Stelle zu ändern und das Verhalten an einer anderen Stelle durcheinander zu bringen. Es entsteht immer noch das Problem, dass Sie, wenn Sie Service B
und Interface B
ändern, alle davon abhängigen Elemente ändern müssen. Dieser Ansatz löst nichts; Meiner Meinung nach fügt es einfach eine Schnittstelle über den Elementen hinzu. Sie sollten niemals Abhängigkeiten injizieren, sondern sie ein für alle Mal loswerden. Hurra für die Unabhängigkeit!
Die Lösung für modularen Code
Der Ansatz löst meines Erachtens alle Hauptprobleme von Abhängigkeiten, indem er überhaupt keine Abhängigkeiten verwendet. Sie erstellen eine Komponente und ihren Listener. Ein Listener ist eine einfache Schnittstelle. Wann immer Sie eine Methode außerhalb des aktuellen Elements aufrufen müssen, fügen Sie dem Listener einfach eine Methode hinzu und rufen sie stattdessen auf. Das Element darf nur Dateien verwenden, Methoden innerhalb seines Pakets aufrufen und Klassen verwenden, die vom Hauptframework oder anderen verwendeten Bibliotheken bereitgestellt werden. Unten sehen Sie ein Diagramm der Anwendung, die für die Verwendung der Elementarchitektur modifiziert wurde.

Bitte beachten Sie, dass in dieser Architektur nur die Main
mehrere Abhängigkeiten hat. Es verbindet alle Elemente miteinander und kapselt die Geschäftslogik der Anwendung.
Dienste hingegen sind völlig eigenständige Elemente. Jetzt können Sie jeden Dienst aus dieser Anwendung herausnehmen und an anderer Stelle wiederverwenden. Sie sind von nichts anderem abhängig. Aber warten Sie, es wird noch besser: Sie müssen diese Dienste nie wieder ändern, solange Sie ihr Verhalten nicht ändern. Solange diese Dienste das tun, was sie tun sollen, können sie bis zum Ende der Zeit unberührt bleiben. Sie können von einem professionellen Software-Ingenieur oder einem erstmaligen Programmierer erstellt werden, der aus dem schlechtesten Spaghetti-Code kompromittiert wurde, den je jemand gekocht hat, mit gemischten goto
-Anweisungen. Es spielt keine Rolle, weil ihre Logik gekapselt ist. So schrecklich es auch sein mag, es wird niemals auf andere Klassen übergreifen. Das gibt Ihnen auch die Möglichkeit, die Arbeit in einem Projekt auf mehrere Entwickler aufzuteilen, wobei jeder Entwickler unabhängig an seiner eigenen Komponente arbeiten kann, ohne einen anderen unterbrechen zu müssen oder gar von der Existenz anderer Entwickler zu wissen.
Schließlich können Sie noch einmal mit dem Schreiben von unabhängigem Code beginnen, genau wie zu Beginn Ihres letzten Projekts.
Elementmuster
Lassen Sie uns das Strukturelementmuster so definieren, dass wir es auf wiederholbare Weise erstellen können.
Die einfachste Version des Elements besteht aus zwei Dingen: Einer Hauptelementklasse und einem Listener. Wenn Sie ein Element verwenden möchten, müssen Sie den Listener implementieren und die Hauptklasse aufrufen. Hier ist ein Diagramm der einfachsten Konfiguration:
Natürlich müssen Sie dem Element irgendwann mehr Komplexität hinzufügen, aber Sie können dies leicht tun. Stellen Sie einfach sicher, dass keine Ihrer Logikklassen von anderen Dateien im Projekt abhängig ist. Sie können nur das Hauptframework, importierte Bibliotheken und andere Dateien in diesem Element verwenden. Wenn es um Asset-Dateien wie Bilder, Ansichten, Sounds usw. geht, sollten sie auch in Elemente gekapselt werden, damit sie in Zukunft einfach wiederverwendet werden können. Sie können einfach den gesamten Ordner in ein anderes Projekt kopieren und fertig!
Unten sehen Sie ein Beispieldiagramm, das ein fortgeschritteneres Element zeigt. Beachten Sie, dass es aus einer Ansicht besteht, die es verwendet, und nicht von anderen Anwendungsdateien abhängt. Wenn Sie wissen möchten, wie Abhängigkeiten auf einfache Weise überprüft werden können, sehen Sie sich einfach den Importabschnitt an. Gibt es Dateien von außerhalb des aktuellen Elements? Wenn dies der Fall ist, müssen Sie diese Abhängigkeiten entfernen, indem Sie sie entweder in das Element verschieben oder dem Listener einen entsprechenden Aufruf hinzufügen.
Sehen wir uns auch ein einfaches „Hello World“-Beispiel an, das in Java erstellt wurde.
public class Main { interface ElementListener { void printOutput(String message); } static class Element { private ElementListener listener; public Element(ElementListener listener) { this.listener = listener; } public void sayHello() { String message = "Hello World of Elements!"; this.listener.printOutput(message); } } static class App { public App() { } public void start() { // Build listener ElementListener elementListener = message -> System.out.println(message); // Assemble element Element element = new Element(elementListener); element.sayHello(); } } public static void main(String[] args) { App app = new App(); app.start(); } }
Zunächst definieren wir ElementListener
, um die Methode anzugeben, die die Ausgabe druckt. Das Element selbst wird unten definiert. Beim Aufrufen von sayHello
für das Element wird einfach eine Nachricht mit ElementListener
. Beachten Sie, dass das Element völlig unabhängig von der Implementierung der Methode printOutput
ist. Es kann in die Konsole, einen physischen Drucker oder eine ausgefallene Benutzeroberfläche gedruckt werden. Das Element hängt nicht von dieser Implementierung ab. Aufgrund dieser Abstraktion kann dieses Element problemlos in verschiedenen Anwendungen wiederverwendet werden.
Werfen Sie nun einen Blick auf die Haupt- App
-Klasse. Es implementiert den Zuhörer und baut das Element zusammen mit der konkreten Implementierung zusammen. Jetzt können wir anfangen, es zu benutzen.
Sie können dieses Beispiel auch hier in JavaScript ausführen
Elementarchitektur
Werfen wir einen Blick auf die Verwendung des Elementmusters in groß angelegten Anwendungen. Es ist eine Sache, es in einem kleinen Projekt zu zeigen – es ist eine andere, es auf die reale Welt anzuwenden.
Der Aufbau einer Full-Stack-Webanwendung, die ich gerne verwende, sieht wie folgt aus:
src ├── client │ ├── app │ └── elements │ └── server ├── app └── elements
In einem Quellcodeordner teilen wir zunächst die Client- und Serverdateien auf. Dies ist sinnvoll, da sie in zwei verschiedenen Umgebungen ausgeführt werden: dem Browser und dem Back-End-Server.
Dann teilen wir den Code in jeder Ebene in Ordner namens app und elements auf. Elements besteht aus Ordnern mit unabhängigen Komponenten, während der App-Ordner alle Elemente miteinander verbindet und die gesamte Geschäftslogik speichert.
Auf diese Weise können Elemente zwischen verschiedenen Projekten wiederverwendet werden, während die gesamte anwendungsspezifische Komplexität in einem einzigen Ordner gekapselt und häufig auf einfache Aufrufe von Elementen reduziert wird.
Praktisches Beispiel
In der Überzeugung, dass die Praxis immer die Theorie übertrumpft, werfen wir einen Blick auf ein reales Beispiel, das in Node.js und TypeScript erstellt wurde.
Beispiel aus dem wirklichen Leben
Es ist eine sehr einfache Webanwendung, die als Ausgangspunkt für fortgeschrittenere Lösungen verwendet werden kann. Es folgt der Elementarchitektur und verwendet ein weitgehend strukturelles Elementmuster.
An den Highlights können Sie erkennen, dass die Hauptseite als Element ausgezeichnet wurde. Diese Seite enthält eine eigene Ansicht. Wenn Sie es beispielsweise wiederverwenden möchten, können Sie einfach den gesamten Ordner kopieren und in einem anderen Projekt ablegen. Einfach alles zusammenstecken und fertig.
Es ist ein einfaches Beispiel, das zeigt, dass Sie heute damit beginnen können, Elemente in Ihre eigene Anwendung einzuführen. Sie können damit beginnen, unabhängige Komponenten zu unterscheiden und ihre Logik zu trennen. Es spielt keine Rolle, wie chaotisch der Code ist, an dem Sie gerade arbeiten.
Schneller entwickeln, häufiger wiederverwenden!
Ich hoffe, dass Sie mit diesem neuen Werkzeugsatz leichter wartbaren Code entwickeln können. Bevor Sie mit der praktischen Verwendung des Elementmusters beginnen, fassen wir kurz alle wichtigen Punkte zusammen:
Viele Probleme in der Software treten aufgrund von Abhängigkeiten zwischen mehreren Komponenten auf.
Indem Sie an einer Stelle etwas ändern, können Sie woanders unvorhersehbares Verhalten einführen.
Drei gängige Architekturansätze sind:
Der große Schlammball. Es ist großartig für eine schnelle Entwicklung, aber nicht so gut für stabile Produktionszwecke.
Abhängigkeitsspritze. Es ist eine halbgare Lösung, die Sie vermeiden sollten.
Elementarchitektur. Mit dieser Lösung können Sie unabhängige Komponenten erstellen und in anderen Projekten wiederverwenden. Es ist wartbar und brillant für stabile Produktionsversionen.
Das grundlegende Elementmuster besteht aus einer Hauptklasse, die alle wichtigen Methoden enthält, sowie einem Listener, der eine einfache Schnittstelle ist, die die Kommunikation mit der Außenwelt ermöglicht.
Um eine Full-Stack-Elementarchitektur zu erreichen, trennen Sie zunächst Ihren Front-End-Code vom Back-End-Code. Dann erstellen Sie jeweils einen Ordner für eine App und Elemente. Der Elements-Ordner besteht aus allen unabhängigen Elementen, während der App-Ordner alles miteinander verbindet.
Jetzt können Sie anfangen, Ihre eigenen Elemente zu erstellen und zu teilen. Auf lange Sicht wird es Ihnen helfen, leicht wartbare Produkte zu erstellen. Viel Glück und lassen Sie mich wissen, was Sie erstellt haben!
Wenn Sie feststellen, dass Sie Ihren Code vorzeitig optimieren, lesen Sie How to Avoid the Curse of Premature Optimization von Kevin Bloch, einem Kollegen von Toptaler.