Archives du mot-clé etat

PHP – Le design pattern Etat

Le design pattern Etat est un design pattern comportemental; son utilisation est préconisée dès lors que le comportement d’un objet dépend directement de l’état dans lequel il se trouve à l’instant T.

Programmeur énervé par l'absence de pattern Etat

Programmeur se mettant dans un état pas possible à la lecture d’un code écrit voilà 10 ans

Architecture du design pattern Etat

Les participants à ce design pattern Etat sont au nombre de 3:

  • Une abstraction (interface ou classe abstraite) qui liste les différents comportements devant être implémentés par des états concrets
  • Des états concrets réalisant chacune des méthodes listées dans l’abstraction évoquée ci-dessus
  • Un contexte définissant l’interface que le code client va utiliser et ayant en composition les états concrets auquel il va simplement déléguer les requêtes lui parvenant

Where’s the bill, Bill?

Prenons un exemple parlant: le commerce en ligne ! Une commande faite en ligne traverse toute une série d’états; elle peut être créée, validée, payée ou mise en recouvrement, annulée, remboursée, expédiée etc. Evidemment certaines transitions d’état ne peuvent pas se faire: quand elle est expédiée, elle ne peut pas être annulée et inversement.

Notre site de vente en ligne d’ouvrages informatique CODEBOOKS reçoit plusieurs centaines de commandes par jour.
Une commande est créée dans l’état « En Attente », ce qui signifie qu’elle est en attente d’une validation, manuelle ou automatique. Cette validation peut intervenir ou pas, si le moyen de paiement s’avère frauduleux ou s’il ne parvient pas dans les délais précisés dans les conditions générales de vente.

Une fois qu’elle est validée, elle est préparée puis expédiée afin d’être livrée. C’est notre scénario nominal, le Best Case Scenario.
D’autres scenarii peuvent se produire: l’acheteur réalise à la livraison qu’il a déjà le livre ou bien qu’il l’a commandé dans une langue qu’il ne sait pas lire; il devra être remboursé après renvoi du livre à CODEBOOKS. Une erreur de stock peut également conduire à valider une commande portant sur un livre non disponible, elle devra donc être annulée puis remboursée.

Tes états d’âme, Eric…(air bien connu)

Nos états

Chacun de nos états fera l’objet d’une classe concrète dérivant une classe abstraite (ou implémentant une interface, au choix). J’ai choisi une classe abstraite dans laquelle je factorise mon constructeur, pour éviter de le répéter bêtement dans chaque classe fille car il fait dans tous les cas la même chose. Cette classe abstraite implémente une interface qui va contraindre chacune des classes filles à implémenter l’ensemble des méthodes qu’un état pourra mettre à disposition. Notez bien que le contexte est en composition de chacun des états concrets, pour garder la trace de l’état courant d’une commande.

interface EtatInterface
{
    public function mettreEnAttente(): void;
    public function valider(): void;
    public function annuler(): void;
    public function rembourser(): void;
    public function expedier(): void;
    public function signalerLivre(): void;
}

abstract class EtatAbstract implements EtatInterface
{
    protected $contexte;
    
    public function __construct(ContexteInterface $contexte)
    {
        $this->contexte = $contexte;
    }
}

Après étude, nous avons déterminé les états possibles d’une commande:

  • En Attente: c’est l’état par défaut, celui dans lequel se trouve toute commande « atterrissant » sur notre système d’information
  • Annulé
  • Validé
  • Expédié
  • Remboursé
  • Livré

Vous allez me dire « Mais avec cette interface, l’état Annulé va se retrouver à devoir implémenter une méthode expedier alors qu’il ne doit pas pouvoir transiter vers cet état ! » et vous aurez tout à fait raison ! C’est comme ça, il nous faut une interface qui liste toutes les méthodes qu’on peut invoquer sur l’ensemble des états pour faire plaisir à ce bon vieux Liskov !

Dès qu’une méthode sera appelée sur un état qui ne doit pas l’implémenter, nous lèverons une exception maison nommée MethodNotImplementedException:

class MethodNotImplementedException extends \Exception {}

Le contexte

Notre contexte va être appelé par le code client, c’est lui qui va garder une trace de l’état courant de notre commande. Il a en composition une instance de chacun des différents types d’états disponibles dans lesquels il va s’auto-injecter et à sa construction, il mettra l’état courant à sa valeur par défaut, à savoir « en attente ». Il propose une série de getters (sauf pour enAttente car nous postulons qu’aucun état ne peut effectuer de transition vers cet état initial) ainsi que les méthodes que le code client appellera. Vous notez que ces méthodes font de la délégation bête et méchante. L’unique setter est crucial pour tenir le contexte à jour des changements d’état intervenus dans l’application.

interface ContexteInterface
{
    public function etatValide(): EtatInterface;
    public function etatAnnule(): EtatInterface;
    public function etatExpedie(): EtatInterface;
    public function etatLivre(): EtatInterface;
    public function etatActuel(): EtatInterface;
    public function changerEtat(EtatInterface $etat): void;
}

class Contexte implements ContexteInterface
{
    private $enAttente;
    private $valide;
    private $expedie;
    private $livre;
    private $rembourse;
    private $annule;
    private $etatActuel;

    public function __construct()
    {
        $this->enAttente = new EnAttente($this);
        $this->valide = new Valide($this);
        $this->expedie = new Expedie($this);
        $this->livre = new Livre($this);
        $this->rembourse = new Rembourse($this);
        $this->annule = new Annule($this);
        
        $this->etatActuel = $this->enAttente;
    }
    
    public function etatValide(): EtatInterface
    {
        return $this->valide;
    }
    
    public function etatAnnule(): EtatInterface
    {
        return $this->annule;
    }
    
    public function etatExpedie(): EtatInterface
    {
        return $this->expedie;
    }
    
    public function etatLivre(): EtatInterface
    {
        return $this->livre;
    }
    
    public function etatRembourse(): EtatInterface
    {
        return $this->rembourse;
    }
    
    public function etatActuel(): EtatInterface
    {
        return $this->etatActuel;
    }
    
    public function changerEtat(EtatInterface $etat): void
    {
        $this->etatActuel = $etat;
    }
    
    public function valider(): void
    {
        $this->etatActuel->valider();
    }
    
    public function expedier(): void
    {
        $this->etatActuel->expedier();
    }
    
    public function signalerCommeLivre(): void
    {
        $this->etatActuel->signalerLivre();
    }
    
    public function effectuerRemboursement(): void
    {
        $this->etatActuel->rembourser();
    }
    
    public function effectuerAnnulation(): void
    {
        $this->etatActuel->annuler();
    }
}

Nos états…concrets !

EnAttente

Prenons notre première classe concrète: EnAttente. Cet état peut effectuer des transitions vers les états Validé (si le paiement est valide) ou Annulé (dans le cas contraire). Il ne peut pas le faire vers lui-même ainsi que vers les états Remboursé, Expédié et Livré car à ce stade le paiement de la commande n’est pas une certitude.

class EnAttente extends EtatAbstract
{   
    public function mettreEnAttente(): void
    {
        throw new MethodNotImplementedException('Déjà en attente');
    }
    
    public function valider(): void
    {
        $this->contexte->changerEtat($this->contexte->etatValide());
    }
    
    public function annuler(): void
    {
        $this->contexte->changerEtat($this->contexte->etatAnnule());
    }
    
    public function rembourser(): void
    {
        throw new MethodNotImplementedException('En attente');
    }
    
    public function expedier(): void
    {
        throw new MethodNotImplementedException('En attente');
    }
    
    public function signalerLivre(): void
    {
        throw new MethodNotImplementedException('En attente');
    }
}

Annulé

Comme tous les états, Annule ne peut aller vers lui-même et il n’effectue de transition qu’en direction de Remboursé.

class Annule extends EtatAbstract
{
    public function mettreEnAttente(): void
    {
        throw new MethodNotImplementedException('Annulé');
    }
    
    public function valider(): void
    {
        throw new MethodNotImplementedException('Annulé');
    }
    
    public function annuler(): void
    {
        throw new MethodNotImplementedException('Déjà annulé');
    }
    
    public function rembourser(): void
    {
        $this->contexte->changerEtat($this->contexte->etatRembourse());
    }
    
    public function expedier(): void
    {
        throw new MethodNotImplementedException('Annulé');
    }
    
    public function signalerLivre(): void
    {
        throw new MethodNotImplementedException('Annulé');
    }
}

Validé

Valide peut évoluer en Annulé (opposition bancaire ou autres) ou en Expédié, si tout se déroule normalement.

class Valide extends EtatAbstract
{    
    public function mettreEnAttente(): void
    {
        throw new MethodNotImplementedException('Validé');
    }
    
    public function valider(): void
    {
        throw new MethodNotImplementedException('Déjà validé');
    }
    
    public function annuler(): void
    {
        $this->contexte->changerEtat($this->contexte->etatAnnule());
    }
    
    public function rembourser(): void
    {
        throw new MethodNotImplementedException('Validé');
    }
    
    public function expedier(): void
    {
        $this->contexte->changerEtat($this->contexte->etatExpedie());
    }
    
    public function signalerLivre(): void
    {
        throw new MethodNotImplementedException('Validé');
    }
}

Expedié

Cet état peut effectuer des transitions uniquement vers Livré.

class Expedie extends EtatAbstract
{   
    public function mettreEnAttente(): void
    {
        throw new MethodNotImplementedException('Expédié');
    }
    
    public function valider(): void
    {
        throw new MethodNotImplementedException('Expédié');
    }
    
    public function annuler(): void
    {
        throw new MethodNotImplementedException('Expédié');
    }
    
    public function rembourser(): void
    {
        throw new MethodNotImplementedException('Expédié');
    }
    
    public function expedier(): void
    {
        throw new MethodNotImplementedException('Déjà expédié');
    }
    
    public function signalerLivre(): void
    {
        $this->contexte->changerEtat($this->contexte->etatLivre());
    }
}

Remboursé

C’est un état terminal.

class Rembourse extends EtatAbstract
{
    public function mettreEnAttente(): void
    {
        throw new MethodNotImplementedException('Remboursé');
    }
    
    public function valider(): void
    {
        throw new MethodNotImplementedException('Remboursé');
    }
    
    public function annuler(): void
    {
        throw new MethodNotImplementedException('Remboursé');
    }
    
    public function rembourser(): void
    {
        throw new MethodNotImplementedException('Déjà Remboursé');
    }
    
    public function expedier(): void
    {
        throw new MethodNotImplementedException('Remboursé');
    }
    
    public function signalerLivre(): void
    {
        throw new MethodNotImplementedException('Remboursé');
    }
}

Livré

Cet état ne peut transiter que vers Annulé, quand le client reçoit un produit qui ne lui convient pas. L’annulation donnera ensuite lieu à un remboursement.

class Livre extends EtatAbstract
{
    public function mettreEnAttente(): void
    {
        throw new MethodNotImplementedException('Livré');
    }
    
    public function valider(): void
    {
        throw new MethodNotImplementedException('Livré');
    }
    
    public function annuler(): void
    {
        $this->contexte->changerEtat($this->contexte->etatAnnule());
    }
    
    public function rembourser(): void
    {
        throw new MethodNotImplementedException('Livré');
    }
    
    public function expedier(): void
    {
        throw new MethodNotImplementedException('Livré');
    }
    
    public function signalerLivre(): void
    {
        throw new MethodNotImplementedException('Déjà Livré');
    }
}

Le code client

Nous avons à notre disposition une classe Commande; elle possède une référence vers le contexte, qui va ainsi garder trace des différents états qu’elle traverse. C’est elle que notre code client va appeler.

class Commande
{
    private $contexte;
    
    public function __construct(ContexteInterface $contexte)
    {
        $this->contexte = $contexte;
    }
    
    public function valider(): void
    {
        $this->contexte->valider();
        echo "Etat = ".get_class($this->contexte->etatActuel()).PHP_EOL;
    }

    public function expedier(): void
    {
        $this->contexte->expedier();
        echo "Etat = ".get_class($this->contexte->etatActuel()).PHP_EOL;
    }
    
    public function signalerCommeEtantLivre(): void
    {
        $this->contexte->signalerCommeLivre();
        echo "Etat = ".get_class($this->contexte->etatActuel()).PHP_EOL;
    }
    
    public function procederAuRemboursement(): void
    {
        $this->contexte->effectuerRemboursement();
        echo "Etat = ".get_class($this->contexte->etatActuel()).PHP_EOL;
    }
 
    public function effectuerUneAnnulation(): void
    {
        $this->contexte->effectuerAnnulation();
        echo "Etat = ".get_class($this->contexte->etatActuel()).PHP_EOL;
    }
    
}

$contexte = new Contexte();
$commande = new Commande($contexte);

Voici notre scénario nominal, ou best case scenario (fingaz crossed!) :

$commande->valider();
$commande->expedier();
$commande->signalerCommeEtantLivre();

La commande va traverser 4 états: elle va passer de « en attente » à « validée », puis à « expédiée » et enfin à « livrée ».

Voici le scénario où la commande est validée mais une erreur de stock conduit à devoir l’annuler et la rembourser:

$commande->valider();
$commande->effectuerUneAnnulation();
$commande->procederAuRemboursement(); // état TERMINAL

Dans le scénario suivant, le produit est livré mais l’acheteur réalise qu’il l’a déjà ou bien qu’il s’est trompé lors de sa commande et le produit de remplacement n’est plus disponible dans le stock:

$commande->valider();
$commande->expedier();
$commande->signalerCommeEtantLivre();
$commande->effectuerUneAnnulation();
$commande->procederAuRemboursement(); // état TERMINAL

Enfin, dans notre dernier scénario, le paiement n’est pas arrivé à temps (mandat, virement bancaire, chèque…) et il faut annuler la commande ! C’est le plus simple:

$commande->effectuerUneAnnulation();