Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

Java : internationalisation des fichiers "properties"

De nos jours, il est indispensable d'internationaliser correctement les applications, et en particulier les applications web.

La technique habituelle consiste à externaliser les chaînes de caractère affichées par l'application dans des fichiers ".properties" (un par langue). Ces fichiers peuvent ensuite être chargés grâce à la classe Properties, soit manuellement (à la charge du développeur de déterminer le bon fichier), soit via un ResourceBundle (qui sait sélectionner le fichier adéquat en fonction d'une Locale).

Dans les deux cas, on se heurte à une limitation fondamentale du fichier ".properties" : son contenu est systématiquement considéré comme encodé en ISO-8859-1. Si cela convient parfaitement pour la plupart des langues de racine latine ou germanique, il est en revanche très difficile de représenter les symboles de beaucoup d'autres langues (asiatiques, arabes...). Certes, il est toujours possible de les remplacer par leurs équivalents unicodes (ex: 家 = \u5bb6), mais le texte ainsi produit est illisible et se prête mal à la relecture ou à la modification.

Démonstration du problème

Pour démontrer le problème, nous allons créer un fichier ".properties", y placer des caractères russes et japonais, et tenter de le charger avec la classe Properties.
Attention : si vous êtes sous Eclipse, n'oubliez pas de régler la console afin qu'elle puisse afficher correctement des caractères UTF-8 : menu Run -> Run Configurations... -> onglet Common -> Console Encoding -> UTF-8.

Voici le contenu du fichier ".properties" :

# Fichier : i18n.properties
francais=Français
japonais=日本語
russe=русский язык

Voici un petit programme de test, qui lit le fichier ci-dessus et l'affiche dans la console :

  1. // Fichier : I18NTest.java
  2. public class I18NTest
  3. {
  4. public static void main(String[] args) throws Exception
  5. {
  6. Properties p = new Properties();
  7. p.load(new FileInputStream("i18n.properties"));
  8. p.list(System.out);
  9. }
  10. }

L'exécution de ce programme donne le résultat suivant, prouvant que les caractères non-ascii ont été mal décodés par la classe Properties :

-- listing properties --
francais=Français
japonais=日本語
russe=русский язык

Une solution simple en Java 5+

Cette limitation de la classe Properties a été apparemment reconnue, et depuis Java 5, celle-ci dispose d'une méthode alternative de chargement des propriétés : il est désormais possible de charger des fichiers au format XML, dont le principal avantage est le libre choix de la méthode d'encodage, moyennant sa déclaration au niveau du header :

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

Le format du fichier XML est défini par une DTD très simple :

<!ELEMENT properties ( comment?, entry* ) >
<!ATTLIST properties version CDATA #FIXED "1.0">
<!ELEMENT comment (#PCDATA) >
<!ELEMENT entry (#PCDATA) >
<!ATTLIST entry key CDATA #REQUIRED>

Convertissons notre fichier d'exemple au format XML :

  1. <!-- Fichier : i18n.properties.xml -->
  2. <?xml version="1.0" encoding="UTF-8"?>
  3. <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
  4. <properties>
  5. <entry key="francais">Français</entry>
  6. <entry key="japonais">日本語</entry>
  7. <entry key="russe">русский язык</entry>
  8. </properties>

Et modifions le programme d'exemple.
Notez que la méthode appelée est désormais loadFromXML(), et non plus load() :

  1. public class I18NTest
  2. {
  3. public static void main(String[] args) throws Exception
  4. {
  5. Properties p = new Properties();
  6. p.loadFromXML(new FileInputStream("i18n.properties.xml"));
  7. p.list(System.out);
  8. }
  9. }

Cette fois nous obtenons le bon résultat :

-- listing properties --
japonais=日本語
russe=русский язык
francais=Français

Utilisation avec les ResourceBundles

Nous avons vu qu'il est avantageux et très facile de remplacer les "vieux" fichiers ".properties" par des fichiers XML... si l'on manipule la classe Properties directement. Mais qu'en est-il si l'on passe par un ResourceBundle ?

Edit :
Lors de la rédaction de cet article, je n'avais trouvé aucune solution pour utiliser les ResourceBundles en conjonction avec les fichiers de propriétés au format XML. Depuis, les deux solutions ci-dessous ont été portées à mon attention (merci à fabien29200 !).

Il existe donc (au moins) deux solutions pour charger des fichiers de propriétés XML depuis un ResourceBundle.

  • La première consiste à implémenter une classe ResourceBundle.Control personnalisée, et à la passer en paramètre de la méthode ResourceBundle.getBundle(). Cette technique est bien expliquée dans la javadoc de cette classe, mais peu pratique à mettre en oeuvre.
  • La seconde consiste à développer une sous-classe (réutilisable) de ResourceBundle, sachant gérer les fichiers XML. Voici donc la classe XMLResourceBundle, inspirée de PropertyResourceBundle (et qui, à mon sens, aurait dû être incluse dans le JDK...) :
  1. /**
  2.  * Implémentation de ResourceBundle permettant de charger les ressources depuis un fichier XML.
  3.  */
  4. public abstract class XMLResourceBundle extends ResourceBundle
  5. {
  6. /** Suffixe des fichiers de ressources */
  7. public static final String FILE_SUFFIX = ".xml";
  8.  
  9. /** Ensemble des propriétés lues depuis le fichier de ressources */
  10. private final Properties properties = new Properties();
  11.  
  12. /**
  13. * Constructeur. Charge le fichier de ressources identifié par {@link XMLResourceBundle#getFileName()}
  14. * @throws IOException Si une erreur survient lors de la lecture du fichier de ressources.
  15. */
  16. protected XMLResourceBundle() throws IOException
  17. {
  18. InputStream stream = getClass().getClassLoader().getResourceAsStream(getFileName());
  19. try
  20. { this.properties.loadFromXML(stream);
  21. }
  22. finally
  23. { stream.close();
  24. }
  25. }
  26.  
  27. /** Implémentation de ResourceBundle */
  28. @Override
  29. public Object handleGetObject(String key)
  30. {
  31. return this.properties.get(key);
  32. }
  33.  
  34. /** Implémentation de ResourceBundle */
  35. @Override
  36. public Enumeration getKeys()
  37. {
  38. return this.properties.keys();
  39. }
  40.  
  41. /**
  42. * Détermine le nom du fichier de ressources. Permet aux sous-classes de modifier la stratégie de localisation du fichier de ressources.
  43. * Par défaut, recherche un fichier de même nom et situé dans la même package que la classe ResourceBundle associée, avec une extension définie par {@link XMLResourceBundle#FILE_SUFFIX}.
  44. * @return Le nom du fichier de ressources
  45. */
  46. protected String getFileName()
  47. {
  48. return this.getClass().getName().replace('.', '/') + XMLResourceBundle.FILE_SUFFIX;
  49. }
  50. }

Cette classe s'utilise ensuite exactement comme un PropertyResourceBundle. Pour chaque Locale supportée, il faut :

  • Créer une sous-classe de XMLResourceBundle, nommée selon le pattern "MaClasse_Locale" (ex: "LoginScreenProperties_fr_FR")
  • Créer un fichier de propriétés associé. Par défaut, le fichier doit avoir le même nom et être situé dans le même package que la classe permettant de le gérer, mais ceci est configurable en surchargeant la méthode XMLResourceBundle.getFileName().

Exemple :

  • Soit une classe LoginScreen devant être internationalisée
  • Nous décidons que le nom de base des ResourceBundles gérant l'internationalisation de cette classe sera "LoginScreenProperties"
  • Pour chaque Locale supportée, nous allons donc :
    • développer une classe dérivant de XMLResourceBundle, nommée selon le pattern "LoginScreenProperties_Locale" (ex: LoginScreenProperties_fr_FR)
    • créer un fichier XML nommé selon le même pattern (ex: LoginScreenProperties_fr_FR.xml)
  • La classe LoginScreen peut maintenant appeler ResourceBundle.getBundle("LoginScreenProperties", locale) pour récupérer le XMLResourceBundle correspondant à la Locale sélectionnée
  • La classe LoginScreen peut enfin interroger ce XMLResourceBundle pour récupérer les textes internationalisés
  1. /** ResourceBundle par défaut */
  2. public class LoginScreenProperties extends XMLResourceBundle
  3. {
  4. public LoginScreenProperties() throws IOException { }
  5. }
  6.  
  7. /** ResourceBundle pour la Locale française */
  8. public class LoginScreenProperties_fr_FR extends XMLResourceBundle
  9. {
  10. public LoginScreenProperties_fr_FR() throws IOException { }
  11. }

Fichiers de propriétés (dans le même package que les classes ci-dessus) :

  1. <!-- Fichier LoginScreenProperties.xml -->
  2. <?xml version="1.0" encoding="UTF-8"?>
  3. <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
  4. <properties>
  5. <entry key="login">Login</entry>
  6. <entry key="username">Username</entry>
  7. <entry key="password">Password</entry>
  8. </properties>
  9.  
  10. <!-- Fichier LoginScreenProperties_fr_FR.xml -->
  11. <?xml version="1.0" encoding="UTF-8"?>
  12. <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
  13. <properties>
  14. <entry key="login">Identification</entry>
  15. <entry key="username">Identifiant</entry>
  16. <entry key="password">Mot de passe</entry>
  17. </properties>

Notre hypothétique classe LoginScreen pourrait alors charger les propriétés comme ceci :

  1. public class LoginScreen
  2. {
  3. public LoginScreen(Locale locale)
  4. {
  5. // Calcul du nom du nom de base des ResourceBundles associés, en ajoutant "Properties" au nom de la classe (ex: LoginScreen -> LoginScreenProperties)
  6. String propertiesClassBaseName = LoginScreen.class.getName() + "Properties";
  7.  
  8. // Chargement du bon ResourceBundle en fonction de la Locale
  9. ResourceBundle rb = ResourceBundle.getBundle(propertiesClassBaseName, locale);
  10.  
  11. // Récupération du texte localisé pour la clé "password"
  12. String passwordInputLabel = rb.getString("password");
  13. }
  14. }

Commentaires

1. Le lundi 3 novembre 2008, 08:17 par fabien29200

Pourtant une simple recherche Google "resourcebundle loadFromXML" donne une réponse.

Regarde l'exemple 2 de la page : http://java.sun.com/javase/6/docs/api/java/util/ResourceBundle.Control.html

Ca fonctionne chez moi. J'ai mis ça dans la classe Messages :

private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle.getBundle(BUNDLE_NAME, new ResourceBundle.Control() { ... cf exemple 2 du lien ... }

et donc aussi la classe XMLResourceBundle :

private static class XMLResourceBundle extends ResourceBundle { ... cf exemple 2 du lien ... }

a++;

2. Le lundi 3 novembre 2008, 10:52 par Olivier Croisier

Ah oui effectivement, je suis complètement passé à côté de cette solution; pourtant expliquée dans la javadoc... Par contre, je trouve que cela reste relativement artisanal et peu pratique. Pourquoi n'ont-ils pas directement simplifié tout ce système en incluant une classe Control modifiée dans le jdk ?

En tout cas merci, je modifierai la fin de l'article en conséquence !

3. Le lundi 3 novembre 2008, 11:38 par fabien29200

C'est clair que c'est pas ce qu'il y a de plus propre de réimplémenter en dur la classe Control ... Dommage qu'il n'est pas pris le temps d'intégrer ça à la JVM.

4. Le mardi 4 novembre 2008, 02:38 par Olivier Croisier

Voilà, j'ai mis à jour l'article, en détaillant bien la solution qui me paraît la plus propre (et réutilisable). Encore merci :)

5. Le mardi 4 novembre 2008, 08:59 par fabien29200

Le problème de la solution numéro 2 est qu'il faut créer une classe (et donc toucher au code) pour supporter une nouvelle langue. Or un des avantages de la norme i18n est d'essayer au maximum de rendre indépendant le code et les langues supportées.

6. Le mardi 4 novembre 2008, 11:58 par Olivier Croisier

Je ne vois pas le problème de la solution 2 ? Les ResourceBundles ont toujours obligé à développer une classe par Locale supportée... (cf. Javadoc) La méthode RessourceBundle.getBundle(String baseName, Locale locale) est une "factory" qui localise la classe nommée selon le pattern "BaseName_locale" et en renvoie une instance. Je pense donc au contraire que cette solution est la plus simple et la moins intrusive.

7. Le mardi 4 novembre 2008, 13:13 par fabien29200

Ah ben moi, je ne crée qu'un seul fichier par langue. J'ai un messages.xml et un message_fr_FR.xml.

Ensuite, si je fais un Locale.setDefault de la locale voulue, et hop, mon appli se retrouve dans une autre langue.

D'ailleurs, si on regarde le tutoriel i18n de Sun, à aucun moment il n'est conseillé de faire des classes supplémentaires par locale : http://java.sun.com/docs/books/tutorial/i18n/intro/steps.html

8. Le mardi 4 novembre 2008, 14:09 par Olivier Croisier

Ils disent l'inverse dans le chapitre sur les ResourceBundles : "Conceptually each ResourceBundle is a set of related subclasses that share the same base name. The list that follows shows a set of related subclasses. ButtonLabel is the base name. The characters following the base name indicate the language code, country code, and variant of a Locale. ButtonLabel_en_GB, for example, matches the Locale specified by the language code for English (en) and the country code for Great Britain (GB)."

Mon vieux livre "Core Java" indique également qu'il faut créer une sous-classe de ResourceBundle par Locale. Mais effectivement, certains exemples laissent penser qu'un seul ResourceBundle peut suffire pour gérer l'ensemble des fichiers ".properties" liés. La doc n'est décidément pas très claire sur le sujet...

9. Le mardi 4 novembre 2008, 23:02 par fabien29200

Bizarre ... Pourtant c'est explicitement dit dans l'introduction d'i18n : "Support for new languages does not require recompilation"

http://java.sun.com/docs/books/tutorial/i18n/intro/index.html

Mais si on sépare déjà les textes dans des fichiers différents et que ResourceBundle peut choisir tout seul le bon fichier en fonction de la locale, pourquoi faire des classes séparées ? A mon avis, ça peut se justifier si il y a des modifications + importantes (comme des sens de lecture différents) qui nécessitent d'adapter les objets graphiques, mais sinon, c'est se compliquer la vie à mon sens.

10. Le mardi 25 août 2009, 13:33 par Philippe Lhoste

Olivier, le chapitre dit : "Conceptually"... Le but de la factory est précisément de fournir simplement des instances de classes à partir des fichiers properties.
L'article http://java.sun.com/developer/techn... donne un bon exemple : on peut utiliser des sous-classes (ListResourceBundle, PropertyResourceBundle ou custom) ou juste utiliser des fichiers .properties. Autrement dit, on peut créer des classes plus ou moins personnalisées, comme dans l'exemple de l'API, qui retourne une chaîne en fonction des clés données (on peut imaginer d'utiliser des méthodes plus sophistiquées), et utiliser les fichiers .class générés comme ressources, mais c'est optionnel, cela fonctionne très bien avec juste la factory et les fichiers texte.

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.