Tłumaczenie aplikacji internetowych – RAZOR

Tłumaczenie aplikacji internetowych – RAZOR

Wstęp

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.

Napiszę bardzo ogólnie jak to działa, dużo lepiej jest to opisane w artykule o middleware pipeline 🙂

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:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
}

Dla takiego kodu pipeline będzie prawie pusty:

Dodajmy teraz przekierowanie https:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   app.UseHttpsRedirection();
   app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
}

Teraz pipeline będzie wyglądał tak:

Żądanie przejdzie najpierw przez HttpsRedirection, który może sobie na nim pracować i może przekazać wywołanie do kolejnego middleware (ale wcale nie musi). Żądanie może następnie trafić do RouterMiddleware, który wie, jaką stronę ma pokazać. Następnie generowana jest odpowiedź, która przechodzi przez middleware’y w odwrotnej kolejności (w tym momencie nie można już zmodyfikować nagłówków).

Widzisz zatem, że kolejność dodawania middleware’ów do pipeline jest istotna, a nawet dokładnie opisana na stronie Microsoftu: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-6.0#middleware-order . No bo co się stanie, jeśli dodasz autoryzację za routerem? Autoryzacja zadziała dopiero za routerem, a więc użytkownik zobaczy już stronę, której nie powinien.

Konfiguracja języków

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:

  • przekazujemy domyślną kulturę (DefaultRequestCulture)
  • przypisujemy wspierane kultury UI i zwykłe

Taka konfiguracja daje nam dostęp do:

  • 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 RequestLocalizationOptions na 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:

app.MapControllerRoute(
    name: "default",
    pattern: "{culture=en-US}/{controller=Home}/{action=Index}/{id?}");

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:

@using Microsoft.AspNetCore.Mvc.Localization
@inject IStringLocalizer<LangRes> SharedLocalizer
@inject IStringLocalizer<WebLangRes> WebLocalizer

<h>@WebLocalizer[nameof(WebLangRes.HelloMsg)]</h>

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.:

<p>@SharedLocalizer[nameof(LangRes.Contact)]
  <mail prompt="@WebLocalizer[nameof(WebLangRes.MailPrompt)].Value" />
</p>

IHtmlLocalizer

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.

Podziel się artykułem na:
Uwierzytelnianie (autentykacja) w .NET

Uwierzytelnianie (autentykacja) w .NET

Wstęp

Ten artykuł opisuje mechanizm uwierzytelniania w .NET BEZ użycia Microsoft Identity

Na szybko (kliknij, żeby rozwinąć)

  1. Stwórz projekt RazorPages lub Razor MVC z opcją Authentication type ustawioną na NONE
  1. W pliku Project.cs (lub Startup.cs) dodaj serwis autentykacji
using Microsoft.AspNetCore.Authentication.Cookies;
//

builder.Services.AddAuthentication(o =>
{
    o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
    o.Cookie.IsEssential = true;
    o.Cookie.HttpOnly = true; //bezpieczeństwo
    o.Cookie.SameSite = SameSiteMode.Strict; //bezpieczeństwo
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always; //bezpieczeństwo
    o.Cookie.MaxAge = TimeSpan.FromDays(30); //ciastko logowania ważne przez 30 dni
    o.AccessDeniedPath = "/AccessDenied";
    o.LoginPath = "/Login";
    o.ReturnUrlParameter = "return_url";
});
  1. Dodaj uwierzytelnianie do middleware -> pamiętaj, że kolejność jest istotna
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

Teraz już możesz logować i wylogowywać użytkowników.

Logowanie
  1. Zidentyfikuj użytkownika ręcznie – po prostu w jakiś sposób musisz sprawdzić, czy podał prawidłowe dane logowania (login i hasło)
  2. Stwórz ClaimsPrincipal dla tego użytkownika
  3. Wywołaj HttpContext.SignIn -> to utworzy ciastko logowania i użytkownik będzie już uwierzytelniony w kolejnych żądaniach (HttpContext.User będzie zawierało wartość utworzoną w kroku 2)
Wylogowanie

Wywołaj HttpContext.SignOutAsync -> to zniszczy ciastko logowania. W kolejnych żądaniach obiekt HttpContext.User będzie pusty.

