- Fonctionnalités
- Pricing
Aujourd'hui, on va intégrer cette bibliothèque C dans PHP et faire en sorte que tout fonctionne sur Upsun.
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 est que PHP est, par nature, accessible par des systèmes distants. Cela crée un risque de sécurité inhérent. Avec la FFI, si tu parvenais à exploiter une faille quelconque 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 « bon sang ! », donc par défaut, PHP ne prend même pas en charge cette fonctionnalité. La FFI n’est activée par défaut qu’à partir de 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 FFI. L’opcache est cependant désactivé par défaut, car sur la CLI, il n’a nulle part où conserver les opcodes mis en cache d’une exécution à l’autre. Pour utiliser 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 plus tôt. D’abord, on va avoir besoin d’une structure en PHP pour imiter la point struct. (Ce n’est pas obligatoire, mais reproduire les objets de valeur des deux côtés rend l’API plus facile à suivre.)
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 à informer PHP de l'existence de la bibliothèque. Il existe plusieurs façons de le faire, mais nous allons 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 prend deux paramètres : une chaîne qui définit les structures C à exposer et le chemin vers le .so fichier dans lequel elles se trouvent.
Dans ce cas, le plus simple est de simplement lire le .h fichier de définition. Cependant, la FFI de PHP n'utilise pas d'analyseur d'en-têtes C standard. Elle a le sien, qui, à l'heure 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 légale dans le code C moderne et qui est en fait assez courante dans la plupart des programmes C actuels. Alors que les bibliothèques écrites sur mesure comme celle-ci peuvent réutiliser leur en-tête, la plupart des 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.
Ce n’est pas grave ; le couplage entre un fichier d’en-tête et une bibliothèque est bien plus faible que, disons, 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 éléments correspondent au moment du chargement, ça fonctionnera.
Ç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 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 maintenant 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 l’univers C. Comme ce sont des objets, on peut les utiliser comme tels et ils se chargent de la conversion vers le C ; donc, leur attribuer les valeurs de nos Point valeurs de l'objet 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 (alias un float), et la renvoie. L'exécution de ce code affiche fidèlement Distance is 6.4031242374328.
Ou, enfin, presque. Rappelle-toi qu’on a dit plus haut que la FFI nécessite un opcache opérationnel, ce dont le CLI ne dispose pas par défaut. On peut soit l’activer dans php.ini ou directement sur la ligne de commande. Cette dernière option est plus simple :
$ php -d opcache.enable_cli=true inline.php
Distance is 6.4031242374328On obtient maintenant le résultat souhaité.
Il y a cependant un problème ici. 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 que l'on connecte ce code à une requête web, on souhaite éviter cette surcharge si possible.
Heureusement, 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 très pratique.
Tu peux également 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 futures. Par défaut, c'est la seule façon de l'utiliser dans une requête web, pour les raisons de sécurité mentionné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 le préchargement en ligne de commande. Puisqu'on utilise le préchargement de toute façon, on va déplacer la Points définition de 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"La FFI_SCOPE spécifie un identifiant unique grâce auquel PHP FFI reconnaîtra cette bibliothèque. FFI_LIB spécifie 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 mise en garde importante 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 passer à la configuration 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' opcache_compile_file() commande indique au préchargeur de tout charger classes.php dans la mémoire partagée de PHP. L'autre appel, FFI::load(), demande à 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 configurer un autre script de test pour voir la version préchargée en action ; il peut être presque identique à la version en ligne, avec juste une modification du code de configuration 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) que nous voulons utiliser. Comme la bibliothèque est déjà chargée en mémoire lors de l'étape de préchargement, cet appel est beaucoup moins coûteux. Le reste du code est identique.
Note aussi que, puisque nous l'avons 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, on doit spécifier 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, que nous n'aborderons pas ici. Heureusement, en tant que programmeurs, nous avons une solution standard pour les API peu pratiques : « Tiens, une abstraction ! »
On va apporter deux modifications principales à notre base de code. D'abord, on va laisser les Point aux objets de se convertir eux-mêmes en une 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 une portée FFI donnée plusieurs fois en toute sécurité, 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 n'avoir 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, est de savoir comment faire fonctionner tout ce code pour les requêtes web. C'est assez simple sur Upsun, mais les éléments clés sont essentiellement 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 services.yaml sont déjà configurés. Ensuite, on va ajouter un web répertoire contenant un simple script web qui n'est autre que le preload.php script d'il y a un instant, avec notre API épurée.
La partie intéressante se trouve dans le .platform.app.yaml fichier, où il y a quelques éléments mobiles. On va compiler la bibliothèque partagée à chaque build pour toujours disposer de la dernière version. Heureusement gcc est déjà disponible sur tous les environnements Upsun, ce qui rend la tâche très simple. 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 , la bibliothèque de 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 script preloader.php pour initialiser notre POINTS portée FFI (ainsi que tout ce qu’on veut faire d’autre). La configuration web est on ne peut plus simple ; chaque requête va vers notre index.php , qui correspond au code que nous avons 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 moyen 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 beaucoup de concepts. Est-ce que ça en vaut la peine ?
Comme d’habitude, la réponse est « ça dépend ». La plupart du temps ? Non, ça ne vaut pas le coup. Pour un cas trivial comme celui-ci, c’est contre-productif.
D'une part, le PHP moderne est déjà sacrément rapide en soi. Les gains de performance 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 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 appelles à travers la frontière PHP/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 trivial comme cet exemple, c'est presque certainement plus lent que de simplement implémenter distance() directement en PHP.
Alors, quand est-il approprié de déplacer 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 FFI avec des bibliothèques existantes pour lesquelles une réimplémentation en PHP n'aurait aucun sens. Maintenant que tu maîtrises bien les bases de FFI, ces exemples te permettront de passer au niveau supérieur.