czwartek, 17 lutego 2011

Internacjonalizcja w CakePHP

Niestety świat się nie kręci wokół javy i czasem trzeba wrócić do starego poczciwego php'a. Szukałem dość długo jak powinna wyglądać poprawna (w kontekście kodu jak i SEO) internacjonalizacja w CakePHP i generalnie w każdym tutorialu czegoś brakowało, coś nie do końca działało, dlatego postarałem się jakoś zebrać tą wiedzę i usystematyzować. Żeby zrozumieć co się dzieje w tutku, trzeba mieć podstawową wiedzę z Caka (wersja 1.2.9), oraz przeczytać rozdział o i18n i l10n z manuala.

1. Na początek radze zapoznać się ze sposobami internacjonalizacji aplikacji, które są dość dobrze opisane tu. Niestety nie stać mnie na różne domeny dla różnych języków (podejście (1)), dlatego wybrałem opcje (2), czyli zróżnicowanie linków pod kątem języków. Generalnie jako piaskownice i poligon doświadczalny postanowiłem zinternacjonalizować swoją bidną stronę domową. Plus będzie tego taki, że wszystkie przykłady będą miały pokrycie na działającym przykładzie.

2. Przyjmuję następującą strukturę linków:

http://www.ludwikowski.info/ - ładuje domyślną stronę w języku polskim

http://www.ludwikowski.info/pol - również ładuje domyślną stronę w języku polskim
http://www.ludwikowski.info/eng - ładuje domyślną stronę w języku angielskim

http://www.ludwikowski.info/pol/pages/display/projects - ładuje wybraną stronę w język polskim
http://www.ludwikowski.info/eng/pages/display/projects - ładuje wybraną stronę w języku angielskim

i teraz pytanie z dziedziny SEO, co z linkami typu:
http://www.ludwikowski.info/pages/display/projects - niby można to obsługiwać domyślnie po polsku, ale nie wiem czy jest sens, dlatego postanowiłem po pierwsze nie generować takich linków, po drugie ich nie obsługiwać. Aczkolwiek, jeśli ktoś ma na ten temat inne zdanie to zapraszam do dyskusji, generalnie zasady SEO są dla mnie czasami dość rozmyte.

3. Wszędzie tam gdzie treść ma być poddana internacjonalizacji używamy funkcji __().

4. Potrzebujemy plików .po, w których będą przechowywane przetłumaczone frazy. Możemy je stworzyć ręcznie, albo użyć jednego z poleceń cakekowych z poziomu konsoli, a konkretnie: cake i18n extract. Polecenie to generuje nam plik .pot będący szablonem na podstawie którego tworzymy już konkretne pliki .po. Czyli w moim przypadku, fragmenty plików .po wyglądają następująco.

app/locale/eng/LC_MESSAGES/default.po

#: \views\elements\rightMenu.ctp:5
msgid "Mój blog"
msgstr "My blog"

#: \views\elements\topMenu.ctp:3
msgid "O mnie"
msgstr "About me"

app/locale/pol/LC_MESSAGES/default.po
#: \views\elements\rightMenu.ctp:5
msgid "Mój blog"
msgstr "Mój blog"

#: \views\elements\topMenu.ctp:3
msgid "O mnie"
msgstr "O mnie"
jeśli zasada tworzenie plików .po nie jest nam znana odsyłam do literatury w necie.

5. Teraz trzeba dostosować aplikację, żeby w miarę wygodnie można było tworzyć linki oraz ich używać. Po pierwsze zasady routingu. W app/config/routes.php umieszczamy (koniecznie przed standardowymi definicjami routtingu!):
//mapowanie na stronę główną po angielsku
Router::connect('/eng', array('language' =>'eng', 'controller' => 'pages', 'action' => 'display', 'home'));
 
//mapowanie na stronę główną po polsku
Router::connect('/pol', array('language' =>'pol', 'controller' => 'pages', 'action' => 'display', 'home'));
 
//pobiera language z np. array('controller'=> 'contacts', 'action' => 'sendEmail', 'language' => 'pol') i umiesza go na początku url'a
Router::connect('/:language/:controller/:action/*', array(), array('language' => '[a-z]{3}'));
Jak dokładnie działa routowanie w cake'u odsyłam do manuala.

6. Należy jakoś zautomatyzować tworzenie linków, żebyśmy nie musieli za każdym razem ręcznie odczytywać (czy to z sesji, czy to z cookie) jaki jest wybrany język i umieszczać go w linku. Dlatego tworzymy app/app_handler i nadpisujemy metodę tworzącą url'e:
class AppHelper extends Helper {
    
    function url($url = null, $full = false) {
        
        if(!isset($url['language'])){
            //sprawdzamy czy jest w coookie
            if (isset($_COOKIE['lang'])){                
                $language=$_COOKIE['lang'];
            }
            //sprawdzamy czy jest w sesji
            if (isset($_SESSION['Config']['language'])){                
                $language=$_SESSION['Config']['language'];        
            }else {
                //jeśli nie ma to domyślnie ustawiamy język polski
                $language='pol';
            }            
            //dodajemy do urla język
            $url['language'] = $language;
        }
        return parent::url($url, $full);
    }
}
Dzięki takiemu zabiegowi możemy tworzyć linki dokładnie tak samo jak to było do tej pory:
echo $html->link(__('Kontakt', true), array('controller'=>'pages', 'action'=>'display', 'contact'));
Język w postaci parametru 'language', będzie dodawany automatycznie.

7. Przechodzimy do dostosowania kontrolera, który będzie przełączał język (na podstawie url'a) jeśli różni się on od obecnie ustawionego. Tworzymy w tym celu odpowiedni komponent app/controllers/components/language.php:
class LanguageComponent extends Object {
    var $name = 'Language';    
    var $components = array('Cookie', 'Session');
    
    //pobiera język z sesji, lub z cookie, lub domyślny = 'pol' jeśli nie był do tej pory ustawiony język 
    function getLanguage(){    
        if ($this->Session->read('Config.language')){
            return $this->Session->read('Config.language');
        }else if ($this->Cookie->check('lang')){
            return $this->Cookie->read('lang');
        }else {
            return 'pol';
        }
    }
    
    // ustawia wybrany język, dodatkowo inicjalizuje parametr w sesji jeśli wygasła 
    function setLanguage($language  = 'pol'){        
        if ($this->Cookie->read('lang') && !$this->Session->check('Config.language')) {
            
            $this->Session->write('Config.language', $this->Cookie->read('lang'));
        }
        else if ($language !=  $this->Session->read('Config.language')) {
            $this->Session->write('Config.language', $language);
            $this->Cookie->write('lang', $language, false, '20 days');
        }
    }
    
    //zmienia $url np.  /eng/controller/action/... na /$lang/controller/action/... 
    function prepareUrl($url, $lang){
        return "/".$lang.substr($url, 4, strlen($url));
    }
}
w app/app_controller.php:
class AppController extends Controller {
    var $components = array('Session', 'Cookie', 'Visit', 'Language');

    function beforeFilter() {        
        $this->set('visitors', $this->Visit->visitCookieUpdate());
        $this->Language->setLanguage($this->_getLanguageFromParams());
    }
    
    function _getLanguageFromParams(){        
        if (isset($this->params['language'])){
            return $this->params['language'];
        }else{
            return 'pol';
        }
    }
}

8. Ok, ogólny kontroler załatwiony, przydałby się jeszcze jakiś, który będzie wymuszał zmianę języka i przenosił na aktualnie oglądaną stronę app/controllers/languages_controller.php:
class LanguagesController extends AppController{    
    var $uses = array();    
    var $components = array('Language');
    
    function change(){        
        $this->redirect($this->Language->prepareUrl($this->referer(), $this->Language->getLanguage()));
    }
}
kontroler jest tak prosty, ponieważ wszystko robi za nas app_controller, język wymuszamy poprzez odpowiedni link. Tak de facto do zadań tego kontrolera należy jedynie przekierowanie na tą samą stronę. Możliwe, że można to jakoś sprawniej załatwić, ale nic mi nie przyszło do głowy, jak ktoś ma pomysł to pisać.
echo $html->link('Polski', array('language' => 'pol', 'controller' => 'languages', 'action' => 'change');

9. Praktycznie to by było na tyle, aczkolwiek żeby mieć już pełny pogląd na sprawy internacjonalizacji, pozostaje ostatni aspekt. Funckja __(), powinna być jedynie zastosowana do krótkich wiadomości. W przypadku potrzeby przetłumaczenia np. całej zawartości paragrafu, należy użyć techniki podmiany widoków, która została opisana w oficjalnym manualu. Niestety pages_controller rządzi się trochę swoimi prawami i oddzielnie dla niego trzeba dorobić jedną rzecz. Ogólnie zastanawiam się, czy nie szybciej byłoby napisać swój własny pages_controller. Moje rozwiązania, które 'łata' pages_controller nie uważam, za zbyt finezyjne, dlatego jak ktoś ma inny pomysł jak to zrobić to z chęcią przeczytam propozycje.

Dorabiamy kolejny komponent pages_i18n:
class PagesI18nComponent extends Object {    
    var $name = 'PagesI18n';    
    var $components = array('Language');
    
    function viewName($viewName){        
        if (file_exists(VIEWS.'pages'.DS.$this->Language->getLanguage().DS.$viewName.'.ctp')){
            
            return $this->Language->getLanguage().DS.$viewName;
        }else{
            return $viewName;
        }
    }
}
w pages_controller wywołujemy podmianę nazwy widoku:
        if (isset($path[0])){            
            $path[0] = $this->PagesI18n->viewName($path[0]);
        }        
        
        $this->set(compact('page', 'subpage', 'title'));
        $this->render(join('/', $path));
a widoki umieszczamy odpowiednio: views/pages/pol/home.ctp, views/pages/eng/home.ctp.

środa, 26 stycznia 2011

Jak zamenić " na \" w java za pomocą String.replaceAll()

Z kategorii tak dziwne, że aż śmieszne. Otóż zamiana " w String'u w Javie na \" może przysporzyć pewnych trudności.

Używając oferowanej przez String'a funckji: replaceAll(), zapisujemy:

String s = "Adam Mickewicz \"Pan Tadeusz\" ";
s = s.replaceAll("\"", "\\\"");

efekt - żaden. Taka operacja nic z naszym String'iem nie zrobi. Należy pamiętać, że pierwszy argument funkcji replaceAll, to wyrażenie regularne. Dlatego znak '\', musi być dodatkowo wyeskejpowany dla regexp'a, czyli dopiero:

s = s.replaceAll("\\\"", "\\\\\"");

robi to o co nam chodziło. Zapis iście komiczny, ale co zrobisz...

piątek, 21 stycznia 2011

Zamiana wierszy na kolumny w tabeli - crosstab (postgresql).

Jakiś czas temu musiałem, zrobić dość nieprzyjemną operacje na tabeli, tzn. zamienić wiersze tak aby tworzyły one kolumny. Czyli coś takiego:
 x f01 a

 x f02 b

 x f03 c
 y f01 a
 y f02 b

 z f01 a

 z f02 b
 z f03 c

 z f04 d
 z f05 e

miało zamienić się w coś takiego:

 id f01 f02 f03 f04 f05
 x a b c
 y a b
 z a b c d e

Generalnie rzecz niby prosta, ale dość nienaturalna dla tabel w bazce . W realnym przykładzie miałem tak de facto 20 kolumn, co wiązało by się z wykonaniem 20 JOIN-ów, na tabelach, które posiadać będą olbrzymią ilość danych.

W postgresie na szczęście znalazłem funkcję: crosstab, która umożliwia szybkie (?) wykonanie zapytania krzyżowego (typu PIVOT) i pięknie realizuje to, o co mi chodzi.

Zastanawiam się tylko jak jest ona zaimplementowana, i jaka jest jej wydajność, czy poradzi sobie z zakładaną dość dużą ilością danych? Ktoś coś wie na ten temat?

wtorek, 12 października 2010

PowerMock i przetestujesz wszystko...

Poprzednie dwa posty pokazywały jak można testować metody i pola prywatne klasy. Zapis nie za piękny, czytelność słaba, ogólnie takie rozwiązanie takie se. Na szczęście istnieje bardzo przyjemne narzędzie umożliwiające wyżej wymienione zagadnienia i wiele, wiele innych.

PowerMock to framework rozszerzający biblioteki takie jak: EasyMock i Mockito. Odsyłam do oficjalnej strony w celu zapoznania się na co konkretnie pozwala PowerMock. Ja używam go wraz z Mockito i nie miałem do tej pory żadnych problemów. Poniżej prosty przykład użycia.

Klasa do testów:
public class SomeClass {
    
    private Integer privateField;

    private boolean privateMethod(String s, int i){
        return false;
    }
}
Klasa testująca:
import static org.powermock.reflect.Whitebox.getField;
import static org.powermock.reflect.Whitebox.invokeMethod;

import java.lang.reflect.Field;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.modules.junit4.PowerMockRunner;

@RunWith(PowerMockRunner.class)
public class SomeClassTest {
    
    @Test
    public void shouldTestPrivateMethod() throws Exception {

        //given
        SomeClass someClass = new SomeClass();

        //when        
        boolean test = invokeMethod(someClass,"privateMethod", "Test", 1);

        //then
    }
    
    
    @Test
    public void shouldTestPrivateField() throws IllegalArgumentException, IllegalAccessException {

        //given
        SomeClass someClass = new SomeClass();

        //when
        Field field = getField(SomeClass.class, "privateField");
        Integer testInteger = (Integer) field.get(someClass);

        //then
    }
}

WhiteBox oferuje jak wspomniałem o wiele więcej i warto się z tą funkcjonalnością zaznajomić.

czwartek, 30 września 2010

Mechanizm refleksji - dostęp do prywatnych atrybutów.

Oczywiście mechanizm refleksji, umożliwia dostęp nie tylko do metod prywatnych, ale również prywatnych atrybutów klasy. Krótki kodzik poniżej, gdyby ktoś potrzebował dostać się do takiego prywatnego pola.

private Integer getPrivateField(SomeClass someClass) 
    throws SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException {

    Field field = SomeClass.class.getDeclaredField("privateInteger");
    field.setAccessible(true); 
 
    return (Integer) field.get(someClass); 
}

public class SomeClass{
    
    private Integer privateInteger;
    ...
}

piątek, 17 września 2010

Testowanie metod prywatnych w javie

Pomijając dysputy o tym, czy w ogóle można, czy też nie można testować metod prywatnych. Zamieszczam przykład jak to zrobić jeśli, ktoś będzie w sytuacji, że po prostu musi to zrobić.

Mamy przykładowo klasę do stestowania:
public class SomeClass{
    ...
    private Boolean someMethod(String s, Integer i){
        ...
    }    
} 

Podczas testów używam mechanizmu refleksji i schematu given/when/then, o którym więcej można posłuchać tutaj. Przykładowa klasa testująca metodę prywatną z klasy SomeClass może być następująca:
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.Fail.fail;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import org.junit.Before;
import org.junit.Test;

public class SomeClassTest {
    
    SomeClass someClass;
    
    @Before
    public void init() {
        someClass = new SomeClass();    
    }
    
    @Test
    public void shouldDoSomethingGood() {        
        //given
        //sekcja przygotowująca
        
        //when
        Boolean result=null;
        try {
            result = invokePrivateMethodSomeMethod("test",10);
        } 
        catch (Exception e) {
            e.printStackTrace();
            fail("Nie oczekujemy wyjątku");
        }        
        //then
        assertThat(result).isTrue();    
    }
    
    private Boolean invokePrivateMethodSomeMethod(String s, Integer i) throws SecurityException, NoSuchMethodException, IllegalArgumentException, IllegalAccessException, InvocationTargetException{
    
        Class[] args =  {String.class, Integer.class}; 
        //niestety musimy wpisać nazwę metody jako string - niezbyt dobre rozwiązanie w przypadku późniejszego refaktoringu
        Method method = SystemUtils.class.getDeclaredMethod("someMethod", args);
        method.setAccessible(true);
        Object[] argObjects = {s,i};
        return (Boolean) method.invoke(someClass, argObjects);
    }
}

piątek, 10 września 2010

Spring form:checkboxes a JSF h:selectManyCheckbox

Krótka notka dla wszystkich, którzy przesiedli się z JSF'a na Spring'a (wersja 2.5.6) i przyszło im użyć tagu form:checkboxes. Idea obu tagów jest taka sama, czyli jak "przyjemnie" obsłużyć na formularzu wiele checkboxów.

Załóżmy, że mamy encje Employee i Language, które są w relacji wiele-do-wielu.
Aby wyświetlić na formularzu wszystkie checkboxy (języki) z zaznaczonymi tymi, które dany pracownik zna. W JSF używamy:


  


To samo w Spring'u wygląda następująco


Pominę kwestie skąd i jak wstrzykiwane są allLanguages i languages. 

Wszystko teoretycznie powinno działać, ale niestety nie jest tak różowo. Pojawia się następujący błąd:
Failed to convert property value of type [java.lang.String] to required type [java.util.Set] for property languages; nested exception is java.lang.IllegalArgumentException: Cannot convert value of type [java.lang.String] to required type [emp.entity.Language] for property languages[0]: no matching editors or conversion strategy found
Przyczyną błędu jest brak odpowiedniego PropertyEditor'a dla Language. Przykładowy editor dla klasy Language:
public class LanguageEditor extends PropertyEditorSupport {    
    private LanguageManager languageManager;   
    public LanguageEditor(LanguageManager languageManager) {
        super();
        this.languageManager = languageManager;
    }
    @Override
    public void setAsText(String text) throws IllegalArgumentException {
        setValue(languageManager.getLanguageById(Long.parseLong(text)));
    }    
    @Override
    public String getAsText() {        
        Language language = (Language) getValue();
        return language == null ? null :  Long.toString(language.getId());
    }    
}

W moim przypadku metoda setAsText używa managera, który pobiera encje Language o danym id z bazy. Trzeba jeszcze tylko dodać tego editora w metodzie initBuilder w kontrolerze który odpowiada za obsługę formularzu:

public class EmployeeEditController extends SimpleFormController{
    ...
    @Override
    protected void initBinder(HttpServletRequest request, ServletRequestDataBinder binder) throws Exception {
    super.initBinder(request, binder);
    binder.registerCustomEditor(Language.class, new LanguageEditor(languageManager));
    
    .... 

} 

I coś takiego rozwiązuje zgłaszany błąd.