Ajout d'un collecteur de données personnalisé dans la barre de debug Symfony

Publié le 23/07/2020 • Mis à jour le 23/07/2020

Dans cet article nous allons voir comment ajouter un collecteur de données personnalisé dans la barre de debug Symfony. La barre de debug aussi appelée profileur, est l'un des composants les plus utiles car elle est d'une grande aide pendant le développement. Nous allons voir un cas concret nous aidant à améliorer le SEO d'un site en affichant des métas informations à propos de la page courante. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

Prérequis

Je présumerai que vous avez les compétences de base concernant Symfony et que vous savez comment créer une application et y accéder avec l'environnement de debug activé.

Configuration

Rien de particulier ici, mais pour avoir le profileur, vous devez avoir la dépendance de développement composer symfony/profiler-pack installée.

  • PHP 7.4
  • Symfony 5.2.0-RC2
  • "symfony/profiler-pack": "*" (composer)

Introduction

Le SEO (🇬🇧 Search Engine Optimization) est crucial de nos jours puisqu'il détermine la quantité de visiteurs arrivant sur un site par les moteurs de recherche comme Google. Être sur la première page de résultats pour des mots-clés donnés est quelque chose qui peut rendre une entreprise prospère ou pas. J'ai récemment été audité par les experts SEO Pragm et ils m'ont donné plusieurs conseils. L'un d'entre eux est de surveiller le contenu et la longueur du titre (le tag HTML <title>) et de la description de la page (le tag HTML <meta> ayant la valeur "description" pour l'attribut name).

But

Le but va être de récupérer le contenu du titre et de la description pour vérifier qu'ils respectent certaines règles :

  • Le titre doit faire entre 30 et 65 caractères.
  • La description doit faire entre 120 et 155 caractères.

Bien sûr, ces deux informations doivent être présentes. Une page sans titre ni description est le pire cas. Donc, le nouveau panneau que nous allons construire va nous indiquer la valeur du titre et de la description ainsi que leurs longueurs respectives.

Le layout

Tout d'abord, voyons comment est affiché le titre et la description dans le layout (🇫🇷 template de base de l'application) :

    <title>{% block title %}{{ 'head_meta_title'|trans({'%app_name%': app_name})|seo_title }}{% endblock title %}</title>
    <meta name="description" content="{% block description %}{{ 'head_meta_description'|trans({'%app_name%': app_name})|seo_description|raw }}{% endblock %}" />

Comme vous pouvez le voir, dans les deux cas, on définit un bloc, puis, on récupère une traduction, enfin; on applique un filtre Twig. Nous verrons ces filtres un peu plus tard. Donc, pour chaque page, on redéfinit le bloc en utilisant un contexte de traduction différent pour avoir les bonnes valeurs pour la page courante. Utiliser des blocs est très pratique puisque ça nous permet de les réutiliser à d'autres endroits pour d'autres tags métas, comme ici pour Open Graph et Twitter :

    <meta property="og:title" content="{{ block('title') }}" />
    <meta property="og:description" content="{{ block('description') }}" />
    <meta name="twitter:title" content="{{ block('title') }}" />
    <meta name="twitter:description" content="{{ block('description') }}" />

Le collecteur de données

La première étape est de créer un collecteur de données personnalisé. Si on utilise l'auto-wiring on a rien à faire et la classe sera automatiquement détectée par Symfony. Quelques explications après le code :

<?php declare(strict_types=1);

// src/DataCollector/SeoCollector.php

namespace App\DataCollector;

use App\Twig\Extension\SeoExtension;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use function Symfony\Component\String\u;

final class SeoCollector extends DataCollector
{
    private const MAX_PANEL_WIDTH = 50;
    private const CLASS_ERROR = 'red';
    private const CLASS_WARNING = 'yellow';
    private const CLASS_OK = 'green';

    public function collect(Request $request, Response $response, \Throwable $exception = null): void
    {
        $crawler = new Crawler((string) $response->getContent());

        // Title ———————————————————————————————————————————————————————————————
        $titleTag = $crawler->filter('title');
        if ($titleTag->count()) {
            $titleStr = u($titleTag->text());
            $titleSize = $titleStr->length();
            $titleInfo = [
                'value' => $titleStr->wordwrap(self::MAX_PANEL_WIDTH)->toString(),
                'size' => (string) $titleSize,
                'status' => $this->getTitleClass($titleSize)
            ];
            $this->data['title'] = $titleInfo;
        }

        // Description —————————————————————————————————————————————————————————
        $meta = $crawler->filterXPath('//meta[@name="description"]');
        if ($meta->count()) {
            $descriptionStr = u((string) $meta->attr('content'));
            $descriptionLength = $descriptionStr->length();
            $descriptionInfo = [
                'value' => $descriptionStr->wordwrap(self::MAX_PANEL_WIDTH)->toString(),
                'size' => (string) $descriptionLength,
                'status' => $this->getDescriptionClass($descriptionLength)
            ];
            $this->data['description'] = $descriptionInfo;
        }
    }

    private function getTitleClass(int $size): string
    {
        if ($size === 0) {
            return self::CLASS_ERROR;
        }

        return $size >= SeoExtension::MIN_TITLE_LENGTH && $size <= SeoExtension::MAX_TITLE_LENGTH ? self::CLASS_OK : self::CLASS_WARNING;
    }

    private function getDescriptionClass(int $size): string
    {
        if ($size === 0) {
            return self::CLASS_ERROR;
        }

        return $size >= SeoExtension::MIN_DESCRITION_LENGTH && $size <= SeoExtension::MAX_DESCRITION_LENGTH ? self::CLASS_OK : self::CLASS_WARNING;
    }

    /**
     * @return array<string,string>
     */
    public function getTitle(): array
    {
        return $this->data['title'] ?? [];
    }

    /**
     * @return array<string,string>
     */
    public function getDescription(): array
    {
        return $this->data['description'] ?? [];
    }

    public function reset(): void
    {
        $this->data = [];
    }

    public function getName(): string
    {
        return self::class;
    }
}

La principale fonction de notre nouveau collecteur est collect(). Elle est responsable de rassembler les nouvelles données. Pour ce faire, on ajoute des clés / valeurs à l'attribut data.

Dans notre cas, nous ajoutons deux nouvelles clés : title et description. Pour chacune, nous avons trois informations :

  • value : La valeur courante de la chaîne
  • size : Sa longueur
  • status : Le status de la longueur, on a trois cas; OK, avertissement ou erreur

Pour ces deux informations on applique le même procédé. D'abord, on récupère la chaîne à analyser à partir du contenu de la réponse grâce au crawler Symfony. Si elle existe, on calcule les différentes informations que nous avons à afficher dans le panneau de debug; sinon, la clé reste vide. Comme vous pouvez le voir, j'utilise massivement le nouveau composant string pour toutes les manipulations impliquant des chaînes de caractères; c'est tellement plus pratique que d'utiliser les fonctions PHP standards.

OK, maintenant que nous avons récupéré les nouvelles données, voyons comment afficher ces informations dans la barre de debug.

Le panneau de debug

Le panneau de debug est un template Twig qui étend le template Symfony WebProfiler/Profiler/layout.html.twig. Voici son contenu :

{% extends '@WebProfiler/Profiler/layout.html.twig' %}

{# templates/data_collector/seo_collector.html.twig #}

{% block toolbar %}
    {% set icon %}
        <span class="sf-toolbar-value"><i class="fad fa-vector-square"></i> SEO</span>
    {% endset %}

    {% set text %}
        <div class="sf-toolbar-info-piece">
            <b>Title length ({{ constant('App\\Twig\\Extension\\SeoExtension::MIN_TITLE_LENGTH') }} > {{ constant('App\\Twig\\Extension\\SeoExtension::MAX_TITLE_LENGTH') }})</b>
            {% set status = collector.title['status'] is defined ? collector.title['status'] : 'red' %}
            <span class="sf-toolbar-status sf-toolbar-status-{{ status }}">
                {% if collector.title is not empty %}
                    {{ collector.title['size'] }}
                {% else %}
                    <span>Title is empty.</span>
                {% endif %}
            </span>
        </div>

        {% if collector.title is not empty %}
            <div class="sf-toolbar-info-piece">
                <b>Title</b>
                <span>{{ collector.title['value']|nl2br|raw }}</span>
            </div>
        {% endif %}

        <div class="sf-toolbar-info-piece">
            <b>Description length ({{ constant('App\\Twig\\Extension\\SeoExtension::MIN_DESCRITION_LENGTH') }} > {{ constant('App\\Twig\\Extension\\SeoExtension::MAX_DESCRITION_LENGTH') }})</b>
            {% set status = collector.description['status'] is defined ? collector.description['status'] : 'red' %}
            <span class="sf-toolbar-status sf-toolbar-status-{{ status }}">
                {% if collector.description is not empty %}
                    {{ collector.description['size'] }}
                {% else %}
                    <span>Description is empty.</span>
                {% endif %}
            </span>
        </div>

        {% if collector.description is not empty %}
            <div class="sf-toolbar-info-piece">
                <b>Description</b>
                <span>{{ collector.description['value']|nl2br|raw }}</span>
            </div>
        {% endif %}
    {% endset %}

    {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: false }) }}
{% endblock %}

