Nice url – czyli przyjazne linki

Jakiś czas temu pisałem o routingu w Kohana 3.1, w którym to wpisie stwierdziłem, że nie działają mi nazwane podwzorce (named subpattern) pomimo tego że mam wersję PHP wyższą niż minimalna wymagana 5.2.2. Trafiłem jednak na wpis nospora „Ładne url’e (nice url)” i postanowiłem mimo wszystko powrócić do tematu routingu i powalczyć z tymi wyrażeniami regularnymi.

Może to kwestia konfiguracji komputera – gdyż eksperyment przeprowadzałem na innym kompie niż poprzednio ale tym razem okazało się, że jednak da się w PHP nadawać nazwy wycinkom wzorca.

<?php
// Przykładowe urle
$urls = array(
    '/pl/news/list/1/',
    '/news/list/1/',
    '/news/list/',
);
// Ścieżka która pasuje do powyższych urli - parametry lang i page są opcjonalne
$route = '%^/((?P<lang>\w{2})/)?(?P<controler>\w+)/(?P<method>\w+)/((?P<page>\d+)/)?$%';
 
// Definicja wartości domyślnych parametrów opcjonalnych
$defaults = array('lang' => 'pl', 'page' => 1);
 
// Odczytanie ze ścieżki nazw parametrów
preg_match_all('/P\<(\w+)\>/', $route, $matches, PREG_PATTERN_ORDER);
$params = $matches[1];
 
foreach ($urls as $url) {
    // Założyłem w przykładzie, że wszystkie urle pasują do ścieżki 
    // więc nie sprawdzam czy tak jest
    preg_match($route, $url, $matches);
    $vars = array();
    foreach ($params as $param) {
        // Oczytanie z urla wartości parametrów lub nadanie wartości domyślnych
        $vars[$param] = (isset($matches[$param]) && !empty($matches[$param])) 
            ? $matches[$param] : $defaults[$param];
    }
    print $url."<br />"; print_r($vars);
}
?>

Wynikiem wywołania powyższego kodu jest

/pl/news/list/1/

Array
(
    [lang] => pl
    [controler] => news
    [method] => list
    [page] => 1
)

/news/list/1/

Array
(
    [lang] => pl
    [controler] => news
    [method] => list
    [page] => 1
)

/news/list/

Array
(
    [lang] => pl
    [controler] => news
    [method] => list
    [page] => 1
)

Wyżej przytoczony przykład to tylko proof of concept, którego stworzenie nie zajęło mi więcej czasu niż wpisanie tych paru słów komentarza. Daje on jednak pojęcie jak łatwe i przyjemne jest stworzenie routingu w oparciu o nowe możliwości wyrażeń regularnych (nowe w PHP :)).

Routing w Kohana 3.1

Z frameworkiem Kohana pracuję już kilka lata. O ile Kohana w wersji 2 była przyjazna i w miarę dobrze udokumentowana o tyle 3-cie wydanie tego frameworka jest już mniej przyjazne. Nie zrozumcie mnie źle – ogólnie zmiany w architekturze i implementacji oceniam pozytywnie, ale ogromne braki w dokumentacji i ciągłe zmiany w kodzie mają zdecydowanie negatywny wpływ na przyjemność pracy z tym oprogramowaniem.

Zabieram się właśnie za nowy projekt. Jest niewielki więc postanowiłem wypróbować Kohanę w najnowszej „stabilnej” wersji 3.1. Utworzyłem katalog dla projektu, przekopiowałem do niego pliki frameworka, utworzyłem plik hteaccess według wzoru i odpaliłem w przeglądarce adres.

http://localhost/project/

Uruchomił mi się instalator, który poinformował mnie, że muszę nadać prawa do zapisu odpowiednim katalogom. Pozostałe wymagane warunki miałem spełnione. Nie miałem PECL HTTP i cURL ale to opcjonalne zależności bez, których wszystko, a przynajmniej fundamenty Kohany powinny ruszyć więc je zignorowałem. Zgodnie z instrukcją zmieniłem nazwę plikowi install.php i…

Otrzymałem komunikat błędu

HTTP_Exception_404 [ 404 ]: The requested URL project/index was not found on this server.

Popularny błąd 404 – not found – wydawałoby się i wszystko jasne. Tylko, że to pierwsze uruchomienie frameworka, w który jest wstępnie skonfigurowany, posiada domyślny kontroler „welcome” a w nim domyślną akcję „index” tak więc powinienem zobaczyć przyjazny napis „hello, world!”.

Gdyby to było moje pierwsze zetknięcie z frameworkiem Kohana to pewnie po pierwszych 5 min. dałbym sobie spokój. Nie dość, że przy pierwszym uruchomieniu dostajemy exceptiona to jeszcze tak naprawdę nic nam on nie mówi o możliwościach jego rozwiązania. W końcu kontroler jest tam gdzie trzeba, klasa ma odpowiednią nazwę i jest metoda, która zgodnie z dokumentacją powinna zostać wywołana.

