(English version: https://dev.to/fredbouchery/dx-setter-wither-and-mutants-29e6)
Quand je conçois un code, je passe beaucoup de temps à réfléchir à sa DX (Developer eXperience), c'est d'ailleurs pour cela que j'apprécie les "test before", car cela me permet de réfléchir à la façon d'utiliser le code avant de l'implémenter.
Dernièrement, je concevais une classe de configuration pour une couche d'accès à une BDD et voici la façon dont j'avais initialement imaginé son usage :
<?php
$conf = (new ConnectionConfig())
->connection('main')
->driver(DriverInterface::DRIVER_MYSQL)
->primary()
->host('127.0.0.1', 3306)
->credentials('root', 'root')
->replica()
->host('127.0.0.1', 3307)
->credentials('readonly', 'readonly')
;
Au niveau implémentation, c'est une interface chaînée (fluent interface) assez simple :
-
connection(string $name)
vient définir une propriété "$currentConnectionName
" -
primary()
oureplica()
viennent définir une propriété$currentReplicationServer
Ensuite, quand on appelle host()
ou credentials()
, j'utilise les propriétés "$currentXxxx
" pour stocker l'information.
Puis, j'ai voulu définir comment j'allais lire ces informations, et j'ai commencé à écrire ça :
<?php
[
'login' => $login,
'password' => $password
] = $conf
->connection('main')
->primary()
->getCredentials()
;
Premier constat : Pffff ... j'ai eu la flemme de définir un objet "ServerCredentials" pour y stocker le login et le pass, alors qu'un tableau associatif faisait bien l'affaire. Ne me regardez pas avec cette expression sévère, on l'a tous fait ... les jours de flemme, et comme le dirait Krän : "Y'a des jours, faut pas m'chercher ! Et y'a des jours tous les jours !". Mais c'est pas le débat aujourd'hui, voyons le problème suivant que je voudrais mettre en valeur.
Pour lire l'identité, j'ai utilisé un getter getCredentials()
alors que pour la définir, j'ai utilisé la simple méthode credentials($login, $pass)
, car je trouvais ça plus cool pour ma DX.
Simplement, quand on se place du point de vue de la personne qui va récupérer la définition, déjà, ce n'est pas habituel d'avoir un getter et une méthode d'assignation qui ne commence pas par "set", et en plus, cela peut être confusant quand l'IDE va autocompléter et proposer ces deux méthodes.
Bon, certes, il n'y a pas trop d'ambiguïté, car une méthode avec deux paramètres $login
et $pass
, il est probable qu'elle permette de les assigner. Mais, j'ai quand même préféré changer ça avec des setters.
Oui, car quand on conçoit une bonne DX, il faut éviter de trop changer les habitudes des développeuses et des développeurs ! Même si votre idée semble révolutionnaire, innovante, jamais faite auparavant, cela peut desservir votre DX drastiquement.
Me voilà donc à changer la définition :
<?php
$conf = (new ConnectionConfig())
->connection('main')
->setDriver(DriverInterface::DRIVER_MYSQL)
->primary()
->setHost('127.0.0.1', 3306)
->setCredentials('root', 'root')
->replica()
->setHost('127.0.0.1', 3307)
->setCredentials('readonly', 'readonly')
;
Et là, une nouvelle difficulté s'est présenté à moi : les mutants !
Regardez ce code:
<?php
$serverIp = '127.0.0.1';
$login = 'root';
$password = 'root';
$conf = new ConnectionConfig();
$primary = $conf->connection('main')->primary();
$replica = $conf->connection('main')->replica();
// Define hosts
$primary->setHost($serverIp, 3306);
$replica->setHost($serverIp, 3307);
// Define credentials
$primary->setCredentials($login, $password);
$replica->setCredentials($login, $password);
Oui, on imagine une façon d'utiliser un code et la personne va le faire d'une toute autre manière, c'est son droit.
Seulement, dans le cas présent, il y a un bug assez gênant : le serveur primaire n'est jamais défini. Si vous ne voyez pas pourquoi, je vous laisse essayer de comprendre avant de lire l'explication qui va suivre.
.
.
.
.
.
.
.
.
.
Comme je l'ai indiqué au début, quand on appelle primary()
ou replica()
, on vient définir une propriété $currentReplicationServer
, et comme j'utilise une interface "fluent", les variables $primary
et $replica
contiennent toutes les deux la même instance d'objet. Lors de l'appel à ->replica()
la propriété $currentReplicationServer
va donc être modifiée pour indiquer que l'on configure le réplica, ce qui concerne également l'instance dans la variable $primary
(je vous laisse relire si vous n'avez pas bien compris).
On tombe dans un grand classique en PHP (et d'autres langages) : la mutabilité des objets.
Pour résoudre cela, on utilise l'auto-clonage, on change la propriété et on retourne la nouvelle instance. On passe donc de :
<?php
public function primary(): self
{
$this->currentReplicationConfiguration = 'primary';
return $this;
}
À :
<?php
public function primary(): self
{
$self = clone $this;
$self->currentReplicationConfiguration = 'primary';
return $self;
}
Et on va appliquer cette technique sur l'ensemble des méthodes, sauf que ... ça ne fonctionne pas. Car dans le cas :
<?php
$conf = new ConnectionConfig();
$primary = $conf->connection('main')->primary();
$replica = $conf->connection('main')->replica();
Ce que je souhaite, c'est que $conf contienne tous les éléments de configuration, pour la connexion "main", avec le serveur primaire et le réplica, et au final, on a 3 instances de la classe de configuration avec des valeurs dissociées.
Et c'est bien tout le problème du design "fluent" couplé à un changement d'état (stateful vs stateless) : Si le comportement d'une méthode dépend de l'appel d'une autre, vous êtes dans le pétrin !
Il y a une possibilité pour résoudre partiellement ça, c'est en faisant comprendre au développeur, ou à la développeuse, que la méthode retourne une nouvelle instance : on va remplacer les "setters" par des "withers" :
<?php
$conf = new ConnectionConfig();
$primary = $conf->withConnection('main')->withPrimary();
En utilisant le préfixe "with
" cela indique que le retour sera une nouvelle instance de l'objet, donc $conf
n'est pas la même instance que $primary
. Le développeur ou la développeuse va donc devoir chaîner ses appels, sinon le résultat final ne contiendra pas ce qu'il souhaite.
En réalité, on n'a pas résolu la question par du code supplémentaire, mais juste par un simple renommage, car, oui, la conception d'un code, et d'une bonne DX, passe aussi par son nommage.
Le code final serait :
<?php
$conf = (new ConnectionConfig())
->withConnection('main')
->setDriver(DriverInterface::DRIVER_MYSQL)
->withPrimary()
->setHost('127.0.0.1', 3306)
->setCredentials('root', 'root')
->withReplica()
->setHost('127.0.0.1', 3307)
->setCredentials('readonly', 'readonly')
;
Et, si on revient à l'accès, cela donne :
<?php
[
'login' => $login,
'password' => $password
] = $conf
->withConnection('main')
->withPrimary()
->getCredentials()
;
Franchement, je trouve pas ça dingue comme DX, car dans mon petit cerveau, j'ai tendance à associer les "withers" à de la construction et non de l'accès.
C'est la quadrature du cercle, vous ne trouvez pas ?
En fait non, c'est tout simplement parce que j'ai fait fausse route depuis le début, et cela a commencé quand j'ai écrit que j'avais eu la flemme !
- Ma classe aurait dû s'appeler "
ConnectionsConfiguration
" avec un "s" et un mot complet (Arrêtez les raccourcis, c'est pas 7 caractères de plus qui vont impacter votre productivité) - Il ne devrait y avoir qu'une seule méthode "
connection(string $name)
" qui retourne un objet "ConnectionConfiguration
" (sans le "s" donc) -
ConnectionConfiguration
dispose de trois méthodessetDriver()
,primary()
etreplica()
, dont les deux dernières retournent des instances mutables deServerConfiguration
. -
ServerConfiguration
contient quatres méthodessetHost()
,setCredentials()
,getHost()
etgetCredentials()
gérant des objetsServerHost
etServerCredentials
Au final, le code sera :
<?php
$conf = (new ConnectionsConfiguration())
->connection('main')
->setDriver(DriverInterface::DRIVER_MYSQL)
->primary()
->setHost('127.0.0.1', 3306)
->setCredentials('root', 'root')
->replica()
->setHost('127.0.0.1', 3307)
->setCredentials('readonly', 'readonly')
;
Sauf qu'avec ce code, je n'ai plus une interface fluent et ... j'ai une erreur fatale !
Si vous regardez attentivement, après le setCredentials()
, j'appelle replica()
, mais cette méthode n'existe pas, car setCredentials()
ne retourne pas un ConnectionConfiguration
mais un ServerCredentials
. De même, la variable "$conf
" ne contient pas un objet ConnectionsConfiguration
. Bref, sans interface fluent, il faudrait écrire comme ceci :
<?php
$conf = new ConnectionsConfiguration();
$conf->connection('main')
->setDriver(DriverInterface::DRIVER_MYSQL)
->primary()
->setHost('127.0.0.1', 3306)
->setCredentials('root', 'root')
;
$conf->connection('main')
->replica()
->setHost('127.0.0.1', 3307)
->setCredentials('readonly', 'readonly')
;
C'est tout de suite moins joli que les enchaînements d'appels d'une fluent, mais on aura beaucoup moins de risque de bug.
Pour l'accès à la configuration, cela donne :
<?php
$credentials = $conf
->connection('main')
->primary()->
->getCredentials()
;
$login = $credentials->login;
$password = $credentials->password;
Les plus attentifs auront noté que mes classes sont mutables, et pour certains, c'est une hérésie, un scandale, une honte qui devrait me conduire au bûcher ! À ceux là, je répondrais :
Oui, mais cela générerait beaucoup plus de problèmes qu'il n'en résout et casserait ma DX. Alors, je vous renvois au mantra de Krän : "Y'a des jours, faut pas m'chercher ! Et y'a des jours tous les jours !".
En résumé :
- Placez-vous du point de vue de l'utilisation et non de l'implémentation
- N'innovez pas trop, les gens ont des habitudes
- Ne soyez jamais flemmards
- Utilisez l'immutabilité avec parcimonie
- Attention aux interfaces fluents sur les objets stateful
- Lisez Krän, c'est une BD déjantée à souhait
Top comments (0)