Klasa, czy struktura

Klasa, czy struktura

Wstęp

W tym artykule wyjaśnię Ci, kiedy używać klasy, a kiedy struktury w C#. Granica czasem wydaje się bardzo rozmyta, ale są pewne zasady, które pomagają wybrać tę „lepszą” w danej sytuacji drogę. Zazwyczaj jednak jest to klasa.

Żeby lepiej zrozumieć różnice między nimi, muszę Cię zapoznać z takimi pojęciami jak stos, sterta, typ referencyjny i typ wartościowy. Jeśli znasz dobrze te zagadnienia, możesz przejść od razu na koniec artykułu.

Obszary w pamięci – stos i sterta

Pamięć programu jest zazwyczaj dzielona na stos i stertę. Wyobraź sobie to dokładnie w taki sposób. Możesz mieć w kuchni stos talerzy (jeden na drugim), a w pokoju stertę pierdół (rzeczy rozsypane w nieładzie wszędzie). Każdy z tych obszarów jest przeznaczony do różnych zastosowań.

Stos

Stos (ang. stack) przechowuje zmienne lokalne, parametry metody i wartości zwracane – generalnie dane krótko żyjące. To jest pewne uproszczenie, ale na potrzeby tego artykułu przyjmijmy to za pewnik.

Pamięć na stosie jest zarządzana automatycznie przez system operacyjny. Jest też mocno ograniczona (w zależności od tego systemu). Dlatego też, jeśli próbujesz na stosie umieścić zbyt wiele danych, dostaniesz błąd „przepełnienie stosu” (stack overflow). Z tego wynika, że możesz mieć ograniczoną ilość zmiennych lokalnych, czy też parametrów metody. Jednak w dzisiejszych czasach ten błąd sugeruje problem z rekurencją (czego wynikiem jest właśnie przepełnienie stosu).

Dane ze stosu są ściągane po wyjściu z jakiegoś zakresu – np. po wyjściu z metody lub jakiegoś bloku. No i wkładane są na stos podczas wchodzenia do bloku (metody).

Sterta

Sterta (ang. heap) jest dynamicznym obszarem pamięci. Nie ma takich limitów jak stos. Sam możesz decydować ile chcesz pamięci zarezerwować na stercie i co tam przechowywać. W językach bez pamięci zarządzalnej (C, C++, Delphi) sam musisz też troszczyć się o to, żeby usuwać dane ze sterty w odpowiednim momencie. W C# troszczy się o to zazwyczaj Garbage Collector (w specyficznych rozwiązaniach też programista musi się tym zająć).

Sterta jest przeznaczona do przechowywania danych długożyjących. A także takich, których rozmiar nie jest znany w momencie kompilacji programu.

Typ referencyjny i wartościowy

W C# mamy dwa rodzaje typów – referencyjne i wartościowe. Wszystkie typy proste (int, double, float, bool itd.) oraz struktury są typami wartościowymi. Typy referencyjne to klasy lub… referencje. Ale o tych drugich nie będziemy za bardzo tutaj mówić 🙂

Czyli podsumowując: klasy – typy referencyjne; struktury i typy proste – typy wartościowe (pamiętaj, że string nie jest typem prostym. Jego wartość jest przechowywana na stercie).

To rozróżnienie sprowadza się do kilku rzeczy.:

Struktury (typy wartościowe)

  1. Przechowywane są w pamięci na stosie (to pewne uproszczenie, ale na razie potraktuj je za pewnik)
  2. Automatycznie tworzone i zwalniane przez system operacyjny

Klasy (typy referencyjne)

  1. Przechowywane w pamięci na stercie
  2. Programista sam musi utworzyć obiekt takiego typu
  3. Typ referencyjny jest zwalniany przez Garbage Collector (w C# programista bardzo rzadko musi taki typ sam zwolnić)

Ubrudźmy sobie ręce

Teraz z użyciem debuggera pokażę Ci dokładnie jak to wygląda od praktycznej strony w pamięci.

Spójrz na poniższy kod:

static void Main(string[] args)
{
    int number = 5;
}

Na początku tworzymy zmienną typu int. A właściwie robi to za nas system operacyjny. To jest zmienna lokalna, a więc jest tworzona automatycznie na stosie. Co to oznacza? Że w momencie wejścia do metody Main system tworzy stos dla tego bloku* i dodaje do niego wszystkie zmienne lokalne i parametry metody. Stos w tym momencie wygląda tak:

A tak to widać w debugerze:

*Dla czepialskich, dociekliwych i maruderów

Stos nie jest tak naprawdę tworzony za każdym razem – to skrót myślowy. Istnieje pojęcie ramki stosu (stack frame). Ramka stosu to fragment pamięci wydzielony jako… „stos” dla danej metody, czy zakresu. Po wejściu/wyjściu z tej metody zmienia się aktualna ramka stosu na inną (a konkretnie rejestr procesora *SP wskazuje na inny obszar w pamięci jako początek stosu). Na stosie jest oczywiście więcej informacji niż te tutaj pokazane (m.in. parametr args), ale zostały pominięte ze względu na czytelność i nie mają związku z artykułem.

Jeśli nie wiesz, co tu widzisz, to już tłumaczę.

W górnym okienku jest uruchomiony program w trybie debugowania. W dolnym widzimy podgląd fragmentu pamięci programu. Zmienna number dostała konkretny adres w pamięci: 0x000000C4F657E52C. Jak widzisz, pod tym adresem znajdują się jakieś dane: 05 00 00 00. Te 4 bajty mają wartość 5. Widzimy też, że żeby zapisać zmienną typu int, potrzebujemy do tego 4 bajtów.

A teraz co się stanie, jeśli będziemy mieć dwie zmienne lokalne?

static void Main(string[] args)
{
    int number = 5;
    int score = 1;
}

Stos będzie wyglądał tak:

A w debugerze zobaczymy:

Jak widać z rysunku i debugera, stos rośnie „w górę”. Są na nim odkładane poszczególne wartości.

A jeśli teraz dodamy do tego strukturę?

struct Point3d
{
    public int x;
    public int y;
    public int z;
}
internal class Program
{
    static void Main(string[] args)
    {
        int number = 5;
        int score = 1;
        Point3d point;
        point.x = 2;
        point.y = 3;
        point.z = 4;
    }
}

Teraz stos wygląda tak:

Robi się gęsto. Na wierzchu stosu mamy wszystkie składowe struktury – x, y i z. Potem jakiś pusty element – tutaj oznaczony na niebiesko, a potem to co już było – zmienne score i number.

Z tego obrazka wynika kilka rzeczy:

  1. Nie musisz tworzyć struktury przez new, bo skoro jest ona tworzona na stosie, to tworzy ją tam system operacyjny automatycznie – a o tym pisałem wcześniej – dane na stosie są tworzone automatycznie przez system operacyjny.
  2. Struktura jest niczym innym jak workiem na dane.

Czym jest ten pusty, niebieski element? Padding – tylko dla dociekliwych

Spójrz na cztery ostatnie „niebieskie” bajty struktury: 00 00 00 00. Co reprezentują? Zupełnie nic. To jest tzw. padding. Padding to mechanizm, który polega na dopełnieniu wielkości struktury do „naturalnej” wielkości adresowej. Czyli wielokrotności 4 (32 bitowy system) lub 8 (64 bitowy system) bajtów. To znaczy, że domyślnie wielkość wszystkich struktur jest podzielna przez 4 lub 8. Padding niekoniecznie występuje na końcu struktury. Będzie raczej występował po konkretnych jej elementach. Np. strukturę:

struct MyStruct
{
    public bool b;
    public int i;
}

gdzieś tam na niskim pozimie zapisalibyśmy tak:

struct MyStruct
{
    public bool b;
    byte[3] _padding_b;
    public int i;
}

To nie jest domena C#, to jest domena wszystkich znanych mi języków programowania.

Po co ten padding? Dzięki niemu pewne rzeczy mogą działać szybciej. Nie będę zagłębiał się w szczegóły, ale uwierz mi na słowo, że procesorowi łatwiej przenieść w inne miejsce 4 bajty niż 3.

Istnieje też w pewnym sensie nieco odwrotna operacja – pack. Powoduje ona usunięcie tego paddingu. To się przydaje gdy np. Twoja struktura lata pomiędzy bibliotekami dll pisanymi w różnych językach. Wtedy „pakowanie” ich znaaaaacznie ułatwia życie programiście.

Czym jest typ wartościowy?

Skoro już wiesz, że struktura i typy proste są typami wartościowymi, to teraz zadajmy sobie pytanie – czym do cholery są te typy wartościowe?

Typ wartościowy jest domyślnie przekazywany do metody przez wartość. Co to oznacza? Powinieneś już wiedzieć, jednak jeśli masz zaległości w temacie, już tłumaczę. Spójrz na poniższy kod:

static void Main(string[] args)
{
    int number = 5;

    Foo(number);
    Console.WriteLine($"number: {number}");
}

static void Foo(int data)
{
    data = 1;
}

Co tu się dzieje? Najpierw tworzymy zmienną lokalną number. Jak już wiesz, zmienna lokalna jest tworzona na stosie. Przed wejściem do metody Foo pamięć wygląda tak:

Teraz wchodzimy do metody Foo. I komputer widzi, że przekazujemy zmienną przez wartość, a więc najpierw przydziela miejsce dla parametru data, następnie KOPIUJE zawartość zmiennej number do zmiennej data. I tutaj „kopiuje” jest słowem kluczem. Po przypisaniu wartości data = 1 pamięć wygląda tak (patrzymy na większą część pamięci niż stos dla metody):

I teraz widzisz dokładnie, że w metodzie Foo pracujemy na kopii zmiennej number, a nie na zmiennej number. A właściwie na kopii jej wartości. Zmienna number cały czas „wskazuje” na zupełnie inny obszar w pamięci. Dlatego też po wyjściu z metody Foo zmienna number cały czas będzie miała warość 5.

Tak działa przekazywanie zmiennych przez wartość. I tak też są przekazywane struktury. Jaka jest tego konsekwencja? Jeśli masz strukturę składającą się z 10 zmiennych typu int (załóżmy 40 bajtów), to podczas wchodzenia do funkcji (lub wychodzenia z niej – jeśli dane zwracasz) te 40 bajtów jest kopiowane. W pewnych sytuacjach może się to odbić na szybkości działania programu.

Czym jest typ referencyjny?

Przekazywanie wartości przez referencję

Zacznę od tego, że w C# (i w innych językach też) możesz przekazać typ wartościowy za pomocą referencji. W C# robi się to za pomocą słowa ref lub out. Spójrz na ten przykład:

static void Main(string[] args)
{
    int number = 5;

    Foo(ref number);
    Console.WriteLine($"number: {number}");
}

static void Foo(ref int data)
{
    data = 1;
}

Dodałem tu jedynie słowa kluczowe ref, które mówią kompilatorowi, że tę wartość trzeba przekazać przez referencję. Co to znaczy? Że nie zostanie utworzona nowa zmienna w pamięci i nie zostaną skopiowane do niej dane. Po prostu komputer tak uruchomi metodę Foo:

Hej Foo, mam dla Ciebie dane w pamięci o adresie 0xblabla. Pracuj bezpośrednio na tych danych. Uruchom się.

I metoda Foo będzie pracować bezpośrednio na tych danych:

W konsekwencji pracujemy na tej samej pamięci, na którą wskazuje zmienna number. A więc zmienna number po wyjściu z metody Foo będzie miała już inną wartość.

Strukturę też możesz przekazać przez referencję dokładnie w taki sam sposób.

Typ referencyjny

No dobrze, dochodzimy wreszcie do tego, czym jest typ referencyjny. W C# każda klasa jest typem referencyjnym. Spójrz na ten kod:

class MyClass
{
    public int Data { get; set; }
}

internal class Program
{
    static void Main(string[] args)
    {
        MyClass obj;
    }
}

Stos dla tego kodu będzie wyglądał tak:

Co widać w debugerze:

Obiekt tej klasy nie został utworzony. Klasa jest typem referencyjnym, a typy referencyjne nie są tworzone przez system operacyjny. Dlaczego? Ponieważ są to elementy „dynamiczne” – są tworzone w „dynamicznej” części pamięci – na stercie. Jak pisałem na początku, to programista odpowiada za tworzenie obiektów na stercie. I to programista musi przydzielić pamięć takiemu obiektowi. W C# robimy to za pomocą słowa new. Stwórzmy więc taki obiekt i zobaczmy co się stanie:

internal class Program
{
    static void Main(string[] args)
    {
        MyClass obj = new MyClass();
        obj.Data = 5;
    }
}

Tworząc nowy obiekt najpierw musimy przydzielić mu miejsce na stercie. Takimi szczegółami zajmuje się już system operacyjny. Najpierw szuka odpowiedniego miejsca na stercie, które będzie na tyle duże, żeby pomieścić nasz obiekt, następnie rezerwuje tam pamięć dla niego, a na koniec adres do tej pamięci zwraca do zmiennej obj:

W debugerze stos teraz wygląda tak:

Tutaj zmienna obj jest na stosie i wskazuje na adres: 0x000001af16552cc0. A teraz spójrzmy, co pod tym adresem się znajduje:

Pod tym adresem znajdują się jakieś dane należące do tego konkretnego obiektu. Jest ich więcej niż tylko nasza zmienna int, ale nie jest istotne teraz co zawierają, istotne jest, że znajdują się na stercie.

Potem, gdy zmienna obj nie będzie już nigdzie używana, Garbage Collector zwolni to miejsce na stercie, które zajmuje ten obiekt.

Przekazanie obiektu do metody

Skoro klasy są typami referencyjnymi, to pewnie już się domyślasz, że obiekty są domyślnie przekazywane przez referencję. I dokładnie tak jest. Skoro zmienna obj posiada tylko adres, to ten adres zostanie przekazany do metody Foo:

static void Main(string[] args)
{
    MyClass obj = new MyClass();
    obj.Data = 5;

    Foo(obj);
    Console.WriteLine(obj.Data);
}

static void Foo(MyClass data)
{
    data.Data = 1;
}

Metoda Foo teraz wie, że dane ma pod konkretnym adresem. I na tych danych pracuje. Po wyjściu z metody Foo obj.Data będzie teraz miało wartość 1.

Dla dociekliwych – kopiowanie…

To nie jest tak, że w momencie wejścia do metody Foo nie jest robiona żadna kopia. Jest. Bo musi być. Bo metoda Foo ma własny stos i własne dane na tym stosie. A jedną z tych danych jest parametr data. Tyle, że ten parametr jest referencją, a więc przechowuje adres to obszaru na stercie. Zatem kopiowany jest tylko ten adres – te kilka bajtów. Niezależnie od tego jak wielka jest klasa i co w niej masz, jest tylko kopiowane te 8 bajtów (lub 4 w zależności od bitowości systemu operacyjngeo).

Użycie pamięci

Jak już wiesz, klasy są tworzone na stercie, a struktury na stosie.

Alokacja pamięci na stercie jest „droższa” od alokacji pamięci na stosie. Przede wszystkim trzeba znaleźć odpowiednio duży obszar. A na koniec Garbage Collector musi posprzątać. Jednak nie myśl o tym przy normalnym tworzeniu aplikacji – to zazwyczaj nie jest problemem. Chyba, że pracujesz nad naprawdę jakimś hiper krytycznym systemem. Ale wtedy nie czytałbyś tego artykułu 😉

Jednak pojawia się pewien mały szczegół podczas przekazywania typów wartościowych przez referencje. Ten szczegół to boxing i unboxing i jest pomysłem na inny artykuł. Generalnie chodzi o to, że typ wartościowy jest „opakowywany” w jakąś klasę, której obiekt jest tworzony na stercie. Później ten obiekt jest sprzątany przez Garbage Collector.

Jednak jeśli robisz dużo takich operacji (przekazywanie przez referencję), wtedy dużo dzieje się boxingów i unboxingów. To może mieć już wpływ na ogólną szybkość działania aplikacji.

Ogólnie przyjęte zasady

Z powyższych wynikają pewne ogólnie przyjęte zasady:

ZAWSZE STOSUJ KLASY – to jest obywatel lepszego sortu. Są jednak pewne wytyczne co do tego, kiedy bardziej opłaca stosować się struktury.

Rozważ (czytaj „możesz rozważyć) użycie struktury, gdy

  • struktura jest mała (poniżej 16 bajtów)
  • dane są niezmienne (przypisujesz tylko w konstruktorze i już ich nie zmieniasz)
  • nie będziesz przekazywał jej przez referencję
  • zawiera tylko typy proste
  • żyje krótko
  • reprezentuje „jedną fizyczną daną”, np. liczbę zespoloną, datę, punkt.

Ufff, ze trzy razy podchodziłem do tego artykułu. Mam nadzieję, że rozwiałem wątpliwości raz na zawsze, kiedy w C# stosować struktury, a kiedy klasy. Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu.

Aha, no i udostępnij artykuł osobom, które ciągle się zastanawiają nad tematem 🙂

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