Tipps für die Arbeit mit Legacy-Code

Im Grunde genommen ist Legacy-Code ein seltsam höflicher Euphemismus für Code, der besonders schwer zu aktualisieren oder zu bearbeiten ist. Das kann daran liegen, dass er jahrelang von einer sich ständig wechselnden Gruppe von Entwicklern gepflegt wurde, dass unvorhergesehene neue Anforderungen alle möglichen internen Annahmen ungültig machen oder dass zu wenig Energie in die weniger glamourösen Teile des Codes investiert wurde. Aber sobald er einmal in einer Codebasis vorhanden ist, ist es sehr schwierig, ihn wieder loszuwerden, und deshalb müssen wir als Entwickler lernen, damit umzugehen. Hier sind einige Tipps, die ich bei der Arbeit mit Legacy-Code als nützlich empfunden habe.

Mach dich mit Git (oder JJ) vertraut

Versionskontrolle ist eine Superkraft für Legacy-Code, aber es kann etwas dauern, bis man sich daran gewöhnt hat!

Eine Taktik besteht darin, Commits zu erstellen, deren einzige Nachricht „WIP" oder „Work in Progress" lautet. Dadurch erhältst du Checkpoints, zu denen du zurückkehren kannst, wenn du feststellst, dass dein Versuch, Probleme zu beheben, alles durcheinandergebracht hat. Mit git checkout HEAD~N kannst du in der Zeit zurückgehen, wobei N die Anzahl der Commits ist, die du rückgängig machen möchtest. Schau dich um und entscheide, ob das der richtige Ort ist, erstelle dann einen neuen Branch mit git switch -c new-branch-name und fahre von diesem Punkt aus fort. Später kannst du nicht mehr benötigte Branches mit git branch -d old-branch-name-1 old-branch-name-2 bereinigen.

Der große Nachteil dieses Ansatzes ist, dass deine Commits nicht mehr so nützlich sind. Anstatt ganze Features mit nützlichen Beschreibungen darüber, werden sie zu einer langen Liste von „WIP" „WIP" „WIP" „WIP", und deine Commit-Historie klingt plötzlich wie ein Frosch mit einem Lispeln. In Git können wir dieses Problem lösen, indem wir die Commits zu einem einzigen neuen Commit zusammenfassen, der alle Änderungen enthält, idealerweise mit einer neuen Commit-Nachricht, die alles erklärt, was passiert ist. Dafür gibt es mehrere Möglichkeiten – die klassische ist die interaktive Rebase, welche etwas gewöhnungsbedürftig ist, aber sehr leistungsfähig. Wenn du jedoch damit zufrieden bist, einen gesamten Pull Request zu einem einzigen Commit zusammenzufassen, können die meisten Git-Forges wie GitHub oder GitLab es dir ermöglichen, dies automatisch mit nur einem Knopfdruck zu tun.

Der Vorteil all dessen ist, dass man mehr mit verschiedenen Ideen experimentieren kann. Wenn klar ist, dass der Versuch, alles zu reparieren, alles nur noch schlimmer macht, kann man einen Rückzieher machen und von vorne beginnen. Und mit dem oben beschriebenen Ansatz, bei dem git checkout und git switch -c verwendet werden, hat man immer noch seine alten Branches zur Verfügung, falls man doch zu dem Schluss kommt, dass der ursprüngliche Versuch gar nicht so schlecht war.

Das Erstellen von Checkpoints ist nicht der einzige Vorteil der Versionskontrolle. Wenn ich mit Code arbeite, den ich nicht verstehe, greife ich oft zu git blame. Das ist ein furchtbarer Name für ein wirklich nützliches Tool: git blame <Dateiname> zeigt dir, wann jede Zeile einer Datei zuletzt geändert wurde, von wem und in welchem Commit. Der letzte Teil ist der wichtigste, denn er bedeutet, dass du durch die Codebasis reisen und sehen kannst, wie sich eine bestimmte Datei im Laufe der Zeit verändert hat.

Wenn ich beispielsweise die Git-Codebasis klone, könnte ich git blame blame.c ausführen, um herauszufinden, wie sich der Befehl blame selbst im Laufe der Zeit verändert hat. Die Ausgabe sieht in etwa so aus:

