Hej, mimo że .NET daje Ci kilka gotowych mechanizmów (schematów) uwierzytelniania, to jednak czasem trzeba napisać coś swojego. Takimi przykładami mogą być Basic Authentication albo chociażby Api Key Authentication. Api Key będziesz używał wtedy, kiedy masz swoje API dostępne dla innych programistów, jednak chcesz uwierzytelnić w jakiś sposób każdego klienta, który z Twojego API korzysta.
W tym artykule pokażę Ci jak skonstruować swój własny mechanizm uwierzytelniania. Co więcej – pokażę jak wybrać dynamicznie odpowiedni schemat w zależności od przekazanego żądania. No to jedziemy.
Do artykułu przygotowałem przykładowe kody, które możesz pobrać z GitHub.
Generalnie proces uwierzytelnienia polega na tym, żeby sprawdzić dane identyfikacyjne, które przychodzą od użytkownika (np. w żądaniu HTTP) i wystawić na ich podstawie ClaimsPrincipal.
Najprostszym przykładem będzie właśnie klucz API. Załóżmy, że gdy klient korzysta z Twojego API, powinien w żądaniu wysłać nagłówek X-API-KEY. Jeśli go nie ma, taka osoba jest anonimowa (nie jest uwierzytelniona). Jeśli jest, to sprawdzasz, czy ten klucz jest gdzieś u Ciebie zarejestrowany. Jeśli tak, to na tej podstawie możesz stworzyć odpowiedni obiekt ClaimsPrincipal. Na tym właśnie polega cały proces – uwierzytelnij klienta, czyli zwróć informację na temat KIM ON JEST.
Później ten ClaimsPrincipal jest używany przez mechanizm autoryzacji, który sprawdza, co dany użytkownik może zrobić. No i ten ClaimsPrincipal jest dostępny w kontrolerach w HttpContext.User.
Czym tak naprawdę jest API Key?
Jeśli wystawiasz dla świata jakieś API, to to API może być publiczne (dostęp dla każdego), niepubliczne (dostęp tylko dla zarejestrowanych klientów) lub mieszane, przy czym zarejestrowani klienci mogą więcej.
Jeśli ktoś rejestruje u Ciebie klienta do Twojego API, powinieneś wydać mu tzw. API Key – klucz jednoznacznie identyfikujący takiego klienta. To może być w najprostszej postaci GUID. Po prawdzie klient też powinien dostać od Ciebie API Secret – czyli coś w rodzaju hasła.
Gdy klient chce wykonać jakąś operację na API, powinien się uwierzytelnić, wysyłając w żądaniu co najmniej Api Key. W taki sposób możesz logować operacje tego klienta lub w ogóle nie dopuścić go do używania API. Klient może się też uwierzytelnić za pomocą różnych mechanizmów jak OpenId Connect, ale ten artykuł nie jest o tym.
Dzisiaj pokazuję jak stworzyć taki mechanizm uwierzytelniania w .NET.
Jak działa mechanizm uwierzytelniania w .NET?
Tworząc swój własny mechanizm uwierzytelniania, tak naprawdę tworzysz własny „schemat”. Schemat to nic innego jak nazwa (np. „ApiKey”) połączona z Twoją klasą do uwierzytelniania (handler).
Wszystko sprowadza się ostatecznie do trzech kroków:
stwórz swój handler do uwierzytelniania (klasa dziedzicząca po AuthenticationHandler)
stwórz w nim obiekt ClaimsPrincipal
zarejestruj swój schemat
AuthenticationHandler
Całą obsługę uwierzytelniania robimy w klasie, która dziedziczy po AuthenticationHandler (bądź implementuje interfejs IAuthenticationHandler, co jest nieco trudniejsze). To na początek może wyglądać nieco skomplikowanie, ale jest proste.
Opcje
Klasa abstrakcyjna AuthenticationHandler jest klasą generyczną. Przyjmuje w parametrze typ, w którym trzymamy opcje naszego schematu uwierzytelnienia. Przy czym te opcje muszą dziedziczyć po klasie AuthenticationSchemeOptions i mogą być zupełnie puste, np.:
public class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions
{
}
W tych opcjach możemy mieć wszystko, co nam się podoba. Przykładem może być uwierzytelnianie za pomocą Bearer Token, gdzie w opcjach masz czas życia takiego tokena, wystawcę itd. Żeby zademonstrować całość, zrobimy sobie ograniczenie do długości klucza API. Nie ma to w prawdzie żadnego zastosowania praktycznego. Po prostu pokazuję, jak wykorzystać te opcje:
public class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions
{
public int ApiKeyLength { get; set; }
public bool CheckApiKeyLength { get; set; }
}
Handler
Teraz musimy napisać klasę, która będzie całym sercem uwierzytelniania – ta, która dziedziczy po AuthenticationHandler:
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
throw new NotImplementedException();
}
}
Jak widzisz, wystarczy przesłonić metodę HandleAuthenticateAsync lub jej synchroniczną odpowiedniczkę.
Metoda musi zwrócić AuthenticationResult. Ten AuthenticationResult może przyjąć 3 stany:
sukces,
niepowodzenie,
brak wyniku.
Sukces
Jeśli rezultat kończy się sukcesem, musimy do niego przekazać „bilet” – ticket. Jest to taki mały obiekt, który trzyma informacje o schemacie uwierzytelnienia, ClaimsPrincipal i może zawierać jakieś dodatkowe dane (AuthenticationProperties). W swojej minimalnej postaci wystarczy mu nazwa schematu i ClaimsPrincipal.
Oczywiście „sukces” oznacza, że nasz mechanizm poprawnie uwierzytelnił danego klienta / użytkownika.
Niepowodzenie
Jeśli rezultat zakończy się niepowodzeniem (Fail) oznacza to, że nie dość, że użytkownik nie został uwierzytelniony przez nasz mechanizm, to jeszcze wszystkie inne ewentualne handlery już go nie mogą próbować uwierzytelnić.
Brak wyniku
Jeśli jednak rezultat zakończy się brakiem wyniku (NoResult) oznacza to, że użytkownik nie jest uwierzytelniony TYM SCHEMATEM, jednak inne ewentualne handlery mogą próbować go dalej uwierzytelniać.
Kiedy to stosujemy? Załóżmy, że mamy dwa schematy – ApiKey i Login + Hasło. Każdy handler jest uruchamiany po kolei przez Framework (chyba, że któryś handler zwróci sukces lub niepowodzenie – wtedy kolejne nie są już uruchamiane).
I teraz jeśli handler do ApiKey nie znajdzie klucza tam, gdzie powinien on być (np. w nagłówku żądania), może chcieć przekazać proces uwierzytelnienia kolejnym handlerom. Gdzieś tam wystartuje taki, który spodziewa się loginu i hasła.
Cały proces można by przedstawić w postaci prostego algorytmu:
UWAGA! W rzeczywistym świecie ta odpowiedź ma sens tylko, gdy w mechanizmie AUTORYZACJI wybrano kilka schematów uwierzytelnienia dla jakiejś końcówki. A, że to jak najbardziej jest możliwe, trzeba stosować tę wartość.
Podczas zwykłej operacji uwierzytelnienia (bez autoryzacji) zawsze w grę wchodzi tylko jeden schemat.
Konstruktor
Klasa AuthenticationHandler wymaga pewnych obiektów przekazanych w konstruktorze. Dlatego też minimalny konstruktor musi je przyjąć. Na szczęście wszystko ogarnia Dependency Injection. Teraz całość wygląda tak:
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
public ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
throw new NotImplementedException();
}
}
Jak widzisz, jedną z tych wymaganych rzeczy jest IOptionsMonitor. Jeśli nie wiesz, czym to jest, pisałem o tym w artykule o opcjach.
Piszemy handlera
Napiszmy sobie teraz jakąś oszukaną klasę, która zwróci dane użytkownika, dla którego jest zarejestrowany dany ApiKey. Ta klasa pełni rolę „bazy danych”. Równie dobrze możesz tutaj użyć EfCore, czy czegokolwiek sobie życzysz:
public class ApiKeyClientProvider
{
private Dictionary<string, ApiKeyClient> _clients = new Dictionary<string, ApiKeyClient>();
public ApiKeyClientProvider()
{
AddClients();
}
public ApiKeyClient GetClient(string key)
{
ApiKeyClient result; ;
if (_clients.TryGetValue(key, out result))
return result;
else
return null;
}
private void AddClients()
{
var client = new ApiKeyClient()
{
ApiKey = "klucz-1",
Email = "client1@example.com",
Id = 1,
Name = "Klient 1"
};
_clients[client.ApiKey] = client;
var client2 = new ApiKeyClient()
{
ApiKey = "klucz-2",
Email = "client2@example.com",
Id = 2,
Name = "Klient 2"
};
_clients[client2.ApiKey] = client2;
}
}
W kolejnym kroku możemy zaimplementować ostatecznie nasz schemat uwierzytelniania:
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
private readonly ApiKeyClientProvider _clientProvider;
public ApiKeyAuthenticationHandler(
ApiKeyClientProvider clientProvider, //wstrzykujemy naszą oszukaną bazę danych
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
_clientProvider = clientProvider;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var apiKey = GetApiKey();
if (string.IsNullOrWhiteSpace(apiKey))
return AuthenticateResult.Fail("No API key provided");
var client = _clientProvider.GetClient(apiKey);
if (client == null)
return AuthenticateResult.Fail("Invalid API key");
var principal = CreatePrincipal(client);
AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
return AuthenticateResult.Success(ticket);
}
private string GetApiKey()
{
StringValues keyValue;
if (!Context.Request.Headers.TryGetValue("X-API-KEY", out keyValue))
return null;
if (!keyValue.Any())
return null;
return keyValue.ElementAt(0);
}
private ClaimsPrincipal CreatePrincipal(ApiKeyClient client)
{
ClaimsIdentity identity = new ClaimsIdentity("ApiKey");
identity.AddClaim(new Claim(ClaimTypes.Email, client.Email));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, client.Id.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Name, client.Name));
return new ClaimsPrincipal(identity);
}
}
Przejdźmy ją fragmentami.
Na samym dole jest metoda CreatePrincipal. Ona tworzy obiekt ClaimsPrincipal na podstawie przekazanego rekordu klienta z naszej bazy.
Tworzenie ClaimsPrincipal polega w sumie na utworzeniu odpowiednich ClaimsIdentity wraz z Claimsami. ApiKey, które widzisz podczas tworzenia ClaimsIdentity to po prostu nazwa naszego schematu. Dzięki temu wiesz – aha, ten ClaimsIdentity powstał ze schematu ApiKey.
Jeśli nie wiesz, czym jest ten ClaimsPrincipal i Claimsy, przeczytaj ten artykuł.
Ok, dalej mamy metodę GetApiKey. Ona po prostu pobiera wartość odpowiedniego nagłówka żądania. Jak widzisz, klasa AuthenticationHandler daje nam bezpośredni dostęp do kontekstu HTTP przez właściwość Context.
No i najważniejsza metoda – HandleAuthenticateAsync. Przyjrzyjmy się jej jeszcze raz:
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var apiKey = GetApiKey();
if (string.IsNullOrWhiteSpace(apiKey))
return AuthenticateResult.NoResult;
var client = _clientProvider.GetClient(apiKey);
if (client == null)
return AuthenticateResult.Fail("Invalid API key");
var principal = CreatePrincipal(client);
AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
return AuthenticateResult.Success(ticket);
}
Na początku pobieramy klucz API z nagłówka żądania. Jeśli jest pusty, to znaczy że nie można uwierzytelnić takiego klienta TYM SCHEMATEM. Klient po prostu nie dodał klucza do żądania. Zwracamy błąd uwierzytelnienia. Być może inny schemat będzie w stanie go zidentyfikować.
Jeśli jednak ten klucz jest, pobieramy użytkownika przypisanego do niego z naszej bazy. I znowu – jeśli taki użytkownik nie istnieje, to znaczy że klucz API nie jest prawidłowy.
Na koniec jeśli użytkownik istnieje, tworzymy na jego podstawie ClaimsPrincipal. Na koniec wydajemy mu „bilecik” z jego danymi i zwracamy sukces uwierzytelnienia.
Używamy opcji
Jak widzisz, nie dorobiliśmy jeszcze sprawdzenia, czy nasz klucz API ma odpowiednią długość. Ale wszystko mamy wstrzyknięte w konstruktorze. IOptionsMonitor daje nam te opcje. Wykorzystajmy więc go. Jeśli nie wiesz, czym jest IOptionsMonitor i jak z niego korzystać, przeczytaj ten artykuł.
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var apiKey = GetApiKey();
if (string.IsNullOrWhiteSpace(apiKey))
return AuthenticateResult.Fail("No API key provided");
if (Options.CheckApiKeyLength)
{
if (apiKey.Length != Options.ApiKeyLength)
return AuthenticateResult.Fail("Invalid API key");
}
var client = _clientProvider.GetClient(apiKey);
if (client == null)
return AuthenticateResult.Fail("Invalid API key");
var principal = CreatePrincipal(client);
AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
return AuthenticateResult.Success(ticket);
}
Jak widzisz, dostęp do opcji uwierzytelniania masz przez właściwość Options z klasy bazowej. Teraz tylko musimy zarejestrować nasz schemat.
Wszystko rozbija się o rejestrację AddAuthentication. W parametrze podajemy domyślny schemat uwierzytelniania. Następnie dodajemy nasz schemat przez metodę AddScheme. Jeśli nie używasz opcji, to w drugim parametrze możesz dać po prostu null. Drugi parametr to delegat, który ustawia nasze opcje. Oczywiście w prawdziwym programie te wartości byłyby pobierane z konfiguracji.
Pamiętaj też o middleware. Musisz dodać przed UseAuthorization():
app.UseAuthentication();
app.UseAuthorization();
Challenge
Challenge (authentication challenge) to mechanizm, który jest uruchamiany przez .NET, gdy użytkownika nie można uwierzytelnić. Efektem tego może być przejście na stronę logowania albo po prostu dodanie jakiejś informacji w odpowiedzi na żądanie. Domyślny Challenge zwraca po prostu błąd 401.
Aby zrobić coś swojego, wystarczy przeciążyć metodę HandleChallengeAsync w naszej klasie. Można to zrobić tak:
Podczas wywoływania HandleChallengeAsync przez .Net możemy korzystać z Response – czyli możemy modyfikować sobie odpowiedź do klienta. Standardowym podejściem w takim przypadku jest umieszczenie nagłówka www-authenticate z nazwą schematu lub jakimiś wskazówkami, jak uwierzytelniać się w naszym systemie.
To jest opcjonalne, Domyślny mechanizm, jak mówiłem, zwraca po prostu błąd 401.
Jeśli spróbujesz teraz pobrać dane przez Postmana, oczywiście nie zobaczysz ich, ale zostanie zwrócony Ci właśnie ten nagłówek. Zwróć też uwagę na to, że zwrócony kod operacji (200) oznacza operację zakończoną sukcesem:
ForwardChallenge
Jeśli przyjrzysz się klasie bazowej do opcji uwierzytelniania, zobaczysz taką właściwość jak ForwardChallenge. Możesz tutaj przypisać nazwę schematu, który będzie użyty do Challengowania. Jeśli więc podczas konfiguracji naszego schematu, przypisałbyś takie opcje:
To wtedy, jeśli Twój schemat nie uwierzytelni użytkownika, Challenge zostanie przekazany do schematu o nazwie Bearer. Oczywiście, jeśli taki schemat nie został zarejestrowany, program się wysypie.
Forbid
To jest metoda, która wykona się, gdy dostęp do zasobu nie został udzielony dla Twojego schematu uwierzytelniania. Inaczej mówiąc, załóżmy że masz dwa schematy uwierzytelniania:
Użytkownik podaje login i hasło
Klient API podaje klucz API
Teraz, niektóre końcówki mogą wymagać konkretnego schematu uwierzytelniania. Załóżmy, że mamy jakieś końcówki administracyjne, na które nie można się dobić za pomocą uwierzytelniania przez klucz API. One wymagają uwierzytelnienia za pomocą loginu i hasła. Można to w kontrolerze zablokować przekazując po prostu nazwę schematu, który oczekujemy, np:
I teraz załóżmy taką sytuację. Jakiś klient API został uwierzytelniony przez nasze ApiKeyAuthorizationHandler. Natomiast końcówka wymaga uwierzytelnienia przez jakiś schemat LoginAndPass. W tym momencie zostanie wywołana metoda Forbid w naszym handlerze (ponieważ to nasz handler go uwierzytelnił). Działa to analogicznie do metody Challenge. Domyślnie zwracany jest błąd 403.
Oczywiście tutaj też możemy przekazać Forbid do innego schematu, używając – analogicznie jak przy Challenge – ForwardForbid w opcjach uwierzytelniania.
Inne opcje
Jeśli chodzi o uwierzytelnianie klientów API, istnieje inna opcja, w której właściwie nie musisz pisać tego kodu. Jest to usługa Azure’owa o nazwie Azure API Management, która załatwia to wszystko za Ciebie. Możesz też ustawić limity czasowe/ilościowe dla konkretnych klientów. Czego dusza zapragnie. Usługa daje Ci duuużo więcej (wraz z portalem dla Twoich klientów). Nie jest jednak darmowa.
Basic Authentication
Basic Authentication to standardowy mechanizm uwierzytelniania. Polega on na obecności odpowiedniej wartości w nagłówku Authentication.
A ta wartość to po prostu: Base64(<login>:<hasło>).
Czyli dajesz login i hasło przedzielone dwukropkiem, a następnie konwertujesz to na Base64. Taką wartość umieszcza się w nagłówku Authentication. Jak zapewne się domyślasz, nie jest to zbyt dobra metoda, jednak jest używana. W związku z tym, że przekazywane jest jawnie login i hasło, konieczne jest użycie SSL przy tej formie.
Napiszemy sobie teraz prosty mechanizm uwierzytelniania używający właśnie Basic Authentication. To będzie zrobione analogicznie do tego, co robiliśmy wyżej. Więc możesz po prostu przejrzeć sobie kod:
public class BasicAuthenticationOptions: AuthenticationSchemeOptions
{
}
public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationOptions>
{
private readonly UserProvider _userProvider;
private record UserCredentials(string login, string password);
public BasicAuthenticationHandler(
UserProvider userProvider,
IOptionsMonitor<BasicAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
_userProvider = userProvider;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var creds = RetrieveCredentials();
if (creds == null)
return AuthenticateResult.Fail("No credentials");
var userData = _userProvider.GetUser(creds.login, creds.password);
if (userData == null)
return AuthenticateResult.Fail("No such user");
if (userData.Password != creds.password)
return AuthenticateResult.Fail("Invalid password");
var principal = CreatePrincipal(userData);
var ticket = new AuthenticationTicket(principal, "Basic");
return AuthenticateResult.Success(ticket);
}
private UserCredentials RetrieveCredentials()
{
if (Context.Request.Headers.Authorization.Count == 0)
return null;
var basedValue = Context.Request.Headers.Authorization[0];
if (basedValue.StartsWith("Basic "))
basedValue = basedValue.Remove(0, "Basic ".Length);
else
return null;
var byteData = Convert.FromBase64String(basedValue);
var credsData = Encoding.UTF8.GetString(byteData);
var credValues = credsData.Split(':');
if (credValues == null || credValues.Length != 2)
return null;
return new UserCredentials(credValues[0], credValues[1]);
}
private ClaimsPrincipal CreatePrincipal(UserData user)
{
ClaimsIdentity identity = new ClaimsIdentity("Basic");
identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));
return new ClaimsPrincipal(identity);
}
}
Jedyne, czego tu nie widać, to klasa UserProvider, która wygląda bardzo podobnie jak ApiKeyClientProvider. Możesz zobaczyć całość na GitHub. Wszystko działa tutaj analogicznie.
Dodałem tę metodę, żeby pokazać Ci teraz, w jaki sposób możesz dynamicznie wybrać sobie schemat uwierzytelniania.
Dynamiczny wybór schematu uwierzytelniania
Żeby móc dynamicznie wybrać schemat, musimy dodatkowo dodać politykę. To nie wymaga dużo wysiłku, spójrz na ten kod:
Gdy rejestrujemy mechanizmy uwierzytelniania przez AddAuthentication, zobacz że jako domyślny schemat podajemy nazwę ApiKeyOrBasic – czyli nazwę naszej polityki do wyboru schematu.
Teraz, wykonując AddPolicyScheme, rejestrujemy właśnie taką politykę.
W rezultacie, wywołany zostanie domyślny schemat uwierzytelniania – czyli nasza polityka, która po prostu sprawdzi, czy w żądaniu znajduje się odpowiedni nagłówek. Następnie zwraca nazwę schematu, którym to żądanie powinno być uwierzytelnione. Nazwa trafia do ForwardDefaultSelector.
.NET w kolejnym kroku uruchomi właśnie ten schemat.
Czym jest domyślna nazwa schematu?
W .NET możesz m.in. przy kontrolerach wymagać uwierzytelnienia użytkownika konkretnym schematem. Czyli przykładowo: „Jeśli użytkownik chce wykonać tę operację, MUSI być zalogowany schematem login i hasło„.
Jeśli tego nie podasz jawnie, wtedy do gry wejdzie domyślny schemat uwierzytelniania. Dlatego ważne jest, żeby zawsze go podać.
Dobre praktyki
Kod, który pokazałem nie zawiera dobrych praktyk. Ale dzięki temu jest bardziej czytelny.
W prawdziwym kodzie upewnij się, że stosujesz te dobre praktyki, czyli:
Nazwy nagłówków – jeśli wprowadzasz jakieś własne nazwy nagłówków, upewnij się, że NIE zaczynają się od X-. Jest to przestarzała forma, która jest już odradzana przez konsorcjum. Zamiast tego powinieneś w jakiś jednoznaczny sposób nazwać swój nagłówek, np.: MOJ-PROGRAM-API-KEY.
Nazwy schematów w gołych stringach – no coś takiego w prawdziwym kodzie woła o pomstę do nieba. Powinieneś stworzyć jakieś stałe w stylu:
class ApiKeyAuthenticationDefaults
{
public const string SchemeName = "ApiKey";
}
i posługiwać się tymi stałymi.
Nazwy nagłówków w gołych stringach – tutaj tak samo. Wszystko powinno iść przez stałe.
Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd, koniecznie daj znać w komentarzu. No i udostępnij go osobom, którym się przyda 🙂
Rekordy weszły do C# w wersji 9. Obok struktur i klas stały się trzecim „ogólnym” typem. Chociaż są podobne zarówno do klas jak i struktur, to jednak dostarczają pewnych mechanizmów, które mogą przyspieszyć i ułatwić pisanie.
Zanim przeczytasz ten artykuł, powinieneś znać dokładne różnice między klasami i strukturami, stosem i stertą. Na szczęście opisałem je w tym artykule 🙂
Czym właściwie jest rekord?
Rekord to typ danych, który może zachowywać się tak jak struktura (być typem wartościowym) lub klasa (być typem referencyjnym). Jednak jasno musimy to określić podczas jego deklaracji. Rekordy rządzą się też pewnymi prawami. Gdy tworzymy rekord, kompilator dodaje automatycznie kilka elementów. O tym za chwilę. Najpierw spójrz jak rekordy się deklaruje.
Jest kilka możliwości deklaracji rekordu, najprostszą z nich jest:
public record MyRecord(int Id, string Name);
I tyle. Jedna linijka. Czy to nie jest piękne? Gdyby to przetłumaczyć na deklarację klasy, otrzymalibyśmy mniej więcej coś takiego:
public class MyRecord
{
public int Id { get; init; }
public string Name { get; init; }
public MyRecord(int id, string name)
{
Id = id;
Name = name;
}
public static bool operator==(MyRecord x, MyRecord y)
{
//o tym później
}
public static bool operator!=(MyRecord x, MyRecord y)
{
return !(x == y);
}
public override bool Equals(object? obj)
{
//o tym później
}
public void Deconstruct(out int id, out string name)
{
id = Id;
name = Name;
//o tym później
}
}
A to i tak tylko szkielet 🙂
Jak można z tego korzystać? Dokładnie tak samo jak z każdej innej klasy:
static void Main(string[] args)
{
var rec = new MyRecord(5, "Adam");
}
Jednak, jak wspomniałem wcześniej, rekord to coś więcej niż tylko cukier składniowy na klasę, czy strukturę. Gdy deklarujemy typ rekordowy, kompilator dokłada co nieco od siebie:
Porównanie wartościowe
Rekordy są automatycznie wyposażone w operatory ==, != i metodę Equals. Te elementy porównują dwa rekordy wartościowo. Tzn., że rekordy są takie same wtedy i tylko wtedy, gdy:
są tego samego typu
WARTOŚCI wszystkich właściwości są takie same.
Zwróć uwagę na słowo „WARTOŚCI”. Jeśli porównujesz ze sobą dwa obiekty, one domyślnie są porównywane referencyjnie. Czyli dwa obiekty będą takie same, jeśli wskazują na to samo miejsce w pamięci. Przy rekordach porównywane są domyślnie WARTOŚCI wszystkich pól.
Jednak pamiętaj, że to muszą być typy wartościowe. Jeśli rekord będzie zawierał klasę, to referencje obiektów tych klas zostaną ze sobą porównane (można powiedzieć, że wartością klasy jest referencja). Lepiej to wygląda na przykładzie. Porównajmy rekordy, które mają te same dane, których wartości można porównać:
var rec = new MyRecord(5, "Adam");
var rec2 = new MyRecord(5, "Adam");
Console.WriteLine(rec == rec2); // TRUE
Dla przykładu, jeśli to byłyby klasy, zostałyby porównane referencyjnie, a nie wartościowo:
var c1 = new MyClass //KLASA
{
Name = "Adam"
};
var c2 = new MyClass //KLASA
{
Name = "Adam"
};
Console.WriteLine(c1 == c2); // FALSE
Jednak gdy teraz dodamy klasę (typ referencyjny) do rekordu i będziemy chcieli porównać:
public record MyRecord(MyClass data);
//
var c1 = new MyClass
{
Name = "Adam"
};
var c2 = new MyClass
{
Name = "Adam"
};
var rec1 = new MyRecord(c1);
var rec2 = new MyRecord(c2);
Console.WriteLine(rec1 == rec2); //FALSE
No to obiekty c1 i c2 zostaną jednak porównanie referencyjnie i te dwa rekordy będą różne.
Czyli – 2 rekordy są takie same, gdy są tego samego typu i WARTOŚCI ich pól są takie same. W przypadku klasy, można powiedzieć, że jej wartością jest ADRES, na jaki wskazuje na stercie.
Niezmienialność (Immutability)
Rekordy są domyślnie niezmienialne (jest od tego wyjątek). Jeśli tak zadeklarujesz rekord:
public record Person(string Name);
to po utworzeniu obiektu tego rekordu nie będziesz mógł już go zmienić. Czyli wszystkie dane przekazujesz w konstruktorze i dalej nie można już niczego modyfikować.
Ale to nie dotyczy rekordów „strukturowych”, o których za chwilę.
Dekonstrukcja rekordów
Deklarując rekord, kompilator automatycznie tworzy też specjalną metodę Deconstruct, która w C# ma konkretne znaczenie. Służy do… dekonstrukcji obiektu 🙂 Dekonstrukcja jest znana z Tupli. O co chodzi? Spójrz na to:
public record struct Person(int Id, string FirstName, string LastName);
//
var p1 = new Person(1, "Adam", "Jachocki");
var (id, fName, lName) = p1;
Przyjrzyj się temu kodowi, bo jeśli nie słyszałeś o dekonstrukcji, to może wydawać się błędny. Ale jest zupełnie poprawny. Teraz wszystkie wartości z obiektu trafią do poszczególnych zmiennych: id, fName i lName. Osobiście bardzo uwielbiam dekonstrukcję.
Metoda ToString()
Rekordy mają również predefiniowaną metodę ToString, która zwraca wszystkie wartości w takiej postaci:
Person { Name = Adam, Age = 38 }
Różne rodzaje rekordów
Rekordy ze względu na swoją specyfikę, mogą zachowywać się na trzy różne sposoby. Chociaż wszystkie gwarantują porównywanie wartościowe i inne te rzeczy, o których pisałem wyżej, to jednak są pewne minimalne różnice.
Rekord klasowy (record)
Deklarujesz go w ten sposób:
public record Person(string Name);
//od C#10 możesz napisać również
//public record class Person(string Name);
Wyróżnia się tym, że jest tworzony na stercie. Jest domyślnie niezmienialny. Ma tylko jeden konstruktor.
Rekord strukturowy (record struct)
Deklarujesz go w taki sposób:
public record struct Person(string Name);
Wyróżnia się tym, że jest tworzony na stosie. Domyślnie jest zmienialny – możesz nadpisywać właściwości ile tylko chcesz. Ma też dwa konstruktory – ten z parametrami i domyślny – bezparametrowy.
Rekord strukturowy tylko do odczytu (readonly record struct)
Deklarujesz go tak:
public readonly record struct Person(string Name);
Wyróżnia się tym, że jest tworzony na stosie. Domyślnie jest NIEZMIENIALNY. Jednak kompilator tworzy dla niego dwa konstruktory – domyślny (bezparametrowy) i ten z parametrami.
Jeśli używasz konstruktora bezparametrowego, jedyną opcją na przypisanie wartości poszczególnym polom, jest przypisane podczas tworzenia obiektu:
var p = new Person { Name = "Adam" };
Dodatkowe pola w rekordzie
W rekordach możesz umieszczać dodatkowe pola i metody. Robisz to w sposób standardowy:
//Deklaracja
public readonly record struct Person(string Name)
{
public int Age { get; init; }
}
//użycie
var p1 = new Person("Adam") { Age = 38 };
Age jest zadeklarowane z setterm inicjującym. Dlatego możesz nadać mu wartość tylko w trakcie tworzenia obiektu.
Rekordy mogą mieć też normalne metody.
Dziedziczenie
Rekordy mogą dziedziczyć po innych rekordach. Ale nie mogą dziedziczyć po klasach, a klasy nie mogą dziedziczyć po rekordach. Dotyczy to jednak tylko rekordów klasowych. Rekordy strukturowe nie dziedziczą. Rekordy klasowe mogą też implementować interfejsy:
public interface IIface
{
void Foo();
}
public record Person(string Name);
public record Emloyee(int DeptId, string Name) : Person(Name), IIface
{
public void Foo()
{
throw new NotImplementedException();
}
}
Kopiowanie rekordów
Fajną sprawą jest kopiowanie rekordów. W bardzo łatwy sposób można stworzyć rekord na podstawie istniejącego. Służy do tego słowo with:
var p1 = new Person("Adam") { Age = 38 };
var p2 = p1 with { Name = "Janek" };
Teraz obiekt p2 będzie miał Name ustawiony na Janek, a Age na 38.
Pamiętaj jednak, że jeśli w rekordzie jest pole z klasą, to wtedy zostanie skopiowana referencja do tego obiektu, a nie jego wartości. Czyli mając taki program:
//deklaracja
public class MyClass
{
public int Id { get; set; }
}
public readonly record struct Person(string Name, MyClass Data)
{
public int Age { get; init; }
}
//użycie
var c1 = new MyClass { Id = 1 };
var p1 = new Person("Adam", c1) { Age = 38};
var p2 = p1 with { Name = "Janek" };
c1.Id = 10;
pamiętaj, że klasa w strukturach wskazuje na to samo miejsce na stercie. Czyli p2.Data.Id będzie również równe 10.
Kiedy używać rekordów
Z zasady, jeśli potrzebujesz danych, które są:
porównywane wartościowo
niezmienialne
Rekordy są idealnym kandydatem dla bindingu w kontrolerach. Chociaż ja głównie ich używam, jeśli mój serwis musi zwrócić do kontrolera więcej niż jedną wartość.
To tyle, jeśli chodzi o rekordy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz już znasz różnice między typami rekordów i, że będziesz ich używał bardziej świadomie. Albo w ogóle zaczniesz 🙂
Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu 🙂
Czasem bywa tak, że trzeba użyć DLL pisanej w innym języku. Sprawa jest dość prosta, jeśli to są języki „natywne”, jak np. C, C++, czy Pascal. Jednak podczas używania natywnej biblioteki w .NET czeka na nas kilka niespodzianek.
Ze względu na ogrom informacji i możliwości, podzielę ten artykuł na kilka części. W tej części ogólny zarys działania mechanizmu, przekazywanie typów prostych i stringów.
W artykule przedstawię głównie praktykę. Teorii jest naprawdę sporo, a znając praktyczne metody radzenia sobie z tym problemem, teoria niewiele daje. Jeśli jednak naprawdę chcecie wiedzieć dokładnie dlaczego tak, a nie inaczej i teoria zawarta w tym artykule jest dla Was niewystarczająca, dajcie znać w komentarzu 🙂
Za każdym razem, gdy piszę o funkcji C++, mam na myśli funkcję pisaną w dowolnym języku niskiego poziomu (niezarządzanego) typu C, C++, Pascal.
Zaznaczam, że cykl jest dość zaawansowany. Wymaga trochę wiedzy na temat pamięci i wskaźników. Co nieco pisałem w tym artykule. Ale jeśli czegoś nie rozumiesz, to koniecznie daj znać w komentarzu.
Jeśli nigdy nie miałeś do czynienia z programowaniem w innych językach niż C#, możesz nie znać różnicy między funkcją a metodą. Można powiedzieć, że metoda to funkcja będąca składnikiem klasy. W innych językach nie wszystko musi być klasą, wobec czego funkcja może być umieszczona poza jakąkolwiek klasą.
Rozróżnienie w tym artykule jest istotne, ponieważ, aby można było używać DLL napisanej w C++, w kodzie muszą zostać wyeksportowane poszczególne funkcje (z klasami też w prawdzie się da, ale to jest już zupełnie inny mechanizm na zupełnie inny cykl).
Jak to działa z grubsza?
Mamy dwa światy – kody zarządzane (managed) i niezarządzane (unmanaged). Zarządzane to te pisane przykładowo w C#. Niezarządzane to natywne – może być to C++, Pascal itp.
Jak się pewnie domyślasz te dwa różne światy dzieli pewna przepaść. Powstał więc mechanizm marshalingu jako pomost między nimi.
Co to ten Marshaling?
Wyobraź sobie Marshaling jako proces przekształcania danych między różnymi formatami. Jeśli przykładowo wysyłasz żądanie HTTP z jakimś obiektem, to ten obiekt jest przekształcany na format JSON, a po drugiej stronie przekształcany z JSON na jakąś inną klasę (być może nawet w innym języku). Można powiedzieć, że to też pewna forma Marshalingu. Chociaż w przypadku programowania międzyplatformowego oczywiście mówimy o czymś bardziej skomplikowanym.
Ale wszystko sprowadza się właśnie do tego. Mechanizm marshalingu po prostu bierze kilka bajtów z pamięci i konwertuje je (przekształca) na odpowiedni typ danych.
Co to Platform Invoke (P/Invoke)?
To jest mechanizm (w C# wyrażany za pomocą atrybutów), który pozwala na używanie funkcji ze świata niezarządzanego w środowisku .NET. Częścią tego mechanizmu jest Marshaling.
Co się dzieje, gdy wywołujemy funkcję z C++?
Mechanizm P/Invoke wkracza do akcji i:
Szuka biblioteki DLL, która zawiera wymaganą funkcję
Ładuje bibliotekę do pamięci (tylko przy pierwszym wywołaniu)
Znajduje adres w pamięci, w którym zaczyna się wywoływana funkcja
Kładzie (push) argumenty na stosie, robiąc marshaling (marshaling tylko przy pierwszym wywołaniu)
Uruchamia funkcję znajdującą się pod znalezionym adresem.
Jeśli funkcja coś zwraca, wtedy P/Invoke znów dokonuje marshalingu (jeśli trzeba) i zwraca te dane do kodu zarządzanego (w uproszczeniu).
Lokalizowanie funkcji
Mangling
Każda funkcja w DLL ma swój numer lub nazwę. Niestety to wcale nie musi być nazwa jakiej się spodziewamy. Istnieje mechanizm manglingu, szczególnie istotny w C++. Polega to na tym, że do funkcji dodaje się prefix i/lub sufix, którego zadaniem jest utworzenie jednoznaczej nazwy funkcji. Załóżmy, że mamy dwie funkcje:
void foo();
void foo(int a);
Teraz mangling bierze pod uwagę parametry i typy zwracane poszczególnych funkcji i ostatecznie mogą one zostać wyeksportowane pod takimi nazwami (zupełnie przykładowo):
foo@_v1
foo@_vi2
Od czego zależy mangling? Chociażby od tego, czy funkcje eksportujesz jako funkcje C, czy C++. W przypadku eksportu funkcji jako C, manglingnie jest wykorzystywany. Dzięki czemu funkcje w DLL mają dokładnie takie nazwy, jakich się spodziewamy.
Kolejność parametrów i odpowiedzialność
Jak się domyślasz, kolejność parametrów jest cholernie ważna. Ale też może być różna. Co więcej, czasem za niszczenie parametrów na stosie odpowiedzialny jest caller, a czasem biblioteka, którą wywołujemy. To wszystko zależy od konwencji wywołania funkcji.
Mamy w programowaniu kilka konwencji:
stdcall – funkcja w bibliotece jest odpowiedzialna za zniszczenie parametrów na stosie. Parametry są przesyłane w odwrotnej kolejności (od prawej do lewej). Głównie świat Windows.
cdecl – kod wywołujący (caller) jest odpowiedzialny za zniszczenie parametrów na stosie. Parametry są przesyłane od lewej do prawej. Głównie świat Unix
Są jeszcze inne konwencje wywołań, ale nie będę o nich pisał, bo nie są istotne dla tego artykułu. Jeśli Cię to interesuje, poczytaj o: fastcall, thiscall, vectorcall, syscall.
Zatem, żeby możliwe było używanie funkcji z C++, konwencje wywołań muszą być jednakowe zarówno w bibliotece jak i w kodzie wywołującym. Jeśli będą inne – wszystko wybuchnie i posypią się meteoryty.
Zestaw znaków (Charset)
Funkcje WinApi (i niektórych frameworków) można podzielić m.in. na użycie zestawu znaków. Jedne używają czystego ANSI, a drugie Unicode (WideString). To jest istotne, ponieważ te, używające ANSI są zakończone literką A. Te drugie – W. Np.:
int MessageBoxA(const char * pMessage); //używa ANSI
int MessageBoxW(const wchar_t * pMessage); //używa Unicode
Jeśli zatem używasz funkcji, która przyjmuje w parametrze jakieś stringi, wtedy istotne jest, abyś określił zestaw znaków w mechanizmie P/Invoke. To pomaga na wyszukanie odpowiedniej wersji funkcji, a poza tym na odpowiedni marshaling stringów. Chociaż to jest trochę głębszy temat, do którego wrócimy później. Tak, mechanizm P/Invoke może szukać funkcji o nazwie FooA, zamiast Foo, jeśli określisz Charset na ANSI (analogicznie może szukać FooW, jeśli określisz Charset na Unicode).
Ok, czas pobrudzić sobie ręce…
Jedziemy z koksem
Przygotowałem prostą solucję, którą możesz pobrać z GitHuba. To są dwa projekty – jeden C++, drugi C#. Projekt C++ to prosta biblioteka DLL, którą będziemy używać w C#. Nie ma żadnego konkretnego sensu. Napisałem ją tylko na potrzeby tego artykułu. Na dzień pisania artykułu (kwiecień 2023) biblioteka nie jest w pełni ukończona. Będę dodawał jej funkcjonalność przy kolejnych częściach artykułu. Niemniej jednak, projekty się budują i działają w obrębie tego artykułu.
Budowanie solucji
Żeby móc zbudować tę solucję, musisz mieć zainstalowane narzędzia do budowania C++. Biblioteka używa toolseta v143 (VisualStudio 2022). Musisz mieć w Visual Studio Installer zaznaczone „Programowanie aplikacji klasycznych w C++”:
Uruchamianie i debugowanie
Zaznacz w Visual Studio konfigurację Debug x64, a jako projekt startowy – CsClient:
Zwróć też uwagę na plik launchSettings.json w projekcie CsClient. Powinien wyglądać tak:
Dzięki temu możliwe będzie debugowanie kodu C++ z aplikacji pisanej w C#.
Zacznijmy od czegoś prostego…
Podstawy
Przede wszystkim pamiętaj, że dobrze jest stworzyć sobie klasę w C#, która będzie odwoływać się do funkcji eksportowanych z biblioteki. Tutaj rolę tej klasy przejmuje LibraryClient.
Weź też po uwagę, że w rzeczywistym świecie niektóre biblioteki mogą nie nadawać się do importu. Nie eksportują żadnych funkcji albo eksportują je z natywnymi typami danych (np. std::wstring). Lub też eksportują całe klasy (jeśli to jest mechanizm COM, to można z tym sobie poradzić, ale to temat na zupełnie inny artykuł).
Pierwsza klasa
Na początek spójrzmy na kod w C++ – plik library.h. To jest plik nagłówkowy, w którym są umieszczane deklaracje funkcji. Zobacz, że są w bloku extern "C", dzięki czemu nie ma manglingu. Każda z nich jest eksportowana w konwencji stdcall. Teraz spróbujmy użyć pierwszej:
DLL_EXPORT int __stdcall add(int a, int b);
Ta funkcja dodaje do siebie dwa inty i zwraca ich sumę.
Stwórzmy teraz klasę wrappera w C#:
internal class LibraryClient
{
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int add(int a, int b);
public int Add(int a, int b)
{
return add(a, b);
}
}
Zobacz teraz, co się dzieje. W linijce 3 i 4 używamy mechanizmu P/Invoke. Mówimy mu: „Hej, używam metody add z biblioteki CppDll.dll z konwencją StdCall„. Mechanizm P/Invoke wie jakiej funkcji szukać, ponieważ taką nazwę przekazałeś w linijce 4. Oczywiście jest też parametr atrybutu DllImport – EntryPoint, w którym możesz przekazać inną nazwę funkcji.
Tutaj nie ma żadnego problemu. Nie ma też marshalingu. Dlatego, że typ int jest tzw. typem kopiowalnym (blittable type). O tym za chwilę.
Co się dzieje:
P/Invoke ładuje bibliotekę CppDll.dll do pamięci
P/Invoke szuka adresu funkcji o nazwie add
P/Invoke kopiuje na jej stos parametry b, a
P/Invoke uruchamia funkcję
Po stronie C++ parametry są zdejmowane ze stosu, robione na nich obliczenia, następnie na stos jest zwracany wynik. C++ zwalnia pamięć używaną przez parametry po swojej stronie.
P/Invoke zdejmuje ze stosu wynik funkcji i zwraca go w odpowiedniej postaci: int.
Proste? Proste 🙂
Typy kopiowalne i niekopiowalne
Część typów danych ma wspólną reprezentację zarówno po stronie zarządzanej jak i niezarządzanej. Przykładowo typ int zarówno po stronie C++ jak i C# ma 4 bajty, a te 4 bajty są ułożone w tej samej kolejności. W związku z tym bez problemu można sobie je „kopiować”. Bo znaczą dokładnie to samo. Te typy są kopiowalne. Nie wymagają żadnej konwersji pomiędzy dwoma światami. Należą do nich:
Typy wartościowe składające się z typów kopiowalnych
Teraz pytanie o typy niekopiowalne. One wymagają marshalingu. Czyli jakiejś formy konwersji.
Ważne jest, że referencje do obiektów nie są kopiowalne. Możesz mieć kopiowalną strukturę, ale jeśli masz tablicę referencji do obiektów tej struktury, to ten obiekt nie jest już kopiowalny.
Przesyłanie stringa
Trochę gorzej ze stringami. To jest typ niekopiowalny. Co więcej jest traktowany w specjalny sposób. Zanim w nie wejdziemy, muszę odpowiedzieć na pytanie – czym tak naprawdę jest string?
Czym jest string?
String to tablica znaków zakończona znakiem ASCII #0 (null) lub dwoma takimi znakami w przypadku unicode. W przypadku stringu ANSI na jeden znak przypada jeden bajt. Jednak w przypadku unicode jeden znak jest już kodowany dwoma bajtami (jeśli znak jest typowym znakiem z zakresu ASCII, wtedy drugi bajt ma wartość 0x00). A w niektórych przypadkach – nawet czterema (języki głównie azjatyckie). Spójrz na fragment pamięci z kodu w C++:
To jest najbardziej prawdziwy string. Znaki 0x00 na końcu są potrzebne do tego, żeby komputer wiedział, gdzie ten string się kończy. On nie ma informacji o długości ani o niczym innym. To jest po prostu tablica pojedynczych znaków.
A teraz spójrz na string w C#:
A co tu się dzieje? Co to za awantury?
No właśnie, string w .NET to nie jest prawdziwy string. To jest jakaś klasa, która posiada więcej informacji (chociażby długość stringa). Co więcej, wcale nie musi się kończyć dwoma nullami. Zatem widzisz, że to jest jakaś większa klasa. Coś jak std::wstring w C++ – to jest klasa. Dlatego też, jeśli w parametrze eksportowanej funkcji C++ jest std::string lub std::wstring, taka funkcja nie nadaje się do użytku w .NET (ogólnie poza kodem pisanym w C++ w odpowiedniej wersji biblioteki).
Przesyłanie stringa do C++
Na szczęście przesyłanie stringa do C++ jest banalnie proste.
Od strony C++ wystarczy go przechwycić jako wskaźnik na tablicę znaków:
DLL_EXPORT size_t getStrLen(const wchar_t* pStr);
A od strony C# po prostu wysyłamy go jako zwykły string:
Co ważne, zwróć uwagę, że przekazujemy tutaj CharSet. Jeśli tego nie zrobisz, domyślnie będzie użyty ANSI.
Zwróć uwagę, że do C++ jest w takim wypadku przesyłany dokładnie ten string (dokładnie to miejsce w pamięci na zarządzanej stercie, gdzie zaczyna się tekst).
I teraz bardzo ważne jest, żeby kod C++ używał tego stringa tylko do odczytu. Jeśli w jakiś sposób spróbuje go zmienić, najpewniej skończy się to uszkodzeniem sterty.
Jednak, jeśli po stronie C++ odbieramy ANSI string (char *), wtedy string z C# jest kopiowany do dodatkowego bufora (i konwertowany) i to to miejsce jest przekazywane do C++. Gdy wracamy z funkcji, zawartość tego bufora jest ponownie kopiowana do stringa w C#. Więc jeśli masz dużo takich wywołań, możesz mieć problem z wydajnością. Wtedy spróbuj wysyłać tablicę bajtów zamiast stringa i sprawdź, co się stanie.
Jeśli jednak masz taką opcję, to posłuż się BSTR – o którym piszę niżej.
Pobieranie string z C++
Tutaj jest trochę trudniej. Dla takiego stringa trzeba zaalokować pamięć na stercie i odpowiednio go przekonwertować. Jest kilka sposobów na pobranie stringa z C++. Wszystkie mają swoje plusy i minusy.
Pobieranie jako rezultat
Pobierz i zwolnij
Po stronie C++ musisz skopiować string do miejsca, w którym będzie rezultat.
DLL_EXPORT wchar_t* getInfo()
{
return _wcsdup(L"Jestem wywołany z C++!"); //C++ tworzy kopię tego stringa i zwraca ją
}
W związku z tym, że C++ utworzył nowy obiekt na stercie, musi też go zwolnić w odpowiednim momencie. Dlatego też biblioteka powinna dać funkcję, która zwolni zaalokowaną pamięć, np.:
Teraz po stronie C# musisz pobrać ten string jako wskaźnik (w końcu wchar_t * jest wskaźnikiem), potem użyć marshalingu, żeby skonwertować go na string .NETowy, na koniec musisz zwolnić pobrany wskaźnik:
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern IntPtr getInfo();
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void freeInfo(IntPtr pData);
//
public string GetInfo()
{
var ptr = getInfo();
var result = Marshal.PtrToStringUni(ptr);
freeInfo(ptr);
return result;
}
Marshal.PtrToStringUni traktuje dane, na które wskazuje ptr jak string w Unicode. Czyli szuka jego końca (2 znaki null), kopiuje go na stertę zarządzaną i zwraca w postaci .NETowego stringa. Pobrany wskaźnik cały czas wskazuje na miejsce na stercie niezarządzanej, więc trzeba go zwolnić. Dlatego też wywołujemy metodę freeInfo z C++.
Pamiętaj, że za zwolnienie pamięci jest odpowiedzialna biblioteka, która tę pamięć zaalokowała. W tym przypadku C# używa innej biblioteki do zarządzania pamięcią niż C++. Dlatego też to po stronie C++ pamięć musi zostać zwolniona.
Pobieranie bez zwalniania
Jest jednak mechanizm, który zadziała nieco inaczej. Prościej dla programisty C#, jednak od strony C++ wymaga nieco więcej zachodu. To wykorzystuje technologię COM. Nie wchodząc w szczegóły – C# używa tej samej biblioteki do zwolnienia pamięci, co C++ do jej alokacji. Dlatego też w tym przypadku C# może być odpowiedzialny za zwolnienie tej pamięci i tak też się dzieje (zupełnie automatycznie). Od strony C++ wygląda to tak:
DLL_EXPORT wchar_t* __stdcall getInfoWithCom()
{
std::wstring str = L"Cześć, jestem z COM";
int allocSize = str.size() * 2 + 2;
STRSAFE_LPWSTR result = (STRSAFE_LPWSTR)CoTaskMemAlloc(allocSize);
StringCchCopy(result, allocSize, str.c_str());
return (wchar_t*)result;
}
Najpierw liczymy, ile pamięci musimy zaalokować. Pamiętaj, że jeden znak stringa unicodowego zajmuje dwa bajty. Dlatego też ilość znaków w string str mnożymy przez dwa. Następnie trzeba dodać jeszcze dwa znaki na zakończenie stringa (to będą te dwa nulle na końcu).
Pamiętaj, że w niektórych przypadkach jeden znak może potrzebować aż 4 bajtów. Jeśli wiesz, że Twoja aplikacja będzie działała na rynku azjatyckim i nie znasz długości stringa, dla jakiego chcesz zaalokować pamięć, to lepiej mnóż przez 4.
Następnie dzieje się magia. Używamy funkcji CoTaskMemAlloc do zaalokowania odpowiedniej ilości pamięci. C# pod spodem użyje CoTaskMemFree do zwolnienia tej pamięci. To jest ta sama biblioteka.
Na koniec kopiujemy string str do zaalokowanej pamięci i zwracamy go.
A po stronie C#? Możemy to odebrać jako zwykły string:
Plusy – to widać po stronie C#. Mniej kodu i programiści nie muszą zajmować się pamięcią. Minusy – to widać po stronie C++. Kod musi być odpowiednio napisany, żeby dało się tak zrobić.
Pobieranie z użyciem BSTR
BSTR to specjalna forma stringa, która składa się z trzech części:
długość stringu
tekst
znaki kończące (null)
Zwróć uwagę, że sam BSTR wskazuje na początek tekstu, a nie na 4 wcześniejsze bajty, w których jest zaszyta długość stringu. BSTR jest używany w mechanizmie COM i jest naturalny dla P/Invoke. Jego użycie jest bardzo podobne do tego, co było wyżej, jednak jest prostsze (od strony C++) i zwraca inny typ:
DLL_EXPORT BSTR __stdcall getInfoWithBstr()
{
return SysAllocString(L"A tak działa BSTR");
}
Tutaj jest istotne, żeby oznaczyć typ zwracany odpowiednim Marshalerem. W tym przypadku BStr. Dzięki temu to C# może zatroszczyć się o zwolnienie pamięci zarezerwowanej przez C++. Ta rezerwacja i zwalnianie też dzieje się przez mechanizm COM.
Pobieranie w parametrze
String można pobierać również przez parametr. W internetach (m.in. Stack Overflow) często widać taki kod jak poniżej. Jest on prosty, ale ma swoje problemy:
Użycie StringBuilder
Po stronie C++ mamy taki kod:
DLL_EXPORT void getInfo2(wchar_t* pData, int strLen)
{
ZeroMemory(pData, strLen * 2);
std::wstring result = L"Można i tak...";
result = result.substr(0, strLen);
std::copy(result.begin(), result.end(), pData);
}
W parametrze pData przekazujemy gotowe miejsce w pamięci do przyjęcia stringu. Jak duże? O tym mówi parametr strLen. Zarówno miejsce, jak i strLen jest przekazywane przez kod C#. Tylko trzeba pamiętać o dwóch rzeczach, o których powiem jeszcze przy okazji kodu C#.
Musisz przekazać strLen wystarczająco duży, aby pomieścił string, który będzie zwracany. No i parametr strLen musi też wziąć pod uwagę dwa dodatkowe bajty na zakończenie stringa (2 znaki null). W innym przypadku dostaniesz jakieś śmieci. A teraz, co się dzieje w kodzie?
W pierwszej kolejności zerujemy tę pamięć. Chodzi o to, żeby mieć pewność, że te dwa ostatnie znaki (zakończenie stringa) będą nullami. Tak, można by zerować tylko te dwa ostatnie bajty, ale tak jest mi prościej i łatwiej tłumaczyć 🙂
Potem kopiujemy do jakiejś zmiennej stringa – w tym przypadku, jeśli podamy ilość znaków większą niż string faktycznie zawiera, string będzie skopiowany do końca.
Na koniec kopiujemy go w przekazane w parametrze miejsce.
A jak to wygląda od strony C#?
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo2", CharSet = CharSet.Unicode)]
private static extern void getInfo(StringBuilder data, int length);
//
public string GetInfoWithStrBuilder()
{
var sb = new StringBuilder(255);
getInfo(sb, sb.Capacity);
return sb.ToString();
}
No i tutaj sprawa jest prosta. Najpierw tworzymy StringBuildera odpowiednie dużego, żeby mógł przetrzymać zwracany string. Pamiętamy też o dwóch znakach kończących stringa. Następnie wywołujemy funkcję z tymi parametrami.
Jak dużo pamięci?
Teraz pojawia się pytanie – skąd mam wiedzieć, ile pamięci mam zarezerwować na ten string? Skąd mam znać jego długość?
No to jest odwieczne pytanie programistów C++. Dlatego też powstało kilka mechanizmów, które rozwiązują ten problem. Oczywiście trzeba je samemu zaprogramować.
Pierwszy sposób jest taki, że funkcja C++ getInfo zwraca wartość bool, która mówi, czy string się skończył, czy nie. Wtedy tę funkcję wywołujemy w pętli, przekazując od którego znaku ma być czytany string. Często ten mechanizm zwraca stałą ilość znaków (lub mniej, jeśli już nie ma).
Drugi sposób to taki, gdzie wywołujemy funkcję getInfo z parametrem pierwszym (miejsce w pamięci) ustawionym na null – C++ widzi, że to miejsce nie jest zaalokowane (bo jest nullem) i zwraca długość stringa, jaki wyprodukuje. Wtedy w C# ustawiamy odpowiednio duży bufor. To może wyglądać tak:
DLL_EXPORT int __stdcall getInfo3(wchar_t* pData, int strLen)
{
std::wstring str = L"A to jest string o nieznanej długości";
if (pData == nullptr)
return str.size();
ZeroMemory(pData, strLen * 2);
str = str.substr(0, strLen);
std::copy(str.begin(), str.end(), pData);
return 0;
}
A po stronie C#:
public string GetInfoWithLen()
{
int len = getInfoWithLen(null, 0);
var sb = new StringBuilder(len + 2);
getInfoWithLen(sb, sb.Capacity);
return sb.ToString();
}
Tak naprawdę wystarczyłoby dodanie 1 znaku w zaznaczonej wyżej linii zamiast dwóch. Jeden znak w unicode to dwa bajty i o te dwa bajty nam chodzi. Dodałem specjalnie 2, żeby wbić do głowy te dwa bajty kończące string unicodowy.
Problemy
Minusem rozwiązania ze string builderem jest ilość alokacji na stercie. To jest stosunkowo wolna operacja, więc jeśli masz duże stringi i robisz to często, to raczej zauważysz problemy z wydajnością:
tworzenie StringBuildera alokuje pamięć na stercie zarządzanej
wywołanie funkcji:
alokuje bufor na stercie niezarządzanej
kopiuje zawartość StringBuildera do sterty niezarządzanej
tworzy zarządzaną tablicę i tam kopiuje zawartość natywnego stringa
StringBuffer.ToString() tworzy kolejną zarządzaną tablicę.
Także podczas tej operacji mamy 4 alokacje, których można by uniknąć. Dlatego tak mocno powielany sposób ze StringBuilderem uważam za zły. Są inne – lepsze.
Użycie tablicy
Zamiast StringBuildera, możesz użyć tablicy znaków – pamiętając, że string to nic innego niż tablica znaków. Nie ma tutaj za wiele do omówienie, po prostu pokażę kod:
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo3", CharSet = CharSet.Unicode)]
private static extern int getInfoWithArray([Out] char[] data, int length);
//
public string GetInfoWithArray()
{
int len = getInfoWithArray(null, 0);
char[] buffer = ArrayPool<char>.Shared.Rent(len + 1);
getInfoWithArray(buffer, buffer.Length);
return new string(buffer);
}
Tutaj nie ma już tylu alokacji co przy StringBuilderze, ten kod jest zdecydowanie lepszej jakości.
Użycie BSTR
Już pisałem wcześniej o użyciu BSTR. Mając wiedzę z całego artykułu, możesz na spokojnie wywnioskować taki kod:
DLL_EXPORT void __stdcall getInfo4(BSTR& data)
{
data = SysAllocString(L"Dzień dobry, jak się masz?");
}
To jest najprostszy i najlepszy sposób pobierania stringa z parametru. Oczywiście nie zawsze biblioteka natywna będzie do tego przygotowana, ale jeśli możesz iść w tym kierunku, to idź. W taki sam sposób możesz przekazać stringa.
Ufff, to na tyle jeśli chodzi o pierwszą część artykułu o połączeniu C++ z C#. W drugiej części zajmiemy się strukturami.
Jeśli znalazłeś w artykule błąd lub czegoś nie zrozumiałeś, koniecznie daj znać w komentarzu. Dzięki za przeczytanie, koniecznie dodaj go do ulubionych, bo kiedyś może Ci się przydać. I do następnego! 🙂
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)
Przechowywane są w pamięci na stosie (to pewne uproszczenie, ale na razie potraktuj je za pewnik)
Automatycznie tworzone i zwalniane przez system operacyjny
Klasy (typy referencyjne)
Przechowywane w pamięci na stercie
Programista sam musi utworzyć obiekt takiego typu
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:
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.
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:
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 🙂
Kiedy tworzysz opcje do swojego programu, warto dodatkowo je walidować. Pewnie, że nie wszystkie. Jednak daje to większe poczucie spokoju, zwłaszcza kiedy aplikacja chodzi na różnych środowiskach (chociażby produkcyjne i deweloperskie). Jeśli ktoś przez pomyłkę źle skonfiguruje system, to nie będzie on działał poprawnie. Co więcej, przez długi czas możesz się o tym nie dowiedzieć. Niepoprawne opcje mogą przez dłuższy czas nie dawać o sobie znaku. Walidacja ich od razu może rozwalić program. Od razu będzie wiadomo, że coś jest nie tak.
W tym artykule pokażę Ci jak możesz walidować swoje opcje zarówno za pomocą adnotacji, jak i fluent validation.
.NET umożliwia Ci walidację opcji na kilka różnych sposobów. Możesz sprawdzać typowymi adnotacjami (DataAnnotations) w klasie modelu opcji. Pisałem już o tym w artykule o walidacji.
Załóżmy więc, że mamy taki prosty model opcji:
public class SimpleOptions
{
[EmailAddress]
public string SenderEmail { get; set; }
[Required]
public string SmtpAddress { get; set; }
}
Jak widać, walidujemy tutaj za pomocą adnotacji. Pole SenderEmail musi być adresem e-mail, natomiast pole SmtpAddress jest wymagane.
Teraz, żeby uruchomić walidację, trzeba nieco inaczej skonfigurować te opcje niż w sposób domyślny opisany w tym artykule. Teraz zamiast metody Configure, użyjemy AddOptions, które zwraca obiekt klasy OptionBuilder, który z kolei umożliwia walidacje:
Zauważ, że używając OptionBuildera, trzeba użyć metody Bind do powiązania tych opcji i na koniec ValidateDataAnnotations, co uruchomi walidację tych opcji, używając adnotacji. Tylko adnotacji. Pamiętaj o tym.
Teraz, jeśli jakieś opcje nie będą spełniały założeń, podczas ich wstrzykiwania pójdzie wyjątek. Np. spójrz na taki appsettings.json:
Jak widzisz, nie ma tutaj w ogóle pola SmtpAddress, które jest wymagane w naszym modelu. Teraz, jeśli chcielibyśmy takie opcje odczytać np.:
[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
private readonly SimpleOptions _simpleOptions;
public TestController(IOptions<SimpleOptions> simpleOptions)
{
_simpleOptions = simpleOptions.Value;
}
}
to w linijce 8 dostaniemy wyjątek. Takich opcji nie można pobrać, bo nie spełniają warunków.
Problemem jest to, że program musi dojść do tego miejsca, żeby opcje zostały zwalidowane. Na szczęście w .NET6 można sprawdzić konkretne opcje już podczas uruchamiania aplikacji, co jest naprawdę mega użyteczne. Piszę o tym później.
Oczywiście sam możesz pisać własne atrybuty walidacyjne, o czym pisałem tutaj. Wystarczy napisać klasę dziedziczącą po ValidationAttribute.
To prosta walidacja. Nie można za jej pomocą zrobić bardziej wyrafinowanych sprawdzeń. A jeśli można to jest to uciążliwe. Dlatego dla takich scenariuszy przychodzi kolejna możliwość…
Własny walidator
Wystarczy stworzyć własny walidator – klasę, która implementuje interfejs IValidateOptions. Nic nie stoi na przeszkodzie, żeby Twój model ten interfejs implementował, jednak z punktu widzenia czystości kodu, to nie jest dobre rozwiązanie. Pamiętaj o tym.
Stworzę zatem osobną klasę, która będzie walidować taki model:
public class ApiOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ClientUri { get; set; }
}
Te przykładowe opcje pozwalają się łączyć z hipotetycznym API. Założenie jest takie, że albo podajemy ClientId i ClientSecret (który musi być odpowiedniej długości), albo podajemy ClientUri. Napiszmy teraz walidator do tego. Zacznijmy od pustego:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
}
}
Jak widzisz, interfejs IValidateOptions posiada tylko jedną metodę do implementacji. W parametrze name dostaniesz nazwę tych opcji, jeśli używasz named options. Natomiast w parametrze options otrzymasz cały model odczytany z konfiguracji. I teraz możesz go sprawdzić np. w taki najprostszy sposób:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
bool isIdAndSecret = IsIdAndSecret(options);
bool isUri = IsUri(options);
if (isIdAndSecret && isUri)
return ValidateOptionsResult.Fail("Nie możesz jednocześnie podać ClientUri i sekretów");
if (!isIdAndSecret && !isUri)
return ValidateOptionsResult.Fail("Musisz podać jakieś dane do połączenia z API");
if (isIdAndSecret && options.ClientSecret.Length < 5)
return ValidateOptionsResult.Fail("Client secret jest za krótki");
return ValidateOptionsResult.Success;
}
private bool IsIdAndSecret(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
}
private bool IsUri(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientUri);
}
}
Po prostu wykonujemy kilka prostych sprawdzeń i albo zwracamy ValidateOptionsResult.Success, albo Fail. W przypadku, jeśli walidacja się nie powiedzie, zachowanie będzie identyczne jak przy walidacji adnotacjami. Program się wywali na próbie pobrania opcji.
Teraz tylko trzeba to zarejestrować w nieco inny sposób. Możemy posłużyć się zarówno OptionsBuilderem jak i konfiguracją, jednak trzeba dodatkowo zarejestrować takiego walidatora:
W .NET8 wprowadzono budowniczego ValidateOptionsResultBuilder, którym możesz sobie zbudować cały rezultat jeśli chcesz. Dzięki temu możesz zwrócić kilka błędów. Powyższy kod mógłby wyglądać tak:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
bool isIdAndSecret = IsIdAndSecret(options);
bool isUri = IsUri(options);
ValidateOptionsResultBuilder builder = new();
if (isIdAndSecret && isUri)
builder.AddError("Nie możesz jednocześnie podać ClientUri i sekretów");
if (!isIdAndSecret && !isUri)
builder.AddError("Musisz podać jakieś dane do połączenia z API");
if (isIdAndSecret && options.ClientSecret.Length < 5)
builder.AddResult(ValidateOptionsResult.Fail("Client secret jest za krótki"));
return builder.Build();
}
private bool IsIdAndSecret(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
}
private bool IsUri(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientUri);
}
}
Zaznaczyłem znaczące linie. Jak widzisz, możesz do buildera dodawać zarówno errory jak i całe obiekty ValidateOptionsResult.
Dzięki temu możesz pokazać wszystkie problemy związane z opcjami, a nie tylko jeden.
Walidacja w OptionsBuilderze
Ten sposób walidacji zostawiam raczej jako ciekawostkę. W małych systemach pewnie się sprawdzi, natomiast w większych lepiej go unikać.
Można napisać kod walidacyjny podczas rejestrowania opcji:
To tylko fragment wcześniejszej walidacji. Musiałbym napisać resztę przypadków, ale to nie ma sensu (bo to tylko przykład). Tutaj metoda Validate przyjmuje delegat – funkcję, która zwraca bool, a w parametrze ma model opcji.
Dlaczego to nie ma sensu? To chyba widać. W przypadku większej ilości opcji lub bardziej wyrafinowanych walidacji w kodzie po prostu zrobi się burdel i całość stanie się mało czytelna.
Tak jak mówiłem wcześniej – w małych, szybkich projektach to się może sprawdzić. Natomiast w większych raczej nie.
Walidacja przy starcie systemu
Domyślny mechanizm będzie walidował opcje dopiero w momencie próby ich pobrania: var myOptions = options.Value. Natomiast możesz sobie życzyć, żeby opcje były sprawdzane podczas uruchamiania programu. Plusem tego jest to, że od razu dowiesz się, że coś jest nie tak, bo apka wywali się podczas uruchamiania. Minus? Aplikacja będzie potrzebować nieco więcej czasu, żeby się uruchomić, ponieważ będzie sprawdzać opcje, które wskażesz. Myślę jednak, że warto to zrobić, bo od razu dostajesz wiedzę, że coś jest źle skonfigurowane.
Wystarczy, że wywołasz metodę ValidateOnStart z OptionsBuilder:
Pamiętaj, że metoda ValidateOnStart doszła dopiero w .NET6. Dlatego jeśli masz projekt we wcześniejszej wersji przemyśl migrację do .NET6.
Walidacja typu FLUENT
Na koniec pokażę jak zaprząc do walidacji opcji znaną i lubianą bibliotekę open source – FluentValidation. Od razu zaznaczam, że ten artykuł nie jest kursem ani nawet nie muska działania tej biblioteki. Jeśli wiesz, co ona robi, ten akapit może Ci się przydać. W innym przypadku spróbuj się z nią najpierw zapoznać.
FluentValidation umożliwia walidacje w sposób „fluent” modeli. Jednak standardowo nie obsługuje opcji. Można to w dość prosty sposób zmienić.
Spójrzmy na przykładowy model opcji:
public class FluentApiOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ClientUri { get; set; }
public string ApiUrl { get; set; }
}
Założenia będą takie jak w poprzednim przykładzie, tzn. podajemy albo clientId i secret, albo clientUri. Jedno z dwóch. Dodatkowo zawsze musi być ApiUrl.
Walidator Fluenta
Napiszmy do tego standardowy walidator fluentowy:
public class FluentApiOptionsValidator: AbstractValidator<FluentApiOptions>
{
public FluentApiOptionsValidator()
{
RuleFor(x => x.ApiUrl)
.NotEmpty();
RuleFor(x => x.ClientUri)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.ClientId) && string.IsNullOrWhiteSpace(x.ClientSecret))
.WithMessage("Jeśli nie podajesz clientId i sekretu, musisz podać ClientUri")
.MinimumLength(5)
.WithMessage("ClientUri jest za krótkie");
RuleFor(x => x.ClientId)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.ClientUri))
.WithMessage("Musisz podać ClientId i sekret, jeśli nie podajesz ClientUri");
RuleFor(x => x.ClientSecret)
.NotEmpty()
.When(x => !string.IsNullOrWhiteSpace(x.ClientId))
.WithMessage("Brak client secret");
}
}
I dopiero teraz zacznie się zabawa.
Mając już walidator do konkretnego modelu, musimy teraz stworzyć swój własny walidator opcji – ten, implementujący interfejs IValidateOptions. Dlaczego?
Integracja z opcjami
Jak już mówiłem, FluentValidation nie jest domyślnie zintegrowany z mechanizmem opcji w .NET. A szkoda, bo mógłby być. Zatem sami musimy sobie taką integrację zapewnić. I tutaj przychodzi z pomocą IValidateOptions. Utworzymy generyczny walidator, żeby można go było używać z każdym typem opcji. To w najprostszej postaci może wyglądać tak:
public class GenericFluentValidator<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IServiceProvider _serviceProvider;
private readonly string _name;
public GenericFluentValidator(string name, IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_name = name;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (_name != null && _name != name)
return ValidateOptionsResult.Skip;
using var scope = _serviceProvider.CreateScope();
var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();
ValidationResult res = validator.Validate(options);
if (res.IsValid)
return ValidateOptionsResult.Success;
var errorArray = res.Errors.Select(e => e.ErrorMessage).ToArray();
var msg = string.Join(Environment.NewLine, errorArray);
return ValidateOptionsResult.Fail(msg);
}
}
To na pierwszy rzut oka może okazać się zawiłe, ale jest naprawdę bardzo proste.
Pomińmy na razie konstruktor – w jakiś sposób dostaniemy IServiceProvider i nazwę (jeśli używamy named options). Przejdźmy od razu do metody Validate.
Najpierw sprawdzamy, czy używamy named options i czy to odpowiedni do tego walidator. Jeśli nie, to olewamy.
Następnie tworzymy sobie scope’a, żeby pobrać z niego serwis implementujący IValidator<TOptions>. A teraz pytanie – co to takiego? Interfejs IValidator<T> pochodzi z FluentValidation. Wszystkie walidatory ten interfejs implementują. A więc po prostu szukamy walidatora dla konkretnego typu.
Gdy już mamy go, to w linijce 21 uruchamiamy walidację. Jeśli się udała, zwracamy sukces. Jeśli nie, zwracamy listę błędów z tego walidatora w postaci jednego stringa.
Rejestracja
Teraz, jak już wiesz, trzeba zarejestrować ten GenericFluentValidator:
builder.Services.AddSingleton<IValidateOptions<FluentApiOptions>>(sp =>
{
return new GenericFluentValidator<FluentApiOptions>("", sp);
});
Po prostu dodajemy go tak jak w poprzednich przykładach, tyle że z wykorzystaniem fabryki – dzięki czemu możemy przekazać IServiceProvidera. Parametrem name na razie się nie przejmuj, zajmiemy się nim później.
Na koniec wystarczy zarejestrować konkretny walidator modelu:
No i wszystko śmiga. Ale przyznasz, że żeby ogarnąć jeden model w taki sposób, trzeba się sporo napisać. Właściwie upierdliwe są te rejestracje. Ale jest na to metoda…
Upraszczamy rejestracje
Posłużymy się extensioniem, żeby ułatwić sobie pracę. Całą rejestrację przeniesiemy do extensiona. Przy okazji załatwi nam to problem named options:
public static class OptionsBuilderExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions, TValidator>(this OptionsBuilder<TOptions> builder)
where TOptions : class
where TValidator: class, IValidator<TOptions>
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
{
return new GenericFluentValidator<TOptions>(builder.Name, sp);
});
builder.Services.AddSingleton<IValidator<TOptions>, TValidator>();
return builder;
}
}
Dzięki takiemu rozwiązaniu, model opcji możemy zarejestrować w taki sposób:
W tym pakiecie znajdują się metody, które automatycznie rejestrują wszystkie walidatory ze wskazanego assembly. Więc teraz wystarczy uprościć nasze rozszerzenie i wywalić z niego rejestrację walidatora:
public static class OptionsBuilderExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> builder)
where TOptions : class
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
{
return new GenericFluentValidator<TOptions>(builder.Name, sp);
});
return builder;
}
}
I tutaj rodzi się pytanie, czy walidowanie opcji za pomocą FluentValidation ma sens i czy nie jest to przerost formy nad treścią. Jak zwykle – to zależy. Jeśli szybciej/lepiej pracuje Ci się z FluentValidation i widzisz zysk w takim sprawdzaniu, zamiast pisać własny kod walidujący, to na pewno ma to sens, a czas włożony w konfigurację tego ustrojstwa szybko się zwróci. Zwłaszcza, że jest już sporo gotowych walidatorów na „dzień dobry”. A jak widzisz, konfiguracja nie jest aż taka straszna.
Nowości w .NET8
Walidacja opcji bez użycia refleksji – zgodność z AOT
.NET8 przynosi pewną, małą nowość. Kod, który używa refleksji (na przykład ten standardowy sposób walidacji powyżej), nie jest zgodny z AOT. Dlatego też nie moglibyśmy używać walidacji opcji w kompilacji AOT.
Możemy napisać częściowego walidatora, którego kod zostanie wygenerowany automagicznie i ten kod nie będzie już używał refleksji.
Brzmi jak kupa roboty? Może i tak, ale spójrz na to:
public class MyAppConfig
{
[EmailAddress]
[Required]
public string SmtpAdress { get; set; }
[Range(1, 10)]
public int TraceLevel { get; set; }
}
To jest model, który będziemy walidować. A walidator będzie wyglądał tak:
[OptionsValidator]
public partial class MyAppConfigValidator: IValidateOptions<MyAppConfig>
{
}
I to jest dokładnie tyle. Dobrze widzisz. Tutaj są istotne dwie rzeczy:
klasa musi być oznaczona jako partial
klasa musi posiadać atrybut [OptionsValidator]
W innym wypadku po prostu się nawet nie skompiluje.
W efekcie zostanie wygenerowany kod dla klasy MyAppConfigValidator, który będzie miał zaszytą całą logikę walidacji w sobie. I to wszystko zadzieje się bez wykorzystania refleksji. Dzięki temu możesz tego używać w kompilacjach AOT.
Bindowanie opcji bez użycia refleksji – zgodność z AOT
Jeśli chodzi o rejestracje opcji, tj. Configure(TOptions), Bind i Get, to standardowo była do tego wykorzystywana refleksja. W .NET8 w aplikacjach internetowych domyślnie konfiguracja jest realizowana przez generator kodu. Czyli jest to zgodne z AOT i nie wymaga żadnych zmian.
Jeśli jednak chcesz być zgodny z AOT i nie tworzysz aplikacji webowej, musisz na takie działanie jawnie wyrazić zgodę. Wystarczy dodać ustawienie w projekcie:
A czy ty walidujesz swoje opcje? Daj znać w komentarzu 🙂
Dziękuję Ci za przeczytanie tego artykułu. Wierzę, że walidacja opcji stała się dla Ciebie jasna i będziesz jej używać. Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
No i podziel się tym artykułem z kimś, komu uważasz że się przyda 🙂
Witaj podróżniku. Skoro już tu jesteś, prawdopodobnie wiesz czym jest EntityFramework. Jeśli jednak nie, to przeczytaj kolejny akapit. W tym artykule postaram szybko wprowadzić Cię w jego świat, żebyś w ciągu kilku minut był w stanie zacząć z nim wspólną przygodę.
Czym jest Entity Framework? – rozwiń jeśli nie wiesz
EntityFramework Core zwany również EfCore to narzędzie typu ORM – Object Relational Mapper. Prostymi słowami to mechanizm, który umie zamienić (mapować) rekordy znajdujące się w bazie danych, na obiekty modelowe. Po co to? To jest zwykły „pomagacz”. Bo oczywiście możesz napisać w starym dobrym ADO.NET kod w stylu:
//pseudokod
var resultList = new List<Employee>();
string sql = "SELECT * from employees";
var reader = _db.OpenQuery(sql);
while(reader.Read())
{
var employee = new Employee();
employee.Id = reader.GetGuid("id");
employee.Name = reader.GetString("name");
employee.Salary = reader.GetDecimal("salary");
resultList.Add(employee);
reader.Next();
}
return resultList;
ale ostatecznie większość programistów, pisząc w ADO.NET i tak kończyła z jakąś ubogą formą własnego ORMa.
Dlatego powstały właśnie takie narzędzia, żeby ułatwić pracę. Programista wie jak ma pobrać dane i wie jaki obiekt chce zwrócić. Nie musi się zajmować czarną robotą w stylu mapowania tak jak wyżej.
Jest sporo różnych ORMów na rynku. Myślę, że najpopularniejsze z nich (przynajmniej w mojej subiektywnej ocenie) to Dapper, nHibernate i właśnie EfCore. Przy czym Dapper zalicza się do grupy tzw. micro orm. Dlatego, że nie potrafi tyle, co EfCore, czy nHibernate. Wciąż musisz pisać własnego SQLa, ale za to dostajesz obiekt już elegancko zmapowany. No i Dapper jest zdecydowanie szybszy.
Kwestia nazwy
Pewnie zobaczysz nie raz obok siebie nazwy w stylu „Entity Framework”, „EfCore”, „Entity Framework Core”. Czy to jest ten sam produkt? Nie do końca. Jeśli chodzi o „Entity Framework” jest on używany w .NET Framework (czyli w tej starszej technologii przed .NET Core). Natomiast „EfCore”, czy po prostu „Entity Framework Core” to wersja dla .Net Core i nowszych. Czasem możesz widzieć te nazwy zamiennie. W tym artykule, pisząc „Ef”, „Entity Framework” zawsze będę miał na myśli „EfCore”.
Kwestia prędkości
Oczywiście, że ORMy są wolniejsze niż ręczne mapowanie. Jednak to zwolnienie w większości przypadków jest niezauważalne. Jeśli jednak faktycznie w niektórych miejscach potrzebujesz większej szybkości, możesz tylko te miejsca traktować inaczej – bez ORMa.
Instalacja narzędzi EfCore
Na początek sprawdź, czy masz już zainstalowane narzędzia do EF. Wklep taką komendę do command line’a:
dotnet ef --version
Jeśli Ci się wykrzaczyło, znaczy że nie masz. W takim przypadku zainstaluj:
dotnet tool install --global dotnet-ef
Powyższe polecenie zainstaluje globalnie narzędzia do EF, a poniższe zaktualizuje do najnowszej wersji:
dotnet tool update --global dotnet-ef
Przykładowa aplikacja
Jak zwykle, przygotowałem małą przykładową aplikację do tego artykułu. Możesz ją pobrać z GitHuba.
Aplikacja to prosty programik w stylu ToDoList. W ramach ćwiczeń polecam Ci go wykończyć.
Wymagane NuGety:
EfCore nie jest standardową biblioteką. Musisz pobrać sobie minimum dwa NuGety:
Microsoft.EntityFrameworkCore – cały silnik dla EfCore
Microsoft.EntityFrameworkCore.Design – to jest zestaw narzędzi potrzebny programiście podczas „projektowania” aplikacji. Są to narzędzia, które np. pomagają utworzyć migracje (o tym później). Nie są jednak dostępne w czasie działania aplikacji (runtime). Więc jeśli nie będziesz robił migracji, nie musisz tego instalować. Ale lepiej mieć, niż nie mieć.
Biblioteka do obsługi konkretnego typu bazy danych (DBMS) – inna jest dla MSSQL, inna dla Sqlite, inna dla Oracle itd. Ich nazwy określają do jakiej bazy danych się odnoszą, np: Microsoft.EntityFrameworkCore.SqlServer – do obsługi MSSQL.
Modele bazodanowe
Na początek stwórzmy sobie podstawowy model bazodanowy do aplikacji.
public enum ToDoItemStatus
{
NotStarted,
Running,
Done
}
public class ToDoItem: BaseDbItem
{
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
Mamy tutaj zwykły ToDoItem, który może być w 3 stanach:
Nierozpoczęty (NotRunning)
Rozpoczęty (Running)
Zakończony (Done)
Zauważysz też, że mój model dziedziczy po BaseDbItem. To zwykła klasa abstrakcyjna, która zawiera Id:
public abstract class BaseDbItem
{
public Guid Id { get; set; }
}
Oczywiście nie musisz jej tworzyć i możesz umieścić w każdym modelu pole Id. Ja jednak wolę to zrobić, bo to pomaga i może uprościć wiele rzeczy w późniejszym utrzymaniu takiego systemu. Dlatego też zachęcam Cię, żebyś stosował taką klasę bazową.
Kontekst bazy danych
To jest najważniejszy element EfCore. Technicznie to klasa, która dziedziczy po DbContext. Odzwierciedla całą zawartość bazy danych – wszystkie tabele, które chcesz mieć. Dlatego też musisz napisać taką klasę. W swojej najprostszej i NIEWYSTARCZAJĄCEJ postaci może wyglądać tak:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
}
Właściwie to zbiór właściwości typu DbSet. DbSet odnosi się do tabeli w bazie danych. Dlatego też dla każdej tabeli będziesz miał osobne właściwości DbSet. Stwórzmy zatem drugi model – użytkownik. Dodamy też od razu go do kontekstu:
public class User: BaseDbItem
{
public string Name { get; set; }
}
//dodajmy go też jako właściciela konkretnego ToDoItem:
public class ToDoItem: BaseDbItem
{
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
//no i do kontekstu:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
public DbSet<User> Users { get; set; }
}
Połączenie z bazą danych
Mając stworzony DbContext, możemy teraz utworzyć połączenie do bazy danych. Sprowadza się to do trzech kroków:
Przechowywanie ConnectionString
ConnectionString do bazy będzie przechowywany oczywiście w ustawieniach – appsettings. Ja trzymam w głównym pliku: appsettings.json zamiast appsettings.Development.json, ponieważ jest mi prościej (za chwilę dowiesz się dlaczego).
Pamiętaj, że ten plik ląduje w repozytorium na serwerze, więc jeśli umieszczasz tam jakieś hasło, to pamiętaj, że to zła praktyka i powinieneś posłużyć się sekretami. W przeciwnym razie każdy z dostępem do repozytorium zobaczy Twoje hasło.
To teraz jak utworzyć ConnectionString do bazy lokalnej? Bardzo prosto. Najpierw otwórz sobie okienko SQL Server Object Explorer:
To okienko pozwala Ci zarządzać lokalnymi bazami. Teraz odnajdź bazę master na lokalnym serwerze i wejdź w jej właściwości (ważne, żebyś rozwinął bazę master. Jeśli jej nie rozwiniesz, nie zobaczysz connection stringa):
Z właściwości możesz odczytać connection stringa:
Skopiuj go i wklej do ustawień w pliku appsettings.json lub w secrets.json:
Teraz zwróć uwagę, że w connection string jest zaszyta nazwa bazy – master. Dlatego też powinieneś ją zmienić na swoją docelową bazę. Ta baza nie musi istnieć, EfCore sam ją sobie utworzy, ale zmień tą nazwę. Nigdy nie pracuj na bazie master (traktuj ją tylko jako do odczytu):
Musimy teraz w jakiś sposób powiedzieć EfCore’owi z jaką bazą chcemy się łączyć. Robimy to poprzez kontekst bazy danych. Wystarczy oprogramować konkretną wersję konstruktora w taki sposób:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
public DbSet<User> Users { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
I to w zasadzie wystarczy. Oczywiście nic Ci nie broni, żebyś w tym konstruktorze posiadał jakiś kod. Ale to w domyślnym mechanizmie wystarczy.
Pamiętaj tylko, że jeśli używasz EfCore z dependency injection, stwórz konstruktor z generyczną wersją DbContextOptions – tak jak wyżej: DbContextOptions<ApplicationDbContext> zamiast samego DbContextOptions.
Dlaczego? Jeśli masz w aplikacji jeden DbContext, to nie ma żadnej różnicy. Jednak, jeśli masz ich kilka, to użycie generycznej wersji DbContextOptions<T> zapewni, że do odpowiednich kontekstów zawsze trafią odpowiednie opcje. W przeciwnym razie dependency injection może coś pochrzanić.
Więc lepiej wyrób sobie taki nawyk.
Teraz dwa słowa wyjaśnienia. EfCore działa w taki sposób, że to kontekst bazy danych musi wiedzieć z jaką bazą się łączy. I ten connection string przekazuje się w konstruktorze. Jednak nie możesz go tam zahardkodować, bo to zła praktyka z punktu widzenia bezpieczeństwa i utrzymania takiego kodu.
Oczywiście mógłbyś posłużyć się mechanizmem konfiguracji i wstrzyknąć tutaj jakieś IOptions. Ale to już jest domyślnie. Ten domyślny sposób korzysta z tego przeciążenia konstruktora – wstrzykuje do kontekstu odpowiednie opcje.
Podczas rejestracji kontekstu, rejestrowane są też jego opcje (DbContextOptions), w którym connection string jest już obecny. Jeśli dobrze zarejestrujemy 🙂
Rejestracja kontekstu bazy danych
Kontekst rejestrujemy tak jak każdy inny serwis, dodatkowo podając connection stringa i mówiąc mu, jakiej bazy chcemy użyć.
var config = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});
Uwaga! GetConnectionString to metoda pomocnicza, która pobiera sekcję z ustawień aplikacji o nazwie „ConnectionStrings”. Więc jeśli Twoja sekcja nazywa się inaczej lub w innym miejscu masz tego connection stringa, wtedy posłuż się standardowym wywołaniem w stylu config[„MojaSekcja:Connection”].
Pamiętaj, że UseSqlServer to extension method pochodzące z NuGet: Microsoft.EntityFrameworkCore.SqlServer. Jeśli zainstalujesz inny NuGet, np. do obsługi SQLite, wtedy będziesz używał metody UseSqlite. Analogicznie z innymi bazami danych.
Inne sposoby
Są też inne prawilne sposoby rejestracji takiego kontekstu. Np. za pomocą DbContextFactory, o którym za chwilę.
Czym są migracje w EfCore?
Nieodłącznym mechanizmem EfCore są migracje. EfCore jest w stanie sam utworzyć bazę danych. Co więcej, jest w stanie pilnować, żeby struktura bazy danych odpowiadała strukturze w Twoich modelach (choć pamiętaj, że świat relacyjny i obiektowy to zupełnie dwa różne miejsca). Co więcej, od jakiegoś czasu EfCore potrafi też stworzyć modele na podstawie tabel istniejących w bazie danych.
Na pewno spotkałeś się z pojęciami Code First i Database First. To właśnie opisuje sposób tworzenia bazy danych. Podejście Code First tworzy bazę danych z istniejących modeli obiektowych. Database First z kolei na odwrót – tworzy modele obiektowe na podstawie struktury bazy danych. W EfCore jeszcze do niedawna było możliwe użycie jedynie podejścia CodeFirst.
EfCore, aby to wszystko było możliwe, posługuje się migracjami. Z technicznego punktu widzenia, migracja to zwykła klasa, która posiada przepis na utworzenie konkretnej wersji bazy danych.
Gdy zmieniasz swój model i uznajesz go za pewną skończoną wersję, powinieneś zrobić migrację. Robi się to za pomocą narzędzi EfCore. Wystarczy, że w konsoli przejdziesz do folderu, w którym jest Twój projekt i wpiszesz tam takie polecenie:
dotnet ef migrations add "InitialDbCreate"
InitialDbCreate to oczywiście nazwa Twojej migracji. Ta jest standardową nazwą na pierwszą migrację. Poza tym przyjęło się, że nazwa migracji powinna mówić o tym, co migracja zawiera, np. „AddedClientName” albo „RemovedOwnerModel” itd.
Wykonanie tego polecenia stworzy folder Migrations i dwa pliki w środku. Pierwszy to migracja (klasa dziedzicząca po Migration), a drugi to snapshot aktualnej wersji – powstaje na podstawie migracji. Jeśli chcesz od nowa zbudować bazę danych, po prostu możesz usunąć cały folder i bazę danych.
Mając taką migrację, możesz teraz uaktualnić / stworzyć bazę danych poleceniem:
dotnet ef database update
To polecenie po pierwsze połączy się z bazą danych, a po drugie utworzy lub odczyta specjalną tabelę, w której są zapisane nazwy migracji, z których powstaje baza danych. Patrzy na ostatni wpis i wykonuje kolejną migrację – aktualizuje strukturę bazy danych.
Oczywiście dokładnie tak samo, jak tworzyłbyś skrypty SQL aktualizujące strukturę – tutaj też to może się nie udać. Przykładowo, jeśli istnieją rekordy i jakaś kolumna ma wartości NULL, a teraz chcesz zrobić, żeby ta kolumna nie mogła być NULLem, to się nie uda. Więc trzeba na takie rzeczy zwracać uwagę.
Jak to działa?
Zarówno tworzenie migracji jak i aktualizacja bazy uruchamia Twoją aplikację. Najpierw apka jest budowana (choć to można pominąć, dodając do polecenia argument --no-build, ale uważaj na to, bo może się okazać że migracja nie zostanie przeprowadzona), potem uruchamiana w specjalny sposób. I tutaj może pojawić się problem z odczytem connection stringa. W .NET6 narzędzia szukają connection stringa na produkcyjnej wersji środowiska (chyba że masz globalnie ustawioną zmienną środowiskową ASPNET_ENVIRONMENT z wartością Development) – dlatego wcześniej pisałem o tym, że dla ułatwienia connection string trzymany jest w głównym appsettings.json (lub secrets.json).
Pewnie można to jakoś zmienić, ale jeszcze nie rozkminiłem jak 🙂
Konfiguracja EfCore
EfCore jest na tyle „mądry”, że dużo potrafi wywnioskować na temat modeli będących w DbContext. I tak naprawdę jeśli w ogóle nie skonfigurujesz swoich modeli, to on i tak rozkimi, że pole o nazwie Id jest kluczem głównym; że pole OwnerId jest jakimś kluczem obcym. Co więcej nawet jak zobaczy właściwość wskazującą na inny obiekt, też stworzy z tego relację.
Jednak nie wszystko jest w stanie wydedukować. Poza tym mamy w naszym przypadku jeden poważny problem. Kolumna Status w tabeli ToDoItems domyślnie została utworzona jako int (EfCore tak domyślnie mapuje enumy). Czy to dobrze? Zdecydowanie nie. W tym momencie mamy możliwe 3 wartości:
public enum ToDoItemStatus
{
NotStarted,
Running,
Done
}
NotStarted zostało zmapowane do 0, Running to 1, a Done to 2. Fajnie. A jak ktoś dołoży kolejną?
public enum ToDoItemStatus
{
NotStarted,
Running,
Paused,
Done
}
Wtedy w bazie wszystko się pop…rzy. Bo nagle Paused przyjmie wartość 2, a Done 3. Z bazy będzie wynikało, że żadne zadanie nie zostało ukończone. Za to te, co były ukończone będą uważane za wstrzymane. Dlatego mimo wszystko lepiej enumy trzymać jako string.
To wszystko i wiele więcej możemy uzyskać dzięki konfiguracji modeli. Konfigurację można przeprowadzić na dwa sposoby:
za pomocą adnotacji
używając fluentowej konfiguracji
Osobiście jestem zwolennikiem tej drugiej, bo dzięki temu modele bazodanowe są czyste. Poza tym adnotacje nie pozwolą na wszystko.
Przykład konfiguracji za pomocą adnotacji może wyglądać tak:
public class ToDoItem: BaseDbItem
{
[Required]
public Guid OwnerId { get; set; }
[ForeignKey(nameof(OwnerId)]
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
[MaxLength(30)]
public string Title { get; set; }
public string Description { get; set; }
}
Tutaj mówimy do ORMa trzy rzeczy:
Pole OwnerId ma być wymagane (not null)
Owner to klucz obcy do tabeli Users, odpowiada mu właściwość OwnerId
Pole Title ma mieć maksymalnie 30 znaków
Tych adnotacji jest oczywiście więcej, ale moim skromnym zdaniem szkoda na nie czasu. Przejdźmy od razu do Fluent Configuration.
Konfiguracja typu Fluent
Możesz ją przeprowadzić na dwa sposoby. Czysto – tzn. oddzielne klasy odpowiadają za konfigurację konkretnych modeli lub brudno – wszystkie modele w jednej metodzie. Najpierw pokażę Ci tę „brudną” konfigurację.
Przejdźmy do naszego DbContext. Tam jest do przeciążenia taka metoda jak OnModelCreating, w której możemy te modele pokonfigurować. Ale zanim do tego dojdzie, zróbmy małą, ale cholernie istotną zmianę w modelu:
public class ToDoItem: BaseDbItem
{
public Guid OwnerId { get; set; }
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
Dodałem właściwość OwnerId. Dzięki temu będziemy mogli przeszukiwać tabelę ToDoItems pod kątem konkretnych użytkowników z użyciem Linq. Jednocześnie nie będziemy musieli robić joina z tabelą Users. Skonfigurujmy to teraz:
Ta konfiguracja fluentowa również sama się świetnie opisuje 🙂 Założę się, że bez tłumaczenia od razu widzisz jakie masz możliwości i co robi powyższy kod.
Generalnie, posługując się metodą Property, możemy ustawiać ograniczenia, konwertery itd. dla konkretnych właściwości.
Zwróć uwagę szczególnie na linijki 10 i 11, gdzie konwertujemy enum do stringa.
Czy warto bawić się w konfigurację, skoro EfCore robi dużo za nas? Moim zdaniem warto. Ja nie za bardzo lubię oddawać kontrolę nad kodem, są rzeczy które wolę jasno określić. Jednak, jeśli Ty jesteś bardziej otwarty lub nie potrzebujesz niektórych ograniczeń, możesz spróbować dać EfCore wolną wolę i zobaczyć co się stanie.
Ignorowanie właściwości
EfCore domyślnie umieszcza w bazie wszystkie właściwości z modelu (w przeciwieństwie do nHibernate). Tutaj też możemy mu powiedzieć jakie właściwości ma ominąć. Np. jeśli bym nie chciał, żeby do bazy trafiał czas zakończenia zadania, mógłbym to skonfigurować tak:
todoItemBuilder.Ignore(x => x.EndDate);
Jak widzisz sam konkretny builder danego modelu ma w sobie też różne ciekawe elementy. Pozwala między innymi na zmianę nazwy tabeli.
Klucze
Możesz ustawić dodatkowy klucz na jednym lub na kilku polach. Przy czym jeśli chodzi o klucz ustawiany na kilku polach, jest to możliwe tylko za pomocą fluent configuration. Nie można tego zrobić adnotacjami:
todoItemBuilder.HasKey(x => x.Status);
todoItemBuilder.HasKey(x => new {x.OwnerId, x.Status});
Tutaj utworzyłem dwa klucze – jeden na pole Status, drugi jest kluczem na dwóch polach – OwnerId i Status. Jak widzisz, jest to banalnie proste.
Relacje w EfCore
Relacje to dość ważny aspekt w bazach relacyjnych 😀 W EfCore konfiguruje się je bardzo prosto, trzeba pamiętać tylko o kilku rzeczach i pojęciach:
relację konfigurujemy tylko z jednej strony. Tzn. możemy ustawić, że ToDoItem ma jednego ownera albo User ma wiele ToDoItem'ów. Nie musimy konfigurować dwóch modeli. Tzn. nie musimy w User podawać, że ma wiele ToDoItem'ów i jednocześnie w ToDoItem, że ma jednego usera. Wystarczy, że skonfigiujemy to po jednej stronie
encja zależna (dependent entity) – to jest ta encja, która zawiera klucz obcy. Czyli w naszym przypadku encją zależną jest ToDoItem – ponieważ to w tej klasie zdefiniowaliśmy klucz obcy
encja główna (principal entity) – to jest ta encja, która jest „rodzicem” encji zależnej. W naszym wypadku będzie to User. No, jeśli usuniemy Usera, jego encje zależne też powinny zostać usunięte (czyli jego wszystkie zadania).
właściwość nawigacyjna (navigation property) – to jest właściwość w encji zależnej i/lub głównej, która wskazuje na tę drugą stronę. Bardziej po ludzku, w modelu ToDoItems naszym navigation property jest Owner – to ta właściwość wskazuje na konkretny model encji głównej. Możemy dodać takie navigation property również do modelu User, dodając np. listę todo itemów:
public class User: BaseDbItem
{
public string Name { get; set; }
public List<ToDoItem> Items { get; set; }
}
Generalnie navigation property może posiadać kilka typów:
kolekcję zależnych encji (jak wyżej właściwość Items)
właściwość wskazującą na jedną powiązaną encję (np. w naszym modelu ToDoItems jest to Owner)
Czasem mówimy też o inverse property. To jest zwykłe navigation property tyle, że po drugiej stronie relacji. Jakby to powiedzieć prościej… Jeśli nasza encja User miałaby listę Itemów jak w powyższym przykładzie, wtedy ta lista Itemów byłaby takim „inverse property”. Kiedy to się przydaje i po co?
Generalnie EfCore potrafi sam wykminić wiele rzeczy na podstawie konwencji nazewnictwa, zależności itd. Ale w pewnych sytuacjach nie ogranie. Czasem możesz dostać błąd, że nie może „wydedukować” rodzaju relacji. Wtedy, żeby mu ułatwić możesz zastosować ten inverse property. I konfiguracja takiej relacji wyglądałaby tak:
Mam tu na myśli taką konfigurację, że poszczególne klasy są konfigurowane w osobnych plikach – tak jak ma to miejsce przy nHibernate. Najpierw warto utworzyć sobie konfiguracyjną klasę bazową, np:
public abstract class BaseModelConfig<TModel> : IEntityTypeConfiguration<TModel>
where TModel : BaseDbItem
{
public virtual void Configure(EntityTypeBuilder<TModel> builder)
{
builder.Property(x => x.Id)
.IsRequired()
.ValueGeneratedOnAdd();
}
}
Zwróć uwagę, że nasza klasa bazowa implementuje interfejs IEntityTypeConfiguration. Tutaj właśnie przyda się też bazowy model, o czym pisałem wcześniej. Naprawdę warto go mieć.
Następnie konfiguracja pozostałych modeli polega na dziedziczeniu po klasie BaseModelConfig:
Oczywiście nie musisz tworzyć abstrakcyjnej klasy bazowej. Ważne, żeby konkretne konfiguracje implementowały interfejs IEntityTypeConfiguration. Jednak taka klasa bazowa dużo ułatwia.
Na koniec musisz jeszcze powiedzieć w kontekście, gdzie ma szukać konfiguracji. DbContext będzie szukał implementacji interfejsu IEntityTypeConfiguration w Assembly, które mu podasz. W tym przypadku wykorzystujemy aktualne, główne:
Przede wszystkim pamiętaj, że EfCore pełni rolę repozytorium. Dlatego nie twórz repozytorium (mówię tutaj o wzorcu projektowym), używając EfCore – to jest częsty błąd w tutorialach na YouTubie i zbyt wielu artykułach. Pamiętaj – EfCore to repozytorium.
Jeśli chodzi o programowanie webowe, to najczyściej jest używać EfCore w serwisach. Kontroler odwołuje się do serwisu, serwis ma wstrzyknięty DbContext. A teraz zobaczmy, w jaki sposób można dodać rekord. Spójrz na poniższy serwis:
public class UserService
{
private readonly ApplicationDbContext _dbContext;
public UserService(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task AddOrUpdateUser(User data)
{
_dbContext.Users.Update(data);
await _dbContext.SaveChangesAsync();
}
}
Najpierw wstrzykujemy do konstruktora ApplicationDbContext. Następnie możemy się nim posługiwać tak jakbyśmy używali zwykłego wzorca repozytorium. A nawet lepiej, bo można używać Linq do pobierania danych.
Zwróć tylko uwagę na to, że dodawanie/modyfikacja danych w poszczególnych DbSet'ach jeszcze niczego nie zmienia. Na tym poziomie działa taki sprytny wewnętrzny mechanizm EfCore (change tracker), który oznacza odpowiednie rekordy w odpowiedni sposób (ten rekord będzie zmodyfikowany, ten trzeba dodać, a tu jest jakiś do usunięcia).
Faktyczne operacje do bazy danych pójdą dopiero po wywołaniu SaveChanges. I do tego pójdą w transakcji.
Czas życia DbContext – ważne
DbContext nie jest thread-safe. Nie powinien żyć długo. Domyślnie jest wstrzykiwany jako scope (choć to można zmienić podczas konfiguracji). Zalecam zostawić domyślne zachowanie, chyba że pojawiają się problemy, to wtedy można zmienić na transient:
Jeśli używasz zwykłego WebApplication albo WebApi, to takie konstrukcje ze wstrzykiwaniem DbContext są jak najbardziej poprawne. Ale problem pojawia się przy Blazor lub aplikacjach desktopowych.
Inaczej dla Blazor i aplikacji desktopowych
Dlaczego? Jak już mówiłem, DbContext jest standardowo wstrzykiwany jako scoped. Możesz też zmienić na transient. Jednak co to oznacza dla Blazor? Tam, jeśli masz serwis oznaczony jako scope, to właściwie zachowuje się to jak singleton. Dokładnie tak samo jest w aplikacjach desktopowych.
Nie oznacza to teraz, że w Blazor wszystkie serwisy korzystające pośrednio lub bezpośrednio z bazy danych, mają być wstrzykiwane jako transient. O nie. Jest lepszy sposób. Spójrzmy na fabrykę.
Fabryka DbContext
Zamiast rejestrować DbContext, możesz zarejestrować fabrykę:
Przyznasz, że wielkiej różnicy nie ma. Różnica pojawia się w momencie używania tego kontekstu. Teraz wstrzykujemy fabrykę do serwisu:
public class UserService
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
public UserService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task AddOrUpdateUser(User data)
{
using var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Users.Update(data);
await dbContext.SaveChangesAsync();
}
}
Tutaj do dostępu do bazy danych wykorzystujemy fabrykę. Po prostu ona nam tworzy ten kontekst. Pamiętaj, że musisz tworzyć to przez using. DbContext implementuje interfejs IDisposable i jest cholernie ważne, żeby po sobie posprzątał.
Nie ma nic złego w takim tworzeniu kontekstu. Co więcej – to naturalny sposób w przypadku aplikacji desktopowych no i we wspomnianym Blazor.
Pamiętaj, że jeśli chodzi o WebApplication lub WebApi – gdzie każdy request tworzy i usuwa scope, to możesz posłużyć się tą wersją ze wstrzykiwaniem samego kontekstu.
Zapytania SELECT
Oczywiście jest to tak samo proste jak pobieranie danych ze zwykłej listy. Zwróć tylko uwagę na to, czym jest IQueryable. Spójrz na poniższy kod:
var data = dbContext.Users.Where(x => x.Name.Length > 3);
Tutaj pobieramy użytkowników, których imię ma więcej niż 3 znaki. Czy to zapytanie się wykona?
Nie wykona się. Dlatego, że metoda Where zwraca obiekt IQueryable. A to taki twór, który możesz uznać za zapytanie SQL. Możesz mu zatem dodać inne warunki, np:
var data = dbContext.Users.Where(x => x.Name.Length > 3);
data = data.Where(x => x.Name.Contains("A"));
A kiedy IQueryable zostanie wysłane do bazy danych? We wszystkich tych metodach, które zwracają coś innego niż IQueryable (a konkretnie nasz model lub jego listę), np:
var list = await data.ToListAsync();
var first = await data.FirstAsync();
var fod = await data.FirstOrDefaultAsync();
Itd. Generalnie zawsze spójrz na to, co zwraca konkretna metoda. Tam, gdzie masz IQueryable, to nie ma jeszcze żadnej pracy na bazie danych.
Join
A teraz spójrz na takie zapytanie:
using var ctx = _dbContextFactory.CreateDbContext();
var data = await ctx.ToDoItems.ToListAsync();
Co zostanie pobrane?
Same zadania. Bez ich właścicieli. To super wiadomość. EfCore domyślnie zwróci tylko te dane, o które pytamy. Nie jest to wprawdzie takie „lazy loading” jak w nHibernate (choć to też da się ustawić), jednak do bazy idą tylko najbardziej potrzebne zapytania.
Oczywiście możemy do tego zrobić joina, żeby uzyskać zadania wraz z właścicielami. Ale musimy to jasno powiedzieć. W taki sposób:
using var ctx = _dbContextFactory.CreateDbContext();
var data = await ctx.ToDoItems
.Include(x => x.Owner)
.ToListAsync();
Metoda Include powie EfCore’owi, że chcemy pobrać również właściciela zadania. Istnieje również metoda ThenInclude, która pobiera dane z kolejnej encji (w naszym przypadku byłby to User).
Uważaj na podwójny INSERT
Spójrz na tan kod:
public async Task AddOrUpdateTask(ToDoItem item)
{
User owner;
using var ctx = _dbContextFactory.CreateDbContext();
owner = await ctx.Users.FirstAsync();
item.Owner = owner;
ctx.ToDoItems.Add(item);
await ctx.SaveChangesAsync();
}
Tak naprawdę nie jest ważne skąd pochodzi owner, ważne jest że ten owner znajduje się już w bazie danych. Taki kod wybucha błędem o niepoprawnym kluczu. EfCore myśli, że skoro dodajemy nowy rekord (ToDoItem), to dodajemy go z całym inwentarzem, czyli chcemy też dodać jego ownera (technicznie – INSERT owner i INSERT todoItem). Ale User o takim id już istnieje w bazie danych.
Czemu tak się stało?
EfCore ma taki sprytny mechanizm, który nazywa się ChangeTracker. On po prostu sprawdza, co się zmieniło w encji podczas życia kontekstu bazy danych. Akurat ta encja (TodoItem) jest zupełnie nowa. Więc w ChangeTrackerze, który żyje razem z kontekstem bazy danych ma oznaczenie „New”. Wszystko co ma oznaczenie „New” musi zostać dodane do bazy przez INSERT. EfCore uznaje również, że wszystkie encje zależne też są nowe.
Ale to nie koniec niespodzianki. Jeśli stworzymy jeden DbContext i pobierzemy z niego TodoItem, następnie kontekst zostanie ubity, to po utworzeniu drugiego kontekstu ten TodoItem, który został pobrany też będzie widoczny jako nowy – w tym nowym DbContext. To jest pułapka, na którą często sam się łapałem i na którą zwracam Tobie szczególną uwagę.
Więc jak sobie z czymś takim poradzić? Po prostu:
public async Task AddOrUpdateTask(ToDoItem item)
{
User owner;
using var ctx = _dbContextFactory.CreateDbContext();
owner = await ctx.Users.FirstAsync();
item.OwnerId = owner.Id;
ctx.ToDoItems.Add(item);
await ctx.SaveChangesAsync();
}
Czyli zamiast obiektu, przypisuję Id. Czasem nawet będziesz musiał zrobić takie cudo:
Musisz znullować ownera jako obiekt, ale zostawiasz jego Id. To oczywiście dotyczy tej samej sytuacji, czyli jedna encja pochodzi z jednego kontekstu, a druga z drugiego. Może się tak zdarzyć chociażby przy WebApi. Dlatego też pomocne może okazać się przesyłanie między końcówkami modelu DTO, zamiast bazodanowego (i mocno Cię do tego zachęcam, żebyś nie przesyłał modeli bazodanowych).
Różne bazy danych, różne możliwości
Możliwości EfCore zależą trochę od rodzaju bazy danych, na jakiej pracujesz. Niektóre operacje nie będą dozwolone np. na Sqlite. Zazwyczaj pracujemy na jednym typie bazy. Ale nie zawsze. Czasem zdarza się, że potrzebujesz dwóch rodzajów. Dlatego ważne jest, żeby przetestować wszystkie funkcje na wszystkich DBMS, które wykorzystujesz.
Dziękuję Ci za przeczytanie tego artykułu. To oczywiście nie wyczerpuje tematu. To są podstawy podstaw jeśli chodzi o EfCore. Na tym blogu mam jeszcze kilka tekstów na ten temat, jeśli Cię zainteresowało, to polecam:
Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i oczywiście udostępnij artykuł osobom, które powinny wiedzieć jak podejść do EfCore.
Często zdarza się, że tworzymy własne modele, którymi przesyłamy błędy z API do klienta API. Osobiście często miałem z tym rozkminy, jak to zrobić uniwersalnie. Na szczęście w 2016 roku w życie wszedł standard RFC7807, który opisuje ProblemDetails – uniwersalny sposób przesyłania błędów.
Standard ProblemDetails
Po co powstał ten standard? Przede wszystkim REST ma być uniwersalny. Twórcy dostrzegli problem typu „każdy wuj na swój strój” – każdy robił własne modele i standardy przesyłania informacji o błędach. Chodziło też o usprawnienie automatyzacji.
Przykładowo, człowiek siedzący za monitorem często z kontekstu może wydedukować, czy błąd 404 jest związany z brakiem strony, czy może z brakiem szukanego rekordu w bazie danych. Automaty niekoniecznie.
Czym jest ProblemDetails?
Z punktu widzenia dokumentacji to jest ustandaryzowany model, który wygląda tak:
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"status": 400,
"instance": "/account/12345/msgs/abc",
}
Teraz omówię każde z tych pól:
Type
To pole jest wymagane i powinno posiadać adres do strony HTML na temat danego błędu. Informacje mają być w formie, w której człowiek może je przeczytać. Jeśli nie możesz dać żadnego adresu, daj domyślną wartość „about:blank”.
Klient API musi traktować to pole jako główne pole opisu błędu. Tzn. powinien wiedzieć, jak zareagować na przykładową wartość: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4.
Title
To ma być jakieś podsumowanie problemu czytelne dla człowieka. I tutaj uwaga – zawartość tego pola nie może się zmieniać. Tzn. że kilka „wywołań” tego samego problemu musi dać ten sam tytuł. Czyli nie zmieniaj tego w zależności od kontekstu.
Wyjątkiem od tej reguły może być lokalizowanie treści. Czyli inną możesz dać dla Niemca (po niemiecku), inną dla Polaka (po polsku).
Detail
Tutaj powinien znaleźć się opis konkretnej sytuacji czytelny dla człowieka. O ile pole title nie powinno się zmieniać w zależności od danych, to pole detail jak najbardziej może – tak jak widzisz w przykładzie. Pole jest opcjonalne.
Klient API nie powinien w żaden sposób parsować informacji zawartych w tym polu. Są inne możliwości, które pomagają w automatyzacji, o których piszę później (rozszerzenia).
Status
To pole jest opcjonalne. Jego wartością powinien być kod oryginalnego błędu. Powiesz – „ale po co, skoro kod błędu jest w odpowiedzi na żądanie?”. No niby jest. Ale niekoniecznie musi być oryginalny. Jeśli np. ruch przechodzi przez jakieś proxy albo w jakiś inny sposób jest modyfikowany, wtedy kod w odpowiedzi może być inny niż w oryginalnym wystąpieniu błędu. Dlatego w polu status zawsze podawaj oryginalny kod błędu.
Instance
Tutaj powinno znaleźć się miejsce, które spowodowało błąd. To też powinien być adres URI.
Rozszerzenia
Standard pozwala rozszerzać ProblemDetails. Tzn. możesz dodać do modelu inne właściwości. Teraz możesz powiedzieć: „I tu się kończy uniwersalność, dziękuję, do widzenia.”. I w pewnym sensie będziesz miał rację, ale tylko w pewnym sensie. Zauważ, że cały czas podstawowy model jest taki sam.
Przykład modelu z rozszerzeniem
{
"type": "https://example.net/validation-error",
"title": "Your request parameters didn't validate.",
"status": 400,
"invalid_params": [ {
"name": "age",
"reason": "must be a positive integer"
},
{
"name": "color",
"reason": "must be 'green', 'red' or 'blue'"}
]
}
Jak widzisz w przykładzie powyżej, doszło pole „invalid_params„. Takie rozszerzenia mogą być użyteczne dla klientów API lub po prostu posiadać dodatkowe informacje. Pamiętaj, że pole detail nie powinno być w żaden sposób parsowane. Więc, wracając do pierwszego przykładu, moglibyśmy dać dwa rozszerzenia, którymi klient API może się w jakiś sposób posłużyć:
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"status": 400,
"instance": "/account/12345/msgs/abc",
"balance": 30,
"price": 50
}
Generalnie rozszerzenia umożliwiają przekazanie większej ilości informacji w sposób, w jaki chcesz.
Czy muszę używać ProblemDetails?
Nie musisz. Jeśli masz już swój mechanizm, który działa, to go zostaw. ProblemDetails nie ma zamieniać istniejących mechanizmów. Ma dawać uniwersalność i brak konieczności tworzenia własnych rozwiązań.
Jeśli jednak zwracasz jakiś konkretny model, zawsze masz opcję żeby zrobić z niego swój ProblemDetails. Po prostu potraktuj swój model jako rozszerzenie. Wystarczy, że dodasz pola wymagane i będzie git.
Ale powtarzam – jeśli masz już swój działający mechanizm, niczego nie musisz robić. Nie musisz tego implementować na siłę. Jednak w nowych projektach – doradzam używanie ProblemDetails.
ProblemDetails w .NET
Na szczęście .NET ma już ogarnięte ProblemDetails i w zasadzie nie musisz tworzyć własnego mechanizmu. Różnice występują między .NET6 i .NET7. Nie piszę o wcześniejszych wersjach jako że już nie są wspierane. Jeśli utrzymujesz jakieś apki poniżej .NET6, powinieneś zmigrować do nowszej wersji.
Zwracanie problemu
W .NET standardowo możesz zwracać Problem w kontrolerze:
[HttpGet]
public IActionResult ReturnProblem()
{
return Problem();
}
Oczywiście możesz przekazać mu wszystkie pola z modelu standardowego (jako parametry metody Problem). Jeśli tego nie zrobisz, .NET sam je uzupełni domyślnymi wartościami. Z powyższego przykładu uzyskasz taką zwrotkę:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-4139aae6364cb68d2235c576689bb359-88964c6328a1d834-00"
}
Zauważ, że:
type odnosi do opisu błędu 500 (bo taki jest domyślnie zwracany przy problemie)
title – to jakaś ogólna domyślna wartość
status – jak pisałem wyżej, zwraca oryginalny kod błędu
traceId – to jest rozszerzenie .NET, które ułatwia szukanie błędów w logach
Piszemy rozszerzenie
Tutaj wszystko sprowadza się do zwrócenia obiektu ProblemDetails zamiast wywołania metody Problem(). Niestety metoda Problem() nie umożliwia dodawania rozszerzeń. Ale nic się nie bój – nie musisz wszystkiego tworzyć sam. Jest dostępna fabryka, którą wykorzystamy. Musisz ją wstrzyknąć do kontrolera, a potem użyć jej, żeby stworzyć instancję ProblemDetails:
public class ProblemController : ControllerBase
{
private readonly ProblemDetailsFactory _problemDetailsFactory;
public ProblemController(ProblemDetailsFactory problemDetailsFactory)
{
_problemDetailsFactory = problemDetailsFactory;
}
[HttpGet]
public IActionResult ReturnProblem()
{
var result = _problemDetailsFactory.CreateProblemDetails(HttpContext);
var extObject = new
{
errorType = "LoginProblem",
systemCode = 10
};
result.Extensions["additionalData"] = extObject;
return Unauthorized(result);
}
}
Wszystko powinno być dość jasne. Tylko zwróć uwagę na jedną rzecz. Właściwość Extensions z klasy ProblemDetails to jest słownik. Zwykły Dictionary<string, object>. Co nam to daje? Kluczem musi być string (to będzie nazwa pola), ale za to wartością może być już cokolwiek. Może być string, liczba jakaś, a nawet cały obiekt – to, co zrobiliśmy tutaj.
Utworzyłem jakiś anonimowy obiekt i dodałem go jako rozszerzenie. W efekcie ten obiekt zostanie zserializowany (domyślnie do JSON).
Oczywiście na koniec musimy zwrócić problem posługując się jedną z metod w stylu BadRequest, NotFound, Unauthorized itd. Ja przykładowo wybrałem Unauthorized, ale pewnie w rzeczywistym projekcie, najchętniej użyjesz BadRequest.
Zwróć uwagę na jedną rzecz. W response będziesz miał kod 401 (bo zwróciliśmy Unauthorized), natomiast w modelu ProblemDetails masz 500 – bo z takim kodem jest tworzony domyślnie ProblemDetails. Oczywiście wypadałoby to ujednolicić, przekazując do fabryki odpowiedni kod błędu:
var result = _problemDetailsFactory.CreateProblemDetails(HttpContext, statusCode: (int)HttpStatusCode.Unauthorized);
Zobacz też, że nigdzie nie musisz podawać właściwości type. I super! .NET sam ma wszystko ładnie zmapowane do strony https://tools.ietf.org/html/rfc7231, na której są już opisane wszystkie błędy.
Właściwość type jest uzupełniana na podstawie przekazanego statusCode. Oczywiście, jeśli potrzebujesz, możesz dać swoje type w parametrze metody Problem() lub fabryki.
Obsługa błędów
Cały mechanizm umożliwia też elegancką obsługę błędów. Jeśli gdziekolwiek zostanie rzucony nieprzechwycony wyjątek, standardowe działanie jest takie, że zwracany jest błąd 500. Natomiast mechanizm ProblemDetails w .NET tworzy odpowiedź z użyciem tego standardu. Niestety… dopiero w .NET7. Tu jest ta różnica. W .NET7 jest to dostępne na „dzień dobry”, natomiast w .NET6 trzeba sobie samemu to dopisać. Chociaż istnieje NuGet Hellang.Middleware.ProblemDetails którego możesz użyć we wcześniejszych wersjach. Ja go nie używałem, więc tylko daję Ci znać, że jest.
Wyjątki i ProblemDetails w .NET6
W .NET6 musisz do tego napisać własny mechanizm (lub posłużyć się wspomnianym Nugetem). Możesz np. napisać middleware, który Ci to ogarnie. Taki middleware w najprostszej postaci mógłby wyglądać tak:
public class ProblemDetailsMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleProblemDetails(context, ex);
}
}
private async Task HandleProblemDetails(HttpContext context, Exception ex)
{
var factory = context.RequestServices.GetRequiredService<ProblemDetailsFactory>();
var problem = factory.CreateProblemDetails(context,
title: "Unhandled exception",
detail: ex.GetType().ToString());
problem.Extensions["message"] = ex.Message;
if (IsDevelopement(context))
problem.Extensions["stackTrace"] = ex.StackTrace;
context.Response.StatusCode = problem.Status.Value;
await context.Response.WriteAsJsonAsync(problem, null, "application/problem+json");
}
private bool IsDevelopement(HttpContext context)
{
var hostEnv = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
return hostEnv.IsDevelopment();
}
}
Jak widzisz, po prostu zapisuję problem do odpowiedzi. Sprawdzam też środowisko. Jeśli jest deweloperskie, to nie waham się pokazać stack trace 🙂 W innym przypadku – ze względów bezpieczeństwa lepiej tego nie robić. W takim przypadku musisz też uważać na to, jakie komunikaty dajesz, gdy rzucasz jakiś wyjątek – nie mogą być drogowskazem dla hackerów. Pisałem o tej „podatności” w książce „Zabezpieczanie aplikacji internetowych w .NET – podstawy„.
To jest najprostszy możliwy middleware. Dobrze byłoby również zalogować taki błąd.
Oczywiście musisz pamiętać o dodaniu tego middleware do pipeline:
builder.Services.AddScoped<ProblemDetailsMiddleware>();
var app = builder.Build();
app.UseMiddleware<ProblemDetailsMiddleware>();
Pamiętaj, że middleware’y obsługujące wyjątki powinny być uruchamiane jak najwcześniej w pipeline.
Wyjątki i ProblemDetails w .NET7
Jak już pisałem, w .NET7 to masz „out-of-the-box”. Wystarczy zrobić dwie rzeczy. Podczas rejestracji serwisów dodaj serwisy od ProblemDetails:
builder.Services.AddProblemDetails();
A przy konfiguracji middleware pipeline włóż obsługę wyjątków:
app.UseExceptionHandler();
A jeśli byś chciał dodać jakieś swoje rozszerzenie do ProblemDetails przy niezłapanym wyjątku, wystarczy dodać serwisy z odpowiednim przeciążeniem, np:
W tym przykładzie, do każdego ProblemDetails zostanie dołożone moje rozszerzenie; pole „user_logged„, mówiące czy użytkownik jest zalogowany, czy nie. W obiekcie ctx (ProblemDetailsContext) mamy do dyspozycji m.in. HttpContext z żądania. Więc jeśli potrzebujesz jakiś dodatkowych informacji, to proszę bardzo.
Bezpieczeństwo
Pamiętaj, że komunikat błędu może tworzyć jakiś drogowskaz dla hackerów. A tym bardziej cały stack trace. Na szczęście mechanizm ProblemDetails w .NET7 jest bezpieczny. Dopóki nie zrobisz niczego bardzo głupiego, to będzie dobrze. Stack Trace zostanie dołączone do modelu ProblemDetails tylko na środowisku Developerskim. Natomiast na każdym innym nie zobaczysz go. I to dobrze.
Dzięki za przeczytanie artykułu. To tyle jeśli chodzi o podstawy użycia ProblemDetails. Jeśli znalazłeś w artykule jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
W tym artykule przedstawię Ci mechanizm własnego wiązania obiektów, czyli po ludzku „Custom model binding” w .NET.
.NET w standardzie ma sporo wbudowanych binderów, których zadaniem jest po prostu utworzenie jakiegoś konkretnego obiektu na podstawie danych z żądania HTTP. W zasadzie rzadko zdarza się potrzeba napisania własnego bindera, bo te domyślne załatwiają prawie 100% potrzeb. Prawie.
Postaram przedstawić Ci ten cały mechanizm od zupełnego początku. Jeśli szukasz konkretnego rozwiązania przejdź na koniec artykułu.
Do artykułu powstał przykładowy projekt. Możesz go pobrać z GitHuba.
Czym jest binder?
Wyobraź sobie taki kod w kontrolerze:
[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
}
}
Tutaj dzieje się dość sporo magii. Jak zapewne wiesz, żądanie HTTP to nic innego jak standaryzowany tekst wysłany z jednego komputera do drugiego.
W tym przypadku takie proste żądanie mogłoby wyglądać tak:
GET /api/user/5 HTTP/1.1
Host: localhost
Już pomijam w jaki sposób z czegoś takiego .NET umie wywołać konkretną metodę z konkretnej klasy. Ale spójrz, że w żądaniu masz ścieżkę /api/user/5. I tę piątkę dostaniesz w parametrze id w swojej metodzie GetById. Cały myk polega na tym, żeby Twoja nazwa parametru w procedurze (int id) była taka sama jak w atrybucie HttpGet ({id}).
Zanim wywołana zostanie metoda GetById, do roboty rusza specjalny binder, który patrzy i widzi:
„Aha, tutaj w atrybucie mam zmienną o nazwie id. W parametrze procedury też mam zmienną o nazwie id. To teraz wezmę sobie wartość z żądania („5”) i skonwertuję to na żądany typ (int) i zwrócę inta o odpowiedniej wartości (5)”.
Być może brzmi to nieco skomplikowanie, ale w rzeczywistości jest banalnie proste, ponieważ sam framework robi już dużo roboty za Ciebie. Spróbujmy napisać coś podobnego.
Binder działa dla konkretnego parametru. Spójrz na taki przykład:
[HttpPost("{id}")]
public IActionResult SaveData(int id, [FromBody]Author authorData)
{
}
Tutaj do pracy ruszą dwa różne bindery. Pierwszy – który powiąże id, drugi który odczyta jsona z ciała żądania i stworzy z niego obiekt Author.
Prosty binder
Opis zadania
Będziemy chcieli przesłać jakiś prosty rekord w żądaniu i po drugiej stronie odebrać to jako obiekt.
Model będzie wyglądał tak:
public class SimplePost
{
public int Id { get; set; }
public string Title { get; set; }
}
A metoda w kontrolerze:
[HttpPost("{post}")]
public IActionResult AddPost(SimplePost post)
{
}
Żądanie, które będzie wysłane to:
POST /api/posts/id:2;title:Tytuł postu
Weź pod uwagę, że to tylko przykład. W rzeczywistości prawdopodobnie przesłałbyś te dane po prostu jsonem. Ale chcę Ci pokazać, jak działa binder.
Zauważ, że tutaj analogicznie jak w pierwszym przykładzie, nazwa zmiennej w atrybucie jest taka sama jak nazwa zmiennej w parametrze metody. Do tej zmiennej dowiążemy te dane.
Wybór bindera
Musisz dać znać frameworkowi, jakiego bindera ma użyć dla konkretnego przypadku. Można to zrobić na dwa sposoby. Swój model możesz opatrzyć atrybutem ModelBinder, np:
[ModelBinder(typeof(SimplePostBinder))]
public class SimplePost
{
public int Id { get; set; }
public string Title { get; set; }
}
albo jeśli nie chcesz lub nie możesz posłużyć się atrybutem ModelBinder, zawsze możesz napisać swojego providera.
Provider dla bindera
UWAGA! SimplePostBinder przedstawiony w tym akapicie, to binder, którego piszemy w następnym akapicie 🙂
public class SimplePostBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType == typeof(SimplePost))
return new BinderTypeModelBinder(typeof(SimplePostBinder));
else
return null;
}
}
Teraz pewnie zachodzisz w głowę co to do cholery jest BinderTypeModelBinder i dlaczego nie możesz po prostu zrobić new SimplePostBinder().
Otóż możesz. Natomiast BinderTypeModelBinder to jest fabryka dla bindera. Umożliwia wykorzystanie dependency injection. Jeśli chciałbyś zwrócić swój obiekt: new SimplePostBinder, a konstruktor wymagałby różnych serwisów, no to wtedy mamy problem. Na szczęście BinderTypeModelBinder go rozwiązuje i sam się zatroszczy o wstrzyknięcie odpowiednich obiektów.
Na koniec musisz tego providera zarejestrować przy rejestracji kontrolerów:
builder.Services.AddControllers(options =>
{
options.ModelBinderProviders.Insert(0, new SimplePostBinderProvider());
});
UWAGA!
Providerzy są sprawdzani wg kolejności na liście ModelBinderProviders. Jest prawie pewne, że jeśli dodasz swojego providera na końcu (metodą Add), wtedy framework wybierze jakiegoś innego, zarejestrowanego wcześniej, który spełni swój warunek. Dlatego też zawsze dodawaj swojego bindera na początek listy – metodą Insert.
Jeśli otrzymujesz błędy w stylu 415: Unsupported media type lub widzisz że Twój binder nie chodzi, upewnij się w pierwszej kolejności, że dodałeś go na początek listy.
Piszemy bindera
W pierwszym kroku musisz stworzyć klasę, która implementuje interfejs IModelBinder. Interfejs ma tylko jedną metodę do napisania:
public class SimplePostBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
}
}
Pierwsza uwaga – pisząc własnego bindera, jak najbardziej możesz posługiwać się dependency injection. Możesz do konstruktora wrzucić sobie to, co potrzebujesz.
Parametr ModelBindingContext to jest klasa abstrakcyjna, której implementacja (DefaultModelBindingContext) daje Ci sporo informacji.
Przede wszystkim musimy pobrać nazwę modelu – czyli parametr z atrybutu HttpPost:
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var paramName = bindingContext.ModelName;
return Task.CompletedTask;
}
BindingContext ma jeszcze jedną podobną właściwość: FieldName – to jest nazwa parametru w Twojej metodzie.
Mając nazwę parametru z atrybutu (pamiętaj – z atrybutu), możemy teraz pobrać jego wartość:
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var paramName = bindingContext.ModelName;
ValueProviderResult value = bindingContext.ValueProvider.GetValue(paramName);
return Task.CompletedTask;
}
ValueProviderResult to w pewnym uproszczeniu tablica stringów. ValueProvider, jeśli zwróci jakąś wartość, to zawsze będą to stringi. I dopiero te stringi możemy konwertować na poszczególne typy.
Tutaj dostaniemy stringa: „id:2;title:Tytuł postu” i teraz musimy go skonwertować na nasz typ. Tu już jest prosto:
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var paramName = bindingContext.ModelName;
ValueProviderResult value = bindingContext.ValueProvider.GetValue(paramName);
if(value == ValueProviderResult.None)
return Task.CompletedTask;
var strValue = value.First();
if(string.IsNullOrWhiteSpace(strValue))
return Task.CompletedTask;
SimplePost result = ConvertFromString(strValue);
bindingContext.Result = ModelBindingResult.Success(result);
return Task.CompletedTask;
}
Najpierw w linijce 6 upewniłem się, że faktycznie otrzymaliśmy jakąś wartość. Potem w linijce 10 upewniłem się, że wartość, którą mamy nie jest pustym stringiem. Na koniec konwertuję tego stringa do obiektu i zwracam za pomocą bindingContext.Result.
Domyślnie bindingContext.Result jest ustawiony na Failed, dlatego ustawiam go tylko, gdy konwersja się powiodła.
Jeśli z ciekawości chciałbyś zobaczyć metodę ConvertFromString, to może wyglądać tak:
W sytuacji, gdzie użytkownik przekazuje dane, wszystko może pójść nie tak. Tak samo w naszym przypadku. Dlatego zawsze warto jest zabezpieczyć się przed problemami.
Jeśli przekażemy złe dane, np.: „idik:2;title:Tytuł postu” – idik zamiast id, nie będziemy w stanie utworzyć obiektu. Po prostu metoda ConvertFromString się wykrzaczy. Najprościej poradzić sobie z tym w taki sposób:
try
{
SimplePost result = ConvertFromString(strValue);
bindingContext.Result = ModelBindingResult.Success(result);
}catch(Exception ex)
{
bindingContext.ModelState.AddModelError(paramName, $"Could not convert from specified data, error: {ex.Message}");
}
return Task.CompletedTask;
W aplikacji webowej np. RazorPages musisz teraz sprawdzić stan modelu w standardowy sposób:
if(!ModelState.IsValid)
return View()
W przypadku WebApi (klasa opatrzona atrybutem ApiController) stan modelu jest sprawdzany automatem przed uruchomieniem metody w kontrolerze. W naszym przykładzie po prostu dostaniesz taką zwrotkę:
400: BadRequest z przykładową zawartością:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-528c9aba1d62ac1e013a1b841d57c8b0-e13ad6c0c13efb6f-00",
"errors": {
"post": [
"Could not convert from specified data, error: The given key 'id' was not present in the dictionary."
]
}
}
Tak samo z automatu zadziała sprawdzenie pól, które mają dodatkowe wymagania np. za pomocą adnotacji. Przykładowo:
[Range(10, 1000)]
public int Id { get; set; }
Piszemy bindera dla Jsona w formularzu
To już właściwie formalność, bo poznałeś już zasadę działania bindera. I w tym punkcie będzie więcej zabawy refleksją niż binderem. Niemniej jednak uczulę Cię na jedną rzecz.
Jeśli chcesz przesłać plik wraz z jakimiś danymi w JSONie, prawdopodobnie chciałbyś mieć analogiczny model:
public class Data
{
public IEnumerable<IFormFile> Files { get; set; }
public Author AuthorData { get; set; }
}
Czyli plik i obiekt w jednej klasie. Da się to zrobić i pokażę Ci jak. Jednak uczulam Cię, że zdecydowanie lepiej jest mieć pliki i dane osobno. Dlaczego? Bo binder do pobierania plików już istnieje w standardzie i wcale nie jest taki oczywisty, jakby się mogło wydawać.
Model generyczny
Stworzymy model generyczny, który będzie mógł mieć dowolny obiekt, który będzie pochodził z JSONa. Model jest banalnie prosty:
[ModelBinder(typeof(FormJsonBinder))]
public class FormJsonData<T>
where T: class, new()
{
public T Model { get; set; }
}
Teraz przyjmiemy model i plik w osobnych parametrach:
public class Author
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTimeOffset Birthday { get; set; }
public string Note { get; set; }
}
Jak już wiesz – binder działa na konkretnym parametrze. A więc w tym przypadku pliki z parametru files przyjdą ze standardowego bindera .NET. Więc zajmiemy się tylko FormJsonData.
Z PostMana można by wysłać takie żądanie:
Jak widzisz, wysyłamy formularz, gdzie w polu author mam JSONa z danymi autora, a w polu files mamy pliki. Zwróć uwagę, że te pola nazywają się tak samo jak parametry w naszej procedurze. To ważne, bo tak będziemy to wiązać.
Binder
Teraz napiszemy sobie bindera do tego FormJsonData. Początek już znasz:
public class FormJsonBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var fieldName = bindingContext.FieldName;
var fieldValue = bindingContext.ValueProvider.GetValue(fieldName);
if (fieldValue == ValueProviderResult.None)
return Task.CompletedTask;
return Task.CompletedTask;
}
}
Teraz w fieldValue mamy całego przekazanego JSONa. Musimy stworzyć już tylko odpowiedni obiekt. No właśnie. Ale jaki? FormJsonData jest klasą generyczną. Dlatego musimy się nieco pobawić refleksją. Nie będę tego omawiał, bo to nie jest artykuł na ten temat:
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var fieldName = bindingContext.FieldName;
var fieldValue = bindingContext.ValueProvider.GetValue(fieldName);
if (fieldValue == ValueProviderResult.None)
return Task.CompletedTask;
try
{
Type modelType = GetTypeForModel(bindingContext);
var result = ConvertFromJson(modelType, fieldValue.FirstValue);
bindingContext.Result = ModelBindingResult.Success(result);
}catch(Exception ex)
{
bindingContext.ModelState.AddModelError(fieldName, $"Could not convert from specified data, error: {ex.Message}");
}
return Task.CompletedTask;
}
private Type GetTypeForModel(ModelBindingContext context)
{
Type modelType = context.ModelType;
Type[] genericArgs = modelType.GenericTypeArguments;
if (genericArgs.Length == 0)
throw new InvalidOperationException("Invalid class! Expected generic type!");
return genericArgs[0];
}
private object ConvertFromJson(Type modelType, string jsonData)
{
Type outputType = typeof(FormJsonData<>).MakeGenericType(modelType);
JsonSerializerOptions opt = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var model = JsonSerializer.Deserialize(jsonData, modelType, opt);
var result = Activator.CreateInstance(outputType);
var modelProp = outputType.GetProperty("Model");
modelProp.SetValue(result, model);
return result;
}
Dzięki takiemu podejściu moglibyśmy mieć kilka różnych obiektów w jednym formularzu! W różnych polach formularza możesz umieścić różne Jsony i odbierać je w kontrolerze stosując odpowiednie nazwy parametrów.
Pobranie pliku w binderze
Tak naprawdę do pobierania plików w binderze służy… osobny binder. I ma on dużo więcej kodu niż, to co pokazuję Ci niżej:
private async Task GetFormFilesAsync(ModelBindingContext bindingContext,
ICollection<IFormFile> postedFiles)
{
var request = bindingContext.HttpContext.Request;
if (request.HasFormContentType)
{
var form = await request.ReadFormAsync();
foreach (var file in form.Files)
{
if (file.Length == 0 && string.IsNullOrEmpty(file.FileName))
continue;
postedFiles.Add(file);
}
}
}
Ten kod zadziała, ale zwracam uwagę po raz trzeci – binder do pobierania plików robi więcej rzeczy niż tylko to. Dlatego najlepiej pobieraj pliki osobnym parametrem, tak jak zrobiliśmy to wyżej.
Dzięki za przeczytanie tego artykułu. To tyle jeśli chodzi o custom model binding w .NET. Temat dość prosty i przyjemny, ale za to rzadko stosowany 🙂 Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu.
W tym artykule pokazuję, jak przesyłać pliki między aplikacjami webowymi. Myślę, że wiedza tutaj zawarta jest kompletna i dużo obszerniejsza niż w innych artykułach. Zobaczysz tutaj kompletne mechanizmy do przesyłania plików pożądane w każdej aplikacji.
UWAGA! Ze względu na prostotę i czytelność, kody przedstawione w tym artykule nie są zbyt czyste. Miej to na uwadze.
Na GitHubie znajdują się przykładowe projekty. W pliku readme.md jest instrukcja, która pokazuje jak je poprawnie uruchamiać.
Jeśli szukasz informacji jak przesłać formularzem plik z danymi w JSON, przeczytaj ten artykuł.
Po stronie HTML posługujemy się zwykłym inputem o typie file. Atrybut accept pozwala ograniczyć typy plików tylko do tych, które chcemy zobaczyć w okienku do wyboru plików.
Ale ważna uwaga – pamiętaj że musisz ustawić atrybut enctype dla formularza na multipart/form-data. W innym przypadku plik po prostu nie przejdzie.
Teraz pytanie, jak odebrać plik po stronie serwera? Służy do tego specjalny interfejs IFormFile. Ten interfejs znajduje się w Nugecie: Microsoft.AspNetCore.Http, więc zainstaluj go najpierw.
Model
Napiszmy teraz klasę, która będzie modelem dla tego formularza i prześle plik wraz z jakimiś innymi danymi:
public class FormData
{
[Required(ErrorMessage = "Musisz wybrać plik")]
public IFormFile? FileToUpload { get; set; }
[Required(ErrorMessage = "Musisz wpisać wiadomość")]
public string? Message { get; set; }
}
Zwróć uwagę, że oba pola są oznaczone jako Required – będą wymagane. I tu mała dygresja.
Wszystkie pola, które nie są oznaczone jako nullable – znakiem zapytania (a nullable używane jest w projekcie) domyślnie są uznane za wymagane. Z tego wynika, że powyższy kod mógłbym zapisać tak:
public class FormData
{
public IFormFile FileToUpload { get; set; }
public string Message { get; set; }
}
Zauważ, że w drugim przykładzie przy typach pól nie ma znaku zapytania, który oznacza pole jako nullable. W tym momencie te pola są domyślnie wymagane. I jeśli któreś z nich nie będzie wypełnione, formularz nie przejdzie walidacji.
Przesyłanie
Jeśli nie wiesz co robią atrybuty Required i jak walidować formularz, koniecznie przeczytaj artykuł Walidacja danych w MVC.
To teraz dodamy ten model do strony i zaktualizujemy formularz:
index.cshtml.cs:
public class IndexModel : PageModel
{
[BindProperty]
public FormData Data { get; set; }
public async Task OnPostAsync()
{
}
}
Jeśli nie wiesz, czym jest atrybut asp-validation-for lub _ValidationScriptsPartial, koniecznie przeczytaj artykuł o walidacji. Krótko mówiąc – jeśli jakiś warunek walidacyjny nie zostanie spełniony (np. [Required]), to w tym spanie pojawi się komunikat błędu.
Pamiętaj też, że _ValidationScriptsPartial musisz umieścić na końcu strony. W przeciwnym razie mechanizm walidacji po stronie klienta może nie zadziałać poprawnie.
Odbieranie danych
Teraz już wystarczy tylko odebrać te dane. Robimy to w metodzie OnPostAsync.
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
EnsureFilesDirExists();
var secureFileName = Path.GetFileName(Data.FileToUpload.FileName);
var filePath = Path.Combine("files", secureFileName);
using var stream = new FileStream(filePath, FileMode.CreateNew);
await Data.FileToUpload.CopyToAsync(stream);
return Page();
}
private void EnsureFilesDirExists()
{
if (!Directory.Exists("files"))
Directory.CreateDirectory("files");
}
Najpierw sprawdzam, czy stan modelu jest prawidłowy – po stronie serwera też musisz o to zadbać.
Następnie tworzę obiekt klasy FileStream, do którego wkopiowuję otrzymany plik. Plik za sprawą tego strumienia ląduje na dysku.
Jeśli przesyłane jest kilka plików, zamiast pojedynczej właściwości IFormFile użyj jakiejś formy kolekcji w swoim modelu, np. IEnumerable<IFormFile>, czy też List<IFormFile>.
Nazwa właściwości w modelu musi być taka sama jak wartość atrybutu name w elemencie input. Oczywiście, jeśli posługujesz się tylko RazorPages i domyślnym bindingiem, to masz to zagwarantowane.
Niebezpieczeństwa
Zapchanie pamięci i atak DoS
UWAGA! Nie używaj MemoryStream do łapania plików po stronie serwera. Pamiętaj, że wszystko co jest w MemoryStream znajduje się w pamięci. Jeśli na Twój serwer przesyłane są pliki, wtedy użycie MemoryStream może doprowadzić do wyczerpania pamięci i BUM! Wszystko wybuchnie. Stworzysz też podatność na atak Denial Of Service.
Dlatego też zawsze w takiej sytuacji używaj FileStream lub lepiej – jeśli masz taką możliwość – przesyłaj plik bezpośrednio na blob storage – o tym za chwilę.
Niebezpieczna nazwa pliku i przejęcie kontroli nad komputerem
Drugim zagrożeniem jest nazwa pliku. Zdziwiony? Nazwa pliku i plik mogą zostać tak spreparowane, żeby dobrać się do danych znajdujących się na serwerze. A nawet przejąć nad nim całkowitą kontrolę. Jeśli posłużysz się gołą nazwą pliku, która przychodzi w IFormFile.FileName stworzysz sobie podatność na atak Path Traversal. Piszę o tym więcej w swojej książce Zabezpieczanie aplikacji internetowych w .NET – podstawy. Jest tam wyjaśniony ten atak wraz z przykładem. A także kilka innych. Polecam zerknąć 🙂
Aby uniknąć podatności, zastosuj po prostu nazwę pliku, którą zwróci Ci Path.GetFileName().
Dobre praktyki
Poza tym, co opisałem wyżej, warto jeszcze sprawdzić rozmiar przesyłanego pliku. Ten rozmiar masz we właściwości IFormFile.Length. Daj jakieś maksimum na wielkość pliku. Jeśli przesłany plik jest za duży, to po prostu zwróć błąd.
Dobrze jest też uruchomić jakiś skaner antywirusowy na przesyłanym pliku, jeśli masz taką możliwość.
No i pamiętaj, żeby zawsze sprawdzać po stronie serwera co do Ciebie przychodzi, bo walidacje po stronie klienta można łatwo obejść.
Jeśli przesyłany plik jest naprawdę duży, to zapisuj go w osobnym tasku. Zwróć odpowiedź do klienta w stylu, że plik jest procesowany i wkrótce się pojawi.
Ochrona przed ponownym przesłaniem
Może się zdarzyć tak, że w przeglądarce ktoś zdąży zupełnym przypadkiem wcisnąć przycisk do wysyłania pliku dwa razy. Można się przed tym uchronić w prosty sposób. Wystarczy zablokować guzik po przesłaniu. Wymaga to jednak nieco JavaScriptu. W standardowym mechanizmie walidacji można to osiągnąć w taki sposób:
Tutaj nadałem ID formularzowi i przyciskowi do wysyłania. Dodatkowo dodałem obsługę zdarzenia onsubmit dla formularza. W tym handlerze najpierw sprawdzam, czy formularz jest poprawnie zwalidowany. Wywołanie form.validate().checkForm() nie powoduje kolejnej walidacji, tylko sprawdza, czy formularz jest zwalidowany. Jeśli nie jest (bo np. nie podałeś wszystkich wymaganych danych), wtedy zwracam false – formularz wtedy nie zostaje przesłany.
W przeciwnym razie blokuję guzik do wysyłania i zwracam true. Nie ma opcji podwójnego wciśnięcia tego przycisku. Taki mechanizm powinieneś stosować w każdym formularzu. Możesz też schować taki formularz i wyświetlić komunikat, że formularz jest wysyłany. Po prostu pokaż zamiast niego jakiegoś diva z taką informacją.
Walidacja rozmiaru pliku
Tak jak pisałem wyżej – warto dać jakiś maksymalny rozmiar pliku. Można to dość łatwo zwalidować zarówno po stronie klienta jak i serwera – wystarczy użyć własnego atrybutu walidacyjnego 🙂 Przede wszystkim muszę Cię odesłać do tego artykułu, w którym opisałem jak tworzyć własne atrybuty walidacyjne.
Nie będę tutaj powtarzał tej wiedzy, więc warto żebyś go przeczytał, jeśli czegoś nie rozumiesz. Tutaj dam Ci gotowe rozwiązanie.
Atrybut może wyglądać tak:
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field,
AllowMultiple = false)]
public class MaxFileSizeAttribute : ValidationAttribute, IClientModelValidator
{
private readonly int _maxFileSize;
public MaxFileSizeAttribute(int maxFileSize)
{
_maxFileSize = maxFileSize;
}
public override bool IsValid(object value)
{
var file = value as IFormFile;
if (file == null)
return true;
return file.Length <= _maxFileSize;
}
public void AddValidation(ClientModelValidationContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
MergeAttribute(context.Attributes, "data-val", "true");
MergeAttribute(context.Attributes, "data-val-filesize", ErrorMessageString);
MergeAttribute(context.Attributes, "data-val-filesize-maxsize", _maxFileSize.ToString());
}
private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value)
{
if (!attributes.ContainsKey(key))
{
attributes.Add(key, value);
}
}
}
Dodatkowo trzeba dodać walidację po stronie klienta. Ja robię to w pliku _ValidationScriptsPartial.cs. Cały plik może wyglądać tak:
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script type="text/javascript">
$.validator.addMethod("filesize", function (value, element, param) {
if (element.type != "file")
return false;
for(let i = 0; i < element.files.length; i++)
if(element.files[i].size > param)
return false;
return true;
});
$.validator.unobtrusive.adapters.addSingleVal("filesize", "maxsize");
</script>
Teraz wystarczy już tylko użyć tego w modelu:
public class FormData
{
[Required(ErrorMessage = "Musisz wybrać plik")]
[MaxFileSize(1024 * 1024, ErrorMessage = "Plik jest za duży, maksymalna wielkość pliku to 1 MB")]
public IFormFile? FileToUpload { get; set; }
[Required(ErrorMessage = "Musisz wpisać wiadomość")]
public string? Message { get; set; }
}
Ważne! Pamiętaj że żeby walidacja działała, model na stronie musi być opatrzony atrybutem BindProperty:
public class IndexModel : PageModel
{
[BindProperty]
public FormData Data { get; set; } = new();
public async Task<IActionResult> OnPostAsync()
{
}
}
Jeśli tego nie będzie, formularz może zadziałać poprawnie i przesłać dane, ale walidacja nie zostanie uruchomiona.
Przesyłanie pliku bezpośrednio na blob storage
BlobStorage jest to usługa Azure’owa do przechowywania plików. Czasem lepiej/łatwiej umieścić pewne pliki właśnie tam niż u siebie na serwerze. To nie jest artykuł o tym, więc nie piszę tutaj więcej w szczególności jak obsługować i tworzyć taki storage. Jeśli wiesz o co chodzi, to ten akapit może Ci się przydać. Jeśli nie – pewnego dnia na pewno to opiszę.
Generalnie wszystko sprowadza się do wywołania odpowiedniego przeciążenia metody UploadBlobAsync z BlobContainerClient:
W takiej sytuacji plik ląduje bezpośrednio na Twoim blob storage.
Przesyłanie pliku w Blazor
Tutaj sprawa wygląda nieco inaczej. Generalnie mamy gotowy komponent do pobrania pliku: InputFile. Co więcej, ten komponent nie musi być częścią formularza. To jednak nieco zmienia sposób podejścia do zadania. Zacznijmy od tego jak w ogóle działa przesyłanie plików w Blazor:
<InputFile OnChange="FileChangeHandler" accept=".jpg" />
@code{
private async Task FileChangeHandler(InputFileChangeEventArgs args)
{
using var stream = args.File.OpenReadStream();
}
}
Po pierwsze mamy komponent InputFile, którego kluczowym zdarzeniem jest OnChange. Trzeba tutaj podać handlera, który dostanie przekazany plik.
Parametr metody posiada pole File (typu IBrowserFile). Znajduje się tam pierwszy dostępny plik. Jeśli pozwoliłeś na dodanie kilku plików, wtedy możesz je odczytać z metody GetMultipleFiles, która zwraca Ci listę IBrowserFile. To wszystko znajduje się w klasie InputFileChangeEventArgs w przesłanym parametrze.
Interfejs IBrowserFile zawiera metodę OpenReadStream, która zwraca strumień z danymi pliku. I teraz uwaga nr 1. OpenReadStream przyjmuje w parametrze maksymalny rozmiar pliku, który może przekazać. Domyślnie ten parametr wskazuje 512 KB. Jeśli plik w strumieniu będzie większy, wtedy metoda wybuchnie wyjątkiem.
UWAGA! Blazor WASM pracuje tylko po stronie klienta – w jego przeglądarce. Nie ma tutaj żadnego serwera. Daje nam to pewne ograniczenie – nie możesz zapisać pobranego pliku – Blazor nie ma dostępu do systemu plików. Dlatego też w przypadku Blazor nie możesz posłużyć się FileStream, jak to było wyżej. Tutaj musisz po prostu przechować plik albo w tablicy bajtów, albo w MemoryStream.
Zobaczmy teraz w jaki sposób można zrobić pobieranie pliku z progress barem.
Przesyłanie pliku z progress barem
<InputFile OnChange="FileChangeHandler" accept=".jpg" />
@if(ReadingFile)
{
<div>
<progress value="@DataRead" max="@FileSize" />
</div>
}
@code{
private long FileSize { get; set; }
private long DataRead { get; set; }
private bool ReadingFile { get; set; } = false;
private async Task FileChangeHandler(InputFileChangeEventArgs args)
{
try
{
FileSize = args.File.Size;
DataRead = 0;
ReadingFile = true;
byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(1024); //pobranie buforu min. 1024 bajty
using var stream = args.File.OpenReadStream(1024 * 1024); //ograniczenie wielkości pliku do 1 MB
using MemoryStream fileContent = new MemoryStream();
while(await stream.ReadAsync(buffer) is int read && read > 0)
{
DataRead += read;
fileContent.Write(buffer);
StateHasChanged();
}
}finally
{
ReadingFile = false;
}
}
}
Wszystko rozbija się o odczyt pliku w porcjach po 1024 bajty. Kolejne części pliku są zapisywane w strumieniu fileContent. Przy każdym zapisywanym fragmencie aktualizuję ilość odczytanych danych (DataRead), która jest aktualną wartością w progress barze. Aby progress bar się odświeżył, trzeba zawołać StateHasChanged.
Na koniec odczytu cały plik będzie w strumieniu fileContent.
Pokazanie pobranego obrazka
W związku z tym, że Blazor pracuje po stronie klienta, nie możemy pliku zapisać gdzieś i go pokazać. Najprościej zatem pokazać taki obrazek w postaci zakodowanej do Base64. Robi się to w taki sposób, że odczytujemy dane obrazka (np. przekazujemy go do MemoryStream lub tablicy bajtów), następnie zamieniamy na Base64, a na koniec do tagu IMG przekazujemy ten obrazek w takiej postaci:
<img src="data:image/jpeg;base64,==data==" />
Ten tajemniczy ciąg w atrybucie src możemy rozbić na poszczególne elementy:
data:image/jpeg;base64,==data==
data – to określenie, że obrazek będzie w formie danych, a nie ścieżki do pliku
image/jpeg – to określenie typu tych danych
base64 – format danych i dane
Zwróć uwagę na różne separatory konkretnych elementów – po data jest dwukropek, po content-type jest średnik, a po base64 jest przecinek. Jasne, prawda? 🙂 Jeśli nie widzisz faktycznego obrazka tylko ikonkę zastępczą, sprawdź te separatory w pierwszej kolejności.
Zwróć uwagę, że content type przekazywanego pliku wziąłem z parametru InputFileChangeEventArgs. Mógłbym go wpisać na sztywno, ale wtedy byłby problem jeśli użytkownik wybrałby inny typ pliku. Png, svg, czy chociażby bmp.
Walidacja pliku po stronie klienta
W związku z tym, że plik nie musi być w Blazor częścią formularza i tak naprawdę nie ma żadnej walidacji, musimy posłużyć się małym mykiem.
Tak jak w kodzie powyżej, odczytamy plik do jakiejś zmiennej i będziemy walidować tę zmienną. To oznacza, że trzeba zmienić model widoku dla Blazor:
public class FormDataViewModel
{
[Required(AllowEmptyStrings = false, ErrorMessage = "Wybierz plik")]
[MinLength(1, ErrorMessage = "Plik nie może być pusty")]
[MaxLength(1024 * 1024, ErrorMessage = "Plik jest za duży")]
public byte[]? FileContent { get; set; }
[Required(ErrorMessage = "Musisz wpisać wiadomość")]
public string? Message { get; set; }
}
I zasadniczo taka walidacja jest prawie dobra. Problem w tym, że jeśli przekażemy zbyt duży plik, to nie będzie o tym informacji. Dlaczego? Przypomnij sobie, jak odczytujemy plik:
var stream = args.File.OpenReadStream(1024 * 1024);
Jeśli będzie większy niż 1MB, to wtedy OpenReadStream rzuci wyjątek. Dlatego też powinniśmy sprawdzić w tym miejscu, czy plik nie jest za duży.
Zgłoszenie błędu można zrobić na kilka sposobów. Najprostszym z nich jest po prostu pokazanie jakiegoś spana przy InputFile z odpowiednim tekstem. Np.:
To jest najprostsze rozwiązanie, które pewnie sprawdzi się w większości przypadków. Jednak można to też zrobić inaczej, za pomocą ValidationMessage. Wymaga to jednak zmiany sposobu walidacji formularza i wykracza poza ramy tego artykułu. Obiecuję, że napiszę osobny artykuł o dodatkowej walidacji w Blazor.
Przesyłanie pliku na serwer do WebApi
Skoro mamy już fajny przykład w Blazorze, wykorzystamy go do przesłania pliku do WebApi. A właściwie całego modelu. Pokażę Ci jak przesłać dane wraz z plikiem.
Przede wszystkim musisz pamiętać, że tak jak w przypadku MVC / Razor Pages, plik jest wysyłany formularzem. Zasadniczo – przesyłanie pliku na serwer zawsze sprowadza się do umieszczenia go w formularzu i wysłaniu tego formularza.
Odczyt po stronie Api
Na początek stwórzmy projekt WebApi. Następnie dodajmy model, który będzie wyglądał tak:
public class WebApiFormData
{
public IFormFile File { get; set; }
public string Message { get; set; }
}
Zwróć uwagę na starego znajomego – IFormFile.
Teraz najlepsze, jedyne co musimy zrobić w kontrolerze w WebApi to:
[Route("api/[controller]")]
[ApiController]
public class FormController : ControllerBase
{
[HttpPost]
public IActionResult PostHandler([FromForm]WebApiFormData data)
{
if (!ModelState.IsValid)
return BadRequest();
return Ok();
}
}
I to wystarczy, żeby WebApi odpowiednio odebrało dane! Po tej stronie już niczego nie musimy robić. Czy to nie jest piękne?
UWAGA! Jeśli podczas wysyłania danych do WebApi otrzymujesz błąd 415 (Unsupported Media Type) sprawdź, czy na pewno pobierasz w WebApi dane z formularza – atrybut FromForm.
Wysyłanie do API
Rejestracja HttpClientFactory
Teraz musimy te dane wysłać formularzem za pomocą HttpClient. Zrobimy to z aplikacji Blazor, która była tworzona wyżej w tym artykule. Zacznijmy od rejestracji fabryki dla HttpClient przy rejestracji serwisów:
builder.Services.AddHttpClient("api", client =>
{
client.BaseAddress = new Uri("https://localhost:7144/");
});
Tego klienta pobierzemy sobie w metodzie, która jest wywoływana po poprawnym przesłaniu formularza w Blazor:
[Inject]
public IHttpClientFactory HttpClientFactory { get; set; }
private async Task ValidSumbitHandler(EditContext ctx)
{
var client = HttpClientFactory.CreateClient("api");
}
Teraz musimy utworzyć całe ciało wiadomości, którą będziemy wysyłać. Będzie to formularz złożony z części.
Każda część to tzw. content. C# daje nam różne takie klasy jak np. StreamContent, do którego możemy wrzucić strumień, ByteArrayContent, do którego możemy podać tablicę, StringContent, do którego wrzucamy stringa.
I tutaj uwaga. Pojedyncze żądanie może mieć tylko jeden content. Ale jest specjalny typ – MultipartFormDataContent, który składa się z innych contentów. Ostatecznie wszystko jest przesyłane oczywiście jako strumień bajtów. A skąd wiadomo gdzie kończy się jedna zawartość a zaczyna druga?
Istnieje coś takiego jak Boundary. Jest to string, który oddziela poszczególne części między sobą. Jest ustawiany automatycznie, ale sam też go możesz podać. Pamiętaj tylko, że taki string powinien być unikalny.
Najpierw stwórzmy i dodajmy zawartość pliku. Posłużymy się tutaj ByteArrayContent, ponieważ trzymamy plik w tablicy bajtów:
var request = new HttpRequestMessage(HttpMethod.Post, "api/form");
var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Data.FileContent);
form.Add(fileContent, nameof(WebApiFormData.File), "uploaded.jpg");
Na początku utworzyłem HttpRequestMessage, który ostatecznie zostanie wysłany. To on będzie miał zawarty w sobie formularz.
Potem tworzę formularz, do którego będą dodawane poszczególne zawartości.
I tak tworzymy fileContent z zawartością pliku, który został odczytany. Podczas dodawania tego contentu (form.Add) ważne jest wszystko – nazwa tego contentu – file – taka sama jak w modelu, który będzie przyjmowany przez WebApi. No i nazwa pliku – „uploaded.jpg”. Nazwa pliku musi być dodana w przypadku przesyłania pliku. Tak naprawdę powinieneś dodać prawdziwe rozszerzenie. Ja tylko z czystego lenistwa i prostoty wpisałem na sztywno „jpg”. Pamiętaj, żeby dodawać faktyczne rozszerzenie.
To teraz zajmiemy się pozostałymi właściwościami:
var msgContent = new StringContent(Data.Message, new MediaTypeHeaderValue("text/plain"));
form.Add(msgContent, nameof(WebApiFormData.Message));
Tutaj wszystko wygląda analogicznie, z tym że posługuję się StringContent zamiast ByteArrayContent.
I tutaj istotna uwaga. Zawsze dobrze jest podać media-type dla stringa, który przesyłasz. Może to być czysty tekst (text/plain), może to być string z json’em (application/json), cokolwiek. Może być też tak, że WebApi nie chce mieć przekazanego żadnego mime-type albo wręcz przeciwnie – jakiś konkretny. W takim przypadku zastosuj to, co jest napisane w dokumentacji konkretnego WebApi.
Teraz można już dodać nasz formularz do żądania i wysłać je:
Często trzeba przesłać większą ilość danych wraz z plikiem. Napisałem osobny artykuł o tym jak przesłać dane w JSON razem z plikiem. Zachęcam do przeczytania, bo mówię też tam o mechanizmie bindingu.
Dzięki za przeczytanie tego artykułu. Mam nadzieję, że wszystko jest jasne. Jeśli jednak znalazłeś jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
Pewnie nie raz spotkałeś się z sytuacją, gdzie próba wywołania API z Blazor albo JavaScript zakończyła się radosnym błędem
XMLHttpRequest cannot load http://…. No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://…’ is therefore not allowed access.
Czym jest CORS, dlaczego jest potrzebne i jak się z nim zaprzyjaźnić? O tym dzisiaj.
Co to CORS
Cross Origin Request Sharing to mechanizm bezpieczeństwa wspierający politykę same-origin. Same-origin polega na tym, że JavaScript z jednej domeny (a konkretnie origin) nie może komunikować się z serwerem z innej domeny (origin).
Innymi słowy, jeśli masz stronę pod adresem: https://example.com i chciałbyś z niej wywołać za pomocą JavaScript coś ze strony https://mysite.com, to musisz mieć na to specjalne pozwolenie wydane przez mysite.com.
Czyli jeśli u siebie lokalnie z końcówki https://localhost:3000 będziesz chciał zawołać jakieś API z końcówki: https://localhost:5001, to też się to nie uda bez specjalnego pozwolenia. Tym wszystkim zarządza przeglądarka.
Czym jest ORIGIN
Już wiemy, że żeby nie było problemów, obydwie strony żądania muszą należeć do tego samego originu. Czym jest zatem origin?
To połączenie: protocol + host + port, czyli np:
https://example.com i https://example.com:443 – należą do tego samego originu. Pomimo, że w pierwszym przypadku nie podaliśmy jawnie portu, to jednak protokół https domyślnie działa na porcie 443. A więc został tam dodany niejawnie.
http://example.com i https://example.com – nie należą już do tego samego originu. Różnią się protokołem i portem (przypominam, że https działa domyślnie na porcie 443, a http na 80).
https://example.com:5000 i https://example.com:5001 – też nie należą do tego samego originiu, ponieważ różnią się portem.
https://api.example.com i https://example.com – też nie należą do tego samego originu, bo różnią się hostem. Zasadniczo origin definiuje aplikację internetową.
Polityka same-origin
Jak już pisałem wcześniej, polityka same-origin zakazuje jednej aplikacji korzystać z elementów innej aplikacji. Skryptów js, arkuszy css i innych… Ale…
No, ale jak to? A CDN? A linkowanie bootstrapa itd?
No właśnie. Przede wszystkim przeglądarki nie są zbyt rygorystyczne pod tym względem. Głównie ze względu na kompatybilność wsteczną. Pół Internetu przestałoby działać. Jednak to „rozluźnienie” niesie za sobą pewne zagrożenia. Np. może dawać podatność na atak XSS lub CSRF (pisałem o Cross Site Request Forgery w książce o podstawach zabezpieczania aplikacji internetowych).
Wyjątki polityki same-origin
Skoro przeglądarki niezbyt rygorystycznie podchodzą do polityki same-origin, to znaczy że są pewne luźniejsze jej elementy. Oczywiście, że tak. Przeglądarki pozwalają ogólnie na:
zamieszczanie obrazków z innych originów
wysyłanie formularzy do innych originów
zamieszczanie skryptów z innych originów – choć tutaj są już pewne ograniczenia
Na co same-origin nie pozwoli
Przede wszystkim nie pozwoli Ci na dostęp do innych originów w nowych technologiach takich jak chociażby AJAX. Czyli strzały HTTP za pomocą JavaScriptu. Co to oznacza? Zacznijmy od najmniejszego problemu – jeśli piszesz aplikację typu SPA w JavaScript lub Blazor, to chcesz się odwoływać do jakiegoś API. W momencie tworzenia aplikacji prawdopodobnie serwer stoi na innym originie niż front. Na produkcji może być podobnie. W takiej sytuacji bez obsługi CORS po stronie serwera, nie połączysz się z API.
Idąc dalej, jeśli chcesz na swojej stronie udostępnić dane pobierane z innego źródła – np. pobierasz AJAXem kursy walut – to też może nie zadziałać. W prawdzie użyłem tych kursów walut jako być może nieszczęśliwy przykład. Jeśli to działa to tylko ze względu na luźną politykę CORS. W przeciwnym razie musiałbyś się kontaktować z dostawcą danych, żeby pozwolił Ci na ich pobieranie. I tak też często się dzieje. I on może to zrobić właśnie dzięki CORS.
Więc jak działa ten CORS?
Pobierz sobie przykładową solucję, którą przygotowałem na GitHub. Jest kam kilka projektów:
WebApiWithoutCors – api, które w żaden sposób nie reaguje na CORS – domyślnie uniemożliwi wszystko
WebApiWithCors – api z obsługą CORS
ClientApiConfig – podstawowy klient, który chciałby pobrać dane i zrobić POST
DeletableClient – klient, któremu polityka CORS pozwala jedynie na zrobienie DELETE
BadClient – klient, któremu żadne API na nic nie pozwala
Każdy projekt pracuje w HTTP (nie ma SSL/TLS) specjalnie, żeby umożliwić w łatwy sposób podsłuchiwanie pakietów w snifferze.
Przede wszystkim działanie CORS (Cross Origin Request Sharing) jest domeną przeglądarki. Jeśli uruchomisz teraz przykładowe projekty: ClientApp (aplikacja SPA pisana w Blazor) i WebApiWithoutCors i wciśniesz guzik Pobierz dane, to zobaczysz taki komunikat:
A teraz wywołaj tę samą końcówkę z PostMan, to zobaczysz że dane zostały pobrane:
Co więcej, jeśli posłużysz się snifferem, np. WireShark, zobaczysz że te dane do przeglądarki przyszły:
To znaczy, że to ta małpa przeglądarka Ci ich nie dała. Co więcej, wywaliła się wyjątkiem HttpRequestException przy pobieraniu danych:
var client = HttpClientFactory.CreateClient("api");
try
{
var response = await client.GetAsync("weatherforecast");
if (!response.IsSuccessStatusCode)
ErrorMsg = $"Nie można było pobrać danych, błąd: {response.StatusCode}";
else
{
var data = await response.Content.ReadAsStringAsync();
WeatherData = new(JsonSerializer.Deserialize<WeatherForecast[]>(data));
}
}catch(HttpRequestException ex)
{
ErrorMsg = $"Nie można było pobrać danych, błąd: {ex.Message}";
}
Co z tą przeglądarką nie tak?
Przeglądarka odebrała odpowiedź z serwera, ale Ci jej nie pokazała. Dlaczego? Ponieważ nie dostała z serwera odpowiedniej informacji. A konkretnie nagłówka Access-Control-Allow-Origin, o czym informuje w konsoli:
XMLHttpRequest cannot load http://.... No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://...’ is therefore not allowed access.
To poniekąd serwer zdecydował, że nie chce klientowi danych pokazywać. A dokładniej – serwer nie zrobił niczego, żeby te dane pokazać. Po prostu na dzień dobry obsługa CORS na serwerze jest wyłączona. Można powiedzieć, że jest zaimplementowana jedynie w przeglądarce.
Niemniej jednak przeglądarka wysłała do serwera zapytanie GET, które odpowiednie dane pobrało i zwróciło. Czyli jakaś operacja na serwerze się wykonała. Pamiętaj, że zapytanie GET nie powinno mieć żadnych skutków ubocznych. Czyli nie powinno zmieniać żadnych danych. Powinno dane jedynie pobierać. A więc teoretycznie nic złego nie może się stać.
A gdyby tak przeglądarka wysłała POST? Zadziała czy nie? No właśnie nie w każdej sytuacji.
Jeśli teraz w przykładowej aplikacji uruchomisz narzędzia dewelopera (Shift + Ctrl + i) i wciśniesz guzik Wywołaj POST, to zobaczysz coś takiego:
Zanim przeglądarka wyśle to żądanie, najpierw wykona specjalne zapytanie, tzw. preflight. Czyli niejako zapyta się serwera: „Hej serwer, jestem z takiego originu i chciałbym wysłać do Ciebie POST z takimi nagłówkami. Mogę?”
To specjalne żądanie wysyłane jest na adres, na który chcesz rzucić POSTem. Z tym że tutaj metodą jest OPTIONS. Poza tym w nagłówkach są zaszyte informacje:
Access-Control-Request-Headers – lista nagłówków z jakimi chcesz wysłać POST
Access-Control-Request-Method – metoda, jaką chcesz wywołać (POST, DELETE itd)
Origin – origin, z którego żądanie będzie wysłane
Możesz to podejrzeć zarówno w narzędziach dewelopera jak i w Wireshark:
A co zrobił serwer? Zwrócił błąd: 405 - Method not allowed. Co znaczy, że pod takim endpointem serwer nie obsługuje zapytań typu OPTIONS. Co dla przeglądarki daje jasny komunikat: „Nie wysyłaj mi tego, nie obsługuję CORS”. Przeglądarka więc zaniecha i nie wyśle takiego zapytania.
Wyjątkowe formularze
Jak już pisałem wcześniej, formularze są pewnym wyjątkiem. Przeglądarka i tak je wyśle. To kwestia kompatybilności wstecznej. Jeśli będziesz chciał wysłać metodę POST z Content-Type ustawionym na multipart/form-data, to takie zapytanie zostanie wykonane bez żadnego preflight'u. Takich wyjątków jest więcej i są bardzo dobrze opisane na stronie Sekuraka, więc nie będę tego powielał. Jeśli masz ochotę zgłębić temat, to polecam.
Obsługa CORS w .NET
Skoro już wiesz z grubsza czym jest CORS i, że to serwer ostatecznie musi dać jawnie znać, że zgadza się na konkretne zapytanie, to teraz zaimplementujmy ten mechanizm po jego stronie. Spójrz na projekt WebApiWithCors z załączonej solucji.
Jeśli pracujesz na .NET < 6, to pewnie będziesz musiał dorzucić Nugeta: Microsoft.AspNetCore.Cors.
Przede wszystkim musisz dodać serwisy obsługujące CORS podczas rejestracji serwisów:
builder.Services.AddCors();
a także wpiąć obsługę CORS w middleware pipeline. Jeśli nie wiesz, czym jest middleware pipeline, przeczytaj ten artykuł.
Pamiętaj, że UseCors musi zostać wpięte po UseRouting, ale przed UseAuthorization.
Takie dodanie jednak niczego nie załatwi. CORS do odpowiedniego funkcjonowania potrzebuje polityki. I musimy mu tę politykę ustawić.
Polityka CORS
CORS Policy mówi jakich dokładnie klientów i żądania możesz obsłużyć. Składa się z trzech części:
origin – obsługuj klientów z tych originów
method – obsługuj takie metody (POST, GET, DELETE itd…)
header – obsługuj takie nagłówki
To znaczy, że klient aby się dobić do serwera musi spełnić wszystkie trzy warunki – pochodzić ze wskazanego originu, wywołać wskazaną metodę i posiadać wskazany nagłówek.
Wyjątkiem jest tu POST. Co wynika z wyjątkowości formularzy. Jeśli będziesz chciał wysłać POST, przeglądarka zapyta się o to jedynie w przypadku, gdy Content-Type jest odpowiedni (np. nie wskazuje na formularz). Co to dalej oznacza? Jeśli stworzysz na serwerze politykę, która nie dopuszcza POST, ale dopuszcza wszystkie nagłówki (AllowAnyHeader), to ten POST i tak zostanie wysłany. Kwestia kompatybilności wstecznej.
Ona zezwala na połączenia z dowolnego originu, wykonanie dowolnej metody z dowolnymi nagłówkami. Czy to dobrze? To zależy od projektu.
Co więcej, metoda AddDefaultPolicy doda domyślną politykę. Wpięty w pipeline UseCors będzie używał tej domyślnej polityki do sprawdzenia, czy żądanie od klienta może pójść dalej.
Za pomocą metody AddPolicy możesz dodać politykę z jakąś nazwą. Tylko wtedy do UseCors musisz przekazać w parametrze nazwę polityki, której chcesz używać domyślnie. UseCors wywołany bez parametrów będzie używał polityki dodanej przez AddDefaultPolicy. Jeśli jej nie dodasz, wtedy CORS nie będzie obsługiwany.
Konkretna polityka
Oczywiście możesz w polityce wskazać konkretne wartości, np.:
To spowoduje, że polityka dopuści strzały tylko z originu http://localhost:5001 z jakąkolwiek metodą i dozwolonym nagłówkiem X-API-KEY.
I tutaj dwie uwagi. Po pierwsze – pamiętaj, żeby originu nie kończyć slashem: / . Jeśli tak wpiszesz http://localhost:5001/, wtedy origin się nie zgodzi i mechanizm CORS nie dopuści połączeń. Czyli – brak slasha na końcu originu. Idąc dalej, nie podawaj pełnych adresów w stylu: https://localhost:5001/myapp – to nie jest origin.
A teraz pytanie za milion punktów. Co się stanie, gdy mając taką politykę z poprawnego originu wywołasz:
var data = new WeatherForecast
{
Date = DateTime.Now,
Summary = "Cold",
TemperatureC = 5
};
var client = HttpClientFactory.CreateClient("api");
client.DefaultRequestHeaders.Add("X-API-KEY", "abc");
var response = await client.PostAsJsonAsync("weatherforecast", data);
Dodałeś nagłówek X-API-KEY do żądania i wysyłasz JSONa za pomocą post (dowolna metoda).
Zadziała?
Przemyśl to.
Jeśli powiedziałeś „nie”, to zgadza się. Gratulacje 🙂 A teraz pytanie dlaczego to nie zadziała. Spójrz jaki przeglądarka wysyła preflight:
O co pyta przeglądarka w tym żądaniu?
„Hej serwer, czy mogę ci wysłać POST z nagłówkami contenty-type i x-api-key? A co odpowiada serwer?
„Ja się mogę zgodzić co najwyżej na metodę POST i nagłówek X-API-KEY„.
Przeglądarka teraz patrzy na swoje żądanie i mówi: „Ojoj, to nie mogę ci wysłać content-type. Więc nie wysyłam”. To teraz pytanie skąd się wzięło to content type? Spójrz jeszcze raz na kod:
var response = await client.PostAsJsonAsync("weatherforecast", data);
Wysyłasz JSONa. A to znaczy, że gdzieś w metodzie PostAsJsonAsync został dodany nagłówek: Content-Type=application/json. Ponieważ w zawartości żądania (content) masz json (czyli typ application/json).
Uważaj na takie rzeczy, bo mogą doprowadzić do problemów, z którymi będziesz walczył przez kilka godzin. Ale w tym wypadku już powinieneś wiedzieć, jak zaktualizować politykę CORS:
Jeśli wydaje Ci się, że CORS powinien zadziałać, a nie działa, w pierwszej kolejności zawsze zobacz jaki preflight jest wysyłany, jakie nagłówki idą w żądaniu i czy są zgodne z polityką.
Pamiętaj też, że w żądaniu nie muszą znaleźć się wszystkie nagłówki. Jeśli nie będzie w tej sytuacji X-API-KEY nic złego się nie stanie. Analogicznie jak przy polityce dotyczącej metod. Możesz wysłać albo GET, albo POST, albo DELETE… Nie możesz wysłać kilku metod jednocześnie, prawda? 🙂
CORS tylko dla jednego endpointu
Corsy możesz włączyć tylko dla konkretnych endpointów. Możesz to zrobić za pomocą atrybutów. I to na dwa sposoby. Jeśli chcesz umożliwić większość operacji, możesz niektóre endpointy wyłączyć spod opieki CORS. Spójrz na kod kontrolera:
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
[HttpPost]
[DisableCors]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
return Ok();
}
Atrybut DisableCors spowoduje, że mechanizm CORSów uniemożliwi wywołanie tej końcówki. Jeśli przeglądarka użyje preflight, wtedy serwer odpowie, ale nie pozwalając na nic.
Kilka polityk CORS
Skoro można zablokować CORS na pewnych końcówkach, to pewnie można też odblokować na innych. No i tak. Zgadza się. Zróbmy sobie takie dwie polityki:
Zwróć uwagę na to, że nie dodajemy teraz żadnej domyślnej polityki (AddDefaultPolicy), tylko dwie, które jakoś nazwaliśmy. Teraz każdy endpoint może mieć swoją własną politykę:
[HttpGet]
[EnableCors("get-policy")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
[HttpPost]
[EnableCors("set-policy")]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
return Ok();
}
Każdy endpoint dostał swoją własną politykę za pomocą atrybutu EnableCors. Jako parametr przekazujemy nazwę polityki. Jeśli w takim przypadku nie podasz atrybutu EnableCors, to końcówka będzie zablokowana. Dlaczego? Spójrz na middleware:
app.UseCors();
Taki middleware po prostu będzie chciał użyć domyślnej polityki (AddDefaultPolicy), której jednak nie ma. Dlatego też zablokuje wszystko. Oczywiście możesz w tym momencie podać konkretną politykę, jaka ma być używana przez middleware:
app.UseCors("get-policy");
Wtedy każdy endpoint bez atrybutu [EnableCors] będzie używał tej polityki.
Dynamiczna polityka CORS
Czasem możesz potrzebować bardziej płynnej polityki, która może zależeć od konkretnego originu. Możesz chcieć wpuszczać tylko te originy, które są zarejestrowane w bazie albo dla różnych originów mieć różne polityki.
Wtedy sam musisz zadbać o to, żeby mechanizm CORS dostał odpowiednią politykę. Na szczęście w .NET6 jest to banalnie proste. Wystarczy zaimplementować interfejs ICorsPolicyProvider, np. w taki sposób:
public class OriginCorsPolicyProvider : ICorsPolicyProvider
{
private readonly CorsOptions _corsOptions;
public OriginCorsPolicyProvider(IOptions<CorsOptions> corsOptions)
{
_corsOptions = corsOptions.Value;
}
public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
{
var origin = context.Request.Headers.Origin;
var policy = _corsOptions.GetPolicy(origin);
if (policy == null)
policy = _corsOptions.GetPolicy(policyName ?? _corsOptions.DefaultPolicyName);
return Task.FromResult(policy);
}
}
Interfejs wymaga tylko jednej metody – GetPolicyAsync. Najpierw jednak zobacz w jaki sposób zarejestrowałem odpowiednie polityki podczas rejestracji serwisów CORS:
Nazwa polityki to po prostu origin, dla którego ta polityka jest utworzona. A teraz wróćmy do providera. Spójrz najpierw na metodę GetPolicyAsync.
Najpierw pobieram origin z requestu, następnie pobieram odpowiednią politykę. Metoda GetPolicy z obiektu _corsOptions zwraca politykę po nazwie. Te polityki są tam dodawane przez setup.AddPolicy. Gdzieś tam pod spodem są tworzone jako dodatkowe opcje, co widzisz w konstruktorze – w taki sposób możesz pobrać zarejestrowane polityki.
Oczywiście nic nie stoi na przeszkodzie, żebyś w swoim providerze połączył się z bazą danych i na podstawie jakiś wpisów sam utworzył odpowiednią politykę dynamicznie i ją zwrócił.
Teraz jeszcze tylko musimy zarejestrować tego providera:
Słowem zakończenia, pamiętaj żeby nie podawać w middleware kilka razy UseCors, bo to nie ma sensu. Pierwszy UseCors albo przepuści żądanie dalej w middleware albo je sterminuje.
To tyle jeśli chodzi o CORSy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz się poruszał po tym świecie z większą pewnością. Jeśli znalazłeś błąd w artykule albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie