Mit dem stetigen Wachstum der Zahl der mit dem Internet verbundenen Geräte steigen die Anforderungen an Web-APIs mehr denn je. Das geht so weit, dass gängige Web-Frameworks häufig als Plattformen für die Entwicklung von Rest-APIs verwendet werden. Da jedoch keines von ihnen primär für die Implementierung eines Rest-API-Projekts entwickelt wurde, kann die Projektkomplexität deutlich höher werden, was zu längeren Entwicklungszeiten und höheren Wartungskosten führt.
Ziele
In diesem Dokument werden wir versuchen eine Frage zu beantworten, mit der sich jeder Programmierer früher oder später auseinandersetzt – wie strukturiert man ein Projekt richtig, damit es:
- einfach zu handhaben ist, auch für neue Entwickler
- Bei großen Projekten kommen im Laufe der Zeit häufig Entwickler hinzu und andere verlassen das Projekt; eine leichte Einarbeitung reduziert die dafür benötigte Zeit, unterstützt den stetige Fortschritt des Projekts und senkt die Wartungskosten während der Projektlaufzeit.
- in Zukunft leicht zu warten und zu erweitern ist
- Bei einem API-Projekt sollten zukünftige Upgrades im Auge behalten werden. Der einfachste Ausgangspunkt ist die Versionierung der Endpunkte Ihrer API, was die Implementierung neuer Standards bei deren Einführung erheblich erleichtert. Die Struktur des Projekts muss darauf ausgerichtet sein und nur minimale Änderungen erfordern, wenn ein neuer Standard eingeführt wird. Dadurch werden die Upgrade-Kosten erheblich reduziert, da keine Zeit für die Umstrukturierung des Projekts und das Refactoring von Altcode aufgewendet werden muss.
- nicht durch seine Größe beeinträchtigt wird
- Einige Strukturen funktionieren sehr gut bei kleinen Projekten. Aber eine Sache, die man mit der Zeit lernt, ist, dass ein kleines Projekt selten ein kleines Projekt bleibt. Größere Projekte leiden oft unter einem „Kleinprojekt-Ansatz“ in der Anfangsphase. Dadurch erhöhen sich ihre Kosten, was bei einer besseren Planung vermieden worden wäre. Diese Struktur vermeidet diese Problematik, da sie sowohl für kleine als auch für große Projekte gut funktioniert und Sie sie hoffentlich in beiden Szenarien testen können.
Diese Struktur ermöglicht die Entwicklung von Applikationen mit mehreren Modulen. Genau das haben wir in dem beigefügten Beispiel getan. Es funktioniert jedoch ebenso gut mit einem einzelnen Modul.
Was wir hier nicht bearbeiten
In diesem Artikel soll nicht auf die Einzelheiten der Code-Implementierung eingegangen werden; es wird jedoch ein Beispielprojekt vorgestellt, auf welchem Sie Ihre Projekte leicht aufbauen können. Wir gehen nicht im Detail darauf ein, wie Sie Ihre API-Ausgaben richtig handhaben – Dies ist ein Thema für einen weiteren Beitrag. In diesem Beispiel werden stattdessen einfache „JSON Responses“ verwendet. Für eine profesionelle Anwendung ist die Standardisierung der API-Ausgabe ein Muss. Auch sicherheitstechnische Faktoren sind nicht Gegenstand dieses Textes. Sie werden jedoch wahrscheinlich die Sicherheitsvorteile des vorgestellten Ansatzes erkennen, auch wenn diese nicht explizit erwähnt werden.
Das Framework
Wir verwenden Symfony, da es ein weit verbreitetes Framework für die webbasierte API-Entwicklung ist. Ein Framework sollte niemals die Anwendungsstruktur vorgeben und genau das versucht Symfony 4 nicht zu tun. Bei Symfony 3 war das anders und der erste Fehler, den fast jedes Team machte, das an einem Symfony-basierten Projekt arbeitete, war die Verwendung seiner „gebündelten Struktur“ (bundled structure). Diese Struktur sollte nur dann verwendet werden, wenn der Code exakt so wie er ist in anderen Anwendungen wiederverwendet werden soll, was in den meisten Situationen nicht der Fall ist.
Die API-Projektstruktur
Um die Struktur vorzustellen, haben wir ein Beispielprojekt erstellt. Dieses Projekt hat zwei Anwendungen, die sich einige Ressourcen teilen – die „Consumer“-Anwendung, die den API-Zugriff von der Verbraucherseite aus ermöglichen soll, und die „Admin“-Anwendung, die zur Verwaltung der API für den Administratorzugriff erstellt wurde. Die „Admin“-Anwendung hat Zugriff auf alle Ressourcen der „Consumer“-Anwendung, während die „Consumer“-Anwendung nur auf ihre eigenen Ressourcen zugreifen kann. Die Anwendungen müssten von verschiedenen Domains aus erreichbar sein (z.B. www.your-api-site.tld und admin.your-api-site.tld). Wir beginnen mit dem Root-Ordner des Projekts, der auch die Datei composer.json und den vendor Ordner enthält.
Root Struktur
``` ├─ bin/ ├─ config/ ├─ docs/ ├─ migrations/ ├─ public/ ├─ src/ ├─ tests/ ├─ translations/ ├─ var/ └─ vendor/ ... ```
Die Root-Ordner sind alle in Kleinbuchstaben geschrieben. Insofern Sie Sie Erfahrung in der Webentwicklung haben sind Sie wahrscheinlich mit der Verwendung der meisten vertraut. Wir gehen sicherheitshalber nochmal alle durch, damit wir auf demselben Stand sind.
Die Ordner `bin` und `public` enthalten die Front-Controller für den Cli- bzw. Web-Zugriff. Der Ordner `config` enthält die globalen Einstellungen, die von allen Anwendungen im Projekt geerbt werden. `docs` ist für die Swagger-API-Dokumentation gedacht. Das kann wegfallen, wenn Sie nicht vorhaben Swagger zu verwenden, oder wenn Sie beabsichtigen Swagger Annotations zu verwenden (nicht empfehlenswert, da es die Lesbarkeit des Codes verringert. YAML ist besser lesbar und lässt die Controller überschaubar bleiben). `migrations` wird alle Migrationen für alle Anwendungen enthalten. `src` enthält den Quellcode unserer Anwendungen und ist die Namespace-Root für unser Projekt. Im mitgelieferten Beispiel ist das die „SampleApp“. `tests` ist für Unit-Tests vorgesehen, falls Sie diese ebenfalls implementieren. `translations` sollte Übersetzungsbibliotheken enthalten. Der Ordner `var` wird für alle von der Anwendung generierten Dateien verwendet. `vendor` wird vom Composer generiert und enthält alle Abhängigkeiten.
Bin Ordner
Bei einer Standard-Symfony-Installation würde der Ordner `bin` nur den `console` Front-Controller für die CLI enthalten. Wenn Ihr Projekt nur eine Anwendung (ein Modul) enthielte, würde das so bleiben. Aber in einer Multi-Anwendungs-Umgebung, wie dem mitgelieferten Beispiel, hat `bin` mehrere CLI-Front-Controller:
``` ... ├─ bin/ │ ├─ admin │ ├─ console │ └─ consumer ... ```
In diesem Setup werden Befehle, die in der Datei `admin` aufgeführt sind, nur in der Admin-Anwendung ausgeführt. Dasselbe gilt für die Consumer-Anwendung. Die `console` führt jedoch in beiden Anwendungen den gleichen Befehl aus. Dies ist für automatisierte Bereitstellungen oder Skripte erforderlich, die nach einem Composer-Update oder einer Composer-Installation ausgeführt werden.
Config Ordner
Der `config` Ordner enthält den Standardinhalt für eine Symfony-App. Das einzige was Sie sich merken sollten ist, dass das in diesem Setup als „globale“ Konfiguration zu betrachten ist. Jedes Modul/jede Applikation kann einen eigenen Config-Ordner haben, der die globale Konfiguration erweitert oder überschreibt. Eine globale Konfiguration kann z.B. einen globalen Entity-Manager haben, der so konfiguriert ist, dass er mit der Hauptdatenbank des Projekts arbeitet. Ein Modul kann seinen eigenen Entity-Manager definieren, der nur mit dem für dieses Modul spezifischen Schema arbeitet (kein anderes Modul hat Zugriff darauf).
Docs Ordner
Docs ist dazu gedacht, übergeordnete Definitionen zu enthalten. Z.B.:
``` ... ├─ docs/ │ ├─ admin.yaml │ ├─ common.yaml │ └─ consumer.yaml ... ```
Erstellen Sie Ihre Swagger-Dokumente niemals mit Kommentaren. Sie sind schwer zu pflegen, werden nicht automatisch eingerückt und verringern die Lesbarkeit des Codes…
Migrations
Migrations sollten wie folgt sortiert werden:
``` ... ├─ migrations/ │ ├─ Admin/ │ │ ├─ Version2019{...}.php │ │ ├─ Version2019{...}.php │ │ └─ ... │ └─ Consumer/ │ ├─ Version2019{...}.php │ ├─ Version2019{...}.php │ └─ ... ... ```
Public Ordner
In den meisten Fällen enthält der Public-Ordner nur den Front-Controller für das gesamte Projekt – `index.php`. Bei Lösungen mit Multi-App-Anwendungen kann entweder der Front-Controller angepasst werden, um die Domäne zu erkennen und zu entscheiden, welcher Kernel der Anwendung gestartet werden soll, oder es kann die folgende Lösung verwendet werden:
``` ... ├─ public/ │ ├─ admin/ │ │ └─ index.php │ └─ consumer/ │ └─ index.php ... ```
Auf diese Weise entscheiden Apache oder Nginx in Abhängigkeit von der Domäne, aus der die Anfrage kommt, welchen Front-Controller sie aufrufen.
Src Ordner
Src ist der Hauptordner, um den es in diesem Beitrag geht – bei einigen Anwendungen ist dies auch der Ordner `app`. Er handelst sich um das Root-Verzeichnis des Namespaces unseres Projektes und enthält die Hauptordner der Applikationen sowie den Ordner der gemeinsamen Ressourcen `Core`:
``` ... ├─ src/ │ ├─ Admin/ │ ├─ Consumer/ │ └─ Core/ ... ```
Der „Core“ und die gemeinsamen Kompenenten
Der `Core` kann alle gemeinsamen Komponenten wie Subscribers, Exceptions, Sicherheitsimplementierungen auf Projektebene enthalten. Er sollte jedoch keine Controller/Aktionen, Entitäten oder andere Ressourcen enthalten, die spezifisch für eine Anwendung im Projekt sind. Im vorliegenden Beispiel werden ein ExceptionSubscriber und ein RequestSubscriber bereitgestellt. Diese betreffen die Bearbeitung von requests und responses für alle Anwendungen innerhalb des Projekts. RequestSubscriber überprüft die Gültigkeit eingehender Anfragen. Im gegebenen Beispiel ist das recht einfach; wir schlagen jedoch eine saubere Implementierung des Content Negotiators vor. ExceptionSubscriber fängt Ausnahmefälle ab und gibt darauf eine API-freundliche Antwort. Ein weiterer Subscriber, der unter den APIs häufig vorkommt, ist ein ResponseSubscriber. Dieser soll die Antworten korrekt verarbeiten, anstatt die JsonResponse zu verwenden, wie wir es im angegebenen Beispiel tun. Ein richtiger Ansatz wäre die Zurückweisung von Ressourcen oder Objekten von Controllern und deren Serialisierung gemäß dem angeforderten Inhaltstyp im ResponseSubscriber. Eine Serialisierung in jedem Controller würde zu einer Menge sich wiederholenden Code-Blöcken führen, die nicht mit dem von uns verwendeten Entwurfsmuster übereinstimmen (mehr dazu gleich).
Die application Ordner
Die Haupt-Anwendungsordner sind dort, wo die meiste Entwicklungsarbeit geleistet wird. Sie werden mit dem Umfang der Applikation wachsen und irgendwann könnte eine neue Version für die API eingeführt werden. Um dem Wachstum der Anwendung gerecht zu werden, teilen sie die Funktionen ihrer Anwendung in Gruppen auf. Zum Beispiel werden benutzerbezogene Funktionen wie Authentifizierung, Kontoverwaltung und Ähnliches in den Ordner „Account“ oder „User“ verschoben. Funktionen, die sich auf die Handhabung von Zahlungen oder die Ausstellung von Rechnungen beziehen, gehen in „Finance“. Wenn Sie diese Logik anwenden, sieht die Struktur am Ende etwa so aus:
``` ... ├─ src/ │ ├─ Admin/ │ │ ├─ ... │ │ ... │ │ └─ Kernel.php │ ├─ Consumer/ │ │ ├─ Account/ │ │ │ ├─ Action/ │ │ │ │ ├─ Login.php │ │ │ │ ├─ Register.php │ │ │ │ └─ ResetPassword.php │ │ │ ├─ Command/ │ │ │ │ ├─ LoginCommand.php │ │ │ │ ├─ RegisterCommand.php │ │ │ │ └─ ResetPasswordCommand.php │ │ │ ├─ Entity/ │ │ │ │ └─ User.php │ │ │ ├─ Exception/ │ │ │ │ └─ InvalidCredentialsException.php │ │ │ ├─ Handler/ │ │ │ │ ├─ LoginHandler.php │ │ │ │ ├─ RegisterHandler.php │ │ │ │ └─ ResetPasswordHandler.php │ │ │ └─ Repository/ │ │ ├─ Catalog/ │ │ │ ├─ Action/ │ │ │ ├─ Command/ │ │ │ ├─ Entity/ │ │ │ └─ Handler/ │ │ ├─ config/ │ │ │ ├─ packages/ │ │ │ ├─ routes/ │ │ │ └─ services.yaml │ │ ├─ Finance/ │ │ │ ├─ Action/ │ │ │ ├─ Command/ │ │ │ ├─ Entity/ │ │ │ ├─ Event/ │ │ │ └─ Handler/ │ │ └─ Kernel.php │ └─ Core/ │ ├─ Exception/ │ │ ├─ ResourceExpiredException.php │ │ ├─ UnsupportedContentTypeException.php │ │ └─ ValidationException.php │ ├─ Subscriber/ │ │ ├─ ExceptionSubscriber.php │ │ ├─ RequestSubscriber.php │ │ └─ ResponseSubscriber.php │ └─ Traits/ │ └─ Timestampable.php ... ```
Bei kleinen Projekten kann auf eine solche Gruppierung verzichtet werden. Sie sollte jedoch eingeführt werden, wenn das Projekt wächst, da die Größe des Projekts es sonst zu ersticken droht.
Hier ist ein Beispiel für ein kleineres Projekt:
``` ... ├─ src/ │ ├─ API/ │ │ ├─ Action/ │ │ │ ├─ Auth/ │ │ │ │ ├─ Login.php │ │ │ │ ├─ Register.php │ │ │ │ └─ ResetPassword.php │ │ │ ├─ ViewSomething.php │ │ │ └─ CreateSomething.php │ │ ├─ Command/ │ │ │ ├─ Auth/ │ │ │ │ ├─ LoginCommand.php │ │ │ │ ├─ RegisterCommand.php │ │ │ │ └─ ResetPasswordCommand.php │ │ │ ├─ CreateSomethingCommand.php │ │ │ └─ ViewSomethingCommand.php │ │ ├─ config/ │ │ │ ├─ packages/ │ │ │ ├─ routes/ │ │ │ └─ services.yaml │ │ ├─ Entity/ │ │ │ ├─ Something.php │ │ │ └─ User.php │ │ ├─ Event / │ │ ├─ Exception/ │ │ │ └─ InvalidCredentialsException.php │ │ ├─ Handler/ │ │ │ ├─ Auth/ │ │ │ │ ├─ LoginHandler.php │ │ │ │ ├─ RegisterHandler.php │ │ │ │ └─ ResetPasswordHandler.php │ │ │ └─ SomethingHandler.php │ │ ├─ Repository/ │ │ └─ Kernel.php │ └─ Core/ │ ├─ Exception/ │ │ ├─ ResourceExpiredException.php │ │ ├─ UnsupportedContentTypeException.php │ │ └─ ValidationException.php │ ├─ Subscriber/ │ │ ├─ ExceptionSubscriber.php │ │ ├─ RequestSubscriber.php │ │ └─ ResponseSubscriber.php │ └─ Traits/ │ └─ Timestampable.php ... ```
Beispiel für kleineres Projekt Set-up
Die obigen Strukturen führen Änderungen am MVC-Pattern ein. Elemente eines ADR-Patterns (Action-Domain-Responder), sowie des Command-Patterns sind ebenfalls vorhanden.
Das erste, was Ihnen auffallen wird, ist, dass die Controller durch Actions ersetzt werden. Statt einer Controller-Datei, die mehrere Methoden (Actions) enthält, welche jeweils mehrere Endpunkte definieren, verwendet diese Struktur Actions. Dabei definiert jede Datei einen einzigen Endpunkt. Diese Action-Dateien sind eigentlich die Invokable-Controller von Symfony, die aus einem Constructor (der für die automatische Verknüpfung verwendet wird) und einer Aufruf-Methode bestehen, welche beim Zugriff auf diesen Endpunkt aufgerufen wird. Dies führt zu viel kleineren Dateien, die viel einfacher zu lesen und zu steuern sind. Sie müssen nicht mehr den Controller öffnen und von Methode zu Methode nach unten scrollen, um den Endpunkt zu suchen, an dem Sie arbeiten möchten.
Sobald der request auf eine Action trifft, versucht dieser einen für die Bearbeitung der Anfrage erforderlichen Command zu instanzieren. Die Commands sollten die Factory-method enthalten, die die Eingabedaten entgegennimmt und eine Instanz des Commands zurückgibt. Alle Assertions und Validierungen müssen in der Factory-method des Commands erfolgen. Wenn mit den Eingabedaten etwas nicht stimmt (fehlender Parameter, ungültige Eingabe usw.), muss die Factory-Methode eine Exception auslösen. Diese Exception wird dann vom ExceptionSubscriber abgefangen, der eine ordnungsgemäße 422 response generiert. Der Command muss, sobald er instanziert ist, alle Daten enthalten, die der „Handler“ zur Verarbeitung der Anfrage benötigt. Die Daten in einer Befehlsinstanz müssen gültig sein und erfordern keine weitere Validierung.
Nach der Instanzierung wird der Befehl über den Command Bus durchgeführt. Ein passender „Handler“ wird aufgerufen und der Großteil der Logik geschieht dort. In dem Beispielprojekt haben wir die einfachsten „Handler“ als Proof-of-Concept erstellt. In einem richtigem Projekt sollte der „Handler“ eine Sammlung mit Ressourcen zurückgeben. Die Action sollte eine „Representation“ zurückgeben, die dann von dem bereits erwähnten ResponseSubscriber serialisiert wird.
Voraussetzungen
Um eine Arbeitsstruktur wie oben erwähnt aufbauen zu können, müssen einige wenige Bilbliotheken einbezogen werden:
- League/Tactician-Bundle
- Implementierung des Command Pattern
- Beberlei/Assert
- Eingabevalidierung in der Command Factory-method
- Harmbandstra/Swagger-UI-Bundle
- Einfache Dokumentation der API-Endpunkte, wir haben die erforderlichen Ordner in den obigen Text aufgenommen
- Einfache Dokumentation der API-Endpunkte, wir haben die erforderlichen Ordner in den obigen Text aufgenommen
Empfehlungen
Die folgenden Punkte wurden in diesem Text nicht erwähnt. Wir empfehlen Ihnen jedoch sich damit auseinanderzusetzen und diese in Ihren zukünftigen API-Projekten zu verwenden:
- JMS/Serializer-Bundle
- Obligatorisch für die Unterstützung von Ausgaben mit mehreren Inhaltstypen, ermöglicht die einfache Erstellung von serialisierbaren Objekten
- lcobucci/JWT
- Für JWT-basierte Authentifizierung
- Willdurand/Hateoas-Bundle
- Eine Implementierung der HATEOAS REST-Architektur
- enm/json-api-server-bundle
- Eine Implementierung einer JSON:API-Architektur
Literatur:
- Action-Domain-Responder Design Pattern: https://en.wikipedia.org/wiki/Action%E2%80%93domain%E2%80%93responder
- Command Pattern: https://en.wikipedia.org/wiki/Command_pattern
- JSON:API: https://jsonapi.org/
- HATEOAS: https://en.wikipedia.org/wiki/HATEOAS
- League Tactician (Command Pattern): https://tactician.thephpleague.com/