API-Design – Do’s and Don’ts

Im Zeitalter der Modularisierung von Software kommt kein Java-Entwickler daran vorbei, früher oder später ein Application-Programming-Interface (API) zu definieren oder zu erweitern. Der Entwurf eines guten API erfordert keine Kenntnisse in schwarzer Magie. Werden einige wenige Konventionen befolgt, erhöht das die Qualität der Schnittstelle deutlich.

Was sind die Qualitätsmerkmale eines API? Eine Schnittstelle muss zuallererst alle geforderten Funktionen umsetzen. Eine gute Schnittstelle ist darüber hinaus in sich schlüssig, einfach zu verwenden und zeigt kein unerwartetes Verhalten. Im Folgenden zeigen mehrerer Beispiele, wie ein API durch wenige Handgriffe verbessert werden kann. Sie orientieren sich an Java und ähnlichem Pseudocode, sind aber im Prinzip auch auf andere Programmiersprachen übertragbar. Der erste und zugleich am einfachsten zu ändernde Qualitätsfaktor ist die Namensgebung einer Schnittstelle.

 

Nomen est omen

Der Name einer Methode oder Klasse beschreibt ihre Funktion. Wird er falsch gewählt, ist die Schnittstelle im besten Fall schwer zu verstehen. Im schlimmsten Fall wird sie durch falsche Namensgebung missverständlich. Ein Beispiel: Welches Verhalten kann ein Nutzer der Methode process in der Klasse UserProcessor (Listing 1) erwarten? Offensichtlich wird hier ein Benutzer verarbeitet. Was genau hinter dem Verarbeitungsprozess steckt, bleibt allerdings unklar. Dem Nutzer bleibt nichts anderes übrig, als einen Blick in den Quellcode der Klasse UserProcessor zu werfen. Oder er vertraut auf die Dokumentation. In den allermeisten Fällen wird diese allerdings veraltet, fehlerhaft oder schlicht nicht vorhanden sein. Im Gegensatz dazu drückt die Methode checkValidityAndUpdateStatus in der UserStatusChecker Schnittstelle deutlich aus, was ein Entwickler von ihr erwarten kann.

Listing 1

Führen mehrere Methoden vergleichbare Prozesse aus, nutzen sie auch das gleiche Verb (Listing 2). So kann ein Entwickler von dem Verhalten einer Methode auf das Verhalten der anderen Methoden schließen. Gleichzeitig unterstützt eine solche Namensgebung die Auto-Complete-Funktion moderner Entwicklungsumgebungen. Tippt der Entwickler load in den Editor, bekommt er bereits loadUsers(), loadAccounts() und loadAddresses() vorgeschlagen.

Listing 2

Wenn es um Namen geht, kommt im deutschsprachigen Raum häufig ein weiteres Problem hinzu: Einige Unternehmen schreiben in ihren Entwicklungsrichtlinien vor, dass als Entwicklungssprache Deutsch zu verwenden ist. Die Software-Entwicklung ist allerdings durch den englischsprachigen Raum geprägt. Entsprechend sind Programmiersprachen und Bibliotheken in Englisch gehalten. Auch sind viele Entwurfsmuster vor allem mit ihren englischen Namen bekannt. Ein Singleton-, Visitor- oder Repository-Pattern klingt in seiner deutschen Übersetzung als Einzelstück-, Besucher- und Aufbewahrungsort-Muster falsch. Die Vermischung deutscher Begriffe mit englischen führt dabei bisweilen zu Stilblüten (Listing 3).

 

Listing 3

Der Quellcode ist so schwieriger zu lesen. In einigen Fällen führt die Verwendung der deutschen Sprache in Quellcode sogar zu Fehlern. Beispielsweise legt das Framework Java-Server-Faces (JSF) fest, dass Eigenschaften von Beans über Setter- bzw. Getter-Methoden verfügen. Heißt eine Methode Benutzer.setzeAlter(…), wird die Eigenschaft Alter im zugehörigen XHTML-Dokument als zeAlter referenziert. Der entsprechende Getter muss dann getZeAlter() heißen. Dieses Beispiel zeigt deutlich, wie die Vermischung verschiedener Sprachen im Quellcode die Wartbarkeit verschlechtert.

 

Allerdings gibt es Ausnahmen hierzu: deutsche Begriffe, für die es keine geeignete englische Übersetzung gibt. In einer Kalenderanwendung ist es vollkommen in Ordnung, die Methode zum Laden von Brückentagen loadBrueckentage() zu nennen. Auch wenn die Benennung als loadBridgeDay() bei den Kollegen sicherlich ein Lächeln hervorruft. Eine eindeutige und konsistente Namensgebung erleichtert Nutzern die Verwendung des API. Um sie auch noch resistenter gegen Fehler zu machen, hilft es auf Null-Referenzen zu verzichten.

 

Fehler vorbeugen: Die Verwendung von null

Der am häufigsten auftretende Fehler in Java-Anwendungen ist die NullPointerException[1]. Beim Einsatz von null in Schnittstellen ist daher große Vorsicht geboten. Entwickler verwenden null in Übergabeparametern gerne, um optionale Parameter zu ermöglichen (Listing 4). Methoden mit vielen Parametern sind beim Aufruf im Client schwer zu verstehen. Die absichtliche Verwendung von Null-Referenzen verschlechtert die Lesbarkeit weiter. Gleichzeitig führt dieses Entwicklungsmuster auf Seite des Schnittstellen-Bereitstellers zu Methoden mit vielen bedingten Anweisungen (Listing 5).

 

Besser ist der Einsatz von dedizierten Methoden ohne optionale Parameter. Ist die Anzahl der Kombinationsmöglichkeiten zu groß, können die Parameter auch in ein Hilfsobjekt verpackt werden. Das Builder-Pattern[2] adressiert optionale Parameter bei der Erzeugung von Objekten. Es eignet sich in diesem Fall hervorragend für die Erzeugung von Hilfsobjekten.

Listing 4

Listing 5

Wesentlich problematischer ist der Einsatz von null jedoch als Ergebnis einer Methode. Entwickler vergessen häufig auf null zu prüfen. Die NullPointerException ist damit (im wahrsten Sinne des Wortes) vorprogrammiert. Glücklicherweise bietet Java für optionale Rückgabewerte seit Version 8 die Klasse Optional an. Sie signalisiert dem Aufrufer, dass ein leeres Ergebnis Teil der möglichen Ergebnismenge ist. Die Schnittstelle nimmt den Entwickler dadurch an die Hand.

 

Schlechter Stil ist es hingegen, Optional in Übergabeparametern zu verwenden. Ein solcher Parameter kann gleich drei Zustände annehmen: null, ein leeres Optional oder ein Optional mit Wert. Das verdoppelt die Aufwände zur Überprüfung des Parameters in der Methode: Statt parameter != null muss hier (parameter != null) && (parameter.isPresent()) geprüft werden.

 

Erfahrene API-Designer verwenden null in APIs nur dann, wenn es absolut notwendig ist. Viele moderne Programmiersprachen adressieren Null-Referenzen über Sprachkonstrukte: Beispielsweise geht Kotlin davon aus, dass Variablen immer gesetzt werden. Soll eine Variable nullable sein, muss dies explizit über die Angabe eines Fragezeichens bei der Variablendefinition angekündigt werden. Das ermöglicht schon zur Kompilierzeit den Code auf mögliche NullPointerExceptions zu prüfen.

 

Durch den sparsamen Einsatz von null schützt ein API seine Nutzer vor Laufzeitfehlern. Es wird dadurch schwerer, die Schnittstelle falsch zu benutzen. Darauf zahlt auch der nächste Tipp ein: Die Wiederentdeckung des Geheimnisprinzips.

 

Geheimes geheim halten

Entwicklungsumgebungen nehmen Entwicklern heutzutage viel Arbeit ab. Eine Klasse erzeugen, ein paar Parameter hinzufügen und anschließend Getter und Setter automatisch hinzufügen lassen: Mit wenigen Klicks ist die Klasse fertiggestellt. Dabei machen Entwickler schnell mehr Informationen zugänglich, als sie eigentlich wünschen (Listing 6).

 

Das Geheimnisprinzip besagt, dass der Zustand eines Objekts vor dem Zugriff von außen geschützt ist. Im Studium oder während der Ausbildung lernen viele Informatiker, dass Datenkapselung über Getter und Setter und private Objektvariablen erreicht wird. Das ist grundsätzlich richtig, allerdings nur der erste Schritt in die Welt des Schnittstellenentwurfs. Robert „Uncle Bob“ C. Martin unterscheidet in seinem Buch „Clean Code“ beim Thema Geheimnisprinzip zwischen Datenstrukturen und Objekten[3]. Datenstrukturen dienen zum Transport von Daten. Der freie Zugriff auf ihre Objektvariablen ist gewünscht. Entsprechend können Getter und Setter entfallen. Objekte hingegen müssen ihren Zustand schützen. Sie implementieren einen Teil der Geschäftslogik.

In dem verwendeten Beispiel hängt vermutlich ein Prozess am Ändern des Passworts (Listing 7). Statt das Passwort über Getter und Setter zugänglich zu machen, definiert die Klasse explizite Methoden zum Ändern und Kontrollieren des Passworts. Objekte dieser Klassen schützen dadurch ihren Zustand.

 

Listing 6

Listing 7

 