(snip)
a470beea39b (Nguyễn Thái Ngọc Duy     2018-09-21 17:57:21 +0200  107) static void verify_working_tree_path(struct repository *r,
ecbbc0a53b0 (Nguyễn Thái Ngọc Duy     2018-08-13 18:14:41 +0200  108)                                struct commit *work_tree, const char *path)
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  109) {
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  110)   struct commit_list *parents;
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  111)   int pos;
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  112)
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  113)   for (parents = work_tree->parents; parents; parents = parents->next) {
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  114)           const struct object_id *commit_oid = &parents->item->object.oid;
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  115)           struct object_id blob_oid;
5ec1e728237 (Elijah Newren            2019-04-05 08:00:12 -0700  116)           unsigned short mode;
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  117)
50ddb089ff6 (Nguyễn Thái Ngọc Duy     2019-06-27 16:28:49 +0700  118)           if (!get_tree_entry(r, commit_oid, path, &blob_oid, &mode) &&
a470beea39b (Nguyễn Thái Ngọc Duy     2018-09-21 17:57:21 +0200  119)               oid_object_info(r, &blob_oid, NULL) == OBJ_BLOB)
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  120)                   return;
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  121)   }
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  122)
a470beea39b (Nguyễn Thái Ngọc Duy     2018-09-21 17:57:21 +0200  123)   pos = index_name_pos(r->index, path, strlen(path));
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  124)   if (pos >= 0)
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  125)           ; /* path is in the index */
a470beea39b (Nguyễn Thái Ngọc Duy     2018-09-21 17:57:21 +0200  126)   else if (-1 - pos < r->index->cache_nr &&
a470beea39b (Nguyễn Thái Ngọc Duy     2018-09-21 17:57:21 +0200  127)            !strcmp(r->index->cache[-1 - pos]->name, path))
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  128)           ; /* path is in the index, unmerged */
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  129)   else
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  130)           die("no such path '%s' in HEAD", path);
072bf4321fb (Jeff Smith               2017-05-24 00:15:34 -0500  131) }
(snip)

Wenn mich nun eine dieser Codezeilen verwirrt, beispielsweise warum diese Funktion in Zeile 116 den unsigned short mode verwendet, kann ich git show 5ec1e728237 eingeben und den Commit anzeigen, in dem diese Änderung vorgenommen wurde. Dadurch werden mir die Meldung (die mir mit etwas Glück mehr Kontext liefert) und die Unterschiede zu anderen Änderungen in diesem Commit angezeigt. Ich kann auch sehen, wer den Commit vorgenommen hat, was nützlich sein kann, wenn diese Person noch da ist und meine Fragen beantworten kann!

Die meisten IDEs bieten diese Funktionalität ebenfalls, entweder integriert oder als Erweiterung. In VSCode verwende ich „Better Git Line Blame", das an der Zeile, an der sich mein Cursor befindet, einen kleinen Text anzeigt, der mir sagt, wer die Änderung vorgenommen hat, wann dies geschehen ist und wie die Commit-Meldung lautete. Wenn ich mit der Maus über diesen Text fahre, kann ich auch direkt zum Diff des Commits springen.

Eine Einschränkung ist, dass Git diese Art des Hin- und Herspringens nicht immer einfach macht, was den Einstieg erschweren kann. Wenn du dich in dieser Situation befindest, empfehle ich dir wärmstens JJ/Jujutsu. Dies ist ein alternatives Tool zur Versionskontrolle, das jedoch vollständig mit Git kompatibel ist, sodass du es mit deinen üblichen Projekt-Repositorys zusammen mit Kollegen verwenden kannst, die Git verwenden. Der große Vorteil für Legacy-Code besteht darin, dass es sehr einfach ist, überall neue, temporäre Commits zu erstellen, bei Bedarf zwischen ihnen zu springen und Änderungen leichter rückgängig zu machen (es ist buchstäblich nur jj undo). Wenn du daran interessiert bist, dies auszuprobieren, hat Steve Klabnik ein sehr nützliches JJ-Tutorial verfasst.

Erstelle einen „Tech Debt"-Tracker (und nutze ihn!)

