Guide pratique sur l’encodage à l’usage des développeurs JavaEE

Au cours de mes différentes missions, j’ai pu être confronté plusieurs fois à des problèmes d’encodage. À chaque fois (ou presque), le problème était situé entre la chaise et le clavier. Et une fois, il s’agissait d’IE7. Cette fois exceptée, des règles simples permettent de les éviter. Et en comprenant les erreurs fréquentes, on trouve plus facilement la solution. Ces erreurs trouvent leurs origines dans la manière dont sont encodés les caractères.

Deux approches

Coder le caractère

L’origine des encodages vient des Téléscripteurs (1866 !), dont le but était de transmettre des informations via des signaux électriques. Il a donc fallu un code. Un des premiers standardisés est le Code Baudot, suivi de très près par le code ASCII. Pas de chance pour les non-anglophones, il ne permet pas de coder les caractères accentués. L’ASCII est composé de 128 caractères, dont 33 de contrôles. Ces caractères de contrôles sont très fortement liés à la technologie de l’époque : mon petit préféré ? le caractère BEL. Si vous avez déjà tapé à la machine à écrire, il doit vous être familier, c’est annonciateur de fin de ligne :)

Mais pourquoi seulement 128 caractères, sachant que c’est 2^7 ? Il aurait été plus intelligent de faire un octet complet ! Sauf qu’à l’origine, le dernier bit de l’octet est utilisé en bit de parité, pour détecter les erreurs de transmissions. Mais les non-anglophones finissent par avoir raison de celui-ci et développe rapidement des encodings qui incluent les caractères de la localisation (la devise, les accents, etc.). Sauf qu’avec 8 bits, les possibilités de représentation sont limitées. Ainsi, il existe un ISO-8859 par alphabet (Grec, Cyrillique, Latin, …). Bien sur, ils ne sont pas compatibles entre eux, puisque tous les caractères dont le code est supérieur à 128 sont des caractères locaux. Les échanges de mails sont rapidement devenus une plaie.

Sans compter qu’on ne peut pas représenter sur 256 valeurs l’intégralité des logogrammes présents dans les langues orientales notamment. C’est pourquoi des gens ont commencé à travailler sur Unicode.

Coder la représentation d’un caractère…

Unicode est une couche d’abstraction entre le caractère et son encodage. Pour bien comprendre, il faut savoir qu’un caractère Unicode porte un nom. La lettre “a” s’appelle “LATIN SMALL LETTER A”. La lettre “À” s’appelle “LATIN CAPITAL LETTER A WITH ACUTE”, etc. Ce nom est normé, et ne dépend absolument pas de la forme de la lettre, de la police, ou de sa représentation. On peut penser en caractère d’imprimerie. Par exemple, si vous avez déjà utilisé LaTeX (ou un éditeur de PAO type inDesign), vous avez sûrement noté les ligatures sur ff et fi : il existe un caractère Unicode (et bien un seul caractère, essayez de le sélectionner : et ) pour chacune d’entre-elle.

Chaque caractère Unicode est ensuite numéroté : “LATIN SMALL LETTER A” est U+0061. Ce numéro est un “code-point”. Le U+ définit qu’il s’agit d’Unicode, les chiffres qui suivent sont en hexadécimal. Il existe sous les différents OS un utilitaire pour trouver les valeurs unicode de chaque caractère, et une multitude de sites internet.
Une chaîne Unicode n’est rien d’autre qu’une suite de codes-points Unicode. UTF-8, UTF-16, UCS-2, … sont des encodages. Ils servent à coder les codes-points.

Intéressons-nous au célèbre UTF-8. C’est un format dont le but initial était de faciliter la migration à Unicode de documents ASCII vers UTF-8. L’astuce se situe dans l’attribution des mêmes octets entre l’UTF-8 et l’ASCII : Un “A” encodé en ASCII s’écrit 0x61, et en UTF-8, 0x61 (mais en Unicode, le “A” s’écrira U+0061 ou “LATIN SMALL LETTER A”, pas autrement !)
Jusqu’ici, tout est simple, sauf que Unicode définit dans la dernière version 110 182 codes-points. Nous sommes donc très loin des 256 valeurs possibles dans un octet. Voyons comment UTF-8 s’en sort.

