Routing w Kohana 3.1

Podziel się z innymi!

    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.

    Podziel się z innymi!