Attributing Code @php[architect]
Volume 20 - Numéro 8 (08/2021) by Chris Tankersley - USA / Traduit par Jon, relecture et correction par Pierre Crespel
Une des fonctionnalités parmi les plus demandées en PHP depuis longtemps a été le concept d'Annotation. Les annotations permettent aux développeurs d'ajouter une description additionnelle, et plus important, et plus concrètement coder sans utiliser une approche procédurale. Beaucoup d'articles aime dire que c'est 'un ajout de métadonnées' au code, mais je ne pense pas qu'il faut résumer cette fonctionnalité à cela.
Les annotations permettent à un développeur d'ajouter des fonctionnalités supplémentaires en utilisant des mots clés sur des classes, méthodes, propriétés et fonctions. On peut dire que c'est des 'métadonnées' puisqu’à la lecture du code ils ajoutent une description en plus, et à l'exécution, ces annotations vont apporter du plus. Mais pour l'auteur c'est bien plus que 'l'ajout de de métadonnées'.
De nombreux langages utilisent les annotations. Comme Java qui lors de la compilation peut tagger du code comme étant déprécié (@Deprecated) ou qu'une méthode a été surchargée (@Override). Ces annotations ne changent pas nécessairement la façon dont le code s'exécute, mais ils peuvent être utilisés par des outils comme le compilateur pour lancer des erreurs de compilation.
D'autres annotations peuvent ajouter des fonctionnalités supplémentaires au code où ils sont déclarées. Les développeurs peuvent créer leurs propres annotations et créer des outils pour lire ces annotations. L'API Persistence de Java permet d'ajouter des annotations pour préciser différents paramètres comme le nom de la table, les colonnes et le type de données.
Ne pas confondre avec les décorateurs Python, qui utilise aussi la syntaxe @. L'auteur a fait un article sur le sujet précédemment.
Les annotations, quelle que soit le langage, ne sert à rien tant qu'il n'est pas analysé par un outil. Typiquement le langage lui même n'interprétera pas les annotations. Par exemple, L'API Persistence de Java ne fera rien tant que cette API n'analyse pas le code. Sans cette analyse, ce n'est que des commentaires.
Le langage PHP implémente souvent les bonnes idées des autres langages, et à raison. Les annotations permettent aux développeurs d'ajouter des contexte ou des fonctionnalités supplémentaires au code d'autres composants sans complexité.
PHP 8 a introduit les annotations et leurs prises en charge sous la forme des attributs.
Les annotations à l'ancienne
Les annotations existent depuis un moment, mais comme utilisateur d'un composant. Le langage PHP en interne n’interprète pas les annotations, ou les attributs comme appelés depuis la version 8. Ils sont issus de l'extension de DocBlock, dont la syntaxe est inspirée des annotations Java.
Si vous utilisez Symfony ou Doctrine, vous avez déjà utilisés les annotations, dont les balises personnalisées vont être analysés via l'API Reflection et effectuer un certain nombre de chose.
Doctrine utilise des balises DocBlock pour dire comment les entités sont définies, c'est à dire comment les propriétés de la classe ont leurs correspondances avec les champs de la base de données, et comment cette entités interagissent avec les autres entités.
L'API Reflection en PHP interprète tous commentaires sur les classes, méthodes, attributs et fonctions. Voir le Listing 1.
Les balises @ORM sont des exemples d'annotations. Les annotations @ORM\Table permettent à Doctrine de savoir quel est le nom de table SQL associé à l'entité. Et Doctrine comprend que l'attribut $id de l'entité est un integer, qui est considéré comme la clé primaire de la table et qui comme tel est auto-incrémenté. Si vous avez déjà écrit des contrôleurs Symfony, vous avez sûrement utilisé l'annotation @route pour spécifier la route URL et d'autres informations relatives aux contrôleurs.
Dans le cas de Doctrine, une classe lecteur d'annotation extrait et interprète les annotations en objet PHP. Les annotations personnalisées sont extraites et traduites en classes PHP par Doctrine. L'annotation @ORM\Table est mappée avec la classe Doctrine\ORM\Mapping\Table. Toutes propriétés additionnelles ajoutées dans l'annotation sont transmises au constructeur de la classe Table.
Comment les annotations fonctionnent
Nous pouvons écrire un interpréteur d'annotation basique en utilisant les regex.
Notre interpréteur s'attendra à ce que l'annotation donne le nom de la classe pleinement qualifié (FQCN, c'est à dire avec son namespace, utilisation : class_name::class). De plus, l'objet ainsi créé prend un seul paramètre obligatoire dans le constructeur. On s'attendra à ce que l'annotation soit au dessus d'une méthode dans un objet tel que dans le Listing 2.
Commençons à décortiquer tout cela. Tout d'abord, nous avons notre classe AnnotationReader. Comme l'auteur l'a déjà mentionné, les annotations en elles-mêmes ne font rien. Il faut quelque chose qui puisse analyser les annotations et faire appel à la logique qui leur est associée. C'est précisément ce que fera notre classe AnnotationReader via sa méthode parse().
Nous pouvons passer une classe à analyser en appelant parse(). Examinons un exemple de classe (Listing 3) que nous pouvons utiliser :
Cette classe n'est pas compliquée du tout. Nous avons une seule méthode myAction() qui, lorsqu'elle est appelée, retourne "Hello World". Nous y avons attaché une annotation au format attendu (nom de la classe et un seul paramètre) dans le DocBlock de la méthode myAction(). Notre lecteur se charge d'analyser le DocBlock et de rechercher l'annotation.
Si nous revenons à la méthode parse() de l'AnnotationReader, nous configurons d'abord une expression rationnelle qui recherche le format attendu. Il y a deux groupes de capture. Le premier capture le nom de la classe et le second capture le paramètre que nous voulons passer à l'annotation.
Dans ce cas, pour @Route('/'), nous capturons Route comme nom de classe et '/' comme paramètre. Pour des raisons de simplicité, nous prenons tout ce qui se trouve entre les parenthèses comme un paramètre unique.
Nous passons ensuite le nom de la classe à l'API de Reflection. Puisque nous inspectons une classe, nous créons un nouvel objet ReflectionClass. Nous pouvons ensuite fouiller dans cet objet et voir ce qui s'y trouve. Nous les attendons sur les méthodes, donc on commence à boucler sur toutes les méthodes de la classe.
Puisque les annotations existent dans les commentaires d'une méthode, nous pouvons les extraire en utilisant la méthode getDocComment() de ReflectionClass sur l'objet $method du foreach. Cela renvoie une chaîne contenant tous les commentaires. L'API Reflection n'a aucune autre connaissance des annotations, comme le moteur PHP n'a aucun connaissance de ce type de commentaire, c'est à dire les annotations, nous sommes donc contraints de faire une analyse syntaxique des chaînes de caractères (d'où l'utilisation d'une regex).
Nous explode() les lignes et lisons chaque commentaire ligne par ligne. Supposons que nous tombions sur une chaîne qui corresponde à notre regex. Dans ce cas, nous utilisons $matches[1] pour obtenir le nom de la classe pour l'annotation (encore une fois, dans notre exemple, cela correspond à Route). Ensuite, nous utilisons $matches[2] comme paramètre à passer ('/'), avec les guillemets autour. Ce n'est pas idéal, mais c'est correct pour un exemple ! Nous enlevons tous les guillemets simples ou doubles que nous trouvons
dans $matches[2] avant de passer le tout dans notre annotation.
Puisque nous faisons correspondre un chemin URL à un objet PHP et une méthode, nous devons également indiquer à notre classe d'annotation quelle classe et méthode est à appeler. Nous pouvons utiliser l'array avec une fonction de rappel pour le faire. Mais à quoi ressemble cette classe Route ? Regardez le Listing 4 :
Route est une classe assez basique. Elle prend la route que nous voulons faire correspondre et le callback que nous voulons exécuter. Nous pouvons ensuite faire appel à ce callback en utilisant la méthode execute(). C'est tout ce qu'il y a à faire. Notre AnnotationReader crée un tableau de ces routes au fur et à mesure qu'il analyse les classes et stocke les informations pour plus tard.
Que faisons-nous après avoir empilé toutes les routes ? Nous pouvons utiliser une simple classe Router (Listing 5) pour prendre en compte une route et exécuter l'objet et la méthode s'ils correspondent !
Nous pouvons créer un nouveau Router, passer les routes depuis l'AnnotationReader, puis appeler route() avec une route que nous pourrions obtenir avec quelque chose comme $_SERVER['REQUEST_URI']. Dans la pratique, cela pourrait ressembler à quelque chose comme ceci :
Ce changement améliore considérablement la lisibilité du code. Notre logique métier est condensée dans l'AnnotationReader, qui peut analyser n'importe quelle classe, et un Router qui peut exécuter cette annotation. Ce qui constitue une route est laissé aux classes elles-mêmes. Nous n'avons pas besoin de compiler nous-mêmes une liste de routes dans un fichier de configuration par exemple. Nous pouvons garder cette information dans les classes qui sont exécutées.
L'inconvénient est que du code développé en PHP est plus lent à s'exécuter que du code intégré au moteur PHP, et l'API de Reflection elle-même est déjà un sous-système lent. Si nous devons analyser les annotations à chaque requête, nous pouvons sérieusement ralentir les choses.
Des framework comme Symfony font remarquer que les annotations sont lentes par rapport aux autres options de configuration. Dans un environnement de production, Symfony met massivement en cache ces informations pour éviter d'analyser le code à chaque requête.
En plus de cela, toute l'analyse est un simple « parsing » de chaîne de caractère. Ce qui veut dire que de nombreuses regex d'analyse d'annotation ne vont pas correspondre. Si vous ajoutez plusieurs paramètres à l'annotation, des chaînes de caractères pouvant avoir des possibilités multiples, des caractères spéciaux, et autres dans l'équation, les regex peut être incroyablement complexes. L'analyse syntaxique de la chaîne de caractères peut être lente en soi, mais, la plupart du temps, les développeurs ont eu une mauvaise expérience en écrivant des expressions régulières complexes.
Les attributs
PHP 8 a introduit les attributs. Ce sont les annotations que je viens de décrire, mais avec une syntaxe légèrement différente. Il ajoute également une métode à l'API Reflection qui permet aux développeurs d'extraire les attributs et leurs informations de manière native.
Le concept général reste toutefois le même. Les attributs peuvent être ajoutés aux classes, méthodes, propriétés et fonctions. Le moteur PHP ne fait rien d'autre que de faciliter et d'accélérer la recherche et l'analyse des attributs, mais ne fait rien pour les exécuter. A moins qu'un bloc de code n'agisse sur les attributs, ils ne sont guère plus que des commentaires.
La principale différence avec les attributs est la syntaxe. Il n'utilise pas la syntaxe DocBlock mais la syntaxe Rust Attribute de #[Nom de la classe(paramètres)]. Vous échangerez @ pour # et vous mettrez l'appel de la classe entre crochets, comme dans le Listing 6.
D'un point de vue syntaxique, c'est très proche de ce que nous avions auparavant. L'utilisation de la syntaxe @ existante entraînait des complications avec le code de suppression des erreurs qui existait déjà dans le
moteur. La syntaxe d'origine qui a été acceptée dans le cadre de la RFC originelle a fini par rencontrer d'autres problèmes concernant l'analyse syntaxique interne (ainsi qu'un énorme tollé public par rapport à la syntaxe en elle-même). En fin de compte, le CORE a opté pour la syntaxe de style Rust, car elle causait le moins de problèmes internes et était déjà acceptée dans un autre langage.
Finalement, nous n'avons pas besoin de changer beaucoup notre code pour travailler avec des attributs. La première chose à faire est de créer un Attribut (Listing 7).
Il s'agit d'une classe qui a été marquée explicitement comme un attribut et qui contient toutes les informations que nous passons dans
l'attribut. Par exemple, nous voulons qu'un attribut contienne les informations de notre route. Nous allons également commencer à utiliser des espaces de noms afin d'avoir des noms conviviaux.
La première chose à noter est qu'il ne s'agit pas d'une classe qui contient la logique métier liée à la route. Cette classe est simplement un espace réservé qui contient les informations qu'un.e développeur.euse souhaite stocker avec l'attribut lui-même. Puisque notre attribut n'attend qu'un seul paramètre, nous n'acceptons que le chemin que nous passons. Ceci diffère légèrement de notre annotation, qui est liée directement à notre objet Route.
La deuxième chose à noter est qu'un attribut est une classe avec l'attribut #[Attribute]. Cela permet au moteur PHP d'invoquer cette classe comme un attribut. Vous pouvez également spécifier les différents endroits où l'attribut est considéré comme valide.
Dans notre cas, nous voulons que l'Attribut soit uniquement sur une méthode (même pas une fonction), nous passons Attribute::TARGET_METHOD dans l'invocation de [#Attribute()].
Nous pouvons apporter une légère modification syntaxique à notre contrôleur. Nous allons passer à l'utilisation de la syntaxe Rust/nouvelle syntaxe PHP Attribute pour lier notre méthode à une route. Voir le Listing 8. Puisque tout ceci est à présent intégré au langage, nous pouvons l'appeler comme n'importe quel autre constructeur. J'utiliserai la syntaxe du paramètre nommé, mais vous pouvez aussi utiliser la syntaxe positionnelle.
Ensuite, laissons tomber le AnnotationReader et déplaçons la logique dans le Router. Nous pouvons alors enregistrer directement les contrôleurs et extraire les routes, comme indiqué dans le Listing 9.
Puisque les attributs sont intégrés à l'API de Reflection, nous pouvons nous débarrasser de notre expression régulière. L'API de Reflection dispose désormais de plusieurs méthodes d'extraction des attributs, comme getAttributes() que nous utilisons ci-dessus. Puisque notre routeur ne s'intéresse qu'aux routes, nous pouvons transmettre un nom de classe d'attributs pour ne renvoyer que ces attributs ! Gardez à l'esprit que, pour des raisons de conflit de nommage, j'ai renommé l'attribut Route en RouteAttribute dans la classe Router. Il existe un autre objet Route - comme celui de l'exemple Annotation - déjà dans l'espace de noms.
La méthode getAttributes() retourne un tableau d'attributs qui correspondent. Nous n'en attendons qu'un seul, nous pouvons donc extraire la première instance de cet attribut en utilisant
$attributes[0]->newInstance();. Cela renvoie un objet contenant notre classe d'attribut de route et toutes les informations qui ont été transmises. Nous pouvons alors créer un objet Route approprié comme nous l'avons fait avec l'exemple d'annotation et le stocker. Nous pouvons ensuite essayer de l'invoquer en utilisant la méthode route() du Router.
La logique est presque la même que pour les annotations. Nous devons toujours analyser un objet via l'API de Reflection, mais celle-ci est est bien mieux équipée pour gérer les attributs. Nous avons la syntaxe PHP native pour définir les attributs et les lier au code, ce qui signifie qu'il n'y a plus d'expressions régulière.
Pour terminer, notre classe Route (Listing 10), qui gère l'invocation du contrôleur, et autre, reste inchangée, à part le renommage du namespace.
La logique métier de notre script est presque la même (Listing 11), sauf que nous avons laissé tomber l'AnnotationReader car cette logique existe maintenant dans le routeur.
De mon point de vue, le code est mieux écrit avec les Attributs. Nous avons un emplacement approprié pour les informations de l'attribut. Puisque l'API de Reflection comprend les attributs, la logique que nous devons construire dans nos analyseurs est beaucoup plus facile à écrire et à comprendre. Nous devons toujours analyser les attributs, mais il y a beaucoup moins de choses à faire lorsqu'il s'agit d'attributs plus complexes.
Quelques réflexions pour finir
Les attributs ne sont pas adaptés à tous les codes sources. Il existe de nombreuses bibliothèques où cela a du sens, comme avec Doctrine ou des frameworks comme Symfony. Cela peut réduire considérablement le temps de développement et est standard dans beaucoup de domaines. Puisque les attributs ne sont que des classes PHP, vous pouvez leur faire faire toutes sortes de choses logiques lorsqu'ils sont appelés, et pas seulement les stocker comme nous l'avons fait ici.
Le principal inconvénient est qu'ils nécessitent toujours un analyseur syntaxique à un certain niveau. Ce serait génial si on pouvait simplement attacher un attribut à une méthode, et celle-ci serait automatiquement appelée, comme par magie. Cependant, le moteur aurait besoin d'une configuration importante pour savoir comment gérer les attributs, ce qui revient à avoir besoin d'analyseurs syntaxiques de toute façon.
Jetez un coup d'œil aux attributs et voyez comment ils peuvent être utiles. De plus en plus de bibliothèques sont déjà en train de passer à la nouvelle syntaxe. Ils ne sont donc pas près de disparaître, et il y a de fortes chances qu'ils puissent vous aider à organiser votre code. Leur utilité a été démontrée dans de nombreux langages, et heureusement, PHP les a maintenant en natif.