15 Gennaio, 2014 | Di

Porting del pacchetto Symfony Web Profiler in Drupal 8

Porting del pacchetto Symfony Web Profiler in Drupal 8

Introduzione

La maggior parte dei principali componenti di Symfony2 sono ora in Drupal 8, ma se avete già un po' di esperienza con la versione standard di Symfony sapete che contiene anche alcuni pacchetti interessanti (un "pacchetto" è come un modulo Drupal nel gergo di Symfony). Uno di questi è il pacchetto Web Profiler che mostra, alla fine di tutte le pagine web, una barra degli strumenti con molte informazioni interessanti in merito a quale canale è associato, quanta memoria è stata consumata, quante query sono state eseguita e così via. Nelle applicazioni tipiche di Symfony2 queste informazioni sono raccolte da alcune classi nel componente HttpKernel configurato nel pacchetto Framework e forniti nel pacchetto Web Profilier. Fortunatamente il componente HttpKernell si trova nel nucleo di Drupal 8, ma non sono presenti i pacchetti Framework e Web Profiler. Nella parte restante di questo tutorial spiegheremo come il pacchetto Web Profiler lavora e come trasportare la sua logica in un modulo Drupal. L'intero codice è stato caricato su drupal.org. Lo trovate qui.

Data collectors

Una sottoclasse Symfony\Component\HttpKernel\DataCollector è responsabile di raccogliere alcune informazioni specifiche in merito ad una Request. Nel componente di Symfony2 HttpKernel ci sono alcune implementazioni di questa classe, denominate RequestDataCollector, MemoryDataCollector ed altre. Definire un nuovo DataCollector è semplice ed è descritto qua (http://symfony.com/doc/2.3/cookbook/profiler/data_collector.html). Quello che abbiamo fatto è riutilizzare alcuni dei DataColletors standard e migliorarne altri per raccogliere informazioni specifiche di Drupal.

Default

  • - RequestDataCollector (raccoglie informazioni in merito alla richiesta)
  • MemoryDataCollector (raccoglie informazioni riguardo alla memoria usata per fornire la risposta)

Specifica Drupal

  • DrupalDataCollector (raccoglie informazioni generiche riguardo l'installazione di Drupal, come la versione fondamentale)
  • DrupalConfigDataCollector (raccoglie informazioni riguardo la configurazione di Drupal)
  • TimerDataCollector (raccoglie quanto tempo impiega Drupal a fornire la risposta)
  • DatabaseDataCollector (raccoglie informazioni in merito alle query eseguite)
  • FormDataCollector (raccoglie informazioni in merito al form presente nella pagina).

Le sottoclassi DataCollectors devono eseguire i metodi collect e getName, più alcuni altri per recuperare i dati raccolti; per esempio TimerDataCollector appare come questo:

<?php
namespace Drupal\webprofiler\DataCollector;
 
use Drupal\Component\Utility\Timer;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
 
class TimerDataCollector extends DataCollector {
 
  /**
   * Collects data for the given Request and Response.
   *
   * @param Request $request A Request instance
   * @param Response $response A Response instance
   * @param \Exception $exception An Exception instance
   *
   * @api
   */
  public function collect(Request $request, Response $response, \Exception $exception = NULL) {
    // Timer::start('page') was called in bootstrap.inc
    $this->data['timer'] = Timer::read('page');
  }
 
  /**
   * @return float
   */
  public function getTimer() {
    return $this->data['timer'];
  }
 
  /**
   * Returns the name of the collector.
   *
   * @return string The collector name
   *
   * @api
   */
  public function getName() {
    return 'timer';
  }
}

Per ogni DataCollector dobbiamo definire un file .twig con alcuni blocchi che saranno usati per fornire sia la barra degli strumenti sia la pagina di dettaglio.

{% block toolbar %}
    {% set icon %}
    <a href="/admin/profiler/view/{{ token }}?panel=database">
        <img width="20" height="28" alt="Database"
             src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAcCAYAAABh2p9gAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQRJREFUeNpi/P//PwM1ARMDlcGogZQDlpMnT7pxc3NbA9nhQKxOpL5rQLwJiPeBsI6Ozl+YBOOOHTv+AOllQNwtLS39F2owKYZ/gRq8G4i3ggxEToggWzvc3d2Pk+1lNL4fFAs6ODi8JzdS7mMRVyDVoAMHDsANdAPiOCC+jCQvQKqBQB/BDbwBxK5AHA3E/kB8nKJkA8TMQBwLxaBIKQbi70AvTADSBiSadwFXpCikpKQU8PDwkGTaly9fHFigkaKIJid4584dkiMFFI6jkTJII0WVmpHCAixZQEXWYhDeuXMnyLsVlEQKI45qFBQZ8eRECi4DBaAlDqle/8A48ip6gAADANdQY88Uc0oGAAAAAElFTkSuQmCC"/>
        <span class="sf-toolbar-status{% if 50 < collector.querycount %} sf-toolbar-status-yellow{% endif %}">{{ collector.querycount }}</span>
        {% if collector.querycount > 0 %}
            <span class="sf-toolbar-info-piece-additional-detail">in {{ '%0.2f ms'|format(collector.time * 1000) }}</span>
        {% endif %}
    </a>
    {% endset %}
    {% set text %}
    <div class="sf-toolbar-info-piece">
        <b>DB Queries</b>
        <span>{{ collector.querycount }}</span>
    </div>
    <div class="sf-toolbar-info-piece">
        <b>Query time</b>
        <span>{{ '%0.2f ms'|format(collector.time * 1000) }}</span>
    </div>
    <div class="sf-toolbar-info-piece">
        <b>Database</b>
        <span>{{ collector.database.driver }}://{{ collector.database.host }}:{{ collector.database.port }}/{{ collector.database.database }}</span>
    </div>
    {% endset %}
    <div class="sf-toolbar-block">
        <div class="sf-toolbar-icon">{{ icon|default('') }}</div>
        <div class="sf-toolbar-info">{{ text|default('') }}</div>
    </div>
{% endblock %}
 
{% block menu %}
    <span class="label">
    <span class="icon"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAcCAYAAAB/E6/TAAABLUlEQVR42u3TP0vDQBiA8UK/gDiLzi0IhU4OEunk5OQUAhGSOBUCzqWfIKSzX8DRySF0URCcMjWLIJjFD9Cpk/D6HITecEPUuzhIAz8CIdyTP/f2iqI4qaqqDx8l5Ic2uIeP/bquezCokOAFF+oCN3t4gPzSEjc4NEPaCldQbzjELTYW0RJzHDchwwem+ons6ZBpLSJ7nueJC22h0V+FzmwWV0ee59vQNV67CGVZJmEYbkNjfpY6X6I0Qo4/3RMmTdDDspuQVsJvgkP3IdMbIkIjLPBoadG2646iKJI0Ta2wxm6OdnP0/Tk6DYJgHcfxpw21RtscDTDDnaVZ26474GkkSRIrrPEv5sgMTfHe+cA2O6wPH6vOBpYQNALneHb96XTEDI6dzpEZ0VzO0Rf3pP5LMLI4tAAAAABJRU5ErkJggg==" alt="" /></span>
    <strong>Database</strong>
    <span class="count">
        <span>{{ collector.querycount }}</span>
        <span>{{ '%0.0f'|format(collector.time * 1000) }} ms</span>
    </span>
</span>
{% endblock %}
 
{% block panel %}
    {% for query in collector.queries %}
        {{ query.query }}<br/>
    {% endfor %}
{% endblock %}

I blocchi definitivi sono: toolbarmenu e panel. Il blocco toolbar è usato per fornire un widget nella stessa barra degli strumenti mentre quelli menu e panel sono usati nella pagina di dettaglio.

Tutte queste classi bisogna che siano collegate all'applicazione definendole come servizi nel Service Container. Ogni modulo Drupal può aggiungere servizi usando il file modulename.services.yml. In questo modo nel webprofiler.services.yml dobbiamo inserire:

services:
  webprofiler.drupal:
    class: Drupal\webprofiler\DataCollector\DrupalDataCollector
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/drupal.html.twig', id:'drupal' }
  webprofiler.config:
    class: Drupal\webprofiler\DataCollector\DrupalConfigDataCollector
    arguments: ['@module_handler']
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/config.html.twig', id:'config' }
  webprofiler.request:
    class: Symfony\Component\HttpKernel\DataCollector\RequestDataCollector
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/request.html.twig', id:'request' }
      - { name: event_subscriber }
  webprofiler.timer:
    class: Drupal\webprofiler\DataCollector\TimerDataCollector
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/timer.html.twig', id:'timer' }
  webprofiler.memory:
    class: Symfony\Component\HttpKernel\DataCollector\MemoryDataCollector
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/memory.html.twig', id:'memory' }
  webprofiler.form:
    class: Drupal\webprofiler\DataCollector\FormDataCollector
    arguments: ['@form_builder']
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/form.html.twig', id:'form' }
  webprofiler.database:
    class: Drupal\webprofiler\DataCollector\DatabaseDataCollector
    arguments: ['@database']
    tags:
      - { name: data_collector, template:'@webprofiler/Collector/database.html.twig', id:'database' }

Attenzione, in un file Yaml le indentazioni contano!

In questo modo possiamo vedere il potere di Dependency Injection; guardate per esempio al servizio webprofiler.database, la sua funzione di costruzione riceve il database come definizione senza chiedere alcuna classe specifica (in questo caso ogni implementazione di classe astratta di Drupal/Core/Database/Connection va bene). Un'altra funzionalità interessante sono i tag: possiamo segnare un servizio con uno o più tag per essere recuperate dopo; in questo caso usiamo il tag data_collector (attenzione, un tag può avere definizioni, come template e id).

Profilatore

Ora che abbiamo i collettori in posizione e collegati abbiamo bisogno di un Profiler per raccogliere i dati ed immagazzinarli da qualche parte. La classe predefinita di Symfony è Symfony\Component\HttpKernel\Profiler\Profiler. Il costruttore di questa classe ha bisogno di un'implementazione di Symfony\Component\HttpKernel\Profiler\ProfilerStorageInterface e di un'implementazione opzionale di Psr\Log\LoggerInterface. Possiamo dichiarare queste classi come servizi nel nostro webprofiler.service.yml:

profiler.storage:
    class: Symfony\Component\HttpKernel\Profiler\FileProfilerStorage
    arguments: ['%data_collector.storage%']
  profiler.logger:
    class: Psr\Log\NullLogger
  profiler:
    class: Symfony\Component\HttpKernel\Profiler\Profiler
    arguments: ['@profiler.storage', '@profiler.logger']

Abbiamo definito un servizio per lo spazio di archiviazione ed un servizio per la registrazione, che sono inseriti nel servizio profilatore come constructor arguments. Fare attenzione che il servizio profiler.storage effettua una variabile come constructor arguments (data_collector.storage), ci ritorneremo in seguito.

ServiceProvider

Ok. fino ad ora abbiamo un sacco di collettori e un profilatore capaci di usarli, come possiamo dire al profilatore come scoprire i collettori? Ma ovviamente usando il tag data_collector! Introducendo il Compiler.

Il Service Container di Symfony è compilato in un file PHP ed archiviato (per un'applicazione Drupal) in sites/default/file/php/service_container/service_container_prod.php\[some random string].php. Il Compilatore usa un set di passaggi per esaminare tutti i servizi e per costruire i Service Container, alcuni passaggi sono definiti da Symfony stesso, altri da Drupal ma un modulo contributivo può aggiungerne altri.

Se diamo un'occhiata al metodo discoverServiceProviders() della classe Drupal\Core\DrupalKernel scopriremo come Drupal carica specifici moduli Service Provider:

foreach ($this->moduleList as $module => $weight) {
      $camelized = ContainerBuilder::camelize($module);
      $name = "{$camelized}ServiceProvider";
      $class = "Drupal\\{$module}\\{$name}";
      if (class_exists($class)) {
        $serviceProviders[$name] = new $class();
        $this->serviceProviderClasses[] = $class;
      }
      $filename = dirname($module_filenames[$module]) . "/$module.services.yml";
      if (file_exists($filename)) {
        $this->serviceYamls[] = $filename;
      }
    }

Nel nostro caso dobbiamo creare una classe WebprofilerServiceProvider nella cartella webprofiler/lib/Drupal/webprofiler:

<?php
namespace Drupal\webprofiler;
 
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
use Drupal\webprofiler\Compiler\ProfilerPass;
 
/**
 * Class WebprofilerServiceProvider
 *
 * @package Drupal\webprofiler
 */
class WebprofilerServiceProvider extends ServiceProviderBase {
 
  /**
   * {@inheritdoc}
   */
  public function register(ContainerBuilder $container) {
    $container->addCompilerPass(new ProfilerPass());
  }
 
}

E una classe ProfilerPass nella cartella webprofiler/lib/Drupal/webprofiler/Compiler:

<?php
namespace Drupal\webprofiler\Compiler;
 
use Drupal\Core\StreamWrapper\PublicStream;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
 
/**
 * Class ProfilerPass
 *
 * @package Drupal\webprofiler\Compiler
 */
class ProfilerPass implements CompilerPassInterface {
 
  /**
   * @param ContainerBuilder $container
   *
   * @throws \InvalidArgumentException
   */
  public function process(ContainerBuilder $container) {
    // replace the class for form_builder service
    $form_builder = $container->getDefinition('form_builder');
    $form_builder->setClass('Drupal\webprofiler\Form\ProfilerFormBuilder');
 
    // configure the profiler service
    if (FALSE === $container->hasDefinition('profiler')) {
      return;
    }
 
    $definition = $container->getDefinition('profiler');
 
    $collectors = new \SplPriorityQueue();
    $order = PHP_INT_MAX;
    foreach ($container->findTaggedServiceIds('data_collector') as $id => $attributes) {
      $priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
      $template = NULL;
 
      if (isset($attributes[0]['template'])) {
        if (!isset($attributes[0]['id'])) {
          throw new \InvalidArgumentException(sprintf('Data collector service "%s" must have an id attribute in order to specify a template', $id));
        }
        $template = array($attributes[0]['id'], $attributes[0]['template']);
      }
 
      $collectors->insert(array($id, $template), array($priority, --$order));
    }
 
    $templates = array();
    foreach ($collectors as $collector) {
      $definition->addMethodCall('add', array(new Reference($collector[0])));
      $templates[$collector[0]] = $collector[1];
    }
 
    $container->setParameter('data_collector.templates', $templates);
 
    // set parameter to store the public folder path
    $path = 'file://' . DRUPAL_ROOT . '/' . PublicStream::basePath() . '/profiler';
    $container->setParameter('data_collector.storage', $path);
  }
}

Il metodo ProfilerPass:: process è chiamato in causa quando Drupal redige il Service Container (ossia quando un amministratore pulisce la cache). Qua troviamo tutti i servizi etichettati con la tag data_collector ed inseriti nella definizione di profiler. Abbiamo anche inserito due parametri nel Contenitore: uno per archiviare tutti i template definiti da Datacollectors nel webprofiler.services.yml (data_collector.templates) e l'altro per archiviare la posizione della directory pubblica (data_collector.storage). In ultimo è usata da FileProfilerStorage per salvare i profili. Abbiamo anche cambiato la classe predefinita del servizio form_builder, ma ritorneremo su questo punto dopo.

EventListener

Abbiamo finalmente raggiunto l'ultimo step per avere raccolto i dati ed archiviati nel filesystem, quello che dobbiamo fare è registrare (in webprofiler.services.ymh) un EventListener che funziona sull'evento KernelEvent::RESPONSE ed usa il servizio profilatore per effettuare questo duro lavoro:

webprofiler.matcher:
    class: Drupal\webprofiler\RequestMatcher\WebprofilerRequestMatcher
  webprofiler.profilerListener:
    class: Symfony\Component\HttpKernel\EventListener\ProfilerListener
    arguments: ['@profiler', '@webprofiler.matcher']
    tags:
      - { name: event_subscriber }

L'unica cosa da mettere in evidenza qui è il servizio webprofiler.matcher inserito come seconda definizione di ProfilerListener. Quando si è loggati come admin su un'installazione predefinita Drupal c'è una richiesta al browser, ossia la pagina principale attiva tre richieste al server, una per la se stessa, una per la barra degli strumenti dell'admin ed una per alcuni dati contestualizzati (per il modulo contestuale). Siccome vogliamo tracciare un profilo per la richiesta principale abbiamo bisogno di eliminare le altre due. WebprofilerRequestMatcher è un'implementazione dell'interfaccia di Symfony\Component\HttpFoundationRequestMatcherInterface:

<?php
namespace Drupal\webprofiler\RequestMatcher;
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
 
class WebprofilerRequestMatcher implements RequestMatcherInterface {
 
  /**
   * Decides whether the rule(s) implemented by the strategy matches the supplied request.
   *
   * @param Request $request The request to check for a match
   *
   * @return Boolean true if the request matches, false otherwise
   */
  public function matches(Request $request) {
    $path = $request->getPathInfo();
    $matches = NULL;
 
    // exclude contextual request
    preg_match('/\\/contextual\\/(.*)/', $path, $matches);
    if ($matches) {
      return FALSE;
    }
 
    // exclude admin toolbar request
    preg_match('/\\/toolbar\\/(.*)/', $path, $matches);
    if ($matches) {
      return FALSE;
    }
 
    return TRUE;
  }
}

Con il codice in posizione (e dopo aver eliminato la cache) inizierete a vedere che alcuni file e cartelle sono apparse nella cartella sites/default/file/profiler quando navigate nel sito. Un index.csv contiene tutti i profili salvati, ognuno dei quali identificato da un token. Il dettaglio del profilo è salvato nelle sottocartelle.

E' giunto il momento di fornire queste informazioni in fondo ad ognuna delle pagine web. Questo è un procedimento a due step, prima bisogna inserire HTML e javascript alla fine di una pagina web, poi il javascript effettua una chiamata ajax per inserirlo nella barra degli strumenti. Dobbiamo registrare un event listener nell'evento KernelEvents::RESPONSE ed aggiungere il codice giusto prima del tag </body>. Il codice è fornito da un file template di Drupal standard (webprofiler_loader). L'event listener è registrato nel Service Container (in webprofiler.service.yml) e taggato event_subscriber da autoscoprire:

 webprofiler.WebprofilerEventListener:
      class: Drupal\webprofiler\EventListener\WebprofilerEventListener
      arguments: ['@current_user']
      tags:
        - { name: event_subscriber }

Qui abbiamo bisogno del servizio current_user perché la barra degli strumenti deve essere stampata solo per gli utenti che hanno i permessi access web profiler (definito generalmente nel webprofiler.module).

Controller

Dopo il completo caricamento della pagina una chiamata ajax viene effettuata per recuperare la barra degli strumenti, questa chiamata ha bisogno di un path router e un controllore da eseguire. In Drupal 8 il percorso è definito in modulename.routing.yml, in questo modo nel nostro webprofiler.routing.ym abbiamo:

webprofiler.toolbar:
  path: '/profiler/{token}'
  defaults:
    _controller: '\Drupal\webprofiler\Controller\WebprofilerController::toolbarAction'
  requirements:
    _permission: 'access web profiler'

Il metodo toolbarAction prende il segno dalla url, carica il profilo ed inserisce la barra degli strumenti:

/**
 *
 */
public function toolbarAction(Request $request, $token) {
  if (NULL === $token) {
    return new Response('', 200, array('Content-Type' => 'text/html'));
  }
  $profiler = $this->container()->get('profiler');
  $profiler->disable();
 
  if (!$profile = $profiler->loadProfile($token)) {
    return new Response('', 200, array('Content-Type' => 'text/html'));
  }
 
  $url = NULL;
  try {
    $url = $this->container()->get('router')->generate('webprofiler.profiler', array('token' => $token));
  } catch (\Exception $e) {
    // the profiler is not enabled
  }
 
  $templates = $this->container()->get('templateManager')->getTemplates($profile);
 
  $toolbar = array(
    '#theme' => 'webprofiler_toolbar',
    '#token' => $token,
    '#templates' => $templates,
    '#profile' => $profile,
    '#profiler_url' => $url,
  );
 
  return new Response(render($toolbar));
}

Non vogliamo descrivere questa richiesta quindi dobbiamo disabilitare il profilatore.

Alcuni widget della barra degli strumenti sono collegati ad una pagina di dettaglio, in questo modo abbiamo bisogno di un altro canale e di un altro controllore (webprofiler.profiler e WebprofilerController::profilerAction).

FormDataCollector

Un'ultima cosa, Drupal 8 ha un nuovo set di classi riguardanti i building form, il principale definito come un servizio form_builder: Drupal\Core\Form\FormBuilder. Tutti i form che sono forniti in una pagina sono costruiti da FormBuilder, ma questa classe non ha alcun supporto a collezionarli. Quello che dobbiamo fare è rimpiazzare questa classe con un'altra che esegue quello che vogliamo (più specificatamente una sottoclasse di FormBuilder):

<?php
namespace Drupal\webprofiler\Form;
 
use Drupal\Core\Form\FormBuilder;
 
/**
 * Class ProfilerFormBuilder
 *
 * @package Drupal\webprofiler\Form
 */
class ProfilerFormBuilder extends FormBuilder {
 
  private $build_forms;
 
  /**
   * @return array
   */
  public function getBuildForm() {
    return $this->build_forms;
  }
 
  /**
   * {@inheritdoc}
   */
  public function buildForm($form_id, &$form_state) {
    $this->build_forms[] = $form_id;
    return parent::buildForm($form_id, &$form_state);
  }
}

Ogni volta che un form è costruito immagazziniamo il $form_id in una variabile privata, poi inseriamo il servizio form_builder nel FormDataCollector.

Conclusioni

Drupal è un potente framework, ma con la maggior parte di Symfony sotto di sè abbiamo accesso a molte funzionalità e codice già scritto. Abbiamo visto che adattare il WebProfiler in modo che funzioni su Drupal e questo lavoro può essere fatto con altri gruppi o moduli, come si chiamano in Drupal.