… et encoder un code-point

Dans le tableau ci-dessous, on peut voir la manière dont sont stockées les chaînes Unicode en UTF-8. (NB : le tableau complet est disponible sur wikipedia)

Bits Last code point Byte 1 Byte 2 Byte 3
  7 U+007F 0xxxxxxx
11 U+07FF 110xxxxx 10xxxxxx
16 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx

source et version complète

Pour encoder le caractère “ũ” U+0169 en UTF-8,

  • D’après le tableau, le code-point (0x169) est compris entre 0x007F et 0x07FF, il faudra donc 2 octets pour l’encoder.
  • 0x169 est égal en binaire à 101101001b.
  • Les bits du code-point servent à remplacer les x du tableau : 110xxxxx 10xxxxxx devient : 1100101 10101001

Une erreur courante est de confondre Unicode et UTF-8. UTF-8 est un encodage permettant de représenter Unicode, pas Unicode.

Et dans la pratique ?

Vers de l’Unicode

Prenons la chaîne suivante “à”. Il est impossible de l’encoder en ASCII, puisque “à” ne fait pas partie de cet encodage. En revanche, en ISO8859-1, c’est tout à fait possible :

1
2
byte[] bytes = new String("à").getBytes("iso8859-1");
for (byte aByte : bytes)  System.out.format("%X",aByte);

On obtient : 0xE0, soit 11100000, ou 224 en décimal. Qu’en est-il de l’UTF-8 ? On obtient 0xC3 0xA0. Ainsi, UTF-8 utilise bien 2 octets pour coder un caractère, là où ISO8859-1 n’en utilisait qu’un.

Simulons une erreur courante : un caractère encodé en ISO-8859-1 est lu en tant que chaîne UTF-8.

1
2
3
byte[] bytes = new byte[]{(byte) 0xE0};
String test = new String(bytes, "utf-8");
System.out.println(test);

Un caractère qui doit vous être familier s’affiche : � (soit : ‘REPLACEMENT CHARACTER’ ou U+FFFD). Ce caractère sert à représenter un caractère non représentable dans l’encodage choisi (UTF-8 ou UTF-16). On a donc connaissance des erreurs de décodage. On peut donc “sans risque” décoder un flux ISO-8859 en UTF-8 : UTF-8 saura repérer certains caractères en erreur. En revanche, le remplacement se fera avec le même caractère pour toutes les erreurs : il y a perte d’information (mais elle est connue !)

Depuis l’Unicode

Toujours avec notre chaîne “à”, il est possible de l’encoder via Unicode en UTF-8. On obtient alors les 2 octets 0xC3 et 0xA0. Simulons une autre erreur courante : le caractère encodé en UTF-8 et lu en tant que chaîne ISO-8859-1 :

1
2
3
byte[] bytes = new byte[]{(byte) 0xC3, (byte) 0xA0};
String test = new String(bytes, "iso8859-1");
System.out.println(test);

Cette fois, on obtient : “Ã ” (soit, en Unicode : “LATIN CAPITAL LETTER A WITH TILDE (U+00C3)” “NO-BREAK SPACE (U+00A0)”). UTF-8 utilisant 2 octets, il est normal que ISO-8859-1 voit 2 caractères. Cependant ici, aucun moyen de détecter une erreur, et pourtant, celle-ci doit aussi vous être familière ! Et le problème ici est qu’il n’y a aucun moyen de détecter l’erreur ! Pas de perte d’information, juste une mauvaise interprétation, sans remplacement.

S’il vous est déjà arrivé de manipuler des fichiers ouverts par des outils mal configurés (ISO-8859-1 vs UTF-8), vous avez surement remarqué qu’un fichier ISO-8859-1 ouvert en UTF-8 et écrasé n’est plus récupérable (du fait du caractère de remplacement U+FFFD). En revanche, ouvrir un fichier UTF-8 en ISO-8859-1, l’éditer avec des caractères ASCII et l’enregistrer ne l’affecte pas. Voici l’explication !

Et dans la vie de tous les jours ?

Un flux d’octet n’a aucun sens sans Charset

Décoder un flux d’octet est une opération courante, à chaque requête, l’implémentation de HttpServletRequest présente dans le conteneur web décode les informations associées à la requête : ici dans tomcat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* Parse request parameters.
*/

    protected void parseRequestParameters() {

        requestParametersParsed = true;

        Parameters parameters = coyoteRequest.getParameters();

        String enc = coyoteRequest.getCharacterEncoding();
        boolean useBodyEncodingForURI = connector.getUseBodyEncodingForURI();
        if (enc != null) {
            parameters.setEncoding(enc);
            if (useBodyEncodingForURI) {
                parameters.setQueryStringEncoding(enc);
            }
        } else {
            parameters.setEncoding
                (org.apache.coyote.Constants.DEFAULT_CHARACTER_ENCODING);
            if (useBodyEncodingForURI) {
                parameters.setQueryStringEncoding
                    (org.apache.coyote.Constants.DEFAULT_CHARACTER_ENCODING);
            }
        }

        parameters.handleQueryParameters();

        if (usingInputStream || usingReader)
            return;

        if (!getMethod().equalsIgnoreCase("POST"))
            return;

        String contentType = getContentType();
        if (contentType == null)
            contentType = "";
        int semicolon = contentType.indexOf(';');
        if (semicolon >= 0) {
            contentType = contentType.substring(0, semicolon).trim();
        } else {
            contentType = contentType.trim();
        }
        if (!("application/x-www-form-urlencoded".equals(contentType)))
            return;

        int len = getContentLength();

        if (len > 0) {
            try {
                byte[] formData = null;
                if (len < CACHED_POST_LEN) {
                    if (postData == null)
                        postData = new byte[CACHED_POST_LEN];
                    formData = postData;
                } else {
                    formData = new byte[len];
                }
                int actualLen = readPostBody(formData, len);
                if (actualLen == len) {
                    parameters.processParameters(formData, 0, len);
                }
            } catch (Throwable t) {
                ; // Ignore
            }
        }

    }

Voyons comment le régler correctement dans une solution Spring MVC déployé dans un tomcat.

Client

Le client (ici le navigateur WEB) reçoit une réponse HTTP depuis un serveur (peu importe la requête d’origine). Il existe plusieurs moyens de jouer sur l’encoding.
La RFC 2616 (HTTP/1.1) est claire de ce point de vue : le Header HTTP Content-Type est dédié à cet effet.
En Spring MVC, il est facile de mettre ce header dans toutes les pages générées, directement dans le ViewResolver :

1
2
3
<bean id="freemarkerViewResolver" class="org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver">
    <property name="contentType" value="text/html; charset=UTF-8" />
</bean>

Il est bien entendu possible de surcharger cette valeur directement dans la View. En JSP, il suffit de mettre dans la page :

1
<%@page contentType="text/html; charset=UTF-8" %>

On peut aussi mettre ce Header directement dans Apache/nginx. Il faudra aussi penser à régler les outils pour générer de l’UTF-8 (dans Freemarker, la propriété url_escaping_charset et output_encoding). Malheureusement, il se peut que nous n’ayons pas accès à cette possibilité. Que faire alors ?
Dans la page HTML, il est possible de définir un meta dans l’élément head :

1
2
3
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">

Quelle bizarrerie ! Commencer à lire un flux sans définir son encoding et attendre quelques octets avant de le connaître… Heureusement que la plupart des encodings utilisent les mêmes octets que dans l’ASCII.

