post_ico4

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.

Co sądzisz o wpisie?
BeżnadziejnySłabyŚredniDobryBardzo dobry (Brak ocen, bądź pierwszy!)
Loading...
  • Filip

    Nie do końca jest tak jak opisujesz z tym generatorem. Owszem to działa ale tylko wtedy kiedy zmieniamy statyczną część linku. Jeżeli dodamy jakiś nowy parametr, lub zmienimy jego nazwę, to i tak w każdym miejscu w którym użyliśmy generatora musimy poprawić nasz link (w sensie dodać parametr, lub zmienić jego nazwę).

    Teraz warto się zastanowić, skoro korzysta się z routera wzorowanego na symfony, ale napisanego w dużo prostszy sposób co by nie dodawać sobie zbędnego narzutu wydajnościowego (domniemam że to był powód napisania routera od nowa, popraw mnie jeżeli się mylę), więc jaki sens ma używania generatora do linków statycznych, tylko po to żeby móc później ów statyczny link zmienić sobie w ustawieniach routingu?

    Oczywiście to jest moje zdanie, ale dla mnie idea generatora jest trochę na wyrost, jedyne przy czym się to przydaje, to w miejscach o których zapomniałeś dodać dodatkowy parametr lub go zmienić (jego nazwę) bo wtedy sypnie ci błędem i wiesz gdzie masz szukać.

    • „Jeżeli dodamy jakiś nowy parametr, lub zmienimy jego nazwę, to i tak w
      każdym miejscu w którym użyliśmy generatora musimy poprawić nasz link (w
      sensie dodać parametr, lub zmienić jego nazwę).”

      Całego procesu niestety nie da się zautomatyzować (przynajmniej ja się z takim czymś nie spotkałem). Zmiana tylko statycznej części linka i tak moim zdaniem daje sporo. W sytuacji gdy musisz zmienić np. link „rejestracja” na „zaloz-konto” oszczędzasz czas i niwelujesz do zera możliwość pominięcia czegoś. Takie sytuacje mogą się zdarzać ze względu na optymalizację pod SEO.

      Jednym ze wstępnych pomysłów było wykorzystanie właśnie routera Symfony. Ale gdy zobaczyłem ile ma zależności stwierdziłem, że nie ma sensu dorzucania tylu bibliotek tylko dla samego routingu (ze względu na wydajność). Aplikacja, dla której napisałem router, powstaje na bazie OpenCart’a, a więc nie ma tam żadnego frameworka. Stąd pomysł na napisanie tego skryptu.

      Oczywiście bazowałem na koncepcji z routera Symfony, ale dodatkowym założeniem była prostota i łatwość wdrożenia. Tego router Symfony nie posiada. Dla większości aplikacji myślę, że mój router spełni oczekiwania.

      • Filip

        IMO jeżeli ów routing użyjesz w sposób w jaki użyłeś w swoim wpisie, to zależności wyglądają tak jak w przypadku twojego routera. Nie to że ciebie do niego namawiam, ale chodzi o fakt że wymyśliłeś koło na nowo. Sam kiedyś coś takiego uczyniłem i teraz jestem trochę mądrzejszy o to doświadczenie, i zamiast napisać całkowicie na nowo, wcześniej analizując inny router, zwyczajnie wywaliłem kilka zależności które uważam za zbędne.

        Co do generatora, moim zdaniem nie jest to warte użycia, tylko po to żeby zautomatyzować sobie użycie statycznych linków, bo jest to zbędny narzut na tak mało wymagające zdanie..

        • Wiem, że takie routery istnieją i to nawet bardziej rozbudowane niż mój. Zdecydowałem się napisać swój, ponieważ uznałem, że będzie to szybsze i wydajniejsze w tej konkretnej sytuacji. Aplikacja jest de facto gotowa (mechanizm OpenCarta do końca nie sprawdzał się) i chciałem to zrobić jak najmniej inwazyjnie.

  • Łukasz

    W jakim celu podajesz nazwę pliku w tablicy jako „file”?
    W dzisiejszych czasach autoloaderów jest to zbędne, wszystko leci automatycznie dzięki przestrzeni nazw.

    • Tak wiem, ale przy tej aplikacji było to konieczne. Nie ma wystarczających zasobów, by wszystko nagle dostosować. Poza tym czasem jest wymaga większa elastyczność :)

  • Adrian

    Świetny artykuł. Może jakiś przykład dla „multilanguage”? :) np http://www.twoja-strona.pl – strona w języku domyślnym, http://www.twoja-strona.pl/en/ – strona w języku angielskim, http://www.twoja-strona.pl/de/ – strona w języku niemieckim.

    • $collection->add(‚article/one’, new RoutingRoute(
      ‚artykuly///’,
      array(
      ‚file’ => ‚controller_article.php’,
      ‚class’ => ‚ArticleController’,
      ‚method’ => ‚one’
      ),
      array(
      ‚slug’ => „w+”, ‚lang’ => „w+”,
      ‚id’ => „d+”
      )
      ));
      I tak robisz ze wszystkimi linkami, gdzie ma być multilanguage :)

      • Adrian

        bardziej myślałem coś na wzór ‚/artykuly//’ gdzie lang tylko wtedy kiedy nie jest to domyślny język ;)

        • Adrian

          Da się ustawić domyślny kontroler czy echo ‚Brak odpowiedniej reguly’; trzeba zamienić na własny warunek?

          • if ($router->run()) {
            echo „Plik: ” . $router->getFile() . ”
            Klasa: ” . $router->getClass() . ”
            Metoda: ” . $router->getMethod();
            } else {
            echo ‚Brak odpowiedniej reguly’;
            }

            Możesz w else {…} stworzyć obiekt dowolnego kontrolera i wywołać metodę.

        • Tu już jest dowolność. Można to zrobić jak kto woli :)

          • Adrian

            No tak tylko jak dam regułę:
            $collection->add(‚jezyki’, new Route(
            ‚(/)?informacja/’,
            array(
            ‚file’ => ‚controller_article.php’,
            ‚class’ => ‚ArticleController’,
            ‚method’ => ‚index’
            ),
            array(
            ‚lang’ => „w+”,
            ‚string’ => „w+”
            )
            ));

            http://strona.pl/pl/informacja/fsdfsfs
            array(2) { [„lang”]=> string(2) „pl” [„word”]=> string(10) „informacja” }

            http://strona.pl/informacja/fsdfsfs
            array(2) { [„lang”]=> string(10) „informacja” [„word”]=> string(7) „fsdfsfs” }

            a powinno być:
            array(2) { [„lang”]=> string(2) „pl” [„word”]=> string(10) „informacja” }

          • Adrian

            Znalazłem rozwiązanie, nie wiem czy najlepsze, ale utworzyłem tabelę pomocniczą $lang = array(‚de’, ‚en’); i zmienną $default_lang =’pl’.
            Daję $trim = explode(‚/’, $_SERVER[„REQUEST_URI”]) i sprawdzam czy in_array($trim[0] , $lang) jeśli nie to dodaję na początku $default_lang.’/’, jeśli jest to nie ruszam ;)

          • A próbowałeś dodać wartości domyślne dla lang?

            $collection->add(‚jezyki’, new Route(
            ‚(/)?informacja/’,
            array(
            ‚file’ => ‚controller_article.php’,
            ‚class’ => ‚ArticleController’,
            ‚method’ => ‚index’
            ),
            array(
            ‚lang’ => „w+”,
            ‚string’ => „w+”
            ), array(
            ‚lang’ => ‚pl’
            )
            ));

  • Pionas

    Gdy chciałem wygenerować link dla mniejszej liczby parametrów niż założyłem w kolekcji to zostawiał . Zamieniłem w Route::generateUrl:

    return str_replace(array_keys($data2), $data2, $url);
    na
    $url = str_replace(array_keys($data2), $data2, $url);
    return preg_replace(„//(]*>)/”, ”, $url);