Dans le monde merveilleux du développement applicatif orienté objet, il existe un concept permettant de lutter contre la tyrannie de la redondance, du code spaghetti, rigide et qui fuit de toute part: le concept SOLID.
Non, il ne s'agit pas d'un slogan prônant le cadre de vie de Nâdiya (mon dieu que cette ref est vieille), mais bien quelques règles simples popularisées par Robert C. Martin pour utiliser au maximum la POO (Programmation Orientée Objet) pour avoir un code plus compréhensible, flexible et maintenable.
Le mot SOLID est en fait un acronyme, de 5 principes que nous allons détailler et illustrer ici :
Commençons donc par décrire le tout premier principe, à savoir...
Ce principe est très simple. Le but est que chaque classe n'ait qu'une seule responsabilité et donc qu'une seule raison de changer.
Concrètement, imaginons une application de génération de catalogues. On aurait alors une interface CatalogPdfGenerationInterface :
/**
* Génère un catalogue en PDF.
*/
interface CatalogPdfGenerationInterface {
/**
* Génère le catalogue PDF et retourne le fichier généré.
* @param Catalog $catalog Modèle catalogue.
* @return Le chemin du fichier catalogue PDF.
*/
public function getCatalogFile(Catalog $catalog): string;
/**
* Transforme le catalogue en HTML.
* @return string Le HTML.
*/
public function generateHtml(): string;
/**
* Retourne le template utilisé pour la génération HTML du PDF.
* @return string Le chemin du template.
*/
public function getTemplate(): string;
/**
* Sauvegarde le HTML en PDF.
* @return string Le chemin du PDF.
*/
public function savePdf(): string;
}
Avec, pour principe, qu'on crée des classes Catalogue implémentant cette classe, pour avoir tous mes types de catalogues. Oui mais voilà, là on voit qu'une telle classe aurait 2 responsabilités :
Ça parait pas bien grave comme ça, mais voilà, imaginons que pour transformer vos PDF vous utilisez actuellement wkhtmltopdf et, comme il n'est plus maintenu, vous choisissez d'utiliser Gotenberg, vous allez être obligé de modifier tous vos catalogues. Et je vois le petit malin du fond qui me dit "Ooouuuii mais si j'ai implémenté mon savePdf dans une classe parente?". Eh bien Kevin, pour peu qu'un autre développeur ait surchargé cette fonction pour utiliser un paramètre qu'il aurait initialement instancié dans le getTemplate (bizarre, mais vous SAVEZ que c'est possible), eh ben c'est fichu.
Mais on pourrait se dire qu'il ne s'agit que d'une seule logique métier, transformer le catalogue en PDF, c'est pas faux... Cependant, si demain le service marketing vous demande de générer une page web de prévisualisation des catalogues, seule la partie HTML changera. La séparation vous évite d’impacter la génération PDF. Il s'agit toujours d'un arbitrage à avoir.
Autant faire 2 classes qui auront chacune leur responsabilité, par exemple :
interface CatalogHtmlGenerationInterface {
/**
* Transforme le catalogue en HTML.
* @param Catalog $catalog Modèle catalogue.
* @return string Le HTML.
*/
public function generateHtml(Catalog $catalog): string;
/**
* Retourne le template utilisé pour la génération HTML du PDF.
* @return string Le chemin du template.
*/
public function getTemplate(): string;
}
interface CatalogPdfGenerationInterface {
/**
* Génère le catalogue PDF et retourne le fichier généré.
* @param CatalogHtmlGenerationInterface $htmlGenerator Catalog to HTML generator.
* @param Catalog $catalog Modèle catalogue.
* @return Le chemin du fichier catalogue PDF.
*/
public function getCatalogFile(CatalogHtmlGenerationInterface $htmlGenerator, Catalog $catalog): string;
/**
* Sauvegarde le HTML en PDF.
* @return string Le chemin du PDF.
*/
public function savePdf(): string;
}
Et voilà! Plus qu'à changer votre hypothétique CatalogWkhtmltopdfGeneration en CatalogGotenbergGeneration et le tour est joué. En plus pour un développeur qui arriverait derrière, il verra directement votre petit namespace "Pdf\Generation" et saura directement où aller.
Mais très bien de voir que vous maitrisez l'héritage, c'est une bonne transition pour le second principe...
Le but de ce principe est de prévoir son code de manière à ce qu'il soit possible d'ajouter de nouveaux comportements sans modifier le code existant.
En pratique, ça veut dire qu'on va chercher à utiliser un maximum l'héritage (classe parent / enfant), la composition (ne pas tous mettre dans une seule fonction / un seul objet) et l'abstraction (utiliser des classes abstraites / interfaces).
Reprenons notre interface CatalogHtmlGenerationInterface par exemple. On cherche donc à faire en sorte que ce genre de classe :
Okay, dans le principe c'est sympa, mais... Imaginons que j'ai d'un côté un catalogue de crevettes grises pour lequel la confédération indépendantiste des crevettes m'a donné un template en twig, ou j'ai un déjà un beau service qui va me faire ça juste en prenant le chemin de mon twig et une liste de paramètres, et d'un autre côté j'ai l'association pour la libération des crabes qui eux nous donne un template en HTML que je vais devoir parser en DOM pour pouvoir générer mon HTML, car comme tous le monde le sait, les crabes sont plus vieux jeu que les crevettes...
Et là, Kevin dans le fond me dit "bah... C'est pas bien compliqué, je fais 2 classes différente et je gère le tout dans generateHtml". Mmh mmh... Okay. Peux-tu me définir "le tout"? Genre... Pour le twig tu vas transformer le catalogue en paramètres simple, appeler ton service avec getTemplate et tes paramètres, et pour le DOM tu vas... Transformer le catalogue en paramètres, et parser le DOM pour y insérer ces paramètres. N'y a-t-il pas une forme de redondance, un truc qu'on pourrait isoler, coder qu'une seule fois pour ne plus jamais y toucher? Genre... Transformer le catalogue en paramètres simples?
Reprenons notre interface... On va la modifier un peu :
interface CatalogHtmlGenerationInterface {
/**
* Transforme le catalogue en HTML.
* @param Catalog $catalog Modèle catalogue.
* @return string Le HTML.
*/
public function generateHtml(Catalog $catalog): string;
/**
* Transforme le catalogue en HTML.
* @param array $params Les paramètres du template.
* @return string Le HTML.
*/
public function getHtml(array $params): string;
/**
* Retourne le template utilisé pour la génération HTML du PDF.
* @return string Le chemin du template.
*/
public function getTemplate(): string;
/**
* Transforme le catalogue en paramètres de template.
* @param Catalog $catalog Modèle catalogue.
* @return array Les paramètres pour les templates.
*/
public function parseCatalog(Catalog $catalog): array;
}
On a donc rajouté 2 fonctionnalités séparées :
On reste dans de la génération de catalogue en HTML, donc pas d'ajout de responsabilité, on est donc bon avec le premier principe.
Grâce à ces modifications, je vais pouvoir créer une classe abstraite :
abstract class AbstractCatalogHtmlGenerator implements CatalogHtmlGenerationInterface
{
/**
* {@inheritDoc}
*/
public function generateHtml(Catalog $catalog): string
{
$params = $this->parseCatalog($catalog);
return $this->getHtml($params);
}
/**
* {@inheritDoc}
*/
public function parseCatalog(Catalog $catalog): array
{
return [
'title' => $catalog->getTitle(),
'description' => $catalog->getDescription(),
'pages' => array_map([$this, 'parsePage'], $catalog->getPages()),
];
}
/**
* Transforme une page en paramètre simple.
* @return array
*/
public function parsePage(Page $page): array
{
return [
'page' => $page->getPage(),
'title' => $page->getTitle(),
'content' => $page->getContentHtml()
];
}
}
Et voilà. Ce code, de cette classe parente, n'a aucune raison de changer (bon, mise à part si le modèle change... Et on verra ça après). Tous mes autres catalogues n'auront plus qu'à hériter de cette classe, et implémenter getHtml et getTemplate... Je dirais même plus, il ne me reste plus qu'à faire 2 classes enfants AbstractCatalogTwigGenerator et AbstractCatalogDomGenerator qui héritent de ma classe, implémentant getHtml utilisant getTemplate, et je n'aurai plus qu'à implémenter mes classes finales pour chacun de mes catalogues implémentant juste getTemplate. Et plus besoin de revenir dessus.
On pourrait également injecter le générateur de HTML via un constructeur ou un setter, ce qui serait une approche plus flexible que l’héritage si vous préférez la composition, mais j'essaie de rester dans cet article simple, bas niveau et indépendant de tous frameworks.
C'est bien... On a plein de classes, plein d'interfaces... Mais pourquoi s'embêter avec tous ces fichiers... Eh bien encore une bonne transition pour le 3ème principe, à savoir...
Derrière le terme bizarre de substitution de Liskov se cache un truc tout simple... Qu'un enfant puisse être utilisé comme son parent sans que ça pose le moindre souci (technique).
Imaginons donc, par exemple, qu'on aurait décidé que notre générateur de catalogue Twig ressemble à :
abstract class AbstractCatalogTwigGenerator extends AbstractCatalogHtmlGenerator
{
/**
* Service de parsing twig.
* @var Environment|null
*/
private Environment|null $twig = null;
public function setTwigParser(Environment $twig): void
{
$this->twig = $twig;
}
/**
* {@inheritDoc}
*/
public function getHtml(array $params): string
{
if (null === $this->twig) {
throw new RuntimeException("Set the twig parser first!");
}
return $this->twig->render($this->getTemplate(), $params);
}
}
Voyez-vous le problème? Oui Kevin? Non, on s'en fiche que j'ai pas commenté setTwigParser... Le problème, c'est qu'on va devoir utiliser le code suivant pour gérer nos cas actuels :
if ($generator instanceof AbstractCatalogTwigGenerator) {
$generator->setTwigParser(new Environment());
}
$pdf = $generator->getCatalogFile($catalog);
Et... Ce pour chacun de nos générateurs si on garde la logique, car aucune idée du parser de template va être utilisé ou non... On a ici une classe enfant qui a une utilisation différente de son parent, vu que ça ajoute une étape supplémentaire pour ne pas se prendre d'exception...
Pour régler ça, je vois 2 solutions simples :
Sachant que les deux solutions ne sont pas excluantes (getTemplateParser qui retournerait le parser injecté). Pour l'exemple, on va prendre la première solution.
Je vais modifier ma classe parente comme suit :
interface CatalogHtmlGenerationInterface
{
/**
* Get parser.
* @return ParserInterface
*/
public function getParser(): ParserInterface;
}
En se disant que ParserInterface est une interface commune de tous mes parsers. Et du coup, dans ma classe parente:
abstract class AbstractCatalogHtmlGenerator implements CatalogHtmlGenerationInterface
{
/**
* {@inheritDoc}
*/
public function getHtml(array $params): string
{
return $this->getParser()->parse($this->getTemplate(), $params);
}
}
Qu'est-ce que ça change, tu me demandes Kevin? Eh bien c'est simple, ainsi quoi qu'il arrive, mon appel pour générer mon PDF sera:
$pdf = $generator->getCatalogFile($catalog);
Et ce que j'ai un parser Twig, DOM, Jinja2, ou je ne sais quoi... Il n'y a pas de parser DOM dans ton environnement, tu me dis, et tu voudrais utiliser les services déjà là? Haaa, très bien, eh bien c'est une très bonne transition pour le 4ème principe...
Ce principe est également très simple : découper nos héritages pour ne pas avoir à implémenter des fonctions dont la classe ne pourrait pas avoir besoin... Et là, c'est typiquement notre cas. Dans le cas d'un parsing DOM ou il n'y a PAS de parser (et qu'on ne veut pas séparer le parsing dans un service séparé...), on ne va pas pouvoir fournir de retour à getParser.
Une solution aurait pu être de surcharger getHtml et d'implémenter getParser comme suit :
abstract class CatalogDomHtmlGenerator extends AbstractCatalogHtmlGenerator
{
/**
* {@inheritDoc}
*/
public function getHtml(array $params): string
{
$dom = new DOMDocument($this->getTemplate());
// Je remplace mes machins dans mon dom avec mes params.
return $dom->saveHTML();
}
/**
* {@inheritDoc}
*/
public function getParser(): ParserInterface
{
// On s'en fout, c'est pas appelé.
}
}
C'est possible oui... MAIS C'EST MOCHE! Pourquoi je dois implémenter getParser si ça ne sert à rien ?!
Typiquement, ça met en valeur une erreur de conception, ce n'est pas au parent à gérer le template de l'enfant... Mais à l'enfant lui-même, du coup, il faut enlever la fonction getParser de notre interface principale. Du coup on tourne en rond, tu dis Kevin? Eh bien... Non pas du tout. Parce qu'on va juste la déplacer.
Créons ainsi une nouvelle interface:
interface ParserAwareInterface
{
/**
* Get parser.
* @return ParserInterface
*/
public function getParser(): ParserInterface
/**
* Get parser.
* @return ParserInterface
*/
public function setParser(ParserInterface $parser): void;
}
Et donc, notre classe CatalogDomHtmlGenerator n'a plus à implémenter getParser()... Et CatalogTwigHtmlGenerator pourra implémenter cette nouvelle interface...
Quoi Kevin? Ça ne change rien? Il faudra toujours appeler setParser pour notre générateur Twig? C'est vrai... Mais ça, c'est parce que tu ne prend pas en compte le dernier principe...
Ce principe peut paraître complexe sur le papier, mais en vrai c'est tout simple. Il dit qu'il ne faut pas que vos modules de haut niveaux dépendent de modules de bas niveau, mais d'abstractions... En gros, qu'il ne faut pas qu'un de vos services dépende d'autres services, mais d'interfaces implémentés par d'autres services...
Mais... Pourquoi? Qu'est-ce que ça apporte? Eh bien, beaucoup de choses. Prenez notre exemple, on a déjà un cas d'abstraction:
interface CatalogPdfGenerationInterface {
// ...
public function getCatalogFile(CatalogHtmlGenerationInterface $htmlGenerator, Catalog $catalog): string;
// ...
}
En faisant ça utilisant l'interface plutôt que par exemple ma classe parente, je vais pouvoir donner n'importe quel objet ayant les fonctions de CatalogHtmlGenerationInterface. Quoi Kevin, tu me dis qu'il y en a beaucoup à mettre, surtout des fonctions comme getHtml que de toute manière le module final ne va pas pouvoir utiliser... C'est bien, ça fait plaisir de voir que tu suis Kevin.
En effet, là pour l'exemple j'avais mis mes fonctions dans l'interface qui meritraient d'être protected... Mais les fonctions protected sont impossibles dans une interface, bien qu'elles peuvent être structurelles... Les mettre comme fonctions abstraites dans notre classe parente suffit, faisons ça:
interface CatalogHtmlGenerationInterface {
/**
* Transforme le catalogue en HTML.
* @param Catalog $catalog Modèle catalogue.
* @return string Le HTML.
*/
public function generateHtml(Catalog $catalog): string;
}
abstract class AbstractCatalogTwigGenerator extends AbstractCatalogHtmlGenerator
{
/**
* Retourne le template utilisé pour la génération HTML du PDF.
* @return string Le chemin du template.
*/
abstract protected function getTemplate(): string;
// ... Les autres fonctions ont déjà été implémentées ici, il faut juste les mettre en protected
}
Et voilà ~. Votre générateur de PDF n'a plus besoin que d'un objet implémentant CatalogHtmlGenerationInterface, et donc uniquement une fonction generateHtml qui, à partir d'un catalogue, retourne du HTML. Qu'il y ait un parser ou non, un template ou non. On peut même imaginer un objet qui retournerait du HTML directement construit par chaine de caractère que ça fonctionnera.
Cela permet également de mieux gérer nos dépendances... Reprenons la dernière interface créée qui paraissait inutile, en la modifiant un petit peu...
interface ParserAwareInterface
{
/**
* Get parser.
* @return ParserInterface
*/
public function getParser(): ParserInterface
/**
* Get parser.
* @return ParserInterface
*/
public function setParser(ParserInterface $parser): void;
/**
* Get parser class.
* @return string
*/
public function getParserClass(): string;
}
Vous voyez pas l'intérêt? Bon certes, c'est très succinct et il vaudrait mieux un getParserFactory plutôt qu'un getParserClass, mais pour l'exemple ça suffira. Maintenant, grâce à ça, on peut avoir du code du genre :
public function getCatalogFile(CatalogHtmlGenerationInterface $htmlGenerator, Catalog $catalog): string
{
if ($htmlGenerator instanceof ParserAwareInterface) {
$htmlGenerator->setParser(new $htmlGenerator->getParserClass());
}
$this->html = $htmlGenerator->generateHtml($catalog);
// Génère le pdf à partir de mon attribut $html.
return $this->savePdf();
}
Et voilà ~. Maintenant, quel que soit le générateur avec parser que vous avez, du moment qu'il implémente ParserAwareInterface, l'injection est gérée. Dans la limite où le constructeur du parser ne prend pas de paramètres, mais encore, c'est pour l'exemple, une factory aurait été mieux. Ce bout de code pourrait encore être externalisé pour gérer TOUT objets ayant ParserAwareInterface, pas seulement les générateurs, c'est d'ailleurs ce que fait un certain nombre de service Symfony.
Si vous respectez le principe SOLID pour vos applications, vous devriez très vite voir :
Mais cela multiplie le nombre de classes et d'interfaces, ce qui peut facilement rebuter les développeurs les plus débutants (oh mon dieu ton code à plein de fichiers partout je comprends rien!). Soyez donc plus rigoureux sur vos commentaires et documentation... Et ça tombe bien, parce qu'en SOLID, il y en a moins à écrire vu que le code est généralement plus atomisé.
Également, la POO en PHP souffre d'une limitation qui n'existe pas en C++ par exemple: il est impossible d'hériter plusieurs classes parentes. Du coup, pour éviter la duplication de code, n'hésitez pas à utiliser les traits. Par exemple, pour notre cas de ParserAwareInterface :
trait ParserAwareTrait
{
/**
* Parser.
* @var ParserInterface
*/
private ParserInterface|null $parser = null;
/**
* {@inheritDoc}
*/
public function setParser(ParserInterface $parser): void
{
$this->parser = $parser;
}
/**
* {@inheritDoc}
*/
public function getParser(): ParserInterface
{
return $this->parser;
}
}
Et voilà ~. Comme ça il n'y aura plus qu'à implémenter getParserClass sur tous vos parsers, et le tour est joué:
abstract class AbstractCatalogPdfTwigGenerator extends AbstractCatalogHtmlGenerator implements
ParserAwareInterface
{
// Défini setParser et getParser()
use ParserAwareTrait;
/**
* {@inheritDoc}
*/
public function getParserClass(): string
{
return Twig\Environment::class;
}
/**
* {@inheritDoc}
*/
public function getHtml(array $params): string
{
if (null === $this->parser) {
throw new RuntimeException("Parser not set. Did you use the dependency injection?");
}
return $this->parser->parse($this->getTemplate(), $params);
}
}
final class ShrimpCatalogPdfGenerator extends AbstractCatalogPdfTwigGenerator
{
/**
* {@inheritDoc}
*/
public function getTemplate(): string
{
return "templates/shrimpCatalog.twig.html";
}
}
Et voilà ~. Si demain j'ai un autre type de génération HTML nécessitant un parser, par exemple en Jinja2, je n'aurai juste qu'à réimplémenter un AbstractCatalogPdfJinja2Generator avec getParserClass et getHtml, si je ne l'ai pas lui aussi déplacé sur une autre classe abstraite ou un autre trait (si c'est toujours la même fonction par exemple), et sans rien changer d'autre, le tour est joué.
N'hésitez pas à nous contacter si vous avez besoin d'une expertise PHP ou en POO.