Pour continuer dans la série d'articles sur la Création d'un module avec Drupal 8, aujourd'hui, les services... Par où commencer... Un service doit executer un processus et en général retourner un résultat, tout simplement.
Introduction
Dans Drupal 8, la notion de service est intimement liée à l'injection des dépendances. Je ne vais pas trop m'étaler sur le sujet, mais l'injection des dépendances, c'est un peu le graal de la programmation orientée objet. Le but ? faire en sorte que les classes ne soient pas dépendantes les unes des autres, "découpler" son code afin de les laisser le plus libre possible.
Un exemple concret serait la couche d'abstraction de base de données utilisée dans Drupal. D'un côté, nous avons une base de données: MySql, SQLServer, SQLite, Postgres ou encore MariaDB. D'un autre, nous avons des méthodes d'accès à la base de données. Entre les deux, nous avons ce qu'on appelle communément une "couche d'abstraction", qui permet d'interroger la base de donnée, peu importe son type. Dans Drupal, quand vous utilisez la méthode db_select, peu vous importe que vous interrogiez MySQL ou Postgres. La méthode est la même. C'est en arrière-plan que tout se passe. Les différentes bases de données s'appuient sur des syntaxes différentes, mais la méthode db_select va s'occuper de transformer votre "pseudo" requête pour qu'elle soit compréhensible par la base de données ciblée.
Dans Drupal 8, le noyau a été réfléchi pour proposer cette indépendance des classes via des services. Une classe "ContainerInjection" permet d'injecter les services nécessaires à d'autres classes sans avoir à transmettre toute la logique d'instanciation. Il est donc possible d'accéder à une multitude de services en indiquant lesquels doivent être injectés au sein de vos classes. Il est également possible de définir ses propres services qui pourront être réutilisés à l'intérieur de vos modules, mais également par d'autres modules. Dans certains cas, il peut être très pratique de créer des services qui seront réutilisables ailleurs.
Besoins
Dans le cas du module "Internal link", j'ai 2 services, qui eux-même utilisent d'autres services du noyau. Le premier service est un service orienté "données". Il va fournir une couche de requêtes basée sur EntityQuery, afin de retourner des données relatives aux entités de type internal_link. Ce n'est pas l'exemple le plus intéressant dans la déclaration de services, car à priori, mon module va se suffire à lui-même, il n'est pas certain que d'autres modules aient besoin d'accéder à ce service. Néanmoins, je voulais me faire un peu la main et comprendre ces notions services/injection de dépendances, c'est pour cette raison que j'ai intégré ce service. Il permet également de ne pas avoir à intégrer la logique de requêtes dans chaque classe qui aura besoin de faire appel à des données issues de la base de données.
Génération
Pour déclarer un service, la première chose à faire est de posséder un fichier [module].services.yml à la racine du module. La seconde est d'avoir une classe correspondante à notre service.
Ici encore, nous allons utiliser Drupal Console pour générer le squelette de service et les éléments nécessaire:
MacPro:drupal8 titouille$ drupal generate:service // Welcome to the Drupal service generator Enter the module name [internal_link]: > Enter the service name [internal_link.default]: > internal_link.data Enter the Class name [DefaultService]: > DataService Create an interface (yes/no) [yes]: > no Do you want to load services from the container (yes/no) [no]: > yes Type the service name or use keyup or keydown. This is optional, press enter to continue Enter your service [ ]: > entity.query Enter your service [ ]: > entity_type.manager Enter your service [ ]: > entity.repository Enter your service [ ]: > language_manager Enter your service [ ]: > Do you confirm generation? (yes/no) [yes]: > Generated or updated files Site path: /Users/titouille/Dev/web/htdocs/sabugo/drupal8 1 - modules/custom/internal_link/internal_link.services.yml 2 - modules/custom/internal_link/src/DataService.php // cache:rebuild Rebuilding cache(s), wait a moment please. [OK] Done clearing cache(s). MacPro:drupal8 titouille$
Le service DataService.php a été créé dans le répertoire src/ et un nouveau fichier YAML nommé internal_link.services.yml existe maintenant à la racine du module. Ouvrons ce dernier et regardons son contenu:
services: internal_link.data: class: Drupal\internal_link\DataService arguments: ["@entity.query", "@entity_type.manager", "@entity.repository", "@language_manager"]
Comme vous le voyez, une propriété "class" est indiquée, qui va pointer vers la classe correspondant à mon service. La seconde propriété "arguments" fait références aux services existant que j'ai injecté dans mon service personnalisé à l'aide de Drupal Console. J'ai injecté les services:
- EntityQuery: service permettant d'effectuer des requêtes vers la base de données;
- EntityTypeManager: service de gestion des types d'entité
- EntityRepository: service de stockage des entités, permettant entre autre d'accéder aux traductions d'entité
- LanguageManager: service de gestion des langages, qui propose diverses méthodes pour connaitre la langue en cours, les langages disponibles, etc...
Le fichier src/DataService.php contient quant à lui la base pour mon service orienté données:
/** * @file * Contains \Drupal\internal_link\DataService. */ namespace Drupal\internal_link; use Drupal\Core\Entity\EntityRepository; use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Entity\Query\QueryFactory; use Drupal\language\ConfigurableLanguageManager; /** * Class DataService. * * @package Drupal\internal_link */ class DataService { /** * Drupal\Core\Entity\Query\QueryFactory definition. * * @var Drupal\Core\Entity\Query\QueryFactory */ protected $entity_query; /** * Drupal\Core\Entity\EntityTypeManager definition. * * @var Drupal\Core\Entity\EntityTypeManager */ protected $entity_type_manager; /** * Drupal\Core\Entity\EntityRepository definition. * * @var Drupal\Core\Entity\EntityRepository */ protected $entity_repository; /** * Drupal\language\ConfigurableLanguageManager definition. * * @var Drupal\language\ConfigurableLanguageManager */ protected $language_manager; /** * Constructor. */ public function __construct(QueryFactory $entity_query, EntityTypeManager $entity_type_manager, EntityRepository $entity_repository, ConfigurableLanguageManager $language_manager) { $this->entity_query = $entity_query; $this->entity_type_manager = $entity_type_manager; $this->entity_repository = $entity_repository; $this->language_manager = $language_manager; } }
Nous pouvons voir que les services injectés sont passés directement au constructeur et sont stockés dans des variables protégées. Dans un premier temps, je vais renommer les variables pour qu'elles correspondent à une syntaxe orientée objet (camelCase). Je vais ensuite rajouter une série de méthodes qui vont être utiles par la suite.
Implémentation
Au final, mon service va ressembler à ça:
/** * @file * Contains \Drupal\internal_link\DataService. */ namespace Drupal\internal_link; use Drupal\Core\Entity\EntityRepository; use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Entity\Query\QueryFactory; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\TypedData\TranslatableInterface; use Drupal\language\ConfigurableLanguageManager; /** * Class DataService. * * @package Drupal\internal_link */ class DataService { /** * Drupal\Core\Entity\Query\QueryFactory definition. * * @var Drupal\Core\Entity\Query\QueryFactory */ protected $entityQuery; /** * Drupal\Core\Entity\EntityTypeManager definition. * * @var Drupal\Core\Entity\EntityTypeManager */ protected $entityTypeManager; /** * Drupal\Core\Entity\EntityRepository definition. * * @var Drupal\Core\Entity\EntityRepository */ protected $entityRepository; /** * Drupal\language\ConfigurableLanguageManager definition. * * @var Drupal\language\ConfigurableLanguageManager */ protected $languageManager; /** * Constructor. */ public function __construct(QueryFactory $entity_query, EntityTypeManager $entity_type_manager, EntityRepository $entity_repository, ConfigurableLanguageManager $language_manager) { $this->entityQuery = $entity_query; $this->entityTypeManager = $entity_type_manager; $this->entityRepository = $entity_repository; $this->languageManager = $language_manager; } /** * Get entity query. * * @param string $entity_type * @return \Drupal\Core\Entity\Query\QueryFactory */ private function getEntityQuery($entity_type = 'internal_link') { return $this->entityQuery->get($entity_type); } /** * Get entity type manager. * * @param string $entity_type * @return \Drupal\Core\Entity\EntityTypeManagerInterface */ private function getEntityTypeManager($entity_type = 'internal_link') { return $this->entityTypeManager->getStorage($entity_type); } /** * Get internal link entities by id's. * Retrieve translation if possible, or avoid * not translated entities to display only * translated links. * * @param array $ids * An array of id's. * @return \Drupal\internal_link\Entity\InternalLink[] * An array of internal link entities matching id's paramter */ public function getEntitiesById($ids) { $entities = []; $store = []; // Retrieve original entities if (!empty($ids)) { $entities = $this->getEntityTypeManager()->loadMultiple($ids); } if (!empty($entities)) { // Get current content language. $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); /** @var \Drupal\Core\Entity\EntityInterface $entity */ foreach ($entities as $entity_id => $entity) { // Get possible translation. The repository // check if entity is translatable and return // possible translation, fallback candidate or // current entity of not translatable. $translation = $this->entityRepository->getTranslationFromContext($entity); // Check if entity is translatable but has no translation // in current language and set it as NULL to don't add // it in store. if ($entity instanceof TranslatableInterface && !$entity->hasTranslation($langcode)) { $translation = NULL; } if (isset($translation)) { $store[$entity_id] = $translation; } } } return $store; } /** * Retrieve internal links containing * string parameter in their name. * * @param string $string * A string to compare. * * @return array * An associative array with value/label data. */ public function getNamesContains($string) { $names = []; $query = $this->getEntityQuery(); $query->condition('name', $string, 'CONTAINS'); $ids = $query->execute(); $entities = $this->getEntitiesById($ids); if (!empty($entities)) { foreach ($entities as $id => $entity) { $label = $entity->getName(); $names[] = [ 'value' => $label, 'label' => $label, ]; } } return $names; } /** * Check if an internal link with same * text exists. * * @param string $name * The name to check existence. * @param string $langcode * The langcode to apply to query condition. * * @return boolean * TRUE if an entity with same name exists, else FALSE. */ public function checkExistenceByName($name, $langcode) { $query = $this->getEntityQuery(); $query->condition('name', $name, '=', $langcode); $ids = $query->execute(); return !empty($ids); } /** * Retrieve automatic and both internal links. * * @return \Drupal\internal_link\Entity\InternalLink[] * An array of internal link entities */ public function getAutomaticEntities() { $query = $this->getEntityQuery(); $query->condition('mode', [INTERNAL_LINK_MODE_AUTO, INTERNAL_LINK_MODE_ALL], 'IN'); $query->condition('status', 1); $query->sort('weight', 'DESC'); $ids = $query->execute(); return $this->getEntitiesById($ids); } /** * Retrieve entities by id's. * Use this function to be sure the * internal links returned are enabled. * * @param array $ids * An array of id's. * * @return \Drupal\internal_link\Entity\InternalLink[] * An array of internal link entities matching id's parameter */ public function getEntities($ids) { $query = $this->getEntityQuery(); $query->condition('id', $ids, 'IN'); $query->condition('status', 1); $query->sort('weight', 'DESC'); $ids = $query->execute(); return $this->getEntitiesById($ids); } }
Nous trouvons au sommet les variables correspondants aux services injectés, qui sont passés sous formes d'argument au constructeur de la classe:
/** * Constructor. */ public function __construct(QueryFactory $entity_query, EntityTypeManagerInterface $entity_type_manager, EntityRepositoryInterface $entity_repository, LanguageManagerInterface $language_manager) { $this->entityQuery = $entity_query; $this->entityTypeManager = $entity_type_manager; $this->entityRepository = $entity_repository; $this->languageManager = $language_manager; }
Nous trouvons ensuite 2 méthodes utilitaires qui vont simplifier l'accès au service de requêtes:
private function getEntityQuery($entity_type = 'internal_link') { return $this->entityQuery->get($entity_type); }
ainsi qu'au gestionnaire de types d'entité:
private function getEntityTypeManager($entity_type = 'internal_link') { return $this->entityTypeManager->getStorage($entity_type); }
Ces 2 méthodes font simplement en sorte de retourner les instances de services en leur indiquant le type d'entité (internal_link) à utiliser par défaut. Ainsi, pas besoin d'effectuer cette action à chaque fois que ces deux objets sont nécessaires dans les méthodes de notre service.
Nous trouvons une méthode réutilisable "getEntitiesById" qui prend en argument un tableau d'identifiants et retourne la liste des entités de type internal_link correspondantes:
public function getEntitiesById($ids) { $entities = []; $store = []; // Retrieve original entities if (!empty($ids)) { $entities = $this->getEntityTypeManager()->loadMultiple($ids); } if (!empty($entities)) { // Get current content language. $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); /** @var \Drupal\Core\Entity\EntityInterface $entity */ foreach ($entities as $entity_id => $entity) { // Get possible translation. The repository // check if entity is translatable and return // possible translation, fallback candidate or // current entity of not translatable. $translation = $this->entityRepository->getTranslationFromContext($entity); // Check if entity is translatable but has no translation // in current language and set it as NULL to don't add // it in store. if ($entity instanceof TranslatableInterface && !$entity->hasTranslation($langcode)) { $translation = NULL; } if (isset($translation)) { $store[$entity_id] = $translation; } } } return $store; }
Cette méthode nous assure de retourner des entités dans le langage actuel du contenu. Que le site soit multilingue ou non, les entités retournées corresponderont à la langue en cours, grace à l'utilisation des services "languageManager" et "entityRepository". Ainsi, nul besoin de contrôler ces informations à l'autre bout de la chaine.
Enfin, nous avons quelques méthodes spécifiques:
- getNamesContains($string): retourne la liste des entités dont le nom contient la chaine de caractères passée en argument;
- checkExistenceByName($name, $langcode): retourne une valeur booléene indiquant si une entité possédant le nom $name existe dans le langage $langcode;
- getAutomaticEntities(): retourne toutes les entités dont le mode est "automatique" ou "manuel et automatique", ordonnées par poids;
- getEntities(): retourne toutes les entités correspondantes au tableau d'identifiants passé en argument, et dont le statut est à 1, ordonnées par poids;
Conclusion
En somme, rien de bien compliqué. Cette classe est une classe relativement simple permettant de retourner des données issues de la base de données. Ce qui est intéressant, c'est qu'elle sera injectée à divers endroits et permettra de récupérer des données en exécutant une méthode ou l'autre, plutôt que de coder de manière complète chaque requête vers la base de données. Si à tout hasard nous devions intégrer de nouvelles méthodes, ou modifier des méthodes existantes, il serait relativement simple de le faire, car la logique de récupération des données est maintenant centralisée.
Muni de ce petit service, nous allons pouvoir continuer l'implémentation de notre module. Dans l'article suivant, nous verrons comment personnaliser le formulaire d'édition pour proposer aux gestionnaires de contenu les bonnes informations.