Internacionalizando nuestra aplicación PrimeFaces/JSF/Spring

Hola gente,

intentaremos dar un paso más en la construcción de la aplicación PrimeFaces/JSF/Spring, en este caso agregaremos la característica de Localización (I10N) mediante el agregado de componentes de Internacionalización (I18N).

Introducción

Según wikipedia "La internacionalización es el proceso de diseñar software de manera tal que pueda adaptarse a diferentes idiomas y regiones sin la necesidad de realizar cambios de ingeniería ni en el código. La localización es el proceso de adaptar el software para una región específica mediante la adición de componentes específicos de un locale y la traducción de los textos, por lo que también se le puede denominar regionalización. No obstante la traducción literal del inglés es la más extendida.

En informática, un locale es un conjunto de parámetros que define el idiomapaís y cualquier otra preferencia especial que el usuario desee ver en su interfaz de usuario.
Generalmente un identificador de locale consiste como mínimo de un identificador de idioma y un identificador de región. Este concepto es de fundamental importancia en el campo de la localización de idiomas."

Esto parece complejo, pero es sencillo de implementar con JSF.

Manos a la obra!

Archivos de recursos con mensajes

Los archivos de recursos con mensajes son archivos de texto plano que contienen una serie de claves y cada clave contiene un valor asociado, los valores serán las cadenas con mensajes internacionalizados. Luego estos archivos son transformados en objetos java.util.Map para su fácil manipulación.

Crearemos dos archivos de recursos en el paquete ar.com.magm.recursos, los recursos son simples archivos planos, se pueden crear haciendo botón derecho sobre el paquete y seleccionando New > Other... / General / File

Los archivos a crear y sus respectivos contenidos son:

mensajes.properties

lbl.login=Ingreso
lbl.username=Usuario
lbl.password=Clave
lbl.welcome=Bienvenid@
lbl.error.login=Error en el ingreso
lbl.invalidcredentials=Credenciales inválidas

lbl.ventas=Ventas
lbl.gauge=Tacómetro
lbl.logout=Salir
lbl.all.m=Todos
lbl.all.f=Todas
lbl.months=Enero,Febrero,Marzo,Abril,Mayo,Junio,Julio,Agosto,Septiembre,Octubre,Noviembre,Diciembre

lbl.table.sales.empty=No hay ventas con este criterio de filtrado
lbl.find.all=Buscar en todos
lbl.col.zone=Zona
lbl.col.customer=Cliente
lbl.col.year=Año
lbl.col.month=Mes
lbl.col.salesamount=Importe Ventas

lbl.gauge.title=Tacómetro Personalizado

mensajes_en.properties

lbl.login=Login
lbl.username=User
lbl.password=Password
lbl.welcome=Welcome
lbl.error.login=Login error
lbl.invalidcredentials=Invalid credentials

lbl.ventas=Sales
lbl.gauge=Gauge
lbl.logout=Logout
lbl.all.m=All
lbl.all.f=All
lbl.months=January,February,March,April,May,June,July,August,September,October,November,December

lbl.table.sales.empty=There are no sales for this filter criteria
lbl.find.all=Find all
lbl.col.zone=Zone
lbl.col.customer=Customer
lbl.col.year=Year
lbl.col.month=Month
lbl.col.salesamount=Sales amount

lbl.gauge.title=Custom Gauge

Tres cosas son fáciles de notar con solo ver el contenido y nombre de los archivos, una es que el nombre del archivo contiene como parte de el el locale, en el caso de mensajes_en.properties, el locale es en, si por ejemplo necesitásemos crear un archivo con información de localización para Francia, el archivo debería llamarse mensajes_fr.properties. La extensión debe ser .properties, aunque ya no la mencionaremos más. Lo otro que se puede notar es que ambos archivos poseen las mismas claves, solo los valores son diferentes y por último se nota que uno de los archivos no tiene locale, este es el archivo con la localización por defecto o base, en nuestro caso es por Español.

 
Configuración de archivos de recursos en JSF
 
Ahora debemos "decirle" a JSF cual es el archivo de recursos base y cual será el nombre del bean que lo representará en tiempo de ejecución, además configuraremos el locale por defecto y los locales disponibles. Para ello debemos editar el archivo WEB-INF/faces-config.xml y agregar:
 
 

<?xml version="1.0" encoding="UTF-8"?>

<faces-config xmlns="https://java.sun.com/xml/ns/javaee"
  xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://java.sun.com/xml/ns/javaee https://java.sun.com/xml/ns/javaee/web-facesconfig_2_1.xsd" version="2.1">

  <application>
    <el-resolver>org.springframework.web.jsf.el.SpringBeanFacesELResolver</el-resolver>
    <locale-config>
      <default-locale>es</default-locale>
      <supported-locale>en</supported-locale>
    </locale-config>
    <resource-bundle>
      <base-name>ar.com.magm.recursos.mensajes</base-name>
      <var>msg</var>
    </resource-bundle>
  </application>

...
...
 
Creo que el agregado no merece mucha explicación, la estructura XML y los nombres de elemento hablan por si solos. A partir de aquí solo debemos recordar que accederemos al recurso mediante msg, ya que <var>msg</var> y que el recurso es un mapa, por ello haremos cosas como msg['clave'].

Hasta aquí hemos hecho todo lo necesario a nivel de infraestructura para dar soporte a I18N y L10N, ahora debemos recodificar algunos componentes para que esto realmente funcione.

 
Componentes del lado del cliente
 
A continuación copiaré los códigos de los archivos a modificar y resaltaré las modificaciones en negrita.
 
login.xhtml

<html xmlns="https://www.w3c.org/1999/xhtml"
  xmlns:h="https://java.sun.com/jsf/html"
  xmlns:f="https://java.sun.com/jsf/core"
  xmlns:p="https://primefaces.org/ui">
<h:head></h:head>
<h:body style="text-align:center">
  <p:growl id="mensajes" showDetail="true" life="2000" />
  <h:form>
    <p:panel header="#{msg['lbl.login']}" style="width:300px">
      <h:panelGrid columns="2" cellpadding="5">
        <h:outputLabel for="username" value="#{msg['lbl.username']}:" />
        <p:inputText value="#{loginBean.nombre}" id="username" required="true" label="username" />
        <h:outputLabel for="password" value="#{msg['lbl.password']}:" />
        <p:password value="#{loginBean.clave}" id="password" required="true" label="password" />
        <f:facet name="footer">
          <p:commandButton id="loginButton" value="#{msg['lbl.login']}" actionListener="#{loginBean.login}" update=":mensajes" oncomplete="manejarLogin(xhr, status, args)" />
        </f:facet>
      </h:panelGrid>
    </p:panel>
  </h:form>
</h:body>

<script type="text/javascript">
  //<![CDATA[
  function manejarLogin(xhr, status, args) {
    if (!args.validationFailed && args.estaLogeado) {
      setTimeout(function() {
        window.location = args.view;
      }, 1000);
    }
  }
  //]]>
</script>
</html>

menu.xhtml

<html xmlns="https://www.w3c.org/1999/xhtml"

  xmlns:h="https://java.sun.com/jsf/html"
  xmlns:f="https://java.sun.com/jsf/core"
  xmlns:p="https://primefaces.org/ui">
<h:head></h:head>
<h:body>
  <p:layout fullPage="true">
    <p:layoutUnit position="center">
      <iframe id="frame" src="ventas.xhtml" style="width: 100%; height: 100%; text-align: center;" seamless='seamless' />
    </p:layoutUnit>
  </p:layout>
  <h:form id="form">
    <p:dock>
      <p:menuitem value="#{msg['lbl.ventas']}" icon="/images/ventas.png" url="javascript:cambioPagina('ventas.xhtml')" />
      <p:menuitem value="#{msg['lbl.gauge']}" icon="/images/gauge.jpg" url="javascript:cambioPagina('gauge.xhtml')" />
      <p:menuitem value="#{msg['lbl.logout']}" icon="/images/logout.png" actionListener="#{loginBean.logout}" oncomplete="logout(xhr, status, args)" />
    </p:dock>
  </h:form>
</h:body>
<script type="text/javascript">
  //<![CDATA[
  var actual = 'ventas.xhtml';
  function cambioPagina(pagina) {
    if (pagina != actual) {
      $('#frame').attr('src', pagina);
      actual=pagina;
    }
  }
  function logout(xhr, status, args) {
    setTimeout(function() {
      window.location = 'login.xhtml';
    }, 500);
  }
  //]]>
</script>
</html>
 
gauge.xhtml
 

<html xmlns="https://www.w3c.org/1999/xhtml"
  xmlns:h="https://java.sun.com/jsf/html"
  xmlns:p="https://primefaces.org/ui">
<h:head></h:head>
<h:body>
  <h:form id="formGauge">
    <p:poll interval="2" update="gauge" />
    <p:meterGaugeChart id="gauge" value="#{gaugeBean.meterGaugeModel}" showTickLabels="false" labelHeightAdjust="110" intervalOuterRadius="130" seriesColors="66cc66, 93b75f, E7E658, cc6666" style="width:400px;height:250px" title="#{msg['lbl.gauge.title']}" label="km/h" />
  </h:form>
</h:body>
</html>

 
Fácil no?, como se puede observar solo hay que sustituir la cadena que se desee por la expresión EL de forma: #{msg['clave']}

Componentes del lado del server

Los componentes del lado del server también contienen datos que se muestran en las vistas del usuario y deben ser internacionalizados. A continuación se mostrarán las porciones de código y las clases que hay que modificar.
 
ar.com.magm.web.primefaces.LoginBean (solo método login())
 

public void login(ActionEvent actionEvent) {
  RequestContext context = RequestContext.getCurrentInstance();

  FacesContext jsfCtx= FacesContext.getCurrentInstance();
  ResourceBundle bundle = jsfCtx.getApplication().getResourceBundle(jsfCtx, "msg");

  FacesMessage msg = null;
  if (usuarioValido(nombre, clave)) {
    logeado = true;
    msg = new FacesMessage(FacesMessage.SEVERITY_INFO, bundle.getString("lbl.welcome"), nombre);
  } else {
    logeado = false;
    msg = new FacesMessage(FacesMessage.SEVERITY_WARN,bundle.getString("lbl.error.login"), bundle.getString("lbl.invalidcredentials"));
  }
  FacesContext.getCurrentInstance().addMessage(null, msg);
  context.addCallbackParam("estaLogeado", logeado);
  if (logeado)
    context.addCallbackParam("view", "menu.xhtml");
}

Con las siguientes líneas:

FacesContext jsfCtx= FacesContext.getCurrentInstance();
ResourceBundle bundle = jsfCtx.getApplication().getResourceBundle(jsfCtx, "msg");

Obtenemos una referencia al archivo de recursos en la variable bundle.

Luego para obtener un valor utilizamos: bundle.getString("clave")

ar.com.magm.web.primefaces.VentasBean

public class VentasBean implements Serializable {

 
  private static final long serialVersionUID = -6690574219803425728L;
 
  private String[] meses ;
 
  private String sql = "SELECT year(fecha) as anio, month(fecha) as mes, zona, cliente, sum(importe*cantidad) as ventas FROM dw_ventasfact v INNER JOIN clientes c ON v.idCliente=c.idCliente INNER JOIN zonas z ON z.idZona=c.idZona WHERE cliente like ? GROUP BY zona, cliente, anio, mes ORDER BY anio,mes,zona,cliente";
  private List<Venta> ventas;
  private List<Venta> ventasFiltradas;
  private List<String> zonas;
  private FacesContext jsfCtx;
  private ResourceBundle bundle;

  public VentasBean() {
    jsfCtx = FacesContext.getCurrentInstance();
    bundle = jsfCtx.getApplication().getResourceBundle(jsfCtx, "msg");
    // processList(null);
  }
 
  public SelectItem[] getMesesOptions() {
    meses=bundle.getString("lbl.months").split(",");
    SelectItem[] r = new SelectItem[13];
    for (int t = 0; t < meses.length; t++)
      r[t + 1] = new SelectItem(meses[t], meses[t]);
    return r;
  }

...
...

  public SelectItem[] getZonasOptions() {
    SelectItem[] r = new SelectItem[zonas.size() + 1];
    r[0] = new SelectItem("", bundle.getString("lbl.all.f"));
    for (int t = 0; t < zonas.size(); t++)
      r[t + 1] = new SelectItem(zonas.get(t), zonas.get(t));
    return r;
  }
 
  private void processList(Object args[]) {
    meses=bundle.getString("lbl.months").split(",");
    ventas = new ArrayList<Venta>();
    zonas = new ArrayList<String>();
...
...

En el caso de VentasBean solo hemos utilizado la internacionalización para la opción "Todos"/"Todas" de las listas de filtrado y para internacionalizar los meses.
 
Probando la Aplicación
 
Para probar la aplicación, solo debemos reconfigurar nuestro brower y cambiar entre los idiomas Español e Inglés, a continuación adjunto un video del funcionamiento de la aplicación internacionalizada.

 
Como siempre el proyecto completo y el WAR en el repo de GITHUB: https://github.com/magm3333/workspace-pftuto
 
Espero les sea útil.
 
Saludos
 
Mariano