Wenn du mit Legacy-Code arbeitest, musst du bedenken, dass du nicht alles auf einmal bereinigen kannst und dass du viele Dinge niemals bereinigen können wirst. Aber du musst trotzdem wissen, wo die Probleme liegen, auch wenn du sie im Moment nicht beheben kannst. Ein Backlog für technische Schulden ist dafür ideal, muss aber gut genutzt werden, sonst kann es zu einem Friedhof der technischen Verzweiflung werden.

Das Wichtigste dabei ist die Dokumentation. Wenn du etwas zum Backlog hinzufügst, mach Folgendes deutlich:

  • Was genau das Problem ist (Leistungsprobleme, eine verwirrende API, Code, der schwer zu aktualisieren ist).
  • Welche Teile des Codes sind betroffen (nützlich, um später den Überblick zu behalten).
  • Was du bereits versucht hast, um das Problem zu beheben.
  • Mindestens eine Idee für eine mögliche Lösung.

Das Ziel ist – wie bei allen guten Tickets – dass jeder im Entwicklerteam, der mit diesem Teil des Codes vertraut ist, das Ticket aufnehmen und mit der Arbeit daran beginnen kann.

Sobald du das Ticket erstellt hast, musst du auch etwas unternehmen. Als Entwickler halte ich es oft für sinnvoll, in unseren Schätzungen Platz für die Begleichung technischer Schulden einzuplanen. Wenn ich beispielsweise eine neue Funktion in einem bestimmten Bereich der Codebasis implementieren muss und weiß, dass es in diesem Bereich technische Schulden gibt, könnte ich vorschlagen, beides zu kombinieren und den Aufwand für die gleichzeitige Umsetzung zu schätzen. Dies erfordert jedoch die Zustimmung aller Beteiligten, was nicht immer möglich ist.

Ein anderer Ansatz besteht darin, jede Woche/jeden Sprint/etc. eine bestimmte Zeit für die Bearbeitung von Tech-Debt-Tickets vorzusehen. Auch hier müssen alle akzeptieren, dass das Team kurzfristig langsamer vorankommt, um langfristig die Geschwindigkeit zu verbessern.

Bei einem Tech-Debt-Tracker besteht eine der Schwierigkeiten in der Priorisierung. Bei der Hinzufügung von Funktionen oder der Behebung von Fehlern ist die Priorisierung in der Regel eine geschäftliche Entscheidung, bei Tech Debt hingegen handelt es sich eindeutig um eine technische Entscheidung. Eine gute Methode, um zu erkennen, inwieweit ein bestimmtes Ticket die Entwickler beeinträchtigt (und daher, wie schnell es behoben werden sollte), besteht darin, Kommentare oder „+1" zu diesem Ticket hinzuzufügen, wenn du oder ein anderer Entwickler erneut darauf stößt. Bei physischen Trackern mit Haftnotizen und Stiften habe ich gesehen, dass dies mit farbigen Punktstickern gemacht wird.

Schreibe Tests (auch wenn sie das Falsche behaupten)

Ich möchte hier nicht um den heißen Brei herumreden: Das Schreiben von Tests für Legacy-Code ist schwierig. Das Schreiben guter Tests ist in erster Linie eine Frage des Schreibens von sauberem, leicht zu testendem Code, und das Problem bei Legacy-Code ist, dass er weder sauber noch leicht zu testen ist!

Andererseits erfordert die Arbeit mit Legacy-Code viel Refactoring, und Tests sind unverzichtbar, um das Refactoring richtig durchzuführen! Das Hinzufügen von Tests kann also eine lohnende, wenn auch mühsame Aufgabe sein.

Das Kernproblem bei Legacy-Code ist in der Regel, dass die Abstraktionen in der Codebasis nicht ganz korrekt sind und dass Entwickler, um dies zu beheben, Code-Teile, die nie miteinander verbunden werden sollten, zu einem Spaghetti-Code verknüpfen oder Teile des Codes durch komplexe Systeme verknüpfen mussten, damit sie in einer anderen Funktion, die sie benötigt, zugänglich sind.

Das bedeutet, dass du, um nützliche Tests zu schreiben, ohne in die Gefahren des Mockings zu geraten, oft Tests auf einer höheren Abstraktionsebene schreiben musst, als du es normalerweise tun würdest. In einer React- oder Vue-Anwendung musst du beispielsweise möglicherweise eine ganze Komponente oder sogar eine ganze Seite testen, anstatt nur einen einzelnen Hook.