On doit associer ce template au tag data_collector. Pour ce faire, on ajoute l'entrée suivante dans le fichier services.yml. Veuillez noter qu'ici que j'utilise la FQCN de la classe du collecteur comme identifiant (id:), ça permet d'utiliser la constante self::class dans la fonction getName() et évite à la configuration de contenir des chaînes arbitraires comme identifiant.


    App\DataCollector\SeoCollector:
        tags:
          -
            name:     data_collector
            template: 'data_collector/seo_collector.html.twig'

Quelques explications à propos du template; on redéfinit le bloc toolbar dans lequel on définit deux variables : icon et text. text est l'icône qui est affichée dans la barre de debug et qu'il faut survoler avec la souris afin de voir le détail du panneau. text représente le contenu du panneau. Ces deux variables sont utilisées dans le template WebProfiler/Profiler/toolbar_item.html.twig qui est inclus tout en bas. On peut identifier dans le contenu de la variable text quatre éléments distincts :

  • La longueur du titre avec la taille recommandée
  • Le titre
  • La longueur de la description avec la taille recommandée
  • La description.

Pour les deux éléments correspondant aux longueurs, on remarque que l'on assigne un statut (sf-toolbar-status-{{ status }}). Il permet de spécifier la couleur de fond de la taille (il a été préparé dans le collecteur de données), rouge indique un texte manquant, jaune indique que la taille est soit trop courte soit trop longue. Finalement, vert indique que la longueur correspond bien aux recommandations. Voici à quoi ressemble la barre de debug avec la nouvelle entrée :


La barre de debug Symfony avec la nouvelle entrée SEO.

Une fois déplié, nous voyons le détail du panneau :


Le contenu du panneau SEO

