Routing linków w PHP

Tworząc nawet małą aplikację internetową musimy zadbać o „przyjazne” linki. Zwiększają one wygodę użytkownika, jak i mają istotny wpływ na SEO. Gdy piszemy aplikację w oparciu o jeden z popularnych frameworków problemu nie ma, dostarczają one komponenty do tworzenia ładnych adresów, ale co w sytuacji, gdy tworzymy bez ich wsparcia?

Jednym z rozwiązań jest napisanie odpowiednich reguł w pliku .htaccess. Jednak to rozwiązanie nie jest za bardzo wygodne. W przypadku zmiany struktury adresu, trzeba zwykle poprawiać linki w wielu miejscach. Zbędna praca :). Dużo lepszym rozwiązaniem wydaje się stworzenie routera dla linków.

Co to jest Router?

Router linków działa na podobnej zasadzie jak routery znane z infrastruktury sieciowej. W aplikacji internetowej ma on za zadanie wywołać odpowiednią metodę kontrolera na podstawie adresu URL strony.

Po co mi to?

Wykorzystując dobrze stworzony routing zapewniasz sobie przejrzystość kodu oraz łatwe wprowadzanie zmian w przyszłości. Ewentualna zmiana struktury adresu będzie wymagać modyfikacji tylko w jednym miejscu.

Myślę, że teorii już wystarczy, przejdźmy teraz do kodu.

Moja klasa do Routingu

Mój router składa się właściwie z 3 klas:

  • Route – jest to klasa przechowująca parametry dla konkretnego wzorca adresu URL. Wzorzec jest po prostu odpowiednim wyrażeniem regularnym.
  • RouteCollection – zawiera tablicę z obiektami klasy Route.
  • Router – serce mechanizmu. Na podstawie otrzymanego adresu znajduje obiekt Route zawierający pasujący wzorzec adresu.
Route.php
<?php

namespace RacyMind\Library\Routing;

/**
 * Klasa zawiera pojedyńczy element do routingu.
 *
 * @package RacyMind\Library\Routing
 * @author Łukasz Socha <kontakt@lukasz-socha.pl>
 * @licence MIT
 * @version 1.0
 */
class Route
{
    /**
     * @var string Ścieżka URL
     */
    protected $path;
    /**
     * @var string Ścieżka do kontrolera
     */
    protected $file;
    /**
     * @var string Nazwa klasy
     */
    protected $class;
    /**
     * @var string Nazwa metody
     */
    protected $method;
    /**
     * @var array Zawiera wartości domyślne dla parametrów
     */
    protected $defaults;
    /**
     * @var array Zawiera reguły przetważania dla parametrów
     */
    protected $params;

    /**
     * @param string $path Ścieżka URL
     * @param array $config Tablica ze ścieżką do kontrolera oraz nazwą metody
     * @param array $params Tablica reguł przetważania dla parametrów
     * @param array $defaults Tablica wartości domyślne parametrów
     */
    public function __construct($path, $config, $params = array(), $defaults = array())
    {
        $this->path = $path;
        $this->file = $config['file'];
        $this->method = $config['method'];
        $this->class = $config['class'];
        $this->setParams($params);
        $this->setDefaults($defaults);
    }

    /**
     * @param string $controller
     */
    public function setFile($controller)
    {
        $this->file = $controller;
    }

    /**
     * @return string
     */
    public function getFile()
    {
        return $this->file;
    }

    /**
     * @param string $class
     */
    public function setClass($class)
    {
        $this->class = $class;
    }

    /**
     * @return string
     */
    public function getClass()
    {
        return $this->class;
    }

    /**
     * @param array $defaults
     */
    public function setDefaults($defaults)
    {
        $this->defaults = $defaults;
    }

    /**
     * @return array
     */
    public function getDefaults()
    {
        return $this->defaults;
    }

    /**
     * @param string $method
     */
    public function setMethod($method)
    {
        $this->method = $method;
    }

    /**
     * @return string
     */
    public function getMethod()
    {
        return $this->method;
    }

    /**
     * @param array $params
     */
    public function setParams($params)
    {
        $this->params = $params;
    }

    /**
     * @return array
     */
    public function getParams()
    {
        return $this->params;
    }

    /**
     * @param string $path
     */
    public function setPath($path)
    {
        $this->path = HTTP_SERVER . $path;
    }

    /**
     * @return string
     */
    public function getPath()
    {
        return $this->path;
    }

    /**
     * Generuje przyjazny link.
     * @param array $data
     * @return string
     */
    public function generateUrl($data)
    {
        if (is_array($data)) {
            $key_data = array_keys($data);
            foreach ($key_data as $key) {
                $data2['<' . $key . '>'] = $data[$key];
            }
            $url = str_replace(array('?', '(', ')'), array('', '', ''), $this->path);
            return str_replace(array_keys($data2), $data2, $url);
        } else {
            return str_replace(array('?', '(', ')'), array('', '', ''), $this->path);
        }
    }
} 

Obiekt klasy Route przechowuje informacje o pliku, klasie oraz metodzie dla danego wzorca adresu URL. Dzięki temu router będzie „wiedział” jaki kontroler ma zostać uruchomiony. Dodatkowo istnieje możliwość ustawienia wyrażeń regularnych dla parametrów oraz ich domyślnych wartości.

Przykład użycia:

new Routing\Route(
    'http://lukasz-socha.pl/przyklady/routing/artykuly(/<page>)',
    array(
        'file' => 'controller_article.php',
        'class' => 'ArticleController',
        'method' => 'index'
    ),
    array(
        'page' => "\d+"
    ),
    array(
        'page' => 1
    )
);
RouteCollecion.php
<?php

namespace RacyMind\Library\Routing;

/**
 * Klasa zawiera kolekcję elementów klasy Route.
 * @package RacyMind\Library\Routing
 * @author Łukasz Socha <kontakt@lukasz-socha.pl>
 * @licence MIT
 * @version 1.0
 */
class RouteColletion
{
    /**
     * @var array Tablica obiektów klasy Route
     */
    protected $items;

    /**
     * Dodaje obiekt Route do kolekcji
     * @param string $name Nazwa elementu
     * @param Route $item Obiekt Route
     */
    public function add($name, $item)
    {
        $this->items[$name] = $item;
    }

    /**
     * Zwraca obiekt Route
     * @param string $name Nazwa obiektu w kolekcji
     * @return Route|null
     */
    public function get($name)
    {
        if (array_key_exists($name, $this->items)) {
            return $this->items[$name];
        } else {
            return null;
        }
    }

    /**
     * Zwraca wszystkie obiekty kolekcji
     * @return array array
     */
    public function getAll()
    {
        return $this->items;
    }
} 
Router.php
<?php

namespace RacyMind\Library\Routing;

/**
 * Klasa Routera.
 * @package RacyMind\Library\Routing
 * @author Łukasz Socha <kontakt@lukasz-socha.pl>
 * @licence MIT
 * @version 1.0
 */
class Router
{
    /**
     * @var String URL do przetworzenia
     */
    protected $url;
    /**
     * @var array Zawiera objekt RouteCollecion.
     */
    protected static $collection;
    /**
     * @var string Ścieżka do kontrolera
     */
    protected $file;
    /**
     * @var string Nazwa klasy
     */
    protected $class;
    /**
     * @var string Nazwa metody
     */
    protected $method;

    public function __construct($url, $collection = null)
    {
        if ($collection != null) {
            Router::$collection = $collection;
        }
        $url=explode('?', $url);
        $this->url = $url[0];
    }


    /**
     * @param array $collection
     */
    public function setCollection($collection)
    {
        Router::$collection = $collection;
    }

    /**
     * @return array
     */
    public function getCollection()
    {
        return Router::$collection;
    }

    /**
     * @param string $class
     */
    public function setClass($class)
    {
        $this->class = $class;
    }

    /**
     * @return string
     */
    public function getClass()
    {
        return $this->class;
    }

    /**
     * @param string $file
     */
    public function setFile($file)
    {
        $this->file = $file;
    }

    /**
     * @return string
     */
    public function getFile()
    {
        return $this->file;
    }

    /**
     * @param string $method
     */
    public function setMethod($method)
    {
        $this->method = $method;
    }

    /**
     * @return string
     */
    public function getMethod()
    {
        return $this->method;
    }


    /**
     * @param String $url
     */
    public function setUrl($url)
    {
        $this->url = $url;
    }

    /**
     * @return String
     */
    public function getUrl()
    {
        return $this->url;
    }

    /**
     * Sprawdza czy URL pasuje do przekazanej reguły.
     * @param Route $route Obiekt reguły
     * @return bool
     */
    protected function  matchRoute($route)
    {
        $params = array();
        $key_params = array_keys($route->getParams());
        $value_params = $route->getParams();
        foreach ($key_params as $key) {
            $params['<' . $key . '>'] = $value_params[$key];
        }
        $url = $route->getPath();
        // Zamienia znaczniki na odpowiednie wyrażenia regularne
        $url = str_replace(array_keys($params), $params, $url);
        // Jeżeli brak znacznika w tablicy $params zezwala na dowolny znak
        $url = preg_replace('/<\w+>/', '.*', $url);
        // sprawdza dopasowanie do wzorca
        preg_match("#^$url$#", $this->url, $results);
        if ($results) {
            $this->url=str_replace(array($this->strlcs($url, $this->url)), array(''), $this->url);
            $this->file = $route->getFile();
            $this->class = $route->getClass();
            $this->method = $route->getMethod();
            return true;
        }
        return false;
    }

    /**
     * Szuka odpowiedniej reguły pasującej do URL. Jeżeli znajdzie zwraca true.
     * @return bool
     */
    public function run()
    {
        foreach (Router::$collection->getAll() as $route) {
            if ($this->matchRoute($route)) {
                $this->setGetData($route);
                return true;
            }
        }
        return false;
    }

    /**
     * @param Route $route Obiekt Route pasujący do reguły
     */
    protected function setGetData($route)
    {
        $routePath=str_replace(array('(', ')'), array('', ''), $route->getPath());
        $trim=explode('<', $routePath);
        $parsed_url=str_replace(array(HTTP_SERVER), array(''), $this->url);
        $parsed_url=preg_replace("#$trim[0]#", '', $parsed_url, 1);
        // ustawia parametry przekazane w URL
        foreach ($route->getParams() as $key => $param) {
            if($parsed_url[0]=='/') {
                $parsed_url = substr($parsed_url, 1);
            }
            preg_match("#$param#", $parsed_url, $results);
            if (!empty($results[0])) {
                $_GET[$key] = $results[0];
                $temp_url=explode($results[0], $parsed_url, 2);
               // $parsed_url=str_replace($results[0], '', $temp_url[1]);
                //$parsed_url=preg_replace($patern, '', $temp_url[1], 1);
                $parsed_url=$temp_url[1];
            }
        }
        // jezeli brak parametru w URL ustawia go z tablicy wartości domyślnych
        foreach ($route->getDefaults() as $key => $default) {
            if (!isset($_GET[$key])) {
                $_GET[$key] = $default;
            }
        }
    }

    /**
     * Zwraca część wspólną ciągów
     * @param string $str1 Ciąg 1
     * @param string $str2 Ciąg 2
     * @return string część wspólna
     */
    protected function strlcs($str1, $str2){
        $str1Len = strlen($str1);
        $str2Len = strlen($str2);
        $ret = array();

        if($str1Len == 0 || $str2Len == 0)
            return $ret; //no similarities

        $CSL = array(); //Common Sequence Length array
        $intLargestSize = 0;

//initialize the CSL array to assume there are no similarities
        for($i=0; $i<$str1Len; $i++){
            $CSL[$i] = array();
            for($j=0; $j<$str2Len; $j++){
                $CSL[$i][$j] = 0;
            }
        }

        for($i=0; $i<$str1Len; $i++){
            for($j=0; $j<$str2Len; $j++){
//check every combination of characters
                if( $str1[$i] == $str2[$j] ){
//these are the same in both strings
                    if($i == 0 || $j == 0)
//it's the first character, so it's clearly only 1 character long
                        $CSL[$i][$j] = 1;
                    else
//it's one character longer than the string from the previous character
                        $CSL[$i][$j] = $CSL[$i-1][$j-1] + 1;

                    if( $CSL[$i][$j] > $intLargestSize ){
//remember this as the largest
                        $intLargestSize = $CSL[$i][$j];
//wipe any previous results
                        $ret = array();
//and then fall through to remember this new value
                    }
                    if( $CSL[$i][$j] == $intLargestSize )
//remember the largest string(s)
                        $ret[] = substr($str1, $i-$intLargestSize+1, $intLargestSize);
                }
//else, $CSL should be set to 0, which it was already initialized to
            }
        }
//return the list of matches
        if(isset($ret[0])) {
            return $ret[0];
        } else {
            return '';
        }
    }
} 

Najbardziej istotną metodą jest matchRoute(). To ona sprawdza czy przekazany adres URL pasuje do wzorca. Jeżeli tak jest, ustawia odpowiedni kontroler do uruchomienia. Mam nadzieję, ze kod wraz z komentarzami jest na tyle czytelny, że nie muszę go szczegółowo opisywać.

.htacess

Jeszcze reguła do htacess:

Options +FollowSymLinks
RewriteEngine On
RewriteRule ^(.*)$ index.php [NC,L]

Przykład użycia

Przykładowy plik index.php:

<?php
require_once 'route.php';
require_once 'routeCollection.php';
require_once 'router.php';
use RacyMind\Library\Routing;

// Konfihuracja routingu
$collection = new \RacyMind\Library\Routing\RouteColletion();
$collection->add('article/one', new Routing\Route(
    'artykuly/<slug>/<id>',
    array(
        'file' => 'controller_article.php',
        'class' => 'ArticleController',
        'method' => 'one'
    ),
    array( // wyrazenia regularne dla parametrow
        'slug' => "\w+",
        'id' => "\d+"
    )
));
$collection->add('article/list', new Routing\Route(
    'artykuly(/<page>)?',
    array(
        'file' => 'controller_article.php',
        'class' => 'ArticleController',
        'method' => 'index'
    ),
    array( // wyrazenia regularne dla parametrow
        'page' => "\d+"
    ),
    array( // wartosci domyslne
        'page' => 1
    )
));
$collection->add('information', new Routing\Route(
    'strona-opisowa',
    array(
        'file' => 'controller_information.php',
        'class' => 'InformationController',
        'method' => 'index'
    )
));

// uruchomienie Routingu
$router = new Routing\Router('http://' . $_SERVER["SERVER_NAME"] . $_SERVER["REQUEST_URI"], $collection);
$router->setBasePath('http://lukasz-socha.pl/przyklady/routing/');
if ($router->run()) {
    echo "Plik: " . $router->getFile() . "<br/>
    Klasa: " . $router->getClass() . "<br/>
    Metoda: " . $router->getMethod();
} else {
    echo 'Brak odpowiedniej reguly';
}

// A tak wyglada wygenerowanie linka dla konkretnej reguly.
echo "<br/> Wygenerowany link: " . $collection->get('article/one')->generateUrl(array(
        'id' => 5,
        'slug' => 'artykul'
    ));

echo"<br/>Tablica GET<br/>";
var_dump($_GET);

W liniach 9-42 dodaję trzy przykładowe reguły. Z kolei w wierszach 45-46 tworzę obiekt Router przekazując do konstruktora aktualny adres URL i kolekcję reguł oraz ustawiam ścieżkę do katalogu głównego z aplikacją. Natomiast w linii 56 generuję przykładowy link dla wybranej reguły.

Możesz sprawdzić jak działa mechanizm w praktyce wchodząc na poniższe linki:
http://lukasz-socha.pl/przyklady/routing/artykuly/tytul/8
http://lukasz-socha.pl/przyklady/routing/artykuly/2
http://lukasz-socha.pl/przyklady/routing/strona-opisowa

Router ten stworzyłem dla aplikacji rozwijanej przeze mnie. Na razie sprawuje się bez problemów :) Ideą tego skryptu nie jest konkurowanie z rozwiązaniami Symfony czy Zenda. Moim celem było stworzenie czegoś prostego, łatwego do modyfikacji i wdrożenia, ale z zachowaniem swobody w tworzeniu struktury linków.

Print Friendly, PDF & Email