Sauvegarder les valeurs d'un formulaire avec Private Tempstore sous Drupal 8

Dans ce billet, nous allons voir comment sauvegarder temporairement les valeurs d'un formulaire et comment les récupérer ou les traiter plus tard dans un contrôleur. Pour cela, nous allons utiliser la Form API et un stockage de type private tempstore (le temporary store storage system de Drupal 8).

Le scénario est le suivant: nous devons créer un simple lecteur RSS (un formulaire) où un utilisateur puisse introduire l'URL d'un fichier RSS et aussi le nombre d'éléments à récupérer de ce dernier. Ensuite, sur une nouvelle page (un contrôleur), l'application doit afficher les éléments requis avec leur lien vers les pages syndiquées.

La manière la plus simple serait de récupérer ces valeurs dans la méthode buildForm() de notre formulaire, les traiter et les afficher dans un champs spécifique de notre formulaire. Mais ce n'est pas notre cas puisque nous devons afficher les résultats sur une nouvelle page.

Afin de traiter les données du formulaire et d'afficher le résultat sur un autre page, nous devons tout d'abord sauvegarder temporairement les valeurs de notre formulaire et les récupérer ensuite dans un contrôleur. Mais comment et où pourrions-nous sauvegarder et ensuite récupérer ces données?

Pour faire court: Sauvegarder et récupérer des données avec le Private Tempstore

Drupal 8 possède un puissant système de sauvegarde temporaire de données spécifiques à un utilisateur tout en les maintenant disponibles entre plusieurs requêtes même si l'utilisateur ne s'est pas authentifié. Il s'agit du Private Tempstore system.

// 1. Get the private tempstore factory, inject this in your form, controller or service.
$tempstore = \Drupal::service('tempstore.private');
// Get the store collection. 
$store = $tempstore->get('my_module_collection');
// Set the key/value pair.
$store->set('key_name', $value);

// 2. Get the value somewhere else in the app.
$tempstore = \Drupal::service('tempstore.private');
// Get the store collection. 
$store = $tempstore->get('my_module_collection');
// Get the key/value pair.
$value = $store->get('key_name');

// Delete the entry. Not mandatory since the data will be removed after a week.
$store->delete('key_name');

Cela parait assez simple n'est-ce pas? Comme vous pouvez le constater, le Private Tempstore es un système de clé/valeur organisé en collections (généralement nous donnons a une collection le nom du module dans lequel elle est crée 'my_module_collection') qui permet de mettre à disposition des données entre différentes requêtes pour un utilisateur en particulier, qu'il soit authentifié ou non.

Vu que vous disposez à présent de la recette, nous pouvons revenir sur le cas qui nous intéresse où nous allons voir comment pourrions-nous sauvegarder et récupérer les données de notre formulaire.

Pour ce faire nous allons aborder les points suivants:

- Le module d'exemple pour illustrer le système de Private Tempstore
- Types de stockage de données sous Drupal 8
- Sauvegarder les données d'un formulare avec le Private Tempstore
- Récupérer les données d'un Private Tempstore
- Résumé

Le module d'exemple pour illustrer le système Private Tempstore

Pour illustrer le fonctionnement du système de stockage temporaire de Drupal, j'ai créé un petit module qui comprend un formulaire et un contrôleur pour d'abord obtenir les données de l'utilisateur, les sauvegarder temporairement et rediriger le formulaire vers un contrôleur où nous allons récupérer les données introduites par cet utilisateur afin de les traiter et afficher le résultat.

Voici le formulaire:

d8-privatetempstore-form

Et voici le contrôleur avec les résultats:

d8-privatetempstore-controller

Vous pouvez trouver le code de ce module ici.

Types de stockage de données sous Drupal 8

Sous Drupal 8 nous avons plusieurs APIs pour le stockage des données:

