Erhöhe die Flexibilität deiner Bibliotheken mit dem Strategy Pattern
Eines der zentralen Probleme der Softwareentwicklung ist es, eine Trennung zwischen Code, der sich häufig ändert, und Code, der sich selten ändert, zu finden. Design Patterns bieten für diese Probleme Lösungsvorlagen, die wir in unseren eigenen Projekten, auch in der Webentwicklung, anwenden können. In diesem Artikel möchte ich das Strategy Pattern vorstellen, mit dem man flexiblere und erweiterbare Bibliotheken implementieren kann. Aber keine Sorge, wir werden nicht den traditionellen klassenbasierten Ansatz in unsere TypeScript-Codebasis kopieren.
Design Patterns in der Frontend-Entwicklung
Längst vorbei sind die Zeiten, in denen es bei der Web-Entwicklung nur darum ging, die richtigen Regeln zu den CSS-Klassen hinzuzufügen, die vom Backend gerendert wurden. Heutzutage bauen wir komplexe, voll funktionsfähige Anwendungen, die zufällig in einem Browser laufen. Das bedeutet, dass wir als Frontend-Entwickler uns mit Ansätzen vertraut machen müssen, die in der Backend-Entwicklung schon seit langem üblich sind. Einer dieser Ansätze ist die Anwendung von Design Patterns. Aber keine Sorge, wir werden nicht das UML-Diagramm von Java-Klassen in TypeScript nachbilden. Vielmehr werden wir uns die Intention hinter einem der Patterns genauer ansehen und die Implementierung an die Praktiken und Ansätze der Webentwicklung mit TypeScript anpassen.
Erster Beispielfall: Nutzer in einem Tooltip darstellen
Hattest Du schon einmal das Problem, dass Du deinen Code für verschiedene Anwendungsfälle flexibel gestalten wolltest und die erste Lösung darin bestand, Verzweigungen einzubauen?
Lass uns mit einem Beispiel anfangen, um konkreter zu werden. Wir beginnen mit dem folgenden Anwendungsfall: Wir bauen eine Website, auf der sich Benutzer mit anderen Benutzern verbinden und diese als Freunde bezeichnen können. Jetzt haben wir eine Benutzeroberfläche, in der Benutzer mit dem Mauszeiger über die Namen anderer Benutzer fahren können und eine Liste ihrer gemeinsamen Freunde sehen. Um die Daten anzuzeigen, könnten wir etwas wie folgt implementieren:
type User = {
name: string;
mail: string;
isOnline: boolean;
};
function displayUsersTooltip(users: User[]) {
const friends: string[] = [];
for (const user of users) {
/**
* Wie Entscheidung, welche Felder des Nutzers dargestellt
* werden, treffen wir direkt innerhalb der Funktion.
*/
friends.push(user.name);
}
showTooltip(friends);
}
// Später
const users = await loadMutualFriends();
displayUsersTooltip(users);
Perfekt, jetzt haben wir eine einfache Funktion, mit der ein Tooltip angezeigt werden kann, welches eine Liste von Benutzern darstellt, und alle sind zufrieden.
Die nächste Anforderung
Am nächsten Tag ist unsere Projektleiterin froh, dass wir das Tooltip für gemeinsame Freunde so schnell implementiert haben. Sie mag dieses neue Tooltip sehr und will es für einen anderen Fall wiederverwenden. Diesmal haben wir den Auftrag, dieses Tooltip allen Unterhaltungen auf der Plattform hinzuzufügen. Das Tooltip soll alle Benutzer anzeigen, die an der jeweiligen Konversation beteiligt sind. Es soll aber nicht nur deren Namen anzeigen, sondern auch deren E-Mail-Adressen. Das Tooltip für gemeinsame Freunde soll in der UI gleich bleiben, aber natürlich wollen wir so viel Code wie möglich wiederverwenden, um die Deadline einzuhalten.
Gut, das ist nur ein weiterer Fall, den wir zu unserer Tooltip-Funktion hinzufügen müssen, und wir könnten etwas in der folgenden Richtung implementieren:
function displayUsersTooltip(users: User[], includeMail: boolean) {
const friends: string[] = [];
for (const user of users) {
let friend = "";
/**
* Die Entscheidung, welche Felder für jeden Nutzer dargestellt werden
* wird noch immer in der Funktion getroffen, aber über das
* boolean-Flag gesteuert.
*/
if (includeMail) {
friend = `${user.name} (${user.mail})`;
} else {
friend = user.name;
}
friends.push(user.name);
}
showTooltip(friends);
}
Unsere Funktion ist nun etwas größer, aber sie kann nun beide Anwendungsfälle behandeln. Allerdings fühlen wir uns bei dieser Lösung vielleicht ein wenig unwohl, denn wir kennen unseren Kunden. Was ist, wenn er ein Tooltip nur mit E-Mails braucht? Was ist, wenn wir ein Tooltip mit dem Online-Status der Benutzer benötigen? Die Möglichkeiten sind schier endlos, unsere Implementierung jedoch ist es definitiv nicht.
Ihr Ausweg: Das Strategy Pattern
Dieser Anwendungsfall ist eine perfekte Gelegenheit für das Strategy Pattern. Sein Zweck ist es, nicht mehr direkt einen einzelnen Algorithmus zu implementieren, sondern uns stattdessen zu erlauben, zur Laufzeit einen bestimmten Algorithmus auszuwählen, der in unserer generischen Funktion verwendet werden kann. Zusätzlich erlaubt uns das, unseren Code in zwei separate Abschnitte zu unterteilen: Bibliothekscode, der nichts über unseren spezifischen Anwendungsfall weiß, und Anwendungscode, der genau weiß, in welchem Anwendungsfall wir uns gerade befinden.
Ich erspare uns das ursprüngliche Klassendiagramm des Musters, da es nicht für Programmiersprachen mit First-Class-Funktionen modelliert ist. Springen wir stattdessen direkt in die Implementierung des Strategiemusters in TypeScript.
Zunächst müssen wir uns überlegen, was genau der Teil unseres Algorithmus ist, den wir für jeden Anwendungsfall separat definieren wollen (Welcher Code ändert sich häufig?). Für unser Tooltip ist der generische Teil der Bibliothek, ein Tooltip für eine Liste von Strings anzuzeigen. Zusätzlich wollen wir definieren, dass jede der Zeichenketten genau einen Benutzer repräsentiert. Das heißt, der einzige Teil, der flexibel sein muss, ist die Art und Weise, wie wir einen Anzeige-String für jeden Benutzer generieren. Und genau dieser Teilalgorithmus ist die Aufgabe unserer implementierten Strategien. Wir können auch einen Typ für diese Strategie als TypeScript-Typ definieren und mehrere Implementierungen für unsere Anwendungsfälle vordefinieren:
type UserDisplayStrategy = (user: User) => string;
/**
* Die erste Strategie extrahiert den Namen des Nutzers.
*/
const userNameOnlyStrategy: UserDisplayStrategy = (user) => user.name;
/**
* Die zweite Strategie extrahiert Name und E-Mail-Adresse
* des Nutzers.
*/
const userNameAndEmailStrategy: UserDisplayStrategy = (user) =>
`${user.name} (${user.mail})`;
Da wir Funktionen als Argumente an andere Funktionen übergeben können, können wir als Nächstes unsere Tooltip-Funktion so anpassen, dass sie nicht einen booleschen Wert, sondern eine UserDisplayStrategy
erhält.
function displayUsersTooltip(
users: User[],
userDisplayStrategy: UserDisplayStrategy
) {
const friends: string[] = [];
for (const user of users) {
/**
* Wir erzeugen den Anzeige-String aus einem Benutzer mit der angegebenen
* userDisplayStrategy.
*
* Jetzt trifft dieser Code die Entscheidung über die Auswahl
* der Felder, die im Tooltip angezeigt werden sollen, nicht mehr.
*
* Diese Entscheidung wird jetzt an den Anwendungscode nach außen verlagert.
*/
const displayedString = userDisplayStrategy(user);
friends.push(displayedString);
}
showTooltip(friends);
}
/**
* Im Anwendungscode rufen wir die Bibliothek auf und übergeben die richtige
* Strategie für den Anwendungsfall.
*/
// Für gemeinsame Freunde:
const users = await loadMutualFriends();
displayUsersTooltip(users, userNameOnlyStrategy);
// Für Gesprächsteilnehmer:
displayUsersTooltip(users, userNameAndEmailStrategy);
Das Extrahieren der Strategien in separate Variablen ist nur dann wirklich vorteilhaft, wenn die Strategie selbst komplex ist und in mehreren Fällen verwendet wird. Für einfache Fälle ziehe ich es vor, die Strategie inline zu schreiben:
// Für gemeinsame Freunde:
const users = await loadMutualFriends();
displayUsersTooltip(users, (user) => user.name);
// Für Gesprächsteilnehmer:
displayUsersTooltip(users, (user) => `${user.name} (${user.mail})`);
Dies erhöht die Größe dieser Codezeilen nur geringfügig, reduziert aber die Indirektion, da wir nicht zur Definition der Strategien springen müssen, um zu wissen, was passieren wird.
Ein vertrauter Ansatz mit einem ungewohnten Namen
Die vorherigen Code-Beispiele kommen uns vielleicht bekannt vor. Das liegt daran, dass dieses Muster in JavaScript sehr häufig verwendet wird. Alle Array-Funktionen zum Beispiel verwenden diese Strategien (oder Callbacks, wie sie in der Web-Welt üblicherweise genannt werden). Schauen wir uns ein paar Beispiele an:
Sortieren in JavaScript
const users = await loadMutualFriends();
const sortedByName = users.sort((a, b) => a.name.localeCompare(b.name));
const sortedByOnlineState = users.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (b.isOnline && !a.isOnline) return 1;
return 0;
});
Die Hauptkomplexität eines Sortieralgorithmus besteht in der effizienten Auswahl der zu vergleichenden Elemente, um eine vollständig sortierte Liste zu erhalten. Der "einfache" Teil ist, zwei Elemente zu vergleichen. Der eingebaute Bibliotheksalgorithmus kann diese "einfache" Entscheidung jedoch nicht für uns treffen, wir müssen eine Funktion (eine Strategie) bereitstellen, die diese Entscheidung auf der Ebene des Anwendungscodes treffen kann. Der schwierige Teil, nämlich die effiziente Auswahl der zu vergleichenden Elemente, wird von unserer Laufzeitumgebung (z. B. dem Browser) übernommen.
Render Props in React
Zusätzlich zu normalem JavaScript-Code, der dieses Muster oft verwendet, können wir es auch in React-Code antreffen. Werfen wir einen Blick auf dieses Snippet aus der offiziellen React Router Dokumentation:
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route } from "react-router-dom";
ReactDOM.render(
<Router>
<Route path="/home" render={() => <div>Home</div>} />
</Router>,
node
);
Hier übernimmt die Bibliothek den komplexen Teil der Überwachung der URL, um zu prüfen, ob wir uns gerade auf dem Pfad /home
befinden. Auch hier kann die Bibliothek nur einen Teil der Entscheidungen für uns treffen. Sie muss uns die Freiheit geben, auszuwählen, was wir rendern wollen, wenn die angegebene URL passt. Dafür können wir unsere eigene PageRenderingStrategy
bereitstellen, oder wie es in React üblicherweise genannt wird: Einen Render Prop. Wie im Beispiel zuvor haben wir auch hier die Trennung von Code auf Bibliotheksebene (react-router-dom) und Anwendungscode, wobei nur der Anwendungscode die spezifischen Anwendungsfälle kennen kann, um zu entscheiden, was wir rendern wollen.
Die Implementierung der Route
-Komponente könnte in etwa so aussehen wie das folgende Snippet. (Ja, ich weiß, die echte Implementierung verwendet eine Klassen-Komponente und keine Hooks und hat mehr Props, die übergeben werden können, aber das ist für das Beispiel nicht von Bedeutung).
import { useRouteMatch, RouteMatch } from "./some-internal-module";
export function Route(props: {
path: string;
render: (match: RouteMatch) => ReactNode;
}) {
const match = useRouteMatch(props);
/**
* Wenn die Route nicht mit der aktuellen URL übereinstimmt, wird nichts gerendert.
*/
if (!match) return null;
/**
* Wenn die Route mit der aktuellen URL übereinstimmt, wird gerendert, was der Anwendungscode
* will.
*/
return <>{props.render(match)}</>;
}
Wie wir sehen können, erlaubt uns das Strategiemuster (oder Callback-Funktionen oder Render-Props), Bibliothekscode zu schreiben, der flexibel und erweiterbar ist. Wir können problemlos komplexere Strategien übergeben, ohne die Bibliothek zu ändern, und wir könnten immer noch "Standardstrategien" definieren und exportieren, die ohne großen Overhead verwendet werden:
export function byLowerCaseField<T>(
getStringFieldStrategy: (item: T) => string
) {
return function comparator(a: T, b: T) {
const stringA = getStringFieldStrategy(a).toLowerCase();
const stringB = getStringFieldStrategy(b).toLowerCase();
return stringA.localeCompare(stringB);
};
}
/**
* Irgendwo im Anwendungscode:
*/
const users = await loadMutualFriends();
const sortedByName = users.sort(byLowerCaseField((u) => u.name));
Ein paar Worte zur Benamung
Wie wir bereits kurz erwähnt haben, ist die Benennung dieser "Strategien" schwierig. In der freien Wildbahn wirst Du selten eine Funktion mit dem Namen xyzStrategy
sehen. In unseren Projekten versuchen wir normalerweise, Namen zu finden, die den Zweck der Funktion besser beschreiben. Für unser Beispiel von vorhin hätte ich das Argument zum Beispiel userToDisplayString
oder displayStringFromUser
genannt. Das Gleiche gilt für die Array.prototype.sort-Funktion, deren Argument bei MDN compareFunction
und nicht comparisonStrategy
heißt, und für das React-Router-Beispiel, wo der Prop einfach render
heißt.
Was haben wir gelernt?
Ich hoffe, dass diese Beispiele die potenziellen Vorteile der Anwendung des Strategiemusters in Euren eigenen Projekten aufzeigen konnten. Durch die Verwendung, können wir unseren Code in eine Bibliotheks- und eine Anwendungsebene aufteilen, wobei die Bibliothek flexibel bleibt. Es erlaubt uns, Entscheidungen zu definieren, die wir innerhalb der Bibliothek behandeln wollen, und solche, die wir nach außen in die Anwendung verlagern wollen.