Jeśli masz jakiś problem, przeczytaj pełny artykuł poniżej.

UWAGA

W słowniku języka polskiego NIE ISTNIEJE słowo autentykacja. W naszym języku ten proces nazywa się uwierzytelnianiem. Słowo autentykacja zostało zapożyczone z angielskiego authentication. Dlatego też w tym artykule posługuję się słowem uwierzytelnianie.

Po co komu uwierzytelnianie bez Identity?

Może się to wydawać dziwne, no bo przecież Identity robi całą robotę. Ale jeśli chcesz uwierzytelniać użytkowników za pośrednictwem np. własnego WebApi albo innego mechanizmu, który z Identity po prostu nie współpracuje, to nie ma innej możliwości.

Uwierzytelnianie vs Identity

Musisz zdać sobie sprawę, że mechanizm uwierzytelniania i Identity to dwie różne rzeczy. Identity korzysta z uwierzytelniania, żeby mechanizm był pełny. A jakie są różnice?

Co daje Identity

Od Identity dostajesz CAŁĄ obsługę użytkownika. Tzn:

  • zarządzanie kontami użytkowników (tworzenie, usuwanie, tokeny, dwustopniowe uwierzytelnianie itd.)
  • przechowywanie użytkowników (np. tworzenie odpowiednich tabel w bazie danych lub obsługa innego sposobu przechowywania danych użytkowników)
  • zarządzanie rolami użytkowników
  • i generalnie wiele innych rzeczy, które mogą być potrzebne w standardowej aplikacji

Mechanizm Identity NIE JEST dostępny na „dzień dobry”. Aby go otrzymać, możesz utworzyć nową aplikację z opcją Authentication type ustawioną np. na Individual Accounts.

Możesz też doinstalować odpowiednie NuGety i samemu skonfigurować Identity.

Co daje uwierzytelnianie?

  • tworzenie i usuwanie ciasteczek logowania (lub innego mechanizmu uwierzytelniania użytkownika)
  • tworzenie obiektu User w HttpContext podczas żądania
  • przekierowania użytkowników na odpowiednie strony (np. logowania, gdy nie jest zalogowany)

Jak widzisz, Identity robi dużo więcej i pod spodem korzysta z mechanizmów uwierzytelniania. Podczas konfiguracji Identity konfigurujesz również uwierzytelnianie.

Konfiguracja uwierzytelniania

Najprościej będzie, jeśli utworzysz projekt BEZ żadnej identyfikacji. Po prostu podczas tworzenia nowego projektu upewnij się, że opcja Authentication type jest ustawiona na NONE:

Dzięki temu nie będziesz miał dodanego ani skonfigurowanego mechanizmu Identity. I dobrze, bo jeśli go nie potrzebujesz, to bez sensu, żeby zaciemniał i utrudniał sprawę. Mechanizm Identity możesz sobie dodać w każdym momencie, instalując odpowiednie NuGety.

A teraz jak wygląda konfiguracja uwierzytelniania? Składa się tak naprawdę z trzech etapów:

  • zarejestrowania serwisów dla uwierzytelniania
  • konfiguracji mechanizmu, który będzie używany do odczytywania (zapisywania) informacji o zalogowanym użytkowniku (schematu)
  • dodanie uwierzytelniania do middleware pipeline.

Schemat

Zanim pójdziemy dalej, wyjaśnię Ci czym jest schemat. To nic innego jak określenie sposobu w jaki użytkownicy będą uwierzytelniani. Różne scenariusze mogą wymagać różnych metod uwierzytelniania. Każda z tych metod może wymagać innych danych. To jest właśnie schemat. Pisząc uwierzytelniać mam na myśli taki flow (w skrócie):

Work-flow mechanizmu autentykacji
  1. Klient wysyła żądanie do serwera (np. żądanie wyświetlenia strony z kontem użytkownika)
  2. Mechanizm uwierzytelniania (który jest w middleware pipeline) rusza do roboty. Sprawdza, czy użytkownik jest już zalogowany, odczytując jego dane wg odpowiedniego schematu (z ODPOWIEDNIEGO ciastka, bearer token’a, BasicAuth lub jakiegokolwiek innego mechanizmu)
  3. Na podstawie informacji odczytanych w punkcie 2, tworzony jest HttpContext.User
  4. Rusza kolejny komponent z middleware pipeline

Każdy schemat ma swoją własną nazwę, możesz tworzyć własne schematy o własnych nazwach jeśli czujesz taką potrzebę.

Rejestracja serwisów uwierzytelniania

W pliku Program.cs lub Startup.cs (w metodzie ConfigureServices) możesz zarejestrować wymagane serwisy w taki sposób:

builder.Services.AddAuthentication();

To po prostu zarejestruje standardowe serwisy potrzebne do obsługi uwierzytelniania. Jednak bardziej przydatną formą rejestracji jest ta ze wskazaniem domyślnych schematów:

builder.Services.AddAuthentication(o =>
{
    o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
});

W powyższym kodzie ustawiam domyślne schematy do:

  • Uwierzytelniania
  • Challenge
  • Logowania (tworzenia ciastka logowania)
  • Wylogowania (usuwania ciastka wylogowania)

Jak już wiesz, każdy schemat ma swoją nazwę. W .NET domyślne nazwy różnych schematów są zapisane w stałych. Np. domyślna nazwa schematu opartego na ciastkach (uwierzytelnianie ciastkami) ma nazwę zapisaną w CookieAuthenticationDefaults. Analogicznie domyślna nazwa schematu opartego na JWT Bearer Token – JwtBearerDefaults.

Oczywiście, jeśli masz taką potrzebę, możesz nadać swoją nazwę.

Konfiguracja ciasteczka logowania

To drugi krok, jaki trzeba wykonać. Konfiguracja takiego ciastka może wyglądać tak:

using Microsoft.AspNetCore.Authentication.Cookies;
//
builder.Services.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
    o.Cookie.IsEssential = true;
    o.Cookie.HttpOnly = true;
    o.Cookie.SameSite = SameSiteMode.Strict;
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    o.Cookie.MaxAge = TimeSpan.FromDays(30);
    o.AccessDeniedPath = "/AccessDenied";
    o.LoginPath = "/Login";
    o.ReturnUrlParameter = "return_url";
});

W pierwszym parametrze podajesz nazwę schematu dla tego ciastka. W drugim ustawiasz domyślne opcje. Jeśli nie wiesz co one oznaczają i dlaczego tak, a nie inaczej, przeczytaj artykuł o ciastkach, w którym to wyjaśniam.

Na koniec ustawiasz dwie ścieżki:

  • ścieżka do strony z informacją o zabronionym dostępie
  • ścieżka do strony logowania

a także parametr return_url – o nim za chwilę.

Po co te ścieżki? To ułatwienie – element mechanizmu uwierzytelniania. Jeśli niezalogowany użytkownik wejdzie na stronę, która wymaga uwierzytelnienia (np. „Napisz nowy post”), wtedy automatycznie zostanie przeniesiony na stronę, którą zdefiniowałeś w LoginPath.

Analogicznie z użytkownikiem, który jest zalogowany, ale nie ma praw dostępu do jakiejś strony (np. modyfikacja użytkowników, do czego dostęp powinien mieć tylko admin) – zostanie przekierowany automatycznie na stronę, którą zdefiniowałeś w AccessDeniedPath.

Dodanie uwierzytelniania do middleware pipeline

Skoro mechanizm uwierzytelniania jest już skonfigurowany, musimy dodać go do pipeline. Pamiętaj, że kolejność komponentów w pipeline jest istotna. Dodaj go tuż przed autoryzacją:

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

To tyle jeśli chodzi o konfigurację. Teraz zacznijmy tego używać…

Logowanie

UWAGA! Nie opisuję tutaj czym jest ViewModel, DataBinding, czy też jak działa HTML. Zakładam, że znasz podstawy pracy z RazorPages lub RazorViews.

Rozwalmy wszystko na części. Najpierw odpowiedz sobie na pytanie „Na czym polega logowanie?”. Logowanie to:

  • uwierzytelnienie użytkownika (sprawdzenie, czy jego login i hasło się zgadzają)
  • zapisanie ciasteczka logowania (lub w jakiś inny sposób przechowanie informacji o tym, że jest zalogowany)

W pierwszym kroku stwórz stronę do logowania. Przykład w RazorPages może wyglądać tak:

C# (serwer):

public class LoginPageModel : PageModel
{
    [BindProperty]
    public string UserName { get; set; } = string.Empty;
    [BindProperty]
    public string Password { get; set; } = string.Empty;

    [BindProperty]
    public bool RememberMe { get; set; }
}

A formularz logowania może wyglądać tak…

HTML (klient)

@page
@model RazorPages_Auth.Pages.LoginPageModel

<form method="post">
    <div class="form-group">
        <label asp-for="@Model.UserName">Nazwa użytkownika</label>
        <input type="text" class="form-control" asp-for="@Model.UserName"/>
    </div>

    <div class="form-group">
        <label asp-for="@Model.Password" >Hasło</label>
        <input type="password" class="form-control" asp-for="@Model.Password" />
    </div>

    <div class="form-check">
        <label asp-for="@Model.RememberMe" class="form-check-label" />
        <input type="checkbox" class="form-check" asp-for="@Model.RememberMe"/>
    </div>

    <div>
        <button type="submit">Zaloguj mnie</button>
    </div>
</form>

To jest zwykły formularz ze stylami bootstrapa. Mamy trzy pola:

  • nazwa użytkownika
  • hasło
  • checkbox – pamiętaj mnie, żeby użytkownik nie musiał logować się za każdym razem

Nie stosuję tutaj żadnych walidacji, żeby nie zaciemniać obrazu.

Obsługa logowania

Teraz trzeba obsłużyć to logowanie – czyli przesłanie formularza. Do modelu strony dodaj metodę OnPostAsync (fragment kodu):

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();

    ApplicationUser loggedUser = Authorize(UserName, Password);
    if(loggedUser == null)
    {
        TempData["Error"] = "Niepoprawne dane logowania!";
        return RedirectToPage();
    }
}

ApplicationUser Authorize(string name, string pass)
{
    if (name == "Admin" && pass == "Admin")
    {
        ApplicationUser result = new ApplicationUser();
        result.UserName = "Admin";
        result.Id = 1;

        return result;
    }
    else
        return null!;
}

W trzeciej linijce walidujemy przekazany w formularzu model. Chociaż w tym przypadku testowym nie ma czego walidować, to jednak pamiętaj o tym.

W linijce 6 następuje próba zalogowania użytkownika. Przykładowa metoda Authorize jest oczywiście beznadziejna, ale zwróć tylko uwagę na to, co robi. W jakiś sposób sprawdza, czy login i hasło są poprawne (np. wysyłając dane do WebAPI). I jeśli tak, zwraca konkretnego użytkownika. Jeśli nie można było takiego użytkownika zalogować, zwraca null.

Zawartość metody Authorize zależy całkowicie od Ciebie. W przeciwieństwie do mechanizmu Identity, tutaj sam musisz stwierdzić, czy dane logowania użytkownika są poprawne, czy nie.

W następnej linijce sprawdzam, czy udało się zalogować użytkownika. Jeśli nie, wtedy ustawiam jakiś komunikat błędu i przeładowuję tę stronę.

A co jeśli użytkownika udało się zalogować? Trzeba stworzyć dla niego ciastko logowania. Ale to wymaga utworzenia obiektu ClaimsPrincipal.

Czym jest ClaimsPrincipal?

Krótko mówiąc, jest to zbiór danych, który przechowuje informacje na temat zalogowanego użytkownika. Pewnie chcesz zadać pytanie – czy to nie może być moja super klasa User? Nie, nie może. ClaimsPrincipal to pewien standardowy sposób przechowywania i przesyłania danych.

Wyobraź sobie, że jesteś strażnikiem w dużej firmie. Teraz podchodzi do Ciebie gość, który mówi, że jest dyrektorem z innej firmy, przyszedł na spotkanie i nazywa się Jan Kowalski. Sprawdzasz jego dowód (uwierzytelniasz go) i stwierdzasz, że faktycznie nazywa się Jan Kowalski. Co więcej, możesz stwierdzić że zaiste jest dyrektorem i przyszedł na spotkanie. Wydajesz mu zatem swego rodzaju dowód tożsamości – to może być identyfikator, którym będzie się posługiwał w Twojej firmie.

Teraz tego gościa możemy przyrównać do ClaimsPrincipal, a identyfikator, który mu wydałeś to ClaimsIdentity (będące częścią ClaimsPrincipal).

Na potrzeby tego artykułu potraktuj to właśnie jako zbiór danych identyfikujących zalogowanego użytkownika.

Tworzenie tożsamości (ClaimsPrincipal)

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();

    ApplicationUser loggedUser = Authorize(UserName, Password);
    if(loggedUser == null)
    {
        TempData["Error"] = "Niepoprawne dane logowania!";
        return RedirectToPage();
    }

    ClaimsPrincipal principal = CreatePrincipal(loggedUser);
}

ClaimsPrincipal CreatePrincipal(ApplicationUser user)
{
    ClaimsPrincipal result = new ClaimsPrincipal();

    List<Claim> claims = new List<Claim>()
    {
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new Claim(ClaimTypes.Name, user.UserName)
    };

    ClaimsIdentity identity = new ClaimsIdentity(claims);
    result.AddIdentity(identity);

    return result;
}

Tutaj tworzymy tożsamość zalogowanego użytkownika i dajemy mu dwa „poświadczenia” – Id i nazwę użytkownika. Mając utworzony obiekt ClaimsPrincipal, możemy teraz utworzyć ciastko logowania. To ciastko będzie przechowywało dane z ClaimsPrincipal:

await HttpContext.SignInAsync(principal);

Pamiętaj, żeby dodać using: using Microsoft.AspNetCore.Authentication;

Teraz niepełny kod wygląda tak:

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();

    ApplicationUser loggedUser = Authorize(UserName, Password);
    if(loggedUser == null)
    {
        TempData["Error"] = "Niepoprawne dane logowania!";
        return RedirectToPage();
    }

    ClaimsPrincipal principal = CreatePrincipal(loggedUser);

    await HttpContext.SignInAsync(principal);
}

Podsumujmy tę część:

  1. Walidujesz model otrzymany z formularza
  2. W jakiś sposób sprawdzasz, czy przekazany login i hasło są prawidłowe – „ręcznie” uwierzytelniasz użytkownika
  3. Na podstawie uwierzytelnionego użytkownika tworzysz obiekt ClaimsPrincipal, który jest potrzebny do utworzenia ciastka logowania.
  4. Tworzysz ciastko logowania. Od tego momentu, w każdym żądaniu, obiekt HttpContext.User będzie miał te wartości, które utworzyłeś w kroku 3. Wszystko dzięki ciastku logowania, które przy każdym żądaniu utworzy ten obiekt na podstawie swoich wartości.

Nie musisz tutaj podawać schematu uwierzytelniania, ponieważ zdefiniowałeś domyślny schemat podczas konfiguracji uwierzytelniania.

Pamiętaj mnie

W powyższym kodzie nie ma jeszcze użytej opcji „Pamiętaj mnie”. Ta opcja musi zostać dodana podczas tworzenia ciastka logowania. Wykorzystamy tutaj przeciążoną metodę SignInAsync, która przyjmuje dwa parametry:

AuthenticationProperties props = new AuthenticationProperties();
props.IsPersistent = RememberMe;

await HttpContext.SignInAsync(principal, props);

Czyli do właściwości IsPersistent przekazałeś wartość pobraną od użytkownika, który powiedział, że chce być pamiętany w tej przeglądarce (true) lub nie (false). O tym właśnie mówi IsPersistent.

Ale ten kod wciąż nie jest pełny.

Przekierowanie po logowaniu

Po udanym (lub nieudanym) logowaniu trzeba gdzieś użytkownika przekierować. Najwygodniej dla niego – na stronę, na którą próbował się dostać przed logowaniem. Spójrz na taki przypadek:

  • niezalogowany użytkownik wchodzi na Twoją stronę, żeby zobaczyć informacje o swoim koncie: https://www.example.com/Account
  • System uwierzytelniania widzi, że ta strona wymaga poświadczeń (gdyż jest opatrzona atrybutem Authorize), a użytkownik nie jest zalogowany. Więc zostaje przekierowany na stronę logowania. A skąd wiadomo gdzie jest strona logowania? Ustawiłeś ją podczas konfiguracji ciastka do logowania.
  • Po poprawnym zalogowaniu użytkownik może zostać przekierowany np. na stronę domową: "/Index" albo lepiej – na ostatnią stronę, którą chciał odwiedzić, w tym przypadku: https://www.example.com/Account

Ale skąd masz wiedzieć, na jaką stronę go przekierować? Spójrz jeszcze raz na konfigurację ciastka logowania:

builder.Services.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
    o.Cookie.IsEssential = true;
    o.Cookie.HttpOnly = true;
    o.Cookie.SameSite = SameSiteMode.Strict;
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    o.Cookie.MaxAge = TimeSpan.FromDays(30);
    o.AccessDeniedPath = "/AccessDenied";
    o.LoginPath = "/Login";
    o.ReturnUrlParameter = "return_url";
});

Jeśli mechanizm uwierzytelniania przekierowuje Cię na stronę logowania, dodaje do adresu parametr, który skonfigurowałeś w ReturnUrlParameter. A więc w tym przypadku "return_url". Ostatecznie niezalogowany użytkownik zostanie przekierowany na taki adres: https://example.com/Login?return_url=/Account

(w przeglądarce nie zauważysz znaku „/”, tylko jego kod URL: %2F)

To znaczy, że na stronie logowania możesz ten parametr pobrać:

public class LoginPageModel : PageModel
{
    [BindProperty]
    public string UserName { get; set; } = string.Empty;
    [BindProperty]
    public string Password { get; set; } = string.Empty;

    [BindProperty]
    public bool RememberMe { get; set; }

    [FromQuery(Name = "return_url")]
    public string? ReturnUrl { get; set; }
  
    //
}

Pamiętaj, że parametru return_url nie będzie, jeśli użytkownik wchodzi bezpośrednio na stronę logowania. Dlatego też zwróć uwagę, żeby oznaczyć go jako opcjonalny – string?, a nie string

Następnie wykorzystaj go podczas logowania:

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();

    ApplicationUser loggedUser = Authorize(UserName, Password);
    if(loggedUser == null)
    {
        TempData["Error"] = "Niepoprawne dane logowania!";
        return RedirectToPage();
    }

    ClaimsPrincipal principal = CreatePrincipal(loggedUser);

    AuthenticationProperties props = new AuthenticationProperties();
    props.IsPersistent = RememberMe;

    await HttpContext.SignInAsync(principal, props);

    if (string.IsNullOrWhiteSpace(ReturnUrl))
        ReturnUrl = "/Index";

    return RedirectToPage(ReturnUrl);
}

UWAGA!

Pamiętaj, żeby w takim przypadku NIE STOSOWAĆ metody Redirect, tylko RedirectToPage (lub w RazorView – RedirectToAction). Metoda Redirect pozwala na przekierowanie do zewnętrznego serwisu, co w tym przypadku daje podatność na atak „Open Redirect”. Dlatego też stosuj RedirectToPage -> ta metoda nie pozwoli na przekierowanie zewnętrzne.

Wylogowanie

Kiedyś użytkownik być może będzie się chciał wylogować. Na czym polega wylogowanie? Na usunięciu ciastka logowania. Robi się to jedną metodą:

await HttpContext.SignOutAsync();

Ta metoda usunie ciastko logowania i w kolejnych żądaniach obiekt HttpContext.User będzie pusty.


To właściwie tyle jeśli chodzi o mechanizm uwierzytelniania. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, daj znać w komentarzu. Jeśli uważasz ten artykuł za przydatny, również daj znać. Będzie mi miło 🙂 I koniecznie zapisz się na newsletter, żeby nic Cię nie ominęło.

Podziel się artykułem na:
Tłumaczenie aplikacji mobilnych – XAMARIN

Tłumaczenie aplikacji mobilnych – XAMARIN

Wstęp

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:

[assembly: Dependency(typeof(Xamarin.Droid.Services.LocaleService))]

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:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:app="clr-namespace:MojaAplikacja"

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 🙂

Podziel się artykułem na:
Tłumaczenie aplikacji desktopowych (WinForms i WPF)

Tłumaczenie aplikacji desktopowych (WinForms i WPF)

Wstęp

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:

<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:app="clr-namespace:MojaAplikacja">

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.

Obrazek komputera w logo tego artykułu dzięki upklyak / Freepik

Podziel się artykułem na:
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: