borisbaer: Was gibt es beim Schreiben eines PHP-Routers zu beachten?

Hi,

ich bin gerade dabei, für mein MVC-Framework einen Router zu erstellen, der die URL-Anfragen für meine Website verarbeitet. Ich benötige eigentlich nur GET und POST als HTTP-Methoden. Bei den vorgefertigten PHP-Routern auf Github habe ich gesehen, dass beim Erstellen einer Route meist direkt die HTTP-Methode mit angegeben wird. Mitunter werden sogar zwei verschiedene Funktionen dafür geschrieben.

Ich habe erst vor kurzer Zeit mit PHP OOP angefangen und weiß nicht wirklich, worauf man achten muss. Ich möchte in der Controller-Class bzw. im Handler selbst die HTTP-Anfrage auflösen lassen, denn dann kann ich ja beim Hinzufügen der Route darauf verzichten.

Ich sehe zwei Möglichkeiten: Entweder man gibt bei der addRoute-Funktion auf der index.php die HTTP-Methode an und verweist dann je nachdem auf eine bestimmte Method in der Class, z.B.: public function isPost() bzw. isGet(). Oder man verzichtet auf solche Methods, lässt in der addRoute-Funktion die HTTP-Methode außen vor und klärt dann direkt in der Class, was passieren soll, wenn es z.B. ein POST-Request ist, indem man ein if statement einfügt in der Art if ( $_POST ) { // tu was }.

In der index.php würde dann beispielsweise nur stehen:

$router -> add( 'admin/{ handler }' );

Ist das Geschmackssache oder gibt es da so etwas wie eine Best-Practice-Vorgehensweise?

Vielen Dank schon mal!

Grüße
Boris

P.S.: Die Variable { handler } entspricht eigentlich immer einer Controller-Class, die mit einer View-Class desselben Namens korrespondiert. Ist hier die Bezeichnung handler dann überhaupt semantisch korrekt?

  1. Hallo borisbaer,

    ein Router ist generell eine Komponente, die einen HTTP Request auf ein Stück Code abbildet, die den Request bearbeiten soll. Und sie sollte das HTTP Geschäft möglichst von den Controllern fernhalten.

    Ich empfehle daher, die Methode in der Routendeklaration unterzubringen, d.h. du hast unterschiedliche Routendeklarationen für GET und POST.

    Die Frage ist dann, wie man für GET und POST auf die ansonsten gleiche URL eine andere Methode hinterlegt. Dazu könnte man in der Route außer dem URL-Pattern auch noch Regeln geben, wie den Routendaten der Klassen- und Methodenname zu gewinnen ist. Im einfachsten Fall durch ein Pattern:

    $router->route("/admin/{controller}/{action}/{id}", [
       "requestmethod" => "GET",
       "controllername" => "admin/{controller}Controller",
       "functionname" => "get_{action}"
    ]). 
    

    Hier würde für den Controller noch ein Namespace gesetzt und dem Namen der aufzurufenden Funktion ein get_ vorangestellt.

    Aber schau Dir auch mal die Code-Attribute an, die ab PHP 8 verfügbar sind. Damit kannst Du in einem Controller Zusatzinformationen hinterlegen, die besagen, für welche Werte in den Routendaten eine Methode relevant ist. Für ein Posting hier ist mir das zu viel.

    Code-Attribute.

    Deine Controllerklasse könnte dann so aussehen:

    class UserController 
    {
       #[Route(action:"edit", method:"get")]
       public function get_edit($id) {
       }
       #[Route(action:"edit", method:"post")]
       public function post_edit($id) {
       }
    }
    

    Mit Hilfe von Reflection kann der Router erkennen, dass die aufzurufende Methode einen Parameter $id hat, die Routendaten eine id enthalten und sie gleich übergeben. Ist nicht trivial, aber machbar.

    Rolf

    --
    sumpsi - posui - obstruxi
    1. Hallo Rolf,

      danke für die Tipps! Da muss ich mich aber wirklich erst noch reinlesen. Das mit den Code-Attributen habe ich bisher noch nirgends gesehen.

      Dann werde ich die HTTP-Methode doch in der Route belassen.

      Bei mir sieht das auf der index.php momentan so aus:

      $router -> add( 'GET', '', [ 'controller' => 'home'] );
      $router -> add( 'GET', '{ controller }' );
      $router -> add( 'GET', '{ namespace }/{ controller }' );
      
      $router -> dispatch( $uri );
      

      Und die dazugehörige Funktionen:

      public function add( string $httpMethod, string $route, array $params = [] ): void
      {
      	$route = preg_replace( '/\//', '\\/', $route );
      	$route = preg_replace( '/\{ ([a-z]+) \}/', '(?P<\1>[a-z-]+)', $route );
      	$route = preg_replace( '/\{ ([a-z]+):([^\}]+) \}/', '(?P<\1>\2)', $route );
      	$route = '/^' . $route . '$/i';
      
      	$this -> routes[$route][$httpMethod] = $params;
      }
      
      public function getMatches( $uri ): bool
      {
      	$uri = substr( $uri, 1 );
      	$uri = $this -> removeQueryString( $uri );
      
      	foreach ( $this -> routes as $route => $params ) {
      		if ( preg_match( $route, $uri, $matches ) ) {
      			foreach ( $matches as $key => $match ) {
      				is_string( $key ) ? $params[$key] = $match : '';
      			}
      			$this -> params = $params;
      			return true;
      		}
      	}
      	return false;
      }
      
      public function dispatch( string $uri ): void
      {
      	$uri = $this -> removeQueryString( $uri );
      	if ( $this -> getMatches( $uri ) ) {
      		$params = $this -> params;
      
      		isset( $params['controller'] )
      			? $controller = $params['controller']
      			: $controller = $params[$_SERVER['REQUEST_METHOD']]['controller'];
      
      		$controller = $this -> convertToStudlyCaps( $controller );
      		$controller = $this -> getNamespace() . $controller;
      
      		class_exists( $controller )
      			? $controller = new $controller( $this -> params )
      			: "Seite nicht gefunden.";
      	}
      	else {
      		echo 'Seite nicht gefunden.';
      	}
      }
      

      So funktioniert bisher alles.

      Grüße
      Boris

    2. Hallo Rolf,

      class UserController 
      {
         #[Route(action:"edit", method:"get")]
         public function get_edit($id) {
         }
         #[Route(action:"edit", method:"post")]
         public function post_edit($id) {
         }
      }
      

      ich habe Folgendes mit den Attributen umsetzen können umsetzen können:

      public function addRoute( string $httpMethod, string $route, callable|array $handler ): self
      {
      	$this -> routes[$httpMethod][$route] = $handler;
      	return $this;
      }
      
      public function addRouteByAttribute( array $controllers )
      {
      	foreach( $controllers as $controller ) {
      		$reflector = new \ReflectionClass( $controller );
      		foreach( $reflector -> getMethods() as $method ) {
      			$attributes = $method -> getAttributes( \Core\Attributes\Router::class, \ReflectionAttribute::IS_INSTANCEOF );
      			foreach( $attributes as $attribute ) {
      				$route = $attribute -> newInstance();
      				$this -> addRoute( $route -> httpMethod, $route -> route, [ $controller, $method -> getName() ] );
      			}
      		}
      	}
      }
      

      In der index.php steht dann nur noch:

      $router = new \Core\Router();
      $router -> addRouteByAttribute( [
      	Home::class,
      	Games::class,
      	Consoles::class,
      	Form::class,
      ] );
      

      Und letztlich im Controller:

      class Home {
      	#[ GET( '/' ) ]
      	public function index(): View
      	{
      		return View::instantiate( 'home', [
      			'name' => 'Benutzer',
      			'colors' => [ 'rot', 'grün', 'blau' ]
      		] );
      	}
      }
      

      Hast du das ungefähr so gemeint?

      Grüße
      Boris

      1. Hallo borisbaer,

        ja, ungefähr.

        function addRouteByAttribute( array $controllers )

        Das Problem bei PHP ist, dass man Klassen zumeist on-demand lädt, mit einem Autoloader. Wenn Deine PHP Anwendung 17 Controller hat, und Du nur einen davon brauchst, ist es Zeitverschwendung, alle 17 Controller zu laden und darin nach Routendeklarationen zu suchen.

        Vor allem ist es auch oft so, dass eine Routendeklaration mehr als einen Controller ermitteln kann. Eine Generalroute wie /{controller}/{action} kann man nicht in einem einzelnen Controller per Attribut festlegen.

        Deswegen findet die Deklaration der eigentlichen Routen besser ohne Reflection auf die Controller statt, so dass Du Controller- und Action-Name weißt, ohne etwas zu reflektieren.

        Sobald dein Router den Controller kennt, kann er ihn laden (was beim new ReflectionClass automatisch passieren dürfte) und über die Methoden reflektieren, um zu schauen, welche davon zur gefundenen Action passt.

        Aber: Die Controllerklassen sollten auf jeden Fall eine Markierung tragen, dass es sich um Controller handelt. Entweder müssen sie in einem speziell für Controller definierten Namespace stehen, oder Du gibst den Controllerklassen ein Klassen-Attribut "Controller" und prüfst, ob dieses Attribut vorhanden ist, bevor der Router eine Klasse als Controller akzeptiert. Keinesfalls darf der Router beliebige Klassennamen als Controller akzeptieren.

        Wenn Du einen gültigen Controller hast, kannst Du darin die Methoden inspizieren. Aber nicht so:

        $method->getAttributes( \Core\Attributes\Router::class,
                                \ReflectionAttribute::IS_INSTANCEOF );
        $route = $attribute -> newInstance();
        

        Da hast Du einiges falsch verstanden. Deine Attribute sind keine Instanzen der Routerklasse. Es sei denn, du hast tatsächlich eine Klasse GET geschrieben, die von \Core\Attributes\Router abgeleitet ist. Hast Du das? Wenn ja - ich finde es suboptimal.

        Ich würde es Dir so vorschlagen.

        Definiere eine einzige Attributklasse, mit der Du die Methoden ausstattest, die Actions implementieren. Diese Action-Attributklasse besitzt alle Propertys für die benötigten MVC-Informationen.

        namespace Core\Attributes
        {
          #[\Attribute]
          class Action {
            public readonly string $method;
            public readonly string $name;
        
            public function __construct(string $method = "GET", string $actionName = null) {
              $this->method = $method;
              $this->name   = $actionName;
        	}
        }
        

        Es ist wichtig, dass die Attributklasse selbst ein Attribut bekommt. Das Attribut \\Attribute wird von PHP verlangt, damit später der newInstance Aufruf funktioniert. Das \ ist nötig, weil Attribute im globalen Namespace deklariert ist und ActionAttribute in \Core\Attributes steht.

        Die readonly-Deklaration benötigt übrigens PHP 8.1, für ältere PHPs musst Du das weglassen.

        Jetzt kannst Du getAttributes aufrufen und auf ActionAttribute filtern:

           $actionAttributes = $method->getAttributes(
                       \Core\Attributes\Action::class,
                       \ReflectionAttribute::IS_INSTANCEOF );
           if (count($actionAttributes) > 0)
           {
              $action = $actionAttributes[0] -> newInstance();
              if (!$action->name)
                 $action->name = $method->name;
           }
        

        Es ist eine Design-Entscheidung, ob Du zulassen willst, dass eine Methode aufgerufen werden kann, die kein Action-Attribut hat. Ich würde das aus Sicherheitsgründen verbieten, genau wie auf Controller-Ebene.

        Mit dem Action-Attribut kannst Du nun abfragen, für welche HTTP Methoden und welche Routenaktionen die gefundene Methode im Controller geeignet ist. Wenn Du Glück hast, kommt am Ende genau eine Methode 'raus. Andernfalls muss der Router leider den Aufruf zurückweisen.

        Es gibt auch eine Alternative ohne Action-Attributklassen. Wenn Du ein Attribut GET verwendest und keine PHP Klasse GET definierst,, kannst Du auf den Methoden der Klasse auch getAttributes("GET") aufrufen und würdest das Attribut bekommen. Der Aufruf von newInstance geht nun nicht, aber Du kannst getArguments() aufrufen und bekommst die Parameter für das Attribut. Ich meine aber, dass es sauberer ist, mit Attributklassen zu arbeiten und die Interpretation der Attributparameter in dieser Klasse vorzunehmen.

        Rolf

        --
        sumpsi - posui - obstruxi
        1. Hallo Rolf,

          Das Problem bei PHP ist, dass man Klassen zumeist on-demand lädt, mit einem Autoloader. Wenn Deine PHP Anwendung 17 Controller hat, und Du nur einen davon brauchst, ist es Zeitverschwendung, alle 17 Controller zu laden und darin nach Routendeklarationen zu suchen.

          okay, das ergibt Sinn.

          Vor allem ist es auch oft so, dass eine Routendeklaration mehr als einen Controller ermitteln kann. Eine Generalroute wie /{controller}/{action} kann man nicht in einem einzelnen Controller per Attribut festlegen.

          Variable Routen habe ich bei dem Versuch mit den Attributen der Einfachheit halber erst mal nicht berücksichtig.

          Deswegen findet die Deklaration der eigentlichen Routen besser ohne Reflection auf die Controller statt, so dass Du Controller- und Action-Name weißt, ohne etwas zu reflektieren.

          Also soll ich im Vorfeld angeben, auf welchen Controller und welchen Action-Namen ich es mit dieser Route abgesehen habe? Bei variablen Routen ist doch gerade der Clou, dass man vorher nicht wissen muss, was der Router finden soll. Er reagiert, wenn eine entsprechende Controller-Class gefunden wird. Oder habe ich dich da falsch verstanden?

          Sobald dein Router den Controller kennt, kann er ihn laden (was beim new ReflectionClass automatisch passieren dürfte) und über die Methoden reflektieren, um zu schauen, welche davon zur gefundenen Action passt.

          Zur gefundenen Action? Meinst du vielleicht zu der Action, die ich vorher deklariert habe?

          Aber: Die Controllerklassen sollten auf jeden Fall eine Markierung tragen, dass es sich um Controller handelt. Entweder müssen sie in einem speziell für Controller definierten Namespace stehen, oder Du gibst den Controllerklassen ein Klassen-Attribut "Controller" und prüfst, ob dieses Attribut vorhanden ist, bevor der Router eine Klasse als Controller akzeptiert. Keinesfalls darf der Router beliebige Klassennamen als Controller akzeptieren.

          Ja, das habe ich durch den Namespace gelöst. Die Controller befinden sich bei mir im Ordner App/Controllers. Der Router ist im Core-Verzeichnis. Haben beide separate Namespaces.

          Wenn Du einen gültigen Controller hast, kannst Du darin die Methoden inspizieren. Aber nicht so:

          $method->getAttributes( \Core\Attributes\Router::class,
                                  \ReflectionAttribute::IS_INSTANCEOF );
          $route = $attribute -> newInstance();
          

          Da hast Du einiges falsch verstanden. Deine Attribute sind keine Instanzen der Routerklasse. Es sei denn, du hast tatsächlich eine Klasse GET geschrieben, die von \Core\Attributes\Router abgeleitet ist. Hast Du das? Wenn ja - ich finde es suboptimal.

          Ja, habe ich. Und ja, ich finde es auch suboptimal. Ich hätte gerne alles in einer einzigen Attributklasse, aber weiß nicht, wie ich das bewerkstelligen soll.

          Ich würde es Dir so vorschlagen.

          Definiere eine einzige Attributklasse, mit der Du die Methoden ausstattest, die Actions implementieren.

          Moment, für mich waren Actions ein Sammelbegriff für eine Klasse plus die jeweilige Methode. Könntest du erklären, was du mit Methoden meinst, „die Actions implementieren“?

          Diese Action-Attributklasse besitzt alle Propertys für die benötigten MVC-Informationen.

          namespace Core\Attributes
          {
            #[\Attribute]
            class Action {
              public readonly string $method;
              public readonly string $name;
          
              public function __construct(string $method = "GET", string $actionName = null) {
                $this->method = $method;
                $this->name   = $actionName;
          	}
          }
          

          Es ist wichtig, dass die Attributklasse selbst ein Attribut bekommt. Das Attribut \\Attribute wird von PHP verlangt, damit später der newInstance Aufruf funktioniert. Das \ ist nötig, weil Attribute im globalen Namespace deklariert ist und ActionAttribute in \Core\Attributes steht.

          Momentan habe ich eine Klasse, die provisorisch ebenfalls Router heißt, sich aber in einem anderen Namespace befindet (Core\Attributes). Diese Router-Klasse ist ganz simpel aufgebaut:

          <?php
          declare( strict_types = 1 );
          namespace Core\Attributes;
          use Attribute;
          
          #[ Attribute( Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE ) ]
          class Router {
          	public function __construct( public string $route, public string $httpMethod )
          	{ }
          }
          

          Die GET- und POST-Klassen befinden sich ebenfalls im Namespace Core\Attributes und beerben jene Router-Klasse. Aber wie du schon sagtest: eine suboptimale Lösung.

          Die readonly-Deklaration benötigt übrigens PHP 8.1, für ältere PHPs musst Du das weglassen.

          Ich benutze PHP 8.1. Das mit der readonly-Deklaration muss ich mir mal anschauen, klingt interessant.

          Jetzt kannst Du getAttributes aufrufen und auf ActionAttribute filtern:

             $actionAttributes = $method->getAttributes(
                         \Core\Attributes\Action::class,
                         \ReflectionAttribute::IS_INSTANCEOF );
             if (count($actionAttributes) > 0)
             {
                $action = $actionAttributes[0] -> newInstance();
                if (!$action->name)
                   $action->name = $method->name;
             }
          

          Es ist eine Design-Entscheidung, ob Du zulassen willst, dass eine Methode aufgerufen werden kann, die kein Action-Attribut hat. Ich würde das aus Sicherheitsgründen verbieten, genau wie auf Controller-Ebene.

          Dann werde ich deiner Empfehlung folgen.

          Mit dem Action-Attribut kannst Du nun abfragen, für welche HTTP Methoden und welche Routenaktionen die gefundene Methode im Controller geeignet ist. Wenn Du Glück hast, kommt am Ende genau eine Methode 'raus. Andernfalls muss der Router leider den Aufruf zurückweisen.

          Auch hier verstehe ich leider nicht wirklich die Differenzierung zwischen Aktionen und Methoden … sorry. Da muss ich mal eine Weile über deinen Code nachdenken. Abstraktes logisches Denken war nie meine Stärke.

          Es gibt auch eine Alternative ohne Action-Attributklassen. Wenn Du ein Attribut GET verwendest und keine PHP Klasse GET definierst,, kannst Du auf den Methoden der Klasse auch getAttributes("GET") aufrufen und würdest das Attribut bekommen. Der Aufruf von newInstance geht nun nicht, aber Du kannst getArguments() aufrufen und bekommst die Parameter für das Attribut. Ich meine aber, dass es sauberer ist, mit Attributklassen zu arbeiten und die Interpretation der Attributparameter in dieser Klasse vorzunehmen.

          Von dem, was ich verstanden habe, klingt für mich Letzteres auch cleaner.

          Dann probiere ich mal weiter rum und raufe mir die Haare.

          Grüße
          Boris

          1. Hallo borisbaer,

            Variable Routen habe ich bei dem Versuch mit den Attributen der Einfachheit halber erst mal nicht berücksichtig.

            Das geht mit Attributen auch schlecht.

            Also soll ich im Vorfeld angeben, auf welchen Controller und welchen Action-Namen ich es mit dieser Route abgesehen habe?

            Nein, nein.

            Bei variablen Routen ist doch gerade der Clou, dass man vorher nicht wissen muss, was der Router finden soll.

            Ja. Deswegen sind sie für einen brauchbaren Router auch nötig. Ein Router verarbeitet URL-Muster und leitet daraus Suchbegriffe ab. Mit diesen Suchbegriffen findet er eine Klasse und eine Methode darin, die die URL verarbeitet. Suchbegriffe, die DU auf diese Weise finden kannst, sind der Controller, die Aktion und auch das HTTP Verb (also GET, POST oder PUT - ich nenne das jetzt mal bewusst nicht Methode, um es von dem OOP-Begriff "Methode" zu trennen).

            Die Idee, dass man in der URL direkt den Klassennamen und den Methodennamen findet, ist eine von vielen Möglichkeiten, und viele Router sind auch so implementiert.

            Aber Du merkst ja, dass Du damit an Grenzen stößt, wenn Du ein REST System baust, da hat die gleiche URL mit unterschiedlichen HTTP Verben unterschiedliche Bedeutungen. Auch bei Formularen hast Du mit GET und POST das Problem.

            Die Controllersuche wird komplizierter, wenn deine URLs nicht alle dem gleichen Schema folgen. Wenn Du eine URL /user/profile/47 und eine URL /admin/user/delete/47 unterstützen willst, dann wird das sehr wahrscheinlich nicht der gleiche Controller sein. Wahrscheinlich definierst Du zwei verschiedene Routen dafür, und eine von beiden kann nicht stumpf den Controllernamen aus der URL holen, sondern muss etwas hinzufügen. Deswegen hatte ich vorgestern die Patterns vorgeschlagen, um auf einfacher Weise die gefundenen Namensteile in die passenden Platzhalter einzusetzen. Aber das ist nur eine Idee.

            Bei der Abbildung der Aktion auf die Methode kommt dann das von Dir genannte Problem dazu, das HTTP Verb mit einzubeziehen. Die Action-Attribute an den Methoden könnten angeben, für welche Kombination aus HTTP Verb und Aktionsname diese Methode vorgesehen ist.

            Moment, für mich waren Actions ein Sammelbegriff für eine Klasse plus die jeweilige Methode.

            Nee, eigentlich ein Schritt vorher. Der HTTP Request beinhaltet ein HTTP Verb und die URL. Aus der URL bekommst Du einen Controller und eine gewünschte Aktion. Der Controller wird zur Klasse, und aus HTTP Verb und Aktion musst Du herleiten, welche Methode aufzurufen ist. Diese Methode implementiert dann die Aktion für dieses Verb.

            Momentan habe ich eine Klasse, die provisorisch ebenfalls Router heißt

            Whoa - dann hast Du mich damit verwirrt. Ein Attribut sollte seinen Namen nicht davon ableiten, für wen es gemacht ist, sondern welche Eigenschaft es dem attributierten Dings zuschreiben will.

            Dann werde ich deiner Empfehlung folgen.

            Oder deiner Nase. Je nachdem, was Dir besser gefällt 😀 👃

            Rolf

            --
            sumpsi - posui - obstruxi
        2. Hallo Rolf,

          ich habe hier einen Router auf GitHub gefunden, der ebenfalls mit Attributen arbeitet. Was ich hier interessant finde, ist, dass man einen MainController mit allen Routen hat und dass man direkt im Attribut definiert, welche Request Methods erlaubt sind. Der Nachteil ist aber, dass man nicht – wenn ich das richtig sehe – im Vorfeld sagen kann, nach welcher Method der Router suchen soll, sondern eben gleich die ganze Controller-Klasse abgerufen wird. Außerdem wird so ein MainController schnell voll und somit unübersichtlich sein.

          Grüße
          Boris

          1. Hallo borisbaer,

            ja, dieser Router tut genau das, wovon ich abgeraten habe. Bei 2-3 Controllerklassen ist es egal, bei 20-30 wird es kritisch.

            Rolf

            --
            sumpsi - posui - obstruxi