Prérequis

Il n'y a aucun prérequis pour ce cours

Description

Les schémas de conception (ou pattern design en Anglais) sont des solutions de conception en réponse à des problématiques souvent rencontrées par les développeurs. Aujourd'hui, nous allons voir le Singleton, qui permet de s'assurer d'une instance unique d'une classe donnée. Utile par exemple dans le cas des connexions aux bases de données ou dans le cas de la gestion des sessions utilisateurs.
Arthur, l'apprenti développeurSalut ! Aujourd'hui dans mon apprentissage de la Programmation Orientée Objet (POO) je suis en train de voir les pattern design, les schémas de conception en Français. Pourrais-tu m'aider ?

Entendu, nous allons commencer par le pattern Singleton. Ce schéma de conception est une réponse à la problématique suivante :
- Comment faire pour m'assurer de n'avoir qu'une seule instance de classe ?
Ce pattern design est très controversé dans le monde du web et de PHP. En effet, il est souvent considéré comme un "anti-pattern", c'est à dire qu'on l'utilise à mauvais escient. Ce qu'il faut bien comprendre, c'est que ce pattern est à utiliser quand il y a un RÉEL besoin de ne pas instancier sa classe plusieurs fois. Autrement dit, on n'implémente pas Singleton sur un contrôleur parce que ça nous fait plaisir. Ce n'est pas grave si un contrôleur a plusieurs instances en cours !

L'idée de ce pattern est la suivante :
- Si je suis déjà instancié, alors je retourne cette instance
- Si je ne le suis pas, je m'instancie, je stocke cette instance et je la retourne



Prérequis


Pour ce tutoriel, il te faudra quelques prérequis :
- Des bonnes bases en PHP et en programmation orientée objet (classe, méthode, visibilité, héritage, statisme)
- Un environnement pour pouvoir tester le code si tu le désires (php/apache/mysql par exemple avec WAMP/MAMP/LAMP)

Je t'accompagnerai pas à pas au cours de ce tutoriel pour que tu comprennes bien la problématique et la solution, ne t'inquiète pas !

Le problème


Imaginons le code simplifié ci-dessous. Nous avons un code assez commun avec un Manager parent et un enfant.

{"language":"application/x-httpd-php","content":"<?php\nabstract class Manager{\n\tprotected $db;\n\n\tpublic function __construct() {\n\t\t$this->db = 'Imaginons qu\\'ici je me connecte à la BDD avec new PDO...';\n\t}\n}","filename":"Manager.php"}


{"language":"application/x-httpd-php","content":"<?php\nclass UserManager extends Manager{\n\tpublic function getUser() {\n\t\t/**$req = $this->db->query('select * from users');\n\t\treturn $req->fetchAll();**/\n\t\treturn 'plein de users';\n\t}\n}","filename":"UserManager.php"}


{"language":"application/x-httpd-php","content":"<?php\n\n$userManager = new UserManager();\n$users = $userManager->getUser();\nvar_dump($users);","filename":"index.php"}


Comme on peut s'y attendre, le résultat est le suivant :

{"language":"shell","content":"string(14) \"plein de users\"","filename":""}


Arthur, l'apprenti développeurJusqu'ici, je ne vois pas vraiment de problème...

En effet, pour le moment, on imagine mal le souci.
Pour compléter, rajoutons un nouveau manager qui gèrera des articles.

{"language":"application/x-httpd-php","content":"<?php\nclass ArticleManager extends Manager{\n\tpublic function getArticles() {\n\t\t/**$req = $this->db->query('select * from articles');\n\t\treturn $req->fetchAll();**/\n\t\treturn 'plein d\\'articles';\n\t}\n}","filename":"ArticleManager.php"}


{"language":"application/x-httpd-php","content":"<?php\n$userManager = new UserManager();\n$users = $userManager->getUser();\n\n$articleManager = new ArticleManager();\n$articles = $articleManager->getArticles();\nvar_dump($users);\nvar_dump($articles);","filename":"index.php"}


