Gute und schlechte Software-Praktiken gestern und heute
Wodurch sich robuste Software auszeichnet und warum Entwickler (und Unternehmen) sie brauchen.
Einen wesentlichen Teil bei der Entwicklung einer OXID eShop Lösung für ein mittelständisches Unternehmen nimmt die Integration des Shops mit seinen Umsystemen ein. Hierzu zählen Warenwirtschaftssysteme, Produktdatenmanagementsysteme, CRM-Systeme und externe Suchserver. Letztere sind häufig eine Notwendigkeit, um eine leistungsfähige Suche zu verwirklichen.
Wenn die Suche eine "On-Premise-Lösung" ist, kann sie zwar als Teil des Shops aufgefasst werden, stellt aber dennoch eine Integrationsaufgabe da, die korrekt, robust und performant realisiert werden muss. Anhand eines kleinen Stück Codes aus einer solchen Integrationaufgabe möchte ich diskutieren, wodurch sich gute und schlechte Praktiken unterscheiden.
Guter Code existiert jenseits vom Buzz
Aus dem Blickwinkel von nahezu 40 Jahren Praxis in der Software-Entwicklung ziehen sich immer wiederkehrende gute und schlechte Praktiken wie ein roter Faden durch das Tätigkeitsfeld des Software-Entwicklers. Statt die schlechten Praktiken zu beseitigen, werden gerne neue Hypes und Buzzwords in den Ring geworfen. Von ihnen wird immer wieder die quasi ‚automatische Lösung' von Softwareproblemen erwartet. Dabei ist Software oftmals Auftragsarbeit, die die sogenannte "Bottom Line" eines Wirtschaftsunternehmen verbessern und den Kunden robuste und betreibbare Lösungen liefern soll. Ich möchte daher dafür plädieren, nicht zu viel Zeit mit der Suche nach dem Heiligen Gral in Form des aktuellen Hypes zu verbringen, sondern die eigenen Fähigkeiten zu entwickeln, wie man robuste Software schreibt.
Ein Typisches Anti-Pattern…
Illustrieren möchte ich das an einem hübschen kurzen Beispiel aus der Praxis, das ein hartnäckig verfolgtes Anti-Pattern zeigt:
Eine Software zur Erstellung eines Suchindexes zeigt merkwürdiges Verhalten in Bezug auf die Handhabung mehrerer Sprachen. So landet im englischen Sprachindex deutscher Text, obwohl die Indizierungslogik die Sprache doch augenscheinlich korrekt setzt. Zentral ist hier eine Klasse Indexer, die in reduzierter Form in Pseudocode wie folgt aussieht:
class Indexer
{
private int langId = 0;
private String langIsoCode = null;
public Indexer()
{
}
public setLanguageId(int id)
{
this.langId = id;
}
public int getLanguageId()
{
return this.langId;
}
public setLanguageIsoCode(String isoCode)
{
this.langIsoCode = isoCode;
}
public String getLanguageIsoCode()
{
retrurn this.langIsoCode;
}
public index(String key)
{
String view = Database.getLanguageViewFor(this.langId);
String value = Database.readFromView(view, key);
this.sendToIndex(key, value);
}
private sendToIndex(key, value)
{
// not included here
}
}
Diese hübsche Klasse macht noch ein paar weitere Dinge (vielleicht keine klare "Separation of Concerns"?), aber sie wird wie folgt verwendet:
String keyToIndex = 'myKey';
Indexer indexer = new Indexer();
foreach (Languages: String langIsoCode) {
indexer.setLanguageIsoCode(langIsoCode);
indexer.index(keyToIndex);
}
Das sieht beim flüchtigen Lesen, vor allem, wenn es in eine grössere Code-Base eingebettet ist, vernünftig aus.
Das Problem ist jedoch, dass Indexer zwei Member-Variablen langId und langIsoCode hat, die eigentlich einem Constraint unterworfen sein sollten (die ID muss immer zum ISO-Code passen). Dieser Constraint existiert jedoch nicht im Code, sondern bestenfalls im Kopf des Entwicklers und er wird durch die Konstruktion dieser Klasse nicht erzwungen.
… und die Probleme, die daraus erwachsen
Die Probleme beginnen bereits in der Konstruktionsphase, die das Anlegen eines nicht-funktionsfähigen Objekts erlaubt. Die public Setter und Getter erlauben eine beliebige Änderung von außen, die den Zusammenhang von ID und ISO-Code verletzen können. Diese Probleme bleiben über den gesamten Lebenszyklus des Objektes bestehen: Der Verwender muss peinlich genau aufpassen, immer beide Argumente vorher übereinstimmend zu setzen. Somit hat diese Klasse auch ein großes Usability-Problem.
Am besten wäre es, Indexer als eine nicht-mutierbare Klasse anzulegen, die zudem nur entweder die ID oder den ISO-Code der Sprache als Konstruktor-Parameter nimmt und damit nur korrekt an gelegt werden kann und auch nicht mehr durch den Aufruf einer mutierenden Methode in einen inkorrekten Zustand versetzt werden kann.
Wenn die Klasse schon mutierbar sein soll (was sehr oft vermieden werden kann), dann sollte es keine Mutation geben, die einen ungültigen Zustand erzeugt. Vorstellbar wäre ein Setter für entweder ID und ISO-Code oder ein Setter, der beide als Parameter nimmt und eine fehlerhafte Kombination der beiden Parameter erkennen kann.
Gegenprobe: Punktet das Pattern mit Flexibilität?
Wie oft wurde aber ein Design wie das obige von einem Programmierer als erstrebenswert, weil "flexibel" verteidigt? Schliesslich wird doch in vielen Büchern (die vielleicht nie hätten gedruckt werden sollen) genau solcher Stil vorgemacht. Da ich die Zahl der Bugs, die auf dieses Anti-Pattern zurückzuführen sind aber schon nicht mehr zählen kann, nenne ich es nur noch "Das Schicksal herausfordern".
Die geliebte "Flexibilität" führt nämlich allzu oft dazu, dass eine Instanz einer solchen Klasse in einen ungültigen Zustand versetzt wird und der Zusammenhang zwischen Ursache und Wirkung oft nicht leicht zu erkennen ist, da beide in der Code-Base weit voneinander entfernt sind. Bei genauer Betrachtung handelt es sich bei dem geschilderten Anti-Pattern nämlich nicht um eine Flexibilisierung des Codes – z.B. durch Interfaces und Polymorphie – sondern um das Einbringen von Non-Determinismen.
Sehr viel mehr zur Robustheit von Software trägt hingegen das Inversion-Of-Control- oder Hollywood-Prinzip ("don't call us, we call you") bei. In der OO-Welt findet sich das in Frameworks bzw. in Form des "Template Method"-Patterns wieder, in der funktionalen Welt findet es sich in Erlang/OTP als sogenannte Behaviours. Der Grund dafür, dass diese Entwurfstechnik zu robustem Code führt, ist einfach: Hier wird Wiederverwendung in bester DRY-Manier (Don't repeat yourself) betrieben und durch jede Ausprägung eines Frameworks (oder Behaviours) werden die Fehler in diesem reduziert.
Angenehmer Nebeneffekt ist, dass das Verhalten einer Software, die auf solchen Prinzipien fußt, vorhersagbarer ist und kein unerwartetes Verhalten zeigt. Auf diese Weise fordert der Entwickler das Schicksal nicht mehr heraus, sondern begibt sich selber in dessen Kontrolle.
Flexibilität vs. Robustheit
Das Interessante ist nun, dass es robuste Programmiertechniken schon lange gibt und dass sie auch in modernen Software-Ökosystemen immer noch mit Erfolg angewendet werden. Trotzdem sind schlechte Praktiken immer noch verbreitet und eben deswegen auch so schwer auszurotten. Man muss wohl erst selber die Erfahrung gemacht haben, dass man sein Leben als Software-Entwickler nicht durch "flexible" Entwürfe erleichtert, sondern eher durch robuste. Der Grund dafür liegt darin, dass der Hauptaufwand nicht im schnellen Erstellen eines "Proof of Concepts" liegt, sondern in der sogenannten Wartungsphase, in der Fehler behoben und neue Anforderungen so realisiert werden müssen, dass sie möglichst keine neuen Fehler einführen.