C'est bien connu, PHP est lent. Si au fil des versions, il a fait énormément de progrès, son principe de fonctionnement originel, celui d'un langage interprété nécessitant la relecture de tous les fichiers impliqués à chaque requête, pose une limite indépassable. Vraiment ? Et si le serveur conservait toute l'application en mémoire à tout moment ? Voilà ce que vous propose FrankenPHP.
Attention, à l'heure où j'écris ces lignes, FrankenPHP est un projet hautement expérimental ! Les choses auront sans doute évolué lorsque vous lirez cet article, mais il ne sera peut-être pas encore prêt pour un usage en production.
FrankenPHP a été élaboré par Kévin Dunglas. Ce n'est pas un inconnu. En effet, il fait partie des développeurs du noyau de Symfony et est également à l'origine de API Platform [API], des protocoles Mercure.rocks et Vulcain.rocks, sans compter ses nombreuses contributions, y compris à PHP lui-même. Un tel curriculum est prometteur.
Le but de FrankenPHP est de proposer une solution architecturale à la lenteur de PHP. Depuis ses commencements, beaucoup de travail a été fait pour rendre PHP plus rapide, comme l'amélioration du langage lui-même, le développement de méthodologies et de frameworks vertueux ou encore d'extensions de cache. L'architecture des serveurs et réseaux, avec l'utilisation de proxy-cache et de compression, de CDN, l'optimisation des protocoles et du HTML, et même les progrès des navigateurs a également contribué à nous offrir un Web plus rapide et plus fluide. Mais jusqu'ici, le principe de fonctionnement de PHP restait le même : PHP traite les requêtes qui lui sont transmises par le serveur web individuellement, traitement qui n'est optimisé que par le cache applicatif. Il y a pourtant une exception : le serveur Roadrunner [ROAD]. Écrit en Go, il permet de conserver l'application PHP en mémoire tout au long de la vie du serveur, permettant des performances inégalables. FrankenPHP s'inspire des principes de Roadrunner, mais s'appuie sur le serveur web Caddy, ce qui lui permet d'assumer toutes les fonctions d'un serveur web de façon optimale sans recours à un service tiers ; il ajoute également la gestion des Early hints, une intégration parfaite des applications Symfony et même un hub Mercure. Et, pour finir, il se propose même d'intégrer le serveur PHP dans des applications Go à l'aide d'un paquet dédié !
FrankenPHP est-il l'aube d'une nouvelle révolution dans l'écosystème PHP ? Passons en revue tous ces éléments pour voir ce qu'il en est.
1. Utilisation
1.1 Mode classique
Bien que ce ne soit pas la façon optimale d'utiliser FrankenPHP, il est possible de s'en servir comme d'un serveur web classique afin d'expérimenter sa compatibilité avec votre application et avant de vous engager dans la configuration pour un usage en mode worker.
Voici un exemple de docker-compose.yml à utiliser pour cela :
Rien de très élaboré ici. Le seul point d'attention à avoir est le volume à mapper : FrankenPHP attend que l'application réside dans le dossier /app (mappé ici sur le sous-dossier app du dossier courant) et que son entrée publique soit dans /app/public (l'entrée principale de votre application étant alors /app/public/index.php). Il est aujourd'hui habituel, pour une application PHP moderne, de séparer les ressources auxquelles un navigateur accède directement dans un sous-dossier public : cela permet de sécuriser l'application en interdisant l'accès à des fichiers non autorisés. Si ce n'est pas le cas pour votre application, vous devrez alors mapper sur /app/public :
Pour notre premier test, créons un fichier app/public/index.php, sans originalité :
Il n'y a plus qu'à ouvrir un navigateur web sur l'URL : https://localhost. Attention ! Cela ne fonctionnera pas avec l'URL https://127.0.0.1 : le certificat autosigné que Caddy émet n'est pas valide pour cette adresse. Sur la première, vous aurez certainement un avertissement de sécurité tenant au fait qu'il n'y a pas de sens à ce qu'il y ait un tiers de confiance pour une telle adresse. Il suffit d'accepter l'exception de sécurité pour pouvoir continuer.
1.2 Mode worker : première approche
Pour utiliser le mode worker, les choses sont un peu plus complexes. Tout d'abord, il faut modifier le fichier docker-compose.yml pour lui ajouter ces deux lignes :
Cela nous permet de passer une variable d'environnement configurant FrankenPHP. Nous lui indiquons ainsi de ne lancer qu'un unique worker utilisant le fichier index.php. Par défaut, le nombre de workers est égal au nombre de processeurs, mais pour découvrir, je vous recommande d'en limiter le nombre à un : en effet, certains comportements peuvent être plus difficiles à observer si ce n'est pas toujours le même worker qui répond à vos requêtes.
Le fichier index.php que nous avons défini au chapitre précédent ne fonctionnera pas en mode worker. Cela ne signifie pas pour autant que nous ne pourrons pas l'exploiter par la suite : contentons-nous de le renommer en app.php. Puis, créons à nouveau un fichier index.php. Voici son contenu, simplifié au maximum :
02:
03: do {
04: $running = frankenphp_handle_request(function() {
05: include 'app.php';
06: });
07: gc_collect_cycles();
08: } while ($running);
Cette utilisation n'est pas encore optimisée, mais elle nous permet d'expérimenter plus en détail FrankenPHP. Voyons un peu ce qu'il en est avant de passer à un fonctionnement plus performant.
Nous avons donc une boucle do/while destinée à se poursuivre jusqu'à la fin des temps, à moins qu'une erreur ou une interruption se produise. Les développeurs PHP purs y sont rarement accoutumés, il s'agit de la façon habituelle de faire fonctionner un processus serveur, tâche dévolue d'ordinaire au serveur web. À la ligne 4, il est fait appel à la fonction frankenphp_handle_request. C'est elle qui permet à PHP de répondre aux requêtes entrantes de la même façon, pour le navigateur, que lors d'un fonctionnement normal, mais sans jamais interrompre ce process. frankenphp_handle_request reçoit en paramètre un Callable qui doit alors traiter la requête.
Voici une petite expérience qui permettra de mieux ressentir le fonctionnement de ce mode :
- dans la boucle, mettons entre les lignes 4 et 5, ajoutez une instruction suivante :
- redémarrez votre container :
- modifiez le texte émis par votre instruction précédente :
- rechargez la page dans votre navigateur.
Vous verrez alors, avant le traitement de app.php, apparaître le chiffre 1. Vous pourrez modifier autant que vous le voulez le fichier index.php, tant que vous n'aurez pas redémarré votre container, il affichera toujours ce 1 et jamais 2.
Par contre, si vous modifiez le fichier app.php, en modifiant "Hello world !" en "Lisez Linux Pratique !" par exemple, le changement sera reflété dès le premier rechargement de la page.
En fait, FrankenPHP nous permet de distinguer deux ensembles dans notre application :
- les parties immobiles, telles que la configuration ou la connexion à la base de données, qui sont initialisées avant le lancement de la boucle infinie, dès le démarrage du service ;
- et les parties mobiles, qui prennent en charge le traitement de la requête au moment où elle a lieu.
Dans son fonctionnement habituel, PHP réinitialise les parties immobiles à chaque requête : outre la répétition inutile des mêmes actions, cette initialisation est très souvent l'aspect le plus lourd du traitement d'une requête. Le gain de performance est ainsi considérable.
Mais en réalité, FrankenPHP va plus loin que cela, et, même dans cette utilisation non optimisée du mode worker, les améliorations de performances sont à attendre. Je vais vous relater une petite expérience pour vous le montrer. J'étais curieux d'en apprendre plus sur le fonctionnement de la fonction headers_send, proposée par FrankenPHP et dont nous reparlerons plus loin. Cette fonction est évoquée dans la documentation, mais d'une façon tellement succincte que j'avais envie d'en apprendre plus. J'ai donc eu recours aux capacités de réflexion pour l'explorer. J'ai donc modifié mon fichier app.php de la façon suivante [PHP] :
Le résultat m'a laissé un peu sur ma faim :
Aussi ai-je à nouveau modifié mon fichier de cette façon :
Mais j'ai été quelque peu surpris de voir le nouveau résultat :
L'erreur disparaissait après avoir redémarré le container et j'obtenais :
Cela impliquait que la précédente version de ma fonction était conservée en mémoire. Tant que je ne l'avais pas modifiée, FrankenPHP était capable de l'utiliser ; puis, détectant une modification, il a tenté de la charger à nouveau, ce qui a provoqué l'erreur. Pour m'en assurer, je modifiais une dernière fois mon fichier app.php en supprimant la définition de la fonction :
Et je rechargeais ma page : aucune erreur liée à l'utilisation d'une fonction non définie, bien au contraire, tout continuait de fonctionner de la même façon.
Lors de cette expérience, j'ai pu observer que selon le nombre de workers, le résultat pouvait varier : l'erreur d'appel à une fonction non définie se produisait bien, mais pas à chaque fois. On peut en conclure que chaque worker embarque sa représentation de l'application et que, si celle-ci est modifiée sur des éléments structurels tels que les fonctions, les classes, tout en dehors des instructions d'exécution immédiate, des incohérences de comportement peuvent apparaître entre les workers. Il est donc impératif que de telles modifications soient suivies d'un redémarrage du container.
Encore un mot à propos de la fonction gc_collect_cycles, appelée à la ligne 7 du fichier index.php. Il s'agit d'une fonction native de PHP depuis la version 5.3. Pourtant, la plupart des développeurs PHP en sont peu coutumiers. Il s'agit en fait de la fonction utilisée pour appeler le ramasse-miette [RAM], qui permet un nettoyage de la mémoire. Si cette notion est fréquente dans d'autres langages, elle est quasiment inexistante dans l'écosystème PHP. Et pour cause : dans son fonctionnement classique, un script PHP se termine avec le traitement de la requête ; ainsi, le processus créé pour l'occasion est détruit dans la foulée, et la mémoire mobilisée, libérée. Il n'y a donc pas lieu de surveiller les fuites de mémoire, sauf cas exceptionnel. Avec FrankenPHP, le processus initial dure autant que le serveur, la situation est donc toute différente, et il convient de faire le ménage régulièrement.
1.3 Mode worker : approche finalisée
Maintenant que nous avons saisi le concept, reprenons le fichier index.php pour une application plus structurée :
02: require __DIR__.'/vendor/autoload.php';
03:
04: $myApp = new \App\Kernel();
05: $myApp->boot();
06:
07: do {
08: $running = frankenphp_handle_request(function () use ($myApp) {
09: echo $myApp->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
10: });
11:
12: $myApp->terminate();
13:
14: gc_collect_cycles();
15: } while ($running);
16:
17: $myApp->shutdown();
Cet exemple est tiré de la documentation officielle. Nous retrouvons notre boucle infinie des lignes 7 à 15. Les parties immobiles de l'application sont initialisées entre les lignes 2 à 5 : un classique autoload pour les dépendances pour commencer, la création de l'objet $myApp et son lancement. À partir de là, nous disposons, en mémoire, de cet objet pour répondre à toutes les requêtes. Cet objet doit proposer la méthode handle pour traiter les requêtes. Pour la forme, les éléments permettant de gérer la requête sont passés en arguments : c'est une bonne pratique pour avoir un code indépendant de son contexte d'utilisation, mais si votre application y accède directement, ne vous inquiétez pas, ces variables sont correctement initialisées pour chaque requête.
Enfin, ligne 17, l'arrêt de l'application. Tout cela, naturellement, est fourni à titre d'exemple, et il vous faudra l'adapter à votre contexte.
1.4 Mode worker : avec Symfony !
Kévin Dunglas étant un contributeur à Symfony, rien d'étonnant à ce que ce framework soit particulièrement soigné avec FrankenPHP. Du coup, il ne sera pas nécessaire de modifier votre application Symfony pour qu'elle puisse fonctionner dans ce contexte. Vous aurez seulement à installer une dépendance supplémentaire dans votre projet et à mettre à jour la configuration depuis Docker.
La dépendance s'installe à l'aide de composer :
Cela ajoutera le composant Runtime de Symfony [RUN]. Nous n'allons pas nous attarder sur cet élément, qui n'est pas le cœur de notre sujet. Disons simplement qu'il permet de séparer l'initialisation de votre application de son environnement d'exécution, lui permettant ainsi de fonctionner dans des contextes variés. Ce composant est complexe et puissant et mériterait de s'y attarder. Mais nous n'avons pas besoin de maîtriser cet aspect pour utiliser FrankenPHP : notons simplement qu'il existe déjà de nombreux runtimes, permettant d'exécuter votre application dans des contextes variés comme AWS Lambda, Google Cloud, RoadRunner, Swoole... et qu'il vous est possible de développer le vôtre. Ici, nous utilisons celui qui est dédié à FrankenPHP.
Reste à mettre à jour la configuration. Pour cela, il vous faut ajouter la ligne suivante dans la section environment de votre fichier docker-compose.yml :
Redémarrez votre container et voilà !
Enfin, pour en terminer avec le mode worker, notons qu’au moment où j'écris ces lignes, un développement est en cours pour offrir aux applications Laravel la même souplesse d'utilisation avec FrankenPHP.
2. « Early Hints »
FrankenPHP permet une utilisation optimum des Early Hints, fonctionnalité de HTTP/2 pour laquelle PHP se montre sous-performant dans son fonctionnement classique. Elles permettent d'indiquer au navigateur des ressources à télécharger ou des connexions à initialiser, sans attendre, permettant ainsi un traitement parallèle des différents éléments nécessaires pour construire la page. À vrai dire, dans l'écosystème PHP, elles sont le plus souvent négligées, on leur préfère en général l'utilisation de la balise HTML link en utilisant l'attribut rel avec la valeur preload ou preconnect. Il était déjà possible d'utiliser un entête HTTP Link pour indiquer dès le début du chargement les ressources à solliciter, mais le gain de quelques lignes est négligeable, et l'utilisation des entêtes est plus contraignante. Ainsi :
et :
ont des performances quasi identiques pour peu que le echo soit placé tout au début de la section head du document. En effet, PHP n'envoie les entêtes qu'à partir du moment où le document commence à être produit. Il attend le plus tard possible, laissant la possibilité de les manipuler, jusqu'à ce que les premiers caractères soient fournis. L'envoi de ces entêtes est donc retardé jusqu'à ce moment, et il n'est possible de les envoyer qu'une seule fois : si vous appelez la fonction header après l'instruction echo, vous aurez une erreur.
Qui plus est, avec l'architecture proposée par la plupart des frameworks, la production du document proprement dite est l'étape finale du traitement et elle se déroule par l'application de templates alimentés par des variables. En fait, l'application a fini son travail quand elle en arrive là et le navigateur doit attendre jusque-là pour recevoir ces instructions de connexion ou de chargement anticipé.
Le but des Early Hints est d'envoyer une première réponse anticipée en indiquant les ressources pouvant être téléchargées sans attendre. Ils disposent pour cela d'un statut HTTP dédié, le statut 103. Cette réponse parvient au navigateur avant l'envoi de la réponse principale, pour peu que le langage le permette. PHP ne le permet pas, mais FrankenPHP lui offre cette possibilité. En effet, comme il fonctionne lui-même en serveur et qu'il est profondément intégré à Caddy, il lui est possible d'envoyer des entêtes au moment désiré :
À l'aide de cette technique, la documentation de FrankenPHP affirme que le temps de chargement de la page peut être diminué de 30 %, ce qui est loin d'être négligeable.
3. Composants embarqués
Je ne m'attarderai pas longtemps sur ces points, car s'ils contribuent à l'efficacité de FrankenPHP, ils existent indépendamment de lui et ne constituent pas son originalité. Chacun d'eux mériterait un article dans nos colonnes.
3.1 Caddy
Ce fut d'ailleurs le cas pour Caddy [CADDY], le serveur web tout-en-un écrit en Go. Grâce à lui, FrankenPHP se dote d'une configuration facile à maintenir, de la gestion automatique de certificats SSL, du support automatique du HTTPS, du HTTP/1, 2 et 3, etc. Il est rapide, sécurisé, multiplate-forme. Que demander de plus ?
3.2 Mercure HUB
Mercure et Mercure Hub [MER] sont deux autres projets réalisés par Kévin Dunglas. Mercure est un protocole de communication en temps réel, destiné à remplacer l'API Websocket. Mercure Hub est un serveur pour ce protocole, embarqué dans Caddy.
Mercure n'est en réalité pas nécessaire au fonctionnement de FrankenPHP. Il est surtout là pour que votre application puisse en tirer parti sans avoir à l'installer vous-même. Cela se justifie en un sens : avec FrankenPHP, il s'agit d'aller vers les meilleures performances ; quelle application est plus exigeante en termes de performances qu'une application temps réel ?
4. Images Docker
4.1 Utiliser Alpine
L'image principale de FrankenPHP est basée sur Debian. Mais certains utilisateurs exigeants préféreront sans doute une version construite sur Alpine. Pour ceux-là, rassurez-vous, Kévin Dunglas a pensé à vous, et une image Docker vous attend. Pour l'utiliser, rien de plus simple, modifiez la ligne définissant l'image de cette façon :
Et reconstruisez votre container !
4.2 Ajouter des extensions PHP
Les extensions de PHP ne sont pas installées dans l'image de base. Pour pouvoir les utiliser, il vous faut construire votre propre image de FrankenPHP. Mais comme le script docker-php-extension-installer [INST] est, lui, installé par défaut, il est relativement trivial de le faire. Ainsi, il vous suffira d'écrire un Dockerfile ressemblant à celui-ci (à placer, par exemple, dans le dossier dockers/frankenphp de votre projet) :
Il vous suffit d'ajouter le nom de votre extension puis de reconstruire votre image. Naturellement, il faudra mettre à jour votre fichier docker-compose.yml pour qu'il utilise cette image personnalisée en remplaçant la directive image par :
5. Un paquet Go
Cet aspect intéressera plus les développeurs Go que les administrateurs web ou les développeurs PHP, aussi ne m'y attarderai-je pas trop. En effet, au cœur de FrankenPHP est un paquet Go homonyme [GO]. Celui-ci vous permettra d'embarquer un serveur web PHP dans votre application Go. Ce langage étant lui-même extrêmement polyvalent, permettant des développements pour ordinateurs de bureau, serveurs, terminaux mobiles ou embarqués, cela ouvre de nouveaux horizons pour l'écosystème PHP, bien plus larges que le sien aujourd'hui, où il se cantonne aux applications web.
Est-ce le début d'une révolution pour ce langage et le verra-t-on s'épanouir partout ? En attendant, voici un exemple de code illustrant l'utilisation de FrankenPHP depuis Go :
Conclusion
Assemblage de nombreux éléments, FrankenPHP est un monstre de performances, tout en restant compatible avec les applications existantes. Il ne fait pour moi pas de doute que ce type d'architecture, déjà imaginé avec Roadrunner, va se développer dans l'écosystème PHP. Toutefois, les hébergeurs « classiques » risquent de retarder le mouvement en continuant à proposer en premier lieu des offres basées sur Apache, qui ont l'avantage d'être bien connues et éprouvées depuis des années...
Références
[ROAD] Roadrunner a un avantage incontestable par rapport à FrankenPHP : lui est prêt pour la production... Voir https://roadrunner.dev
[API] Il s'agit d'une API REST et GraphQL développée pour Symfony, voir MOUREY S., « API Platform : développez une API REST et GraphQL avec Symfony », GNU/Linux Magazine HS n°124, avril 2023, p. 76 à 86 : https://connect.ed-diamond.com/gnu-linux-magazine/glmfhs-124/api-platform-developpez-une-api-rest-et-graphql-avec-symfony
[CADDY] Voir MOUREY S., « Caddy, un nouveau venu parmi les serveurs web », Linux Pratique n°138, juillet / août 2023, p. 44 à 53 : https://connect.ed-diamond.com/linux-pratique/lp-138/caddy-un-nouveau-venu-parmi-les-serveurs-web
[RAM] Voir la documentation PHP officielle sur le sujet :
https://www.php.net/manual/fr/features.gc.php
[RUN] Voir la documentation officielle de Symfony :
https://symfony.com/doc/current/components/runtime.html
[PHP] En recopiant la documentation PHP officielle de la méthode ReflectionFunction::__construct :
https://www.php.net/manual/fr/reflectionfunction.construct.php
[MER] https://mercure.rocks
[GO] https://pkg.go.dev/github.com/dunglas/frankenphp
[INST] Vous trouverez la liste des extensions installables sur le dépôt du projet :
https://github.com/mlocati/docker-php-extension-installer