
Die Welt ist unglaublich komplex, und nirgendwo wird dies deutlicher als in der Kunst des Softwareschreibens. Software ist das Ergebnis komplexer Interaktionen zwischen sozialen Strukturen, Geschäftsanforderungen und begrenztem Wissen. Die Komplexität dieser Interaktionen spiegelt sich unweigerlich in der Codestruktur wider. Unsere Aufgabe als Ingenieure ist es, diese Komplexität zu managen und unsere Abstraktionen so zu gestalten, dass sie mit der zunehmenden Komplexität Schritt halten. Die wichtigste Technik dabei ist, Raum für Logik zu schaffen, damit diese komplexer wird, ohne darin zu ertrinken – denn bei Komplexität macht die Dosis das Gift.
Aus diesem Grund lautet einer der häufigsten Ratschläge zum Schreiben von Software, viele kleine, fokussierte Klassen zu schreiben und die Logik in viele kleine, fokussierte Methoden aufzuteilen. Software, die in diesem Stil geschrieben wird, bietet Raum für die zunehmende Komplexität einzelner Komponenten. Wir im Rescale-Entwicklungsteam haben festgestellt, dass die Abspaltung der Logik von einer wachsenden Methode oder Klasse viel früher erfolgt, als Entwickler normalerweise denken. Wir beginnen lieber mit der Abstraktion, während wir die erste und zweite Version von Klassen und Methoden schreiben.
Um dies anhand eines Beispiels zu veranschaulichen: Wir haben kürzlich Code zum Parsen von XML-Dateien von Drittanbietern geschrieben, die Workflows beschreiben – zu übertragende Dateien, auszuführende Analysen und Variablendefinitionen. Unser Parsecode war zunächst relativ einfach, hauptsächlich, weil wir nicht alles wussten, was wir parsen mussten. Wir begannen mit dem Parsen von Eingabedateien und Variablen. Diese werden jeweils durch einen XML-Knoten repräsentiert, und jeder XML-Knoten gibt seinen Typ mit einem Attribut namens „Typ“ an und gibt wichtige Werte mit typabhängigen Attributen an.
Ein anfänglicher Analysecode sah folgendermaßen aus:
Sammlung inputFileNames = getNodeStream() .filter(n -> n.getAttributes() .getNamedItem(„Typ“) .getNodeValue().equals(„Eingabedateityp“)) .map(n -> n.getAttributes() .getNamedItem(„Dateiname“) .getNodeValue()) .collect(Collectors.toList()); Sammlung inputVariableNames = getNodeStream() .filter(n -> n.getAttributes() .getNamedItem(„Typ“) .getNodeValue().equals(„Eingabevariablentyp“)) .map(n -> n.getAttributes() .getNamedItem(„Variablenname“) .getNodeValue()) .collect(Collectors.toList());
Die Hilfsmethode getNodeStream oben konvertiert ein Knotenliste aus dem Dokument in eine Strom zur einfachen Handhabung.
Bei diesem Code sind zwei Dinge zu beachten: Er verwendet magische Zeichenfolgen statt Konstantenund dupliziert Code, um Attributwerte zu extrahieren. Nach der Anwendung dieser einfachen Refaktorierungen ist der Parsing-Code weniger mit Implementierungsdetails überladen und entspricht eher seiner Absicht:
Sammlung inputFileNames = getNodeStream() .filter(n -> INPUT_FILE_TYPE.equals(getAttribute(n, TYPE))) .map(n -> getAttribute(n, FILE_NAME)) .collect(Collectors.toList()); Sammlung inputVariableNames = getNodeStream() .filter(n -> INPUT_VARIABLE_TYPE.equals(getAttribute(n, TYPE))) .map(n -> getAttribute(n, VARIABLE_NAME)) .collect(Collectors.toList());
Dies ist unser erstes Beispiel dafür, wie Best Practices helfen können, Code auf zunehmende Komplexität vorzubereiten. Oberflächlich betrachtet scheint es nicht viel getan zu haben, aber indem wir Code schreiben, der näher an dem ist, was wir bedeuten, anstatt das, was der Computer enthalten?, wir haben es einfacher gemacht, diesem Code mehr Komplexität hinzuzufügen, da wir beim Schreiben neuer Ergänzungen weniger Kontext im Kopf behalten müssen.
Wenn das Parsen dieser String-Sammlungen alles wäre, was wir mit diesem XML-Code tun, wäre es in Ordnung, den Code so zu belassen. Da es sich jedoch um Software handelt, wurde es komplexer. Nachdem wir das dritte oder vierte Typ-/Attribut-Parsing-Paar geschrieben hatten, beschlossen wir, einige Enumerationen zu extrahieren:
öffentliche Aufzählung NodeAttribute { TYPE(„Typ“), FILE_NAME(„Dateiname“), VARIABLE_NAME(„Variablenname“), … private finale String-Attributname; privates NodeAttribute(String-Attributname) { this.Attributname = Attributname; } öffentliche String getValue(Node node) { return node.getAttributes() .getNamedItem(this.Attributname) .getNodeValue(); } öffentliche Aufzählung NodeType { INPUT_FILE(„Eingabedatei“), INPUT_VARIABLE(„Eingabevariable“), OUTPUT_VARIABLE(„Ausgabevariable“), … private finale String-Wert; privater NodeType(String-Wert) { this.Wert = Wert; } öffentliche boolean-Übereinstimmungen(Node node) { return NodeAttributes.TYPE.getValue(node).equals(this.Wert); } }
Jetzt sieht der Analysecode folgendermaßen aus:
Sammlung inputFileNames = getNodeStream() .filter(NodeType.INPUT_FILE::matches) .map(NodeAttribute.FILE_NAME::getValue) .collect(Collectors.toList()); Sammlung inputVariableNames = getNodeStream() .filter(NodeType.INPUT_VARIABLE::matches) .map(NodeAttribute.VARIABLE_NAME::getValue) .collect(Collectors.toList()); …
Es scheint übertrieben, hier Logik zu extrahieren, aber wir waren motiviert, dies zu tun, weil die Enumerationen einen zentralen Ort bieten, um diese Konstanten für die Wiederverwendung zu definieren. Ein weiterer Vorteil, den wir bald erkannten, war, dass sie Raum für die Komplexität der einzelnen Teile. Jetzt denken Sie vielleicht: „Es geht doch nur darum, ein Attribut aus einem XML-Knoten zu extrahieren. Wie könnte das noch komplexer werden?“ Wir hätten das jedenfalls nicht gedacht.
Es stellte sich jedoch heraus, dass in diesem XML-Code von Drittanbietern einige Knoten auf Dateien mit dem Attribut „filename“ und andere auf Dateien mit dem Attribut „fileName“ verweisen. So etwas bringt Programmierer zum Fluchen, aber zum Glück waren wir darauf vorbereitet, dies problemlos zu handhaben:
öffentliche Aufzählung NodeAttribute { TYPE(„Typ“), FILE_NAME(„Dateiname“, „Dateiname“), VARIABLE_NAME(„Variablenname“), … private final String[] attrNames; private NodeAttribute(String... attrNames) { this.attrNames = attrNames; } öffentliche Zeichenfolge getValue(Node node) { return Arrays.stream(this.AttrNames) .map(attr -> node.getAttributes().getNamedItem(attr)) .filter(attr -> attr != null) .findFirst() .orElseThrow(() -> new NoSuchElementException( "Node: " + node.getNodeValue() + " hatte keine Attribute mit dem Namen: " + Arrays.toString(mAttrNames) )); }
Unser sonstiger Parsing-Code musste nicht geändert werden. Hätten wir weiterhin String-Konstanten verwendet, hätten wir den Parsing-Code häufig aktualisieren müssen, um nach Dateinamen oder Dateinamen zu suchen, oder spezielle Methoden für Knoten schreiben müssen, die auf Dateien verweisen. Der Code wäre mit If/Else-Logik überladener geworden. Da wir jedoch frühzeitig abstrahiert haben, hatten wir Platz für diese Logik. Wir möchten noch einmal betonen, dass wir diesen Unterschied in der Attribut-Groß- und Kleinschreibung nicht erwartet hatten, aber genau darum geht es – Sie sollten erwarten Code wird auf eine Weise komplexer, die Sie erwarte nicht.
Warum verwenden manche Knoten „filename“ und andere „fileName“? Wir können davon ausgehen, dass zwei verschiedene Personen an der Serialisierung der verschiedenen Knoten gearbeitet haben und nicht wussten, welches Groß-/Kleinschreibungsschema der jeweils andere gewählt hatte. Vielleicht haben sie sich mündlich verständigt und sich auf „filename“ als Attribut geeinigt, aber einer von ihnen hat Camel Case verwendet. Oder vielleicht haben sie einen Knoten nach dem anderen bearbeitet und vergessen, welches Groß-/Kleinschreibungsschema verwendet wurde.
Wie dem auch sei, die Komplexität der sozialen Struktur des Entwicklungsteams des Drittanbieters manifestiert sich in diesen unterschiedlichen Attributnamen und spiegelt sich in unserer Codebasis wider. Es ist unsere Aufgabe, auf diese Art von Komplexität vorbereitet zu sein und sie mit geeigneten Strukturen bewältigen zu können.
