viernes, 11 de noviembre de 2011

Internacionalización (i18n) de campos con Hibernate

Java
Si estamos desarrollando un sitio web que soporta varios idiomas necesitaremos internacionalizar (i18n) los literales que aparecen en él. Esto incluye también tenerlo en cuenta y solucionarlo en los nombres, descripciones y textos que guardamos en la base de datos de las entidades de dominio y que puedan aparecer en en el html generado.

Hibernate
La primera solución que se nos ocurre para las entidades de dominio es crear un campo por cada idioma y concepto a internacionalizar. Sin embargo, esto tiene el problema de que en la base de datos el número de campos crecerá rápidamente y si tenemos muchos elementos a internacionalizar y muchos idiomas el número de campos puede ser un problema por el tamaño de fila, y aunque los limites en postgresql y los límites de mysql son elevados y más que suficientes para la mayoría de los casos, con la solución anterior podemos llegar a ellos o estar peligrosamente cerca.


Diagrama entidad relación de base de datos
Primera solución (variación número columnas)

Para evitarnos problemas las buenas prácticas de diseño de las bases de datos dicen que las tablas han de estar normalizadas y siguiengo la solución anterior tendríamos variaciones en el número de columnas con lo que inclumpliríamos la primera forma normal (1FN). ¿Pero porque inclumplir esta forma normal puede ser un problema? Porque en el momento que tengamos que soportar un nuevo idioma deberemos modificar el esquema de la base de datos añadiendo un campo por cada concepto de las entidades que haya que internacionalizar. Y hacer esto en una base de datos que está en producción puede ser una fuente de problemas y un peligro. Además, en tablas con muchos registros (de unos cuantos miles o cientos de miles) añadir una columna puede ser muy lento pudiendo llegar a horas o más tiempo cosa que dependiendo del proyecto no es viable al poder tener que realizar paradas largas por mantenimiento.

La solución que voy a explicar a continuación es crear una tabla Diccionario la cual relacionaremos con las tablas de los datos de dominio (Producto) y una tabla Traduccion que relacionaremos con la tabla Diccionario y que contendrá las traducciones para cada idioma. La tabla Traduccion contendrá tres campos la clave primaria del diccionario, el idioma de la traduccion y el literal de la traducción propiamente dicho con lo que ya no tendremos variaciones en el número columnas. En la tabla Traduccion la clave primaria estará formada por la clave del diccionario y el campo locale. El esquema que tendríamos sería el siguiente:

Diagrama entidad relación de base de datos.
Producto-Diccionario 1:1
Diccionario-Traduccion 1:N
 A primera vista la tabla Diccionario no tiene mucho sentido pero nos permitirá crear en Hibernate una clase donde podremos incluir algunos métodos de utilidad de forma que podamos acceder a las traducciones más cómodamente. Para modelar este esquema con hibernate necesitaremos las siguientes entidades de dominio, la entidad Producto, Diccionario, Traduccion y TraduccionPK (necesaria en Hibernate al tener Traduccion una clave compuesta por dos campos o columnas).

// Producto.java (Código resumido)
...

@Entity
@Table(name = "Producto")
public class Producto implements Serializable {
 ...

 @OneToOne(cascade = CascadeType.ALL)
 @JoinColumn(name = "nombre_id", nullable = true)
 private Diccionario nombre;

 @OneToOne(cascade = CascadeType.ALL)
 @JoinColumn(name = "descripcion_id", nullable = true)
 private Diccionario descripcion;

 ...
}

// Diccionario.java
package com.blogspot.elblogdepicodev.domain;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.MapKeyColumn;
import javax.persistence.OneToMany;
import javax.persistence.Table;

import org.apache.commons.lang3.StringUtils;

import com.blogspot.elblogdepicodev.misc.AppThreadLocal;

@Entity
@Table(name = "diccionario")
public class Diccionario implements Serializable {

 private static final long serialVersionUID = -1210174827995726573L;
 
 private static final int LONGITUD_TEXTO_ABREVIADO = 10;
 
 @Id
 @GeneratedValue
 private Long id;

 @OneToMany(cascade = CascadeType.ALL)
 @JoinColumn(name = "diccionario_id")
 @MapKeyColumn(name = "locale")
 private Map<String, Traduccion> traducciones;
 
 public Diccionario() {
 }
 
 public Long getId() {
  return id;
 }

 public void setId(Long id) {
  this.id = id;
 }

 public Map<String, Traduccion> getTraducciones() {
  if (traducciones == null) {
   traducciones = new HashMap<String, Traduccion>();
  }
  return traducciones;
 }

 public void setTraducciones(Map<String, Traduccion> textos) {
  this.traducciones = textos;
 }

 //
 public String getTexto() {
  return getTexto(getLocale());
 }

 public void setTexto(String texto) {
  setTexto(getLocale(), texto);
 }
 
 public String getTexto(Locale locale) {
  Traduccion t = getTraducciones().get(locale.toString());
  if (t == null) {
   return null;
  }
  return t.getTexto();
 }

 public void setTexto(Locale locale, String texto) {
  Traduccion t = getTraducciones().get(locale.toString());
  if (t == null) {
   t = new Traduccion(this, locale.toString());
   getTraducciones().put(locale.toString(), t);
  }
  t.setTexto(texto);
 }

 //
 public boolean equals(Diccionario d) {
  if (d == null) {
   return false;
  }
  return getId().equals(d.getId());
 }

 @Override
 @SuppressWarnings({ "unchecked", "rawtypes" })
 public String toString() {
  Map m = new HashMap();
  m.put("id", getId());
  m.put("texto", StringUtils.abbreviate(getTexto(), LONGITUD_TEXTO_ABREVIADO));
  return m.toString();
 }
    
 private Locale getLocale() {
  return AppThreadLocal.getPreferencias().getLocale();
 }
}

// Traduccion.java
package com.blogspot.elblogdepicodev.domain;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

import org.apache.commons.lang3.StringUtils;

import com.blogspot.elblogdepicodev.misc.Utilidades;

@Entity
@Table(name = "traduccion")
public class Traduccion implements Serializable {

 private static final long serialVersionUID = -1210174827995726573L;
 
 private static final int LONGITUD_TEXTO_ABREVIADO = 10;

 @Id
 private TraduccionPK id;

 @Column(length = 65536)
 private String texto;

 public Traduccion() {  
 }
 
 public Traduccion(Diccionario diccionario, String locale) {
  this.id = new TraduccionPK(diccionario, locale);
 }
 
 public TraduccionPK getId() {
  return id;
 }

 public void setId(TraduccionPK id) {
  this.id = id;
 }

 public String getTexto() {
  return texto;
 }

 public void setTexto(String texto) {
  this.texto = texto;
 }
 
 //
 public Locale getLocale() {
  return Utilidades.getLocale(id.getLocale());
 }

 //
 public boolean equals(Traduccion t) {
  if (t == null) {
   return false;
  }
  return getId().equals(t.getId());
 }

 @Override
 @SuppressWarnings({ "unchecked", "rawtypes" })
 public String toString() {
  Map m = new HashMap();
  m.put("id", getId());
  m.put("locale", getId().getLocale());
  m.put("texto", StringUtils.abbreviate(getTexto(), LONGITUD_TEXTO_ABREVIADO));
  return m.toString();
 }
}

// TraduccionPK.java
package com.blogspot.elblogdepicodev.domain;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

import javax.persistence.Basic;
import javax.persistence.Embeddable;
import javax.persistence.ManyToOne;

@Embeddable
public class TraduccionPK implements Serializable {
 
 private static final long serialVersionUID = 4445136951104395835L;

 @ManyToOne
 private Diccionario diccionario;

 @Basic
 private String locale;

 public TraduccionPK() {  
 }
 
 public TraduccionPK(Diccionario diccionario, String locale) {
  this.diccionario = diccionario;
  this.locale = locale;
 }
 
 public Diccionario getDiccionario() {
  return diccionario;
 }

 public void setDiccionario(Diccionario diccionario) {
  this.diccionario = diccionario;
 }

 public String getLocale() {
  return locale;
 }

 public void setLocale(String locale) {
  this.locale = locale;
 }

 //
 public boolean equals(TraduccionPK t) {
  if (t == null) {
   return false;
  }
  return diccionario.equals(t.getDiccionario()) && getLocale().equals(t.getLocale());
 }

 @Override
 @SuppressWarnings({ "unchecked", "rawtypes" })
 public String toString() {
  Map m = new HashMap();
  m.put("dicionario", getDiccionario().toString());
  m.put("locale", getLocale());
  return m.toString();
 }
}

// Utilidades.java
public class Utilidades {
 ...

 public static Locale getLocale(String locale) {
  String[] s = locale.split("_");
  switch (s.length) {
  case 1: {
   return new Locale(s[0]);
  }
  case 2: {
   return new Locale(s[0], s[1]);
  }
  case 3: {
   return new Locale(s[0], s[1], s[2]);
  }
  default: {
   throw new IllegalArgumentException();
  }
  } 
 }

 ...
}

Esta solución carga las traducciones para todos los idiomas de un diccionario cuando posiblemente solo necesitemos la traducción de un idioma en concreto, tal vez si no quisiésemos que se carguen todos los textos de las traducciones podríamos sacar el campo texto a otra tabla y relacionarla con la Traduccion con un id con lo que los campos de tablas nos quedarían: Traduccion (diccionario_id, locale, texto_id), Texto (id, texto). Aunque con esta última solución necesitamos lanzar una sql más por cada texto que queramos acceder.

Otro problema común que se nos suele presentar en las entidades de dominio es como hacer búsquedas de texto completo («full text seach») en algunas propiedades de esas entidades y con esto no me refiero al débil like de SQL, en el enlace anterior doy varias soluciones (Hibernate Search, SQL, elasticsearch).

Referencia:
http://www.hibernate.org/
http://www.postgresql.org/about/
http://dev.mysql.com/doc/refman/5.0/en/column-count-limit.html
http://es.wikipedia.org/wiki/Normalizaci%C3%B3n_de_bases_de_datos
http://support.microsoft.com/kb/283878/es