Strikte Null-Prüfung in Visual Studio Code
23. Mai 2019 von Matt Bierner, @mattbierner
Sicherheit ermöglicht Geschwindigkeit
Schnell zu arbeiten macht Spaß. Es macht Spaß, neue Funktionen auszuliefern, Benutzer glücklich zu machen und unsere Codebasen zu verbessern. Aber gleichzeitig macht es keinen Spaß, ein fehlerhaftes Produkt auszuliefern. Niemand mag es, Fehler zu erhalten oder mitten in der Nacht wegen eines Vorfalls geweckt zu werden.
Obwohl schnelles Arbeiten und das Ausliefern von stabilem Code oft als unvereinbar dargestellt werden, sollte das nicht der Fall sein. Viele Male sind die gleichen Faktoren, die Code fragil und fehlerhaft machen, auch das, was die Entwicklung verlangsamt. Wie können wir schließlich schnell vorankommen, wenn wir ständig Angst haben, etwas kaputt zu machen?
In diesem Beitrag möchte ich eine wichtige technische Anstrengung teilen, die das VS Code-Team kürzlich abgeschlossen hat: die Aktivierung der strengen Null-Prüfung von TypeScript in unserer Codebasis. Wir glauben, dass diese Arbeit es uns ermöglichen wird, sowohl schneller zu arbeiten als auch ein stabileres Produkt auszuliefern. Die Aktivierung der strengen Null-Prüfung wurde dadurch motiviert, Fehler nicht als isolierte Ereignisse, sondern als Symptome größerer Gefahren in unserem Quellcode zu betrachten. Anhand der strengen Null-Prüfung als Fallstudie werde ich diskutieren, was unsere Arbeit motiviert hat, wie wir zu einem inkrementellen Ansatz zur Bewältigung des Problems gekommen sind und wie wir die Behebung implementiert haben. Dieser allgemeine Ansatz zur Identifizierung und Reduzierung von Gefahren kann auf jedes Softwareprojekt angewendet werden.
Ein Beispiel
Um das Problem zu veranschaulichen, mit dem VS Code vor der Aktivierung der strengen Null-Prüfung konfrontiert war, betrachten wir eine einfache TypeScript-Bibliothek. Machen Sie sich keine Sorgen, wenn Sie neu in TypeScript sind; die Details sind nicht wichtig. Dieses erfundene Beispiel soll nur die Art von Problem veranschaulichen, auf die wir in der VS Code-Codebasis gestoßen sind, und einige traditionelle Reaktionen auf solche Probleme erwähnen.
Unsere Beispielbibliothek besteht aus einer einzigen Funktion getStatus, die den Status eines bestimmten Benutzers vom Backend einer hypothetischen Website abruft
export interface User {
readonly id: string;
}
/**
* Get the status of a user
*/
export async function getStatus(user: User): Promise<string> {
const id = user.id;
const result = await fetch(`/api/v0/${id}/status`);
const json = await result.json();
return json.status;
}
Sieht vernünftig aus. Ausliefern!
Aber nach der Bereitstellung unseres neuen Codes sehen wir eine Zunahme von Abstürzen. Vom Aufrufstapel her scheint es, dass die Abstürze in unserer getStatus-Funktion auftreten. Oh je!
Wenn wir ein wenig weiter zurückverfolgen, scheint es, dass ein Kollege getStatus(undefined) aufruft, um den Status des aktuellen Benutzers abzurufen. Dies verursacht eine Ausnahme, wenn der Code versucht, auf undefined.id zuzugreifen. Einfacher Fehler. Und da wir jetzt die Ursache kennen, beheben wir sie!
Wir aktualisieren also den aufrufenden Code, aktualisieren getStatus, um undefined zu behandeln, und fügen auch eine hilfreiche Warnung in unseren Doc-Kommentar hinzu
/**
* Get the status of a user
*
* Don't call this with undefined or null!
*/
export async function getStatus(user: User): Promise<string> {
if (!user) {
return '';
}
const id = user.id;
const result = await fetch(`/api/v0/${id}/status`);
const json = await result.json();
return json.status;
}
Und weil wir absolut echte Ingenieure sind, schreiben wir auch einen Test
it('should return empty status for undefined user', async () => {
assert.equals(getStatus(undefined), '');
});
Großartig! Keine Abstürze mehr. Und wir haben sogar wieder 100 % Testabdeckung! Unser Code muss jetzt perfekt sein.
Ein paar Tage vergehen und dann: BOOM! Jemand bemerkt etwas Seltsames in unseren Protokollen, eine riesige Anzahl von Anfragen an /api/v0/undefined/status. Das ist ein seltsamer Benutzername...
Also untersuchen wir erneut, beheben den Code erneut, fügen weitere Tests hinzu. Vielleicht senden wir auch eine passiv-aggressive E-Mail an denjenigen, der getStatus({ id: undefined }) aufgerufen hat.
/**
* Get the status of a user
*
* !!!
* WARNING: Don't call this with undefined or null, or with a user without an id
* !!!
*/
export async function getStatus(user: User): Promise<string> {
if (!user) {
return '';
}
const id = user.id;
if (typeof id !== 'string') {
return '';
}
const result = await fetch(`/api/v0/${id}/status`);
const json = await result.json();
return json.status;
}
Perfekt. Aber nur um sicher zu gehen, verlangen wir, dass alle Änderungen, die einen Aufruf von getStatus einführen, von einem leitenden Ingenieur genehmigt werden. Das sollte diese lästigen Fehler endgültig stoppen...
Und vielleicht schaffen wir es dieses Mal ein paar Tage länger bis zum nächsten Absturz. Vielleicht sogar ein paar Monate. Aber solange unser Code nie wieder geändert wird, wird es einen geben. Wenn nicht in dieser spezifischen Funktion, dann irgendwo anders in unserer Codebasis.
Um die Sache noch schlimmer zu machen, erfordert jede Änderung jetzt: defensive Prüfung auf undefined, Änderung von Tests oder Hinzufügen neuer Tests und Genehmigung durch das Team. Was soll das? Wir alle tun unseren Teil und trotzdem gibt es noch Fehler! Es muss einen besseren Weg geben.
Identifizierung der Gefahr
Während die Fehler im obigen Beispiel offensichtlich erscheinen mögen, sind wir bei der Entwicklung von VS Code auf die gleichen Probleme gestoßen. Jede Iteration behoben wir Fehler im Zusammenhang mit einem unerwarteten undefined. Und wir fügten Tests hinzu. Und wir gelobten, bessere Ingenieure zu sein. Das sind alles traditionelle Reaktionen und doch würde es in der nächsten Iteration wieder von vorne beginnen. Das verursachte nicht nur bei einigen Benutzern eine schlechte Erfahrung mit VS Code, sondern diese Fehler und unsere Reaktionen darauf verlangsamten uns auch bei der Arbeit an neuen Funktionen oder der Änderung von bestehendem Quellcode.
Wir erkannten, dass wir unsere Fehler auf eine neue Weise zu verstehen beginnen mussten, nicht als isolierte Ereignisse, sondern als Symptome/Signale größerer Probleme. Unsere Reaktionen auf diese Fehler und unsere Frustration darüber, dass wir uns nicht schnell bewegen konnten, waren ebenfalls Symptome. Als wir begannen, die Ursachen dieser Symptome zu diskutieren, fanden wir einige häufige
- Versäumnis, einfache Programmierfehler abzufangen, wie z. B. der Zugriff auf Eigenschaften von
nulloderundefined. - Unterdefinierte Schnittstellen. Welche Parameter können
undefinedodernullsein und welche Funktionen könnenundefinedodernullzurückgeben? Oft arbeitete der Implementierer der Funktion unter anderen Annahmen als die Aufrufer. - Typ-Eigenheiten.
undefinedvsnull.undefinedvsfalse.undefinedvs leerer String. - Das Gefühl, dass wir dem Code nicht vertrauen oder ihn sicher refaktorieren konnten.
Die Identifizierung der Ursachen war ein guter erster Schritt, aber wir wollten noch tiefer gehen. Was waren die Gefahren in all diesen Fällen, die es einem gutmeinenden Ingenieur ermöglichten, den Fehler überhaupt erst einzuführen? Und wir identifizierten schnell eine offensichtliche Gefahr, die allen diesen Problemen gemeinsam war: das Fehlen einer strengen Null-Prüfung in der VS Code-Codebasis.
Um die strenge Null-Prüfung zu verstehen, müssen Sie sich daran erinnern, dass TypeScript darauf abzielt, JavaScript Typen hinzuzufügen. Eine Folge von TypeScript's JavaScript-Erbe ist, dass TypeScript standardmäßig erlaubt, dass undefined und null für jeden Wert verwendet werden
// Without strict null checking, all of these calls are valid
getStatus(undefined); // Ok
getStatus(null); // Ok
getStatus({ id: undefined }); // Ok
Während diese Flexibilität die Migration von JavaScript zu TypeScript erleichtert, zeigte die Beispielbibliothek für unsere hypothetische Website, dass sie auch eine Gefahr darstellt. Diese Gefahr war auch zentral für die vier Grundursachen, die wir bei der Arbeit an VS Code identifiziert hatten (und viele andere).
Glücklicherweise bietet TypeScript jedoch eine Option namens strikte Null-Prüfung, die dazu führt, dass undefined und null als unterschiedliche Typen behandelt werden. Bei Verwendung der strengen Null-Prüfung muss jeder Typ, der nullbar sein kann, entsprechend annotiert werden
// With "strictNullCheck": true, all of these produce compile errors
getStatus(undefined); // Error
getStatus(null); // Error
getStatus({ id: undefined }); // Error
Das Beheben einzelner Codezeilen oder das Hinzufügen von Tests war eine reaktive Lösung, die nur diese spezifischen Fehler behob. Die Aktivierung der strengen Null-Prüfung ist eine proaktive Lösung, die nicht nur die Fehler beheben würde, die wir jeden Monat gemeldet bekamen, sondern auch diese gesamten Fehlerklassen in Zukunft verhindern würde. Kein Vergessen mehr, zu prüfen, ob eine optionale Eigenschaft einen Wert hat. Kein Infragestellen mehr, ob eine Funktion null zurückgeben kann oder nicht. Die Vorteile waren klar.
Entwicklung eines inkrementellen Plans
Das Problem war, dass wir nicht einfach eine Compiler-Flagge aktivieren und alles wäre magisch behoben. Die Kerncodebasis von VS Code umfasst etwa 1800 TypeScript-Dateien mit mehr als einer halben Million Zeilen. Das Kompilieren mit "strictNullChecks": true ergab etwa 4500 Fehler. Igitt!
Darüber hinaus besteht VS Code aus einem kleinen Kernteam, und wir arbeiten gerne schnell. Das Abzweigen des Codes zur Behebung dieser 4500 strikten Null-Fehler hätte einen enormen zusätzlichen Aufwand bedeutet. Und wo fängt man überhaupt an? Geht man die Liste der Fehler von oben nach unten durch? Außerdem würden Änderungen in einem Branch nicht dem Hauptzweig helfen, an dem der Großteil des Teams weiterhin arbeiten würde.
Wir wollten einen Plan, der die Vorteile der strengen Null-Prüfung inkrementell allen Ingenieuren des Teams zugute kommen lässt, beginnend sofort. Auf diese Weise konnten wir die Arbeit in überschaubare Änderungen aufteilen, wobei jede kleine Änderung den Code etwas sicherer machte.
Zu diesem Zweck erstellten wir eine neue TypeScript-Projektdatei namens tsconfig.strictNullChecks.json, die die strenge Null-Prüfung aktivierte und zunächst keine Dateien enthielt. Dann fügten wir selektiv einzelne Dateien zu diesem Projekt hinzu, behebt die strikten Null-Fehler in diesen Dateien und checkten die Änderung ein. Solange wir Dateien hinzufügten, die entweder keine Importe hatten oder nur andere bereits strikt null-geprüfte Dateien importierten, mussten wir nur eine kleine Anzahl von Fehlern pro Iteration beheben.
{
"extends": "./tsconfig.base.json", // Shared configuration with our main `tsconfig.json`
"compilerOptions": {
"noEmit": true, // Don't output any javascript
"strictNullChecks": true
},
"files": [
// Slowly growing list of strict null check files goes here
]
}
Während dieser Plan vernünftig erschien, war ein Problem, dass Ingenieure, die im Hauptzweig arbeiteten, normalerweise nicht die strikt null-geprüfte Teilmenge von VS Code kompilieren würden. Um versehentliche Regressionen bei bereits strikt null-geprüften Dateien zu verhindern, fügten wir einen kontinuierlichen Integrationsschritt hinzu, der tsconfig.strictNullChecks.json kompilierte. Dies stellte sicher, dass Check-ins, die die strenge Null-Prüfung zurücksetzen, den Build brechen würden.
Wir haben auch zwei einfache Skripte zusammengestellt, um einige der repetitiven Aufgaben im Zusammenhang mit dem Hinzufügen von Dateien zum strikt null-geprüften Projekt zu automatisieren. Das erste Skript gab eine Liste von Dateien aus, die für die strikte Null-Prüfung in Frage kamen. Eine Datei gilt als berechtigt, wenn sie nur Dateien importiert, die selbst strikt null-geprüft waren. Das zweite Skript versuchte, berechtigte Dateien automatisch zum strikten Nullprojekt hinzuzufügen. Wenn das Hinzufügen der Datei keine Kompilierungsfehler verursachte, wurde sie in tsconfig.strictNullChecks.json übernommen.
Wir erwogen auch, einige der strikten Null-Korrekturen selbst zu automatisieren, entschieden uns aber letztendlich dagegen. Strikte Null-Fehler sind oft ein gutes Signal dafür, dass der Quellcode refaktoriert werden sollte. Vielleicht gab es keinen guten Grund, warum ein Typ nullbar war. Vielleicht sollten die Aufrufer null anstelle der Implementierer behandeln. Das manuelle Überprüfen und Beheben dieser Fehler gab uns die Möglichkeit, unseren Code zu verbessern, anstatt ihn mit Gewalt strikt null-kompatibel zu machen.
Ausführung des Plans
In den nächsten Monaten erweiterten wir langsam die Anzahl der strikt null-geprüften Dateien. Dies war oft mühsame Arbeit. Die meisten strikten Null-Fehler waren einfach: nur Hinzufügen von Null-Annotationen. Bei anderen war es schwierig, die Absicht des Codes zu verstehen. Wurde ein Wert absichtlich nicht initialisiert gelassen oder liegt tatsächlich ein Programmierfehler vor?
Im Allgemeinen versuchten wir, den Non-Null-Assertion-Operator von TypeScript in unserer Hauptcodebasis so weit wie möglich zu vermeiden. Wir verwendeten ihn in unseren Tests freier, da die fehlende Null-Prüfung im Testcode ohnehin zu einem Fehler führen würde.
Ein entmutigender Aspekt des gesamten Prozesses war, dass die Gesamtzahl der strikten Null-Fehler in der VS Code-Codebasis nie zu sinken schien. Eher schien unsere gesamte Arbeit an der strikten Null-Prüfung tatsächlich dazu zu führen, dass die Gesamtzahl der Fehler anstieg, wenn wir ganz VS Code mit aktivierter strikter Null-Prüfung kompilierten! Das liegt daran, dass strikte Null-Korrekturen oft kaskadierende Auswirkungen haben. Die korrekte Annotation, dass eine Funktion undefined zurückgeben kann, kann zu strikten Null-Fehlern für alle Konsumenten dieser Funktion führen. Anstatt sich um die Gesamtzahl der verbleibenden Fehler zu kümmern, konzentrierten wir uns auf die Anzahl der Dateien, die bereits strikt null-geprüft waren, und arbeiteten daran, sicherzustellen, dass wir diese Gesamtzahl nie beeinträchtigen.
Es ist auch wichtig zu beachten, dass die Aktivierung der strengen Null-Prüfung nicht magisch verhindert, dass strikt null-bezogene Ausnahmen jemals auftreten. Zum Beispiel können any-Typen oder falsche Typumwandlungen die strenge Null-Prüfung leicht umgehen
// strictNullCheck: true
function double(x: number): number {
return x * 2;
}
double(undefined as any); // not an error
ebenso wie der Zugriff auf Elemente außerhalb des gültigen Bereichs in einem Array
// strictNullCheck: true
function double(x: number): number {
return x * 2;
}
const arr = [1, 2, 3];
double(arr[5]); // not an error
Darüber hinaus, es sei denn, Sie aktivieren auch die strikte Initialisierung von Eigenschaften in TypeScript, beschwert sich der Compiler nicht, wenn Sie auf ein noch nicht initialisiertes Element zugreifen
// strictNullCheck: true
class Value {
public x: number;
public setValue(x: number) {
this.x = x;
}
public double(): number {
return this.x * 2; // not an error even though `x` will be `undefined` if `setValue` has not been called yet
}
}
Der Punkt dieser Bemühung war nie, 100 % der strikten Null-Fehler in VS Code zu eliminieren – was extrem schwierig, wenn nicht unmöglich wäre – sondern die überwiegende Mehrheit der üblichen strikt null-bezogenen Fehler zu verhindern. Es war auch eine gute Gelegenheit, unseren Code aufzuräumen und ihn sicherer zu refaktorieren. 95 % des Weges dorthin waren für uns akzeptabel.
Sie können unseren gesamten Plan zur strengen Null-Prüfung und dessen Ausführung auf GitHub finden. Alle Mitglieder des VS Code-Teams sowie viele externe Mitwirkende waren an dieser Anstrengung beteiligt. Als Treiber dieser Arbeit habe ich die meisten strikt null-bezogenen Korrekturen vorgenommen, aber das hat nur etwa ein Viertel meiner Arbeitszeit in Anspruch genommen. Es gab sicherlich einige Schwierigkeiten, einschließlich einiger Ärgernisse, dass viele strikte Null-Regressionen erst nach der Eincheckung durch die kontinuierliche Integration entdeckt wurden. Die Arbeit an der strikten Null-Prüfung führte auch zu einigen neuen Fehlern. Angesichts der Menge des geänderten Codes verliefen die Dinge jedoch bemerkenswert reibungslos.
Die Änderung, die schließlich die strenge Null-Prüfung für die gesamte VS Code-Codebasis aktivierte, war eher unspektakulär: Sie behob einige weitere Codefehler, löschte tsconfig.strictNullChecks.json und setzte "strictNullChecks": true in unserem Haupt-tsconfig. Die mangelnde Dramatik war genau wie geplant. Und damit war VS Code strikt null-geprüft!
Fazit
Eine häufige Frage, die ich höre, wenn ich Leute von diesem Projekt erzähle, ist: Wie viele Fehler hat es also behoben? Ich denke, diese Frage ist nicht wirklich aussagekräftig. Mit VS Code hatten wir nie Probleme, Fehler im Zusammenhang mit dem Mangel an strikter Null-Prüfung zu beheben. Normalerweise ging es darum, eine Bedingung und vielleicht ein oder zwei Tests hinzuzufügen. Aber wir sahen immer wieder denselben Fehlertyp. Das Beheben dieser Fehler verlangsamte uns unnötigerweise, und es bedeutete, dass wir unserem Code nicht vollständig vertrauen konnten. Das Fehlen einer strengen Null-Prüfung in unserer Codebasis war eine Gefahr, und die Fehler waren nur ein Symptom dieser Gefahr. Durch die Aktivierung der strengen Null-Prüfung haben wir erhebliche Arbeit geleistet, um eine ganze Klasse von Fehlern zu verhindern, zusätzlich zu vielen anderen Vorteilen für unsere Codebasis und unseren Arbeitsstil.
Der Zweck dieses Beitrags war nicht, eine Anleitung zur Aktivierung der strengen Null-Prüfung in einer großen Codebasis zu sein. Wenn dieses Problem auf Sie zutrifft, haben Sie hoffentlich gesehen, dass es möglich ist, dies auf vernünftige Weise und ohne Magie zu tun. (Ich möchte hinzufügen, dass Sie sich, wenn Sie ein neues TypeScript-Projekt starten, einen Gefallen tun und mit "strict": true als Standard beginnen.)
Was ich hoffe, ist, dass Sie mitnehmen, dass die Reaktion auf einen Fehler viel zu oft entweder das Hinzufügen von Tests oder die Schuldzuweisung ist. "Natürlich hätte Bob wissen müssen, dass er auf undefined prüft, bevor er auf diese Eigenschaft zugreift." Die Leute meinen es gut, aber sie werden Fehler machen. Tests sind nützlich, haben aber auch Kosten und testen nur das, wofür wir sie schreiben.
Stattdessen, wenn Sie auf einen Fehler oder etwas anderes stoßen, das Sie verlangsamt, anstatt hastig eine Lösung zu finden und sich dem nächsten Problem zuzuwenden, halten Sie einen Moment inne, um wirklich zu untersuchen, was ihn verursacht hat. Was war die Ursache? Welche Gefahren deckt er auf? Vielleicht enthält Ihr Quellcode ein gefährliches Programmiermuster und könnte eine Überarbeitung gebrauchen. Arbeiten Sie dann daran, die Gefahr in einem dem Einfluss angemessenen Umfang zu beseitigen. Sie müssen nicht alles neu schreiben. Leisten Sie die minimale Vorabarbeit, die erforderlich ist, und automatisieren Sie, wenn es sinnvoll ist. Reduzieren Sie Gefahren und machen Sie die Welt heute inkrementell besser.
Diesen Ansatz haben wir bei der strengen Null-Prüfung von VS Code verfolgt und werden ihn auch in Zukunft auf andere Probleme anwenden. Ich hoffe, Sie finden ihn nützlich, unabhängig von der Art des Projekts, an dem Sie arbeiten.
Viel Spaß beim Programmieren,
Matt Bierner, Mitglied des VS Code-Teams @mattbierner