Identifier et corriger certains problèmes de performance liés à l’utilisation de frameworks ORM

#framework#ORM#performances

Un outil ORM (Object-Relational Mapping ou mapping objet-relationnel en français) offre de nombreux avantages : gain de temps de développement, lisibilité du code, réduction de répétition du code et possibilité offerte aux développeurs de rester dans le concept objet. Cependant, la solution ORM est souvent pointée du doigt lorsqu’un problème de performance est rencontré. Dans la majorité des cas, c’est une mauvaise utilisation qui en est la cause. Cet article présente, au travers d’exemples, des problèmes rencontrés dus à une mauvaise utilisation de Doctrine (également valable pour les autres produits ORM) et les outils permettant de les identifier et les corriger.

Symfony2 étant un framework PHP très populaire, c’est avec celui-ci que l’exemple est réalisé dans le présent article. Symfony2 utilise par défaut Doctrine et permet de mettre en place facilement et rapidement des formulaires liés à des entités métiers. La base de données retenue est MySQL, fréquemment utilisée en environnement PHP-Symfony2.

Présentation de l’exemple

L’exemple s’articule autour d’un bundle permettant la création d’un produit. Un bundle est un répertoire contenant un ensemble de fichiers et de sous-répertoires permettant d’implémenter une ou plusieurs fonctionnalités, similaire au plugin dans Symfony1. Un produit est représenté par un libellé et est rattaché à une catégorie. Ce bundle contient donc deux entités Doctrine, Produit et Categorie, définies avec des annotations :

Ces entités sont liées par une relation ManyToOne : un produit ne peut avoir qu’une seule catégorie, et une catégorie peut être affectée à plusieurs produits. Le bundle inclut également un contrôleur gérant l’action de création du produit (redirection vers le formulaire, insertion du produit dans la base).

Le formulaire de création doit permettre de saisir le libellé du produit et de sélectionner sa catégorie parmi celles déjà existantes :

Les relations entre les entités Doctrine sont définies de sorte que l’accès à la propriété faisant référence à une autre retourne directement celle-ci et non l’id servant à faire la liaison. La propriété $categorie de l’entité Produit est donc une entité Categorie et non un id. Symfony2 construit alors le formulaire en fonction de ces informations et des paramètres ajoutés par le développeur. Le deuxième paramètre du champ « categorie » étant à « null » (dans la méthode add() de l’objet form), Symfony2 utilise par défaut le type de champ le plus adapté : comme il s’agit d’une entité, il crée une liste déroulante contenant toutes les catégories existantes.

Dans la vue Twig (moteur de template PHP intégré dans Symfony2), un champ texte pour le libellé du produit et une liste déroulante pour la sélection de la catégorie sont affichés avec {{ form(form) }} (figure1).

Figure 1

Figure 1

Au fur et à mesure de l’avancement du projet, le contenu de la table « categorie » grossit et contient plusieurs milliers d’éléments. Une liste déroulante n’est alors plus adaptée, sélectionner une entrée parmi une liste très longue étant pénible. On change donc son mode de sélection, avec un champ texte pour saisir l’identifiant de la catégorie souhaitée.

A la soumission du formulaire on obtient une simple requête d’insertion (figure2).

Figure 2

Figure 2

Pour simplifier la récupération de cet identifiant, on peut ajouter un champ texte permettant cette fois de saisir le libellé de la catégorie avec une fonction d’autocomplétion. C’est cette fonction d’autocomplétion qui sera chargée de renseigner le bon identifiant dans un champ caché (figure3).

Figure 3

Figure 3

L’affichage du formulaire est adapté pour l’utilisateur. Cependant, la page met plusieurs secondes à s’afficher alors que rien n’est affiché par défaut (l’autocomplétion s’activant seulement après la saisie d’au moins un caractère).

Outils permettant d’identifier le problème

Pour nous aider à identifier le problème, plusieurs outils sont à notre disposition :

  • la barre du développeur de Symfony2 : disponible en bas de chaque page en environnement de développement, elle affiche un résumé des données de profilage comme le nom de la route utilisée, la mémoire utilisée, les requêtes SQL, …
  • xdebug : pour le debogage pas à pas mais aussi pour la génération de fichiers de profilage. Le profilage est activé en mettant le paramètre xdebug.profiler_enable à 1 dans php.ini.
  • WinCacheGrind (sous Windows) ou KCacheGrind (sous Linux) : pour l’analyse des fichiers de profilage générés par xdebug.
  • le profilage de MySQL : pour les projets n’utilisant pas Symfony2 (et donc ne disposant pas de la barre du développeur), les requêtes SQL peuvent être récupérées dans un fichier de log. Il suffit d’ajouter les options general_log=1 et general_log_file= »chemin_du_fichier/query.log » sous la section [mysqld] du fichier de configuration de MySQL.

La barre du développeur de Symfony2 a permis de mettre en évidence la récupération de la table « categorie » (figure4).

Figure 4

Figure 4

L’analyse de la trace générée par xdebug montre la génération d’une liste déroulante ainsi que le temps passé dessus. Le chargement de cette liste comprend la récupération et l’hydratation des données, ainsi que l’initialisation de la liste (figure5).

Figure 5

Figure 5

Le problème vient donc de la génération de cette liste déroulante contenant l’ensemble des catégories. L’erreur est d’avoir changé uniquement la vue Twig mais pas l’objet form, ce dernier permettant de construire le formulaire. En conséquence, la liste des catégories est de toute manière récupérée et instanciée même si elle n’est pas affichée dans la vue.

Proposition de correction

Une solution consiste à utiliser les convertisseurs de données de Symfony2. Les convertisseurs de données transforment une chaîne de caractères (par exemple, saisie par l’utilisateur) en une entité et inversement. Ceci permet d’avoir un simple champ texte pour la saisie des catégories en créant un nouveau type de champ héritant du champ texte et utilisant le convertisseur.

Tout d’abord, il faut créer le convertisseur qui sera donc chargé de convertir les données :

Il faut ensuite créer un nouveau type de champ de formulaire héritant du champ texte et utilisant notre convertisseur, et le déclarer en tant que service (nécessaire car une connexion à la base de données est requise). Avec l’injection de dépendance de Symfony2, l’ObjectManager sera passé en paramètre dans le constructeur de notre classe.

Enfin, il faut utiliser ce type pour le champ ‘categorie’ :

Avec ces modifications, la liste déroulante créée par défaut n’existe plus, de même que le problème de lenteur au chargement de cette page.

Problème dû au lazy loading

Il convient de faire attention également au lazy loading, fonctionnalité activée par défaut dans la plupart des outils ORM. Le lazy loading permet d’initialiser un objet seulement lorsqu’on y accède (par exemple via un accesseur). Mal utilisé, il peut rendre l’application moins performante en multipliant le nombre de requêtes effectuées. Prenons l’exemple d’une page d’affichage d’un produit avec sa catégorie :

Dans cet exemple, avec le lazy loading activé, le premier appel génère une requête uniquement pour le produit et hydrate l’objet $produit avec les données récupérées. Lorsque $produit->getCategorie() est appelé, Doctrine génère une deuxième requête pour récupérer la catégorie associée au produit. Pour l’affichage d’une page de liste des produits avec leur catégorie, une requête est donc lancée pour chaque produit. Si la page doit afficher 20 produits, il y aurait au minimum 21 requêtes, ce qui est beaucoup trop ! En SQL, la récupération de ces données se fait en une seule requête avec une jointure.
Avec Doctrine, pour forcer la jointure, il suffit de rajouter l’option fetch= »EAGER » sur la définition entre les entités. À chaque fois qu’un produit sera récupéré, la requête utilisera une jointure. Une seule requête sera donc lancée pour afficher la page de liste des 20 produits.

Avec cet exemple, on peut penser qu’il vaut mieux systématiquement désactiver le lazy loading. Attention cependant, car son comportement peut se révéler utile. En effet, si l’option « EAGER » est ajoutée sur toutes les définitions des relations, des jointures sont alors générées dès que l’on accède à des entités, y compris si on ne le souhaite pas. Par rapport à l’exemple ci-dessus, en supposant que l’on ne veuille pas afficher la catégorie d’un produit, l’ajout de l’option fetch= »EAGER » va créer une requête avec jointure vers la catégorie alors que l’on n’a pas besoin de celle-ci. Les conséquences sont un temps de réponse plus long et une allocation mémoire trop importante par rapport au besoin.

Conclusion

Dans la plupart des cas, les problèmes de performance sont liés à la mauvaise utilisation des solutions ORM. Même si un de leurs avantages est de permettre aux développeurs de rester dans le concept objet, il n’empêche que leur utilisation nécessite un minimum de connaissances en SGBDR et donc en SQL. Il convient donc de vérifier les requêtes générées par l’outil ORM et voir si elles correspondent à celles que l’on écrirait de manière optimisée en SQL.

Il est possible de retrouver l’intégralité de cet article dans le numéro 171 du mois de Février 2014 du magazine « Programmez ».