To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach internetowych tworzonych w RAZOR. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.
W aplikacjach internetowych możemy uwzględniać język na kilka sposobów:
informacji wysyłanej z przeglądarki (nagłówek żądania)
parametru w zapytaniu (np. https://example.com?lang=en)
ciasteczka
fragmentu URL (np. https://example.com/en-US/)
Popatrzymy na te wszystkie możliwości.
Żeby w ogóle cała machina ruszyła, trzeba skonfigurować lokalizację… To naprawdę proste, wystarczy zrozumieć 🙂
Czym jest middleware pipeline?
Jeśli wiesz, czym jest middleware pipeline w .NetCore, możesz przejść dalej. Jeśli nie wiesz – też możesz, ale dalsza część artykułu będzie trochę niejasna.
Pipeline (czyli potok) to seria akcji wykonywanych jedna po drugiej podczas odbierania żądania od klienta i wysyłania odpowiedzi. W metodzie Configure ustawiasz właśnie te komponenty w pipelinie za pomocą metod, których nazwy rozpoczynają się zwyczajowo od Use. Np. UseAuthentication, UseAuthorization itd. Spójrz na przykładowe kody:
Najpierw trzeba skonfigurować języki w aplikacji RAZOR. Przede wszystkim zajrzyj do pliku Startup.cs i tam odnajdź metodę ConfigureServices. (jeśli używasz .NET6, możesz nie widzieć Startup.cs, wszystko dzieje się w pliku Program.cs)
Teraz musisz w niej skonfigurować serwis odpowiedzialny za lokalizację. Są takie metody (extensions) w IServiceCollection jak AddControllers*, AddMVC*, czy też AddRazorPages. Każda z nich zwraca obiekt implementujący IMvcBuilder. Z kolei ten, ma w sobie rejestrację lokalizacji (AddViewLocalization()), a więc np:
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;
//...
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews()
.AddViewLocalization();
}
Najprostszą konfigurację lokalizacji robimy w metodzie Configure – PRZED mapowaniem ścieżek. A więc dodajemy to do pipeline. Wygląda to tak:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
app.UseRequestLocalization(localizationOptions);
Teraz przyda się kilka słów wyjaśnienia.
Najpierw trzeba użyć oprogramowania pośredniczącego (middleware) do lokalizacji. Robimy to przez włączenie do pipeline UseRequestLocalization. Można to zrobić na kilka sposobów:
app.UseRequestLocalization() – bez parametrów – odczyta lokalizację z nagłówka żądania, który wysyłany jest przez przeglądarkę. I tyle. Niczego tu nie można zmienić.
app.UseRequestLocalization(RequestLocalizationOptions) – od razu skonfiguruje middleware RequestLocalization zgodnie z przekazanymi opcjami
app.UseRequestLocalization(Action) – podobnie jak wyżej, tyle że przekazujemy tutaj akcję, w której konfigurujemy middleware.
W naszym przykładzie włączamy RequestLocalization do pipeline (pamiętaj, że ZANIM zmapujemy ścieżki), przekazując opcje.
Wróćmy do kodu:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
app.UseRequestLocalization(localizationOptions);
Najpierw tworzona jest lista kultur, które wspieramy, a w drugim kroku ustawiamy opcje lokalizacji:
odczytu lokalizacji z przeglądarki (z nagłówka żądania)
odczytu lokalizacji z parametrów zapytania (?culture=pl-PL)
odczytu lokalizacji z ciasteczka
Czyli konfigurując w taki sposób (z przekazaniem RequestLocalizationOptions) mamy dużo więcej niż po prostu włączając middleware do pipeline bez jego konfiguracji.
To teraz pytanie, skąd system wie, w jaki sposób ma pobrać dane o aktualnej kulturze? Czary? Nie! Z pomocą przychodzi…
RequestCultureProvider
To jest klasa abstrakcyjna, której zadaniem jest zwrócić informacje o kulturze na podstawie danych z żądania. Kilka domyślnych providerów jest już utworzonych i właściwie nie potrzeba więcej, chociaż możesz stworzyć własne (np. odczyt kultury z bazy danych).
W klasie RequestLocalizationOptions (opcje lokalizacyjne) poza obsługiwanymi kulturami znajduje się też lista RequestCultureProvider. Domyślnie utworzone są takie:
QueryStringRequestCultureProvider
zwraca kulturę z zapytania w adresie, np: https://example.com/Home/Index?culture=en-US; świetnie nadaje się to do debugowania. Domyślnie operuje na dwóch kluczach: culture i ui-culture. Wystarczy, że w zapytaniu będzie jeden z nich, drugi otrzyma taką samą wartość. Jeśli są oba, np: ?culture=en-US&ui-culture=en-GB, wtedy inne będą ustawienia dla CurrentCulture i CurrentUICulture.
Oczywiście klucze możesz sobie zmieniać za pomocą właściwości
QueryStringKey (domyślnie „culture”)
UIQueryStringKey (domyślnie „ui-culture”)
Także zamiast ?culture=en-US będziesz mógł podać np. ?lang=en
CookieRequestCultureProvider
zwraca kulturę z ciasteczka. Sam możesz zdecydować o tym, jak ma nazywać się dane ciasteczko (za pomocą właściwości CookieName). Domyślnie to: „.AspNetCore.Culture”.
Żeby to zadziałało, oczywiście jakieś ciasteczko musi zostać wcześniej zapisane. Ta klasa ma dwie przydatne metody statyczne: ParseCookieValue i MakeCookieValue. MakeCookieValue zwróci Ci dokładną zawartość ciasteczka, jakie musisz zapisać.
AcceptLanguageHeaderRequestCultureProvider
zwraca kulturę zapisaną w przeglądarce (a właściwie wysłaną przez przeglądarkę w nagłówkach).
Kolejność tych providerów jest istotna. Jeśli pierwszy nie zwróci danych, drugi spróbuje. Jeśli w przeglądarce masz zapisaną kulturę pl-PL, ale w zapytaniu w adresie strony wpiszesz ?culture=en-US, zobaczysz stronę po angielsku, ponieważ pierwszy w kolejności jest QueryStringRequestCultureProvider.
Oczywiście manipulując tą listą możesz zmienić kolejność providerów, usuwać ich i dodawać nowych.
Pobieranie języka z adresu
Pewnie nie raz widziałeś (chociażby na stronach Microsoftu) taki sposób przekazywania kultury: https://example.com/en-US/Home/Index
gdzie informacje o niej są zawarte w adresie (w URL). Tutaj też tak można, a z pomocą przychodzi RouteDataRequestCultureProvider. Ten provider nie jest domyślnie tworzony, więc trzeba stworzyć obiekt tej klasy samemu i dodać go do RequestLocalizationOptionsna pierwszym miejscu:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
var requestProvider = new RouteDataRequestCultureProvider();
localizationOptions.RequestCultureProviders.Insert(0, requestProvider);
app.UseRequestLocalization(localizationOptions);
Żeby to zadziałało, trzeba jeszcze poinformować router, że w ścieżce są informacje o kulturze:
Tutaj analogicznie jak przy QueryStringRequestCultureProvider możesz zmienić właściwościami klucze culture i uiculture. Oczywiście musisz pamiętać wtedy o zmianie template’a ścieżki.
Tą metodę wywołaj w metodzie Configure, która jest odpowiedzialna za konfigurację zarejestrowanych serwisów – zrób to przed konfiguracją endpointów.
Pobieranie tłumaczenia na widoku
Teraz już możesz pobierać tłumaczenia. Wystarczy, że dodasz do usingów w widokach: Microsoft.AspNetCore.Mvc.Localization i wstrzykniesz interfejs IStringLocalizer:
Jak widzisz, możesz wstrzyknąć do jednego widoku kilka takich „lokalizerów”. W zmiennej generycznej określasz tylko klasę z Twoimi zasobami (czyli to, co robiliśmy w tym artykule). Ja tutaj mam dwa takie zasoby – jeden główny w jakimś projekcie współdzielonym (LangRes) i drugi tylko w projekcie MVC (WebLangRes), w którym są teksty bardzo ściśle związane z serwisem www.
Przy takim prostym wywołaniu jak wyżej (tekst w tagu HTML) nic więcej nie trzeba robić. Natomiast jeśli chcesz przekazać tłumaczenie do tag helpera, musisz dołożyć po prostu właściwość Value, np.:
Mamy do dyspozycji jeszcze coś takiego jak IHtmlLocalizer. Działa prawie tak samo jak IStringLocalizer, z tą różnicą, że możesz mu przekazać zasoby z tagami html, np: <b>Hello!</b>. Jednak nie używam go, bo trochę mi śmierdzi wpisywanie kodu html do zasobów.
To tyle. Jeśli czegoś nie zrozumiałeś lub znalazłeś w tekście błąd, daj znać w komentarzu.
Jeśli uważasz ten artykuł za przydatny, udostępnij go.
To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach mobilnych tworzonych w XAMARIN. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.
Piszemy MarkupExtension
W XAMARINie, jak i w WPF, też mamy do czynienia z językiem XAML. Wersja dla Xamarina może i nie jest tak rozbudowana, jednak pozwala na dużo. I tutaj też posłużymy się MarkupExtension. Jeśli nie wiesz co to, w skrócie to jest interfejs (w WPF to jest klasa), który pozwala Ci na tworzenie własnych tagów XAML. Wystarczy go zaimplementować:
[ContentProperty("ResId")]
public class LocalizeExtension : IMarkupExtension
{
public string ResId { get; set; }
static CultureInfo ci = null;
public LocalizeExtension()
{
if (ci == null && (Device.RuntimePlatform == Device.iOS || Device.RuntimePlatform == Device.Android))
{
ci = DependencyService.Get<ILocaleService>().GetCurrentCultureInfo();
}
}
public object ProvideValue(IServiceProvider serviceProvider)
{
if (string.IsNullOrWhiteSpace(ResId))
return "<???>";
string result = LangRes.ResourceManager.GetString(ResId, ci);
if (string.IsNullOrEmpty(result))
return $"<? {ResId} ?>";
else
return result;
}
}
Klasa trzyma CultureInfo, które jest pobrane z jakiegoś serwisu. Jest to potrzebne tylko na Androidzie i iOS. Kto bardziej spostrzegawczy, to zorientuje się, że moja metoda ProvideValue ma mały błąd. Tak naprawdę powinienem też w niej sprawdzić platformę i albo wykorzystać metodę GetString z przeciążeniem CultureInfo (dla Androida i iOS), albo wersję bez CultureInfo. Jednak w Xamarinie robię tylko pod Androida, więc darowałem sobie to sprawdzenie.
Atrybut ContentProperty ustawiony na klasie wskazuje na domyślną właściwość. Dzięki czemu w XAML nie musimy już jej podawać:
<Label Text="{app:Localize ResId=Receipt}"/>
wystarczy:
<Label Text="{app:Localize Receipt}"/>
No dobrze, ale co z tym serwisem ILocaleService?
Pobranie lokalizacji z Androida i iOS
Xamarin wymaga jednego małego myku. Każdy z tych systemów musi sam zwrócić odpowiednią lokalizację. Zatem idealnym wydaje się utworzenie interfejsu i jego implementacja w konkretnym projekcie (nie ogólnym Xamarin, tylko konkretnie w aplikacji Android i iOS). Interfejs jest prosty:
Tworzenie ILocaleService
Zdefiniuj ten interfejs gdzieś w projekcie Xamarina lub w projekcie współdzielonym przez projekty Xamarina.
public interface ILocaleService
{
CultureInfo GetCurrentCultureInfo();
void SetLocale(CultureInfo ci);
}
Tworzenie PlatformCulture
Teraz musimy utworzyć małą klasę pomocniczą też w projekcie Xamarin (lub współdzielonym). Powinna ona wyglądać tak:
public class PlatformCulture
{
public PlatformCulture(string platformCultureString)
{
if (String.IsNullOrEmpty(platformCultureString))
throw new ArgumentException("Expected culture identifier", nameof(platformCultureString));
PlatformString = platformCultureString.Replace("_", "-"); // .NET expects dash, not underscore
var dashIndex = PlatformString.IndexOf("-", StringComparison.Ordinal);
if (dashIndex > 0)
{
var parts = PlatformString.Split('-');
LanguageCode = parts[0];
LocaleCode = parts[1];
}
else
{
LanguageCode = PlatformString;
LocaleCode = "";
}
}
public string PlatformString { get; private set; }
public string LanguageCode { get; private set; }
public string LocaleCode { get; private set; }
public override string ToString()
{
return PlatformString;
}
}
To jest kod wzięty z oficjalnej dokumentacji Microsoftu. Zadanie tej klasy stanie się za chwilę bardziej jasne. Generalnie jej celem jest właściwie zwrócenie kodu kraju, jeśli dostaniemy z urządzenia kod, którego nie ma w .NET, np. „en-ES”.
Implementacja ILocaleService na Androidzie
Po tych wszystkich znojach, musimy teraz utworzyć klasę w projekcie Androida, która będzie implementowała interfejs ILocaleService:
using System.Globalization;
using System.Threading;
using Xamarin.Forms;
[assembly: Dependency(typeof(Xamarin.Droid.Services.LocaleService))]
namespace Xamarin.Droid.Services
{
public class LocaleService : ILocaleService
{
public CultureInfo GetCurrentCultureInfo()
{
var netLanguage = "en";
var androidLocale = Java.Util.Locale.Default;
netLanguage = AndroidToDotnetLanguage(androidLocale.ToString().Replace("_", "-"));
// this gets called a lot - try/catch can be expensive so consider caching or something
CultureInfo ci = null;
try
{
ci = new CultureInfo(netLanguage);
}
catch (CultureNotFoundException)
{
// iOS locale not valid .NET culture (eg. "en-ES" : English in Spain)
// fallback to first characters, in this case "en"
try
{
var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage));
ci = new CultureInfo(fallback);
}
catch (CultureNotFoundException)
{
// iOS language not valid .NET culture, falling back to English
ci = new CultureInfo("en");
}
}
return ci;
}
public void SetLocale(CultureInfo ci)
{
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = ci;
}
string AndroidToDotnetLanguage(string androidLanguage)
{
var netLanguage = androidLanguage;
//certain languages need to be converted to CultureInfo equivalent
switch (androidLanguage)
{
case "ms-BN": // "Malaysian (Brunei)" not supported .NET culture
case "ms-MY": // "Malaysian (Malaysia)" not supported .NET culture
case "ms-SG": // "Malaysian (Singapore)" not supported .NET culture
netLanguage = "ms"; // closest supported
break;
case "in-ID": // "Indonesian (Indonesia)" has different code in .NET
netLanguage = "id-ID"; // correct code for .NET
break;
case "gsw-CH": // "Schwiizertüütsch (Swiss German)" not supported .NET culture
netLanguage = "de-CH"; // closest supported
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
string ToDotnetFallbackLanguage(PlatformCulture platCulture)
{
var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually);
switch (platCulture.LanguageCode)
{
case "gsw":
netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
}
}
Z urządzenia dostaniemy kod kraju w postaci en_US – z podkreślnikiem zamiast myślnika. Dlatego też w pierwszej kolejności trzeba to zmienić.
Następnie trzeba spróbować utworzyć CultureInfo z przekazanym kodem. Niestety, może się okazać że z urządzenia otrzymamy kod, którego nie ma w .NET (jak wyżej wspomniany en-ES). I tu wchodzi do roboty klasa PlatformCulture, która po prostu sparsuje odpowiednio kod kraju i zwróci tylko identyfikator języka (np. „en”).
Ten kod również pochodzi z oficjalnej dokumentacji Microsoftu. Przyjrzyj się jeszcze linijce nr 5:
To po prostu mechanizm DependencyInjection, którym posłużymy się jeszcze za chwilę. I którym posługujemy się w konstruktorze Xamarinowego LocalizeExtension. Ten atrybut automagicznie rejestruje klasę.
Implementacja ILocaleService na iOS
Nie musisz tego robić, jeśli nie piszesz aplikacji pod iOS. Jeśli jednak ma działać na jabłuszku, jest to konieczne. Poniższy kod jest podobny do tego z Androida i też pochodzi z oficjalnej dokumentacji Microsoftu:
assembly: Xamarin.Forms.Dependency(typeof(Xamarin.iOS.Services.LocaleService))]
namespace Xamarin.iOS.Services
{
//https://docs.microsoft.com/en-us/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=windows
public class LocaleService : ILocaleService
{
public CultureInfo GetCurrentCultureInfo()
{
var netLanguage = "en";
if (NSLocale.PreferredLanguages.Length > 0)
{
var pref = NSLocale.PreferredLanguages[0];
netLanguage = iOSToDotnetLanguage(pref);
}
// this gets called a lot - try/catch can be expensive so consider caching or something
CultureInfo ci = null;
try
{
ci = new CultureInfo(netLanguage);
}
catch (CultureNotFoundException )
{
// iOS locale not valid .NET culture (eg. "en-ES" : English in Spain)
// fallback to first characters, in this case "en"
try
{
var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage));
ci = new CultureInfo(fallback);
}
catch (CultureNotFoundException )
{
// iOS language not valid .NET culture, falling back to English
ci = new CultureInfo("en");
}
}
return ci;
}
public void SetLocale(CultureInfo ci)
{
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = ci;
}
string iOSToDotnetLanguage(string iOSLanguage)
{
// .NET cultures don't support underscores
string netLanguage = iOSLanguage.Replace("_", "-");
//certain languages need to be converted to CultureInfo equivalent
switch (iOSLanguage)
{
case "ms-MY": // "Malaysian (Malaysia)" not supported .NET culture
case "ms-SG": // "Malaysian (Singapore)" not supported .NET culture
netLanguage = "ms"; // closest supported
break;
case "gsw-CH": // "Schwiizertüütsch (Swiss German)" not supported .NET culture
netLanguage = "de-CH"; // closest supported
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
string ToDotnetFallbackLanguage(PlatformCulture platCulture)
{
var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually);
switch (platCulture.LanguageCode)
{
case "pt":
netLanguage = "pt-PT"; // fallback to Portuguese (Portugal)
break;
case "gsw":
netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
}
}
Ustawienie kultury
Teraz w pliku App.xaml.cs w projekcie Xamarin powinieneś dodać gdzieś podczas inicjalizacji taki fragment kodu:
if(Device.RuntimePlatform == Device.iOS || Device.RuntimePlatform == Device.Android)
{
var ci = DependencyService.Get<ILocaleService>().GetCurrentCultureInfo();
LangRes.Culture = ci;
DependencyService.Get<ILocaleService>().SetLocale(ci);
}
Gdzie LangRes to Twoja klasa z zasobami utworzona przez VisualStudio. Wszystko to ma na celu zapewnienie poprawnego działania lokalizacji na urządzeniach z Androidem i iOS.
Jak tłumaczyć w XAML?
Tutaj sprawa wygląda już dokładnie tak samo jak przy WPF. Wystarczy, że zadeklarujesz alias na swój namespace w pliku xaml:
gdzie MojaAplikacja to namespace do klasy LocalizeExtension; następnie w kodzie:
<Label Text="{app:Localize Receipt}"/>
gdzie Receipt to po prostu klucz z zasobów językowych.
To na tyle jeśli chodzi o tłumaczenia aplikacji na urządzeniach mobilnych. Jeśli używasz jakiegoś innego sposobu lub znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂
To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach WinForms i WPF. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.
Tłumaczenia w WinForms
W WinForms nie ma żadnego zmyślnego sposobu na ładowanie tłumaczeń. Po prostu każdemu przyciskowi, labelowi itd musisz zmienić TEXT w runtime. Chyba, że wymyślisz swój własny sposób, który zadziała automatycznie. Ale chyba więcej z tym nerwów niż pożytku.
Generalnie robisz to dokładnie tak samo, jak robiłbyś to w konsoli – dokładnie tak jak opisane w artykule podstawowym.
Pamiętaj, żeby domyślnie nadawać teksty w języku angielskim – wtedy jeśli czegoś nie przetłumaczysz, użytkownicy zobaczą teksty w tym właśnie języku.
Tłumaczenia w WPF
W WPF skorzystamy z ustrojstwa, co się zowie MarkupExtension. Jeśli nie wiesz co to, to odsyłam do netu: „WPF markup extension”, być może kiedyś opiszę ten mechanizm.
W skrócie – to coś, dzięki czemu możesz tworzyć własne tagi XAML. Coś jak {Binding...}
Teraz popatrz na ten fragment kodu:
<GroupBox Header="{app:Localize Receipt}" />
Tutaj widzisz mój markup extension – Localize. Efektem tego kodu będzie pobranie zasobu o kluczu Receipt i wartość tego zasobu będzie widoczna w nagłówku GroupBoxa – w Polsce: „Paragon”, wszędzie indziej: „Receipt”. Super? Ja się jaram 🙂
A teraz zobaczmy, jak coś takiego osiągnąć. Generalnie łatwo, prosto i przyjemnie…
MarkupExtension do tłumaczeń
Stwórz taką klasę najlepiej gdzieś w projekcie WPF:
[ContentProperty("ResId")]
class LocalizeExtension : MarkupExtension
{
public string ResId { get; set; }
public LocalizeExtension()
{
}
public LocalizeExtension(string ResId)
{
this.ResId = ResId;
}
public override object ProvideValue(IServiceProvider serviceProvider)
{
if (string.IsNullOrWhiteSpace(ResId))
return "<???>";
string result = LangRes.ResourceManager.GetString(ResId);
if (string.IsNullOrEmpty(result))
return $"<? {ResId} ?>";
else
return result;
}
}
Wystarczy napisać klasę, która dziedziczy po MarkupExtension.
W linijce 1 podajesz domyślną właściwość… Tzn. gdybym tego nie zrobił, musiałbym kod w XAML napisać tak:
<GroupBox Header="{app:Localize ResId=Receipt}"/> //zwróć uwagę na obecność ResId
Klasa ma jedną metodę – ProvideValue i to w niej dzieje się cała magia. Po prostu pobieram stringa z zasobów na podstawie przekazanego klucza.
Ja tu sobie zrobiłem taki myk, że w razie jakbym nie dodał jakiegoś tłumaczenia, wtedy zamiast konkretnego stringa (którego nie ma w zasobach) zobaczę nazwę tego klucza w nawiasach ostrych. Dzięki temu wiem, że danego tłumaczenia nie ma w zasobach. Pozwalam sobie na taką nonszalancję, bo sprawdzam każde okienko, które robię.
I to właściwie tyle. Po utworzeniu takiej klasy, możesz skompilować projekt i używać swojego markup extension.
Być może nie wiesz skąd się bierze to app w kodzie:
<GroupBox Header="{app:Localize Receipt}"/>
To po prostu alias na namespace, w którym masz swoją klasę LocalizeExtension. Musisz go oczywiście zadeklarować na początku pliku XAML analogicznie do innych, np:
gdzie MojaAplikacja to namespace, w którym znajduje się Twoja klasa. Oczywiście to nie musi być app. To może być cokolwiek, ale mam nadzieję, że to wiesz.
To wszystko jeśli chodzi o tłumaczenie aplikacji desktopowych. Jeśli masz jakieś inne pomysły, podziel się w komentarzu.
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.:
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 Action – Embedded Resource – oznacza, że ten plik ma być wbudowanym zasobem – stanie się częścią Twojej dllki lub execa. Copy to output directory – Do 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:
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:
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie