Aujourd’hui, on va intégrer cette bibliothèque C dans PHP et faire en sorte que tout fonctionne sur Upsun Cloud.
La FFI, ou Foreign Function Interface, est une fonctionnalité présente dans de nombreux langages qui permet à un langage d’appeler du code écrit dans un autre langage. PHP a intégré cette fonctionnalité dans la version 7.4, même si, comme on pouvait s’y attendre, il y a quelques obstacles à surmonter.
L’un des problèmes, c’est que PHP est, par nature, accessible depuis des systèmes distants. Ça crée un risque de sécurité évident. Avec la FFI, si tu parvenais à exploiter n’importe quelle faille dans une application, tu pourrais potentiellement obtenir une faille d’exécution de code à distance pour le code au niveau du système. Sur une échelle de sécurité de 1 à 10, ça mérite un « oh non ! », donc par défaut, PHP ne prend même pas en charge cette fonctionnalité. La FFI n’est activée par défaut que depuis la CLI ou dans du code préchargé.
Il y a toutefois une mise en garde. Le préchargement (également une nouveauté de PHP 7.4, on y reviendra dans un instant) repose sur l’opcache. Tout comme la FFI. L’opcache est cependant désactivée par défaut, car sur la CLI, elle n’a nulle part où conserver les opcodes mis en cache d’une exécution à l’autre. Pour utiliser la FFI avec la CLI, il va donc falloir activer manuellement l’opcache.
Commençons par un peu de code pour utiliser la points bibliothèque qu’on a créée tout à l’heure. D’abord, on va avoir besoin d’une structure en PHP pour reproduire la point struct. (Ce n’est pas obligatoire, mais le fait de reproduire les objets de valeur des deux côtés rend l’API plus facile à comprendre.)
class Point
{
public int $x;
public int $y;
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
}
L’étape suivante consiste à indiquer à PHP l’existence de la bibliothèque. Il y a plusieurs façons de le faire, mais on va commencer par cdef():
$ffi = FFI::cdef(file_get_contents('points.h'), __DIR__ . '/points.so');FFI::cdef() n’est pas une leçon d’alphabet qui a mal tourné ; ça signifie « définition C » et ça prend deux paramètres : une chaîne qui définit les structures C à exposer et le chemin d’accès au .so fichier où elles se trouvent.
Dans ce cas, le plus simple est de simplement lire le .h fichier de définition. Par contre, la FFI de PHP n’utilise pas d’analyseur d’en-têtes C standard. Elle a le sien, qui, au moment où j’écris ces lignes, est… plutôt médiocre. Il ne prend pas en charge une grande partie de la syntaxe qui est tout à fait valide dans le code C moderne et qui est en fait assez courante dans la plupart des vrais programmes C d’aujourd’hui. Alors que les bibliothèques écrites sur mesure comme celle-ci peuvent réutiliser leur en-tête, la plupart des vraies bibliothèques C de production auront des en-têtes trop complexes pour que la FFI de PHP puisse les gérer. Ça veut dire que tu devras réimplémenter la tienne.
En fait, ce n’est pas grave ; le couplage entre un fichier d’en-tête et une bibliothèque est bien plus faible que, disons, celui entre une interface PHP et une classe. Le fichier d’en-tête dit simplement : « Il existe, quelque part, une fonction nommée distance qui prend 2 points». Le .so fichier lui-même indique : « Au fait, j’ai une fonction nommée distance qui prend 2 points». Tant que ces deux éléments correspondent au moment du chargement, ça marchera.
Ça veut aussi dire que si on veut n'exposer qu'une petite partie d'une bibliothèque plus grande, on peut écrire un fichier d'en-tête qui ne déclare que les fonctions et les structures qu'on veut exposer à PHP, en laissant le reste inaccessible. Ça peut être utile avec les bibliothèques plus volumineuses.
On ne va pas s’étendre là-dessus ici, mais Anthony Ferrara a écrit un outil appelé FFIMe qui encapsule l’API FFI pour faciliter son utilisation et qui gère également le prétraitement des en-têtes C en un sous-ensemble du langage pris en charge par PHP. Ce n’est pas parfait, mais ça vaut le coup de s’y intéresser pour une utilisation sérieuse de la FFI.
On peut désormais utiliser cette $ffi variable pour accéder aux parties du .so fichier qui sont exposées.
// 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";
D'abord, on crée deux Point objets. Ensuite, on crée deux variables qui servent de ponts vers la point struct du fichier d’en-tête. Ces variables sont de type FFI\CData et servent d’adaptateur entre l’univers PHP et celui du C. Comme ce sont des objets, on peut les utiliser comme tels et ils s’occupent de la conversion vers le C ; donc, leur attribuer les valeurs de nos Point objets PHP vers celles-ci est un processus simple et sans intérêt.
Enfin, on peut appeler la distance méthode de l’ $ffi objet, qui sert d’adaptateur pour la fonction C. Les données sont transmises à la bibliothèque C qui produit un double (c'est-à-dire un float), et le renvoie. L’exécution de ce code affiche bien Distance is 6.4031242374328.
Ou, enfin, presque. Rappelle-toi qu’on a dit plus haut que la FFI nécessite un opcache opérationnel, dont la CLI ne dispose pas par défaut. On peut soit l’activer dans php.ini ou directement sur la ligne de commande. La deuxième option est plus simple :
$ php -d opcache.enable_cli=true inline.php
Distance is 6.4031242374328On obtient désormais le résultat souhaité.
Il y a toutefois un problème. L’instanciation de la bibliothèque FFI, l’analyse du fichier d’en-tête et la configuration des adaptateurs appropriés en arrière-plan prennent un temps non négligeable, surtout si la bibliothèque C est plus volumineuse que ce simple exemple. Sur la CLI, il n’y a pas de bon moyen d’éviter ce coût à chaque requête, mais une fois qu’on relie ce code à une requête web, on veut éviter cette surcharge si possible.
Heureusement, la FFI propose une autre façon de faire le pont vers une bibliothèque C, sans surcoût d’exécution, en tirant parti du préchargement. Le préchargement est une autre nouvelle fonctionnalité de PHP 7.4 qui te permet de charger les définitions de classes et de fonctions dans la mémoire de PHP-FPM une seule fois au démarrage du serveur, puis de ne plus jamais les recharger. On a déjà parlé du préchargement, et c’est vraiment un ajout génial.
Tu peux aussi initialiser une bibliothèque C via le préchargement et la conserver en mémoire, ce qui évite le coût d’initialisation lors des requêtes suivantes. Par défaut, c’est la seule façon de l’utiliser dans une requête web, pour les raisons de sécurité évoquées plus haut. Il est possible d’activer FFI::cdef() le préchargement dans les requêtes web pour le développement, mais ne le fais surtout jamais en production.
On va d’abord te montrer comment utiliser le préchargement en ligne de commande. Puisqu’on utilise de toute façon le préchargement, on va déplacer la Points définition de la classe dans un autre fichier nommé classes.php, comme si on était de retour en 2006. (C'est rétro.) Ensuite, on doit apporter quelques modifications supplémentaires au fichier d'en-tête en ajoutant ces deux lignes au début :
#define FFI_SCOPE "POINTS"
#define FFI_LIB "./points.so"Le FFI_SCOPE spécifie un identifiant unique grâce auquel PHP FFI reconnaîtra cette bibliothèque. FFI_LIB indique le fichier de bibliothèque auquel il fait référence. Note que le chemin est évalué par rapport au chemin des bibliothèques système, qui n’inclut pas le répertoire courant par défaut. Ça veut dire que le ./ est obligatoire, et s’il manque, le chargement échouera.
Autre point important concernant le fichier d'en-tête : à l'heure où on écrit ces lignes, un bug dans PHP l'empêche d'analyser le fichier d'en-tête s'il y a des lignes de commentaire avant une #define ligne. Toutes les #define instructions doivent précéder les commentaires. On a signalé ce bug à PHP (voir le lien précédent) et on espère qu’il sera corrigé, ou au moins documenté, dans une future version.
On peut maintenant s'atteler à la mise en place d'un script de préchargement. Dans ce cas, c'est un fichier très simple :
<?php
// preloader.php
declare(strict_types=1);
FFI::load(__DIR__ . "/points.h");
opcache_compile_file(__DIR__ . "/classes.php");L'instruction opcache_compile_file() appel demande au préchargeur de tout charger classes.php dans la mémoire partagée de PHP. L’autre appel, FFI::load(), indique à PHP de charger le fichier d'en-tête, puis de créer et d'enregistrer l’adaptateur FFI pour cette définition. Le fichier de bibliothèque à utiliser et l’identifiant de cette bibliothèque sont spécifiés dans le fichier d’en-tête.
Enfin, on peut mettre en place un autre script de test pour voir la version préchargée en action ; il peut être presque identique à la version intégrée, avec juste une modification du code de configuration de la FFI :
<?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";
L’ $ffi adaptateur est désormais créé en utilisant FFI::scope() et en spécifiant la bibliothèque précédemment chargée (POINTS) qu’on souhaite utiliser. Comme la bibliothèque est déjà chargée en mémoire lors de l’étape de préchargement, cet appel est bien moins coûteux. Le reste du code est identique.
Note aussi que, comme on l’a préchargée, il n’est pas nécessaire de require fichier classes.php fichier. Il est déjà en mémoire et prêt à l'emploi.
Pour exécuter ce script depuis la ligne de commande, il faut indiquer le script de préchargement à utiliser, là encore soit en ligne, soit via php.ini:
$php -d opcache.preload="preloader.php" -d opcache.enable_cli=true preload.php
Distance is 6.4031242374328Et voilà.
L’API FFI peut parfois sembler un peu artificielle ; en fait, c’est souvent le cas, surtout quand tu commences à utiliser ses fonctionnalités plus avancées, qu’on ne va pas aborder ici. Heureusement, en tant que programmeurs, on a une solution standard pour les API maladroites : « Tiens, une abstraction ! »
On va apporter deux modifications principales à notre base de code. D'abord, on va faire en sorte que les Point les objets se convertir eux-mêmes en variable FFI :
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;
}
}
Ensuite, on va encapsuler la logique FFI dans un autre objet. Il y a une douzaine de façons de le faire selon le contexte de ce que tu fais, mais on va opter pour la simplicité pour l’instant, juste pour illustrer notre propos (sans jeu de mots) :
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);
}
}
On peut référencer sans risque plusieurs fois une portée FFI donnée ; donc, plutôt que d’essayer d’en faire un singleton global et de l’injecter, on va simplement la stocker dans une variable statique privée.
Maintenant, on peut simplifier notre script d’exemple pour qu’il ne soit plus que ça :
<?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";Pas de FFI en vue, mais c’est ce qui se passe en coulisses.
La question suivante, bien sûr, c’est de savoir comment faire fonctionner tout ce code pour les requêtes web. C’est assez simple sur Upsun Cloud, mais les étapes sont globalement les mêmes sur n’importe quel serveur.
On va commencer avec notre code le plus récent dans un dépôt de projet et supposer que le routes.yaml et un répertoire services.yaml sont déjà configurés. Ensuite, on va ajouter un web répertoire contenant un script web simple qui reprend juste le preload.php script d’il y a un instant, avec notre API épurée.
Le plus intéressant, c’est le .platform.app.yaml fichier, où il y a quelques éléments à prendre en compte. On va compiler la bibliothèque partagée à chaque build pour toujours disposer de sa dernière version. Heureusement, gcc est déjà disponible sur tous les environnements Upsun Cloud, c'est donc un jeu d'enfant. Voici l'exemple complet, avec les commentaires pertinents :
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
Désormais, à chaque git push compilation, la bibliothèque « points » sera recompilée dans un nouveau .so fichier. Ensuite, au moment du déploiement, PHP-FPM va démarrer, charger l’extension FFI et exécuter le preloader.php pour initialiser notre POINTS portée FFI (et tout ce qu’on veut faire d’autre). La configuration web est on ne peut plus simple : chaque requête est dirigée vers notre fichier index.php , qui correspond au même code que celui qu’on a déjà vu.
Si on voulait s'assurer que la bibliothèque FFI fonctionne depuis la ligne de commande, par exemple pour une cron tâche, le plus simple est de t’assurer que la cron commande inclut à la fois le opcache.preload et opcache.enable_cli comme ci-dessus. Les deux sont obligatoires.
Ouf ! Pas beaucoup de code, mais plein de concepts. Est-ce que ça vaut le coup ?
Comme d'habitude, la réponse est « Ça dépend ». La plupart du temps ? Non, ça ne vaut pas le coup. Pour un cas aussi simple que celui-ci, c'est même contre-productif.
D'une part, le PHP moderne est déjà sacrément rapide en soi. Les gains de performances liés au transfert de code PHP banal vers le C sont, au mieux, marginaux la plupart du temps ; éliminer un ou deux appels à la base de données te permettra d'obtenir un gain de vitesse bien plus important pour beaucoup moins d'efforts.
D’autre part, la FFI a son propre surcoût. Chaque fois que tu effectues un appel entre PHP et C, il y a un surcoût de traduction pour convertir les structures de données d’un style à l’autre. Le simple fait d’accéder à une variable via la FFI peut être deux fois plus lent que si la variable était en PHP. Pour du code simple comme dans cet exemple, c’est presque certainement plus lent que d’implémenter distance() directement en PHP.
Alors, quand est-il judicieux de déplacer de la logique de PHP vers C à l’aide de la FFI ? Je vois deux cas d’utilisation principaux :
Si ton cas d’utilisation relève de l’un de ces domaines, tu disposes désormais d’un nouvel outil pour t’attaquer au problème.
Gabriel Couto a rassemblé une série d’exemples d’utilisation de la FFI avec des bibliothèques existantes, pour lesquelles une réimplémentation en PHP n’aurait absolument aucun sens. Maintenant que tu maîtrises bien les bases de la FFI, ces exemples te permettront de passer au niveau supérieur.