Das Geheimnisprinzip betrifft allerdings nicht nur den Zustand von Objekten, sondern auch seine Methoden (Listing 8). In diesem Beispiel kann ein Aufrufer beliebig häufig increaseFailedValidations() aufrufen und damit den Zustand des Accounts manipulieren. Richtigerweise muss die Methode an dieser Stelle mit dem Schlüsselwort private versehen werden.

 

In der Praxis machen Entwickler Private-Methoden häufig von außen zugreifbar, um sie in Modultests besser testen zu können. Komplexe Private-Methoden deuten dabei sehr häufig auf die Verletzung des Single-Responsibility-Prinzips hin. Statt das Geheimnisprinzip für Tests zu verletzen, ist es besser, die Architektur des Codes zu überdenken. Kann die zu testende private Methode zum Beispiel in einen eigenen Dienst ausgelagert werden? Wenn ja, macht es Sinn die Zeit in das Refactoring zu investieren.

Listing 8

Während bei Datenstrukturen das Geheimnisprinzip verletzt werden kann, müssen API-Designer die Schnittstelle einer Klasse immer bewusst gestalten. Die bereitgestellten Methoden unterstützen die Geschäftslogik der Anwendung und verhindern eine beliebige Manipulation des Zustands eines Objekts.

 

Die bisherigen Tipps dienten vor allem dazu, Fehler zu vermeiden. Wie aber sind Fehler zu behandeln, wenn sie doch einmal auftreten?

 

Streitpunkt Exceptions

Über kaum ein Thema im API-Design lässt sich so vortrefflich streiten, wie über den korrekten Einsatz von Exceptions. Java unterscheidet zwischen den Checked- und Unchecked-Exceptions. In den ersten Versionen der Sprache kamen Checked-Exceptions immer dann zum Einsatz, wenn ein Fehler zu erwarten war, beispielsweise beim Lesen einer Datei oder dem Setzen eines Zeichensatzes. Unchecked-Exceptions hingegen deuteten immer auf Programmierfehler hin: Die IllegalArgumentException beim Verletzen der Schnittstellendefinition einer Methode oder die NullPointerException bei der fehlenden Prüfung auf Null-Referenzen.

 

Mit den Jahren hat sich das Verständnis von Exceptions allerdings gewandelt. Die Erfahrung aus unzähligen Projekten zeigt, dass Checked-Exceptions häufig einfach an den Aufrufer weitergegeben werden. In seltenen Fällen kann eine Anwendung auf eine Checked-Exception angemessen reagieren und den Programmablauf wie vorgesehen fortsetzen. Gleichzeitig führen Checked-Exceptions zu sehr viel Boilerplate-Code (Listing 9). Der Nutzer einer Schnittstelle muss alle definierten Checked-Exceptions behandeln, selbst wenn er sie nur weiterreicht. Hinzu kommt, dass es sich bei den Exceptions im Beispiel um technische Ausnahmen handelt. Sie geben Informationen zur Implementierung der Schnittstelle preis: Offenbar befindet sich hinter diesem API ein Datenbankzugriff mit einem XML-Parser. Ändert sich die Implementierung, zum Beispiel durch die Anbindung einer JSON-HTTP-Schnittstelle, muss zwangsweise auch das API angepasst werden. Nur sehr selten ist diese Änderung abwärtskompatibel.

 

Der erfahrene API-Designer kapselt die technischen Ausnahmen in einer domänenspezifischen fachlichen Exception. Das entkoppelt die Schnittstelle von ihrer Implementierung. Zugleich muss der Nutzer statt drei Ausnahmen nur eine einzige behandeln.

Listing 9

Angesichts der Nachteile von Checked-Exceptions wird in den letzten Jahren zunehmend auf ihren Einsatz verzichtet. Stattdessen kommen Unchecked-Exceptions verstärkt zum Einsatz. Die Vorteile liegen auf der Hand: Der Nutzer einer Bibliothek kann in den seltensten Fällen angemessen auf eine Ausnahme reagieren. Bei dem Einsatz von Unchecked-Exception wird er nicht, wie bei den Checked-Exceptions, zu einer Behandlung gezwungen. Stattdessen ist es die Aufgabe des Anwendungs-Frameworks diese Fehler zentral zu behandeln und entsprechende Maßnahmen zu ergreifen. Dieses Muster spiegelt sich in jüngeren Java-APIs wieder. Beispielsweise können Lambda-Ausdrücke und Streams nur mit Unchecked-Exceptions umgehen. Andere Programmiersprachen, auch hier sei als Beispiel Kotlin genannt, unterstützen nur noch Unchecked-Exceptions.

 

Beim Einsatz von Unchecked-Exceptions in APIs ist allerdings zu beachten, dass nicht jede Anwendung ein Framework einsetzt. Außerdem sind immer noch einige Fälle denkbar, in denen eine Anwendung tatsächlich auf einen Fehler angemessen reagieren kann. Hierfür ist es unerlässlich, dass die Existenz der Unchecked-Exception dokumentiert wird. Die Wahl ob und wie auf eine solche Ausnahme reagiert werden soll, liegt dann bei den Entwicklern.

 

Vererbung designen

Software-Architekten raten für gewöhnlich von der Verwendung von Vererbung ab. Durch Vererbung wird Code komplexer, da eine Klasse zusätzlich zu den Abhängigkeiten zu anderen Klassen auch noch Abhängigkeiten zu Eltern- oder Kindklassen aufbaut. Das gilt vor allem auch für APIs (Listing 10). Im Beispiel wird in einer Schnittstelle (ApiClass) eine Methode doSomething() bereitgestellt. Ein Nutzer der Schnittstelle möchte die Funktionalität der ursprünglichen Klasse erweitern. Er legt dazu die Klasse ClientClass mit der Methode doSomethingElse() an und erbt von dem API. Dies geht so lange gut, bis der API-Hersteller selbst auf die Idee kommt doSomethingElse() hinzuzufügen (Listing 11). Da die doSomethingElse() Methode in ClientClass überschrieben wird, verändert sich dadurch nun für sie auch gleichzeitig das Verhalten der doSomething() Methode: Beide führen ab sofort doA() aus. Das Hinzufügen einer Methode zu einer Schnittstelle ist für ihre Nutzer fast immer eine abwärtskompatible Änderung. Anwendungsentwickler übersehen beim Aktualisieren der Bibliotheken daher eine solche Änderung schnell.

Listing 10

Listing 11

Listing 12

Auch wenn dieses Beispiel zugegebenermaßen sehr konstruiert ist, verdeutlicht es dennoch das Problem der sogenannten Open-Inheritance. Bei diesem Prinzip geht es darum, dass jede Klasse zunächst offen für Vererbung ist. Soll Vererbung verboten werden, müssen Entwickler die Klasse oder Methoden mit dem Schlüsselwort final kennzeichnen (Listing 12).

 

Das Gegenteil von Open-Inheritance ist Designed-Inheritance. Hier wird Vererbung nur dort erlaubt, wo sie Teil der Schnittstelle ist. Entwickler müssen hier jene Codestellen kennzeichnen, die für die Vererbung freigegeben werden. Ein Beispiel sind Standardimplementierungen von umfangreichen Interfaces, von denen für gewöhnlich nur einzelne Methoden überschrieben werden. Statt das komplette Interface zu implementieren, können Entwickler ihre Klassen von der Standardimplementierung erben lassen. Ein Schnittstellen-Designer hat somit die Möglichkeit ganz genau zu bestimmen an welchen Stellen Vererbung zum Einsatz kommen kann. Entsprechend kann er bei Aktualisierungen der Schnittstelle darauf achten, dass es nicht zu Problemen kommt. Bei Designed-Inheritance handelt es sich ebenfalls um eine Best-Practice, die von modernen Programmiersprachen bereits umgesetzt ist.

 

Fazit

Ein API sollte nicht nur vollständig im Sinne der Anforderungen sein, sondern auch möglichst einfach zu bedienen. Das wird vor allem durch eine konsistente Benamung und der Vermeidung von Null-Referenzen erreicht. Durch den konsequenten Einsatz des Geheimnisprinzips und dem gezielten Entwurf der Vererbung kann ein Schnittstellen-Designer Fehlern bei der Verwendung des API zuvorkommen. Der richtige Einsatz von Exceptions gibt den Nutzern der API schließlich die Möglichkeit auf Fehler zu reagieren, ohne ihnen eine Behandlung aufzuzwingen. Die vorliegende Betrachtung des Schnittstellen-Entwurfs ist keinesfalls komplett. Weitere Themen sind beispielsweise die Versionierung von APIs und der Einsatz von optionalen Parametern beim Erzeugen von Objekten.

 

Links/Quellen/Verweise:

 

  1. Häufigkeit von NullPointerExceptions: https://bit.ly/2FB8IQh
  2. Eric Freeman & Elisabeth Freeman: Head First Design Patterns
  3. Robert C. Martin: Clean Code
  4. Buchtipp: API-Design von Kai Spichale

 

Jochen Kraushaar arbeitet für die BridgingIT GmbH als Software Entwickler und Consultant. Seine Schwerpunkte liegen auf Software Architektur, Qualitätssicherung und Build-Prozesse. In seiner Freizeit beschäftigt er sich mit aktuellen Trends im Java-Umfeld und Künstlicher Intelligenz.

Victoria Krautter


Leave a Reply