Einer der Vorteile dieser Art des Testens besteht jedoch darin, dass sie die Stellen aufzeigen kann, an denen ein bestimmter Teil der Logik abstrahiert werden muss. Wenn du Tests für eine ganze Komponente schreibst, es aber einige spezifische Randfälle gibt, die auf dieser Ebene wirklich schwer zu testen sind, dann könnte das ein Zeichen dafür sein, dass du damit beginnen solltest, diese Randfälle in eigene Komponenten/Hooks/Klassen usw. umzugestalten.

Ich habe hier einen weiteren Trick aus einem Blogbeitrag von Matklad gelernt: Es ist in Ordnung, Tests zu schreiben, die unerwünschtes Verhalten überprüfen. Wenn du einen Test schreibst, der das korrekte Verhalten überprüft, und der Code derzeit etwas anderes und falsches tut, solltest du den Test so ändern, dass er stattdessen das falsche Verhalten überprüft. Wenn nun ein anderer Entwickler über diesen Codeabschnitt stolpert, muss er nicht dieselbe Entdeckung machen wie du, da es bereits einen Test und einen Kommentar gibt, der das ungewöhnliche Verhalten erklärt. Und natürlich solltest du ein Ticket dazu im Bug-Tracker erstellen und es in diesem Kommentar verlinken. Wenn du dazu kommst, den Code zu korrigieren, kannst du den Test aktualisieren, um das korrekte Verhalten zu überprüfen.

Nimm dir Zeit zum Verstehen

Eine der größten Gefahren beim Lesen von Code – selbst von sehr gutem – sind Vermutungen. Man sieht sich einen Code an, vergleicht ihn mit dem, was man erwartet, und vergisst, ihn richtig zu lesen, sodass man nicht merkt, dass er genau das Gegenteil von dem tut, was man dachte. Ein klassisches Beispiel ist das Lesen von Code, der eine Reihe von x- und y-Werten aktualisiert, ohne zu bemerken, dass ein Teil des Codes y anstelle von x verwendet und umgekehrt.

Wenn es um Legacy-Code geht, hast du es mit einer mangelnden Vertrautheit mit der Codebasis, einer Vielzahl unterschiedlicher Codestile und der Tatsache zu tun, dass die offensichtlichen Fehler bereits behoben wurden und nur noch die wirklich kniffligen zu bewältigen sind. Das bedeutet, dass es in der Regel sehr wichtig ist, sich Zeit zu nehmen, um zu verstehen, was auf jeder Ebene vor sich geht.

Wie im letzten Abschnitt erläutert, können Tests sehr nützlich sein. Tests geben dir die Möglichkeit, aufzuschreiben, was deiner Meinung nach passieren wird, und zu überprüfen, ob dies korrekt ist. Außerdem kannst du damit alle Stellen dokumentieren, an denen du dich geirrt hast. Wie bereits erwähnt, ist es jedoch oft schwierig, nützliche Tests für Legacy-Code zu schreiben. Das ist zwar großartig, wenn es funktioniert, aber es ist schlichtweg nicht immer möglich.

Die Verwendung eines Debuggers ist eine weitere nützliche Option. Das Ein- und Aussteigen aus Funktionen kann eine sehr nützliche Methode sein, um sich zu zwingen, den genauen Kontrollfluss zu verfolgen und nach unerwarteten Rückgaben oder Fehlern mit überraschenden Auswirkungen Ausschau zu halten. Wenn es nicht möglich ist, den Code an einen echten Debugger anzuschließen, kann die Print-Anweisung als einfacher Debugger dienen.

Du solltest dir auch Notizen über alles machen, was du tust. Ich muss gestehen, dass mir das nie besonders gut gelungen ist – ich habe zwei Jahre lang Physik studiert und verzweifelt versucht, ein ordentliches Logbuch zu führen, bevor ich aufgegeben und zur Programmierung gewechselt habe, nur um dann zu erfahren, dass gute Programmierer ebenfalls ein Logbuch führen! Notizen auf Papier zu machen ist großartig, weil man schreiben, zeichnen und Diagramme hinzufügen kann, alles auf demselben Blatt, obwohl ich diese Art von Notizen eher als vorübergehende Lösung empfinde. Dauerhaftere Gedanken können in einem Projekt-Wiki oder sogar in einem Ticket im Tech-Debt-Tracker festgehalten werden. So kann das gesamte Team auf die Informationen zugreifen, aber genauso wichtig ist, dass du in sechs Monaten auf ein Ticket zurückkommen und immer noch eine Chance haben kannst, herauszufinden, was du beim letzten Mal gemacht hast, als du es angesehen hast.

Wenn die Dinge einfach keinen Sinn ergeben, kann es sehr hilfreich sein, sich eine Auszeit vom Code zu nehmen. Mach etwas anderes (oder mach Mittagspause oder geh nach Hause) und komm mit einem neuen Blick zurück, um dir den Code anzusehen. Das ist unglaublich effektiv, und es gibt wahrscheinlich einen biologischen Grund, warum es so gut funktioniert, obwohl ich keine Ahnung habe, welcher das sein könnte.

Alles zeitlich begrenzen

Der letzte Ratschlag hier ist hauptsächlich für mich selbst gedacht. Ich mag das Gefühl der Refaktorisierung sehr. Es ist einfach großartig, etwas, das früher chaotisch und unübersichtlich war, in etwas Sauberes und Lesbares zu verwandeln. Wenn ich nachts nicht schlafen kann und mich an einen Ort zurückziehen muss, an dem ich mich wohlfühle, stelle ich mir einen PR mit einer Datei nach der anderen mit gelöschtem Code vor. (Der letzte Teil ist eine Lüge, aber er entspricht mehr der Wahrheit, als ich zugeben möchte.)

Leider ist jetzt nicht immer der richtige Zeitpunkt und Ort für eine große, tiefgreifende Refaktorisierung. Schließlich ist das eigentliche Ziel von Code, dass er das tut, was er soll – dass er dabei gut aussieht, ist hauptsächlich ein Vorteil für diejenigen von uns, die daran arbeiten. Es macht keinen Sinn, eine Woche damit zu verbringen, einen Teil des Codes zu refaktorisieren, wenn dieser Code von vornherein größtenteils in Ordnung war.

Um meinem natürlichen Drang entgegenzuwirken, setze ich mir selbst Grenzen, wie lange ich nach der schönsten Lösung suchen darf, bevor ich mich mit einer schnellen und provisorischen Lösung zufrieden gebe. Dies wird allgemein als Timeboxing bezeichnet. Das ist besonders dann sinnvoll, wenn man anfängt, an einem Faden in der Codebasis zu ziehen, aber zu Beginn noch nicht weiß, wo dieser Faden enden wird. In der Regel muss man dabei im Voraus entscheiden, wie viel Zeit man in die Erforschung einer Strategie investieren möchte, und diese Strategie dann nach Ablauf dieser Zeit neu bewerten.

Die andere Seite davon ist das Post-Hoc-Timeboxing, bei dem du nach einigen Stunden der Erforschung eines Rabbit Holes entscheidest, ob es wahrscheinlich ist, dass es zu etwas Nützlichem führt, oder ob es sich lohnt, es vorerst aufzugeben. (Beachte, dass dies auf den ersten Punkt in diesem Artikel zurückkommt, nämlich die Verwendung von Git, um praktische Checkpoints zu erstellen, damit du etwas hast, zu dem du zurückkehren kannst!)

Wenn die festgelegte Zeit abgelaufen ist, kannst du dich dennoch entscheiden, weiterzumachen, wenn du das Gefühl hast, kurz vor dem Ziel zu stehen. Du kannst dich aber auch entscheiden, den Versuch ganz aufzugeben oder ihn für später zurückzustellen. Wenn du ihn zurückstellst, solltest du ihn zu einem Ticket hinzufügen, damit andere Personen den bisherigen Verlauf verfolgen können.

Fazit

Letztendlich ist es unwahrscheinlich, dass du Legacy-Code vollständig aus deinen Codebasen entfernen kannst. Selbst einige meiner Lieblingscodebasen enthalten Bereiche, deren Bearbeitung schwieriger ist als andere. Daher ist die Arbeit mit Legacy-Code definitiv eine Fähigkeit, die es sich lohnt zu erlernen. Ich hoffe, diese Tipps waren hilfreich für dich, und ich möchte dich ermutigen, deine eigenen Lieblingstipps, die du als nützlich empfunden hast, mit anderen zu teilen.