Hintergrund
In den letzten Jahren habe ich an zahlreichen Interviews teilgenommen. Bei jedem von ihnen habe ich die Antragsteller nach dem Grundsatz der alleinigen Verantwortung (im Folgenden: SRP) gefragt. Und die meisten Menschen wissen nichts über das Prinzip. Und selbst von denen, die die Definition lesen konnten, konnte fast niemand sagen, wie sie dieses Prinzip in ihrer Arbeit anwenden. Sie konnten nicht sagen, wie sich SRP auf den von ihnen geschriebenen Code oder die Codeüberprüfung von Kollegen auswirkt. Einige von ihnen hatten auch das Missverständnis, dass SRP wie das gesamte SOLID nur für die objektorientierte Programmierung relevant ist. Außerdem konnten Menschen häufig keine offensichtlichen Fälle von Verstößen gegen dieses Prinzip identifizieren, einfach weil der Code in dem vom bekannten Framework empfohlenen Stil geschrieben wurde.
Redux ist ein Paradebeispiel für ein Framework, dessen Richtlinie gegen SRP verstößt.
SRP ist wichtig
Ich möchte mit dem Wert dieses Prinzips beginnen, mit den Vorteilen, die es bringt. Außerdem möchte ich darauf hinweisen, dass das Prinzip nicht nur für OOP gilt, sondern auch für die prozedurale Programmierung, funktional und sogar deklarativ. HTML als Vertreter des letzteren kann und sollte auch zerlegt werden, insbesondere jetzt, wenn es von UI-Frameworks wie React oder Angular gesteuert wird. Darüber hinaus gilt das Prinzip auch für andere technische Bereiche. Und nicht nur das Ingenieurwesen, es gab auch einen solchen Ausdruck in militärischen Fächern: „Teilen und Erobern“, was im Großen und Ganzen die Verkörperung desselben Prinzips ist. Komplexität tötet, teilt sie in Teile und Sie werden gewinnen.
In Bezug auf andere technische Bereiche gab es hier auf der Nabe einen interessanten Artikel darüber, wie die entwickelten Triebwerke des Flugzeugs versagten und nicht auf Befehl des Piloten in den Rückwärtsgang schalteten. Das Problem war, dass sie den Zustand des Chassis falsch interpretierten. Anstatt sich auf die Systeme zu verlassen, die das Fahrgestell steuern, liest die Motorsteuerung direkt die Sensoren, Endschalter usw. im Fahrgestell. In dem Artikel wurde auch erwähnt, dass das Triebwerk einer langwierigen Zertifizierung unterzogen werden muss, bevor es überhaupt in einen Prototyp eines Flugzeugs eingebaut wird. Und eine Verletzung der SRP führte in diesem Fall eindeutig dazu, dass bei einer Änderung des Fahrwerksdesigns der Code in der Motorsteuerung geändert und neu zertifiziert werden musste. Schlimmer noch, eine Verletzung dieses Prinzips war das Flugzeug und das Leben des Piloten fast wert. Glücklicherweise bedroht unsere tägliche Programmierung solche Konsequenzen nicht. Sie sollten jedoch die Prinzipien des Schreibens von gutem Code nicht vernachlässigen. Und hier ist warum:
- Die Zerlegung des Codes verringert seine Komplexität. Wenn Sie zum Lösen eines Problems beispielsweise Code mit einer zyklomatischen Komplexität von vier schreiben müssen, erfordert die Methode, die für die gleichzeitige Lösung von zwei solchen Problemen verantwortlich ist, Code mit der Komplexität 16. Wenn sie in zwei Methoden unterteilt ist, beträgt die Gesamtkomplexität 8. Dies ist natürlich nicht immer der Fall kommt auf den Betrag gegen die Arbeit an, aber der Trend wird sowieso ungefähr der gleiche sein.
- Unit-Tests von zerlegtem Code sind vereinfacht und effizienter.
- Zerlegter Code erzeugt weniger Widerstand gegen Änderungen. Bei Änderungen ist es weniger wahrscheinlich, dass ein Fehler gemacht wird.
- Der Code wird besser strukturiert. Die Suche nach etwas in Code, der in Dateien und Ordnern angeordnet ist, ist viel einfacher als in einem großen Fußtuch.
- Die Trennung von Boilerplate-Code von Geschäftslogik führt dazu, dass die Codegenerierung in einem Projekt angewendet werden kann.
Und all diese Zeichen gehören zusammen, dies sind Zeichen desselben Codes. Sie müssen sich beispielsweise nicht zwischen gut getestetem Code und gut strukturiertem Code entscheiden.
Bestehende Definitionen funktionieren nicht
Eine der Definitionen lautet: „Es sollte nur einen Grund geben, den Code (Klasse oder Funktion) zu ändern.“ Das Problem bei dieser Definition ist, dass sie im Widerspruch zum Open-Close-Prinzip steht, dem zweiten der SOLID-Gruppe von Prinzipien. Seine Definition: "Der Code muss zur Erweiterung geöffnet und zur Änderung geschlossen sein." Ein Grund für eine Änderung gegenüber einem vollständigen Änderungsverbot. Wenn wir genauer offenbaren, was hier gemeint ist, stellt sich heraus, dass es keinen Konflikt zwischen den Prinzipien gibt, aber es gibt definitiv einen Konflikt zwischen unscharfen Definitionen.
Die zweite, direktere Definition lautet: "Der Code sollte nur eine Verantwortung haben." Das Problem bei dieser Definition ist, dass es die menschliche Natur ist, alles zu verallgemeinern.
Zum Beispiel gibt es eine Farm, auf der Hühner angebaut werden, und in diesem Moment hat die Farm nur eine Verantwortung. Und so wird die Entscheidung getroffen, auch dort Enten zu züchten. Instinktiv werden wir dies eine Geflügelfarm nennen, anstatt zuzugeben, dass es jetzt zwei Verantwortlichkeiten gibt. Fügen Sie dort Schafe hinzu, und dies ist jetzt eine Tierfarm. Dann wollen wir dort Tomaten oder Pilze anbauen und uns den folgenden noch allgemeineren Namen einfallen lassen. Gleiches gilt für den „einen Grund“ zur Änderung. Dieser Grund kann so verallgemeinert werden, wie die Vorstellungskraft ausreicht.
Ein weiteres Beispiel ist die Raumstationsmanager-Klasse. Er macht nichts anderes, er verwaltet nur die Raumstation. Wie gefällt dir dieser Kurs mit einer Verantwortung?
Und da ich Redux erwähnt habe, wenn der Bewerber mit dieser Technologie vertraut ist, stelle ich auch die Frage, ob ein typischer SRP-Reduzierer verletzt?
Ich erinnere mich, dass der Reduzierer die switch-Anweisung enthält, und es kommt vor, dass er auf zehn oder sogar Hunderte von Fällen anwächst. Die alleinige Verantwortung des Reduzierers besteht darin, die Statusübergänge Ihrer Anwendung zu verwalten. Das heißt, buchstäblich haben einige Antragsteller geantwortet. Und keine Hinweise könnten diese Meinung auf den Weg bringen.
Wenn eine Art Code dem SRP-Prinzip zu entsprechen scheint, aber gleichzeitig unangenehm riecht, wissen Sie, warum dies geschieht. Weil die Definition von „Code muss eine Verantwortung haben“ einfach nicht funktioniert.
Passendere Definition
Durch Versuch und Irrtum hatte ich eine bessere Definition:
Die Codeverantwortung sollte nicht zu groß seinJa, jetzt müssen Sie die Verantwortung einer Klasse oder Funktion "messen". Und wenn es zu groß ist, müssen Sie diese große Verantwortung in mehrere kleinere Verantwortlichkeiten aufteilen. Zurück zum Beispiel auf dem Bauernhof: Selbst die Verantwortung für die Hühnerzucht kann zu groß sein, und es ist sinnvoll, beispielsweise Broiler von Legehennen zu trennen.
Aber wie kann man es messen, wie kann man feststellen, dass die Verantwortung für diesen Code zu groß ist?
Leider habe ich keine mathematisch genauen Methoden, nur empirische. Und vor allem kommt dies mit Erfahrung, Anfänger sind überhaupt nicht in der Lage, den Code zu zerlegen, fortgeschrittenere können ihn besser besitzen, obwohl sie nicht immer beschreiben können, warum sie es tun und wie es in Theorien wie SRP passt.
- Metrische zyklomatische Komplexität. Leider gibt es Möglichkeiten, diese Metrik zu maskieren. Wenn Sie sie jedoch erfassen, besteht die Möglichkeit, dass sie die am stärksten gefährdeten Stellen in Ihrer Anwendung anzeigt.
- Die Größe von Funktionen und Klassen. Eine 800-Zeilen-Funktion muss nicht gelesen werden, um zu verstehen, dass etwas nicht stimmt.
- Viele Importe. Einmal habe ich eine Datei im Projekt eines benachbarten Teams geöffnet und einen ganzen Bildschirm mit Importen gesehen, die Seite nach unten gedrückt und wieder waren nur Importe auf dem Bildschirm. Erst nach dem zweiten Drücken sah ich den Anfang des Codes. Sie können sagen, dass alle modernen IDEs Importe unter dem "Pluszeichen" verbergen können, aber ich sage, dass ein guter Code die "Gerüche" nicht verbergen muss. Außerdem musste ich ein kleines Stück Code wiederverwenden und entfernte es aus dieser Datei in eine andere, und ein Viertel oder sogar ein Drittel der Importe gingen hinter dieses Stück. Dieser Code gehörte eindeutig nicht dorthin.
- Unit-Tests. Wenn Sie immer noch Schwierigkeiten haben, den Umfang der Verantwortung zu bestimmen, zwingen Sie sich, Tests zu schreiben. Wenn Sie zwei Dutzend Tests zum Hauptzweck einer Funktion schreiben müssen, ohne Grenzfälle usw. zu zählen, ist eine Zerlegung erforderlich.
- Gleiches gilt für zu viele vorbereitende Schritte zu Beginn des Tests und für Prüfungen am Ende. Im Internet finden Sie übrigens die utopische Aussage, dass die sogenannten Der Test sollte nur eine Aussage enthalten. Ich glaube, dass jede willkürlich gute Idee, die zum Absoluten erhoben wird, absurd unpraktisch werden kann.
- Geschäftslogik sollte nicht direkt von externen Tools abhängen. Für den Oracle-Treiber Express Routes ist es wünschenswert, all dies von der Geschäftslogik zu trennen und / oder sich hinter den Schnittstellen zu verstecken.
Ein paar Punkte:
Natürlich hat die Münze, wie ich bereits erwähnt habe, eine Kehrseite, und 800 Methoden in einer Zeile sind möglicherweise nicht besser als eine Methode in 800 Zeilen. Es sollte in allem ein Gleichgewicht bestehen.
Zweitens: Ich gehe nicht auf die Frage ein, wo dieser oder jener Code in Übereinstimmung mit seiner Verantwortung platziert werden soll. Beispielsweise haben Entwickler manchmal auch Schwierigkeiten, zu viel Logik in die DAL-Schicht zu ziehen.
Drittens schlage ich keine spezifischen harten Grenzen wie "nicht mehr als 50 Zeilen pro Funktion" vor. Dieser Ansatz beinhaltet nur eine Richtung für die Entwicklung von Entwicklern und möglicherweise Teams. Er arbeitet für mich, er muss Geld für andere verdienen.
Und schließlich, wenn Sie TDD durchlaufen, wird dies allein Sie sicherlich dazu bringen, den Code zu zerlegen, lange bevor Sie diese 20 Tests mit jeweils 20 Zusicherungen schreiben.
Trennen der Geschäftslogik vom Boilerplate-Code
Wenn man über die Regeln guten Codes spricht, kann man nicht auf Beispiele verzichten. Das erste Beispiel befasst sich mit der Trennung von Boilerplate-Code.

Dieses Beispiel zeigt, wie Back-End-Code normalerweise geschrieben wird. Normalerweise schreiben die Benutzer Logik untrennbar mit dem Code, der dem Webserver Express Parameter wie URL, Anforderungsmethode usw. angibt.
Ich habe die Geschäftslogik als grüne Markierung und den rot eingestreuten Code markiert, der mit den Abfrageparametern interagiert (rot).
Ich teile diese beiden Verantwortlichkeiten immer auf folgende Weise:

In diesem Beispiel befindet sich die gesamte Interaktion mit Express in einer separaten Datei.
Auf den ersten Blick scheint es, dass das zweite Beispiel keine Verbesserungen gebracht hat, es gab 2 Dateien anstelle von einer, zusätzliche Zeilen erschienen, die vorher nicht existierten - der Klassenname und die Methodensignatur. Und was bringt diese Codetrennung dann? Erstens ist der „Anwendungseinstiegspunkt“ nicht mehr Express. Dies ist eine reguläre Typescript-Funktion. Oder eine Javascript-Funktion, ob C #, wer schreibt WebAPI auf was.
Auf diese Weise können Sie verschiedene Aktionen ausführen, die im ersten Beispiel nicht verfügbar sind. Beispielsweise können Sie Verhaltenstests schreiben, ohne Express auslösen zu müssen, ohne http-Anforderungen im Test zu verwenden. Und selbst wenn keine Benetzung erforderlich ist, ersetzen Sie das Router-Objekt durch Ihr Testobjekt. Jetzt kann der Anwendungscode einfach direkt aus dem Test aufgerufen werden.
Ein weiteres interessantes Merkmal dieser Zerlegung ist, dass Sie jetzt einen Codegenerator schreiben können, der userApiService analysiert und auf seiner Basis Code generiert, der diesen Dienst mit Express verbindet. In meinen zukünftigen Veröffentlichungen möchte ich Folgendes angeben: Die Codegenerierung spart beim Schreiben von Code keine Zeit. Die Kosten für den Codegenerator werden sich nicht dadurch auszahlen, dass Sie dieses Boilerplate jetzt nicht mehr kopieren müssen. Die Codegenerierung zahlt sich dadurch aus, dass der von ihr produzierte Code keine Unterstützung benötigt, was auf lange Sicht Zeit und vor allem die Nerven der Entwickler spart.
Teilen und erobern
Diese Methode zum Schreiben von Code gibt es schon lange, ich habe sie nicht selbst erfunden. Ich bin gerade zu dem Schluss gekommen, dass es sehr praktisch ist, Geschäftslogik zu schreiben. Und dafür habe ich ein weiteres fiktives Beispiel entwickelt, das zeigt, wie Sie schnell und einfach Code schreiben können, der sofort gut zerlegt und auch durch Benennungsmethoden selbst dokumentiert wird.
Angenommen, Sie erhalten von einem Geschäftsanalysten die Aufgabe, eine Methode zu erstellen, mit der ein Mitarbeiterbericht an eine Versicherungsgesellschaft gesendet wird. Dafür:
- Daten müssen aus der Datenbank entnommen werden
- In das gewünschte Format konvertieren
- Senden Sie den resultierenden Bericht
Solche Anforderungen werden nicht immer explizit geschrieben, manchmal kann eine solche Reihenfolge aus einem Gespräch mit dem Analysten impliziert oder geklärt werden. Beeilen Sie sich bei der Implementierung der Methode nicht, Verbindungen zur Datenbank oder zum Netzwerk zu öffnen, sondern versuchen Sie stattdessen, diesen einfachen Algorithmus "wie er ist" in den Code zu übersetzen. Ungefähr so:
async function sendEmployeeReportToProvider(reportId){ const data = await dal.getEmployeeReportData(reportId); const formatted = reportDataService.prepareEmployeeReport(data); await networkService.sendReport(formatted); }
Mit diesem Ansatz stellt sich heraus, dass es sich um einen ziemlich einfachen, leicht zu lesenden und zu testenden Code handelt, obwohl ich glaube, dass dieser Code trivial ist und nicht getestet werden muss. Und es lag in der Verantwortung dieser Methode, keinen Bericht zu senden, sondern diese komplexe Aufgabe in drei Unteraufgaben aufzuteilen.
Als nächstes kehren wir zu den Anforderungen zurück und stellen fest, dass der Bericht aus einem Gehaltsabschnitt und einem Abschnitt mit Arbeitsstunden bestehen sollte.
function prepareEmployeeReport(reportData){ const salarySection = prepareSalarySection(reportData); const workHoursSection = prepareWorkHoursSection(reportData); return { salarySection, workHoursSection }; }
Und so weiter und so fort, brechen wir die Aufgabe weiter auf, bis die Implementierung kleiner Methoden, die nahezu trivial sind, erhalten bleibt.
Interaktion mit dem Open-Close-Prinzip
Am Anfang des Artikels habe ich gesagt, dass sich die Definitionen der Prinzipien von SRP und Open-Close widersprechen. Der erste besagt, dass es einen Grund für die Änderung geben muss, der zweite besagt, dass der Code für die Änderung geschlossen werden muss. Und die Prinzipien selbst widersprechen sich nicht nur nicht, sie arbeiten im Gegenteil in Synergie miteinander. Alle 5 SOLID-Prinzipien zielen auf ein gutes Ziel ab - dem Entwickler zu sagen, welcher Code „schlecht“ ist und wie er geändert werden kann, damit er „gut“ wird. Die Ironie - ich habe gerade 5 Verantwortlichkeiten durch eine weitere ersetzt.
Stellen Sie sich also zusätzlich zum vorherigen Beispiel mit dem Senden des Berichts an die Versicherungsgesellschaft vor, dass ein Geschäftsanalyst zu uns kommt und sagt, dass wir dem Projekt jetzt eine zweite Funktionalität hinzufügen müssen. Der gleiche Bericht muss gedruckt werden.
Stellen Sie sich vor, es gibt einen Entwickler, der glaubt, dass es bei SRP "nicht um Zersetzung geht".
Dementsprechend zeigte ihm dieses Prinzip nicht die Notwendigkeit einer Zersetzung, und er realisierte die gesamte erste Aufgabe in einer Funktion. Nachdem die Aufgabe zu ihm gekommen ist, kombiniert er die beiden Verantwortlichkeiten zu einer, weil Sie haben viel gemeinsam und verallgemeinern ihren Namen. Jetzt wird diese Verantwortung als "Servicebericht" bezeichnet. Die Implementierung sieht ungefähr so aus:
async function serveEmployeeReportToProvider(reportId, serveMethod){ switch(serveMethod) { case sendToProvider: case print: default: throw; } }
Erinnert Sie an Code in Ihrem Projekt? Wie gesagt, beide direkten Definitionen von SRP funktionieren nicht. Sie übermitteln dem Entwickler keine Informationen, dass ein solcher Code nicht geschrieben werden kann. Und welcher Code kann geschrieben werden. Es gab nur einen Grund für den Entwickler, diesen Code zu ändern. Er hat einfach den vorherigen Grund umbenannt, Schalter hinzugefügt und ist ruhig. Und hier kommt das Prinzip des Open-Close-Prinzips zum Tragen, das direkt besagt, dass es unmöglich war, eine vorhandene Datei zu ändern. Es war notwendig, Code zu schreiben, damit beim Hinzufügen neuer Funktionen eine neue Datei hinzugefügt und keine vorhandene bearbeitet werden musste. Das heißt, ein solcher Code ist unter dem Gesichtspunkt zweier Prinzipien gleichzeitig schlecht. Und wenn der erste nicht geholfen hat, es zu sehen, sollte der zweite helfen.
Und wie löst die Divide and Conquer-Methode das gleiche Problem:
async function printEmployeeReport(reportId){ const data = await dal.getEmployeeReportData(reportId); const formatted = reportDataService.prepareEmployeeReport(data); await printService.printReport(formatted); }
Fügen Sie eine neue Funktion hinzu. Ich nenne sie manchmal eine "Skriptfunktion", weil sie keine Implementierungen enthalten, sondern die Reihenfolge des Aufrufs zerlegter Teile unserer Verantwortung bestimmen. Offensichtlich stimmen die ersten beiden Zeilen, die ersten beiden zerlegten Verantwortlichkeiten mit den ersten beiden Zeilen der zuvor implementierten Funktion überein. Genau wie die ersten beiden Schritte von zwei Aufgaben, die von einem Business Analyst beschrieben wurden, fallen sie zusammen.
Um dem Projekt neue Funktionen hinzuzufügen, haben wir eine neue Skriptmethode und einen neuen printService hinzugefügt. Alte Dateien wurden nicht geändert. Das heißt, diese Methode zum Schreiben von Code ist unter dem Gesichtspunkt zweier Prinzipien gut. Und SRP und Open-Close
Alternative
Ich wollte auch einen alternativen, konkurrierenden Weg erwähnen, um einen gut zerlegten Code zu erhalten, der ungefähr so aussieht - zuerst schreiben wir den Code „auf die Stirn“ und überarbeiten ihn dann mit verschiedenen Techniken, zum Beispiel gemäß Fowlers Buch „Refactoring“. Diese Methoden erinnerten mich an die mathematische Herangehensweise an das Schachspiel, bei der Sie nicht genau verstehen, was Sie in Bezug auf die Strategie tun. Sie berechnen nur das "Gewicht" Ihrer Position und versuchen, es durch Züge zu maximieren. Ich mochte diesen Ansatz aus einem kleinen Grund nicht - Methoden und Variablen zu benennen ist bereits schwierig, und wenn sie keinen geschäftlichen Wert haben, wird es unmöglich. Wenn diese Techniken beispielsweise darauf hindeuten, dass Sie von hier und von dort 6 identische Zeilen auswählen und diese hervorheben müssen, wie sollten Sie diese Methode nennen? someSixIdenticalLines ()?
Ich möchte eine Reservierung vornehmen - ich denke nicht, dass diese Methode schlecht ist, ich konnte einfach nicht lernen, wie man sie benutzt.
Insgesamt
Wenn Sie dem Prinzip folgen, finden Sie Vorteile.
Die Definition von „es muss eine Verantwortung geben“ funktioniert nicht.
Es gibt eine bessere Definition und eine Reihe von indirekten Merkmalen, die sogenannten Code-Gerüche signalisieren die Notwendigkeit der Zersetzung.
Mit dem Ansatz „Teilen und Erobern“ können Sie sofort gut strukturierten und selbstdokumentierenden Code schreiben.