Tłumaczenie aplikacji cz. 3 – lokalizacja – jak to ogarnąć?

Tłumaczenie aplikacji cz. 3 – lokalizacja – jak to ogarnąć?

Wstęp

Mam nadzieję, że przeczytałeś dwa poprzednie artykuły z serii o globalizacji i przygotowaniu do lokalizacji. Jeśli nie lub jeśli nie zastosowałeś się do nich, to uciekaj szybciutko z tego artykułu i zrób to wszystko, o co prosiłem. Do poczytania część pierwsza, która mówi czym w ogóle jest internacjonalizacja i jak do tego podejść; no i część druga, która przygotowuje do TEJ części artykułu.

Lokalizacja

Proces tłumaczenia aplikacji nazywa się lokalizacją. W dużym skrócie polega na:

  • tłumaczeniu
  • wybraniu sposobu przechowywania i ładowania tłumaczeń – na tym punkcie skupimy się dzisiaj

Pamiętaj, że słowo „OK” może brzmieć po węgiersku jak „Haszaragódgad”. Więc jeśli na sztywno ustawiasz szerokości przycisków, będziesz mieć problem 🙂

Jak ogarnąć tłumaczenie?

Jeśli chodzi o tłumaczenie aplikacji, mamy kilka możliwości:

  • oddać stringi do tłumacza, który zwróci je przetłumaczone na konkretny język
  • użyć automatycznego translatora (np. Google Translate). Nie jest to idealne rozwiązanie, bo jest duża szansa na to, że aplikacja zostanie przetłumaczona w dziwny sposób, np: „Czy chcesz uratować plik? Wciśnij ok, aby plik został uratowany„. Z moich testów wynika, że Google Translator nie do końca radzi sobie z tłumaczeniem z języka polskiego, natomiast całkiem nieźle mu idą tłumaczenia z języka angielskiego. Więc jeśli masz całe frazy, możesz wrzucić je do googla i tłumaczyć z angielskiego na docelowy język (np. na niemiecki).

Do głowy przychodzi mi jeszcze jedna sztuczka – tłumaczenie „ad hoc” – tzn. na gorąco wysyłamy stringi do google translatora. Oczywiście ma to tylko minusy (łącznie z opłatami), więc nie robimy tego.

Generalnie odradzam używanie automatycznych translatorów. Jeśli widzę tak przetłumaczoną aplikację, od razu ustawiam język na angielski, bo w tak tłumaczonym polskim nie idzie się połapać. Tak samo jest, gdy czytam dokumentacje Microsoftu – po polsku po prostu nie idzie nic zrozumieć. Polski jest jednak jednym z najtrudniejszych języków świata, więc może tu leży pies pogrzebany…

Moja rada – tłumacz tylko na język, który znasz lub możesz się z kimś skonsultować.

Niezależnie od tego w jaki sposób otrzymasz tłumaczenia, powinieneś dać aplikację do przetestowania komuś, kto zna dany język. W tym momencie może wyjść kilka kwiatków językowych związanych chociażby z odmianą przez przypadki.

Przechowywanie tłumaczeń

Teraz zastanówmy się w jaki sposób przechowywać teksty. Rzecz jasna, nie możesz ich mieć bezpośrednio w kodzie. One muszą być skądś pobierane. Mamy tutaj znowu kilka możliwości:

  • teksty są w bazie danych (spotykałem się z takimi rozwiązaniami głównie w przeszłości i głównie w aplikacjach internetowych); jeśli mamy aplikację webową, a treści są dynamiczne i wielojęzyczne, to właściwie jest to jedyna opcja. Ale w artykule skupiam się bardziej na tekstach statycznych.
  • teksty są w pliku lokalizacyjnym
  • teksty są w zasobach

Pliki lokalizacyjne

Mówimy tutaj o plikach tekstowych. Mogą być otwierane i zmieniane przez dowolną osobę w dowolnym edytorze. Zazwyczaj składają się z klucza i wartości, np.:

NO_FILE = "Nie znaleziono pliku"
SAVE_FILE_PROMPT = "Czy chcesz zapisać plik?"

Dobrym rozwiązaniem tutaj jest posiadanie osobnych plików dla każdego języka. Pchanie wszystkich języków do jednego pliku robi po prostu burdel i utrudnia pracę tłumaczom, a także Tobie (potem trzeba to mergować).
Takie pliki mogą posiadać dodatkowo różne sekcje, przez co można je traktować jako pliki INI.

Plusy takiego rozwiązania:

  • każdy może dołożyć nowy język
  • każdy może przetłumaczyć aplikację
  • szybko możesz mieć dużą ilość języków w aplikacji (zwłaszcza jeśli masz znajomych w wielu krajach)

Minusy:

  • każdy może dołożyć nowy język
  • każdy może przetłumaczyć aplikację
  • szybko możesz mieć dużą ilość języków w aplikacji (zwłaszcza jeśli masz znajomych w wielu krajach).

Czy to błąd? Nie, specjalnie zrobiłem te cechy zarówno plusami, jak i minusami. Np. Twoja aplikacja może nie być przygotowana na niektóre języki (np. te pisane od prawej do lewej). A ktoś na taki język przetłumaczy. Aplikacja będzie się dziwnie zachowywać i wyglądać. Poza tym duża ilość tłumaczeń w krótkim czasie może być problematyczna, jeśli programujesz sam lub masz mały zespół. Te tłumaczenia trzeba utrzymywać. Wraz z rozwojem aplikacji, pewne stringi znikają i pojawiają się kolejne.

Poza tym nigdy nie wiesz, kto robi tłumaczenia. Jeśli masz fajną społeczność, która się przykłada, to ok. Ale jeśli ludzie będą tłumaczyć automatami…

Część z tych problemów odpada, jeśli aplikacja jest webowa. Wtedy to częste zastosowanie. Jednak format pliku niekoniecznie musi być taki prosty. Możesz trafić na inne, np.: Android XML, Angular Translate, i18next.

Jak tworzyć pliki lokalizacyjne?

Ze względu na to, że to zwykłe pliki tekstowe, można tworzyć je ręcznie w dowolnym edytorze. Jednak często używa się innych narzędzi, skryptów, czy też usług. Automatyczne tworzenie takich plików zwalnia programistę z głupiej, błędogennej pracy i właściwie zapewnia, że pliki będą utworzone poprawnie.

Zasoby – czyli lokalizacja w .NET

Microsoft od dawna woli lokalizację za pomocą zasobów. Ja też raczej idę tą ścieżką, chociaż w przeszłości zdarzało się inaczej. W związku z tym, że jest to „prawilna” droga w .NET, na tym właśnie sposobie się skupimy.

Tworzenie zasobu językowego – przeczytaj to

Teksty trzymasz w zasobach. Wyobraź sobie zasób jako swego rodzaju plik, który jest wkompilowany do Twojego programu.

Zasób dodajesz do konkretnego projektu. Zatem w każdym projekcie możesz mieć osobne zasoby lub możesz mieć jeden projekt z samymi zasobami. Które rozwiązanie lepsze? Jak zwykle – to zależy 🙂
Aby dodać taki plik:

  • kliknij prawym klawiszem myszy na projekt, w którym chcesz dodać zasoby
  • wybierz opcję: Add -> New Item
  • w okienku wyszukiwania zacznij wpisywać „resource”. Na liście zobaczysz element „Resource File”

Teraz, żeby ten plik uczynić plikiem językowym, musisz dodać mu kod języka do nazwy, np:
Resource.en-US.resx
Resource.pl-PL.resx
Resource.en.resx

To po prostu określenie języka (przed myślnikiem) i regionu (po myślniku).

Mamy tutaj 3 pliki

  • en-US – zasoby dla języka angielskiego amerykańskiego (US); angielski brytyjski byłby us-GB
  • pl-PL – zasoby dla języka polskiego; jako, że język polski jest używany tylko w jednym regionie, nie ma większego znaczenia, czy wpiszesz pl, czy pl-PL… Teoretycznie…
  • en – zasoby dla języka angielskiego (bez określenia regionu).

UWAGA!
Z zasady powinieneś tworzyć pliki językowe w kolejności – od ogółu do szczegółu. Czyli np. jeśli masz plik Resource.en-US.resx to powinieneś też mieć bardziej ogólny: Resource.en.resx a najlepiej najbardziej ogólny: Resource.resx.

UWAGA!
Teraz będzie ważne. Kliknij na utworzony plik prawym klawiszem myszy i z menu wybierz Properties. Pojawi Ci się okienko z właściwościami tego elementu. Zwróć na nie uwagę:

Build ActionEmbedded Resource – oznacza, że ten plik ma być wbudowanym zasobem – stanie się częścią Twojej dllki lub execa.
Copy to output directoryDo not copy – oznacza, że plik nie zostanie skopiowany do katalogu wyjściowego podczas budowania (no bo po co, skoro jest częścią execa)
Custom Tool – to będzie narzędzie użyte do procesowania tego pliku podczas budowania. I tutaj ważnym jest, żebyś upewnił się, że każdy z tych plików ma tutaj wpisane ResXFileCodeGenerator. Dzięki temu narzędzie zadziała, a VS wygeneruje potrzebne później pliki z kodami.

Cały mechanizm lokalizacji działa w taki sposób, że framework rozpozna, jaki język jest używany przez użytkownika i w jakim jest on regionie. Jeśli taką aplikację uruchomi Anglik, framework automagicznie wybierze plik Resource.en.resx. Dlatego, że nie ma pliku en-GB. Weźmie pod uwagę tylko ten pierwszy kod. Jeśli program zostanie uruchomiony przez Amerykanina, zostanie użyty en-US (język angielski dla regionu US)

A jeśli nie daj Boże Francuz uruchomi naszą aplikację? Nie mamy przecież zasobu fr. Dlatego powinniśmy mieć jeszcze jeden zasób o nazwie Resource.resx. Bez określenia kodu języka. To będzie domyślny plik dla frameworka. Tak naprawdę, jeśli tworzę aplikację dwujęzyczną (np. polski i angielski), mam tylko dwa pliki zasobów:

  • resource.resx – domyślny, wszystko po angielsku
  • resource.pl.resx – tłumaczenia polskie

Dzięki takiemu rozwiązaniu, Francuz od razu zobaczy angielskie tłumaczenia. Tak samo jak Włoch, Serb, czy Szwed. A nawet Anglik, czy Amerykanin. Tylko Polak zobaczy polskie.

A od czego to zależy? Jeśli chodzi o aplikacje desktopowe, to od ustawień systemowych. Przecież w systemie masz wklepany zarówno swój język, jak i region. Jednak można to zmienić w kodzie, zmieniając właściwość CurrentUICulture w odpowiednim wątku aplikacji.

Dlaczego lepiej mieć pl niż pl-PL

A teraz muszę wyjaśnić Ci mały myk, który kiedyś zajął mi godzinę bezsensownej pracy (dlatego też wyżej pisałem – od ogółu do szczegółu). Tworzyłem aplikację webową i okazało się, że na jednej przeglądarce (Chrome) wszystko działało, natomiast na drugiej (Firefox) nie było polskich tłumaczeń. Problemem było to, że plik miałem nazwany:
resource.pl-PL.resx.

Przeglądarka Chrome zwracała mi kod języka pl-PL, natomiast Firefox zwracał tylko pl – bez określenia regionu. Nie było pliku resource.pl.resx, dlatego wzięty został pod uwagę tylko plik domyślny – z angielskimi tłumaczeniami. Miej to na uwadze.

Zmiana języka w przeglądarce

A skąd przeglądarka wie, jaki ma brać język? Z własnych ustawień:

Firefox – Ustawienia -> Ogólne -> Język i alternatywne:

Analogicznie jest w Chrome: Ustawienia -> Zaawansowane -> Języki

Generalnie przeglądarka może mieć ustawione kilka języków, które obsługuje z określoną wagą. O tym później.

Lokalizacja w praktyce

Koniec teorii, bierzemy się do roboty. Pokażę Ci, jak używać mechanizmu lokalizacji w różnych technologiach. I jak używać go wygodnie.

Wspólnym mianownikiem wszystkich technologii jest utworzenie plików lokalizacyjnych, tak jak pokazałem to wyżej. I tak, jak mówiłem – możesz mieć osobne pliki w osobnych projektach. Możesz mieć też jeden projekt, w którym znajdują się Twoje wszystkie zasoby językowe.

A więc:

  • stwórz 2 pliki w jakimś projekcie: LangRes.resx i LangRes.pl.resx – w taki sposób, jak opisany wyżej
  • kliknij dwukrotnie na jeden z tych plików – otworzy się edytor zasobów. Dodaj po jednym elemencie. W pierwszej kolumnie podajesz klucz, pod jakim będzie dana wartość. Np: „HelloMsg”. W drugiej kolumnie podajesz konkretną wartość dla danego języka. Tak jak na obrazku poniżej:
Domyślny edytor zasobów – widok LangRes.resx

Oczywiście są różne programy do edytowania plików resx. Niektóre pokazują kilka języków jednocześnie, co może ułatwiać tłumaczenie. Ja dawno z niczego takiego nie korzystałem, bo domyślny edytor w zupełności mi wystarcza.

Kolumna Comment, to kolumna w której możesz wpisać sobie jakiś komentarz pomagający przetłumaczyć na inny język. Np. że ma to być dopełniacz albo co tam uważasz za słuszne. Możesz to pole zostawić puste.

Dodaj to samo tłumaczenie w drugim pliku – LangRes.pl.resx

UWAGA!

Zwróć uwagę na pole Access Modifier. Ono może przyjąć 3 wartości:

  • internal
  • public
  • No code generation

No code generation oznacza, że podczas budowania aplikacji dla tego pliku nie zostanie wygenerowany żaden kod. Zdecydowanie NIE CHCEMY TEGO. Chcemy, żeby Visual Studio wygenerował odpowiedni kod. Dlatego upewnij się, że masz tam specyfikator dostępu ustawiony na internal lub public.

Klasa wygenerowana przez Visual Studio będzie miała ten specyfikator dostępu. A więc, jeśli wybierzesz INTERNAL, nie będziesz mógł się odwoływać do tych zasobów z innych swoich projektów. Jeśli wybierzesz PUBLIC, klasa będzie publiczna i odwołasz się bez problemów.

Jeśli więc tworzysz zasoby językowe w osobnym projekcie (współdzielonym), zawsze wybieraj tam PUBLIC.

Super, zbuduj teraz tę aplikację.

Pobieranie zasobów

Sprawa jest banalna, spójrz na ten kod w aplikacji konsolowej:

using System.Globalization;
//...
        static async Task Main(string[] args)
        {
            Console.WriteLine("Tekst zgodny z aktualną kulturą: " + LangRes.HelloMsg);

            LangRes.Culture = new CultureInfo("en-US");
            Console.WriteLine("Tekst zgodny z kulturą angielską: " + LangRes.HelloMsg);

            LangRes.Culture = new CultureInfo("fr");
            Console.WriteLine("Tekst zgodny z kulturą francuską: " + LangRes.HelloMsg);
            Console.ReadKey();

        }

Jak widzisz, powstała specjalna klasa statyczna (podczas kompilacji) LangRes -> nazwa tej klasy to po prostu nazwa główna Twojego zasobu. W tej klasie będą wszystkie klucze obecne w Twoim zasobie.
W taki właśnie sposób możesz używać tłumaczeń w swoim kodzie w .Net Framework.

Pobieranie zasobów przez serwis i tłumaczenia w innym projekcie.

Zauważ, że klasa LangRes jest oznaczona jako internal. To oznacza, że wykorzystać ją możesz tylko z tego projektu, w którym się ona znajduje. Ale być może chciałbyś ją umieścić w innym projekcie i mieć ją nadal oznaczoną jako internal. A może po prostu chcesz mieć serwis, który dostarczy tłumaczenia?

  • stwórz więc nowy projekt Class Library (.Net Framework)
  • dodaj do niego te dwa zasoby (poprzednie możesz usunąć)
  • stwórz publiczną klasę o nazwie Localizer (możesz sobie zrobić z tego interfejs)

Spójrz, jak wygląda moja klasa Localizer (wraz s interfejsem):

public interface ILocalizer
{
    string this[string key] { get; }
} 

public class Localizer : ILocalizer
{
    public string this[string key]
    {
        get
        {
            return LangRes.ResourceManager.GetString(key);
        }
    }
}

Ta klasa jest równie prosta, co przydatna. Zwłaszcza, gdy używasz różnych bibliotek, które po swojemu ogarniają tłumaczenia. Możesz wtedy wszystko zrobić na tym poziomie. Pomijam w tym momencie dependency injection, ale zobacz, jak użyję tego w poprzednim programie:

ILocalizer localizer = new Localizer();
Console.WriteLine("Tekst zgodny z aktualną kulturą: " + localizer[nameof(LangRes.HelloMsg)]);

Tutaj jednak już nie wystarczy zmiana kultury w LangRes. Aby pobrać tekst dla innego języka, musiałbyś posłużyć się odpowiednim przeciążeniem metody GetString, którą używasz w Localizerze, np:

public interface ILocalizer
{
    string this[string key] { get; }
    string Localize(string key, CultureInfo culture);
} 

public class Localizer : ILocalizer
{
    public string this[string key]
    {
        get
        {
            return LangRes.ResourceManager.GetString(key);
        }
    }

    public string Localize(string key, CultureInfo culture)
    {
        return LangRes.ResourceManager.GetString(key, culture);
    }
}

I wtedy poprzedni program może wyglądać tak:

static async Task Main(string[] args)
{
    ILocalizer localizer = new Localizer();

    Console.WriteLine("Tekst zgodny z aktualną kulturą: " + localizer[nameof(LangRes.HelloMsg)]);
    Console.WriteLine("Tekst zgodny z kulturą angielską: " + localizer.Localize(nameof(LangRes.HelloMsg), new CultureInfo("en-US")));
    Console.WriteLine("Tekst zgodny z kulturą francuską: " + localizer.Localize(nameof(LangRes.HelloMsg), new CultureInfo("fr")));
    Console.ReadKey();
}

Dobra rada – NIGDY w kluczu nie używaj stringa. Zawsze posłuż się operatorem nameof tak jak ja wyżej. To daje Ci Intellisensa – dokładnie wiesz, jakie masz klucze w pliku zasobów i nie musisz ich pamiętać. Poza tym, jeśli usuniesz kiedyś jakiś klucz lub zmienisz mu nazwę, to program się nie skompiluje i da Ci szansę uaktualnić kod.

Pobieranie tekstów w różnych technologiach

Postanowiłem ten fragment podzielić na odrębne artykuły. Poniżej masz listę, która będzie aktualizowana:


Jeśli czegoś nie rozumiesz albo znalazłeś błąd w artykule, daj znać w komentarzu 🙂

Podziel się artykułem na:
Tłumaczenie aplikacji cz. 2 – przygotowanie do lokalizacji

Tłumaczenie aplikacji cz. 2 – przygotowanie do lokalizacji

Jest to druga część większego artykułu, więc jeśli jesteś faktycznie zainteresowany pisaniem aplikacji wielojęzycznych, przeczytaj najpierw o globalizacji.

Wstęp

Zanim zabierzemy się za tłumaczenie naszej aplikacji, dobrze jest zrobić coś w rodzaju przeglądu lokalizacji. Coś jak code-review, tylko skupiamy się na elementach globalizacyjnych. To znaczy, że w pierwszym etapie trzeba sprawdzić, czy wszystkie wytyczne z artykułu o globalizacji są spełnione. Jeśli nie – poprawiamy. O tym jest ten artykuł.

Adresy, telefony, nazwiska…

Sprawdź też wszystkie adresy, telefony itd. Te elementy różnie wyglądają w różnych państwach. Niestety czasami wymusza to stosowanie różnych typów modeli lub jakiegoś super modelu. Elementy, na które musisz popatrzeć, to np:

  • adresy
  • numery telefonów
  • rozmiary papieru (!)
  • jednostki miar (!)

Z niewielką pomocą przychodzi tutaj klasa RegionInfo z namespace System.Globalization. Daje ona kilka przydatnych informacji. Spójrz na jej właściwości:

  • CurrencyEnglishName – angielska nazwa waluty (np. „polish zloty”)
  • CurrencyNativeName – lokalna nazwa waluty (np. „złoty polski”)
  • CurrencySymbol – no to chyba wiadomo 😉
  • ISOCurrencySymbol – określenie nazwy waluty. Np. CurrencySymbol będzie „€”, a ISO to „EUR”. Dla polski to będzie PLN.
  • IsMetric – zwraca true, jeśli dany region posługuje się jednostkami metrycznymi. Jeśli ma imperialne, wtedy jest false.

UWAGA! Wiele osób jest przekonanych, że Wielka Brytania posługuje się jednostkami imperialnymi. Nie jest to do końca prawda. Jakiś czas temu zmieniono to oficjalnie na jednostki metryczne, jednak duża część ludzi z przyzwyczajenia używa jeszcze jednostek imperialnych.

Klasa RegionInfo posiada jeszcze kilka innych właściwości, jednak nie uważam ich jako super przydatnych podczas lokalizacji. Można je wyciągnąć innymi metodami.

Aplikacje, które mocno polegają na jednostkach miar (np. AutoCAD) dają swoim użytkownikom możliwość wyboru, czy stosować imperialne jednostki, czy metryczne. I myślę, że to jest dobry pomysł. Pod tym względem traktowałbym klasę RegionInfo jako pomocniczą, a nie jak ostateczny wyznacznik (chociażby ze względu na Anglików).

Przetestuj aplikację

W tym momencie powinieneś zacząć testować aplikację na różnych komputerach, różnych wersjach językowych systemu operacyjnego, posługując się globalnymi danymi. Pomoże Ci to wychwycić pozostałe potencjalne problemy, jak np:

  • serializacja danych (w szczególności dat i liczb zmiennoprzecinkowych)
  • wyświetlanie danych zgodnie z regułami
  • problemy z sortowaniem i porównywaniem stringów

I teraz pewnie powiesz – „Gościu, to ile ja mam mieć komputerów, żeby to wszystko przetestować? Przecież nie będę swojego brudził”. Zgadza się. Jeśli chodzi o testowanie desktopa, wystarczy Ci jeden komputer i wirtualne maszyny na nim. Do tego możesz użyć HyperV lub Oracle Virtual Box. Wszystkie te rozwiązania są darmowe.

Jeśli chodzi o testowanie aplikacji internetowych, to jest prościej. Wystarczy zmienić język w przeglądarce.

Chociaż polecam Ci tak, czy inaczej testy na wirtualnej maszynie ze względu na to, że można pozmieniać ustawienia regionalne w samym systemie. Wtedy dokładnie zobaczysz, co się dzieje z Twoją aplikacją.

Jeśli wszystko jest już gotowe, zapraszam do ostatniego artykułu z serii – lokalizacja.

Podziel się artykułem na:
Idziemy w świat! Czyli aplikacje wielojęzykowe

Idziemy w świat! Czyli aplikacje wielojęzykowe

Wstęp

Aplikacje wielojęzykowe są mimo wszystko dość łatwe w pisaniu, jednak trzeba pamiętać o kilku rzeczach, które koniecznie musimy stosować w tego typu programach. Inaczej bardzo łatwo o bum. Dlatego też uważam, że są łatwe, jednak upierdliwe 🙂

Zagadnienie jest dość rozrośnięte, więc podzieliłem artykuł na kilka części.

W tym cyklu pokażę Ci, czym jest globalizacja, czym się różni od lokalizacji i jak to wszystko ogarnąć w różnych typach aplikacji (Xamarin, WPF, WebApp). Także bierz kawkę lub herbatę i zaczynamy.

Czym jest globalizacja?

Różne kraje mają różne formaty dat, liczb itd. Przykładowo w USA domyślnym formatem daty jest miesiąc/dzień/rok (MM/dd/yyyy). Jest to unikalne, że miesiąc występuje na pierwszym miejscu. W Polsce domyślny format to dzień-miesiąc-rok (dd-MM-yyyy), chociaż czasem stosuje się też odwrotny: rok-miesiąc-dzień (yyyy-mm-dd). Różne kraje też inaczej obsługują liczby. Np. w Polsce (i wielu innych krajach) separatorem dziesiętnym jest przecinek. Ale już np. w Australii, Irlandii, UK, czy USA (i wielu innych) separatorem dziesiętnym jest kropka.

Jakie to ma znaczenie?

Jeśli piszesz aplikację tylko dla swojego kraju, to małe. Twój program jednak może zostać uruchomiony przez dziwnych, złośliwych typów, którzy zrobili sobie inne ustawienia w systemie. Wtedy jest problem. Jeśli z aplikacji mogą korzystać osoby z innych państw, to musisz zatroszczyć się o globalizację.

Tzn. jeśli np. zapiszesz datę w bazie danych w formacie dd-mm-yyyy, a program później zostanie uruchomiony w innym kraju (albo przez złośliwego użytkownika, który zdefiniował sobie inny format), no to już będzie problem. W najgorszym przypadku program zadziała – pokazując złą datę. W najlepszym – po prostu wybuchnie, urywając użytkownikowi rączki i nóżki. Dlatego też powinieneś zatroszczyć się o to, żeby program pokazywał dobre dane przy różnych ustawieniach.

Tworzenie aplikacji wielojęzycznej

Dzisiaj dużo aplikacji jest tworzone od razu z myślą o innych krajach. I Ty także powinieneś tak je tworzyć (chyba, że robisz wewnętrzne narzędzia dla siebie/swojego zespołu, który mieści się tylko w Polsce). Tworzenie aplikacji wielojęzycznej składa się z dwóch procesów: globalizacji i lokalizacji. I ten cały proces nazywa się internacjonalizacją… Ufff.

Jak już wspomniałem, globalizacja polega na takim zaprojektowaniu aplikacji, żeby działała ona z różnymi kulturami (różne ustawienia dat, liczb itd). Lokalizacja natomiast polega na takim zaprojektowaniu aplikacji, żeby teksty dało się tłumaczyć. Czyli nie wpisujesz ich na sztywno, tylko pobierasz je skądś… No właśnie. To kolejny element lokalizacji. Musisz pomyśleć skąd masz pobierać teksty i w jaki sposób ustawiać aktualny język.

Globalizacja

Stringi

  1. Zawsze używaj stringów UNICODE. Na szczęście .NET domyślnie używa UNICODE (UTF-16) dla wszystkich stringów. Jednak można używać też innych stron kodowych. W specyficznych przypadkach ma to sens. Ale nie kombinuj z tym bezmyślnie. Staraj się używać wszędzie UNICODE.
  2. Traktuj string jako całość, a nie jako zbiór znaków. Jest to istotne jeśli chodzi o sortowanie lub porównywanie. W różnych krajach, różne znaki mogą być sortowane inaczej. Co więcej w niektórych sytuacjach jeden znak może być utworzony z kilku obiektów typu char. Dlatego też używaj przeciążenia String.IndexOf(String), zamiast String.IndexOf(char). Ponadto, to przeciążenie ze stringiem bierze pod uwagę kulturę.
  3. Nie porównuj stringów w standardowy sposób: str1 == str2 ani str1.Equals(str2). Te metody nie biorą pod uwagę kultury.
  4. Zawsze porównuj stringi używając metody String.Compare. ZAWSZE. Ta metoda ma dodatkowy parametr StringComparison, który może mieć takie wartości:
    • CurrentCulture – porównaj stringi używając aktualnej kultury i zasad sortowania
    • CurrentCultureIgnoreCase – tak jak wyżej, tylko nie bierz pod uwagę wielkości znaków
    • InvariantCulture – porównaj stringi używając niezmiennej kultury (invariant culture). Ta kultura jest związana z językiem angielskim.
    • InvariantCultureIgnoreCase – jak wyżej, ale nie bierz pod uwagę wielkości znaków
    • Ordinal – porównaj stringi binarnie
    • OrdinalIgnoreCase – jak wyżej, ale nie bierz pod uwagę wielkości znaków.

Najczęściej będziesz używał CurrentCulture(IgnoreCase) lub InvariantCulture(IgnoreCase) chyba, że ważne dla Ciebie są wielkości znaków. InvariantCulture używasz raczej wtedy, kiedy masz pewność, że dane mieszczą się w standardowym zestawie znaków (alfabet łaciński).

UWAGA! Porównanie stringów z CurrentCulture jest wolne. Jeśli widzisz u siebie problem z wydajnością w tym miejscu, zastanów się, czy możesz to zrobić inaczej

Generalnie podczas działań na stringach zawsze powinieneś szukać metod, które przyjmują w parametrze kulturę.

Daty

Często tutaj jest problem. Daty powinny być wyświetlane zgodnie z aktualną kulturą. Format daty w danej kulturze możesz wyciągnąć z CultureInfo.CurrentCulture.DateTimeFormat


Zróbmy małą symulację. Załóżmy, że ktoś z USA wpisał do bazy danych datę, a potem ktoś z Polski to odczytuje:

string dateStr = "02/01/2020"; //Polak odczytał z bazy danych
DateTimeOffset dt = DateTimeOffset.Parse(dateStr);

Amerykanin wprowadził datę po swojemu – 1 luty 2020. W efekcie Polak odczyta 2 stycznia 2020 -> w tych kulturach miesiąc i dzień zamieniają się miejscami. No i mamy najgorszy możliwy przypadek. Program się nie wywalił, ale zadziałał pokazując złą datę. Dlaczego tak się stało? Bo data została zapisana z użyciem kultury en-US, a odczytana z użyciem pl-PL. Jest to częsty błąd, ale łatwy do uniknięcia. Jak to powinno być zrobione poprawnie?

Poprawnie to Amerykanin powinien zapisać datę z InvariantCulture, a Polak ją tak odczytać. Spójrz na ten kod:

//Zapis w USA
DateTimeOffset dt = new DateTimeOffset(2020, 2, 1, 0, 0, 0, 0, TimeSpan.Zero);
string valueToDatabase = dt.ToString(CultureInfo.InvariantCulture);
//i zapis do bazy valueToDatabase

//Odczyt w Polsce
string valueFromDatabase = GetDateFromDbAsString();
DateTimeOffset plDt = DateTimeOffset.Parse(valueFromDatabase, CultureInfo.InvariantCulture);
string s = plDt.ToString();

Console.WriteLine(s);

Przeanalizujmy to:

  1. Najpierw Amerykanin utworzył datę.
  2. Zmienił ją na string, używając niezmiennej kultury („02/01/2020”)
  3. Zapisał do bazy danych
  4. Polak odczytał z bazy danych („02/01/2020”)
  5. Sparsował tego stringa na datę, używając niezmiennej kultury – tu otrzymujemy już poprawną datę, a nie jak w poprzednim przypadku
  6. Polak zamienił datę na stringa, używając aktualnej kultury, w efekcie zobaczył: „01.02.2020”

Zauważ, że DateTime.ToString() i DateTimeOffset.ToString() zmieniają datę na stringa, używając aktualnej kultury. I teraz jeśli Amerykanin wprowadzi w swoim formacie datę 5/20/2020 (20 maj), a Polak spróbuje to odczytać bez użycia InvariantCulture, to program się wywali, ponieważ DateTime.ToString() będzie próbowało odczytać tę datę jako piąty dzień dwudziestego miesiąca.

Jest jeszcze jeden problem. W grę wchodzi baza danych. Więc jeśli tam są robione jakieś operacje na datach, trzeba to też wziąć pod uwagę. Rozwiązania są dwa:

  • zawsze używamy niezmiennej kultury
  • dogadujemy się co do własnego formatu zapisu daty, np: „dd-MM-YYYY HH::ss”

Sam sobie odpowiedz, które rozwiązanie jest lepsze 🙂

Generalnie daty można zapisywać poprawnie na 3 sposoby:

  • binarnie, zamiast stringa (co może być uciążliwe)
  • używając Unix Timestamp – czyli ilość sekund, które upłynęło od 1 stycznia 1970 (co też może być uciążliwe)
  • jako string – używając niezmiennej kultury (lub ustalonego formatu, który NIGDY się nie zmieni).

Każdy z tych sposobów ma swoje plusy i minusy. Np. jeśli w bazie danych zapiszesz daty binarnie lub jako integer (Unix Timestamp), bazodanowe operacje będą szybsze. Ale dla kogoś, kto przegląda rekordy lub próbuje coś debugować, jest to mordęga. Zawsze jednak można sobie stworzyć widok, który doda kolumnę z datą przekonwertowaną na ludzki format.


UWAGA! Jeśli w bazie danych trzymasz czas jako Unix Timestamp w polu 32 bitowym, miej świadomość, że w 2038 roku będzie problem, bo wtedy zakres dat zostanie przekroczony!

To jednak nie wszystko, jeśli chodzi o datę. W grę wchodzą jeszcze strefy czasowe.

Strefy czasowe

W C# mamy dwie klasy, które ułatwiają posługiwanie się strefami czasowymi. TimeZone i TimeZoneInfo. Jednak jeśli chodzi o TimeZone, to klasa została uznana za przestarzałą już w .NetCore, zatem skupimy się tylko na TimeZoneInfo.

W skrócie, TimeZoneInfo pozwala na konwertowanie czasu pomiędzy dowolnymi strefami czasowymi. Ma kilka metod statycznych, które służą do utworzenia obiektu. Nie posługujemy się tutaj konstruktorem.

Informacje o strefie czasowej można w bardzo prosty sposób zapisać, np:

TimeZoneInfo tz = TimeZoneInfo.Local; //pobranie aktualnej strefy czasowej
string serialized = tz.ToSerializedString(); //serializacja do string


TimeZoneInfo restoredTimeZone = TimeZoneInfo.FromSerializedString(serialized);

Najpierw pobieramy aktualną strefę czasową, potem zapisujemy ją w specjalnym stringu za pomocą ToSerializedString(). Takiego stringa teraz możemy zapisać w bazie danych, a potem utworzyć strefę czasową z niego, używając metody FromSerializedString().

Jako twórca aplikacji, nie powinieneś zakładać, że wszystkie czasy są wyrażone w aktualnej strefie czasowej. Np. jeśli ktoś z USA doda post na forum o godzinie 13:00 swojego czasu, Ty mógłbyś w Polsce zobaczyć, że post został dodany o godzinie 13:00 Twojego, polskiego czasu, co oczywiście nie jest prawdą. To jest prosty przykład, ale są systemy, które mocno polegają na czasie i w takich przypadkach mogą nie działać lub być podatne na jakieś ciekawe ataki.

Na szczęście istnieje coś takiego jak UTC (Coordinated Universal Time) – uniwersalny czas koordynowany (Zulu), czyli po prostu niezmienny czas w strefie z południkiem 0. Wszystkie inne strefy czasowe są wyrażone w godzinach dodatnich lub ujemnych od czasu UTC. Zatem, jeśli do wyrażenia dat posługujesz się typem DateTime, zdecydowanie powinieneś dodać do tego TimeZoneInfo.

Teraz spójrz na taki ZŁY kod:

//zapis w USA
DateTime dt = new DateTime(2021, 6, 1, 15, 0, 0);
string usaDt = dt.ToString(CultureInfo.InvariantCulture);
   
//odczyt w Polsce
DateTime dtInPoland = DateTime.Parse(usaDt, CultureInfo.InvariantCulture);

Ostatecznie wychodzi, że ktoś w USA dodał jakiś zasób o godzinie 15:00 czasu Polskiego. To bzdura. Tylko z tego powodu, że pominęliśmy strefy czasowe. Jak taki kod naprawić? Na przykład przed zapisem zmień go na UTC:

//zapis w USA - symulacja wprowadzenia daty w USA
DateTime dt = new DateTime(2021, 6, 1, 23, 0, 0);
TimeZoneInfo pacificZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

//konwersja daty na UTC
dt = TimeZoneInfo.ConvertTime(dt, pacificZone);
DateTime utcTime = TimeZoneInfo.ConvertTimeToUtc(dt, pacificZone);
   
//zapis do bazy
string usaDt = utcTime.ToString(CultureInfo.InvariantCulture);
   
//odczyt w Polsce
DateTime utc = DateTime.Parse(usaDt, CultureInfo.InvariantCulture);
TimeZoneInfo polishTimeZone = TimeZoneInfo.Local;
DateTime polishTime = TimeZoneInfo.ConvertTimeFromUtc(utc, polishTimeZone);
string plDt = polishTime.ToString();

Niech on Cię nie przeraża. Pierwsze cztery linijki to tak naprawdę SYMULACJA zapisu daty w USA. Musiałem zrobić tę symulację, bo na swoim komputerze mam ustawiony polski czas.

A teraz przeanalizujmy go:

  • Ktoś w USA zapisuje dane 1 czerwca o godzinie 15 (czasu USA)
  • Następnie ten czas jest zmieniany na czas UTC – czas uniwersalny
  • Czas UTC jest zapisywany w bazie w formacie InvariantCulture (o tym mówiliśmy w wyżej)
  • Ktoś z Polski odczytuje datę z bazy. Pamiętaj, że data jest zapisana w czasie uniwersalnym.
  • Pobieramy teraz lokalną strefę czasową ustawioną na komputerze (dla ludzi z Polski powinna to być aktualna polska strefa czasowa)
  • Za pomocą TimeZoneInfo.ConvertTimeFromUtc konwertujemy czas UTC na nasz polski czas
  • Na koniec możemy go wyświetlić.

Okazuje się, że wpis został dokonany 1 czerwca o godzinie 23:00 czasu polskiego. I teraz wszystko śmiga. Oczywiście w swoim programie pewnie wystarczy, że zamiast FindSystemTimeZoneById użyjesz po prostu TimeZoneInfo.Local, ponieważ to zwróci Ci lokalną strefę czasową klienta. Jeśli klient będzie w USA, zwróci to odpowiednią strefę z USA. Jeśli będzie z Polski, zwróci to aktualną strefę w Polsce.

UWAGA! NIGDY nie używaj TimeZoneInfo.Local na serwerze, ponieważ zwróci to strefę czasową SERWERA, a nie klienta!

Oczywiście można też zrobić coś innego. Możesz w bazie danych trzymać datę lokalną (zamiast UTC), ale to wymaga, żeby w innym polu były zawarte informacje o strefie czasowej. To z kolei wymaga odpowiedniego mechanizmu odczytu takich danych. I prędzej, czy później ktoś tu coś może spieprzyć.

DateTimeOffset

Staraj się unikać typu DateTime na rzecz DateTimeOffset. Ten drugi jest w pewnym sensie kolejną wersją DateTime. Dodatkowo zawiera informacje o przesunięciu czasowym. Ale nie zawiera pełnych informacji o strefie czasowej. Więc przykładowo porównywanie dat z użyciem DateTime ma sens tylko wtedy, kiedy obie daty pochodzą z tej samej strefy czasowej. DateTimeOffset nie ma już tego problemu. Poza tym, DateTime ma jeszcze kilka takich „kruczków”, m.in., jeśli dodajesz lub odejmujesz jakiś czas do DateTime, najpierw musisz przekonwertować to na UTC:

DateTime dt = DateTime.Parse("Oct 26, 2003 12:00:00 AM");
dt = d.ToUniversalTime().AddHours(3).ToLocalTime();

jeśli tego nie zrobisz, to w specyficznych przypadkach (dni zmiany strefy czasowej w kraju) otrzymasz złe dane.

Zatem używaj DateTimeOffset*

*Niestety niektóre kontrolki third party nie umieją w DateTimeOffset. Trzymają się sztywno DateTime, jak tonący brzytwy. Postaraj się wtedy o odpowiednie konwersje, chyba że faktycznie nie są potrzebne.

Liczby

Tutaj też istnieje różnica pomiędzy wyświetlaniem numerów, a ich składowaniem. Przede wszystkim musisz zapamiętać, że metoda ToString konwertuje liczby do stringa zgodnie z aktualną kulturą. Więc liczbę „jeden i pół” amerykanin zobaczy tak: „1.5”, a Polak tak: „1,5” (zakładając standardowe ustawienia).

I niby fajnie, ale… NIGDY nie przechowuj liczb w takiej postaci (stringa). Grozi to wybuchem i urwaniem rączek. Jeśli robisz czyste zapytania do bazy danych (mam nadzieję, że tego nie robisz), to pamiętaj też, że taką liczbę powinieneś przekonwertować do InvariantCulture. Spójrz na prostą tabelę z dwiema kolumnami:

Tabela(ID: BIGINT, number: float)

I teraz jeśli użytkownik wpisze daną w postaci „1,5”, to jak będzie wyglądać Twoje czyste zapytanie do bazy?
INSERT INTO Tabela(number) VALUES(1,5);

SQL pomyśli, że chcesz wprowadzić dane dla dwóch kolumn (jedna o wartości 1, druga o wartości 5). Dlatego też konwersja do InvariantCulture lub zdecydowanie lepiej – używanie parametrów SQL (ale to historia na inny artykuł).

Ten sam błąd możesz uzyskać, zapisując dane w postaci tekstowej np. w XML. Jeśli zapiszesz w taki sposób:

<Data value="1,5" />

ten przykładowy Amerykanin też tak to odczyta. I później nastąpi konwersja ze stringa na double. I BUM! W USA separatorem dziesiętnym jest KROPKA, a nie przecinek. Dlatego też w takich sytuacjach powinieneś konwertować dane do InvariantCulture.

Przy okazji, skoro tu jesteśmy – jeśli zapisujesz jakieś sumy pieniędzy (czy to w bazie, czy w XML, czy jeszcze gdzieś indziej), nie zapisuj tego jako double. Zawsze zapisuj to jako liczbę całkowitą, czyli np. ilość groszy/centów, a nie złotówek/dolarów. Zamiast zapisać 1,23 (złoty dwadzieścia trzy), po prostu pozbądź się tej części ułamkowej, mnożąc przez 100. Zostanie zapisana liczba 123. Dane finansowe są niezwykle wrażliwe i każde nieodpowiednie (nieprzewidziane) zaokrąglenie może duuużo kosztować. Dlatego przejmij w tej kwestii całkowicie kontrolę i podawaj ilość groszy/centów itd.

CurrentCulture vs CurrentUICulture

Żeby dopełnić artykułu, muszę o tym wspomnieć. Microsoft wyprodukował nam dwie podobne właściwości: CurrentCulture i CurrentUICulture. Powinnśsmy dobrze poznać różnice między nimi, żeby nie było przykrych niespodzianek i niepotrzebnych nerwów.

  • CurrentCulture – używaj do formatowania dat, liczb itd.
  • CurrentUICulture – używaj do pobierania zasobów.

Czyli: Culture – wszelkie formatowanie; UICulture – zasoby.

To tyle w kwestii globalizacji. W kolejnym artykule z tej serii zajmiemy się lokalizacją, czyli skąd i jak wyświetlać stringi w różnych technologiach.

Jeśli masz jakieś problemy lub znalazłeś błąd w artykule, podziel się tym w komentarzu.

Podziel się artykułem na: