Eine einfache REST-API
Bevor PHP objektorientiert wurde, war es gang und gäbe, Anwendungen als eine Ansammlung von einzelnen PHP-Skripten zu erstellen. Gerne auch mit PHP, HTML und SQL gemischt in einer Datei. Die Dateinamen der Skripte entsprachen dann jeweils ihren URL-Pfaden, weil sie jeweils direkt vom Webserver aufgerufen wurden.
Heute wissen wir, dass sowas nicht gerade wartungsfreundlich ist und es darüber hinaus gar nicht so einfach ist, das einheitlich und sicher zu bekommen. Wir sind daher schon vor langer Zeit dazu übergegangen, für neue Anwendungen das sogenannte Tunnelling-Verfahren zu nutzen und mit der Ausbreitung von Frameworks ist es schnell zum Quasi-Standard geworden, alle Requests an PHP durch ein einzelnes Skript zu tunneln.
Normalerweise konfigurieren wir den Webserver dabei so, dass dieser zunächst versucht, vorhandene statische Inhalte wie CSS, JavaScript oder Bilder direkt auszuliefern, ohne dass PHP involviert ist. Es wäre ja auch aus Performance-Sicht nicht wirklich sinnvoll, ein PHP-Binary zu laden, nur um danach eine Datei direkt aus den Filesystem auszuliefern, ohne PHP überhaupt zu benutzen.
Die Einbindung von PHP in den Apache-Webserver funktionierte früher übrigens genau so: PHP wurde als Teil des Webserver-Prozesses immer geladen, unabhängig davon, ob ein statischer oder dynamischer Request verarbeitet wurde. Weil das nicht gerade ressourcenschonend war, verwenden wir heute normalerweise PHP-FPM, um die PHP-Prozesse vom Webserver-Prozess zu separieren. Anstelle von Apache kommt heute meist der schlankere und schnellere nginx als Webserver zum Einsatz.
Wenn unser Webserver nun einen HTTP-Request verarbeiten soll, der keine bereits vorhandene statische Datei abruft, dann wird diese Anfrage an das Tunnelling-Skript gesendet. Wir nennen es aus nostalgischen Gründen einfach mal index.php
, wobei wir auch irgendeinen anderen Namen wählen könnten.
Die entsprechende Konfiguration dafür könnte für nginx
etwa so aussehen:
location / {
try_files $uri @php;
}
location @php {
fastcgi_pass 127.0.0.1:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /project/index.php;
}
Bevor wir uns nun die Datei index.php
selbst ansehen, schreiben wir uns erst einmal eine Klasse, welche unsere kleine REST-API repräsentiert. Wir wollen ja schließlich möglichst objektorientiert arbeiten:
<?php declare(strict_types=1);
class Rest
{
private array $routes = [
'/foo' => FooResource::class,
'/bar' => BarResource::class
];
public function run(): string
{
$class = $this->routes[$_SERVER['REQUEST_URI']];
$resource = new $class;
$method = strtolower($_SERVER['REQUEST_METHOD']);
return $resource->$method();
}
}
Das hier ist ein stark vereinfachtes Beispiel und definitiv kein Beispiel für guten Code. Bevor wir aber aufzählen, was man hier noch alles verbessern müsste, sprechen wir darüber, was hier eigentlich passiert - und warum.
Unsere REST-API unterstützt erst mal nur statische URLs, und wir haben zwei Ressourcen definiert, und zwar foo
und bar
. Wir wollen für beide Ressourcen erst mal nur GET
und POST
-Requests umsetzen, wir werden aber gleich sehen, dass es sehr einfach ist, auch noch weitere HTTP-Methoden umzusetzen.
Im Property routes
definieren wir ein assoziatives Array, das dem URL-Pfad einer Ressource jeweils die Klasse zuordnet, welche diese Ressource repräsentiert und die an diese Ressource gerichteten HTTP-Requsts verarbeiten wird.
In der Methode run()
schauen wir erst mal nach, welcher URL-Pfad aufgerufen wurde und holen uns den entsprechenden Klassennamen aus routes
. Dann erzeugen wir ein Objekt dieser Klasse und ermitteln, welche Methode $method
wir darin aufrufen wollen. Zu guter Letzt rufen wir diese Methode dann auch tatsächlich auf und geben das Ergebnis zurück.
Die Ressourcen sehen wie folgt aus:
<?php declare(strict_types=1);
class FooResource
{
public function get(): string
{
return 'GET /the-foo-resource';
}
public function post(): string
{
return 'POST /the-foo-resource';
}
}
<?php declare(strict_types=1);
class BarResource
{
public function get(): string
{
return 'GET /the-bar-resource';
}
public function post(): string
{
return 'POST /the-bar-resource';
}
}
Und so sieht unsere index.php
aus:
<?php
require __DIR__ . '/autoload.php';
$_SERVER['REQUEST_URI'] = random_int(0, 1) === 0 ? '/foo' : '/bar';
$_SERVER['REQUEST_METHOD'] = random_int(0, 1) === 0 ? 'GET' : 'POST';
print (new Rest)->run();
Wir verwenden Autoload, wie es sich für modernen Code gehört. Dann würfeln wir, um unseren Tag ein wenig interessanter zu machen, aus, ob wir einen Request auf /foo
oder /bar
simulieren und ob das ein GET
oder POST
-Request sein soll. Normalerweise würde ich nichts in superglobale Variablen schreiben, aber für einen schnellen Test an der Kommandozeile kann man das schon mal so machen.
Probieren wir unsere REST-API aus:
POST /the-foo-resource
Das dynamische Routing funktioniert. Ob wir nun Ressourcen, Controller, Handler oder was auch immer aufrufen: wir können in PHP sehr elegant zur Laufzeit sowohl Klassen- als auch Methodennamen zu ermitteln als auch Methoden aufrufen. Mehr oder weniger jedes Framework macht das so.
Wenn es nicht klappt
Diese Dynamik bezahlen wir natürlich damit, dass zur Laufzeit einiges schiefgehen kann. Wir könnten beispielsweise versuchen, eine nicht existierende Klasse zu instantiieren:
<?php declare(strict_types=1);
$class = 'doesNotExist';
$object = new $class;
PHP Fatal error: Uncaught Error: Class "doesNotExist" not found in noClass-will-fail.php:4 Stack trace: #0 {main} thrown in noClass-will-fail.php on line 4
Der Wert der Variable $class
ist erst zur Laufzeit bekannt. Dadurch hat der Compiler keine Chance zu prüfen, ob wir eine vorhandene Klasse zu instantiieren. Deshalb führt der Versuch, Unsinn zu instantiieren, nicht zu einem Übersetzungsfehler, sondern zu einem Laufzeitfehler.
Das gleiche Problem haben wir nochmal mit den Namen der Methoden, die wir aufrufen:
<?php declare(strict_types=1);
$method = 'doesNotExist';
$object = new Something;
$object->$method();
class Something
{
}
PHP Fatal error: Uncaught Error: Call to undefined method Something::doesNotExist() in noMethod-will-fail.php:5 Stack trace: #0 {main} thrown in noMethod-will-fail.php on line 5
Natürlich könnten wir sowohl die Existenz einer Klasse als auch einer Methode mittels Reflection prüfen, in PHP 7 sind allerdings die entsprechenden Laufzeitfehler zu Exceptions geworden, die wir einfach abfangen und entsprechend sinnvoll behandeln könnten.
Generell liegt es aber in der Natur der Programmiersprache PHP, eher Laufzeitfehler als Übersetzungsfehler zu produzieren. Darauf müssen wir mit einem geeigneten Error-Handling reagieren.
Wir sind noch nicht fertig
Es gibt einige Dinge, die wir in diesem Beispiel (zu) stark vereinfacht oder weggelassen haben:
- Das Array
routes
würden wir normalerweise vermutlich aus einer Konfigurationsdatei laden undRest
als Konstruktorparameter übergeben. - In einer echten REST-API müssten wir neben statischen URLs auch noch URLs mit Parametern unterstützen, etwa
/bars/<id>
. Um das einzubauen, könnten den Router zu einer Chain of Responsibility ausbauen. - Wir müssten Code schreiben für den Fall, dass die aufgerufene Route nicht existiert. Hier würden wir vermutlich einen 404 Response-Code zurückgeben.
- Die Erzeugung der Ressource würden wir vermutlich an eine Fabrik delegieren, weil die Ressourcen Abhängigkeiten haben, die bei der Erzeugung aufgelöst werden müssen.
- Wir filtern aktuell nicht, welche Request-Methoden unterstützt werden. Hier würde sich vermutlich der Einsatz eines Enum anbieten. Generell ist es aus Sicherheitsüberlegungen heraus keine gute Idee, Eingaben von Außen mehr oder weniger direkt als Methodennamen zu verwenden.
- Es ist nirgendwo festgelegt, welche Methoden eine Ressource implementieren muss. Dazu sollten wir ein Interface definieren.
- Wir geben einfach einen String zurück, ohne uns weiter um das Format zu kümmern.
- Wir ignorieren geflissentlich, dass unsere HTTP-Response irgendwelche Header braucht.
- Wir greifen direkt auf superglobale Variablen zu. Stattdessen sollten wir ein Request-Objekt verwenden.
- Wir übergeben keinen Request an die Ressourcen. Normalerweise müssten wir den Methoden
get()
undpost()
jeweils einenHTTPRequest
als Parameter übergeben. - Überhaupt gibt es - wie immer bei meinen Beispielprogrammen - keinerlei Error Handling, weil wir hier auf das Wesentliche, nämlich den Happy Path fokussieren wollen.
In den einzelnen Ressourcen fehlt natürlich noch der ganze produktive Code, schließlich sollen unsere Ressourcen nicht nur vorgefertigte Texte zurückliefern. In der Realität würden wir die Ressourcen-Klassen vermutlich ähnlich wie MVC-Controller auf Services delegieren lassen.
Ausblick
Ein solches Tunneling-Skript nennt man übrigens auch Front Controller
. Damit vermeiden wir Duplikation, weil wir das ganze Bootstrapping an einer zentralen Stelle durchführen. Wenn wir zusätzlich prüfen wollten, ob die Ressource überhaupt zugegriffen werden kann, wäre der Front Controller eine gute Stelle, das einzubauen. Die einzelnen Ressourcen müssten dann gar nichts von Zugriffsrechten wissen, was insbesondere bedeutet, dass man als Entwickler der API nicht den Fehler begehen kann, die Rechteprüfung für eine einzelne Ressource einfach zu vergessen oder nicht sauber zu implementieren.
Auf diese Weise lassen sich nicht alle Arten von Rechteprüfungen implementieren, aber das würde jetzt hier zu weit führen. Auch das Routing für eine Website sieht normalerweise anders aus, weil wir dort meistens GET
-Requests verarbeiten.