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!