Alexandre Salomé
Alexandre est consultant Symfony2 chez SensioLabs.
Vous pouvez le retrouver sur twitter: @alexandresalome.
Dans cet article, nous parlerons des méta-données Doctrine, comment elles sont utilisées et comment les étendre.
Si vous ne l'avez pas encore fait, lisez la documentation de Doctrine pour comprendre son fonctionnement et son utilisation.
Quand on utilise Doctrine, les classes représentent les tables de la base de données. On les appelles les entités. Ces entités, contrairement aux autres objets, peuvent être persistées par Doctrine en bases de données. Grâce à cela, il est possible plus tard de re-récupérer cette entité depuis la base de données.
Ces entités, qui ne sont rien de plus que des objets PHP, définissent des propriétés et des méthodes.
Il existe deux contextes différents lors du développement d'une classe PHP :
La définition : on crée une classe Blog. On déclare ses propriétés et ses méthodes; nous somme dans le fichier Blog.php
et nous pouvons accéder aux propriétés privées de l'objet;
L'utilisation : Depuis un contrôleur, un service ou une autre entité du modèle, on appelle des méthodes sur les objets. On ne peut pas accéder directement à ses propriétés;
use Doctrine\Common\Collections\ArrayCollection;
class Blog
{
private $id;
private $title;
private $categories = new ArrayCollection();
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @return array
*/
public function getCategories()
{
return $this->categories;
}
/**
* @return array
*/
public function getHighlightedCategories()
{
return $this->categories->filter(function ($category) {
return $category->isHighlighted();
});
}
/**
* @return array
*/
public function getPosts()
{
$posts = array();
foreach ($this->categories as $category) {
$posts = array_merge($posts, $category->getPosts());
}
return $posts;
}
}
A l'utilisation, nous ne manipulons pas directement les propriétés de l'objet. Nous passons systématiquement par des méthodes sur les objets, ce qui permet d'assurer la consistence de l'entité.
$blog->getName();
$blog->getCategories();
$blog->getHighlightedCategories();
foreach ($blog->getPosts() as $post) {
$post->isVisible();
$post->hasTag('foo');
$post->addTag('foo');
}
Grâce à cela, on peut modifier la définition des entités sans toucher à leur utilisation.
Doctrine vous fournit des outils permettant à partir d'une configuration Doctrine de générer les entités et les méthodes associées. Après avoir généré ces méthodes, il est de votre devoir de retirer les méthodes que vous n'allez pas utiliser, comme par exemple setId
. Si vous n'avez pas prévu d'utiliser une méthode, autant la supprimer.
L'intérêt des classes en POO est de pouvoir définir un comportement sur un ensemble de données. Si on s'en tient aux méthodes générées par défaut (get/set), alors la classe n'a pas plus d'intérêt qu'un tableau.
En supprimant les méthodes non-utilisées, vous renforcez votre modèle objet : le nombre de points d'entrée dans la classe est réduit.
Une fois nos entités créées, on indique à Doctrine quelles propriétés persister, les associations et autres méta-données. Dans le cas d'un blog, on définit le mapping suivant :
Blog:
fields:
title: { type: string, length: 128 }
oneToMany:
categories:
targetEntity: Category
mappedBy: blog
Category:
fields:
title: { type: string, length: 128 }
oneToMany:
posts:
targetEntity: Post
inversedBy: category
manyToOne:
blog:
targetEntity: Blog
inversedBy: categories
Posts:
fields:
title: { type: string, length: 128 }
content: { type: text }
manyToOne:
category:
targetEntity: Category
inversedBy: posts
Grâce à cela, nous disons à Doctrine comment sauvegarder nos entités en base de données. Doctrine va donc lire les propriétés de votre entité et mettre à jour la base de données.
Ainsi, d'une requête à l'autre, vous pouvez conserver les entités et leur état.
Pour fonctionner, Doctrine ORM utilise un ensemble de composants (10 différents). Les principaux composants sont les suivants :
Il est ainsi possible d'utiliser facilement ces composants sans utiliser l'ORM et les méta-données. La page des projets Doctrine vous renseignera davantage sur eux.
En interne, l'ORM Doctrine est composé d'une multitude de sous-parties :
Les méta-données sont donc au coeur de Doctrine pour coordonner ces différentes unités.
La classe principalement utilisée avec Doctrine est la classe EntityManager. Cette classe fournit des méthodes "haut-niveau" :
$post = new Post();
$post
->setTitle('Title of the post')
->setContent('Content of the post')
->addTag('foo')
->addTag('bar')
;
$em->persist(post); // insère un nouvel objet
$em->remove(post); // supprime un objet de la base de données
$em->refresh(post); // rafraîchit un objet
$em->flush();
post = $em->find('Post', 32); // recherche par classe et ID
Chacun des appels à ces méthodes va travailler avec la UnitOfWork présente au sein de la classe EntityManager. Cette UnitOfWork va ensuite lire les méta-données pour savoir quelles propriétés persister, leur type, etc.
Les annotations sont très sémantiques et peu verbeuses, et en va de même pour le XML et le YAML. Cependant, pour avoir un fonctionnement efficace, on ne peut pas se permettre de relire à chaque requête les fichiers de configuration pour obtenir ces informations et résoudre les options implicites.
Pour cette raison, une mise en cache est indispensable. Ainsi, quelque soit la complexité de la résolution des méta-données (annotations, XML, YAML), le résultat est mis en cache sous forme sérialisée, assurant ainsi une performance optimale à l'utilisation.
Beaucoup des propriétés dans la classe ClassMetadata sont d'ailleurs publiques, afin d'éviter des appels de méthode.
Dans une application Symfony2, il est important de vérifier les paramètres dans app/config/config_prod.yml
:
doctrine:
orm:
metadata_cache_driver: apc # memcache, redis ...
Une fois configuré, vérifiez grâce à la commande suivante vos paramètres de production :
$ php app/console doctrine:ensure-production-settings --env=prod --no-debug
Environment is correctly configured for production.
Doctrine enregistre votre configuration dans des objets ClassMetadata. Ces objets comportent toutes les informations à propos de la persistence de la classe. Ces objets sont hautement performants, car utilisés partout dans Doctrine.
Les principales propriétés sont les suivantes :
$name
: classe de l'entité$idenfifier
: tableau avec les noms de champs composant la clé primaire de l'entité$fieldMappings
: configuration des champs en base de données$associationMappings
: configuration des relationsPour un détail exhaustif des propriétés de la classe, consultez sa définition.
Il est possible de configurer la persistence de différentes manières : XML, Yaml, PHP, annotations... Pour chacune de ces manières, Doctrine fournit un Driver permettant de charger cette configuration :
Doctrine\ORM\Mapping\Driver\AnnotationDriver
Doctrine\ORM\Mapping\Driver\XmlDriver
Doctrine\ORM\Mapping\Driver\YamlDriver
L'interface du Driver est la suivante :
namespace Doctrine\Common\Persistence\Mapping\Driver;
interface MappingDriver
{
public function loadMetadataForClass($className, ClassMetadata $metadata);
public function getAllClassNames();
public function isTransient($className);
}
Comme vous pouvez le voir, l'interface est assez synthétique et va à l'essentiel : comment charger les méta-données d'une classe. Pour créer un nouveau moyen de charger les méta-données Doctrine, implémentez cette interface et injectez le dans Doctrine.
Même si ce chargement est coûteux, une fois exécuté, le résultat sera mis en cache.
Toute cette théorie peut sembler abstraite, aussi tâchons de les mettre en pratique à travers des exemples concrets.
Supposons que nous souhaitons créer un Driver minimaliste pour charger nos méta-données d'entités. Dans cet exemple, nous resterons simple, mais il est tout à fait possible de charger ses données depuis un fichier JSON ou tout autre source.
Dans notre exemple, nous appellerons une méthode statique loadMetadata()
sur l'entité :
use Doctrine\Common\Persistence\Driver\MappingDriver;
class MyDriver implements MappingDriver
{
private $classNames = array('Blog', 'Post', 'Category');
public function loadMetadataForClass($className, ClassMetadata $metadata)
{
$className::loadMetadatas($metadata);
}
public function getAllClassNames()
{
return $this->classNames;
}
public function isTransient($className)
{
return in_array($className, $this->classNames);
}
}
Finalement dans notre entité :
class Blog
{
public static function loadMetadatas(ClassMetadata $metadata)
{
$metadata
->mapField(array('fieldName' => 'title', 'type' => 'string', 'length' => 128))
;
}
}
Il ne nous reste plus qu'à injecter notre Driver dans notre EntityManager :
use Doctrine\ORM\Tools\Setup;
use Doctrine\ORM\EntityManager;
$dbParams = array();
$config = Setup::createConfiguration(false, null, $cache);
$config->setMetadataDriverImpl(new MyDriver());
$em = EntityManager::create($dbParams, $config);
Pour le paramètre $dbParams
, référez-vous à la [documentation des options de configuration de Doctrine](see http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/configuration.html).
Maintenant, nous souhaitons vérifier que des conventions soient bien respectées dans l'application, à savoir :
_id
Pour cela, nous allons utiliser l'événement postClassMetadata et inspecter les méta-données de la classe :
use Doctrine\Common\EventSubscriber;
use Doctrine\Common\Persistence\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
class StructureSubscriber implements EventSubscriber
{
public function getSubscribedEvents()
{
return array(
Events::loadClassMetadata
);
}
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
{
$metadata = $args->getClassMetadata();
$ids = $metadata->getIdentifierColumnNames();
if (count($ids) > 1) {
throw new \RuntimeException(sprintf(
'Entity "%s" is configured with a composite primary key: %s',
$metadata->getName(),
implode(', ', $ids)
));
}
if ($ids[0] !== 'id') {
throw new \RuntimeException(sprintf(
'Expected entity "%s" to have a primary key named "id", got "%s".',
$metadata->getName(),
$ids[0]
));
}
foreach ($metadata->getAssociationMappings() as $mapping) {
if ($mapping['type'] === ClassMetadata::MANY_TO_ONE) {
$columnName = $mapping['joinColumns'][0]['name'];
if (!preg_match('/_id$/', $columnName)) {
throw new \RuntimeException(sprintf(
'Join column does not end with "_id". Got "%s".',
$columnName
));
}
}
}
}
}
Il ne reste plus qu'à l'injecter via l'EventSubscriber :
use Doctrine\Common\EventManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Setup;
use Subscriber\StructureSubscriber;
$config = Setup::createConfiguration(true);
$config->setMetadataDriverImpl(new Doctrine\BlogDriver());
$conn = array('driver' => 'pdo_sqlite','path' => __DIR__ . '/db.sqlite');
$eventManager = new EventManager();
$eventManager->addEventSubscriber(new StructureSubscriber());
return EntityManager::create($conn, $config, $eventManager);
Dans ce dernier exemple, on veut préfixer toutes les tables du projet par une clé donnée.
L'exemple ci-dessous fait beaucoup de lignes, néanmoins la logique est assez simple :
namespace Subscriber;
use Doctrine\Common\EventSubscriber;
use Doctrine\Common\Persistence\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
class TablePrefixSubscriber implements EventSubscriber
{
private $prefix = 'acme_';
public function getSubscribedEvents()
{
return array(
Events::loadClassMetadata
);
}
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
{
$metadata = $args->getClassMetadata();
$metadata->setTableName($this->prefix.$metadata->getTableName());
}
}
Désormais, on connaît le rôle et le fonctionnement des méta-données de Doctrine. Nous avons pu voir que ces données sont au coeur du fonctionnement de l'ORM.
Nous avons également pu voir que le XML, le YAML et les annotations n'étaient pas les seuls moyens de configurer nos entités. Il est même possible de charger ces données depuis n'importe où, grâce à l'interface du Driver. Nous avons également vu l'événement loadClassMetadata, qui va lui permettre de "hooker" et faire des vérifications ou des modifications au moment du chargement de ces méta-données.
L'intérêt de venir étendre Doctrine est qu'on peut ajouter des fonctionnalités dont profitent toutes les entités du modèle : préfixe de table et validation de structure dans cet article.
Grâce à cela, vous pourrez personnaliser Doctrine pour en faire un ORM complet avec vos règles métier en plus.