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
- 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.
- 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)
, zamiastString.IndexOf(char)
. Ponadto, to przeciążenie ze stringiem bierze pod uwagę kulturę. - Nie porównuj stringów w standardowy sposób:
str1 == str2
anistr1.Equals(str2)
. Te metody nie biorą pod uwagę kultury. - 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).
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:
- Najpierw Amerykanin utworzył datę.
- Zmienił ją na string, używając niezmiennej kultury („02/01/2020”)
- Zapisał do bazy danych
- Polak odczytał z bazy danych („02/01/2020”)
- Sparsował tego stringa na datę, używając niezmiennej kultury – tu otrzymujemy już poprawną datę, a nie jak w poprzednim przypadku
- 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.
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.
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.