Database API  - Pour pouvoir interagir directement avec la base de données.
State API - Un stockage clé/valeur pour enregistrer des données relatives à l'état ou à un environnement individuel (dev., staging, production) d'une installation Drupal. Par exemple des API keys externes ou l'heure à laquelle le cron a été lancé pour la dernière fois.
UserData API - Un stockage pour enregistrer des données relatives à un environnement individuel mais pour un utilisateur spécifique comme des flags ou des préférences d'utilisateurs.
TempStore API - Un stockage clé/valeur pour sauvegarder temporairement des données (privées ou communes) entre plusieurs requêtes.
Entity API - Une API utilisée pour enregistrer des donnés relatives aux contenus (node, user, comment …) ou à la configuration (views, roles, …).
TypedData API - Une API de bas niveau pour créer et décrire des données d'une manière consistante.

Notre cas d'utilisateur se réfère à des données d'un utilisateur spécifique, données que nous devons conserver pour un court laps de temps entre deux requêtes (d'un formulaire à un contrôleur), et par ailleurs ces données ne sont pas spécifiques à un environnement en particulier. Donc il semble que la meilleur solution soit l'utilisation de la TempStore API mais dans sa mouture privée puisque les données et les résultats seront différents pour chaque utilisateur.

La seule différence entre un 'private' (privé) et 'shared' (commun) tempstore réside dans le fait que les données d'un private tempstore appartiennent à un seul utilisateur, alors qu'avec un shared tempstore, les données sont disponibles pour plusieurs utilisateur à la fois.

Sauvegarder les données d'un formulaire avec le Private Tempstore

Notre objectif est de créer un formulaire où l'utilisateur puisse introduire la URL d'un fichier RSS et un nombre d'éléments à récupérer de ce fichier, ensuite nous avons besoin de sauvegarder ces données pour les récupérer plus tard dans un contrôleur pour afficher les résultats.

A présent, voyons d'un peu plus près comment pourrions-nous sauvegarder les données introduites dans le formulaire. Comme nous recevons ces données (URL et nombre d'items) depuis un formulaire, il est clair que nous allons les sauvegarder dans la méthode submitForm() vu que cette dernière est lancée après que les données soient validées.

Voici le code de notre formulaire.

<?php
namespace Drupal\ex_form_values\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
// DI.
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
/**
 * Class WithControllerForm.
 *
 * Get the url of a RSS file and the number of items to retrieve
 * from this file.
 * Store those two fields (url and items) to a PrivateTempStore object
 * to use them in a controller for processing and displaying
 * the information of the RSS file.
 */
class WithStoreForm extends FormBase {
  /**
   * Drupal\Core\Messenger\MessengerInterface definition.
   *
   * @var \Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;
  /**
   * Drupal\Core\Logger\LoggerChannelFactoryInterface definition.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  protected $loggerFactory;
  /**
   * Drupal\Core\TempStore\PrivateTempStoreFactory definition.
   *
   * @var \Drupal\Core\TempStore\PrivateTempStoreFactory
   */
  private $tempStoreFactory;
  /**
   * Constructs a new WithControllerForm object.
   */
  public function __construct(
    MessengerInterface $messenger,
    LoggerChannelFactoryInterface $logger_factory,
    PrivateTempStoreFactory $tempStoreFactory
  ) {
    $this->messenger = $messenger;
    $this->loggerFactory = $logger_factory;
    $this->tempStoreFactory = $tempStoreFactory;
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('messenger'),
      $container->get('logger.factory'),
      $container->get('tempstore.private')
    );
  }
  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'with_state_form';
  }
  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $form['url'] = [
      '#type' => 'url',
      '#title' => $this->t('Url'),
      '#description' => $this->t('Enter the url of the RSS file'),
      '#default_value' => 'https://www.drupal.org/planet/rss.xml',
      '#weight' => '0',
    ];
    $form['items'] = [
      '#type' => 'select',
      '#title' => $this->t('# of items'),
      '#description' => $this->t('Enter the number of items to retrieve'),
      '#options' => [
        '5' => $this->t('5'),
        '10' => $this->t('10'),
        '15' => $this->t('15'),
      ],
      '#default_value' => 5,
      '#weight' => '0',
    ];
    $form['actions'] = [
      '#type' => 'actions',
      '#weight' => '0',
    ];
    // Add a submit button that handles the submission of the form.
    $form['actions']['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),
    ];
    return $form;
  }
  /**
   * Submit the form and redirect to a controller.
   *
   * 1. Save the values of the form into the $params array
   * 2. Create a PrivateTempStore object
   * 3. Store the $params array in the PrivateTempStore object
   * 4. Redirect to the controller for processing.
   *
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    // 1. Set the $params array with the values of the form
    // to save those values in the store.
    $params['url'] = $form_state->getValue('url');
    $params['items'] = $form_state->getValue('items');
    // 2. Create a PrivateTempStore object with the collection 'ex_form_values'.
    $tempstore = $this->tempStoreFactory->get('ex_form_values');
    // 3. Store the $params array with the key 'params'.
    try {
      $tempstore->set('params', $params);
      // 4. Redirect to the simple controller.
      $form_state->setRedirect('ex_form_values.simple_controller_show_item');
    }
    catch (\Exception $error) {
      // Store this error in the log.
      $this->loggerFactory->get('ex_form_values')->alert(t('@err', ['@err' => $error]));
      // Show the user a message.
      $this->messenger->addWarning(t('Unable to proceed, please try again.'));
    }
  }
}

Dans la méthode submitForm() nous pouvons voir les deux lignes qui sont en rapport avec le private tempstore dans les étapes 2 et 3.

$tempstore = $this->tempStoreFactory->get('ex_form_values');
//...
$tempstore->set('params', $params);

A la première ligne nous appelons la PrivateTempStoreFactory pour créer une nouvelle instance d'un objet de type PrivateTempStore grâce à sa méthode get(). Comme vous pouvez le voir, nous accédons a la factory par une injection des dépendances (Dependency Injection). Nous définissons le nom de notre collection (ex_form_values) sur la base du nom de notre module (par convention) et nous le passons à la méthode get(). Donc, nous avons créé un nouvel objet PrivateTempStore associé à une collection appelée "ex_form_values".

A la deuxième ligne nous utilisons la méthode set() de l'objet PrivateTempStore, cette méthode va sauvegarder une paire clé/valeur que nous définissons, dans notre cas, la clé s'appelle 'params' et la valeur est un vecteur qui contient les valeurs de notre formulaire.

La PrivateTempStoreFactory utilise un stockage basé sur l'interface KeyValueStoreExpirableInterface, ce stockage est formé par une paire clé/valeur avec une date d'expiration qui nous permet d'éliminer automatiquement des entrées qui ne nous servent plus. Ce type de stockage utilise le stockage par défaut DatabaseStorageExpirable qui enregistre les données dans la table key_value_expire.

Voici la structure de cette table:

key_value_expire-strucuture

L'objet PrivateTempStore a les propriétés suivantes, toutes sont de type protected et envoyer au formulaire par injection des dépendances (DI):
$storage - Le stockage de type clé/valeur utilisé.
$lockBackend - L'objet de type lock utilisé.
$currentUser - L'utilisateur actuel à qui les données appartiennent. Si n'est pas authentifié, l'ID de session sera utilisé.
$requestStack - Service pour ouvrir une session pour un utilisateur non authentifié.
$expire - Le temps durant lequel les données seront conservées. Par défaut elles sont conservées une semaine (604800 secondes) avant d'être éliminées comme défini dans l'interface  KeyValueStoreExpirableInterface.

Comme ces propriétés sont protégées, nous ne pouvons pas les modifier, elles sont définies par l'interface KeyValueStoreExpirableInterface lorsque nous créons un nouvel objet de type PrivateTempStore.

A présent la méthode de l'objet PrivateTempStore qui nous intéresse est la méthode set() qui, comme vous l'avez vu plus haut, est celle qui enregistre les données de notre formulaire dans la base de données. Vous pouvez voir le code complet de cette méthode ici.

public function set($key, $value) {

  // Ensure that an anonymous user has a session created for them, as
  // otherwise subsequent page loads will not be able to retrieve their
  // tempstore data.
  if ($this->currentUser
    ->isAnonymous()) {

    // @todo when https://www.drupal.org/node/2865991 is resolved, use force
    //   start session API rather than setting an arbitrary value directly.
    $this
      ->startSession();
    $this->requestStack
      ->getCurrentRequest()
      ->getSession()
      ->set('core.tempstore.private', TRUE);
  }
  // ....
}

Comme vous pouvez le voir au début de cette méthode, nous nous assurons que si c'est un utilisateur anonyme, nous ouvrions une session pour lui et nous en récupérons l'identifiant qui nous permettra de le distinguer plus tard. S'il s'agit d'un utilisateur authentifié, nous utiliserons son UID. Vous pouvez le vérifier dans la méthode getOwner() du même objet.

Quand nous sauvons le formulaire, grâce aux deux lignes que nous avons soulignées plus haut, nous allons pouvoir donc sauvegarder les données de notre formulaire dans la table key_value_expire de notre base de données. Si nous regardons cette table de plus près, nous pouvons voir qu'il y a d'autres records, la plus part en lien avec des procédés d'actualisation, mais aussi de nouvelles entrées qui ont comme valeur tempstore.private.ex_form_values pour le champ 'collection'. A continuation, voici un query qui nous permet de voir toutes les entrées liées à notre formulaire.

key_value_expire-drupal8

Nous n'avons pas inclus le champs 'value' dans notre query parce qu'il contient trop de données pour pouvoir les afficher. Mais vous pouvez voir maintenant comment ces valeurs sont stockées dans la base de données et en particulier pour des utilisateurs anonymes avec leur session ID.

Nous savons à présent comment sauvegarder temporairement les données de notre formulaire dans le privatetempstore de Drupal pour un utilisateur en particulier (D'où le nom private y temp store).

Voyons maintenant comment pourrions-nous récupérer et traiter ces données par la suite dans un contrôleur.

Récupérer les données d'un Private Tempstore

Pour récupérer et traiter les données afin d'afficher les résultats voulus, nous allons rediriger le formulaire vers un contrôleur. Vous trouverez ici le code de ce contrôleur.

<?php
namespace Drupal\ex_form_values\Controller;
use Drupal\Core\Controller\ControllerBase;
// DI.
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\ex_form_values\MyServices;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use GuzzleHttp\ClientInterface;
// Other.
use Drupal\Core\Url;
/**
 * Target controller of the WithStoreForm.php .
 */
class SimpleController extends ControllerBase {
  /**
   * Tempstore service.
   *
   * @var \Drupal\Core\TempStore\PrivateTempStoreFactory
   */
  protected $tempStoreFactory;
  /**
   * GuzzleHttp\ClientInterface definition.
   *
   * @var \GuzzleHttp\ClientInterface
   */
  protected $clientRequest;
  /**
   * Messenger service.
   *
   * @var \\Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;
  /**
   * Custom service.
   *
   * @var \Drupal\ex_form_values\MyServices
   */
  private $myServices;
  /**
   * Inject services.
   */
  public function __construct(PrivateTempStoreFactory $tempStoreFactory,
                              ClientInterface $clientRequest,
                              MessengerInterface $messenger,
                              MyServices $myServices) {
    $this->tempStoreFactory = $tempStoreFactory;
    $this->clientRequest = $clientRequest;
    $this->messenger = $messenger;
    $this->myServices = $myServices;
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('tempstore.private'),
      $container->get('http_client'),
      $container->get('messenger'),
      $container->get('ex_form_values.myservices')
    );
  }
  /**
   * Target method of the the WithStoreForm.php.
   *
   * 1. Get the parameters from the tempstore for this user
   * 2. Delete the PrivateTempStore data from the database (not mandatory)
   * 3. Display a simple message with the data retrieved from the tempstore
   * 4. Get the items from the rss file in a renderable array
   * 5. Create a link back to the form
   * 6. Render the array.
   *
   * @return array
   *   An render array.
   */
  public function showRssItems() {
    // 1. Get the parameters from the tempstore.
    $tempstore = $this->tempStoreFactory->get('ex_form_values');
    $params = $tempstore->get('params');
    $url = $params['url'];
    $items = $params['items'];
    // 2. We can now delete the data in the temp storage
    // Its not mandatory since the record in the key_value_expire table
    // will expire normally in a week.
    // We comment this task for the moment, so we can see the values
    // stored in the key_value_expire table.
    /*
    try {
      $tempstore->delete('params');
    }
    catch (\Exception $error) {
      $this->loggerFactory->get('ex_form_values')->alert(t('@err',['@err' => $error]));
    }
    */
    // 3. Display a simple message with the data retrieved from the tempstore
    // Set a cache tag, so when an anonymous user enter in the form
    // we can invalidate this cache.
    $build[]['message'] = [
      '#type' => 'markup',
      '#markup' => t("Url: @url - Items: @items", ['@url' => $url, '@items' => $items]),
    ];
    // 4. Get the items from the rss file in a renderable array.
    if ($articles = $this->myServices->getItemFromRss($url, $items)) {
      // Create a render array with the results.
      $build[]['data_table'] = $this->myServices->buildTheRender($articles);
    }
    // 5. Create a link back to the form.
    $build[]['back'] = [
      '#type' => 'link',
      '#title' => 'Back to the form',
      '#url' => URL::fromRoute('ex_form_values.with_store_form'),
    ];
    // Prevent the rendered array from being cached.
    $build['#cache']['max-age'] = 0;
    // 6. Render the array.
    return $build;
  }
}

Comme toujours nous allons tout d'abord injecter les services dont nous avons besoin, auxquels nous avons ajouté un service custom MyServices.php qui contient deux méthodes pour télécharger le fichier RSS et préparer notre render array final.

Ce contrôleur a une seule méthode que nous avons appelée showRssItems(). Cette méthode réalise 6 tâches distinctes:

1. Récupérer les données dont nous avons besoin du tempstore
2. Éliminer les données du PrivateTempStore de la base de données (non obligatoire)
3. Afficher un simple message avec les données que nous venons de récupérer
4. Télécharger le fichier RSS y créer un render array avec le nombre d'éléments requis
5. Créer un lien pour retourner vers le formulaire
6. Retourner le render array

Les tâches qui nous intéressent sont la première et la deuxième, où nous récupérons les données pour cet utilisateur.

$tempstore = $this->tempStoreFactory->get('ex_form_values');

Cette ligne n'a rien de neuf pour nous, vu que nous savons à présent que la méthode get() du service PrivateTempStoreFactory va créer une nouvelle instance d'un objet de type PrivateTempStore pour la collection 'ex_form_values'.

$params = $tempstore->get('params');

Dans la ligne ci-dessus, nous récupérons tout simplement la valeur de la clé 'params' de notre collection dans la variable $params. N'oubliez pas que nous travaillons avec un privatetemp storage, donc, quand nous récupérons la valeur nous vérifions aussi si cette valeur a été introduite par le même utilisateur grâce à la méthode getOwner() de l'objet PrivateTempStore.

La seconde tâche de notre méthode élimine de la base de données les valeurs que nous avons récupérées du tempstore. En fait ce n'est pas obligatoire vu que nous travaillons avec un stockage temporaire où les données seront éliminées automatiquement après un certain laps de temps (une semaine par défaut). Mais si nous prévoyons un usage intensif de notre site, cela pourrait être une bonne idée vu que nous n'allons plus avoir besoin de ces données.

