Encoding et alphabets internationaux : comprendre et debugger (2e partie)

Après le premier post où j'expliquais ce qu'était l'encoding, je vais, cette fois, aborder comment gérer l'encoding en Java. mon but étant de faire un article qui se veut complet, quitte à être un peu long. Je donnerai, à chaque fois des exemples de code pour illustrer. Sommaire :

  • Comment gérer et débugger les problèmes d'encoding en Java
  • La représentation interne des Strings en Java
  • Encoding du fichier source
  • Manipulation de Strings
  • Ecriture dans un fichier
  • Connaitre et modifier l'encoding de votre JVM
  • Comprendre ce qu'est input method et inputContext
  • Affichage dans un logiciel (navigateur,éditeur,...)
  • Comment gérer l'encoding dans un environnement Web (Apache, Tomcat, ModJK, framework MVC, base de données, navigateurs).

tous les exemples de code sont réunis dans une classe que j'ai appelé 'EncodingProblemsRepairKit', disponible en annexe à la fin du post. Le code n'est pas blindé (pas de tests, pas de vérification des paramètres d'entrée,etc) et n'est donné qu'a titre d'illustration et fournit quelques méthodes pour débugger plus facilement

Comment gérer et débugger les problèmes d'encoding en Java

Lorsque vous avez un problème d'encoding en Java, il vous faut trouver le ou les maillons qui 'cassent' l'encoding. Est-ce :

  • La lecture de la source d'entrée : la soumission d'un formulaire web, la saisie en ligne de commande, la lecture d'un fichier,...
  • L'écriture des données sur la sortie : l'affichage sur une page web, l'écriture dans un fichier, la sortie sur la console,...
  • Le logiciel que l'on utilise qui affiche mal le résultat : votre navigateur internet peut ne pas arriver à détecter l'encoding automatiquement, ou cette option est peut être désactivée, vos préférences définissent un encoding UTF-8 alors que le contenu est en ISO-8859-1,...

Cela ne veux pas dire que vous devez garder le même encoding, de bout en bout, pour les différentes étapes, mais que la lecture ou l'écriture des informations doivent être cohérentes : Vous ne pouvez lire des données en UTF-8 si ces dernières sont en ISO-8859-1, et vice versa. Cela parait évident, mais représente la majorité des problèmes d'encoding.

Plusieurs règles doivent être respectées lors de votre debug :

  • Les problèmes d'encoding sont dépendants de l'environnement (JVM, Système d'exploitation, locale système, LC_TYPE, etc), il ne faut pas crier victoire trop vite : un problème d'encoding résolu est un problème où la solution ne laisse pas de place au hasard ! et où des tests unitaires couvrent les différents environnements possibles !
  • Travaillez en hexadécimal, vous permet de comprendre la représentation 'brute'. Des méthodes utilitaires sont disponibles dans la classe EncodingProblemsRepairKit disponible en fichier joint à ce post
  • N'utilisez pas les méthodes qui utilisent l'encoding par défaut, cela rend votre code fragile à l'environnement d'exécution (nous allons y revenir en détails)

Pour comprendre comment débugger ces problèmes, il faut comprendre la différence entre travailler avec des octets (aka : byte / Byte) ou travailler avec des caractères Unicode représentés par la classe Character ou le type natif char. On peut par exemple connaitre le sens d'écriture d'un char (de droite à gauche ou de gauche à droite) en appelant getDirectionality(). Ce qui n'est évidemment pas possible avec un byte :)

En java, les Reader / Writer travaillent avec des caractères Unicode, alors que des InputStream / OutputStream travaillent avec des octets. Il est par exemple absurde de lire un fichier contenant une image avec un Reader, puisque son contenu est binaire. En revanche utiliser un InputStream a du sens. Un Inputstream va lire octet par octet sans ce soucier de l'encoding, tandis qu'un Reader va lire, caractère par caractère selon un algorithme défini par l'encoding. C'est pourquoi il n'est pas possible de spécifier l'encoding lors de la lecture dans un InputStream, alors que pour un Reader, on en a besoin pour connaitre la façon de lire les caractères :

Dans le code ci dessus, je crée un buffer où les octets vont être lus, un à un, donc, peu importe l'encoding.

InputStream stream = new FileInputStream(file);

Voici maintenant un bout de code où Je lis, caractère par caractère, dans le flux d'octets, en considérant que les caractères Unicode sont encodés en UTF-8 et je les place dans un tableau de char

InputStream stream = new FileInputStream(file);
Reader reader = new InputStreamReader(stream, "UTF-8");
char[] OneChar = new char[1];
reader.read(OneChar);

L'erreur classique, qui engendre des problèmes est de ne pas spécifier l'encoding, en effet :

 reader = new InputStreamReader(stream);

est tout à fait valide, et comme le stipule la javadoc, c'est l'encoding par défaut qui sera utilisé, c'est à dire celui de la JVM (voir plus bas, comment connaitre et modifier l'encoding de la JVM)

Il existe la même différence entre un Writer et un Outputstream :

OutputStream stream = new FileOutputStream(file);

Pour un writer :

OutputStream stream = new FileOutPutStream(file);
Writer writer = new OutputStreamWriter(stream, "UTF-8");
writer.write(CharArray);

Là aussi il faut TOUJOURS spécifier l'encoding même si le constructeur OutputStreamWriter(OutputStream out) existe, ne l'utilisez pas! Cela vous évitera des problèmes du style : sur ma machine, l'encoding est correct, mais en production, l'encoding comporte des caractères bizarres. C'est pour cela que je disais que l'encoding dépendait de la plateforme.

La représentation interne des Strings en Java

Les strings déclarées dans les fichiers sources Java sont compilées et encodées en UTF-8 (UTF-8 modifié, si on veux être rigoureux), et ce peu importe l'encodage du fichier source !. C'est très facilement vérifiable : créons une classe contenant la String suivante :

String maString ="ééééé";

Puis ouvrons le .class compilé, avec un éditeur hexadécimal : on verra (entre bien d'autres informations) 5 fois C3 A9. ce qui correspond à 5 'é' encodés en UTF-8. Recommençons l'opération en changeant l'encoding du fichier source : cela ne change rien, et nous aurions pu la déclarer en Unicode, cela aurait donné la même chose :

public static String maStringEnUnicode="\u00E9\u00E9\u00E9\u00E9\u00E9";

Mais attention, il ne faut pas confondre l'encoding des Strings compilées avec la représentation interne des Strings dans la JVM qui est un Integer de 16 bits non signé, pouvant soit représenté, un code Unicode allant de U+0000 à U+FFFF ou un caractére UTF-16.

Encoding du fichier source

Puisque les chaines de caractères sont encodées une fois compilées vous n'avez pas à vous soucier de l'encoding du fichier source. Vous évitez aussi le problème de lecture de flux (puisque la String n'est pas lue mais compilée).

Pour spécifier l'encoding du fichier source, vous devez spécifier l'option encoding au compilateur. Exemple pour un fichier source encodé en Shift_JIS (japonais) :

javac -encoding Shift_JIS MonFichier.java

Tous les IDE permettent également de spécifier l'encoding. Sinon des outils comme NativeToAscii permettent de convertir des fichiers en unicode/Latin1 afin d'être indépendant de l'encoding.

Manipulation de Strings

Maintenant que l'on connait la représentation interne des Strings, on peut se demander ce qui se passe lorsque l'on fait un getBytes sur une String, c'est-a-dire que l'on veuille convertir les chars de la string en octets. Quel sera l'encoding utilisé ? Si une String est mal encodée en sortie, c'est que vous n'avez peut être pas spécifié l'encoding lors de l'écriture de la chaine dans un Writer ou que vous avez fait un getBytes() sur la string sans spécifier l'encoding.

Si vous faites un getBytes sur une chaine, vous aurez un encoding égal à celui de la JVM, et ce n'est pas forcément ce que vous vouliez.

Voici un exemple de code pour le prouver. Créez un fichier avec ce contenu et executer le sur une machine dont l'encoding est UTF-16 :

public static void writeBytesToFile(String string,File file)throws IOException{
    FileOutputStream out = new FileOutputStream(file);
    System.out.println("will write '"+string+"' byte to "+file.getAbsolutePath());
    out.write(string.getBytes());
    out.flush();
    out.close();
}

writeBytesToFile("aé",File.createTempFile(EncodingProblemsRepairKit.class.getSimpleName(), ".txt"))

Vous remarquerez qu'on utilise un OutputStream et donc que nous sérialisons des octets, mais En ouvrant le fichier écrit, vous verrez le contenu suivant

FF FE 00 61 00 E9

Le BOM FE FF pour dire qu'il s'agit d'UTF-16 Big Endian. puis les lettre 'a' et 'é' en UTF-16

Si vous recommencez, mais que vous l'éxecutez sur une machine dont l'encoding est UTF-8, le fichier de sortie sera :

61 C3 A9

Pour être indépendant de l'encoding de la machine, n'utilisez pas getBytes() mais plutôt getBytes(String charset), Cela rendra votre code plus robuste.

Ecriture dans un fichier

Pour sérialiser dans un fichier il existe une classe FileWriter, mais Je déconseille fortement de l'utiliser. Explications :

public static void writeStringCharToFile(String string,File file)throws IOException{
    FileWriter out = new FileWriter(file);
    System.out.println("will write '"+string+"' char to "+file.getAbsolutePath());
    out.write(getStringAsChar(string));
    out.flush();
    out.close();
}

public static char[] getStringAsChar(String string){
      char[] charArray = new char[string.length()];
      string.getChars(0, string.length(),charArray , 0);
      return charArray;
  }


File tempFileChar = File.createTempFile(EncodingProblemsRepairKit.class.getSimpleName(), ".txt");
writeStringCharToFile("aé",tempFileChar);

En ouvrant le fichier vous constaterez que l'encoding à été choisi par la JVM selon l'environnement d'execution

Avec FileWriter, vous n'avez pas la possibilité de spécifier l'encoding. Alors on peut se poser la question "Puis-je sérialiser dans un fichier sans tenir compte de l'encoding de la machine ? ". La réponse se trouve dans la javadoc de FileWriter :

FILEWRITER : Convenience class for writing character files. The constructors of this class assume that the default character encoding and the default byte-buffer size are acceptable. To specify these values yourself, construct an OutputStreamWriter on a FileOutputStream. C'est pourquoi je déconseille de l'utiliser.

Il existe d'ailleurs une méthode qui renvoie l'encoding du fileWriter getEncoding(). Pour spécifier l'encoding, il faut écrire la chose suivante :

public static void writeStringCharToFile(String string,File file,String encoding)throws IOException{
    OutputStream stream = new FileOutputStream(file);
    OutputStreamWriter out = new OutputStreamWriter(stream,encoding);
    System.out.println("will write '"+string+"' char to "+file.getAbsolutePath()+" with encoding="+encoding);
    out.write(getStringAsChar(string));
    out.flush();
    out.close();
}

writeStringCharToFile("aé","UTF-8");

et le fichier contiendrait :

61 C3 A9.

CQFD! et ceci, peu importe l'encoding de la machine.

A propos de la méthode getEncoding(), voici une chose importante à savoir ;

If this instance was created with the OutputStreamWriter(OutputStream, String) constructor then the returned name, being unique for the encoding, may differ from the name passed to the constructor. This method may return null if the stream has been closed.

En d'autres termes, la méthode getEncoding() peut ne pas retourner la même valeur que celle spécifiée par le constructeur. Toujours bon à savoir !

Connaitre et modifier l'encoding de votre JVM

Au risque de me répéter, l'encoding par défaut dépend de l'environnement d'exécution, et par conséquent de la JVM. plusieurs propriétés affectent l'encoding et certaines sont dépendantes du vendeur de la JVM : pour Sun vous avez les options 'sun.jnu.encoding' et "file.encoding". Vous pouvez les modifier en les spécifiant en ligne de commande avec -Doption=valeur.

Mon but est de vous démontrer que même si file.encoding permet de spécifier l'encoding par défaut, ce n'est pas la bonne façon de le faire. Comme nous allons le voir cela dépend de la version et du vendeur de JVM, n'est pas modifiable au runtime, et n'est pas pris en compte par toutes les méthodes. Pour spécifier l'encoding par défaut, Sun recommande de modifier la locale système

A mon sens, la meilleure pratique est de ne pas utiliser l'encoding par défaut mais de le déclarer dans une variable, et de l'utiliser à chaque fois qu'un encoding par défaut doit être utilisé. Cela n'empêche pas d'utiliser quelque fois un encoding spécifique, quand ce dernier est déterminé au runtime (e.g : dans le header 'content-encoding' d'une requête HTTP). Cette technique à l'avantage d'avoir une gestion centralisée puisqu'il vous suffit de changer cette valeur (au runtime, ou à la compilation), et de ne pas être dépendant d'options de lancement ou du système d'exploitation sous-jacent, sur lequel vous n'avez pas toujours la main.

Commençons par voir comment modifier ces options au runtime :

public static void setJVMEncoding(String charset) {
        setSystemProperty("file.encoding", charset);
        setSystemProperty("sun.jnu.encoding", charset);
}

public static void setSystemProperty(String name, String value) {
        if (System.getProperty(name) == null || !System.getProperty(name).equals(value)) {
            System.out.println("change system property from " + System.getProperty(name) + " to " + value);
            System.setProperty(name, value);

            System.out.println("System property" + name + " is now : " + System.getProperty(name));
        } else {
            System.out.println(name + "=" + System.getProperty("file.encoding"));
        }
}

public static String UTF8_CHARSET="UTF-8";
setJVMEncoding(UTF8_CHARSET);

La variable qui permet de spécifier le charset est "file.encoding". Malheureusement, le charset utilisé par les Writer / Reader n'est pas celui renvoyé par Charset.getDefault(), changer @file.encoding@@ n'aura donc aucun effet. (le comportement serait différent avec la jvm 6.0, mais je n'ai pas vérifié). Démonstration :

public static void printCharsetandFileEncoding(){
    System.out.println("Default Charset for writer before change=" + getDefaultCharSet());
    System.out.println("Default Charset=" + Charset.defaultCharset());
    System.setProperty("file.encoding", "Latin-1");
    System.out.println("file.encoding=" + System.getProperty("file.encoding"));
    System.out.println("Default Charset=" + Charset.defaultCharset());
    System.out.println("Default Charset for writer after change=" + getDefaultCharSet());
}

private static String getDefaultCharSet() {
    OutputStreamWriter writer = new OutputStreamWriter(new ByteArrayOutputStream());
    String enc = writer.getEncoding();
    return enc;
}

L'éxecution de ce code vous donnera avec une JVM 5.0 et -Dfile.encoding=UTF-8 :

Default Charset for writer before change=UTF8
Default Charset=UTF-8
file.encoding=Latin-1
Default Charset=ISO-8859-1
Default Charset for writer after change=UTF8

En recommencant avec -Dfile.encoding=ISO-8859-1 :

Default Charset for writer before change=ISO8859_1
Default Charset=ISO-8859-1
file.encoding=Latin-1
Default Charset=ISO-8859-1
Default Charset for writer after change=ISO8859_1

L'encoding utilisé par les Writers utilise bien file.encoding, mais ne peux être changer au runtime. (en savoir plus sur les bugs de file.encoding).

file.encoding ne change rien non plus à l'encoding utilisé par défaut pour String.getBytes() si on le change au runtime : créons un fichier, encodé en UTF-8, avec ce code :

public static String stringToHexa(String string) {
    System.out.println("will get bytes for "+string+" without encoding");
    byte[] bytes =string.getBytes();
    String hexaStringSubstring="";
        for (int i =0; i< bytes.length; i++){
        byte x = bytes[i];
        int intValue = Byte.valueOf(x).intValue();
        String hexaString = intValue==0?"00":Integer.toHexString(intValue);
        hexaStringSubstring = hexaString.substring(hexaString.length()-2);
        System.out.println("Hexa is:=" + hexaStringSubstring);
        }
        return hexaStringSubstring;
}

 System.setProperty("file.encoding", "ISO-8859-1");
    stringToHexa(maString);
    System.setProperty("file.encoding", "UTF-16");
    stringToHexa(maString);

l'éxécution affichera :

will get bytes for aé without encoding
Hexa is:=61
Hexa is:=c3
Hexa is:=a9
will get bytes for aé without encoding
Hexa is:=61
Hexa is:=c3
Hexa is:=a9

La sortie prouve que l'encoding utilisé est UTF-8 : comme l'encoding du fichier source. En revanche si vous spécifiez l'option -Dfile.encoding=ISO-8859-1 au démarrage, sans changer l'encoding du fichier source (UTF-8) : la sortie sera différente :

will get bytes for aé without encoding
Hexa is:=61
Hexa is:=e9
will get bytes for aé without encoding
Hexa is:=61
Hexa is:=e9

file.encoding ne change rien lorsqu'il est changé au runtime mais est bien pris en compte au lancement de la JVM.

Si vous ne la spécifier pas, sa valeur sera déterminée par la locale du système d'exploitation, Changer la locale est la façon que Sun recommande pour changer l'encoding par défaut. depuis la jvm 1.4 de sun, vous pouvez spécifiez la locale avec les paramètres -Duser.language -Duser.country et -Duser.variant mais cela rend votre code dépendant de la version et du vendeur de la JVM. Pas top !

Voici un résumé :

  • file.encoding n'est pas la bonne façon de spécifier l'encoding par défaut, même si cela fonctionnera dans certains cas lorsqu'il est spécifié au lancement de la JVM.
  • La bonne façon de modifier l'encoding par défaut est de changer la locale.
  • Ne pas Utiliser filewriter dont l'exécution dépendra de la machine.
  • La meilleure pratique est de ne pas utilisé l'encoding par défaut, mais de le spécifier à chaque fois (Reader, Writer, getBytes, ...)
  • Changer file.encoding au runtime change la valeur de Charset.defaultCharset().
  • Changer file.encoding ne change rien à l'encoding par défaut réellement utilisé par les Writer / Reader
  • file.encoding est utilisé pour determiner l'encoding à utiliser par la méthode getBytes de la classe String quand aucun encoding est spécifié, mais ne sera pris en compte que s'il est spécifié au lancement avec l'option -D.

Tout ça peut vous paraitre un peu complexe, et je ne vous dirai pas le contraire, mais le tout, c'est de le savoir. Au fil du temps, cela deviendra un réflexe.

Spécifier l'encoding n'est pas tout, et pour éviter les UnsuportedEncodingException, vous pouvez vouloir connaitre les charsets supportés par votre JVM :

Charset.availableCharsets();

Cela correspond au charsets déclarés dans le package Charset.jar dans le dossier jre/lib/ de la JVM.

Pour en savoir plus, consultez la FAQ de Sun sur le sujet

Comprendre ce qu'est input method et inputContext

Si vous utilisez eclipse, lorsque vous cliquez droit dans un éditeur de texte, vous avez un menu qui se nomme 'Input Methods'. Plusieurs valeur sont disponibles : System, Simpl, cédille,Thai-Lao, vietnamine, etc. Les input Methods et inputcontext permettent à un utilisateur de pouvoir rentrer des milliers de caractères en utilisant que quelque touche du clavier. Une séquence de touches au clavier permet de saisir un caractère spécifique. Vous pouvez modifier l'inputContext utilisé grace à la methode selectInputMethod de la class InputContext. Ceci ne concerne que les caractères saisie par des éditeurs écrits en Java, et vous ne devez que très rarement y toucher.

affichage dans un logiciel (navigateur,éditeur,...)

La dernière cause, peut ne pas venir de votre code mais d'un mauvais affichage. Le plus simple pour diagnostiquer ce problème étant de visualiser le flux en Héxadécimal, ou de visualiser les trames (avec etherReal par exemple)

Pour ce qui est des polices d'affichage en java , vous devez éditer un fichier qui se nome font.properties qui permet d'associer les polices systèmes aux polices java ( en savoir plus ).

Pour ce qui est des navigateurs, ils choisissent l’encoding à utiliser selon des priorités :

  1. Utilisateur — Si l’utilisateur choisit via son navigateur un encodage spécifique à utiliser c’est ce dernier qui sera utilisé et aucun autre
  2. En-tête — En-tête envoyée par Apache. Si vous spécifiez une en-tête via Java/Tomcat/Jetty c’est cet encodage qui aura la priorité sur celui d’Apache
  3. Balise META — Si aucune en-tête n’a été envoyée par le serveur c’est l’encoding spécifié dans la balise META qui sera utilisé (ou dans le prologue XML si présent)

Voila! félicitation, vous êtes arrivés à la fin de l'article, j'espère que cela vous servira et que vos efforts seront récompensés :)

Le troisième et dernier post portera sur les différentes couches qui rentrent en compte lors de la requête /réponse d'une requête HTTP, et comment spécifier l'encoding Apache, Tomcat, ModJK, framework MVC, base de données, navigateurs).

La discussion continue ailleurs

URL de rétrolien : https://davidmasclet.gisgraphy.com/index.php?trackback/15

Fil des commentaires de ce billet