Grundlagen für gutes API-Design

In diesem Artikel behandeln wir gutes API-Design. Wie können wir den Entwickler an die Hand nehmen und den Einstieg mit einer Bibliothek vereinfachen? Dieser Artikel liefert einige Aspekte.
Vorab: Dieser Artikel erhebt keinen Anspruch auf Vollständigkeit. Aufgrund meiner aktuellen Erfahrung mit einem NuGet-package möchte ich vor allem anregen, den Konsumenten einer API bzw. Softwarebibliothek zu unterstützen. Es gibt nichts frustrierenderes als mehrere Stunden mit einer Bibliothek beschäftigt zu sein, um irgendwie zu erfahren, dass man es anders machen muss. Einige Aspekte entsprechen meiner persönlichen Meinung. Ich freue mich, mit euch darüber diskutieren zu können 🙂
Dokumentation der ersten Schritte
Mir ist die Einstiegsdokumentation sehr wichtig. Was ist die „Hauptklasse“? Wie authentifiziere ich mich (z.B. WebAPI)? Muss ich bestimmte Konfigurationsparameter setzen oder in der Konfigurationsdatei hinterlegen?
Auch wenn die Dokumentation leider nicht immer aktuell ist, sollte es zumindest einen Schnellstart geben. Dieser soll die elementare Frage klären: Wo fange ich an? Die Größe einer „Hallo Welt“ Anwendung für das erste Beispiel reicht aus. Im Optimalfall ruft der Entwickler die erste Funktion durch Copy&Paste direkt auf.
Wichtig sind vor allem implizite Annahmen. Für ASP.Net gibt es beispielsweise die Web.config. Wenn eine Bibliothek einen bestimmten Eintrag erwartet sollte das auf jeden Fall dokumentiert sein. Im schlechtesten Falle, sollte eine entsprechende Fehlermeldung (siehe 3) den Entwickler darauf hinweisen.
Sprechende Klassen und Funktionen
Wie beim CleanCode[1] sollten die Klassen und Funktionen klare Namen haben. Nehmen wir folgendes Beispiel:
var parser = new Parser();
var result= parser.Parse("My input");
Hier ist unklar, die das Ergebnis aussieht. Wäre es ein MarkdownParser
wüssten wir, dass das
Ergebnis HTML ist (beispielsweise als String). Ein PDFParser
erstellt wahrscheinlich ein
PDF-Dokument, welches wird speichern können.
Daher sollten die Klassen und Funktionen sprechende Namen haben, wie folgendes Beispiel zeigt:
var document = new UniversalDocument();
var section = new Section("Section 1");
section.AddParagraph("Paragraph 1");
section.AddParagraph("Paragraph 2");
document.AddSection(section);
document.Export(DocumentType.PDF, "MyFile.pdf")
Ich sehe ein, dass der Name UniversalDocument nur bedingt aussagekräftig ist. Beim Export geben wir an, wie das Ergebnis aussehen soll.
Wie der Name der Klasse und Funktion auszusehen hat, ist individuell abhängig von Bibliothek. Eine Bibliothek zum Erstellen universeller Dokumente hat andere Funktionen als eine REST-Api. Bei einer REST-Api erwarte ich sinngemäß etwas wie:
- GetFooByID()
- GetAllFoos()
- CreateFoo()
- UpdateFoo()
- DeleteFoo()
Meinetwegen kann es auch Funktionen wie CreateOrUpdateFoo
geben. Je nach Design der API oder
Bibliothek, machen solche kombinierten Funktionen eventuell Sinn (obwohl diese Funktion 2
Tätigkeiten übernimmt und nicht mehr SoC-Komform ist).
Sprechende Fehlermeldungen
Ganz wichtig sind sprechende Fehlermeldungen. In der Fehlermeldung sollte beschrieben sein, warum die Funktion einen Fehler ausgelöst hat. Ist der Anwender nicht autorisiert? Gibt es das Objekt nicht mehr? Fehlen Parameter? Fehlen Berechtigungen?
Es gibt kaum etwas, das schlimmer ist, als allgemeine Exceptions ohne Beschreibung. Wenn ich
beispielsweise folgenden Aufruf habe CustomRestClient.GetAllUsers();
dann möchte ich entweder eine
Liste von Usern erhalten oder eine Fehlermeldung, welche besagt, dass ich die Nutzerliste nicht
abfragen darf. Alternativ eine leere Liste, wenn keine Benutzer vorhanden sind 🙂
Vor längerer Zeit habe ich mit einer Bibliothek gearbeitet, welche das Ergebnis in
einem ResultObject
verpackt hat. Dieses sah in etwa folgendermaßen aus:
public class ObjectResult {
public object Result { get; }
public ResultState ResultState { get; }
public string Message { get; }
}
Über die Eigenschaft ResultState
konnte immer der Status geprüft werden (z.B. Success, Error).
In Message
steht die Fehlerbeschreibung und Result
beinhaltet den Rückgabewert (z.B. die Liste
der User). Object sollte hier durch den jeweiligen Rückgabetyp ersetzt werden, um Typensicherheit zu
ermöglichen.
Implizite Verweise und Laufzeitfehler vermeiden
Schlimmer als schlechte Fehlermeldungen sind unerwartete Laufzeitfehler. Wenn eine Bibliothek implizit andere Komponenten aufruft, kommt es zu Laufzeitfehlern, wenn die Bibliotheken nicht vorhanden sind. Ich denke, dass es sich meistens um Flüchtigkeitsfehler handelt. Allerdings erwarte ich von einer Bibliothek, dass diese alle Referenzen mitliefert oder mir in der Dokumentation (siehe 1) beschrieben wird, welche externen Bibliotheken ich noch einbinden muss. Je nach Lizenzmodell kann eine Bibliothek nicht alle Abhängigkeiten von selbst mitliefern.
Ich denke da gerne an die RavenDB zurück, welche ich um 2011 verwendet habe. Es gab ein Portable-Package. Hierdurch kann die Datenbank direkt mit der Anwendung ausgeliefert werden. Für unseren damaligen Fall war das sehr praktisch. Vor allem die Einrichtung und Verwendung waren sehr einfach im Vergleich zu SQLite oder anderen Portable-Datenbanken.
Der Nachteil war nur: Sobald dieses Paket in der Anwendung referenziert wurde (ohne Änderungen am Code!) startete die Anwendung nicht mehr. Nach entfernen des NuGet-Packages, lief die Anwendung ohne Schwierigkeiten. Wir dachten damals, dass es vielleicht Seiteneffekte gab, da die Anwendung recht groß war. Allerdings konnten wir dieses Verhalten mit einem „leeren“ Konsolenprojekt nachstellen.
Somit ist diese Bibliothek innerhalb von 5 Minuten irrelevant geworden (Anmerkung: Das Problem mit der RavenDB ist nun mehr als 6 Jahre her. Mittlerweile kann sich das Problem gelöst haben).
Externe Referenzen minimieren
Ein aktuelles Problem bei mir ist, dass ein NuGet-Paket zu viele externe Referenzen benötigt. Beispielsweise wird das EntityFramework eingebunden, obwohl das Package es für meinen Anwendungsfall niemals braucht.
Zudem werden (durch Dependency Injection) diverse Interfaces geladen und erwartet. Ich muss in meiner Hauptanwendung bis zu 10 Pakete manuell referenzieren und dem IoC-Container bekannt machen. Aus meiner Sicht ist so etwas immer sehr unglücklich. Ich finde es gut, wenn bestimmte Basiskomponenten wie z.B. Logger wiederverwendet werden. Es sollte allerdings so sein, dass externe Abhängigkeiten minimiert werden sollten. Ich empfinde es als sehr unglücklich, wenn ich 10 Pakete referenzieren (und ausliefern) muss, obwohl es sich nur um eine REST-Api handelt.
Einstieg vereinfachen
Eine Bibliothek/API sollte sehr minimalistisch aufgebaut sein. Die Dokumentation (siehe 1) soll einen schnellen Einstieg ermöglichen. Es bringt mir nichts, wenn ich zum Einrichten 20 Minuten brauche, nur um festzustellen, dass die Bibliothek doch nicht meinen Anforderungen entspricht.
Vor allem im Bereich Dependency Injection oder .NET Core-Webanwendungen ist eine UseFoo()-Funktion sehr praktisch. Diese sollte intern die Klassen auf standardisierte Interfaces mappen (DI) sodass ich (beispielsweise) das .NET ILogger-Standardinterface nutzen kann. Nur der Aufruf UseCustomLogger(..) im Programmstart weist darauf hin, dass ein anderes Modul als der Standard verwendet wird.
Zusammenfassung
Aufgrund des langen Artikels hier nochmal die Überschriften zusammengefasst:
- Dokumentation der ersten Schritte
- Sprechende Klassen und Funktionen
- Sprechende Fehlermeldungen
- Implizite Verweise und Laufzeitfehler vermeiden
- Externe Referenzen minimieren
- Einstieg vereinfachen
Wie sieht eine gute API oder Bibliothek eurer Meinung nach aus?
Bildquelle: Pixabay.com