try {
  $tempstore->delete('params');
}
catch (\Exception $error) {
  $this->loggerFactory->get('ex_form_values')->alert(t('@err',['@err' => $error]));
}

Comme la méthode delete() de notre objet privatetempstore peut lancer une erreur, nous allons la récupérer dans un bloque 'try catch'. Je vous conseil de commenter ces lignes pour voir comment les données sont sauvegardées dans la table key_value_expire.

Le reste du contrôleur est assez simple, nous allons passer les données à des méthodes de notre service custom qui vont télécharger le fichier RSS, récupérer le nombre d'éléments nécessaires et construire le render array avec les résultats, comme vous pouvez le voir dans la tâche numéro 4. Ensuite nous créons un simple lien qui permettra à nos utilisateurs de revenir vers le formulaire s'ils le souhaitent.

En résumé

Nous voulions, grâce à un formulaire, recevoir de l'utilisateur l'URL d'un fichier RSS et le nombre d'éléments à afficher de ce fichier, afin de montrer plus tard les résultats dans un contrôleur. Pour ce faire nous avions besoin de sauvegarder les données du formulaire pour les récupérer ensuite dans un contrôleur.

Nous avons décidé d'utiliser le stockage Private TempSore de Drupal parce que:
- Les données ne sont pas relatives à un environnement spécifique ou un état en particulier de notre application (voyez State API dans ce cas précis)
- Si bien les données sont spécifiques pour chaque utilisateur, nous n'avons pas besoin de les enregistrer avec son profile (voyez UserData API dans ce cas là). D'autre part, les utilisateurs anonymes doivent aussi avoir accès au formulaire.
- Nous avons besoin de ces données de manière temporaire pour un court laps de temps (quelques secondes).
- Le stockage doit être privé vu que les données appartiennent à un utilisateur en particulier (anonyme ou authentifié).

Pour enregistrer les données du formulaire nous avons utilisé la méthode submit() et les lignes suivantes:

$tempstore = $this->tempStoreFactory->get('ex_form_values') - pour créer une nouvelle instance d'un objet de type PrivateTempStore avec un nom de collection (que nous choisissons)  'ex_form_values' que se réfère à notre module.

$tempstore->set('params', $params) - pour sauvegarder les valeurs de la variable $params dans la base de données avec la clé 'params'. Souvenez-vous que nous utilisons un private storage, donc Drupal récupérera l'ID de l'utilisateur ou son ID de session s'il est anonyme, et va le concaténer avec la valeur de la clé dans le champ 'name' de la table 'key_value_expire'.

Pour récupérer les données dans la méthode showRssItems() de notre contrôleur, nous avons utilisé les lignes suivantes:

$tempstore = $this->tempStoreFactory->get('ex_form_values') - pour créer une nouvelle instance d'un objet de type PrivateTempStore avec un nom de collection (que nous choisissons)  'ex_form_values' que se réfère à notre module.

$params = $tempstore->get('params') - pour récupérer la valeur stockée pour la clé 'params' de cette collection et pour cet utilisateur particulier.

 

Une fois de plus, grâce a ce simple exemple, nous pouvons voir que Drupal est vraiment très puissant mais aussi que son framework est très simple à utiliser.

Et vous? Dans quelle situation pensez-vous que nous pourrions utiliser le stockage privatetempstore? Partagez vos idées avec la communauté et laissez-nous un commentaire.

 

Plus d'infos:

class PrivateTempStoreFactory (api.drupal.org)

class PrivateTempStore (api.drupal.org)

PrivateTempStore::get (api.drupal.org)

PrivateTempStore::set (api.drupal.org)

Configuration Storage in Drupal 8 (drupal.org)

State API overview (drupal.org)

D8FTW: Storing Data in Drupal 8 (LARRY GARFIELD palantir.net)