Ne pas suivre ces recommandations vous expose à toutes sortes de choses incluant des incendies, des pluies de bananes, des tas de m**** – oui, il y a un caractère pour tout – et de la p****graphie (SFW) ! Plus sérieusement, les navigateurs essayeront de deviner à partir d’analyses statistiques le couple langue/encodage le plus probable. Chose qui fonctionne bien, jusqu’au jour où l’utilisateur écrit un texte qui sort du profil statistique, ce qui est fréquent, en particulier s’il poste du code.

Une fois l’encodage détecté (avec succès ou pas), le navigateur enverra éventuellement des données au serveur (via des requêtes GET, POST ou PUT). L’encoding utilisé par le navigateur pour envoyer le flux au serveur est celui de la page. D’où l’importance de maitriser celui-ci !

Serveur

Ainsi, nous savons que le navigateur nous poste le flux en, au hasard, UTF-8. Parfait. La RFC 2616 définit dans le paragraphe 3.7.1 que par défaut, si aucun encoding n’est spécifié, il faut lire les données en ISO-8859-1.

De fait, dans la spécification de JSP, le point SRV.3.9 définit que l’encoding utilisé par défaut pour le décodage des paramètres (GET et POST) est ISO 8859-1 (Plus d’informations ici sur le pourquoi du comment)

Cependant, lorsque les pages sont affichées en UTF-8, le navigateur enverra les données en UTF-8. Pour forcer le conteneur de servlet à lire les paramètres en UTF-8, une solution à base de Filter est disponible dans Spring : CharacterEncodingFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<filter>
    <filter-name>CharacterEncodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
    <init-param>
        <param-name>forceEncoding</param-name>
        <param-value>true</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>CharacterEncodingFilter</filter-name>
    <servlet-name>webapp</servlet-name>
</filter-mapping>

Ainsi, à chaque requête, le filtre s’applique et force (ou pas) l’encodage pour la lecture des paramètres. Lorsque le paramètre forceEncoding est vrai, le filtre va aussi forcer l’encoding sur la réponse.
Attention, si vous avez bien lu le code de CoyoteRequest, à la première ligne de parseRequestParameters(), il y a un : requestParametersParsed = true. Je vous le donne en mille, l’interprétation des paramètres n’est faite qu’une seule fois. Le filtre qui force le Charset doit donc être appliqué avant la première lecture de paramètre !

Enfin, une dernière chose peut-être faite afin d’autoriser les URI en UTF-8 (comme wikipedia) : ajouter URIEncoding=”UTF-8″ sur l’élément <Connector> dans le server.xml de tomcat.

Une fois toutes ces manipulations effectuées, vous pouvez presque dire adieu aux problèmes d’encoding. Pourquoi presque ? Parce qu’il faut être sur que tous les flux que vous générez sont bien dans l’encoding que vous avez choisi. Attention tout particulièrement aux MessagesBundle dans Spring. La spécification des fichiers de properties indique qu’ils doivent être en ISO-8859-1. Et Eclipse suit cette norme. La solution pour stocker des caractères au delà des 256 possibles dans ISO-8859-1 ? Les mettre en UTF-8 et prier le dieu Unicode pour que personne n’écrase le fichier avec une version ISO-8859-1, ou alors utiliser native2ascii (en standard dans le JDK) pour transformer ses chaînes avant de les enregistrer dans le fichier (un plugin Eclipse existe pour le faire automatiquement ;)). Vous voilà prévenus !

Pour aller plus loin

Je ne saurai que trop vous conseiller la lecture des articles suivants :

Merci à Nicolas Maupu pour la photo ;)

Contrat Creative Commons Article écrit sous Creative Commons 3.0 BY-SA.

VN:F [1.9.22_1171]
Rating: +5 (from 5 votes)
Share
Ce contenu a été publié dans Java, Trucs & astuces, avec comme mot(s)-clef(s) , , , , . Vous pouvez le mettre en favoris avec ce permalien.

Une réponse à Guide pratique sur l’encodage à l’usage des développeurs JavaEE

  1. Citons également cet excellent livre qui traite du sujet :)

    VN:R_U [1.9.22_1171]
    Rating: +2 (from 2 votes)

Laisser un commentaire