W ramach nowoczesnego rozwoju aplikacji webowych, wykorzystanie odpowiednich mechanizmów do zarządzania trasami i parametrami w API stanowi klucz do stworzenia przejrzystych i łatwych w utrzymaniu aplikacji. W tym kontekście ASP.NET Core 8 wprowadza szereg funkcjonalności, które pomagają zorganizować trasy w logiczne grupy oraz automatycznie wiązać parametry żądań HTTP z odpowiednimi argumentami w funkcjach obsługujących zapytania.

Przykładem tego mechanizmu jest grupa tras do zarządzania danymi krajów, która przedstawia, jak można zgrupować różne punkty końcowe (endpoints) w ramach jednej, spójnej struktury. W poniższym przykładzie, funkcja GroupCountries definiuje kilka punktów końcowych związanych z krajami: pobieranie listy krajów, pobieranie danych o konkretnym kraju na podstawie jego identyfikatora oraz pobieranie języków danego kraju.

csharp
public static RouteGroupBuilder GroupCountries(this RouteGroupBuilder group)
{ var countries = new string[] { "France", "Canada", "USA" };
var languages = new Dictionary<string, List<string>>
{ {
"France", new List<string> { "french" } },
{ "Canada", new List<string> { "french", "english" } },
{
"USA", new List<string> { "english", "spanish" } } }; group.MapGet("/", () => countries); group.MapGet("/{id}", (int id) => countries[id]); group.MapGet("/{id}/languages", (int id) => { var country = countries[id]; return languages[country]; }); return group; }

W powyższym przykładzie grupowanie tras jest realizowane przez funkcję rozszerzającą GroupCountries. To podejście pozwala na centralne zdefiniowanie zestawu tras, które następnie dziedziczą wspólną część URL, czyli /countries. Dzięki temu, trasy takie jak /countries/{id} czy /countries/{id}/languages stają się łatwiejsze do zarządzania, a kod staje się bardziej czytelny. Kluczowym aspektem jest to, że grupowanie tras pozwala na stosowanie wspólnych restrykcji (np. dotyczących formatu identyfikatora) na wielu punktach końcowych, co znacząco upraszcza kod.

Ważnym ułatwieniem jest również możliwość reużywania takich grup tras, które dziedziczą wspólną konfigurację. Dzięki mechanizmowi MapGroup, wystarczy raz zdefiniować "trunk" (główną część URL) i wszystkie trasy w obrębie tej grupy będą miały wspólny prefiks. Dodatkowo, jest możliwe dalsze grupowanie tras wewnątrz danej grupy, co pozwala na bardziej zaawansowane operacje, takie jak rozdzielenie tras w zależności od parametrów.

Z punktu widzenia wydajności i porządku w kodzie, to podejście jest bardzo wygodne, szczególnie gdy mamy do czynienia z dużą liczbą podobnych tras. Używając takich funkcji jak MapGet i MapPost, które obsługują różne rodzaje żądań HTTP, możemy szybko zdefiniować różne operacje na tych samych zasobach, nie powielając zbędnie kodu.

Powiązywanie parametrów w ASP.NET Core 8

Podobnie jak w przypadku tras, mechanizm powiązywania parametrów odgrywa kluczową rolę w sprawnym zarządzaniu danymi przesyłanymi w żądaniach HTTP. Powiązywanie parametrów (ang. parameter binding) polega na tym, że ASP.NET Core automatycznie konwertuje dane z żądania HTTP (np. z adresu URL, ciała żądania, nagłówków) na odpowiednie typy danych, które są następnie przekazywane do funkcji obsługujących żądania.

ASP.NET Core 8 oferuje elastyczne podejście do powiązywania parametrów, które obsługuje różne źródła danych:

  • Parametry z trasy (np. {id})

  • Parametry z zapytania (query string)

  • Parametry z ciała żądania (np. dane JSON lub dane formularza)

  • Parametry z nagłówków

Przykład wykorzystania powiązywania parametrów można zobaczyć w przypadku klasy Address, która reprezentuje dane adresowe. Klasa ta może być używana do przesyłania danych w formacie JSON w ciele żądania, które są następnie automatycznie powiązane z odpowiednimi parametrami w funkcji obsługującej zapytanie.

csharp
public class Address {
public int StreetNumber { get; set; }
public string StreetName { get; set; }
public string StreetType { get; set; }
public string City { get; set; } public string Country { get; set; } public int PostalCode { get; set; } }

Z użyciem atrybutu [FromBody], ASP.NET Core umożliwia powiązanie danych JSON z klasy Address bez konieczności ręcznego dekodowania tych danych.

csharp
app.MapPost("/Addresses", ([FromBody] Address address) => { return Results.Created(); });

Warto zauważyć, że chociaż ASP.NET Core 8 obsługuje szeroki zakres typów danych, nie wspiera powiązywania klas zawierających rekursję, jak w przypadku klasy Address, która posiada właściwość AlternateAddress typu Address. Tego rodzaju struktury prowadzą do problemów z powiązywaniem, ponieważ system nie jest w stanie jednoznacznie określić, jak związać takie dane.

Ponadto, ASP.NET Core umożliwia wykorzystanie różnych atrybutów powiązywania dla różnych źródeł danych. Dzięki temu, na przykład, parametr z zapytania URL może być powiązany za pomocą [FromRoute], parametr z ciała żądania za pomocą [FromBody], a parametr z nagłówków przez [FromHeaders]. Dzięki tej elastyczności programista ma pełną kontrolę nad tym, jak dane są pobierane i przetwarzane.

Co jeszcze warto wiedzieć?

Grupowanie tras oraz powiązywanie parametrów to tylko część większego ekosystemu, który ASP.NET Core 8 oferuje programistom. Kluczowe jest zrozumienie, że dobrze zaprojektowana struktura API nie tylko ułatwia zarządzanie kodem, ale także zapewnia spójność i łatwość w utrzymaniu aplikacji. Grupy tras pozwalają na znaczne zmniejszenie powtarzalności kodu, podczas gdy mechanizm powiązywania parametrów upraszcza pracę z danymi, co w praktyce przekłada się na lepszą wydajność i mniej błędów w trakcie implementacji.

Dodatkowo, warto pamiętać, że rozważne stosowanie grup tras i powiązywania parametrów może poprawić ogólną jakość API, szczególnie w przypadku rozbudowanych systemów, w których liczba tras czy parametrów może być bardzo duża. Kluczem do sukcesu jest balans pomiędzy prostotą a elastycznością, co może wymagać doświadczenia i umiejętności dostosowywania technologii do specyficznych wymagań projektu.

Jak zarządzać równoczesnym przetwarzaniem zadań w aplikacjach?

Optymalizacja aplikacji poprzez zarządzanie równoczesnymi zadaniami to kluczowy element wydajności, zwłaszcza gdy aplikacja musi obsługiwać duże ilości danych lub długotrwałe procesy. Jednym z rozwiązań jest użycie kanałów (Channels) w C#, które umożliwiają bezpieczne przesyłanie danych pomiędzy różnymi wątkami w sposób asynchroniczny. Dzięki nim możliwe jest zorganizowanie przetwarzania danych w tle, co pozwala uniknąć zatorów i zapewnia płynność działania aplikacji.

W moim przypadku, wykorzystanie kanału zostało skonfigurowane przy pomocy klasy UnboundedChannelOptions. Dzięki właściwości SingleWriter = false możliwe jest jednoczesne publikowanie wielu wiadomości przez różnych nadawców, takich jak żądania HTTP. Z kolei ustawienie SingleReader = true gwarantuje, że w jednym momencie tylko jeden proces będzie odczytywał dane z kanału – w tym przypadku jest to nasza zadanie w tle (background task). Kluczowym elementem w tej konfiguracji jest metoda SubmitAsync, która stara się opublikować obiekt Stream do kanału za pomocą metody TryWrite. Zwrócenie wartości true oznacza sukces operacji, natomiast false wskazuje na jej niepowodzenie. Do tej pory nie napotkałem przypadku, w którym publikacja danych do kanału zakończyłaby się niepowodzeniem.

Dodatkowo, kanał może zostać skonfigurowany przy użyciu klasy BoundChannelOptions, która pozwala na ograniczenie liczby zdarzeń przechowywanych w kolejce. Mimo że nie było potrzeby stosowania tego rozwiązania w moim przypadku, zawsze warto je mieć w zanadrzu dla bezpieczeństwa. Cały proces jest monitorowany przez metodę WaitToWriteAsync, która przed każdorazowym zapisaniem wiadomości do kanału sprawdza, czy jest to możliwe. Ta metoda przyjmuje parametr CancellationToken, co pozwala na anulowanie operacji w przypadku zakończenia aplikacji. Jeśli proces zakończenia zostanie zainicjowany, dzięki sprawdzeniu właściwości IsCancellationRequested CancellationToken, wiadomości nie zostaną już opublikowane.

W celu pełnej integracji tej funkcjonalności z aplikacją, warto zaimplementować klasę CountryFileIntegrationBackgroundService, której zadaniem jest asynchroniczne przetwarzanie wiadomości z kanału. Klasa ta może wyglądać następująco:

csharp
public class CountryFileIntegrationBackgroundService : BackgroundService
{ private readonly ICountryFileIntegrationChannel _channel; private readonly IServiceProvider _serviceProvider; public CountryFileIntegrationBackgroundService(ICountryFileIntegrationChannel channel, IServiceProvider serviceProvider) { _channel = channel; _serviceProvider = serviceProvider; }
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await foreach (var fileContent in _channel.ReadAllAsync(cancellationToken)) { try { using (var scope = _serviceProvider.CreateScope()) { var service = scope.ServiceProvider.GetRequiredService<ICountryService>(); await service.IngestFile(fileContent); } } catch { } } } }

W tym kodzie, każda wiadomość z kanału jest przetwarzana przez dedykowaną usługę, która zajmuje się jej dalszym przetwarzaniem. Warto zauważyć, że proces jest kontrolowany przez parametr CancellationToken, co pozwala na bezpieczne zakończenie przetwarzania w przypadku zamknięcia aplikacji.

Aby ta funkcjonalność działała poprawnie, konieczna jest rejestracja kanału oraz usługi tła w systemie zależności (Dependency Injection). W pliku Program.cs rejestrujemy kanał jako singleton, aby jedna instancja była używana przez cały czas trwania aplikacji, co jest kluczowe dla prawidłowego odczytu wiadomości. W przypadku rejestracji usługi tła, korzystamy z metody AddHostedService, aby aplikacja mogła uruchomić tło w odpowiednim momencie:

csharp
builder.Services.AddSingleton<ICountryFileIntegrationChannel>(); builder.Services.AddHostedService<CountryFileIntegrationBackgroundService>();

Dodatkowo, warto skonfigurować czas oczekiwania na zakończenie zadań w tle przed zamknięciem aplikacji. W tym celu można ustawić ShutdownTimeout w pliku Program.cs, co pozwala na dokończenie operacji, które były w trakcie wykonywania:

csharp
builder.Services.PostConfigure<HostOptions>(option =>
{ option.ShutdownTimeout = TimeSpan.FromSeconds(60); });

Kiedy procesy tła są już skonfigurowane, można zdefiniować endpoint do przesyłania plików. Warto pamiętać o dobrych praktykach HTTP, w tym o zwracaniu statusu Accepted (202) w odpowiedzi, co wskazuje, że żądanie zostało przyjęte do dalszego przetwarzania, ale jeszcze nie zakończone. W przypadku błędów powinna zostać zwrócona odpowiedź Internal Server Error (500).

csharp
app.MapPost("/countries/upload", async (IFormFile file, ICountryFileIntegrationChannel channel, CancellationToken cancellationToken) =>
{ if (await channel.SubmitAsync(file.OpenReadStream(), cancellationToken)) return Results.Accepted(); return Results.StatusCode(StatusCodes.Status500InternalServerError); }).DisableAntiforgery();

Dzięki tym rozwiązaniom aplikacja jest w stanie zarządzać dużymi zadaniami w tle, przetwarzać pliki w sposób bezpieczny i asynchroniczny, oraz zapewnić płynność działania serwera, nie obciążając bezpośrednio użytkownika czekającego na odpowiedź.

Warto także pamiętać, że skalowanie aplikacji w takim przypadku polega na tworzeniu dedykowanych kanałów dla każdego zadania w tle, aby zapewnić, że każdy kanał będzie odpowiadał tylko za jedno zadanie. To rozwiązanie umożliwia równoczesne przetwarzanie wielu różnych procesów bez ryzyka utraty danych lub przeciążenia systemu.

Dodatkowo, w aplikacjach, które pracują z dużymi zbiorami danych, dobrze jest pomyśleć o implementacji paginacji. Paginacja pozwala na ładowanie danych w częściach, co jest szczególnie istotne w przypadku aplikacji mobilnych, gdzie przepustowość sieci jest ograniczona. Implementacja paginacji na przykładzie zapytania GET /countries wygląda następująco:

csharp
app.MapGet("/countries", async (int? pageIndex, int? pageSize, ICountryMapper mapper, ICountryService countryService) => { var countries = await countryService.GetAllAsync(new PagingDto { PageIndex = pageIndex ?? 1, PageSize = pageSize ?? 10 }); return Results.Ok(mapper.Map(countries)); });

Dzięki tym parametrom, jak pageIndex i pageSize, można precyzyjnie kontrolować, jakie dane będą zwrócone przez API, co w znaczący sposób poprawia wydajność i responsywność aplikacji.

Jak skonfigurować logowanie w ASP.NET Core przy użyciu Serilog i Application Insights?

Serilog to popularna biblioteka do logowania w aplikacjach .NET, która umożliwia strukturalne logowanie i integrację z różnymi mediami przechowywania logów, takimi jak pliki, konsola, czy Application Insights. Dzięki swojej elastyczności, Serilog pozwala na zbieranie dodatkowych metadanych o zdarzeniach, co ułatwia późniejsze analizowanie logów. Oprócz tego, biblioteka ta jest kompatybilna z interfejsem ILogger w ASP.NET Core, co pozwala na łatwą konfigurację logowania w aplikacjach webowych.

Aby skonfigurować logowanie z użyciem Serilog w aplikacji ASP.NET Core, należy wykonać kilka kroków, zaczynając od zainstalowania odpowiednich pakietów NuGet. W tym przypadku są to pakiety: Serilog.AspNetCore oraz Serilog.Sinks.ApplicationInsights. Pierwszy z nich umożliwia korzystanie z interfejsu ILogger, a drugi wysyła logi do Application Insights, który jest narzędziem do monitorowania aplikacji oferowanym przez Microsoft.

Po zainstalowaniu pakietów, kolejnym krokiem jest odpowiednia konfiguracja Serilog w pliku appsettings.json. W tym pliku definiujemy między innymi, jakie "sinki" będą używane do przechowywania logów oraz na jakim poziomie szczegółowości mają być rejestrowane zdarzenia. Przykładowa konfiguracja, jaką można umieścić w pliku appsettings.json, wygląda następująco:

json
{ "Serilog": { "Using": [ "Serilog.Sinks.ApplicationInsights" ], "MinimumLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }, "WriteTo": [ { "Name": "ApplicationInsights", "Args": {
"connectionString": "{YourApplicationInsightsConnectionString}",
"telemetryConverter": "Serilog.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" } } ],
"Enrich": ["FromLogContext"],
"Properties": { "Application": "DemoAPI" } } }

W tej konfiguracji kluczowym elementem jest MinimumLevel, który określa, jakiego poziomu logi mają być zapisywane. Wartość Information pozwala na rejestrowanie logów o poziomach od Information do Critical. Można również skonfigurować logowanie w różnych częściach aplikacji z różnymi poziomami szczegółowości, na przykład dla ASP.NET Core z poziomem Warning.

Po dokonaniu tej konfiguracji, musimy jeszcze zainicjować Serilog w kodzie aplikacji, aby zaczął on działać. W pliku Program.cs dodajemy odpowiednią linię, która inicjuje logowanie:

csharp
builder.Host.UseSerilog((context, configuration) => configuration.ReadFrom.Configuration(context.Configuration));

Dzięki temu, aplikacja jest teraz gotowa do przesyłania logów do Application Insights. Aby rozpocząć korzystanie z logowania, możemy wywołać metody logowania bezpośrednio w kontrolerach lub minimalnych endpointach. Poniżej znajduje się przykład wykorzystania logowania w jednym z endpointów API:

csharp
var app = builder.Build();
app.MapGet("/logging", () => { app.Logger.LogInformation("/logging endpoint has been invoked."); return Results.Ok(); }); app.Run();

Jednak, jak pokazuje przykład, bezpośrednie używanie app.Logger w minimalnych endpointach nie jest najlepszą praktyką, ponieważ wprowadza problem testowalności. Zamiast tego, zaleca się użycie wstrzykiwania zależności i interfejsu ILogger, który umożliwia bardziej elastyczne i łatwe testowanie kodu. Przykład prawidłowego użycia ILogger za pomocą wstrzykiwania zależności wygląda następująco:

csharp
var app = builder.Build(); app.MapGet("/logging", (ILogger<Program> logger) => { logger.LogInformation("/logging endpoint has been invoked."); return Results.Ok(); }); app.Run();

Logowanie w aplikacjach .NET Core jest szczególnie przydatne w kontekście strukturalnego logowania. Dzięki Serilogowi możemy logować informacje w sposób, który umożliwia łatwe zbieranie i analizowanie dodatkowych danych kontekstowych, takich jak wartości zmiennych czy parametry zapytań. Przykład strukturalnego logowania:

csharp
var app = builder.Build();
app.MapGet("/countries", async (int? pageIndex, int? pageSize, ICountryMapper mapper, ICountryService countryService, ILogger<Program> logger) => {
var paging = new PagingDto { PageIndex = pageIndex.HasValue ? pageIndex.Value : 1, PageSize = pageSize.HasValue ? pageSize.Value : 10 }; var countries = await countryService.GetAllAsync(paging); using (logger.BeginScope("Getting countries with page index {pageIndex} and page size {pageSize}", paging.PageIndex, paging.PageSize)) { logger.LogInformation("Received {count} countries from the query", countries.Count); return Results.Ok(mapper.Map(countries)); } }); app.Run();

W powyższym przykładzie widać, że wartości zmiennych, takich jak pageIndex, pageSize, oraz liczba wyników count są zapisywane w logu jako „Custom Properties”. Dzięki temu łatwiej jest analizować logi w narzędziach takich jak Application Insights, gdzie te właściwości będą widoczne jako metadane.

Serilog oferuje również możliwość łatwego wykorzystania interpolacji ciągów znaków, co sprawia, że logi stają się bardziej zrozumiałe. Jednak warto pamiętać, że lepszą praktyką jest używanie strukturalnego logowania, a nie tylko prostego wstawiania zmiennych do tekstu, co pozwala na lepszą analizę logów w późniejszym czasie.

W przypadku aplikacji korzystających z Application Insights, logi mogą być później przeglądane w interfejsie narzędzia, w sekcji „Transaction Search”, gdzie można wyszukiwać logi i szczegółowo analizować zdarzenia, korzystając z dobrze zorganizowanych danych.