Tymczasem przyczyną jest zła konfiguracja. W pliku /application/bootstrap.php mamy taki fragment kodu

Kohana::init(array(
    'base_url'   => '/',
));

Parametr base url w moim przypadku powinien wyglądać tak

Kohana::init(array(
    'base_url'   => '/project/',
));

Proste prawda? Tylko ktoś kto dopiero poznaje tę platformę się tego nie domyśli. Przyznaję się, że sam się trochę tego naszukałem, a to dlatego, że kiedyś już rozwiązałem ten problem kawałkiem uniwersalnego kodu i zdążyłem o nim zapomnieć. Tymczasem teraz stawiając nowy projekt na świeżutkiej Kohanie prosty problemik wrócił do mnie i trafił w głowę.

$dirname = dirname($_SERVER['SCRIPT_NAME']);
$base_url = preg_replace('@/+$@', '', $dirname=="\\"?'':$dirname).'/'; 
 
Kohana::init(array(
    'base_url'   => $base_url,
));

Dla purystów, którzy nie lubią zbędnych operacji i nie ufają zbytnio uniwersalności tego rozwiązania (w końcu było testowane tylko na serwerach z Apache 2) proponuję nieco zmodyfikowaną wersję

Kohana::init(array(
	'base_url'   => '/',
));
 
if (Kohana::$environment != Kohana::PRODUCTION) {
    $dirname = dirname($_SERVER['SCRIPT_NAME']);
    $base_url = preg_replace('@/+$@', '', $dirname=="\\"?'':$dirname).'/';
    if (Kohana::$base_url != $base_url) {
        throw new Kohana_Exception(sprintf('Perhaps you have a bad parameter set 
            base_url in bootstrap.php. Most likely, the correct value should be 
            "%s"', $base_url));
    }
}

Routing w Kohana nie jest szczytem elegancji, wygody i elastyczności. W stosunku do routingu w Django, każdy tego typu system w PHP jest prymitywną próbą naśladownictwa. Wynika to w dużej mierze z ograniczeń wyrażeń regularnych w PHP, które aż do wersji 5.2.2 (PCRE 7.0) – przynajmniej według dokumentacji – nie obsługiwały nazwanych podwzorców (named subpattern).

$str = 'foobar: 2008';
preg_match('/(?P<name>\w+): (?P<digit>\d+)/', $str, $matches);
print_r($matches);
//Array
//(
//    [0] => foobar: 2008
//    [name] => foobar
//    [1] => foobar
//    [digit] => 2008
//    [2] => 2008
//)

Ja mam na swoim kompie PHP 5.3.5 (PCRE 8.12) i dalej mi to nie działa.

Nie mniej routing w Kohana 3.1 wzbogacił się o możliwość definiowania ścieżek z użyciem funkcji lambda lub callback – w zależności od wersji PHP oczywiście. Daje to spore możliwości, których namiastkę spróbuję teraz zaprezentować.

Standardowe ustawienia routingu w Kohana Framework wyglądają następująco.

Route::set('default', '(<controller>(/<action>(/<id>)))')
	->defaults(array(
		'controller' => 'welcome',
		'action'     => 'index',
	));

Powoduje to, że do strony głównej serwisu możemy się odwołać w trojaki sposób.

http://domena.com/
http://domena.com/welcome/
http://domena.com/welcome/index

Jest to problem SEO tylko jeśli w projekcie budujemy różne linki odwołujące się do strony głównej. Jeśli dojdzie do tego wielojęzyczność do adresu zostanie dodany dodatkowy parametr (no chyba, że strony w poszczególnych językach trzymane są na subdomenach). Wtedy strona główna będzie występować w różnych wersjach językowych, a dodatkowo strona główna w języku domyślnym będzie w wersji z oznaczeniem języka i bez.

http://domena.com/
http://domena.com/pl-pl/

Dobrze byłoby zrobić aby strona główna w domyślnej wersji językowej była dostępna tylko pod adresem „/”. Natomiast próba wejścia przez uri „/pl-pl/” kończyła się przekierowaniem na „/”. Po wpisaniu w adres przeglądarki adresów „/welcome/” lub „/welcome/index” powinien być zgłoszony błąd 404.

Aby to osiągnąć stworzyłem klasę ProcessRoute w pliku /application/classes/processroute.php

<?php defined('SYSPATH') or die('No direct script access.');
 
class ProcessRoute {
    public static function main_page($uri) {
        $base_url = Kohana::$base_url;
        if ($uri == I18n::$lang) {
            header("Location: {$base_url}", true, 302); die();
        } else if ($uri == '') {
            if ($_SERVER["REQUEST_URI"] != $base_url) {
                throw new HTTP_Exception_404('Unable to find a route to match the URI: :uri'
                , array(':uri' => str_replace($base_url, '', $_SERVER["REQUEST_URI"])));
            }
            return array(
		        'lang' => 'pl-pl',
		        'directory' => '',
		        'controller' => 'welcome',
		        'action' => 'index',
	        );
        } else if (preg_match('/^[a-z]{2,2}-[a-z]{2,2}$/', $uri)) {
            return array(
		        'lang' => $uri,
		        'directory' => '',
		        'controller' => 'welcome',
		        'action' => 'index',
	        );
        }
        return false;
    }
}

Myślę, że dodatkowego wyjaśnienia wymaga jedynie fragment

if ($_SERVER["REQUEST_URI"] != $base_url) {
    throw new HTTP_Exception_404('Unable to find a route to match the URI: :uri'
    , array(':uri' => str_replace($base_url, '', $_SERVER["REQUEST_URI"])));
}

Otóż w przypadku wpisania w pole adresu przeglądarki urla „http://localhost/project/index/” wartość uri przekazana do metody ProcessRoute::main_page będzie miała pustą wartość „”. Jest to ewidentny bug i dlatego musiałem zastosować to nieeleganckie obejście.

Aby podpiąć wyżej zaprezentowaną klasę do routingu należy w bootstrapie dodać

/**
 * Set the routes. Each route must have a minimum of a name, a URI and a set of
 * defaults for the URI.
 */
Route::set('main', array('ProcessRoute', 'main_page'));

Na stronie głównej budowa witryny internetowej się nie kończy. Podpięcie funkcji zwrotnej pod routing daje o wiele większe możliwości. Załóżmy, że chcę wyświetlać dane kontaktowe różne dla różnych wersji językowych.

class Controller_Contact extends Controller {
 
    public function action_index()
    {
        $lang = Request::current()->param('lang');
        if ($lang == 'en-en') {
            $this->response->body('Contact');
        } else {
            $this->response->body('Kontakt');
        }
    }
} // Contact

Chciałbym aby polską wersję podpiąć pod adres „/pl-pl/kontakt”, a w wersję angielską pod „/en-en/contact”. W tym celu stworzyłem sobie plik konfiguracyjny /application/config/routes.php.

return array
(
	'pl-pl' => array(
	    'kontakt' => array(
		    'controller' => 'contact',
		    'action' => 'index',
	    )
	),
	'en-en' => array(
	    'contact' => array(
		    'controller' => 'contact',
		    'action' => 'index',
	    )
	)
);

Aby routing korzystał z tego pliku konfiguracyjnego do klasy ProcessRoute (w pliku /application/classes/processroute.php) dodałem statyczną metodę „static_pages”.

class ProcessRoute {  
    public static function main_page($uri) {
        // ...
    }
    public static function static_pages($uri) {
        // $lang = I18n::$lang;
        if (preg_match('/^([a-z]{2,2}-[a-z]{2,2})\/(.*)/', $uri, $matches) && count($matches) == 3) {
            $lang = $matches[1];
            // if ($lang == I18n::$lang) return false;
            $uri = $matches[2];
        }
        $routes = Kohana::config("routes")->as_array();
        if (!isset($lang) || !isset($routes[$lang])) {
            return false;
        } else {
            $routes = $routes[$lang];
        }
        if (array_key_exists($uri, $routes)) {
            $route = $routes[$uri];
            $route['lang'] = $lang;
            return $route;
        } 
        return false;
    }
}

Proszę zwrócić uwagę na wykomentowaną linię.

$lang = I18n::$lang;

Chciałem mieć pewność, żeby za wyjątkiem strony głównej na wszystkich podstronach był używany parametr wersji językowej. Mogę odkomentować wspomnianą linię i wtedy dane kontaktowe pojawią się zarówno po wybraniu adresu „/pl-pl/kontakt”, jak i „/kontakt”. Jeśli do tego odkomentuję jeszcze linię

if ($lang == I18n::$lang) return false;

wtedy z kolei dane kontaktowe w domyślnej wersji językowej będą dostępne jedynie pod adresem „/kontakt”.

Zapomniałbym. W bootstrapie trzeba jeszcze wywołać metodę ProcessRoute::static_pages.

Route::set('static_pages', array('ProcessRoute', 'static_pages'));

Jak pokazują te proste przykłady routing w Kohana 3.1 zyskał na elastyczności. Odrobina pracy i można by z tego mechanizmu wycisnąć dużo więcej. Panel administracyjny można oprzeć o tradycyjny routing oparty na kontrolerach i akcjach z kolei część publiczną adresować z wykorzystaniem plików konfiguracyjnych lub też trzymać ścieżki w bazie danych.