Miało być dwa wpisy odnośnie JSF i Facelets, ale założenia się zmieniły i będzie trzy 
Dzisiaj w sumie nieco mniej o Facelets a trochę więcej o JSF + i18n (internationalization).
JSF ułatwia stworzenie wielojęzycznej aplikacji – wspomniałem o tym w poprzednim wpisie. Ale to za mało, przecież to użytkownik ma decydować o języku, a nie konfiguracja aplikacji. Dlatego trzeba dać mu wybór i zachować go na dłużej, np w sesji. Jednak sesja dość szybko wygasa i co wtedy? Użytkownik znowu wjedzie na stronę i musi ponownie wybrać język. Dobrym miejscem na przechowywanie tej informacji jest cookie.
Zaczynamy
Tworzymy kontroler który obsłuży nam wybór języka [czyli nowy JSF Managed Bean]
package eu.ryznar.controller;
import java.util.Locale;
import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
public class Language {
public Language() {
}
public String changeLanguage() {
FacesContext context = FacesContext.getCurrentInstance();
ExternalContext externalContext= context.getExternalContext();
Map requestParameterMap = (Map) context.getExternalContext().getRequestParameterMap();
String language = requestParameterMap.get("language").toString();
HttpServletResponse httpServletResponse = (HttpServletResponse) externalContext.getResponse();
String cookieName = externalContext.getInitParameter("i18n.languageCookieName");
Cookie cookie = new Cookie(cookieName, language);
cookie.setMaxAge(365);
httpServletResponse.addCookie(cookie);
context.getViewRoot().setLocale(new Locale(language));
return null;
}
}
W kodzie jest użyty parametr i18n.languageCookieName definiujący nazwę ciastka w którym przechowamy wybór użytkownika
Parametr należy dodać w pliku web.xml
<context-param>
<param-name>i18n.languageCookieName</param-name>
<param-value>culture</param-value>
</context-param>
Teraz w szablonie, np w template.xml w pasku bocznym można dodać 2 przyciski do zmiany języka
<h:commandLink action="#{language.changeLanguage}" value="#{msgs.langPL}">
<f:param name="language" value="pl" />
</h:commandLink>
<h:commandLink action="#{language.changeLanguage}" value="#{msgs.langEN}">
<f:param name="language" value="en" />
</h:commandLink>
w pikach z tłumaczeniami trzeba nadać odpowiednie wartości dla langPL i langEN Jeśli to zrobimy można uruchomić aplikację, i wybrać język – w tym momencie powinno zostać utworzone ciasteczko.
Wybór języka już mamy, ale trzeba jeszcze zmienić język interfejsu. Ogólnie to miałem dylemat JSF + i18n – jak ugryźć? – gdzie umieścić kod odpowiedzialny za język interfejsu [Filter, View Handler czy Phase Listener]?
W PHP [w frameworku symfony] taki kod znajdował się w filtrze, tutaj w filtrze nie chciało działać
Stanęło na Phase Listener – powiecie “Leo why?”. Żeby rozszerzając klasę FaceletViewHandler nie uzależniać się od Facelets.
Wyglądało by to tak:
import com.sun.facelets.FaceletViewHandler;
import eu.ryznar.utils.Cookies;
import eu.ryznar.utils.I18N;
import java.util.Locale;
import javax.faces.application.ViewHandler;
import javax.faces.context.FacesContext;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
public class CustomViewHandler extends FaceletViewHandler {
public CustomViewHandler(ViewHandler parent) {
super(parent);
}
@Override
public Locale calculateLocale(FacesContext context) {
return I18N.getLocaleFromCookie(context);
}
}
i faces-config.xml:
<view-handler>eu.ryznar.view.CustomViewHandler</view-handler>
Natomiast aby rozwiązać to wykorzystując PhaseListener tworzymy package eu.ryznar.listener i klasę LocalePhaseListener implementującą interfejs PhaseListener
package eu.ryznar.listener;
import eu.ryznar.utils.I18N;
import java.util.Locale;
import javax.faces.context.FacesContext;
import javax.faces.event.PhaseEvent;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseListener;
public class LocalePhaseListener implements PhaseListener {
public void afterPhase(PhaseEvent event) {
FacesContext context = event.getFacesContext();
Locale currentLocale = I18N.getLocaleFromCookie(context);
context.getViewRoot().setLocale(currentLocale);
return;
}
public void beforePhase(PhaseEvent event) {
return;
}
public PhaseId getPhaseId() {
return PhaseId.RENDER_RESPONSE;
}
}
a w pliku faces-config.xml
<lifecycle>
<phase-listener>eu.ryznar.listener.LocalePhaseListener</phase-listener>
</lifecycle>
Jeszcze tylko klasy do internacjonalizacji i obsługi cookie:
package eu.ryznar.utils;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import javax.faces.application.FacesMessage;
import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
public class I18N {
private static final String LANGUAGE_COOKIE = "i18n.languageCookieName";
public static Locale getLocale(String culture) {
if (culture != null && culture.length() == 2) {
return new Locale(culture);
}
return Locale.getDefault();
}
public static Locale getLocaleFromCookie(FacesContext context) {
ExternalContext externalContext = context.getExternalContext();
Cookie cookie[] = ((HttpServletRequest) externalContext.getRequest()).getCookies();
String cookieName = externalContext.getInitParameter(LANGUAGE_COOKIE);
String cultureCookie = Cookies.getCookie(cookie, cookieName);
return getLocale(cultureCookie);
}
}
eu.ryznar.utils
Cookies.java
package eu.ryznar.utils;
import javax.servlet.http.Cookie;
public class Cookies {
public static String getCookie(Cookie[] cookies, String cookieName) {
String value = null;
if (cookies != null) {
int cookieCounter = cookies.length;
for (int i = 0; i < cookieCounter; i++) {
if (cookies[i].getName().equalsIgnoreCase(cookieName)) {
value = cookies[i].getValue();
}
}
}
return value;
}
}
Po odświeżeniu strony [jeśli poprzednio wybraliśmy język angielski], widzimy ze cały interfejs jest w języku angielskim.
Ok, ale jeszcze coś nie tak. Jeśli wybierzemy inny język to nie ma zmiany, trzeba odświeżyć stronę. Rozwiązanie jest bardzo proste. W klasie Language, w funkcji setLanguageCookie(), przed return wpisujemy poniższą linijkę
context.getViewRoot().setLocale(new Locale(language));
Od teraz, każdy wybór języka skutkuje natychmiastową zmianą.
Czyli JSF i 18n mamy załatwione. Można oczywiście pomyśleć o tym żeby tłumaczenia przechowywać w bazie danych a nie w plikach, żeby bez potrzeby nie wykonywać akcji re-deploy, ale o tym może innym razem.
Kolejny krok w uzupełnianiu aplikacji: edycja i usuwanie
w kontrolerze UserController dodajemy kod
public String deleteUser() {
String r = "user_deleted";
FacesContext context = FacesContext.getCurrentInstance();
Map requestParameterMap = (Map) context.getExternalContext().getRequestParameterMap();
try {
Integer userId = Integer.parseInt(requestParameterMap.get("userId").toString());
User u = userDao.getUser(userId);
String userName = u.getUsername();
userDao.deleteUser(u);
addSuccessMessage("User " + userName + " successfully deleted.");
} catch (NumberFormatException ne) {
addErrorMessage(ne.getLocalizedMessage());
r = "failed";
} catch (Exception e) {
addErrorMessage(e.getLocalizedMessage());
r = "failed";
}
return r;
}
public String editUser() {
FacesContext context = FacesContext.getCurrentInstance();
Map requestParameterMap = (Map) context.getExternalContext().getRequestParameterMap();
Integer userId = Integer.parseInt(requestParameterMap.get("userId").toString());
this.user = userDao.getUser(userId);
return "edit_user";
}
private void addErrorMessage(String msg) {
FacesMessage facesMsg = new FacesMessage(FacesMessage.SEVERITY_ERROR, msg, msg);
FacesContext fc = FacesContext.getCurrentInstance();
fc.addMessage(null, facesMsg);
}
private void addSuccessMessage(String msg) {
FacesMessage facesMsg = new FacesMessage(FacesMessage.SEVERITY_INFO, msg, msg);
FacesContext fc = FacesContext.getCurrentInstance();
fc.addMessage("successInfo", facesMsg);
}
w templejtce list.xhtml dodajemy 2 kolumny
<h:column>
<f:facet name="header">
<h:outputText value="#{msgs.actionEdit}"/>
</f:facet>
<h:commandLink action="#{user.editUser}">
<h:outputText value="#{msgs.actionEdit}"/>
<f:param name="userId" value="#{dataTableItem.userId}" />
</h:commandLink>
</h:column>
<h:column>
<f:facet name="header">
<h:outputText value="#{msgs.actionDelete}"/>
</f:facet>
<h:commandLink action="#{user.deleteUser}">
<h:outputText value="#{msgs.actionDelete}"/>
<f:param name="userId" value="#{dataTableItem.userId}" />
</h:commandLink>
</h:column>
tworzymy plik edituser.xhtml
<ui:define name="header">
#{msgs.userEdit}
</ui:define>
<ui:define name="content">
<h:form>
<h:messages/>
<custom:inputTextLabeled label="#{msgs.userName}" value="#{user.user.username}" required="true" labelStyle="addUserInput" />
<br />
<custom:inputTextLabeled label="#{msgs.userFirstName}" value="#{user.user.firstName}" labelStyle="addUserInput" />
<br />
<custom:inputTextLabeled label="#{msgs.userLastName}" value="#{user.user.lastName}" labelStyle="addUserInput" />
<br />
<custom:inputTextLabeled label="#{msgs.userEmail}" value="#{user.user.email}" required="true" labelStyle="addUserInput" />
<br /><br />
<h:commandButton
action="#{user.saveUser}"
value="#{msgs.btnUpdate}" styleClass="saveButton" />
</h:form>
</ui:define>
</ui:composition>
w faces-config.xml
<navigation-rule>
<from-view-id>/list.xhtml</from-view-id>
<navigation-case>
<from-action>#{user.editUser}</from-action>
<from-outcome>edit_user</from-outcome>
<to-view-id>/edituser.xhtml</to-view-id>
</navigation-case>
</navigation-rule>
<navigation-rule>
<from-view-id>/edituser.xhtml</from-view-id>
<navigation-case>
<from-action>#{user.saveUser}</from-action>
<from-outcome>success</from-outcome>
<to-view-id>/list.xhtml</to-view-id>
</navigation-case>
<navigation-case>
<from-action>#{user.saveUser}</from-action>
<from-outcome>fail</from-outcome>
<to-view-id>/list.xhtml</to-view-id>
</navigation-case>
</navigation-rule>
i do plików z tłumaczeniami dodajemy
actionEdit=Edit
actionDelete=Delete
userEdit=Edit user
btnUpdate=Update
oraz
actionEdit=Edytuj
actionDelete=Usuń
userEdit=Edytuj użytkownika
btnUpdate=Aktualizuj
w list.xhtml dodajemy linię
<h:messages errorClass="msgFail" infoClass="msgSuccess"></h:messages>
tu będą wyświetlane komunikaty zależnie od wykonanej akcji [żeby użytkownik wiedział co się stało].
msgFail i msgSuccess to nazwy klas z pliku CSS [plik dostępny w archiwum dołaczonym do wpisu]
Po usunięciu użytkownika powinniśmy zobaczyć coś takiego:

Jednak coś tu nie gra
intefejs mamy polski, a komunikat jest w języku angielskim. Trzeba jeszcze dodać tłumaczenie treści pochodzących z kontrolera.
na początek prznieśmy metody addErrorMessage(String msg) i addSuccessMessage(String msg) z kontrolera do klasy I18N.
Metody są podobne więc można uprościć sprawę i zapisać je w takiej postaci
public static void addErrorMessage(String msg, String clientId) {
addMessage(msg, FacesMessage.SEVERITY_ERROR, clientId);
}
public static void addSuccessMessage(String msg, String clientId) {
addMessage(msg, FacesMessage.SEVERITY_INFO, clientId);
}
private static void addMessage(String messsage, FacesMessage.Severity messageType, String clientId) {
FacesContext fc = FacesContext.getCurrentInstance();
FacesMessage facesMsg = new FacesMessage(messageType, message, message);
fc.addMessage(clientId, facesMsg);
}
a w klasie User kod
addSuccessMessage("User " + userName + " successfully deleted.");
zamieniamy na
I18N.addSuccessMessage("User " + userName + " successfully deleted.", null);
Dzięki temu mamy możliwość wielokrotnego używania komunikatów w innych kontrolerach. Możemy przetsesować aplikację. Jeśli działa przechodzimy dalej.
Ale chwila? Jak przetłumaczyć string którego fragment jest zmienny?
Bardzo prosto, w plikach z tłumaczeniamy dodajemy linie:
userDeleted=User {0} has been deleted
userDeleted=Usunięto użytkownika {0}
W nawiasach klamrowych mamy parametry które podczas tłumaczenia zostaną zamienione na odpowiednie wartości.
Teraz trochę kodu – w klasie I18N dodajemy jeszcze trzy funkcje [pasowałoby jeszcze dopisać obsługę wyjątków].
private static String getMessageFromResourceBundle(String key, Locale locale, Object[] params) {
ResourceBundle bundle = getBundle(locale);
String message = "";
if (bundle == null) {
return null;
}
try {
message = formatMessage(bundle.getString(key), params, locale);
} catch (Exception e) {
}
return message;
}
private static final String BUNDLE_NAME = "eu.ryznar.bundle.messages";
private static ResourceBundle getBundle(Locale locale) {
ResourceBundle bundle = null;
try {
bundle = ResourceBundle.getBundle(BUNDLE_NAME, locale);
} catch (MissingResourceException e) {
// plik nie odnaleziony
}
return bundle;
}
private static String formatMessage(String message, Object[] params, Locale locale) {
if (params != null) {
MessageFormat mf = new MessageFormat(message, locale);
return mf.format(params, new StringBuffer(), null).toString();
}
return message;
}
i w tej samej klasie modyfikujemy wcześniej dodane funkcje [rozsdzerzamy funkcje o możliwość przetworzenia stringów ze parametrami]:
public static void addErrorMessage(String msg, String clientId, Object[] params) {
addMessage(msg, FacesMessage.SEVERITY_ERROR, clientId, params);
}
public static void addSuccessMessage(String msg, String clientId, Object[] params) {
addMessage(msg, FacesMessage.SEVERITY_INFO, clientId, params);
}
private static void addMessage(String msg, FacesMessage.Severity messageType, String clientId, Object[] params) {
FacesContext fc = FacesContext.getCurrentInstance();
String message = I18N.getMessageFromResourceBundle(msg, fc.getViewRoot().getLocale(), params);
FacesMessage facesMsg = new FacesMessage(messageType, message, message);
fc.addMessage(clientId, facesMsg);
}
To wszystko. Archiwum z projektem jest dostępne tutaj.
Do przedstawionego kodu mam jeszcze jedną uwagę. Nie podoba mi się. Czy nie można jakoś norlanie pobrać wartości ciasteczka? Albo funkcja haszująca hasło [w poprzednim artykule]. Czy tego nie da się załątwić jedną lub dwoma linijkami kodu?
I jak zwykle będę wdzięczny za wskazanie wszelkich uchybień jakich dopuściłem się w kodzie bądź w treści.
Najnowsze komentarze