Maintenant, je te pose une question. À ton avis, combien de fois passe-t-on par le constructeur du Manager parent ?
La réponse est assez simple, mais pour nous en convaincre, je vais rajouter un echo 'Je suis dans le constructeur'.

{"language":"application/x-httpd-php","content":"<?php\nabstract class Manager{\n\tprotected $db;\n\n\tpublic function __construct() {\n\t\t$this->db = 'Imaginons qu\\'ici je me connecte à la BDD avec new PDO...';\n\t\techo \"Je suis dans le constructeur \\n\";\n\t}\n}","filename":"Manager.php"}

Résultat : Je suis dans le constructeur Je suis dans le constructeur string(14) "plein de users" string(16) "plein d'articles"

Comme on peut le voir, nous sommes passés 2 fois par le constructeur. Bilan : on a ouvert 2 connexions à la base de données. Et cela peut aller très vite, on l'imagine, dans une application avec plusieurs managers, plusieurs contrôleurs etc etc. Pour éviter ce problème, nous allons donc utiliser le pattern Singleton.

La mise en place du singleton


Nous allons donc créer une nouvelle classe que nous allons appeler, manquant d'imagination, "Database". Cette classe sera chargée de réaliser la connexion à la base de données. Elle pourrait également se charger de définir les configurations pour la base de données etc etc, mais dans le cadre de ce tutoriel, la connexion suffira amplement ;)

Arthur, l'apprenti développeurMais pourquoi créer une nouvelle classe ? Ne peut-on pas appliquer le pattern Singleton sur la classe Manager directement ?

En fait, si nous appliquons ça directement sur le Manager, le problème est multiple :
- Déjà, on ne peut appliquer le singleton si on a de l'héritage par la suite
- Ensuite, si on veut faire un code très propre, avoir un manager qui charge une connexion PDO à la base de données impose à chaque développeur de notre code d'utiliser PDO... Ce qui est un peu contraignant. Si un développeur souhaite utiliser une autre technologie, il faudra changer la classe et l'héritage sur chaque manager, pas très pratique.

Nous allons donc utiliser le principe de la composition, c'est à dire qu'une classe va créer (instancier) et posséder une autre classe.

Commençons notre classe Database simplement avec un constructeur qui crée l'instance de PDO :

{"language":"application/x-httpd-php","content":"<?php\n\nclass Database {\n\n\tprivate $pdo;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tpublic function __construct() {\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}","filename":"Database.php"}


Maintenant, nous allons donc modifier notre Manager pour qu'il ait une instance de cette classe.

{"language":"application/x-httpd-php","content":"<?php\nabstract class Manager{\n\tprotected $db;\n\n\tpublic function __construct() {\n\t\t$this->db = new Database();\n\t}\n}","filename":"Manager.php"}


Arthur, l'apprenti développeurAttends mais là, rien n'a changé, on a juste déplacé le problème non ?

Exactement, tu as tout compris. Ce n'est que maintenant que nous allons appliquer le pattern singleton sur Database.

Rappelons-nous rapidement ce que nous voulons faire :
- Si une instance existe, alors on la retourne, sinon on la crée
Autrement dit, si une instance n'existe pas, c'est uniquement dans ce cas là que nous passerons par le constructeur de la classe Database.

Il y a donc une méthode subsidiaire à créer qui fera la vérification de l'instance.

Arthur, l'apprenti développeurMais comment savoir que notre classe est déjà instanciée ?

Excellente question. En fait, on va tout simplement stocker cette information dans un attribut propre à la classe implémentant le singleton, généralement appelé instance.

{"language":"application/x-httpd-php","content":"<?php\n\nclass Database {\n\n\tprivate $pdo;\n\tprivate $instance = null;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tpublic function __construct() {\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}\n\n\tpublic static function getInstance() {\n\t\t// Si self::$instance est à null, alors on s'instancie et on stocke cette instance dans self::$instance\n\t\t// On retourne self::$instance dans tous les cas\n\t}","filename":"Database.php"}


Nous allons alors compléter la fonction getInstance. Tu l'auras sans doute compris, nous n'avons eu d'autre choix que de la mettre statique pour pouvoir l'appeler sans instancier la classe au préalable... (sinon, c'est le chat qui se mord la queue !).

{"language":"application/x-httpd-php","content":"<?php\n// Déclaration de la classe...\npublic static function getInstance() {\n\t\tif(!(self::$instance instanceof self)) { // On vérifie si $instance contient une instance de nous-même\n\t\t\tself::$instance = new self(); // si non, on s'instancie et on stocke cette instance\n\t\t}\n\t\treturn self::$instance;\n\t}","filename":"Database.php"}


Je te rappelle qu'on utilise self::$instance et non pas $this->instance car nous sommes dans un contexte statique.

Super, maintenant, quelque chose doit te venir à l'esprit. Il y a une amélioration à réaliser au niveau du constructeur.

Arthur, l'apprenti développeurPeut être concernant sa visibilité ? Vu qu'on ne doit pas pouvoir l'instancier sans passer par getInstance...

C'est exactement cela !Jamais on ne devra pouvoir faire depuis l'extérieur new Database(). Du coup, il faut mettre cette méthode en visibilité privée. On en est là :

{"language":"application/x-httpd-php","content":"<?php\n\nclass Database {\n\n\tprivate $pdo;\n\tprivate $instance = null;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tprivate function __construct() {\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}\n\n\tpublic static function getInstance() {\n\t\tif(!(self::$instance instanceof self)) {\n\t\t\tself::$instance = new self();\n\t\t}\n\t\treturn self::$instance;\n\t}\n}\n\nabstract class Manager{\n\tprotected $db;\n\n\tpublic function __construct() {\n\t\t$this->db = Database::getInstance();\n\t}\n}\n\nclass UserManager extends Manager{\n\tpublic function getUser() {\n\t\t/**$req = $this->db->query('select * from users');\n\t\treturn $req->fetchAll();**/\n\t\treturn 'plein de users';\n\t}\n}\n\nclass ArticleManager extends Manager{\n\tpublic function getArticles() {\n\t\t/**$req = $this->db->query('select * from articles');\n\t\treturn $req->fetchAll();**/\n\t\treturn 'plein d\\'articles';\n\t}\n}\n\n$userManager = new UserManager();\n$users = $userManager->getUser();\n\n$articleManager = new ArticleManager();\n$articles = $articleManager->getArticles();\nvar_dump($users);\nvar_dump($articles);\n\n","filename":"all"}


Normalement, tu obtiens l'erreur suivante : "Fatal error: Uncaught Error: Access to undeclared static property: Database::$instance ". Sais-tu pourquoi ?
La réponse est la suivante : nous n'avons pas déclaré l'attribut instance en statique. Si on ne le met pas en statique, nous aurons un problème. En effet, le but est que toutes les instances de Database qu'on puisse avoir sachent qu'une instance est déjà existante. Autrement dit, il faut que l'attribut $instance soit partagée entre toutes les instances de Database. Pour ça, on doit le définir en statique.

Arthur, l'apprenti développeurC'est très clair, super. Mais du coup, comment on fait pour faire des requêtes maintenant ? Parce qu'actuellement dans mes managers je n'ai pas accès à $pdo !

On y vient. Dans la suite du tutoriel, je vais te présenter deux manières de faire. Une façon simple, qui marche bien, et une façon un peu plus complexe mais plus classe :).

Cependant je dois t'avouer quelque chose... Actuellement, il est encore possible d'avoir plusieurs instances de notre classe Database. Hé oui :

  • Si on fait un héritage, on dupliquera les instances

  • Si on clone la classe, on dupliquera les instances

  • Si on sérialise une instance, et qu'on la désérialise, on aura encore une instance


Ces situations sont peu communes mais existent ! Il faut palier à ces problèmes. Tu sais comment faire ?
Premièrement, pour éviter l'héritage, on va utiliser le mot-clé [font-bold]final[/font-bold].
Pour les 2 autres, PHP met à notre disposition des méthodes magiques qui interceptent ces deux situations.

Ainsi, on doit modifier notre classe Database comme suit :

{"language":"application/x-httpd-php","content":"<?php\n\nfinal class Database {\n\n\tprivate $pdo;\n\tprivate static $instance = null;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tprivate function __construct() {\n\t\techo 'Je passe dans le constructeur';\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}\n\n\tfinal public static function getInstance() {\n\t\tif(!(self::$instance instanceof self)) {\n\t\t\tself::$instance = new self();\n\t\t}\n\t\treturn self::$instance;\n\t}\n\n\tpublic function __clone() {\n\t\tthrow new LogicException('Interdit de cloner un singleton ! ');\n\t}\n\n\tpublic function __wakeup() {\n\t\tthrow new LogicException('Interdit de faire des instances en désérialisant ! ');\n\t}\n}","filename":"Database.php"}


Parfait ! Passons à la partie pratique avec l'utilisation de $pdo maintenant.

Récupérer $pdo pour faire des requêtes


Nous allons donc faire de manière simple dans un premier temps. Simple ne veut pas dire mal, autrement dit, on ne va pas mettre l'attribut $pdo en visibilité publique ! Ce serait contre le principe d'encapsulation.
Du coup, on va à juste titre faire un getter pour pdo.

{"language":"application/x-httpd-php","content":"<?php\n\nfinal class Database {\n\n\tprivate $pdo;\n\tprivate static $instance = null;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tprivate function __construct() {\n\t\techo 'Je passe dans le constructeur';\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}\n\n\tfinal public static function getInstance() {\n\t\tif(!(self::$instance instanceof self)) {\n\t\t\tself::$instance = new self();\n\t\t}\n\t\treturn self::$instance;\n\t}\n\n\tpublic function __clone() {\n\t\tthrow new LogicException('Interdit de cloner un singleton ! ');\n\t}\n\n\tpublic function __wakeup() {\n\t\tthrow new LogicException('Interdit de faire des instances en désérialisant ! ');\n\t}\n\n\n\tpublic function pdo() {\n\t\treturn $this->pdo;\n\t}\n}","filename":"Database.php"}

On n'a plus à appeler la méthode pdo() quand on en a besoin !

Arthur, l'apprenti développeurC'est vrai que cette solution me plait bien : simple et efficace !

{"language":"application/x-httpd-php","content":"<?php\n\nabstract class Manager{\n\tprotected $db;\n\n\tpublic function __construct() {\n\t\t$this->db = Database::getInstance();\n\t}\n}\n\nclass UserManager extends Manager{\n\tpublic function getUser() {\n\t\t$req = $this->db->pdo()->query('select * from users');\n\t\treturn $req->fetchAll();\n\t}\n}\n\nclass ArticleManager extends Manager{\n\tpublic function getArticles() {\n\t\t$req = $this->db->pdo()->query('select * from articles');\n\t\treturn $req->fetchAll();\n\t}\n}","filename":"All managers"}


Tu peux vérifier qu'on ne passe bien qu'une fois dans le constructeur de Database grâce au echo que j'ai rajouté.



[facultatif] Récupérer $pdo avec une méthode magique


Pour simplifier l'utilisation, l'idée serait que tout appel de méthodes de Database qui ne sont pas définies soit interceptée et redirigé vers l'instance de PDO si PDO possède cette méthode.
En termes plus simples, c'est éviter de rajouter dans notre classe Database ce genre de chose (voir code ci après) et faire la même chose de manière automatique :

{"language":"application/x-httpd-php","content":"<?php\n\nfinal class Database {\n\n\tprivate $pdo;\n\tprivate static $instance = null;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tprivate function __construct() {\n\t\techo 'Je passe dans le constructeur';\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}\n\n\tfinal public static function getInstance() {\n\t\tif(!(self::$instance instanceof self)) {\n\t\t\tself::$instance = new self();\n\t\t}\n\t\treturn self::$instance;\n\t}\n\n\tpublic function __clone() {\n\t\tthrow new LogicException('Interdit de cloner un singleton ! ');\n\t}\n\n\tpublic function __wakeup() {\n\t\tthrow new LogicException('Interdit de faire des instances en désérialisant ! ');\n\t}\n\n\tpublic function query(string $request) {\n\t\treturn $this->pdo->query($request);\n\t}\n\n\tpublic function execute(string $request) {\n\t\treturn $this->pdo->execute($request);\n\t}\n\n\t/// pareil avec fetch, fetchAll, setFetchMode....\n}","filename":"Database.php"}


Heureusement, PHP possède une méthode magique __call() qui permet de faire exactement ce qu'on veut.

Arthur, l'apprenti développeurHeureusement, car ça me paraissait très long de tout écrire à la main !

Ce qu'on va faire, c'est donc récupérer le nom de la fonction appelée, ses arguments et s'assurer que :
- La fonction appelée est une méthode de PDO
- Il n'y a qu'un seul argument, qui est une chaine de caractères correspondant à la requête
Puis on appellera cette fonction sur notre instance de PDO.

{"language":"application/x-httpd-php","content":"<?php\n\nfinal class Database {\n\n\tprivate $pdo;\n\tprivate static $instance = null;\n\tconst USER = 'antoine';\n\tconst PASSWORD = 'devpass';\n\n\tprivate function __construct() {\n\t\techo 'Je passe dans le constructeur';\n\t\ttry {\n\t\t\t$this->pdo = new \\PDO('mysql:host=127.0.0.1;port=3306;dbname=name', self::USER, self::PASSWORD);\n\t\t\t$this->pdo->setAttribute(\\PDO::ATTR_ERRMODE, \\PDO::ERRMODE_SILENT);\n\t\t} catch(\\PDOException $error) {\n\t\t\techo $error->getMessage();\n\t\t}\n\t}\n\n\tfinal public static function getInstance() {\n\t\tif(!(self::$instance instanceof self)) {\n\t\t\tself::$instance = new self();\n\t\t}\n\t\treturn self::$instance;\n\t}\n\n\tpublic function __clone() {\n\t\tthrow new LogicException('Interdit de cloner un singleton ! ');\n\t}\n\n\tpublic function __wakeup() {\n\t\tthrow new LogicException('Interdit de faire des instances en désérialisant ! ');\n\t}\n\n\tpublic function __call(string $name, array $arguments) {\n\t\tif(count($arguments) === 1 and method_exists(\\PDO::class, $name)) {\n\t\t\treturn $this->pdo->$name($arguments[0]);\n\t\t}\n\t}\n}","filename":"Database.php"}


On n'a plus qu'à modifier légèrement nos managers, et on a fini.

{"language":"application/x-httpd-php","content":"<?php\n\nabstract class Manager{\n\tprotected $db;\n\n\tpublic function __construct() {\n\t\t$this->db = Database::getInstance();\n\t}\n}\n\nclass UserManager extends Manager{\n\tpublic function getUser() {\n\t\t$req = $this->db->query('select * from users');\n\t\treturn $req->fetchAll();\n\t}\n}\n\nclass ArticleManager extends Manager{\n\tpublic function getArticles() {\n\t\t$req = $this->db->query('select * from orders');\n\t\treturn $req->fetchAll();\n\t}\n}\n\n$userManager = new UserManager();\n$users = $userManager->getUser();\n\n$articleManager = new ArticleManager();\n$articles = $articleManager->getArticles();\nvar_dump($users);\nvar_dump($articles);","filename":"All managers"}


Arthur, l'apprenti développeurJe pense que j'ai tout compris ! merci à toi encore une fois.

Pas de soucis Arthur, reviens me voir quand tu veux.

Auteur

Antoine Creuzet
  • Développeur web freelance et mentor
  • En formation d'ingénieur en sécurité des systèmes embarqués
  • Artisan du web fan de Laravel, TailwindCSS, Vue, Alpine

Commentaires

Antoine Creuzet
04/01/2021 à 07:11
Merci pour votre retour Patrick Chardavoine, ravi que ce cours vous ait plu !
Patrick Chardavoine
02/01/2021 à 19:49
merci, excellent choix que celui de cet exemple très pratique !