Aller au contenu

PHP : routage avec callable

Je voulais expérimenter avec le routage de mon framework perso Watamelo en faisant en sorte que l'action à exécuter de chaque route soit définie sous forme de callable dès le départ.

Jusqu'ici, je transmettais deux arguments (string) : la classe et la méthode à exécuter.

Mais comment rendre cela plus générique pour désigner n'importe quelle fonction/méthode exécutable ?

callable : rappel

Pour rappel, il existe 4 cas principaux de callable

// fonction classique
function action1() {
    return 'résultat 1';
}
// callable définie par chaîne de caractère
$callback1 = 'action1';

// méthodes statique et non statique
class Controller {
    public static function action2()
    {
        return 'résultat 2';
    }

    public function action3()
    {
        return 'résultat 3';
    }
}

// méthode statique : tableau avec nom de classe et nom de méthode
$callback2 = [Controller::class, 'action2'];

// méthode non statique : tableau avec instance de classe et nom de méthode
$callback3 = [new Controller(), 'action3'];

// fonction fléchée (anonyme)
$callback4 = (fn() => 'résultat 4');

Donc un callable peut être une simple chaîne (fonction classique), un tableau (méthode de classe, statique ou non) ou directement une fonction fléchée (fonction anonyme).

Router avec callable : blocage

Mon intention était de transmettre un argument callable au constructeur de la route et que celle-ci se débrouille plus tard pour le faire exécuter sans qu'on ait à se soucier du type de callable dont il s'agit.

Ma première approche naïve a rapidement échoué :

class Route {
    public callable $action;

    public function __construct(callable $action)
    {
        $this->action = $action);
    }

    public function render()
    {
        return ($this->action)();
    }
}

Mais ça donne l'erreur Property cannot have type callable. Une recherche rapide m'amène rapidement sur un stackoverflow puis php.net.

Conclusion : des soucis de portée interdisent la mémorisation de callable dans un attribut de classe. Ils suggèrent à la place d'encapsuler ça dans une Closure pour rendre cela indépendant de toute notion de portée.

Router avec Closure : et ça marche !

On remplace le type de l'attribut par Closure, mais on garde toujours un callable en argument du constructeur. Il suffit juste de convertir ce callable en Closure avant de le mémoriser :

class Route {
    public Closure $action;

    public function __construct(callable $action)
    {
        $this->action = Closure::fromCallable($action);
    }

    public function render()
    {
        return ($this->action)();
    }
}

J'ai galéré à faire fonctionner ça au départ, puisque les conseils de la doc sont très généraux et ne donnent aucun exemple complet pour gérer ce cas. Au début, à la place de fromCallable, j'écrivais un truc du genre $this->action = $action(...);, qui semblait marcher, mais je ne suis pas sûr de ce que ça impliquait.

Et puis j'ai tenté l'exécution sous forme de $this->action->call($this), mais la transimssion d'un objet à ce moment-là ne fonctionnait pas (clairement, je ne maîtrise pas encore les Closure).

Et au final, ça marche avec nos 4 différents cas :

$route1 = new Route('action1');
var_dump($route1->render());

$route2 = new Route([Controller::class, 'action2']);
var_dump($route2->render());

$ctrl = new Controller();
$route3 = new Route([$ctrl, 'action3']);
var_dump($route3->render());

$route4 = new Route(fn() => 'action4');
var_dump($route4->render());

Conclusion

Je suis assez content du résultat, c'est élégant et polyvalent.

Cependant, quid des risques de copies d'éléments contextuels à la création de Closure ? Je soupçonne que même dans le cas de l'instance de classe, c'est juste une référence qui est transmise, donc a priori c'est OK. Seul l'usage de variables globales dans ces fonctions/méthodes seraient un souci (mais comme c'est à proscrire en général...).

Je n'ai finalement pas retenu cette jolie solution malgré tout puisque je tenais à éviter d'instancier tous les contrôleurs lors des définitions de route (puisqu'un seul suffira). Une solution à base de lazy loading serait plus pertinente, mais incompatible avec les callable par nature (puisqu'il faut transmettre le nom de classe plutôt qu'une instance, ce qui correspond à un usage de méthode statique).

Mais je me suis dit que ça serait sympa de partager le résultat de cette petite expérience.

Keep routing and call on!