La couleur de fond de l'icône correspond au "pire" status des entrées que contient le panneau relatif. Si vous avez au moins une entrée de type erreur ce sera rouge. Si vous avez au moins une entrée de type avertissement ce sera jaune, sinon ce sera vert. Symfony gère cela de manière automatique. Veuillez noter que comme nous avons passé le paramètre { link: false } au template toolbar_item.html.twig, nous n'avons pas ce nouveau panneau quand on accède au profileur en pleine page (https://127.0.0.1:8000/_profiler/b8fbf5).

Cliquez ici pour voir le contenu du template toolbar_item.html.twig.
<div class="sf-toolbar-block sf-toolbar-block-{{ name }} sf-toolbar-status-{{ status|default('normal') }} {{ additional_classes|default('') }}" {{ block_attrs|default('')|raw }}>
    {% if link is not defined or link %}<a href="{{ url('_profiler', { token: token, panel: name }) }}">{% endif %}
        <div class="sf-toolbar-icon">{{ icon|default('') }}</div>
    {% if link|default(false) %}</a>{% endif %}
        <div class="sf-toolbar-info">{{ text|default('') }}</div>
</div>
Cliquez ici pour voir le contenu du template WebProfiler/Profiler/layout.html.twig.
{% extends '@WebProfiler/Profiler/base.html.twig' %}

{% block body %}
    {{ include('@WebProfiler/Profiler/header.html.twig', with_context = false) }}

    <div id="summary">
        {% block summary %}
            {% if profile is defined %}
                {% set request_collector = profile.collectors.request|default(false) %}
                {% set status_code = request_collector ? request_collector.statuscode|default(0) : 0 %}
                {% set css_class = status_code > 399 ? 'status-error' : status_code > 299 ? 'status-warning' : 'status-success' %}

                <div class="status {{ css_class }}">
                    <div class="container">
                        <h2 class="break-long-words">
                            {% if profile.method|upper in ['GET', 'HEAD'] %}
                                <a href="{{ profile.url }}">{{ profile.url }}</a>
                            {% else %}
                                {{ profile.url }}
                                {% set referer = request_collector ? request_collector.requestheaders.get('referer') : null %}
                                {% if referer %}
                                    <a href="{{ referer }}" class="referer">Return to referer URL</a>
                                {% endif %}
                            {% endif %}
                        </h2>

                        {% if request_collector and request_collector.redirect -%}
                            {%- set redirect = request_collector.redirect -%}
                            {%- set controller = redirect.controller -%}
                            {%- set redirect_route = '@' ~ redirect.route %}
                            <dl class="metadata">
                                <dt>
                                    <span class="label">{{ redirect.status_code }}</span>
                                    Redirect from
                                </dt>
                                <dd>
                                    {{ 'GET' != redirect.method ? redirect.method }}
                                    {% if redirect.controller.class is defined -%}
                                        {%- set link = controller.file|file_link(controller.line) -%}
                                        {% if link %}<a href="{{ link }}" title="{{ controller.file }}">{% endif -%}
                                            {{ redirect_route }}
                                        {%- if link %}</a>{% endif -%}
                                    {%- else -%}
                                            {{ redirect_route }}
                                    {%- endif %}
                                    (<a href="{{ path('_profiler', { token: redirect.token, panel: request.query.get('panel', 'request') }) }}">{{ redirect.token }}</a>)
                                </dd>
                            </dl>
                        {%- endif %}

                        {% if request_collector and request_collector.forwardtoken -%}
                            {% set forward_profile = profile.childByToken(request_collector.forwardtoken) %}
                            {% set controller = forward_profile ? forward_profile.collector('request').controller : 'n/a' %}
                            <dl class="metadata">
                                <dt>Forwarded to</dt>
                                <dd>
                                    {% set link = controller.file is defined ? controller.file|file_link(controller.line) : null -%}
                                    {%- if link %}<a href="{{ link }}" title="{{ controller.file }}">{% endif -%}
                                        {% if controller.class is defined %}
                                            {{- controller.class|abbr_class|striptags -}}
                                            {{- controller.method ? ' :: ' ~ controller.method -}}
                                        {% else %}
                                            {{- controller -}}
                                        {% endif %}
                                    {%- if link %}</a>{% endif %}
                                    (<a href="{{ path('_profiler', { token: request_collector.forwardtoken }) }}">{{ request_collector.forwardtoken }}</a>)
                                </dd>
                            </dl>
                        {%- endif %}

                        <dl class="metadata">
                            <dt>Method</dt>
                            <dd>{{ profile.method|upper }}</dd>

                            <dt>HTTP Status</dt>
                            <dd>{{ status_code }}</dd>

                            <dt>IP</dt>
                            <dd>
                                <a href="{{ path('_profiler_search_results', { token: token, limit: 10, ip: profile.ip }) }}">{{ profile.ip }}</a>
                            </dd>

                            <dt>Profiled on</dt>
                            <dd><time datetime="{{ profile.time|date('c') }}">{{ profile.time|date('r') }}</time></dd>

                            <dt>Token</dt>
                            <dd>{{ profile.token }}</dd>
                        </dl>
                    </div>
                </div>
            {% endif %}
        {% endblock %}
    </div>

    <div id="content" class="container">
        <div id="main">
            <div id="sidebar">
                <div id="sidebar-shortcuts">
                    <div class="shortcuts">
                        <a href="#" id="sidebarShortcutsMenu" class="visible-small">
                            <span class="icon">{{ include('@WebProfiler/Icon/menu.svg') }}</span>
                        </a>

                        <a class="btn btn-sm" href="{{ path('_profiler_search', { limit: 10 }) }}">Last 10</a>
                        <a class="btn btn-sm" href="{{ path('_profiler', { token: 'latest' }|merge(request.query.all)) }}">Latest</a>

                        <a class="sf-toggle btn btn-sm" data-toggle-selector="#sidebar-search" {% if tokens is defined or about is defined %}data-toggle-initial="display"{% endif %}>
                            {{ include('@WebProfiler/Icon/search.svg') }} <span class="hidden-small">Search</span>
                        </a>

                        {{ render(path('_profiler_search_bar', request.query.all)) }}
                    </div>
                </div>

                {% if templates is defined %}
                    <ul id="menu-profiler">
                        {% for name, template in templates %}
                            {% set menu -%}
                                {%- if block('menu', template) is defined -%}
                                    {% with { collector: profile.getcollector(name), profiler_markup_version: profiler_markup_version } %}
                                        {{- block('menu', template) -}}
                                    {% endwith %}
                                {%- endif -%}
                            {%- endset %}
                            {% if menu is not empty %}
                                <li class="{{ name }} {{ name == panel ? 'selected' : '' }}">
                                    <a href="{{ path('_profiler', { token: token, panel: name }) }}">{{ menu|raw }}</a>
                                </li>
                            {% endif %}
                        {% endfor %}
                    </ul>
                {% endif %}

                {{ include('@WebProfiler/Profiler/settings.html.twig') }}
            </div>

            <div id="collector-wrapper">
                <div id="collector-content">
                    {{ include('@WebProfiler/Profiler/base_js.html.twig') }}
                    {% block panel '' %}
                </div>
            </div>
        </div>
    </div>
    <script>
        (function () {
            Sfjs.addEventListener(document.getElementById('sidebarShortcutsMenu'), 'click', function (event) {
                event.preventDefault();
                Sfjs.toggleClass(document.getElementById('sidebar'), 'expanded');
            })
        }());
    </script>
{% endblock %}

L'extension Twig SEO

Quand nous avons regardé le layout, nous avons vu que deux filtres Twig étaient utilisés. Ils permettent de s'assurer l'application de certaines règles. Ils coupent les chaînes si elles sont trop grandes. Pour le titre, il est vérifié si la chaîne de stratégie de marque (branding) peut être ajoutée sans dépasser la taille autorisée (on ajoute au titre " | Strangebuzz"). Voici le code:

<?php declare(strict_types=1);

// src/Twig/Extension/SeoExtension.php

namespace App\Twig\Extension;

use Symfony\Component\String\AbstractString;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use function Symfony\Component\String\u;

/**
 * SEO related Twig helpers.
 */
final class SeoExtension extends AbstractExtension
{
    public const MIN_TITLE_LENGTH = 30;
    public const MAX_TITLE_LENGTH = 65;

    public const MIN_DESCRITION_LENGTH = 120;
    public const MAX_DESCRITION_LENGTH = 155;

    private const BRANDING = ' | Strangebuzz';

    public function getFilters(): array
    {
        return [
            new TwigFilter('seo_title', [$this, 'processTitle']),
            new TwigFilter('seo_description', [$this, 'processDescription']),
        ];
    }

    private function prepareStr(string $str): AbstractString
    {
        return u(strip_tags($str))->trim();
    }

    public function processTitle(string $title): string
    {
        $str = $this->prepareStr($title);
        $brandingStr = u(self::BRANDING);
        $length = $str->length();

        // Nominal case
        if ($length >= self::MIN_TITLE_LENGTH && $length <= self::MAX_TITLE_LENGTH) {
            // Is there enough place for the branding?
            if (($length + $brandingStr->length()) <= self::MAX_TITLE_LENGTH) {
                $str = $str->ensureEnd($brandingStr->toString());
            }

            return $str->toString();
        }

        // Title too short, we add the branding
        if ($length < self::MIN_TITLE_LENGTH) {
            $str = $str->ensureEnd($brandingStr->toString());
        }

        // Title too long, we cup
        if ($length > self::MAX_TITLE_LENGTH) {
            $str = $str->truncate(self::MAX_TITLE_LENGTH);
        }

        return $str->toString();
    }

    public function processDescription(string $description): string
    {
        $str = $this->prepareStr($description);
        $length = $str->length();

        if ($length >= self::MIN_DESCRITION_LENGTH && $length <= self::MAX_DESCRITION_LENGTH) {
            return $str->toString();
        }

        // Description too long, we cut
        if ($length > self::MAX_DESCRITION_LENGTH) {
            $str = $str->truncate(self::MAX_DESCRITION_LENGTH);
        }

        return $str->toString();
    }
}

J'utilise ici également le composant string pour toutes les manipulations de chaîne à part pour la fonction strip_tags. J'ai ajouté cette extension assez récemment mais si j'avais à l'utiliser sur un nouveau projet, je lancerais une exception si le titre ou la description sont manquants ou incorrects. Ça contraindrait le développeur à ajouter ces textes. L'ajout de ces informations ferait donc parti du contrat quand on ajouterait de nouvelles pages publiques.

Conclusion

L'ajout d'un panneau de debug personnalisé n'est pas très difficile puisque le profileur a été conçu pour être étendu. Nous avons vu un cas concret ou il peut être utile et nous faciliter la vie en tant que développeur (🇬🇧 Developer Experience). J'espère que ça vous donnera des idées pour vos projets, n'hésitez pas à m'en faire part ! 😉

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À la revoyure ! COil. 😊

  Lire la doc


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil