Heute werden wir diese C-Bibliothek in PHP einbinden und das Ganze auf der Upsun Cloud zum Laufen bringen.
FFI, kurz für „Foreign Function Interface“, ist eine Funktion vieler Programmiersprachen, die es dieser Sprache ermöglicht, in einer anderen Sprache geschriebenen Code aufzurufen. PHP hat diese Funktionalität seit PHP 7.4, auch wenn es, wie zu erwarten, dabei einige Stolpersteine gibt.
Ein Problem ist, dass auf PHP naturgemäß von Remote-Systemen zugegriffen wird. Das birgt ein offensichtliches Sicherheitsrisiko. Mit FFI könntest du, wenn du irgendeine Schwachstelle in einer Anwendung ausnutzen könntest, potenziell eine Lücke für die Remote-Codeausführung auf Systemebene schaffen. Auf einer Sicherheitsskala von 1 bis 10 ist das ein „Heilige Scheiße!“, daher unterstützt PHP diese Funktion standardmäßig gar nicht. FFI ist standardmäßig nur über die CLI oder in vorinstalliertem Code aktiviert.
Allerdings gibt es dabei einen Haken. Das Vorladen (ebenfalls neu in PHP 7.4, dazu gleich mehr) ist auf den opcache angewiesen. Das gilt auch für FFI. Der opcache ist jedoch standardmäßig deaktiviert, da er in der CLI keine Möglichkeit hat, zwischengespeicherte Opcodes von einer Ausführung zur nächsten beizubehalten. Um FFI mit der CLI zu nutzen, müssen wir den opcache daher manuell aktivieren.
Fangen wir mit etwas Programmieren an, um die points Bibliothek, die wir zuvor erstellt haben. Zunächst brauchen wir eine Struktur in PHP, die die point `struct` nachzubilden. (Das ist zwar nicht zwingend erforderlich, aber die Spiegelung von Wertobjekten auf beiden Seiten macht die API leichter nachvollziehbar.)
class Point
{
public int $x;
public int $y;
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
}
Der nächste Schritt besteht darin, PHP über die Bibliothek zu informieren. Dafür gibt es mehrere Möglichkeiten, aber wir fangen mit cdef():
$ffi = FFI::cdef(file_get_contents('points.h'), __DIR__ . '/points.so');FFI::cdef() keine schiefgelaufene Alphabetstunde; es steht für „C-Definition“ und nimmt zwei Parameter entgegen: einen String, der die C-Strukturen definiert, die verfügbar gemacht werden sollen, und den Pfad zu der .so Datei, in der sie zu finden sind.
In diesem Fall ist es am einfachsten, einfach die .h Datei mit den Definitionen einzulesen. Allerdings verwendet die FFI von PHP keinen Standard-C-Header-Parser. Sie hat ihren eigenen, der zum Zeitpunkt des Verfassens dieses Artikels … eher schwach ist. Er unterstützt viele Syntaxelemente nicht, die in modernem C-Code völlig zulässig sind und in den meisten echten C-Programmen heutzutage sogar recht häufig vorkommen. Während selbst geschriebene Bibliotheken wie diese ihre Header wiederverwenden können, haben die meisten echten C-Bibliotheken in der Produktivumgebung Header, die für die FFI von PHP zu komplex sind. Das bedeutet, dass du deinen eigenen Parser neu implementieren musst.
Das ist eigentlich kein Problem; die Kopplung zwischen einer Header-Datei und einer Bibliothek ist viel schwächer als beispielsweise zwischen einer PHP-Schnittstelle und einer Klasse. Die Header-Datei sagt lediglich: „Irgendwo gibt es eine Funktion namens distance , die 2 points.“ Die .so Datei selbst gibt an: „Übrigens, ich habe eine Funktion namens distance , die 2 points.“ Solange diese beim Laden übereinstimmen, klappt es.
Das bedeutet auch: Wenn wir nur einen kleinen Teil einer größeren Bibliothek verfügbar machen wollen, können wir eine Header-Datei schreiben, die nur die Funktionen und Strukturen deklariert, die wir PHP zur Verfügung stellen wollen, während der Rest unzugänglich bleibt. Das kann bei größeren Bibliotheken nützlich sein.
Wir werden hier nicht näher darauf eingehen, aber Anthony Ferrara hat ein Tool namens „FFIMe“ geschrieben, das die FFI-API umschließt, um die Arbeit damit zu vereinfachen, und das außerdem die Vorverarbeitung von C-Header-Dateien in die von PHP unterstützte Teilsprache übernimmt. Es ist nicht perfekt, aber für den ernsthaften Einsatz von FFI lohnt es sich, sich damit zu beschäftigen.
Wir können nun diese $ffi Variable nutzen, um auf die Teile der .so Datei, die verfügbar sind.
// inline.php
// ...
$p1 = new Point(3, 4);
$p2 = new Point(7, 9);
$cp1 = $ffi->new('struct point');
$cp2 = $ffi->new('struct point');
$cp1->x = $p1->x;
$cp1->y = $p1->y;
$cp2->x = $p2->x;
$cp2->y = $p2->y;
$d = $ffi->distance($cp1, $cp2);
print "Distance is $d\n";
Zuerst erstellen wir zwei Point Objekte. Dann erstellen wir zwei Variablen, die als Brücken zur point Struktur aus der Header-Datei dienen. Diese Variablen sind vom Typ FFI\CData und fungieren als Adapter zwischen der PHP- und der C-Welt. Da es sich um Objekte handelt, können wir sie als solche nutzen, und sie übernehmen die Rückübersetzung ins C – das Kopieren unserer Point PHP-Objektwerte in sie zu kopieren, ist ein einfacher und langweiliger Vorgang.
Schließlich können wir die distance Methode des $ffi Objekt aufrufen, das als Adapter für die C-Funktion dient. Die Daten werden an die C-Bibliothek übergeben, die einen double (auch bekannt als float) erzeugt und diese zurückgibt. Wenn man dieses Programmiert-Programm ausführt, wird pflichtbewusst Distance is 6.4031242374328.
Oder, na ja, fast. Erinnere dich daran, dass wir oben gesagt haben, dass FFI einen funktionierenden Opcache benötigt, über den die CLI standardmäßig nicht verfügt. Wir können ihn entweder in php.ini oder direkt über die Befehlszeile aktivieren. Letzteres ist einfacher:
$ php -d opcache.enable_cli=true inline.php
Distance is 6.4031242374328Jetzt erhalten wir die gewünschte Ausgabe.
Es gibt hier allerdings ein Problem. Das Instanziieren der FFI-Bibliothek, das Parsen der Header-Datei und das Einrichten der entsprechenden Adapter im Hintergrund nimmt eine nicht unerhebliche Zeit in Anspruch, besonders wenn die C-Bibliothek umfangreicher ist als dieses einfache Beispiel. Auf der CLI gibt es keine gute Möglichkeit, diesen Aufwand bei jeder Anfrage zu vermeiden, aber sobald wir dieses Programm mit einer Webanfrage verknüpfen, wollen wir diesen Overhead nach Möglichkeit überspringen.
Glücklicherweise bietet FFI eine alternative Möglichkeit, eine Brücke zu einer C-Bibliothek zu schlagen, die keinen Laufzeit-Overhead verursacht und das Vorladen nutzt. Das Vorladen ist eine weitere neue Funktion in PHP 7.4, mit der du Klassen- und Funktionsdefinitionen beim Serverstart einmalig in den Speicher von PHP-FPM laden und sie anschließend nie wieder neu laden musst. Wir haben bereits über das Vorladen geschrieben, und es ist eine wirklich tolle Ergänzung.
Du kannst eine C-Bibliothek auch per Preloading initialisieren und im Speicher behalten, wodurch sich die Initialisierungskosten bei zukünftigen Anfragen vermeiden lassen. Standardmäßig ist das aus den oben genannten Sicherheitsgründen die einzige Möglichkeit, sie in einer Webanfrage zu nutzen. Es ist möglich, FFI::cdef() für Webanfragen in der Entwicklung zu aktivieren, aber bitte tu das niemals in der Produktivumgebung.
Wir zeigen das Vorladen zunächst über die Befehlszeile. Da wir das Vorladen ohnehin nutzen, verschieben wir die Points Klassendefinition in eine andere Datei namens classes.php, als wären wir wieder im Jahr 2006. (Das ist retro.) Als Nächstes müssen wir einige zusätzliche Änderungen an der Header-Datei vornehmen, indem wir diese beiden Zeilen am Anfang hinzufügen:
#define FFI_SCOPE "POINTS"
#define FFI_LIB "./points.so"Das FFI_SCOPE gibt eine eindeutige Kennung an, anhand derer PHP FFI diese Bibliothek erkennt. FFI_LIB gibt die Bibliotheksdatei an, auf die sie sich bezieht. Beachte, dass der Pfad relativ zum Systembibliothekspfad ausgewertet wird, der standardmäßig das aktuelle Verzeichnis nicht enthält. Das bedeutet, dass die ./ erforderlich ist und das Laden fehlschlägt, wenn er fehlt.
Ein weiterer wichtiger Hinweis zur Header-Datei: Zum Zeitpunkt des Verfassens dieses Artikels verhindert ein Fehler in PHP das Parsen der Header-Datei, wenn vor einer #define Zeile stehen. Alle #define Anweisungen müssen vor etwaigen Kommentaren stehen. Wir haben diesen Fehler an PHP gemeldet (siehe vorheriger Link) und hoffen, dass er in einer zukünftigen Version behoben oder zumindest dokumentiert wird.
Jetzt können wir ein Preload-Skript einrichten. In diesem Fall ist es eine sehr einfache Datei:
<?php
// preloader.php
declare(strict_types=1);
FFI::load(__DIR__ . "/points.h");
opcache_compile_file(__DIR__ . "/classes.php");Der opcache_compile_file() Aufruf weist den Preloader an, alles classes.php in den gemeinsamen Speicher von PHP zu laden. Der andere Aufruf, FFI::load(), weist PHP an, die Header-Datei zu laden und anschließend den FFI-Adapter für diese Definition zu erstellen und zu speichern. Die zu verwendende Bibliotheksdatei und der Bezeichner für diese Bibliothek werden in der Header-Datei angegeben.
Zum Schluss können wir noch ein weiteres Testskript einrichten, um die vorgeladene Version in Aktion zu sehen; es kann fast identisch mit der Inline-Version sein, nur mit einer Änderung im FFI-Einrichtungscode:
<?php
// preload.php
$ffi = \FFI::scope("POINTS");
$p1 = new Point(3, 4);
$p2 = new Point(7, 9);
$cp1 = $ffi->new('struct point');
$cp2 = $ffi->new('struct point');
$cp1->x = $p1->x;
$cp1->y = $p1->y;
$cp2->x = $p2->x;
$cp2->y = $p2->y;
$d = $ffi->distance($cp1, $cp2);
print "Distance is $d\n";
Der $ffi Adapter wird nun erstellt, indem FFI::scope() und unter Angabe der zuvor geladenen Bibliothek (POINTS), die wir verwenden möchten. Da die Bibliothek bereits im Vorladeschritt in den Speicher geladen wurde, ist dieser Aufruf wesentlich effizienter. Der Rest des Codes ist identisch.
Beachte außerdem, dass wir, da wir sie vorab geladen haben, nicht require die classes.php Datei laden. Sie befindet sich bereits im Speicher und ist einsatzbereit.
Um dieses Skript über die Befehlszeile auszuführen, müssen wir das zu verwendende Vorladeskript angeben – wiederum entweder inline oder über php.ini:
$php -d opcache.preload="preloader.php" -d opcache.enable_cli=true preload.php
Distance is 6.4031242374328Et voilà.
Die FFI-API kann sich manchmal etwas unnatürlich anfühlen; eigentlich sogar ziemlich oft, besonders wenn man anfängt, ihre fortgeschritteneren Features zu nutzen, auf die wir hier aber nicht näher eingehen werden. Glücklicherweise haben wir als Programmierer eine Standardlösung für klobige APIs: „Hey, schau mal, eine Abstraktion!“
Es gibt zwei wesentliche Änderungen, die wir an unserem Code vornehmen werden. Erstens lassen wir Point Objekte sich selbst in eine FFI-Variable konvertieren:
class Point
{
public int $x;
public int $y;
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
public function toStruct($ffi)
{
$cp = $ffi->new('struct point');
$cp->x = $this->x;
$cp->y = $this->y;
return $cp;
}
}
Zweitens werden wir die FFI-Logik in ein anderes Objekt verpacken. Je nach Kontext gibt es ein Dutzend Möglichkeiten, dies zu tun, aber wir entscheiden uns jetzt für eine einfache Variante, nur um unseren Standpunkt zu verdeutlichen (kein Wortspiel beabsichtigt):
class PointApi
{
private static $ffi = null;
public function __construct()
{
static::$ffi ??= \FFI::scope("POINTS");
}
public function distance(Point $p1, Point $p2): float
{
$cp1 = $p1->toStruct(static::$ffi);
$cp2 = $p2->toStruct(static::$ffi);
return static::$ffi->distance($cp1, $cp2);
}
}
Auf einen bestimmten FFI-Gültigkeitsbereich kann man problemlos mehrfach verweisen. Anstatt also zu versuchen, ihn als globales Singleton zu definieren und einzubinden, speichern wir ihn einfach in einer privaten statischen Variablen.
Jetzt können wir unser Beispielskript auf Folgendes vereinfachen:
<?php
declare(strict_types=1);
$p1 = new Point(3, 4);
$p2 = new Point(7, 9);
$api = new PointApi();
$d2 = $api->distance($p1, $p2);
print "Distance is $d2\n";Kein FFI in Sicht, aber genau das passiert hinter den Kulissen.
Die nächste Frage ist natürlich, wie man all dieses Programmieren für Webanfragen zum Laufen bringt. Auf der Upsun Cloud ist das ziemlich unkompliziert, aber die wesentlichen Schritte sind auf jedem Server im Grunde dieselben.
Wir fangen mit unserem neuesten Programm in einem Projekt-Repository an und gehen davon aus, dass die routes.yaml und ein leeres services.yaml bereits eingerichtet sind. Dann fügen wir ein web Verzeichnis mit einem einfachen Web-Skript hinzu, das lediglich das preload.php Skript von vorhin enthält, mit unserer bereinigten API.
Der interessante Teil befindet sich in der .platform.app.yaml Datei, wo es ein paar bewegliche Teile gibt. Wir werden die gemeinsam genutzte Bibliothek bei jedem Build kompilieren, damit wir immer die aktuellste Version davon haben. Glücklicherweise gcc ist bereits in allen Upsun-Cloud-Umgebungen verfügbar, sodass das ganz einfach ist. Hier ist das vollständige Beispiel mit den entsprechenden Kommentaren:
name: app
type: php:7.4
# FFI is an extension. It must be explicitly enabled.
runtime:
extensions:
- ffi
# Setting a variable in the `php` namespace makes it an ini setting. This block
# tells PHP-FPM to run `preloader.php` on startup. It's the same file we saw before.
variables:
php:
opcache.preload: "preloader.php"
hooks:
build: |
# Using the Makefile we defined in part 1, compile the `points.so` file.
set -e
make points.so
web:
locations:
"/":
root: "web"
passthru: "/index.php"
disk: 128
Jetzt wird bei jedem git push wird die Punkte-Bibliothek in eine neue .so Datei kompiliert. Beim Deployment startet dann PHP-FPM, lädt die FFI-Erweiterung und führt das einmalige preloader.php Skript aus, um unseren POINTS FFI-Bereich zu initialisieren (und was wir sonst noch tun wollen). Die Webkonfiguration ist denkbar einfach; jede Anfrage geht an unsere Basis- index.php Datei, die denselben Code enthält, den wir bereits gesehen haben.
Wenn wir sicherstellen wollten, dass die FFI-Bibliothek über die Befehlszeile funktioniert, etwa für eine cron Aufgabe, ist der einfachste Weg, dafür zu sorgen, dass der cron Befehl sowohl den opcache.preload und opcache.enable_cli Direktiven wie oben enthält. Beide sind erforderlich.
Puh! Nicht viel Programmieren, aber viele Konzepte. Lohnt sich das?
Wie so oft lautet die Antwort: „Es kommt darauf an.“ In den meisten Fällen? Nein, das ist es nicht. Bei einem so trivialen Fall wie diesem ist es sogar kontraproduktiv.
Zum einen ist modernes PHP an sich schon verdammt schnell. Die Performance-Vorteile, die sich durch die Umsetzung von alltäglichem PHP-Code in C ergeben, sind in den meisten Fällen bestenfalls marginal; ein oder zwei Datenbankaufrufe zu vermeiden, bringt eine viel größere Beschleunigung bei deutlich geringerem Aufwand.
Zum anderen hat FFI seinen eigenen Overhead. Jedes Mal, wenn du über die PHP/C-Grenze hinweg aufrufst, entsteht ein Übersetzungs-Overhead, der Datenstrukturen von einem Stil in den anderen konvertiert. Schon der Zugriff auf eine Variable über FFI kann doppelt so langsam sein, als wenn die Variable in PHP wäre. Bei trivialem Code wie in diesem Beispiel ist es fast sicher langsamer, als die Funktion einfach zu programmieren distance() direkt in PHP.
Wann ist es also sinnvoll, Logik mithilfe von FFI von PHP nach C zu verlagern? Ich sehe zwei Hauptanwendungsf älle:
Wenn dein Anwendungsfall in einen dieser Bereiche fällt, hast du nun ein neues Werkzeug, mit dem du das Problem angehen kannst.
Gabriel Couto hat eine Reihe von Beispielen für den Einsatz von FFI mit bestehenden Bibliotheken zusammengestellt, bei denen eine Neuimplementierung in PHP überhaupt keinen Sinn machen würde. Jetzt, da du die Grundlagen von FFI gut verstanden hast, bieten die dortigen Beispiele den nächsten Schritt.