sobota, 2 lutego 2013

Internacjonalizacja statyczna i dynamiczna z trasowaniem i przełączaniem w CakePHP

Za każdym razem, kiedy przygotowuje nowy serwis staram się wymyślić jak rozwiązać jeszcze lepiej temat internacjonalizacji. Głównie nurtuje mnie wyświetlanie dynamicznej treści w przypadku kiedy ta nie została przetłumaczona. Jak przygotować wyszukiwanie przyjaznych linków w naszej bazie (ang. friendly urls, slugs). Jak rozwiązać problem z trasowaniem tych linków oraz jak wykonać przełączanie i zapamiętywanie wersji językowej.

W ostatnim projekcie przygotowałem aplikacje bez użycia ciasteczek (ang. cookies) i sesji, ale tylko za pomocą przełączania parametru w adresie (URL).

Dlaczego takie rozwiązanie?
Ponieważ sesje i ciasteczka działają świetnie, ale nie rozwiązują naszego problemu tłumaczenia linków poprzez specjalną funkcję z GetText (__();) oraz przede wszystkim wygenerowany link nie określa języka w przypadku wygaśnięcia ciasteczek i/lub sesji.

W wielu przykładach ustawianie wersji językowej odbywa się w Kontrollerze aplikacji (Controller) lub jego Komponencie (Component). W naszym przypadku to nie zadziała prawidłowo gdyż używamy tłumaczeń w Routerze aplikacji (routes.php).
Niektóre z artykułów poruszających tą tematykę rozwiązuje problem poprzez ustawienie języka i odświeżenie strony ($this->redirect();). Mnie osobiście takie rozwiązanie się nie podoba!

Trasowanie linków powinno wyglądać jak te:
/ -> strona startowa dla domyślnej wersji językowej (język polski)
/en -> strona startowa dla angielskiej wersji językowej
/de -> strona startowa dla niemieckiej wersji językowej
/rejestracja -> strona z rejestracji w języku domyślnym
/en/register -> strona z rejestracji  w języku angielskim
/de/anmeldung -> strona z rejestracji  w języku niemieckim
Natomiast celowo nie powinny działać linki jak te poniżej:
/pl/register -> polska wersja ze slugiem w wersji angielskiej
/en/anmeludng -> wersja angielska ze slugiem w wersji niemieckiej
Dlaczego tak? W mojej opinii, nie powinno mieć miejsca mieszanie się wersji językowych w linkach (głównie za sprawą SEO).

Moje rozwiązanie polega na ustawieniu języka przed uruchomieniem Kontrollera (Controller) i to jest moją największa niezadowalająca część całego kodu, później napisze dlaczego.

Przygotujmy naszą aplikację.
bootstrap.php (app/Config/):
define('DEFAULT_LANGUAGE', 'pl');

Configure::write('Config.languages', array(
    'pl' => 'Język polski',
    'en' => 'English version',
    'de' => 'Deutsch Sprache'));
W pierwszej linijce definiujemy stałą (ang. constans), zgodnie z klasą L10n jako język polski. Następnie przygotowujemy tablicę konfiguracyjną z możliwymi dostępnymi językami w naszym systemie.

Dwuliterowe kody języków przyjąłem zgodnie ze standardem ISO 632-9.

routes.php (app/Config/):
$language = substr(Router::url(''), 0, 2);
$languages = array_keys(Configure::read('Config.languages'));

if(!in_array($language, array_diff($languages, array(DEFAULT_LANGUAGE)))) {
    $language = DEFAULT_LANGUAGE;
    $schema = '';
} else {
    $schema = '/:language';
}

Configure::write('Config.language', $language)

/* PagesController */
Router::connect('/', array(
    'controller' => 'pages',
    'action' => 'display',
    'home'));
Router::connect('/:language', array(
    'controller' => 'pages',
    'action' => 'display',
    'home'), array(
        'language' => implode('|', $languages)));

/* UsersController */
Router::connect($schema .'/'. __('rejestracja', true), array(
    'controller' => 'users',
    'action' => 'register'), array(
        'persist' => array(
            'language')));
Tak, właśnie te pierwsze linijki powyżej są najbardziej nieprofesjonalne. Ich zadaniem jest sprawdzenie jaki kod języka w adresie reprezentuje w zapisanej konfiguracji Config.languages. Następnie tworzymy schemat do trasowania oraz zapisanie w konfiguracji jaki dany język został wybrany (Config.language).
W przykładzie powyżej pokazałem przykładowe 3 schematy trasowania linków, dla strony startowej z językiem domyślnym oraz wybranym przez użytkownika, a także link do strony z formularzem rejestracyjnym.
Zastosowałem tutaj parametr persist. Ma on na celu dodanie parametru language zgodnie ze schematen :/language. Więcej w Router API.

AppHelper.php (app/Views/Helpers/):
function url($url = null, $full = false) {
    if($this->params['language'] == DEFAULT_LANGUAGE) {
        unset($this->params['language']);
    }

    return parent::url($url, $full);
}
Musimy nadpisać metodę url(); w celu wyeliminowania parametru language, gdy parametr ma wartość taką jak domyślny język. Powodem tej zmiany jest uniknięcie duplikowania linków (/rejestracja i /pl/rejestracja).

Teraz możemy przetestować nasze linki za pomocą kodu umieszczonego w naszym szablonie:

default.ctp (app/Views/Layouts/):
foreach(Configure::read('Config.languages') as $code => $language) {
    echo $this->Html->link($language, array(
        'controller' => 'pages',
        'action' => 'display',
        'home',
        'language' => $code)) .' ';
}

echo $this->Html->link(__('register', true), array( 
    'controller' => 'users',
    'action' => 'register'));
Statyczną treść i trasowanie mamy za sobą.

Tłumaczenie statycznej treści oraz obsługa programu Poedit zostały dobrze opisane w książce "CakePHP 1.3 Programowanie aplikacji. Receptury" napisanej przez Mariano Iglesiasa (oryginalny tytuł w języku angielskim to "CakePHP 1.3. Application Development Cookbook").

Teraz przyszedł czas na treści dynamiczne przechowywane w bazie (np. MySQL). CakePHP domyślnie ustawia zmienną $locale w Modelu za pomocą zmiennej pobranej z konfiguracji (Config.language). Niestety te rozwiązanie nie uwzględnia wyświetlania treści jeśli ta nie została przetłumaczona, natomiast dobrym rozwiązaniem jest wyświetlenie w tym przypadku treści oryginalnej. Jak to zrobić znajdziemy poniżej:

AppController.php (app/Controller/):
if(Configure::read('Config.language') !== DEFAULT_LANGUAGE) {
    $this->{$this->modelClass}->locale = array(Configure::read('Config.language'), DEFAULT_LANGUAGE);
} else {
    $this->{$this->modelClass}->locale = DEFAULT_LANGUAGE;
}
Powyższy kod należy umieścić w metodzie beforeFilter(); najlepiej na samym początku.
Dlaczego ustawiamy zmienną $locale jeśli jest ona przypisywana domyślnie? Dlatego, iż domyślnie Cake nie wyświetli nam treści oryginalnej w przypadku braku jej tłumaczenia, natomiast zastosowana tutaj tablica z pierwszym parametrem wybranego języka oraz drugim dla języka domyślnego rozwiązuje ten problem.

Nie zapomnijmy tutaj o stworzeniu tabeli i18n w bazie danych oraz dodaniu Translate Behavior wraz z określonymi polami do tłumaczenia dynamicznego.

Teraz należy rozwiązać tworzenia przyjaznych linków (tzw. slugs). W przypadku jeśli nasze tabele w bazie zawierają pola name oraz slug, to stworzymy slug właśnie z pola name. Kod poniżej zilustruje tę zależność:

AppController.php (app/Controller/):
if(!empty($this->request->data) && $this->{$this->modelClass}->hasField('name') && $this->{$this->modelClass}->hasField('slug')) {
    $this->request->data[$this->modelClass]['slug'] = $this->{$this->modelClass}->createSlug($this->request->data[$this->modelClass]['name'], $this->id);
}
Modyfikujemy tablice z danymi poprzez metodę createSlug();, którą zaraz stworzymy w Modelu aplikacji. Dodam, że powyższy kod należy umieścić także w metodzie beforeFilter();, zaraz po ustawieniu zmiennej $locale.

AppModel.php (app/Model/):
function createSlug($string, $id = null, $field = 'slug', $separator = '_') {
    $slug = substr(strtolower(Inflector::slug($string, $separator)), 0, 250);
  
    if(!is_null($id)) {
        $params = array(
            'conditions' => array(
                $field => $slug,
                'not' => array(
                    $this->name .'.id' => $id)),
            'recursive' => -1);
    } else {
        $params = array(
            'conditions' => array(
                $field => $slug),
        'recursive' => -1);
    }
  
    $i = 0;
  
    while(count($this->find('all', $params))) {
        if(!preg_match('/'. $separator .'{'. strlen($separator) .'}[0-9]+$/', $slug)) {
            $slug .= $separator . ++$i;
        } else {
            $slug = preg_replace('/[0-9]+$/', ++$i, $slug);
        }
 
        $params['conditions'][$field] = $slug;
    }
  
    return $slug;
}
Na początku z pola name tworzymy slug za pomocą klasy Inflector o długości maksymalnej 250 znaków. Następnie tworzymy wyrażenia wyszukujące, inne w przypadku dodania nowego rekordu i inne dla edycji już istniejącego ($this->id) aby przepuścić wynik przez pętle while();. Pętla ma za zadanie stworzyć kolejny unikalny link (w przypadku istniejącej identycznej wartości pola slug dodanie kolejnego numeru). W takim przypadku obliczymy numer wykorzystując inkrementację (++$i;) i dodamy przyrostek, w postaci: _numer.

Teraz do przetestowania dynamicznego trasowania linków stworzymy przykładowy schemat: routes.php (app/Config/):
Router::connect($schema .'/'. __('artykuly', true) .'/:slug', array(
 'controller' => 'articles',
 'action' => 'view'), array(
  'pass' => array(
   'slug'),
  'persist' => array(
   'language')));
I to wszystko! Zapraszm do testowania i komentowania.

Całość testowana była na PHP 5.4/CakePHP 2.2.4 i domyślnie ustawionym modułem Apache mod_rewrite.

2 komentarze: