I18n - Internacionalizace

Z FI WIKI
Přejít na: navigace, hledání


Webové aplikace zpřístupněné na Internetu by měly počítat s tím, že jejich uživatelé mluví různými jazyky, používají různé abecedy a obrázková písma, píšou různými směry tj. zleva doprava nebo naopak, jsou zvyklí zapisovat časové údaje, čísla a množství peněz podle svých národních zvyklostí, a mohou se nacházet v libovolném časovém pásmu. Různé abecedy a národní zvyklosti způsobují i různá pravidla pro seřazování textů podle abecedy či převádění malých písmen na velká a naopak.

Představte si například server s webovou aplikací běžící v Brně, ke kterému přistupuje občan USA mluvící anglicky, jenž je v té chvíli na služební cestě v Japonsku. Takový uživatel očekává, že časové údaje (čas kdy došlo k nějaké události, např. příchodu emailu) budou zobrazeny v jeho místním (tj. japonském) čase, nikoliv ve středoevropském čase, který se liší o mnoho hodin. Bude očekávat, že čísla používají desetinou tečku a řády tisíců jsou odděleny čárkou (1,000,000.5), což je odlišné od české konvence, kdy čísla používají desetinou čárku a řády tisíců jsou odděleny mezerou (1 000 000,5). Označení měny bude očekávat před číslem ($100), nikoliv za číslem (100 Kč). Bude užitečné, když zobrazené texty budou v angličtině, nikoliv v češtině. A pokud si bude chtít třeba poznamenat nějaký název místní japonské pamětihodnosti, může chtít zadat text v obrázkovém písmu.

Příprava aplikace na podporu různých kulturních zvyklostí se nazýva internacionalizace, používá se zkratka i18n, protože anglické slovo internationalization má mezi "i" a "n" 18 písmen. Úprava internacionalizované aplikace pro konkrétní jazyk se pak nazývá lokalizace, spočívá především v přeložení textů do konkrétního jazyka.

Java má pro tyto účely velice propracované nástroje. Zobrazování čísel a časových údajů řeší třídy NumberFormat a DateFormat. Zobrazení časových údajů ve správné časové zóně řeší třída TimeZone. A nalézání textů ve správném jazyce řeší třída ResourceBundle.

Locale

Všechny tyto třídy používají třídu Locale pro specifikaci jazyka či národních zvyklostí. Pozor, od Javy 7 došlo v této třídě ke změnám. Zatímco do verze 6 byl správný postup pro vytvoření českého Locale

 
 Locale cestina = new Locale("cs", "CZ");

od verze 7 je doporučovaný způsob

 
 Locale cestina = Locale.forLanguageTag("cs-CZ");

Pro češtinu nejde o podstatnou změnu, změna má význam zejména u jazyků, které používají více abeced, např. uzbečtina nebo čínština. Detaily viz Java Locale Enhancement.

Čísla a měna

Ukázka rozdílu zobrazování ceny v ČR a USA:

Locale cestina = new Locale("cs", "CZ");
Locale usa = Locale.US;
 
BigDecimal rcislo = new BigDecimal("1234567.1234");
 
NumberFormat csFormat = NumberFormat.getCurrencyInstance(cestina);
NumberFormat usaFormat = NumberFormat.getCurrencyInstance(usa);
 
System.out.println(cestina.toString()+"     " + csFormat.format(rcislo));
System.out.println(usa.toString()+"     " + usaFormat.format(rcislo));

výsledek:

cs_CZ    1 234 567,12 Kč
en_US    $1,234,567.12

Ovšem pozor, stejný počet korun a dolarů má rozdílnou hodnotu. Proto je vhodné formátu explicitně nastavit, kterou měnu má zobrazovat:

Currency usd = Currency.getInstance("USD");
 
csFormat.setCurrency(usd);
usaFormat.setCurrency(usd);

výstup je pak lokalizovaný, ale jedná se v obou případech o stejnou měnu:

cs_CZ     1 234 567,12 USD
en_US     $1,234,567.12

Pro cenu je vhodné používat typ BigDecimal, naopak nevhodné je používat float nebo double, protože u nich kvůli zaokrouhlovacím chybám může docházet ke ztrátám haléřů, centů a podobně.

Časové údaje

Malá ukázka jak správně zobrazovat časové údaje:

 
    Locale cestina = new Locale("cs", "CZ");
    Locale usa = Locale.US;
    Locale newZealand = new Locale("en", "NZ");
    Locale saudskaArabie = new Locale("ar", "SA");
 
    showLocalTime(cestina, TimeZone.getTimeZone("Europe/Prague"));
    showLocalTime(usa, TimeZone.getTimeZone("America/Atka"));
    showLocalTime(saudskaArabie, TimeZone.getTimeZone("Asia/Riyadh89"));
    showLocalTime(newZealand, TimeZone.getTimeZone("Pacific/Auckland"));
 
//...
 
public static void showLocalTime(Locale locale, TimeZone tz) {
        Date now = new Date();
        String zoneName = tz.getDisplayName(tz.inDaylightTime(now),TimeZone.LONG,locale);
        DateFormat full = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL, locale);
        full.setTimeZone(tz);
        System.out.println(locale + ": " + full.format(now)+ " ("+zoneName+")");
    }

vypíše tentýž časový údaj podle místních zvyklostí u nás, ve městě Atka (Aljaška, USA), v Rijádu v Saudské Arábii a v Aucklandu na Novém Zélandu. Všimněte si, že na Novém Zélandu mají v březnu letní čas (je to jižní polokoule) a mají jiné datum (leží těsně na západ od datové hranice), v Rijádu je zase časový posun i o minuty, nejen celé hodiny:

cs_CZ: Středa, 9. březen 2011 13:00:00 CET (Central European Time)
en_US: Wednesday, March 9, 2011 2:00:00 AM HAST (Hawaii-Aleutian Standard Time)
ar_SA: 09 مارس, 2011 GMT+03:07 03:07:04 م (GMT+03:07)
en_NZ: Thursday, 10 March 2011 1:00:00 AM NZDT (New Zealand Daylight Time)

Třída Date představuje časový okamžik jako takový, vyjádřený v milisekundách od začátku unixové epochy (půlnoc 1.1.1970 GMT). Interně používá typ long, takže může reprezentovat čas zhruba v úseku 584 milionů let (konkrétně od 2. 12. 292 269 055 př.n.l. do 17. 8. 292 278 994).

Pokud potřebujeme pracovat s časem vyjádřeným lidským způsobem (tedy způsobem závislým na určité kultuře), použijeme třídu Calendar resp. jejího potomka GregorianCalendar, ta umí i operace s posunem času, např:

GregorianCalendar c = new GregorianCalendar(TimeZone.getTimeZone("CET"), cestina);
c.set(2012, Calendar.JANUARY, 31, 12, 0, 0);
GregorianCalendar c2 = (GregorianCalendar) c.clone();
System.out.println(csfull.format(c.getTime()));
c.add(Calendar.MONTH, 1);
System.out.println(csfull.format(c.getTime()));

nastaví poledne 31.1.2012 a pak zjistí, který okamžik je o měsíc později:

Úterý, 31. leden 2012 12:00:00 CET
Středa, 29. únor 2012 12:00:00 CET

Řazení podle abecedy

Abecedy se liší množinami znaků, a pokud se překrývají, i jejich pořadím. Proto je vhodné používat třídu Collator pro porovnávání řetězců:

String[] pole = new String[]{
        "Cecilka",
        "Chranislava",
        "Ilona",
        "Dana",
        "Anička"
};
 
Locale cestina = new Locale("cs", "CZ");
Locale usa = Locale.US;
 
final Collator csCollator = Collator.getInstance(cestina);
final Collator usaCollator = Collator.getInstance(usa);
 
Arrays.sort(pole,csCollator);
System.out.println("cs : "+Arrays.asList(pole));
Arrays.sort(pole,usaCollator);
System.out.println("usa: "+Arrays.asList(pole));

výsledek:

cs : [Anička, Cecilka, Dana, Chranislava, Ilona]
usa: [Anička, Cecilka, Chranislava, Dana, Ilona]

Nicméně standardní Collator neřadí zcela podle české normy, ignoruje mezery a velká písmena umisťuje za malá, což má být naopak. Podrobnosti jsou popsány v článku České řazení na serveru java.cz.

ResourceBundle texty

Texty by neměly být v aplikaci nikdy přímo v kódu, ať už Java kódu nebo uvnitř JSP. Správný postup je používat třídu ResourceBundle V Java kódu pak odkazujeme na texty jen pomocí klíčů, a konkrétní text se dohledává podle specifikovaného Locale. Ukázka:

ResourceBundle csTexty = ResourceBundle.getBundle("Texty",cestina);
System.out.println(csTexty.getString("ted_je_prave"));

Metoda getBundle() se pokouší najít pro dané Locale soubor, jehož jméno je tvořeno zadaným řetězcem, určením locale a příponou .properties, a pokud jej nenalezne, zkouší hledat soubor s obecnějším locale. V tomto případě tedy zkouší hledat soubory

  • Texty_cs_CZ.properties
  • Texty_cs.properties
  • Texty.properties

Podrobnosti viz javadoc ke třídě ResourceBundle. Soubor s texty má mít formát určený třídou java.util.Properties, tedy řetězcové klíče následované rovnítkem a řetězcovou hodnotou, kde ne-ASCII znaky jsou zapsány pomocí \uXXXX konvence (hexadecimálně číslo UNICODE znaku), např:

#Texty_cs_CZ.properties
ted_je_prave=Te\u010f je pr\u00e1v\u011b
#Texty_en_US.properties
ted_je_prave=It is now 

Pro převod českých znaků na \uXXXX tvar můžeme použít nástroj native2ascii, nebo vývojová prostředí mohou provést konverzi automaticky. Alternativně můžeme použít XML formát, což umožní použít přímo např. utf-8.

Parametry a skloňování

Do textů je možné vkládat hodnoty, a dokonce skloňovat podle čísel. Například potřebujeme zobrazit celá i reálná čísla a skloňovat:

 
Locale kultura = new Locale("cs", "CZ");
String sablonaTextu = "Blok  {0,number,integer}  {1,choice,0#nemá turbíny|1#má 1 turbínu|2#má {1} turbíny|4<má {1} turbín } a je na výkonu  {2,number,#,##0.0##}  MW";
MessageFormat veta = new MessageFormat(sablonaTextu,kultura);
for (int i = 0; i < 10; i++) {
    System.out.println(veta.format(new Object[]{998+i, i, 12345.6789d}));
}

produkuje správně skloňované texty:

Blok  998  nemá turbíny a je na výkonu  12 345,679  MW
Blok  999  má 1 turbínu a je na výkonu  12 345,679  MW
Blok  1 000  má 2 turbíny a je na výkonu  12 345,679  MW
Blok  1 001  má 3 turbíny a je na výkonu  12 345,679  MW
Blok  1 002  má 4 turbíny a je na výkonu  12 345,679  MW
Blok  1 003  má 5 turbín  a je na výkonu  12 345,679  MW
Blok  1 004  má 6 turbín  a je na výkonu  12 345,679  MW
Blok  1 005  má 7 turbín  a je na výkonu  12 345,679  MW
Blok  1 006  má 8 turbín  a je na výkonu  12 345,679  MW
Blok  1 007  má 9 turbín  a je na výkonu  12 345,679  MW

Java a UNICODE

Java používá vnitřně pro zápis znaků sadu UNICODE. Aby to nebylo jednoduché, UNICODE se postupně vyvíjí. V době vzniku Javy obsahovala sada UNICODE méně než 65536 znaků, proto bylo možné každý znak zapsat do dvou bajtů pořadovým číslem znaku v rámci sady UNICODE, a proto má Javový typ char rozsah dvou bajtů. Bohužel dnešní verze UNICODE 5.0 obsahuje znaků mnohem více. Proto bylo stanoveno, že dnes typ char, a potažmo String jakožto obal pole znaků, vyjadřuje znaky v kódování UTF-16, tedy některé znaky je nutné vyjádřit pomocí dvojic charů, tzv. surrogate pairs. Naštěstí všechny znaky, kterých se to týká, pocházejí z dnes již mrtvých jazyků, a proto je pravděpodobnost jejich použití velmi malá.

UNICODE je abstraktní sada znaků, seřazených a očíslovaných od 0 do potenciálně 1114112 (hexadecimálně 0x110000). V současnosti (UNICODE 5.0) je přiřazeno 101063 znaků. Pokud chceme znak v UNICODE zapsat, musíme si zvolit kódování (encoding). Pozor, pro zmatení je v MIME hlavičce Content-type kódování označeno parametrem s názvem charset, což je nesmysl, ale historicky vžitý. Na výběr máme tato kódování

UTF-32 4 bajty na každý znak
UTF-16 2 bajty na většinu znaků, občas 2 páry bajtů
UTF-8 1 až 6 bajtů na znak
8bitové (iso-8859-2, windows-1250, ...) vždy 1 bajt na znak, ale většinu znaků nelze vyjádřit

Zápis znaků při styku s vnějším světem

Uvnitř Javy není nutné kódování znaků jakkoliv řešit, char je znak a String je obal na char[]. Ale při styku s vnějším světem je nutné znaky převést na bajty a naopak. To je nutné typicky na těchto místech:

  • čtení a zápis souborů
  • komunikace přes síť (sockety)

je nutné specifikovat kódování, když obalujeme InputStream (resp. OutputStream) Readerem (resp. Writerem)

BufferedReader in = new BufferedReader(
                     new InputStreamReader(
                      new FileInputStream("soubor_win.txt"),
                      "windows-1250"
                     )
                    );
PrintWriter out = new PrintWriter(
                   new BufferedWriter(
                    new OutputStreamWriter(
                     socket.getOutputStream(),
                     "utf-8"
                    )
                   )
                  );
  • práce s databází

je odpovědností JDBC ovladače správně znaky zapsat, není tedy nutné nic dělat, maximálně nastavit nějakou property JDBC driveru

  • generování webových stránek
je nutné zajistit zavolání
response.setContentType("text/html; charset=utf-8");
  • přijímání dat z webového prohlížeče
je nutné zajistit zavolání
request.setCharacterEncoding("utf-8");(

I18n v Servletech

Webové prohlížeče hlásí, kterým jazykům uživatel rozumí, v sestupném pořadí, v HTTP hlavičce Accept-Language. V Servlet API lze získat buď první preferovaný jazyk voláním

Locale l = request.getLocale()

nebo všechny preferované jazyky voláním

Enumeration<Locale> en = request.getLocales()

Pozor, čeština je hlášena jako locale cs, tedy ne cs_CZ, proto ResourceBundle soubory s názvy končícími na _cs_CZ.properties nebudou nalezeny !

I18n v JSTL

Při použití ve webové aplikaci můžeme s výhodou použít Formatting knihovnu JSTL. Pro zobrazení času (uloženého v proměnné ted) a doprovodného textu uloženého pod klíčem ted_je_prave můžeme použít:

<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="f" uri="http://java.sun.com/jsp/jstl/fmt" %>
<f:setBundle basename="Texty" />
<f:message key="ted_je_prave" />: <f:formatDate value="${ted}" type="both" dateStyle="full" timeStyle="full" timeZone="${zona}" />

Zatímco locale (požadovaný jazyk) hlásí webový prohlížeč v HTTP hlavičce Accept-language, a tudíž ho nemusíme nastavovat, protože značky z Formatting JSTL knihovny si ho zjistí samy, požadovanou časovou zónu prohlížeč nehlásí. Ale můžeme uživatele nechat časovou zónu vybrat, seznam dostupných zón vrací metoda TimeZone.getAvailableIDs().

České znaky v JSP stránkách

Jak už bylo vysvětleno v hesle servlety, pro správnou funkci webové aplikace je nejlepší používat důsledně kódování znaků UTF-8 na vstupu i výstupu. Častou chybou je, že vývojáři mají české texty přímo v JSP stránce, a spletou si výstupní kódování stránky s kódováním zdroje JSP stránky. Pokud máme JSP stránku uvozenou:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="iso-8859-2"%>

pak generovaná HTML stránka bude používat kódování UTF-8, ale zdroj JSP stránky je v kódování iso-8859-2. Uvědomme si, že JSP stránka je na pozadí převedena na servlet, tedy Java třídu, a ta je zkompilována. Veškeré texty tak budou převedeny na řetězce v Java zdrojovém kódu, a kompilátor potřebuje vědět, jaké vstupní kódování má použít.

Pro vstup znaků z HTML formulářů je nejlepší použít jednoduchý filter:

package cz.moje;
import java.io.IOException;
import javax.servlet.*;
 
public class SetCharacterEncodingFilter implements Filter {
 
    public void init(FilterConfig filterConfig) throws ServletException { }
 
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (request.getCharacterEncoding() == null) {
            request.setCharacterEncoding("utf-8");
        }
        chain.doFilter(request, response);
    }
}

a namapovat jej na všechny příchozí requesty (editováním souboru web.xml):

<filter>
    <filter-name>kodovani</filter-name>
    <filter-class>cz.moje.SetCharacterEncodingFilter</filter-class>
</filter>
<filter-mapping>
    <filter-name>kodovani</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Tím je o vstupní znaky postaráno.