Controller MVC i/lub API w osobnej bibliotece Class Library

Controller MVC i/lub API w osobnej bibliotece Class Library

Wstęp

Dodawanie kontrolera do osobnej biblioteki może być użyteczne w przypadku, gdy na przykład tworzysz plugin lub system, który korzysta z pluginów. Lub z jakiegoś jeszcze innego powodu chcesz wydzielić część kontrolerów do innego projektu. W .NET robi się to bardzo prosto.

Krok po kroku

Zakładam, że masz już istniejącą solucję z kontrolerami API, czy też MVC.

  1. Dodaj kolejny projekt Class Library do solucji, jeśli jeszcze go nie masz.
  2. Doinstaluj do niego paczkę NuGet: Microsoft.AspNetCore.App
  3. Podczas rejestracji serwisów dodaj:
services.AddMvc().AddApplicationPart(assembly);

Zmienna assembly to oczywiście Twoje assembly z ClassLibrary, w którym masz kontrolery. Możesz to pobrać na kilka sposobów. Jeśli taką rejestrację przeprowadzasz z jakiejś extension method w swojej ClassLibrary, np:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection(this IServiceCollection services)
    {
         services.AddMvc().AddApplicationPart(Assembly.GetExecutingAssembly());
    }
}

Jeśli jednak rejestrację przeprowadzasz z jakiegoś powodu z głównej aplikacji, to najprościej pobrać Assembly po konkretnej klasie.

Załóżmy, że Twój kontroler mieści się w takiej klasie:

namespace API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AccountController : ControllerBase
    {
        //
    }
}

Wtedy wystarczy pobrać assembly z tej właśnie klasy:

var assembly = typeof(API.Controllers.AccountController).Assembly;
services.AddMvc().AddApplicationPart(assembly);

To wszystko. Ta prosta „sztuczka” może sprawić, że Twój projekt stanie się bardziej czytelny i bardziej modularny.

Dzięki za przeczytanie artykułu. Jeśli znalazłeś jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu. Jeśli uważasz, że ta „sztuczka” jest super przydatna i ma też inne zastosowania, to też się podziel 🙂

Podziel się artykułem na:
Piszemy klienta do WebAPI

Piszemy klienta do WebAPI

Wstęp

Często mówimy o tym, czym jest WebApi, jak je tworzyć, a jak nie. Ale jakoś nie mówimy o tym jak stworzyć dobrze fajnego klienta do tego WebAPI.

Mogłoby się zdawać, że wystarczy utworzyć instancję HttpClient i wywołać odpowiednią końcówkę. I czasem nawet można tak zrobić. Ale jeśli chcesz mieć naprawdę dobrego klienta do większego API niż tylko dwie końcówki, to ten artykuł pokaże Ci jak do tego podejść na konkretnym przykładzie.

Jest NuGetowa paczka – RestSharp. Jest to bardzo popularna darmowa biblioteka, która zdecydowanie ułatwia tworzenie klientów API. Jednak w tym artykule nie posłużymy się nią. Zrobimy coś sami. Potem sam zdecydujesz, czy wolisz tworzyć takie rozwiązania samodzielnie, czy z użyciem RestSharpa.

Przede wszystkim – WebAPI

Żeby klient API miał sens, musi przede wszystkim łączyć się z jakimś API. Dlatego też przygotowałem dość proste rozwiązanie, na którym będziemy pracować. Możesz je pobrać z GitHuba.

Uwaga! Nie zwracaj za bardzo uwagi na kod API – jest bardzo prosty, banalny i nie we wszystkich aspektach super poprawny. Nie zajmujemy się tutaj WebAPI, tylko klientem do API.

To Api trzyma dane w słowniku, to znaczy że po ponownym uruchomieniu, wszystkie dane znikną.

Api ma kilka końcówek, możesz sobie je zobaczyć, uruchamiając swaggera. Z grubsza to:

  • POST – /api/clients/all – pobiera listę klientów (dlaczego POST – o tym niżej)
  • POST – /api/clients – dodaje klienta
  • GET – /api/clients/{id} – pobiera klienta o konkretnym id
  • DELETE – /api/clients/{id} – usuwa klienta o konkretnym id
  • POST – /api/orders/all – pobiera zamówienia (dlaczego POST – o tym niżej)
  • POST – /api/orders – dodaje zamówienie
  • GET – /api/orders/client/{clientId} – pobiera zamówienia dla konkretnego klienta
  • GET – /api/orders/{id} – pobiera zamówienie o konkretnym id

Także mamy kilka końcówek podzielonych na dwa kontrolery.

Zaczynamy pisać klienta

OK, skoro już wiemy jak mniej więcej wygląda API, możemy utworzyć projekt, w którym napiszemy klienta. Niech to będzie zwykły projekt Class Library.

Model DTO

Najpierw musimy utworzyć modele DTO. DTO czyli Data Transfer Object – są to klasy, które przekazują dane między API, a klientem. Modele DTO mogą być jak najgłupsze się da. To po prostu worek na dane. Nic więcej.

Teraz możesz zapytać – po co tworzyć dodatkowy model, skoro mamy już dokładny model bazodanowy? Nie lepiej ten model bazodanowy z projektu WebApi przenieść do jakiegoś współdzielonego?

W tym konkretnym przypadku banalnej aplikacji – pewnie tak. Natomiast przy aplikacjach bardziej rozbudowanych przekazywanie danych za pomocą modeli bazodanowych może okazać się baaaardzo problematyczne. Sam wiele lat temu zrobiłem taki błąd. W pewnym momencie okazało się, że muszę stosować jakieś dziwne haki i czary, żeby to wszystko jakoś działało. Dlatego – stwórz osobny model DTO.

W przykładowej aplikacji są w projekcie Models. Modele DTO wyglądają prawie tak samo jak modele bazodanowe. Specjalnie dodałem do modeli bazodanowych jedną właściwość (IsDeleted), żeby je czymś rozróżnić.

Zwróć uwagę na dwie klasy:

GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

GetClientsResultDto:

public class GetClientsResultDto
{
    public IEnumerable<ClientDto> Data { get; init; }
    public int  Offset { get; init; }

    public GetClientsResultDto(IEnumerable<ClientDto> data, int offset)
    {
        Data = data;
        Offset = offset;
    }
}

W standardowym tutorialu tworzenia WebApi zobaczyłbyś, że gdy żądasz listy klientów, API zwraca po prostu listę klientów, np: IEnumerable<ClientDto>.

Jednak w prawdziwym świecie to może być za mało. Dlatego też stworzyłem dwie dodatkowe klasy:

  • GetClientsRequestDto – obiekt tej klasy będzie wysyłany wraz z żądaniem pobrania listy klientów
  • GetClientsResultDto – obiekt tej klasy będzie zwracany przez API zamiast zwykłej listy klientów.

Jak widzisz, te klasy zawierają w sobie informacje ograniczające ilość pobieranych danych. Jeśli miałbyś bazę z 10000 klientów i z jakiegoś powodu chciałbyś pobrać ich listę, to zupełnie bez sensu byłoby pobieranie wszystkich 10000 rekordów. To naprawdę sporo danych. Zamiast tego możesz pobierać te dane partiami i napisać jakiś prosty mechanizm paginacji. Do tego mogą właśnie służyć te dodatkowe klasy.

Analogicznie zrobiłem dla modelu OrderDto.

Abstrakcja

Skoro już mamy modele DTO, możemy pomyśleć o abstrakcji, która umożliwi nam testowanie klienta API.

Zgodnie z regułą pojedynczej odpowiedzialności (signle responsibility) klient API nie powinien być odpowiedzialny za wszystkie operacje związane z API. Ale powinien dać taką możliwość. Jak to osiągnąć? Poprzez dodatkowe klasy operacji. I tak będziemy mieć klasę odpowiedzialną za operacje na zamówieniach i drugą odpowiedzialną za klientów. Stwórzmy teraz takie abstrakcje:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

To jest interfejs, którego implementacja będzie odpowiedzialna za operacje na klientach. Analogicznie stworzymy drugi interfejs – do zamówień:

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

To są bardzo proste interfejsy i na pierwszy rzut oka wszystko jest ok. Ale co jeśli z WebApi otrzymasz jakiś konkretny błąd? Np. podczas dodawania nowego klienta mógłbyś otrzymać błąd w stylu: „Nazwa klienta jest za długa”. W taki sposób tego nie ogarniesz. Dlatego proponuję stworzyć dwie dodatkowe klasy, które będą przechowywały rezultat wywołania końcówki API:

public class BaseResponse
{
    public int StatusCode { get; init; }
    public bool IsSuccess { get { return StatusCode >= 200 && StatusCode <= 299 && string.IsNullOrWhitespace(ErrorMsg); } }
    public string ErrorMsg { get; init; }

    public BaseResponse(int statusCode = 200, string errMsg = "")
    {
        StatusCode = statusCode;
        ErrorMsg = errMsg;
    }
}

public class DataResponse<T> : BaseResponse
{
    public T Data { get; init; }

    public DataResponse(T data, int statusCode = 200, string errMsg = "")
        : base(statusCode, errMsg)
    {
        Data = data;
    }
}

Klasa BaseResponse i operacja zakończona poprawnie

Klasa BaseResponse będzie przechowywała kod odpowiedzi wraz z ewentualnym komunikatem o błędzie. Wg specyfikacji HTTP wszystkie kody od 200 do 299 włącznie oznaczają operację zakończoną poprawnie, dlatego też IsSuccess jest tak skonstruowane.

Teraz pojawia się pytanie – co oznacza „operacja zakończona poprawnie”? W kontekście WebApi zazwyczaj chodzi tutaj o to, że dane przesłane w żądaniu były prawidłowe, na serwerze nic się nie wywaliło, nie było problemu z autoryzacją i serwer odpowiedział prawidłowo. Jednak nie znaczy to, że operacja zakończyła się tak, jak byśmy sobie tego życzyli.

To trochę dziwnie brzmi, zatem pokażę Ci pewien przykład. Załóżmy, że chcesz pobrać klienta o ID = 5. Wg specyfikacji REST Api, jeśli taki klient nie istnieje, powinieneś otrzymać zwrotkę z kodem 404. Jednak błąd 404 oznacza również, że nie znaleziono określonej strony (końcówki API). Jest to pewien znany problem. Czasami się to tak zostawia, czasem można rozróżnić w taki sposób, że z WebAPI zwracamy kod 200 – operacja się powiodła, ale dołączamy informację o błędzie w odpowiedzi np: „Nie ma klienta o takim ID”.

To nam wszystko załatwia klasa BaseResponse.

Klasa DataResponse

Jak widzisz, DataResponse dziedziczy po BaseResponse. Jedyną różnicą jest to, że DataResponse przechowuje dodatkowo dane, które mogły przyjść w odpowiedzi. Teraz, mając takie klasy, możemy zmienić zwracany typ z interfejsów IClientOperations i IOrderOperations. Do tej pory wyglądało to tak:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

A teraz będziemy mieli coś takiego:

public interface IClientOperations
{
    public Task<DataResponse<ClientDto>> AddClient(ClientDto data);
    public Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data);
    public Task<DataResponse<ClientDto>> GetClientById(int id);
    public Task<BaseResponse> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<DataResponse<OrderDto>> AddOrder(OrderDto order);
    public Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data);
    public Task<BaseResponse> DeleteOrder(int id);
}

Interfejs IApiClient

Skoro mamy już interfejsy dla poszczególnych operacji, możemy teraz napisać sobie interfejs do ApiClienta. I tutaj znów – ta abstrakcja nie jest konieczna. Jednak bez niej nie będziesz w stanie testować jednostkowo kodu, który używa klienta API.

Interfejs jest banalny:

public interface IApiClient
{
    IClientOperations ClientOperations { get; }
    IOrderOperations OrderOperations { get; }
}

Jak widzisz, klient API będzie dawał dostęp do poszczególnych operacji. To teraz zajmijmy się implementacją poszczególnych operacji, która zasadniczo będzie prosta.

Implementacja IClientOperations

Do komunikacji z WebApi wykorzystujemy HttpClient – dlatego też on musi znaleźć się w konstruktorze.

internal class ClientOperations : IClientOperations
{
    private readonly HttpClient _httpClient;

    public ClientOperations(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<DataResponse<ClientDto>> AddClient(ClientDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients", data);
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteClient(int id)
    {
        var response = await _httpClient.DeleteAsync($"clients/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<ClientDto>> GetClientById(int id)
    {
        var response = await _httpClient.GetAsync($"clients/{id}");
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients/all", data);
        return await ResponseFactory.CreateDataResponse<GetClientsResultDto>(response, DefaultJsonSerializerOptions.Options);
    }
}

Dalej mamy implementację poszczególnych metod. Każda z nich jest oparta dokładnie na tej samej zasadzie:

  • wyślij żądanie na odpowiednią końcówkę
  • stwórz DataResponse/BaseResponse na podstawie otrzymanej odpowiedzi – HttpResponseMessage.

Zwróć uwagę tutaj na trzy rzeczy.

  1. Klasa DefaultJsonSerializerOptions – jest to klasa, która trzyma domyślne dla aplikacji ustawienia serializacji JSON. W naszej aplikacji nie chcemy, żeby serializacja brała pod uwagę wielkość znaków. Jeśliby brała wtedy taki obiekt:
public class MyClass
{
    public int Id { get; set; }
    public string Name { get; set; }
}

nie zostałby powiązany z takim jsonem:

{
  "id": 5,
  "name": "Adam"
}

Z tego powodu, że występuje różnica w wielkości znaków. Niestety domyślne ustwienia serializatora z Microsoft biorą pod uwagę wielkość znaków. My chcemy tego uniknąć, dlatego powstała klasa, która przechowuje odpowiednie opcje. Znajduje się w projekcie Common.

  1. ResponseFactory to pomocnicza klasa, która z odpowiedzi HttpRequestMessage tworzy interesujące nas obiekty DataResponse lub BaseResponse – omówimy ją za chwilę.
  2. Pobieranie danych za pomocą POST…

No właśnie, spójrz na metodę GetClients. Ona pobiera dane za pomocą POST, a nie GET. Dlaczego tak jest? Czyżby to jaka herezja?

Przyczyną jest obecność klasy GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

Metoda GET nie może mieć żadnych danych w ciele żądania. Oczywiście w tym przypadku można by te dwie właściwości włączyć do query stringa, wywołując końcówkę np: api/clients/all?skip=0&take=10. Jeśli jednak masz sporo więcej do filtrowania, do tego jakieś sortowanie i inne rzeczy… lub z jakiegoś powodu takie dane nie powinny być w query stringu, to spokojnie możesz je wrzucić do POSTa. Nikt Cię za to nie wychłosta 😉 Co więcej – to jest normalną praktyką w niektórych WebAPI.

ResponseFactory

Jak już wspomniałem, klasa ResponseFactory jest odpowiedzialna za utworzenie BaseResponse/DataResponse na podstawie przekazanego HttpResponseMessage. Jej implementacja w naszym przykładzie wygląda tak:

internal static class ResponseFactory
{
    public static async Task<BaseResponse> CreateBaseResponse(HttpResponseMessage response)
    {
        if (response.IsSuccessStatusCode)
            return new BaseResponse((int)response.StatusCode);
        else
            return new BaseResponse((int)response.StatusCode, await GetErrorMsgFromResponse(response));
    }

    public static async Task<DataResponse<T>> CreateDataResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        if (response.IsSuccessStatusCode)
        {
            T data = await GetDataFromResponse<T>(response, jsonOptions);
            return new DataResponse<T>(data, (int)response.StatusCode);
        }
        else
        {
            return new DataResponse<T>(default(T), (int)response.StatusCode, await GetErrorMsgFromResponse(response));
        }
    }

    private static async Task<T> GetDataFromResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        string content = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<T>(content, jsonOptions);
    }

    private static async Task<string> GetErrorMsgFromResponse(HttpResponseMessage response)
    {
        string result = await response.Content.ReadAsStringAsync();
        if (string.IsNullOrEmpty(result))
            return response.ReasonPhrase;
        else
            return result;
    }
}

Nie ma tu niczego skomplikowanego. Wszystko sprowadza się do tego, że odczytuję dane z contentu odpowiedzi i deserializuję je do odpowiedniego obiektu. To wszystko. Jedyne, co może być ciekawe to metoda GetErrorMsgFromResponse, która ma zwrócić komunikat błędu. Zakładam, że jeśli błąd wystąpi, zostanie umieszczony po prostu jako content odpowiedzi – tak jest skonstruowane przykładowe WebAPI.

Implementacja IOrderOperations

Jest analogiczna jak IClientOperations, dlatego też nie będę jej omawiał. Kod wygląda tak:

internal class OrderOperations : IOrderOperations
{
    private readonly HttpClient _httpClient;

    public OrderOperations(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<DataResponse<OrderDto>> AddOrder(OrderDto order)
    {
        var response = await _httpClient.PostAsJsonAsync("orders", order);
        return await ResponseFactory.CreateDataResponse<OrderDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteOrder(int id)
    {
        var response = await _httpClient.DeleteAsync($"orders/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("orders/all", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync($"orders/client/{clientId}", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto> (response, DefaultJsonSerializerOptions.Options);
    }
}

Implementacja ApiClient

OK, nadszedł wreszcie czas na napisanie implementacji głównego klienta API:

public class ApiClient : IApiClient
{
    public IClientOperations ClientOperations { get; private set; }
    public IOrderOperations OrderOperations { get; private set; }

    private readonly HttpClient _httpClient;

    public ApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        ClientOperations = new ClientOperations(_httpClient);
        OrderOperations = new OrderOperations(_httpClient);
    }
}

Tutaj HttpClient przychodzi z dependency injection. Następnie są tworzone odpowiednie obiekty – ClientOperations i OrderOperations, do których przekazujemy tego HttpClienta. Prawda, że proste?

HttpPipeline, czyli zupełnie nowy świat

Żeby klient API był wymuskany, można do niego dodać HttpPipeline. Pisałem o tym w tym artykule, więc nie będę się powtarzał. Zostawię Ci tylko zajawkę, że dzięki Http Pipeline, możesz zrobić zupełnie wszystko z żądaniem (zanim dotrze do celu) i odpowiedzią (zanim wróci do HttpClient). To zupełnie nowy świat możliwości. Przede wszystkim możesz automatycznie ustawiać wersję API, możesz odświeżać bearer token, możesz logować całe żądanie. Nic Cię tu nie ogranicza. Dlatego koniecznie przeczytaj ten artykuł, żeby mieć pełen obraz.

Przykładowe użycie

W repozytorium do tego artykułu jest umieszczony projekt WebApp – jest to bardzo prosta aplikacja RazorPages, które po krótce pokazuje użycie klienta.

UWAGA! Kod w aplikacji przykładowej jak i w WebApi jest podatny na różne rodzaje ataków. Dlatego nie stosuj takich „uproszczeń” w prawdziwym życiu. Różne ataki i jak się przed nimi chronić zostały opisane w tej książce.

W ramach ćwiczeń możesz spróbować zaimplementować w tym rozwiązaniu paginację, a także resztę operacji związanych z zamówieniami.


Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz przykładał większą wagę do klientów API, które tworzysz i artykuł podpowiedział Ci jak to zrobić dobrze. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂

Obrazek artykułu: Server icons created by Freepik – Flaticon

Podziel się artykułem na:
Feature Flags – co to i po co?

Feature Flags – co to i po co?

Wstęp

Czasem bywa tak, że musimy wyłączyć pewne funkcje w aplikacji. Zazwyczaj dlatego, że nie są jeszcze gotowe/przetestowane w 100% i trudno by było opublikować aplikację bez nich.

W dzisiejszym artykule opowiem, jak podejść do tematu.

Czym są Feature flags?

To specjalny rodzaj flagi mówiącej o tym, czy dana funkcja może być używana. Najprostszą taką flagą będzie dyrektywa kompilatora, np:

#if IMPORT_DB
            DbImporter importer = new DbImporter();
            importer.Import();
#endif

Jeśli zdefiniowaliśmy flagę IMPORT_DB, wtedy ten kod się wykona. Tak samo jak wszystkie inne, które będą opatrzone dyrektywami #if..#endif. O ile dość łatwo to ogarnąć np. w C++, to w C# jest już ciężej z tego powodu, że każdy projekt ma swój własny zestaw definów. I wtedy trzeba pamiętać, żeby do każdego projektu dołączyć plik z tymi feature’ami.

Innym problemem może być (ale nie musi) to, że bez ponownej kompilacji nie odblokujemy funkcji programu. Czasem jest to pożądanie, czasem nie.

Dodatkowo, jakby nie patrzeć, takie dyrektywy zaciemniają w pewien sposób kod.

.NetCore ma jednak sprytny mechanizm do zarządzania flagami funkcji. Nazywa się to FeatureManager i za chwilę Ci go przedstawię.

Instalacja FeatureManager

Najpierw musisz pobrać sobie NuGet: Microsoft.FeatureManagement.AspNetCore

Teraz wystarczy już tylko zarejestrować serwisy z tej biblioteki. Robimy to oczywiście podczas rejestrowania wszystkich innych serwisów.

builder.Services.AddFeatureManagement();

Jeśli się przyjrzysz, to zobaczysz, że AddFeatureManagement ma dwie wersje. W drugiej możesz przekazać całą sekcję w konfiguracji, w której wyłączasz lub włączasz poszczególne funkcje (domyślnie są odczytywane z sekcji FeatureManagement).

Domyślne działanie jest takie, że FeatureManager odczytuje sobie poszczególne funkcje z appSettings.json z sekcji „FeatureManagement„. Oczywiście odczytuje to dokładnie tak samo jak wszystkie inne opcje programu. Czyli najpierw appSettings.json, appSettings.{Environment}.json, zmienne środowiskowe itd. Jeśli nie znasz tematu dokładnie, koniecznie przeczytaj ten artykuł (konfiguracja i opcje programu).

Tworzenie flag

Zrobimy sobie przykładowy projekt, który pokazuje działanie flag – symulator telewizora. Tym razem będzie to projekt MVC, żeby móc pokazać więcej rzeczy. Przykład możesz pobrać sobie z GitHuba.

Najpierw zatroszczmy się o flagi. Do appSettings dodaj taką sekcję:

"FeatureManagement": {
  "PowerControl": "true",
  "ChannelControl": "false",
  "VolumeControl": "false"
}

Zwróć uwagę, że sekcja nazywa się FeatureManagement. Tak jak już mówiłem, to z niej domyślnie są odczytywane wartości flag.

Zdefiniowaliśmy tutaj trzy flagi:

  • PowerControl – użytkownik może włączyć i wyłączyć telewizor
  • ChannelControl – użytkownik może przełączać kanały. Jak widzisz, w tym momencie flaga jest wyłączona, czyli pozbawiamy użytkownika tej opcji
  • VolumeControl – użytkownik może zmieniać głośność. Teraz też go pozbawiamy tej opcji.

Oczywiście będziemy musieli się posługiwać nazwami tych flag później w kodzie. Dlatego też powinniśmy je wyekstrahować albo do jakiś stałych, albo do jakiegoś enuma. Ja wybrałem stałe. Utwórz osobny plik do tego:

public static class FeatureFlags
{
    public const string PowerControl = "PowerControl";
    public const string ChannelControl = "ChannelControl";
    public const string VolumeControl = "VolumeControl";
}

Kontrola funkcji

Oczywiście nie można napisać mechanizmu, który automagicznie wyłączy lub włączy poszczególne funkcje. To musimy zrobić samemu. Możemy to zrobić na dwa sposoby. Spójrz na ten kod w widoku:

@using Microsoft.FeatureManagement
@inject IFeatureManager FeatureManager

<h1>SUPER TV!</h1>
<hr />

<h2>Pilot</h2>
<hr />
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2">TV ON</button>
        <button class="btn btn-secondary col-3">TV OFF</button>
    </div>
}

Na początku wstrzykujemy IFeatureManager. Następnie sprawdzamy, czy konkretna flaga została włączona, używając metody IsEnabledAsync. W jej argumencie przekazujemy nazwę flagi.

Jeśli flaga jest włączona, pokazujemy dla niej funkcjonalność. Analogicznie teraz możemy zrobić dla pozostałych flag:

@using Microsoft.FeatureManagement
@inject IFeatureManager FeatureManager

<h1>SUPER TV!</h1>
<hr />

<h2>Pilot</h2>
<hr />
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2">TV ON</button>
        <button class="btn btn-secondary col-3">TV OFF</button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.ChannelControl))
{
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2"><strong>-</strong></button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.VolumeControl))
{
    <div class="row mb-2">
        <button class="btn btn-outline-info col-2 me-1"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">VOLUME</span>
        <button class="btn btn-outline-info col-2"><strong>-</strong></button>
    </div>
}

Interfejs IFeatureManager możesz wstrzyknąć do dowolnej klasy i używać go też na backendzie.

Teraz dodajmy jakieś działanie do tych przycisków. Żeby to zrobić, umieścimy je wszystkie w formularzu, a każdy guzik będzie odnosił na inną końcówkę. Całość będzie wyglądała mniej więcej tak (fragmenty usunąłem dla lepszej czytelności):

<form method="post">
    @if (await FeatureManager.IsEnabledAsync(FeatureFlag.PowerControl.ToString()))
    {
        <div class="row mb-2">
            <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
            <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
        </div>
    }
//i dalej sprawdzenie innych flag
</form>

Zabezpieczanie back-endu

Jeśli na froncie nie ma konkretnej funkcji, nie znaczy że nie można jej wywołać na backendzie. Spójrz na ten kod:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    [HttpPost]
    public IActionResult TvOn()
    {
        return View("Index");
    }

    [HttpPost]
    public IActionResult VolumeUp()
    {
        return View("Index");
    }

Jeśli teraz w jakiś sposób wywołamy końcówkę VolumeUp, no to stanie się coś złego. Więc z tej strony też powinniśmy się przed tym zabezpieczyć. FeatureManager daje nam bardzo fajny atrybut do tego FeatureGate:

[HttpPost]
[FeatureGate(FeatureFlags.VolumeControl)]
public IActionResult VolumeUp()
{
    return View("Index");
}

Jeśli teraz spróbujemy wywołać tę końcówkę, dostaniemy błąd 404 – strony nie znaleziono.

Za takie działanie jest odpowiedzialna domyślna implementacja interfejsu IDisabledFeaturesHandler. Oczywiście możesz sobie ją zmienić tak jak chcesz.

UWAGA! FeatureGate nie działa a RazorPages.

TagHelper

Jeśli nie podoba Ci się ta ifologia w widoku i widzisz tutaj szansę na użycie TagHelpers, dobra wiadomość jest taka, że Microsoft zrobił już to za Ciebie.

Spójrz jeszcze raz na kod widoku:

@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
        <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.ChannelControl))
{
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.VolumeControl))
{
    <div class="row mb-2">
        <button class="btn btn-outline-info col-2 me-1" asp-action="VolumeUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">VOLUME</span>
        <button class="btn btn-outline-info col-2" asp-action="VolumeDown"><strong>-</strong></button>
    </div>
}

Tego brzydala można zamienić na TagHelpery. I to jest drugi sposób ogarnięcia featerów na froncie.

Najpierw do _ViewImports.cshtml dodaj:

@addTagHelper *, Microsoft.FeatureManagement.AspNetCore

Teraz już możesz używać tag helpera feature:

<form method="post">
    <feature name="@FeatureFlags.PowerControl">
        <div class="row mb-2">
            <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
            <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
        </div>
    </feature>

    <feature name="@FeatureFlags.ChannelControl">
        <div class="row mb-2">
            <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
            <span class="col-2 my-auto text-center me-1">CHANNEL</span>
            <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
        </div>
    </feature>

    <feature name="@FeatureFlags.VolumeControl">
        <div class="row mb-2">
            <button class="btn btn-outline-info col-2 me-1" asp-action="VolumeUp"><strong>+</strong></button>
            <span class="col-2 my-auto text-center me-1">VOLUME</span>
            <button class="btn btn-outline-info col-2" asp-action="VolumeDown"><strong>-</strong></button>
        </div>
    </feature>
</form>

Przyznasz, że to wygląda zdecydowanie lepiej.

Co więcej, tag helpery dają Ci więcej możliwości niż tylko takie proste działanie. Możesz pokazać fragment, który będzie się pojawiał jeśli flaga będzie wyłączona, np:

<feature name="@FeatureFlags.ChannelControl">
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
    </div>
</feature>
<feature name="@FeatureFlags.ChannelControl" negate="true">
    <p>Zmiana kanałów będzie możliwa w przyszłości</p>
</feature>

Możesz też chcieć, żeby fragment kodu był widoczny tylko jeśli wszystkie lub kilka flag są włączone. Bardzo proszę:

<feature name="ChannelControl, VolumeControl" requirement="Any">
    <p>ChannelControl lub ValumeControl jest aktywne</p>
</feature>

<feature name="ChannelControl, VolumeControl" requirement="All">
    <p>ChannelControl i ValumeControl są aktywne</p>
</feature>

Wystarczy dodać nazwy tych flag do atrybutu name i posłużyć się atrybutem requirement. On może mieć dwie wartości – Any – jedna z flag musi być włączona; All – wszystkie flagi muszą być włączone.

Filtry i middleware

Jeśli używasz jakiegoś filtru (IAsyncActionFilter), który ma działać tylko gdy funkcja jest dostępna, możesz to zrobić w konfiguracji.

Dodaj ten filtr w nieco inny sposób niż standardowy:

builder.Services.AddControllersWithViews(o =>
{
    o.Filters.AddForFeature<VolumeFilter>(FeatureFlags.VolumeControl);
});

Zwróć uwagę, że nie rejestruję tutaj filtru z użyciem metody Add, tylko AddForFeature. W parametrze generycznym podaję typ filtru, a w środku nazwę flagi, z którą ten filtr ma być powiązany. W takim wypadku filtr zostanie odpalony tylko wtedy, jeśli flaga VolumeControl jest włączona.

Analogicznie można postąpić z middleware. Jeśli masz middleware, który ma być zależny od flagi, wystarczy że dodasz go w taki sposób zamiast standardowego:

app.UseMiddlewareForFeature<ChannelMiddleware>(FeatureFlags.ChannelControl);

To tyle jeśli chodzi o podstawy mechanizmu FeatureManager. To świetnie można połączyć z ustawieniami aplikacji na Azure – wtedy domyślnie stan flag odświeża się co 30 sekund. Ale to jest temat na inny artykuł, który powstanie.

Teraz dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu.

Obrazek wyróżniający: Technologia zdjęcie utworzone przez pvproductions – pl.freepik.com

Podziel się artykułem na:
Autoryzacja oparta na zasobach

Autoryzacja oparta na zasobach

Wstęp – opisanie problemu

Często zdarza się, że użytkownik musi mieć pewne uprawnienia, żeby móc pracować z pewnymi zasobami. Żeby to nie brzmiało aż tak enigmatycznie, posłużę się przykładem.

Załóżmy, że jest aplikacja do fakturowania (np. Fakturownia, której sam używam :)). Załóżmy też, że aplikacja ma kilka ról – administrator, użytkownik i super admin.

Administrator – może tworzyć organizacje, dodawać do niej użytkowników i zarządzać wszystkimi rekordami w swojej organizacji. Użytkownik może tylko przeglądać i dodawać nowe faktury. A superadmin jest „ponad to” i może zupełnie wszystko. Super adminem byłby w tym przypadku właściciel takiego serwisu.

I teraz tak. Administrator zakłada konto „Piękny Lolo”. Dodaje rekordy związane z wydatkami (dla swojej organizacji), usuwa i git. Dodaje też użytkowników pod swoje konto.

Ale nagle rejestruje się nowy administrator (z innej organizacji) – zakłada konto: „Wąsaty Jan”. I teraz jakby wyglądała sytuacja, gdybyś posługiwał się autoryzacją opartą o role? Zarówno Wąsaty Jan, jak i Piękny Lolo mają uprawnienia administratora. Więc teoretycznie Wąsaty Jan może pracować na rekordach Pięknego i vice versa. Nie chcemy tego.

Trzeba zatem ograniczyć ich działanie tylko do ich „organizacji”. W innym przypadku mamy podatność bezpieczeństwa (jest to jedna z podatności, o których piszę w swojej książce – „Zabezpieczanie aplikacji internetowych”).

Tutaj z pomocą przychodzi tzw. autoryzacja oparta na zasobach (resource based authorization).

Przykładowy projekt

Dla ułatwienia posłużymy się prostszym problemem – zrobimy standardową aplikację do zadań. Nie zaciemni to obrazu, a postępowanie jest dokładnie takie samo.

Przygotowałem już gotowe rozwiązanie, które możesz pobrać z GitHuba: https://github.com/AdamJachocki/ResourceBasedAuth

Najpierw przyjrzyjmy się mu.

To jest zwykły projekt RazorPages z ustawionym uwierzytelnianiem (Authentication Type) na Individual Accounts. Dla ułatwienia wszystko zawarłem w jednym projekcie. Pamiętaj, że w prawdziwym życiu powinieneś rozdzielić ten projekt na kilka innych.

UWAGA! To jest bardzo prosta aplikacja bez żadnych walidacji. Pokazuje właściwie najprostszy uporządkowany kod, żeby bez sensu nie zaciemniać obrazu.

Potrafi utworzyć zadanie (TodoItem), zmodyfikować i usunąć je.

Zanim uruchomisz projekt, musisz utworzyć bazę danych. W katalogu z projektem uruchom polecenie:

dotnet ef database update

Namespacey projektu

Abstractions

Zawiera interfejs ITodoItemService, który jest wstrzykiwany do RazorPages. On obsługuje wszystkie operacje na bazie danych. Są dwa serwisy, które implementują ten interfejs: SecureTodoItemService – który pokazuje operowanie na zasobach w sposób bezpieczny, a także InsecureTodoItemService – ten pokazuje działania bez żadnych zabezpieczeń.

Domyślnie działającym jest InsecureTodoItemService. Możesz to zmienić w pliku Program.cs.

Areas

To domyślna obsługa .Net Identity – zakładanie kont, logowanie itp.

Data

Głównym jej elementem jest model bazodanowy TodoItem. Poza tym zawiera migracje EfCore, a także DbContext.

Pages

Zawiera strony i komponenty – zgodnie z nomenklaturą RazorPages

Services

Zawiera potrzebne serwisy.

Działanie niezabezpieczone

Spójrz na serwis InsecureTodoItemService. Jak widzisz nie ma on żadnych zabezpieczeń ani sprawdzeń. Przykładowa metoda usuwająca zadanie wygląda tak:

public async Task RemoveItem(int id)
{
    var model = new TodoItem { Id = id };
    _db.TodoItems.Remove(model);
    await _db.SaveChangesAsync();
}

To znaczy, że właściwie każdy, kto ma konto może usunąć dowolne itemy. Wystarczy poznać ID. Nie jest to, coś co byśmy chcieli uzyskać.

Więc zajmijmy się tym.

Zabezpieczamy program

Zabezpieczenie w tym przypadku polega na sprawdzeniu, czy użytkownik, który wykonuje operację ma prawo do wykonania tej operacji na danym zasobie. Czyli w przypadku tej aplikacji – czy jest właścicielem danego zasobu.

Oczywiście można to zrobić na kilka sposobów, jednak pokażę Ci tutaj standardowy mechanizm .NET, który to zadanie ułatwia.

Krok 1 – dodawanie wymagań

Pierwszy krok jest zarówno najprostszy, jak i najcięższy do zrozumienia. Musimy dodać wymaganie (requirement). To wymaganie musi zostać spełnione, żeby użytkownik mógł przeprowadzić operację.

To wymaganie może wyglądać tak:

public class TodoItemOwnerOrSuperAdminRequirement: IAuthorizationRequirement
{

}

Zapytasz się teraz – dlaczego ta klasa jest pusta? Jaki jest jej sens? To wytłumaczyć najtrudniej. Generalnie interfejs IAuthorizationRequirement nie ma w sobie żadnych metod, właściwości… zupełnie niczego. Jest pusty. Służy głównie tylko do opisania wymagania. Samego zaznaczenia odpowiedniej klasy. Oczywiście nikt Ci nie zabroni dodać do tej klasy jakiejś logiki. Możesz też ją wstrzykiwać do swoich serwisów.

Krok 2 – dodawanie AuthorizationHandler

Drugim krokiem jest dodanie handlera, który sprawdzi, czy użytkownik może wykonać daną operację. Prosty przykład w naszej aplikacji:

public class TodoItemAuthHandler : AuthorizationHandler<TodoItemOwnerOrSuperAdminRequirement, TodoItem>
{
    private readonly LoggedUserProvider _loggedUserProvider;

    public TodoItemAuthHandler(LoggedUserProvider loggedUserProvider)
    {
        _loggedUserProvider = loggedUserProvider;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, 
        TodoItemOwnerOrSuperAdminRequirement requirement, 
        TodoItem resource)
    {
        var loggedUser = await _loggedUserProvider.GetLoggedUser();
        if(resource.OwnerId == loggedUser.Id)
            context.Succeed(requirement);
        else
            context.Fail();
    }
}

Twoja klasa musi dziedziczyć po AuthorizationHandler. AuthorizationHandler jest abstrakcyjną klasą generyczną. W parametrach generycznych przyjmuje typ wymagania, a także typ resource’a. Jest jeszcze druga jej postać, która przyjmuje tylko typ wymagania.

Musisz przesłonić tylko jedną metodę – HandleRequirementAsync. W parametrze AuthorizationHandlerContext dostajesz m.in. zalogowanego użytkownika (ClaimsPrincipal). Ja się posługuję swoim serwisem LoggedUserProvider ze względu na prostotę (w przeciwnym razie musiałbym jakoś odczytywać i zapisywać claimsy). W parametrze dostajesz również obiekt, o który pytasz.

I jeśli spojrzysz teraz do ciała tej metody, zobaczysz że sprawdzam, czy zalogowany użytkownik jest właścicielem danego zasobu. Normalnie sprawdzałbym, czy zalogowany użytkownik jest superadminem lub właścicielem zasobu. Ze względu na prostotę, pominęliśmy tutaj aspekt ról i superadmina.

I teraz, jeśli użytkownik jest superadminem lub właścicielem zasobu, przekazuję do kontekstu sukces. W przeciwnym razie blokuję.

Krok 3 – użycie AuthorizationHandler

W pierwszej kolejności musimy zarejestrować naszą klasę AuthorizationHandler, żeby móc jej używać. Rejestrujemy to oczywiście podczas rejestracji serwisów:

services.AddScoped<IAuthorizationHandler, TodoItemAuthHandler>(); //może być jako singleton, jeśli Twój serwis nie wykorzystuje innych scoped serwisów

A potem już tylko mały zastrzyk do serwisu (plik SecuredTodoItemService.cs). Wstrzykujemy interfejs IAuthorizationService:

private readonly IAuthorizationService _authService;

public SecuredTodoItemService(IAuthorizationService authService)
{
    _authService = authService;
}

I spójrz na przykładowe użycie:

public async Task ModifyItem(TodoItem item)
{
    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, item,
        new TodoItemOwnerOrSuperAdminRequirement());
    if(authResult.Succeeded)
    {
        _db.TodoItems.Update(item);
        await _db.SaveChangesAsync();
    }
}

_httpContextAccessor to oczywiście wstrzyknięty IHttpContextAccessor, bo metoda AuthorizeAsync niestety wymaga od nas przekazania zalogowanego użytkownika (on się później znajdzie w kontekście HandleRequirementAsync z klasy dziedziczącej po AuthorizationHandler).

Generalnie klasa implementująca IAuthorizationService została zarejestrowana automatycznie podczas rejestrowania autoryzacji (AddAuthorization()). Gdy wywołujesz AuthorizeAsync, ona sprawdza typ zasobu i wymaganie, o które pytasz. Na tej podstawie wywołuje metodę HandleRequirementAsync z odpowiedniej klasy dziedziczącej po AuthorizationHandler. A ich możesz mieć wiele. Dla różnych zasobów i różnych wymagań.

Jaki z tego wniosek? Wystarczy, że napiszesz jedną klasę pod konkretny typ zasobu, który chcesz chronić.

Dodatkowe uproszczenie

Oczywiście to można jeszcze bardziej ukryć/uprościć, tworząc przykładową klasę ResourceGuard, np:

public class ResourceGuard
{
    private readonly IAuthorizationService _authService;
    private readonly IHttpContextAccessor _httpCtx;

    public ResourceGuard(IAuthorizationService authService, IHttpContextAccessor httpCtx)
    {
        _authService = authService;
        _httpCtx = httpCtx;
    }

    public async Task<AuthorizationResult> LoggedIsAuthorized<T>(object resource)
        where T: IAuthorizationRequirement, new()
    {
        var requirement = new T();
        var user = _httpCtx.HttpContext.User;

        //tu możesz sprawdzić, czy user jest super adminem albo pójść dalej:

        return await _authService.AuthorizeAsync(user, resource, requirement);
    }
}

Wykorzystanie takiej klasy byłoby już dużo łatwiejsze:

public async Task DeleteItem(TodoItem item)
{
    var authResult = await _guard.LoggedIsAuthorized<TodoItemOwnerOrSuperAdminRequirement>(item);
    if (!authResult.Succeeded)
        return;
    else
    {
        //todo: usuń
    }
}

Gdzie wstrzykujesz już tylko ResourceGuard'a.

Moim zdaniem, jeśli masz dużo zasobów do chronienia, pomysł z ResourceGuardem jest lepszy, ale to oczywiście wszystko zależy od konkretnego problemu.

Pobieranie danych

A jak sprawdzić autoryzację przy pobieraniu danych? Tutaj trzeba odwrócić kolejność. Do tej pory najpierw sprawdzaliśmy autoryzację, a potem robiliśmy operacje na danych.

W przypadku pobierania musisz najpierw pobrać żądane dane, a dopiero potem sprawdzić autoryzację, np.:

public async Task<TodoItem> GetItemById(int id)
{
    var result = await _db.TodoItems.SingleOrDefaultAsync(x => x.Id == id);
    if (result == null)
        return null;

    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, result,
        new TodoItemOwnerOrSuperAdminRequirement());
    if (authResult.Succeeded)
        return result;
    else
        return null;
}

Jeśli musisz pobrać całą listę, to być może będziesz musiał sprawdzić każdy rekord z osobna. I potem w zależności od analizy biznesowej – albo zwracasz tylko te rekordy, do których użytkownik może mieć dostęp, albo gdy nie ma dostępu przynajmniej do jednego – nie zwracasz niczego.

UWAGA!

Zwróć uwagę, że w przykładowym projekcie, jeśli użytkownik nie ma uprawnień do wykonania operacji to albo jej nie wykonuję, albo zwracam null. W rzeczywistym projekcie dobrze jednak jest w jakiś sposób poinformować kontroler, żeby odpowiedział błędem 403 - Forbidden.


Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu 🙂

Daj znać w komentarzu jaką formę artykułów wolisz – taką z gotowym projektem na GitHub, który omawiam – tak jak tutaj, czy klasyczną, w której tworzymy projekt od nowa z pominięciem GitHuba.

Obrazek z artykułu: Tkanina plik wektorowy utworzone przez storyset – pl.freepik.com

Podziel się artykułem na:
Dokumentowanie własnego API automatem – co to Swagger?

Dokumentowanie własnego API automatem – co to Swagger?

Wstęp

Któż z nas nie kocha pisania dokumentacji? 😉 No właśnie. Nikt tego nie chce robić, ale każdy chciałby mieć dokumentację do zewnętrznych systemów. Niestety tworzenie takich materiałów jest po prostu upierdliwe… Ale nie musi. W tym artykule pokażę Ci jak szybko i prosto zrobić bardzo funkcjonalną dokumentację dla własnego API.

Co to Swagger?

Swagger to narzędzie, które magicznie skanuje Twoje API i tworzy stronę, na której ładnie opisuje wszystkie końcówki. Co więcej, umożliwia testowanie takiego API na żywym organizmie. To jest dokumentacja w pełni interaktywna.

Wszystko zrobisz w Visual Studio – nie musisz otwierać żadnego innego edytora. Zaczynamy.

Dodawanie Swaggera do projektu

Swagger jest tak fajnym narzędziem, że Microsoft pozwala na dodanie go już podczas tworzenia samego projektu. W oknie konfiguracji możesz wybrać, czy go używać, czy nie.

To oczywiście najprostsza droga do dodania Swaggera. Ale być może jest tak, że masz projekt, w którym nie zaznaczyłeś tej opcji. Tak też się da.

Dodawanie Swaggera ręcznie

Pobierz NuGet:

Install-Package Swashbuckle.AspNetCore.Swagger

Teraz musisz skonfigurować Swaggera.

Dodaj go przy rejestracji serwisów:

builder.Services.AddControllers();
builder.Services.AddSwaggerGen();

A podczas konfiguracji pipeline dodaj:

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

Tutaj mała uwaga. Być może pracujesz nad API, które będzie wystawiane zewnętrznie dla klientów. W takim przypadku prawdopodobnie nie powinieneś dodawać Swaggera tylko w środowisku deweloperskim ale produkcyjnie też.

I teraz małe wyjaśnienie:

  • builder.Services.AddSwaggerGen(); – rejestruje serwisy potrzebne do obsługi Swaggera
  • app.UseSwagger(); – to podstawowa obsługa
  • app.UseSwaggerUI(); – dodaje do Twojego API specjalną stronę, na której wszystko jest ładnie opisane, a w dodatku można testować.

To tyle, jeśli chodzi o podstawową konfigurację.

Przykładowy projekt

Swaggera najlepiej pokazać na przykładzie. W związku z tym przygotowałem prostą solucję, składającą się z dwóch projektów. SwaggerDemo to jest nasze API, SwagerDemo.Models to projekt przechowujący modele aplikacji. Specjalnie są zrobione dwa projekty, żeby Ci pokazać coś więcej. Cały gotowy kod możesz sobie sklonować z GitHuba: https://github.com/AdamJachocki/SwaggerDemo

Jeśli nie chcesz korzystać z mojego projektu, po prostu dodaj Swaggera do swojego (tak jak to opisane wyżej).

Możesz teraz uruchomić projekt API. Ważne, żeby API otwierało przeglądarkę. Jeśli Twoje nie otwiera, możesz zmodyfikować plik Properties/launchSettings.json, zmieniając wartość zmiennej launchBrowser na true. Możesz też automatem otworzyć stronę Swaggera, dodając do lauchSettings.json zmienną launchUrl:

"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7090;http://localhost:5090",
"environmentVariables": {
  "ASPNETCORE_ENVIRONMENT": "Development"

Jeśli dodawałeś Swaggera automatycznie (lub zmodyfikowałeś launchSettings.json jak wyżej), prawdopodobnie od razu pokazuje Ci się jego strona. Jeśli nie, doklep w przeglądarce końcówkę swagger. Przykładowo, jeśli adres Twojego API to http://localhost:5001, przejdź na: http://localhost:5001/swagger.

Tak mniej więcej wygląda podstawowa dokumentacja wygenerowana Swaggerem. Osobno widzisz każdy kontroler, w kontrolerze kolekcję endpointów, każdy rodzaj endpointa (POST, GET, DELETE) ma swój kolor. Jeśli rozwiniesz endpoint, zobaczysz dokładnie jakie przyjmuje dane, co zwraca i będziesz mógł go wywołać (przycisk Try it out z prawego, górnego narożnika). Swagger automatycznie rozpoznaje dane wchodzące:

Niemniej jednak, zgodzisz się że to słaba dokumentacja i właściwie niczego nie mówi. Poza tym, że pozwala Ci wysłać żądanie po kliknięciu przycisku Try it out. Ale spokojnie. Zaraz się tym zajmiemy.

Dokumentacja generowana z komentarzy

Każdy endpoint możesz dokładnie opisać za pomocą komentarzy dokumentujących, np:

/// <summary>
/// Pobiera użytkownika po przekazanym id
/// </summary>
/// <param name="id">Id użytkownika</param>
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    User testUser = new User
    {
        Email = "test@example.com",
        Id = 1,
        Name = "Test"
    };

    return Ok(testUser);
}

Tekst jaki wpisałeś w <summary> pojawi się jako opis konkretnego endpointa. Natomiast opisy parametrów <param> pojawią się przy parametrach. Jednak żeby to zadziałało, musisz dokonfigurować projekt API.

Konfiguracja projektu

W pliku projektu dodaj:

<GenerateDocumentationFile>True</GenerateDocumentationFile>

Możesz też zrobić to z poziomu ustawień projektu: Build -> Output -> Documentation file:

To ustawienie sprawi, że VisualStudio podczas budowania aplikacji, utworzy specjalny plik XML z metadanymi dokumentacji. Plik będzie nazywał się tak jak projekt, np: SwaggerDemo.xml. I domyślnie tworzy się w katalogu wynikowym.

To ustawienie jednak spowoduje również mały efekt uboczny. Podczas budowania aplikacji otrzymasz warningi CS1591, mówiące o tym, że są publiczne metody, które nie mają komentarzy dokumentujących. My tutaj dokumentujemy tylko metody w kontrolerach, aby Swagger mógł zadziałać. Jeśli nie dokumentujesz wszystkich metod publicznych, możesz ten warning wyłączyć, dodając do pliku projektu:

<NoWarn>$(NoWarn);1591</NoWarn>

Mój plik projektu API wygląda teraz tak:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>True</GenerateDocumentationFile>
	<NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SwaggerDemo.Models\SwaggerDemo.Models.csproj" />
  </ItemGroup>

</Project>

Konfiguracja Swaggera

Swagger odczytuje opisy właśnie z tego pliku XML. Trzeba mu to tylko powiedzieć. Robisz to, podczas konfigurowania Swaggera w kodzie przy konfiguracji serwisów:

builder.Services.AddSwaggerGen(o =>
{
    var assemblyName = Assembly.GetExecutingAssembly().GetName().Name + ".xml";
    var docFile = Path.Combine(AppContext.BaseDirectory, assemblyName);
    o.IncludeXmlComments(docFile);
});

Tutaj nie ma żadnej magii. Kluczową instrukcją jest IncludeXmlComments, gdzie w parametrze podaję pełną ścieżkę do utworzonej automatycznie dokumentacji xml. Czyli pobieram ścieżkę wykonywanego pliku, pobieram nazwę projektu i łączę to.

Teraz dokumentacja Swaggerowa wygląda już tak:

Opisywanie odpowiedzi

Swaggerowi możesz powiedzieć jeszcze, jakie endpoint generuje odpowiedzi i kiedy:

/// <summary>
/// Pobiera użytkownika po id
/// </summary>
/// <param name="id">Id użytkownika</param>
/// <response code="200">Zwraca znalezionego użytkownika</response>
/// <response code="404">Nie znaleziono takiego użytkownika</response>
/// <response code="500">Wewnętrzny błąd serwera</response>
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    User testUser = new User
    {
        Email = "test@example.com",
        Id = 1,
        Name = "Test"
    };

    return Ok(testUser);
}

Teraz strona Swaggera wygląda tak:

Patrząc na taką dokumentację nadal nie wiesz, jakie dane zwróci endpoint, jeśli zapytanie zakończy się sukcesem (kod 200). Możesz oczywiście wywołać tę końcówkę z poziomu Swaggera i otrzymasz wszystkie dane pobrane z API:

Jednak jest pewien sposób…

Opisywanie zwracanego modelu

Możesz pokazać Swaggerowi zwracany model:

[Route("api/[controller]")]
[ApiController]
[Produces("application/json")]
public class UsersController : ControllerBase
{
    /// <summary>
    /// Pobiera użytkownika po id
    /// </summary>
    /// <param name="id">Id użytkownika</param>
    /// <response code="200">Zwraca znalezionego użytkownika</response>
    /// <response code="404">Nie znaleziono takiego użytkownika</response>
    /// <response code="500">Wewnętrzny błąd serwera</response>
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(User), 200)]
    public IActionResult GetById(int id)
    {
        User testUser = new User
        {
            Email = "test@example.com",
            Id = 1,
            Name = "Test"
        };

        return Ok(testUser);
    }
}

Tutaj zrobiłem dwie rzeczy. Na poziomie kontrolera powiedziałem, jaką odpowiedź kontroler zwraca (json). Co nie jest wymagane, ale lepiej wygląda w Swaggerze. No i oczywiście klienci Twojego API nie mają żadnych wątpliwości co do rodzaju zwrotki. W innym przypadku Swagger pokaże combobox z możliwością wyboru typu zwrotki.

Ważniejsza jednak rzecz jest na poziomie samego endpointa – atrybut ProducesResponseType. W parametrach pokazuję jaki typ jest zwracany przy jakim kodzie. Różne kody mogą zwracać różne typy modeli. Teraz Swagger wygląda tak:

Jak widzisz Swagger pokazuje teraz szablon zwracanego modelu.

Opisywanie pól modelu

W rzeczywistości modele bywają bardziej skomplikowane niż ten powyżej. A ich pola nie opisują się tak ładnie. Możemy Swaggerowi opisać dokładnie każde pole modelu. Jak? Również za pomocą komentarzy dokumentujących. Tym razem na poziomie konkretnego modelu:

public class User
{
    /// <summary>
    /// Id użytkownika
    /// </summary>
    public int Id { get; set; }
    /// <summary>
    /// Imię i nazwisko użytkownika. Uwaga! Pole może być puste
    /// </summary>
    public string Name { get; set; }
    /// <summary>
    /// E-mail użytkownika
    /// </summary>
    public string Email { get; set; }
    /// <summary>
    /// Hasło użytkownika. Zawsze puste, gdy pobieramy rekord.
    /// </summary>
    public string Password { get; set; }
}

Co się stanie, gdy odpalimy teraz Swaggera? Zupełnie nic 🙂

Dlatego też stworzyłem dwa projekty – jeden api, drugi dla modeli.

Przypominam, że Swagger opisy odczytuje z pliku dokumentacji (xml) tworzonego przez Visual Studio. O ile projekt API został ładnie ustawiony, to projekt z modelami nie ma takiej konfiguracji. Musimy ją więc dodać. Do projektu z modelami dodaj znane już elementy:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>disable</Nullable>
	<GenerateDocumentationFile>True</GenerateDocumentationFile>
	<NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

</Project>

Teraz jeszcze tylko musisz Swaggerowi powiedzieć, skąd ma ten dokument zaczytać. To też już robiliśmy. Podczas konfiguracji Swaggera trzeba tylko dodać kolejny plik:

builder.Services.AddSwaggerGen(o =>
{
    var assemblyName = Assembly.GetExecutingAssembly().GetName().Name + ".xml";
    var docFile = Path.Combine(AppContext.BaseDirectory, assemblyName);
    o.IncludeXmlComments(docFile);

    var modelsAssemblyName = typeof(User).Assembly.GetName().Name + ".xml";
    var modelsDocFile = Path.Combine(AppContext.BaseDirectory, modelsAssemblyName);
    o.IncludeXmlComments(modelsDocFile);
});

Tutaj, żeby nie wpisywać na sztywno nazwy projektu, posłużyłem się jakąś klasą, która występuje w projekcie z modelami. Traf chciał, że padło na klasę User. Generalnie wybrałem pierwszą lepszą. Chodziło o to, żeby refleksja zwróciła nazwę projektu. Reszta jest taka sama jak wyżej: IncludeXmlComments i wio.

Teraz Swagger wygląda tak:

Pamiętaj, że żeby zobaczyć opisy pól modelu, musisz kliknąć na Schema.

Swagger i wersjonowanie API

Często nasze API są wersjonowane. Swagger niestety nie ogarnia tego domyślnie. Jest kilka sposobów, żeby to zadziałało. Ja Ci pokażę jeden z nich – moim zdaniem najbardziej prawilny.

Jak to działa?

Słowem wstępu, Swagger działa tak, że używa mechanizmu dostarczanego przez Microsoft: EndpointsApiExplorer. Nie musisz tego dodawać ręcznie, to już dodaje Swagger podczas rejestrowania swoich serwisów.

ApiExplorer skanuje Twoje API i zwraca informacje o nim, a Swagger za jego pomocą buduje swoje pliki „map”.

Przy tym podejściu musisz zapewnić, że wersjonujesz API tak jak napisałem tutaj. Głównie chodzi o trzymanie kontrolerów dla różnych wersji w różnych namespace.

Krok 1 – stworzenie konwencji

Na początek musimy utworzyć konwencję, która odpowiednio pogrupuje kontrolery. Stwórz taką klasę:

public class GroupingByNamespaceConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        var controllerNamespace = controller.ControllerType.Namespace;
        var apiVersion = controllerNamespace.Split(".").Last().ToLower();
        if (!apiVersion.StartsWith("v")) 
            apiVersion = "v1";

        controller.ApiExplorer.GroupName = apiVersion;
    }
}

Zadaniem tej klasy jest odpowiednie zgrupowanie kontrolera (dodanie atrybutu GroupName). To grupowanie jest używane tylko przez ApiExplorer, czyli nie ma żadnego znaczenia dla działającego kodu. Zapamiętaj – tylko dla dokumentacji. Teraz trzeba tą konwencję zarejestrować podczas rejestracji serwisów:

builder.Services.AddControllers(o =>
{
    o.Conventions.Add(new GroupingByNamespaceConvention());
});

Krok 2 – konfiguracja dokumentacji Swaggera

Teraz musimy skonfigurować dokumentację dla każdej wersji. Robimy to podczas konfiguracji Swaggera:

builder.Services.AddSwaggerGen(o =>
{
    o.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Wersja 1",
        Version = "v1"
    });

    o.SwaggerDoc("v2", new OpenApiInfo
    {
        Title = "Wersja 2",
        Version = "v2"
    });
});

Dodałem tutaj dwie wersje. Na koniec trzeba jeszcze je dodać podczas konfiguracji middleware:

app.UseSwagger();
app.UseSwaggerUI(o =>
{
    o.SwaggerEndpoint("/swagger/v1/swagger.json", "Wersja 1");
    o.SwaggerEndpoint("/swagger/v2/swagger.json", "Wersja 2");
});

Ważne, żeby nazwa przekazana w SwaggerEnpoint (Wersja1, Wersja2) była spójna z tytułem skonfigurowanym w SwaggerDoc.

Krok trzeci – aktualizacja kontrolerów

Na koniec już tylko musisz zaktualizować kontrolery, żeby powiedzieć im, którą wersję API wspierają. Prawdopodobnie będą sytuacje, że pomiędzy pierwszą i drugą wersją API zmieni Ci się tylko część kontrolerów. Wystarczy, że dodasz do nich atrybuty ApiVersion:

[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{

}

Ułatwienia

Istnieje NuGet, który ułatwia konfigurowanie wersji w Swaggerze. Jednak komentarz autora (który przytaczam fragmentami niżej) mi daje taką myśl: „Wstrzymaj konie i poczekaj na nową wersję”. Ten NuGet to: Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer, jednak nie daj się zmylić – nie ma za wiele wspólnego aktualnie z Microsoftem:

Projekt rozpoczął się jako eksperyment myślowy jak wziąć pomysły stojące za wersjonowaniem API (zwłaszcza RESTowego) i zaimplementować je w praktyczny sposób (…). Rozwój (…) różnych projektów w Microsoft zajął około dwóch lat, ale w końcu powstał ogólny wzorzec i framework. 6 lat temu (2016 – przyp. tłumacz) przeniosłem to do społeczności open source, żeby rozwijać dalej i szczerze – dla mojego własnego egoistycznego użytku do projektów poza Microsoftem. To, że projekt stał się taki popularny, przerosło moje najśmielsze oczekiwania.

Decyzja, żeby przenieść projekt na Microsoftowy GitHub była głównie podyktowana open source’ową polityką firmy. Pomimo powszechnego przekonania, nie jestem i nigdy nie byłem częścią teamu od AspNetCore (…). Ten projekt nigdy w żaden sposób nie był oficjalnie wspierany. Pomimo, że pojawiło się kilku zewnętrznych kontrybutorów, głównie utrzymywałem go sam. W 2021 (…) zdecydowałem się opuścić Microsoft (…). Próbowałem zachować projekt i przekazać go, jednak pojawiło się wiele wyzwań. Zajęło to kilka miesięcy, ale ostatecznie uznałem, że najlepszym będzie przeniesienie projektu z organizacji Microsoft do .NET Foundation (…).

Pojawiło się kilka nowych problemów, m.in. nazwa, która wskazuje, że projekt jest zarządzany przez Microsoft (…). Chciałem zrobić fork projektu i rozpocząć nowy, jednak mogłoby to wprowadzić zamieszanie w społeczności (…).

Drugi problem to identyfikatory pakietów NuGet. Zasugerowano, że po prostu wyślę zawiadomienie, że identyfikator się zmieni. Jednak po 100 milionach pobrań stwierdziłem, że jest to niedopuszczalne. Zajęło to wiele miesięcy aby wyśledzić odpowiednich interesariuszy NuGet, aby rozpocząć proces, ale identyfikatory pakietów zostały teraz przeniesione do zespołu api-versioning z dotnetfoundation. Jeśli zastanawiasz się, dlaczego nie było żadnych aktualizacji od dłuższego czasu, to właśnie dlatego. Teraz mam trochę więcej kontroli nad pakietem i aktualizacje mogą znów się pojawiać. Jednak są z tym związane limity. Nowe funkcje nie mogą pojawiać się pod szyldem Microsoft(…). Zacząłem nawet prace nad nową wersją, która zaczynałaby się prefixem Asp.Versioning.* (…).

Krótko mówiąc – projekt powinien mieć jakieś aktualizacje do wersji 5.*. Jednak niczego więcej po nim nie można się spodziewać. A jego klon gdzieś kiedyś się pojawi.


To tyle, jeśli chodzi o dokumentowanie API. Jak widzisz, nie musi to być tak nudne, jak się wydaje. A i musisz przyznać, że dla klienta taka interaktywna dokumentacja ma dużo większą wartość niż tabelka w Wordzie. Spróbuj sam i zobacz, jak to jest. Co więcej, Swagger posługuje się standardem OpenAPI 3.0, więc możesz to sobie zaimportować nawet do PostMana! 🙂

Dzięki za przeczytanie artykułu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie podziel się w komentarzu. A jeśli znasz kogoś, komu ten artykuł się zdecydowanie przyda, udostępnij mu go 🙂

Podziel się artykułem na:
Po co te interfejsy?

Po co te interfejsy?

Wstęp

„Po co interfejsy, skoro mamy klasy po których można dziedziczyć” – wielu młodych programistów zadaje takie pytanie. Sam też kiedyś o to pytałem, nie rozumiejąc w ogóle istoty interfejsów. No bo po co używać tworu, który niczego nie robi i niczego nie potrafi?

Zrozumienie tego może zabrać trochę czasu. Przyjmuję wyzwanie i postaram Ci się to wszystko wyjaśnić w tym artykule.

Co to interfejs?

Prawdopodobnie już wiesz, że interfejs to taki twór, który niczego nie potrafi. Ma tylko nagłówki metod i tyle. W C# interfejs może mieć też zdefiniowane właściwości (a i od jakiegoś czasu nieco więcej…)

Interfejs a klasa abstrakcyjna

„Czym się różni interfejs od klasy abstrakcyjnej” – to częste pytanie, które pada w rozmowach o pracę dla juniorów, ale uwaga… też i dla seniorów. Okazuje się, że zbyt dużo programistów (co mnie osobiście bardzo szokuje) nie jest w stanie przedstawić jasnych różnic między interfejsem a klasą abstrakcyjną. Zacznijmy więc od podobieństw:

Podobieństwa:

  • nie można utworzyć instancji interfejsu ani instancji klasy abstrakcyjnej
  • klasa abstrakcyjna i interfejs zawierają nagłówki metod – bez ciał

I to właściwie tyle jeśli chodzi o podobieństwa. Różnic jest znacznie więcej:

Różnice:

  • klasa może dziedziczyć po jednej klasie abstrakcyjnej, ale może implementować wiele interfejsów
  • klasa abstrakcyjna może zawierać metody nieabstrakcyjne (z ciałami) – interfejs nie może*
  • klasa abstrakcyjna może zawierać pola i oprogramowane właściwości – interfejs nie może
  • klasa abstrakcyjna może dziedziczyć po innej klasie, a także implementować interfejsy; interfejs może jedynie rozszerzać inny interfejs

*UWAGA! Od C# 8.0 interfejs może zawierać domyślną implementację metod. Nie jest to jednak oczywiste działanie i nie zajmujemy się tym w tym artykule. Wspominam o tym z poczucia obowiązku.

Definiowanie interfejsu

W C# interfejs jest definiowany za pomocą słówka interface:

public interface IFlyable
{
    bool IsFlying { get; }
    void Fly();
}

Utworzyliśmy sobie interfejs IFlyable. Konwencja mówi tak, że nazwy interfejsów zaczynają się literką I (i jak igła).

Interfejs sam w sobie może mieć (tak jak klasa) określoną widoczność. W tym przypadku IFlyable jest publiczny. Natomiast jego składniki nie mogą mieć określanych widoczności. Wszystkie właściwości i metody są publiczne.

Stara konwencja, która właściwie już nie obowiązuje (ale pomoże Ci zrozumieć interfejs), mówi że nazwa interfejsu powinna OPISYWAĆ cechę (kończyć się na -able). Np: IFlyable, ITalkable, IWalkable, IEnumerable…

Interfejs w roli opisywacza

Końcówka -able w nazwie interfejsu powinna dać Ci do myślenia… „Interfejs OPISUJE jakieś zachowanie.” – tak. Interfejs opisuje zachowanie, a właściwie cechę. Klasa, która implementuje dany interfejs, musi też utworzyć dane zachowanie.

Z pomocą przyjdzie przykład. Załóżmy, że tworzysz świat. I masz taką fantazję, że tworzysz organizmy żywe. Podzieliłeś je na jakieś grupy – ssaki, ptaki, gady, owady.

I utworzyłeś analogiczne klasy abstrakcyjne:

public abstract class Mammal
{
    
}

public abstract class Reptile
{

}

public abstract class Bird
{

}

itd. Każda z tych klas abstrakcyjnych ma jakieś elementy wspólne dla całej grupy. I teraz zaczynasz tworzyć sobie konkretnych osobników:

public class Human: Mammal
{

}

public class Bat: Mammal
{

}

public class Pigeon: Bird
{

}

public class Pingeuin: Bird
{

}

I już coś Ci przestaje działać. Dlaczego?

No spójrz. Pewnie na początku wyszedłeś z założenia, że ptaki latają. Okazuje się, że latanie nie jest domeną ptaków. Pingwin nie lata, kura nie lata, struś nie lata… Oczywiście większość ptaków jednak lata ale nie wszystkie. I co dalej? Mamy nietoperza. Lata, ale nie jest ptakiem. Jednak większość ssaków nie lata.

Skoro większość ssaków nie lata, klasa Mammal nie może w żaden sposób zaimplementować latania. Klasa Bird też nie może zaimplementować latania, ponieważ nie wszystkie ptaki latają. No a nie będziesz przecież latania implementował od nowa w każdym gatunku, no bo to jednak jakaś cecha grupy. A my nie chcemy dublować kodu.

Ok, idźmy dalej tą drogą i zróbmy latające ssaki i latające ptaki:

public abstract class Mammal
{
    
}

public abstract class FlyingMammal: Mammal
{
    public abstract void Fly();
}

public class Human: Mammal
{

}

public class Bat: FlyingMammal
{
    public override void Fly()
    {
        
    }
}

public abstract class Bird
{

}

public abstract class FlyingBird
{
    public abstract void Fly();
}    

public class Pigeon: FlyingBird
{
    public override void Fly()
    {
        
    }
}

public class Pingeuin: Bird
{

}

Uff, udało się napisać abstrakcyjne klasy i nawet po nich dziedziczyć. Super.

I teraz przychodzi Ci napisać metodę, która w parametrze przyjmuje zwierzęta latające… BUM. Wszystko wybuchło…

public void StartFlying(FlyingBird bird)
{
    bird.Fly();
}

public void StartFlying(FlyingMammal mammal)
{
    mammal.Fly();
}

Zamiast jednej prostej metody, masz ich wiele (tyle, ile masz klas zwierząt latających). Sam widzisz, że nie tędy droga. I teraz na scenę wkraczają interfejsy. Wróćmy do naszego interfejsu, który napisaliśmy na początku:

public interface IFlyable
{
    bool IsFlying { get; }
    void Fly();
}

Okazuje się, że interfejs pełni rolę cechy. W tym przypadku tą cechą jest latanie. Więc zmieńmy teraz nasze klasy w taki sposób, żeby pozbyć się latających klas abstrakcyjnych na rzecz interfejsu:

public abstract class Mammal
{
    
}

public class Human: Mammal
{

}

public class Bat: Mammal, IFlyable
{
    public bool IsFlying { get; private set; }
    public void Fly()
    {
        
    }
}

public abstract class Bird
{

}

public class Pigeon: Bird, IFlyable
{
    public bool IsFlying { get; private set; }
    public void Fly()
    {
        
    }
}

public class Pingeuin: Bird
{
    
}

I co się okazuje? Niektóre ssaki (ale nie wszystkie) potrafią latać. Niektóre ptaki (ale nie wszystkie) potrafią latać. I jak teraz będzie wyglądała metoda, która w parametrze przyjmuje latające zwierzę?

public void StartFlying(IFlyable f)
{
    if (!f.IsFlying)
        f.Fly();
}

Mamy tutaj polimorfizm w najczystszej postaci. Metoda StartFlying nie wie jakie zwierzę dostanie w parametrze. Wie natomiast, że to co dostanie – na pewno umie latać.

Skoro wiemy, że jednak większość ptaków lata, możemy w tym momencie pokusić się o stworzenie dodatkowej klasy – latające ptaki:

public abstract class FlyingBird: Bird, IFlyable
{
    public bool IsFlying { get; private set; }
    public void Fly()
    {

    }
}

Zauważ, że metoda StartFlying w ogóle się nie zmieni, ponieważ latające ptaki implementują interfejs IFlyable. Klasa FlyingBird jest taką trochę pomocniczą. Ona wie jak ptak powinien latać. A chyba wszystkie latające ptaki robią to w ten sam sposób.

Można napisać taką klasę, ponieważ większość ptaków lata. Jednak nie tworzyłbym klasy FlyingMammal, ponieważ latanie wśród ssaków jest wyjątkową cechą. Zatem zachowałbym ją dla konkretnych gatunków (chociaż to wymaga przemyślenia).

Zróbmy trochę bardziej śmieszny świat. Ssaki… Niektóre są żyworodne, niektóre są jajorodne (kolczatka). Jeśli chodzi ptaki… to chyba wszystkie są jajorodne (chociaż aż tak się nie znam ;)). Ale widzisz, że tutaj żyworodność, czy też jajorodność nie jest domeną całej grupy, więc idealnie nadaje się na interfejs. Tak samo jak umiejętność pływania. Są ptaki, ssaki, owady i inne, które potrafią pływać, ale są też takie, które tego nie potrafią. Tak jak z lataniem.

Klasa abstrakcyjna zamiast interfejsu

Czasem możesz mieć taką pokusę, żeby zastosować klasę abstrakcyjną zamiast interfejsu. Zwłaszcza jak w przykładzie ze zwierzętami latającymi. I czasem będzie to dobre rozwiązanie. Jednak w powyższym przykładzie widzisz, że nie zadziała. Zarówno latające ptaki, jak i latające ssaki powinny dziedziczyć po takiej klasie – a to się nie da. W C# nie mamy wielodziedziczenia.

W niektórych językach (np. C++) jest możliwość dziedziczenia po wielu klasach. Wtedy takie rozwiązanie jest jak najbardziej ok. Ale nie w C#. Czy to ograniczenie? Może i tak. Ale takie samo jak to, które zabrania Ci jechać na czerwonym świetle. Wielodziedziczenie bardzo łatwo może stać się powodem problemów. Do tego stopnia, że niektórzy programiści uważają, że jeśli musisz dziedziczyć po wielu klasach, to coś pewnie zaprojektowałeś źle.

I dlatego mamy też interfejsy. Żeby zaimplementować cechy z różnych „światów”.

Interfejs i wstrzykiwanie zależności

Jeśli nie wiesz, czym jest wstrzykiwanie zależności, koniecznie przeczytaj ten artykuł.

Jeśli chodzi o DI, to interfejsy jakoś tak samoczynnie stały się standardem. Do obiektu nie wstrzykujemy klasy, tylko interfejs. Chociaż czasem wstrzykiwanie klasy abstrakcyjnej jest jak najbardziej ok. Jednak wstrzykiwanie interfejsu czasem jest po prostu szybsze do zaimplementowania. Załóżmy, że masz taką klasę:

public class ConsoleWriter
{
    public void Write(string msg)
    {
        Console.WriteLine(msg);
    }
}

I chcesz ją wstrzyknąć do innej klasy. Więc z ConsoleWriter wyekstrahujesz albo interfejs IWriter, albo klasę abstrakcyjną AbstractWriter. Interfejs w tym momencie jest zdecydowanie bardziej naturalnym podejściem. Daje Ci pewność, że w przyszłości niczego Ci nie popsuje. Interfejs żyje trochę z boku wszystkiego.

Jeśli wyekstrahowałbyś klasę abstrakcyjną, musiałbyś się zastanowić, czy to dobre rozwiązanie. Czy nagle nie pojawi się kiedyś potrzeba, żeby jakaś klasa dziedziczyła po takim writerze, np:

public class MySuperStringList: List<string>//, AbstractWriter???
{

}

Oczywiście nie jesteś w stanie przewidzieć przyszłości. Ale jesteś w stanie przewidzieć, że w DI klasa abstrakcyjna może w pewnym momencie coś zblokować. Natomiast interfejs nigdy niczego nie zablokuje.

Interfejs w roli pluginu / adaptera

W takim przypadku wybór interfejsu jest raczej jednoznaczny. Załóżmy, że piszesz odtwarzacz mp3. I chcesz, żeby taki odtwarzacz mógł być rozszerzany przez pluginy. W pierwszym kroku musisz jakoś zaprojektować taki plugin:

public interface IMyMp3Plugin
{
    void Mp3Started(string fileName);
    void Mp3Finished(string fileName);
}

W taki sposób Twoja aplikacja będzie mogła poinformować pluginy, że piosenka się zaczęła lub skończyła. Pluginy będą implementować ten interfejs i odpowiednio reagować na zmiany. Np. może być plugin, który napisze post na FB o tym, że po raz piąty w tym dniu słuchasz tej samej piosenki. Może być inny plugin, który będzie zapisywał do bazy danych historie słuchanych przez Ciebie utworów i analizował ją. Itd.

Ty z poziomu swojej aplikacji mp3 musisz tylko wywołać metodę z interfejsu.

public void PlayMp3(string fileName)
{
    //w jakiś sposób odtwórz piosenkę, a potem poinformuj pluginy
    foreach(IMyMp3Plugin plugin in plugins)
    {
        plugin.Mp3Started(fileName);
    }
}


No to tyle jeśli chodzi o interfejsy. Mam nadzieję, że wszystko udało mi się wyjaśnić. Jeśli jednak nadal czegoś nie rozumiesz lub też znalazłeś błąd w tekście, koniecznie daj znać w komentarzu.

Podziel się artykułem na:
Testy jednostkowe na wyższym poziomie

Testy jednostkowe na wyższym poziomie

Wstęp

W poprzednim artykule o testach jednostkowych poruszyłem tylko podstawy. Jeśli ich nie ogarniasz, koniecznie przeczytaj najpierw tamten artykuł.

W tym artykule opiszę kilka bardziej zaawansowanych metod, które stosuje się właściwie na co dzień. Jednak nie bój się. Słowo „zaawansowane” w tym kontekście nie oznacza niczego trudnego…

Kod testowalny vs nietestowalny

Każdy system można napisać w taki sposób, że nie da się do niego zrobić testów lub zrobienie ich będzie zupełnie nieopłacalne. Taki projekt nazywamy nietestowalnym. Można system projektować też tak, żeby testy były całkowicie normalnym zjawiskiem. I do tego dążymy.

Jak zwykle kod powie więcej niż 1000 słów…

class UserData
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

class UserDataProvider
{
    public UserData ReadData(int userId)
    {
        string fileName = $@"C:\dane\{userId}.txt";
        if (!File.Exists(fileName))
            return null;

        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Metoda ReadData sprawdza, czy plik o konkretnej nazwie istnieje (1), jeśli tak odczytuje go z dysku (2) i tworzy obiekt klasy UserData (3; metoda ma aż 3 odpowiedzialności)

Jak teraz przetestujesz jednostkowo metodę ReadData? Nie da się, bo jest silnie związana z klasą File, a problem klasy File polega na tym, że odnosi się do konkretnych zasobów, których po prostu podczas jednostkowego testowania nie będzie. Co więcej, jeśli chciałbyś zapisać dane użytkownika, klasa File zapisze plik na dysku – to jest tzw. „efekt uboczny”. Testy jednostkowe nie mogą mieć żadnych efektów ubocznych. Jest to bardzo niepożądane.

Dlatego też, żeby uczynić klasę UserDataProvider testowalną, musimy zaprojektować jakąś abstrakcję – zastosować DependencyInjection. Jeśli nie wiesz co to, przeczytaj artykuł, w który opisuję ten mechanizm.

Stosuj abstrakcje

Zamiast posługiwać się bezpośrednio klasą File, utworzymy interfejs, który zostanie wstrzyknięty do UserDataProvider. Jeśli nie rozumiesz pojęcia wstrzyknięcie, koniecznie przeczytaj ten artykuł.

interface IDataRepository
{
    string[] GetData(int id);
}

public class FileRepository : IDataRepository
{
    public string[] GetData(int id)
    {
        try
        {
            string fileName = $@"C:\dane\{id}.json";
            return File.ReadAllLines(fileName);
        }catch(FileNotFoundException)
        {
            return null;
        }        
    }
}

Mając taką abstrakcję, możemy już zmienić klasę UserDataProvider i uczynić ją testowalną:

class UserDataProvider
{
    IDataRepository repo;
    public UserDataProvider(IDataRepository repo)
    {
        this.repo = repo;
    }
    public UserData ReadData(int userId)
    {
        string[] data = repo.GetData(userId);
        
        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Zobacz, co się przy okazji stało. Metoda ReadData robi już tylko jedną rzecz, a nie kilka jak to było na początku.

Ale jak teraz testować tę klasę? Musimy stworzyć JAKIŚ obiekt implementujący interfejs IDataRepository…

Co to jest Fake Object?

Fake Object to nic innego jak obiekt oszukany. Ma się zachować dokładnie tak, jak tego chcemy w danej sytuacji. Napiszmy więc sobie taki FakeObject, który implementuje IDataRepository:

class FakeRepository : IDataRepository
{
    public string[] DataToReturn { get; set; } = null;
    public string[] GetData(int id)
    {
        return DataToReturn;
    }
}

Po prostu metoda GetData zwróci takie dane, jakie przekażemy wcześniej do właściwości DataToReturn. Teraz przyszedł czas na napisanie pierwszego testu z Fake’iem. Przygotuj zatem nowy projekt testowy (jeśli nie wiesz jak, to przeczytaj artykuł o podstawach testów jednostkowych).

Testy z użyciem Fake

Ja w swoim przykładzie będę stosował bibliotekę nUnit.

Tak jak mówiłem, testujemy klasę UserDataProvider i metodę ReadData. Przypomnę kod:

class UserDataProvider
{
    IDataRepository repo;
    public UserDataProvider(IDataRepository repo)
    {
        this.repo = repo;
    }
    public UserData ReadData(int userId)
    {
        string[] data = repo.GetData(userId);
        
        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Jakie chcemy przetestować przypadki?

  • nie ma użytkownika o takim id
  • odczytane dane są niepoprawne
  • odczytane dane są prawidłowe

Test – brak użytkownika

Napiszmy więc pierwszy test:

[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
    FakeRepository repo = new FakeRepository();
    repo.DataToReturn = null;

    UserDataProvider udp = new UserDataProvider(repo);
    UserData result = null;
    Assert.DoesNotThrow(() => result = udp.ReadData(0));
    Assert.IsNull(result);
}

Najpierw został utworzony obiekt fake’owy. Chcemy, żeby zwracał null – zakładamy, że tak będzie, gdy użytkownika nie będzie w systemie.

Następnie utworzyliśmy prawdziwy obiekt – UserDataProvider, korzystający z oszukanego FakeRepository.

I sprawdzamy, czy metoda się nie wywala (nie chcemy tego) i czy nie zwróciła żadnego użytkownika.

Po uruchomieniu testu okazuje się, że aplikacja się wykrzacza – jest rzucony wyjątek NullReferenceException. No oczywiście, że tak bo okazuje się, że w metodzie ReadData nigdzie nie sprawdzamy, co zostało zwrócone z repozytorium. Poprawmy to:

public class UserDataProvider
{
    IDataRepository repo;
    public UserDataProvider(IDataRepository repo)
    {
        this.repo = repo;
    }
    public UserData ReadData(int userId)
    {
        string[] data = repo.GetData(userId);
        if(data == null)
            return null;

        UserData userData = new UserData();
        userData.FirstName = data[0];
        userData.LastName = data[1];
        return userData;
    }
}

Super, teraz działa. Sprawdźmy zatem drugi przypadek.

Test – poprawne dane

[Test]
public void ReadData_UserExists_ReturnsUser()
{
    FakeRepository repo = new FakeRepository();
    repo.DataToReturn = new string[]
    {
        "Adam",
        "Jachocki"
    };

    UserDataProvider udp = new UserDataProvider(repo);
    UserData user = null;
    
    Assert.DoesNotThrow(() => user = udp.ReadData(0));
    Assert.IsNotNull(user);
    Assert.AreEqual("Adam", user.FirstName);
    Assert.AreEqual("Jachocki", user.LastName);
}

Najpierw skonfigurowaliśmy obiekt fake’owy tak, żeby zwrócił tablicę z dwoma elementami – dokładnie w takiej formie dostaniemy dane z pliku tekstowego.

Na koniec sprawdziliśmy kilka rzeczy:

  • czy program się nie wysypał
  • czy user jest prawidłowym obiektem
  • czy user posiada odpowiednie wartości

Tym razem test zadziałał. No to został ostatni przypadek…

Test – nieprawidłowe dane

[Test]
public void ReadData_InvalidData_ThrowsException()
{
    FakeRepository repo = new FakeRepository();
    repo.DataToReturn = new string[]
    {
        "Adam",
    };

    UserDataProvider udp = new UserDataProvider(repo);
    UserData user = null;

    Assert.Throws<InvalidDataException>(() => user = udp.ReadData(0));
}

Przede wszystkim chcemy, żeby program się wygrzmocił, jeśli dane będą w niepoprawnym formacie (np. repo zwróci tablicę jednoelementową zamiast dwuelementową). To zdecydowanie jest sytuacja wyjątkowa, w której zastosowanie wyjątków ma jak najbardziej sens. Program ma się wywalić, więc nie stosujemy już innych sprawdzeń.

Po uruchomieniu tego testu dostajemy brzydki błąd na twarz z komunikatem:

Expected: <System.IO.InvalidDataException>
  But was:  <System.IndexOutOfRangeException

Oznacza to, że owszem został rzucony wyjątek, ale IndexOutOfRangeException zamiast tego, który chcemy – InvalidDataException. No racja. Jeśli spojrzysz na klasę UserDataProvider, zobaczysz że nigdzie nie rzucamy takiego wyjątku. Natomiast IndexOutOfRange jest rzucany przez system, ponieważ odwołujemy się do nieistniejącego elementu w tablicy. Naprawmy to:

public UserData ReadData(int userId)
{
    string[] data = repo.GetData(userId);

    if (data == null)
        return null;

    if (data.Length < 2)
        throw new InvalidDataException("Dane w niepoprawnym formacie!");

    UserData userData = new UserData();
    userData.FirstName = data[0];
    userData.LastName = data[1];
    return userData;
}

Testy poszły, ale ja teraz mam duże zastrzeżenia do tego kodu. Metoda ReadData nie dość, że tworzy użytkownika, to jeszcze sprawdza poprawność danych. Czyli znów ma dwie odpowiedzialności. Powinniśmy teraz trochę ten kod wyczyścić i walidację danych zrobić w osobnej metodzie:

Trochę czyszczenia

public UserData ReadData(int userId)
{
    string[] data = repo.GetData(userId);
    if (!ValidateData(data))
        return null;       

    UserData userData = new UserData();
    userData.FirstName = data[0];
    userData.LastName = data[1];
    return userData;
}

bool ValidateData(string[] data)
{
    if (data == null)
        return false;

    if(data.Length < 2)
        throw new InvalidDataException("Dane w niepoprawnym formacie!");

    return true;

}

Kod stał się bardziej czytelny i nadal działa. SUPER! Zwróć uwagę na dwie rzeczy:

  • to co właśnie zrobiliśmy (czyszczenie kodu, rozdzielanie go) nazywa się refactoring. Podczas refactoringu czasami dochodzi do błędów. Gdyby nie testy jednostkowe, moglibyśmy ich nie wychwycić, a przynajmniej nie tak szybko. Jest taka zasada, która mówi – nie refaktoruj kodu, do którego nie masz testów.
  • podczas poprawiania kodu może okazać się, że musisz pewne rzeczy przemyśleć lub przeprojektować

Wiesz już czym jest Fake Object i jak go używać w testach. Ale jest jeszcze jedno… Fajne…

Czym jest Mock?

Mock to imitacja (dosłowne tłumaczenie) jakiegoś obiektu. To jest alternatywa dla FakeObject. W niektórych językach programowania może być trudne lub niemożliwe stworzenie mocka. Na szczęście my jesteśmy w świecie .NET, gdzie z odpowiednią biblioteką jest to oczywiste i proste jak beknięcie po piwie.

Różnica między Mock a Fake

Główną różnicą jest to, że jeśli tworzysz FakeObject, musisz zaimplementować wszystkie metody z interfejsu. Gdy tworzysz Mock – implementujesz tylko to co chcesz i tak jak chcesz. I to ad hoc!

Jednak Mock nie jest złotym środkiem. Czasami lepiej się sprawdzi Mock, a w niektórych przypadkach lepiej będzie napisać FakeObject.

Biblioteka Moq

Pobierz sobie do projektu testowego bibliotekę Moq z NuGet: https://www.nuget.org/packages/Moq/

Następnie do uses dodaj:

using Moq;

Teraz się pobawimy. Zmieńmy testy w taki sposób, żeby nie używać Fake, tylko Mock (będziemy „mokować”). Najpierw pierwszy przypadek:

[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
    var mockRepository = new Mock<IDataRepository>();
    mockRepository.Setup(m => m.GetData(It.IsAny<int>())).Returns<string[]>(null);

    UserDataProvider udp = new UserDataProvider(mockRepository.Object);
    UserData result = null;
    Assert.DoesNotThrow(() => result = udp.ReadData(0));
    Assert.IsNull(result);
}

Co tu się stało?

  1. Utworzyliśmy obiekt Mock, mówiąc mu jaki interfejs ma imitować
  2. Za pomocą metody Setup możemy skonfigurować Mocka w taki sposób, żeby powiedzieć mu:
    • jakie argumenty przyjmuje metoda (może to być konkretny argument albo tak jak tutaj – jakikolwiek int: It.IsAny<int>()
    • jaką wartość ma zwracać metoda – w związku z tym, że zwracamy null, musimy podać typ zwracanej wartości
  3. W jednym Setupie konfigurujemy jedną metodę. Nic nie stoi na przeszkodzie, żeby skonfigurować ich więcej.

Nie pisząc żadnej nowej klasy otrzymaliśmy coś, co potrafi imitować działanie obiektu.

Klasa Mock ma właściwość Object, która jest żądanego typu (w naszym przypadku IDataRepository), dlatego też to tę właściwość wstrzykujemy do konstruktora.

A jaki jest kod? Nie ma to znaczenia. To jest zwykła imitacja – najbardziej Cię interesuje, co metoda zwraca (czasami, jaki argument przyjmuje). Co więcej, możesz skonfigurować tak, żeby mock zwracał różne wartości dla różnych parametrów, np:

mockRepository.Setup(m => m.GetData(0)).Returns<string[]>(null);
mockRepository.Setup(m => m.GetData(1)).Returns(new string[] { "Adam", "Jachocki"});
mockRepository.Setup(m => m.GetData(2)).Returns(new string[] { "Jan", "Kowalski" });

W ramach ćwiczeń zachęcam Cię do przerobienia pozostałych testów z Fake’ów na Mocki.

Dokumentacja

Trochę mnie korci, żeby napisać coś więcej o bibliotece Moq, ale to nie jest o tym artykuł. Jeśli będzie jakaś prośba, na pewno to zrobię. Póki co odsyłam do:

Biblioteka Moq potrafi zrobić właściwie chyba wszystko, co sobie wymyślisz. Dlatego polecam poczytać o niej i potestować.


To właściwie wszystko jeśli chodzi o testy jednostkowe. Jeśli czegoś nie rozumiesz, coś pominąłem lub znalazłeś błąd, podziel się w komentarzu. Jeśli uważasz artykuł za przydatny, podziel się nim z innymi 🙂

Podziel się artykułem na:
Wstrzykiwanie zależności – Dependency Injection

Wstrzykiwanie zależności – Dependency Injection

Wstęp

Ten artykuł opisuje czym jest dependency injection. A także jak z tego korzystać w .NET i po co. Jeśli wiesz, znasz, stosujesz, to raczej niczego nowego się tutaj nie dowiesz 🙂 On jest kierowany głównie do młodych programistów lub programistów nie znających tych mechanizmów.

O co chodzi we wstrzykiwaniu zależności?

Przede wszystkim musimy zdefiniować sobie zależność. O zależności mówimy wtedy, kiedy jedna klasa zależy od drugiej. Weźmy sobie przykładową klasę Writer, która umie wypisywać komunikaty i klasę Worker, która wykonuje jakąś operację i posługuje się klasą Writer.

class Writer
{
    public void Write(string message)
    {
        Console.WriteLine(message);
    }
}

class Worker
{
    Writer writer = new Writer();

    public void Foo()
    {
        writer.Write("Rozpoczynam pracę...");
    }
}

Jak widzisz, klasa Worker zależy od klasy Writer.

Co więcej, klasa Worker samodzielnie tworzy i używa obiekt klasy Writer. Takie coś nazywamy silnym związaniem (tight coupling). I chociaż tight coupling to pojęcie szersze, to jednak dobrze jest prezentowany przez ten przykład. Dwie klasy są mocno ze sobą związane.

Takie zakodowane na sztywno zależności (silne związania) są złe dla aplikacji i powinieneś ich unikać. Dlaczego?

  • jeśli chciałbyś aby komunikaty były wpisywane do pliku, a nie na konsolę, musiałbyś zmienić klasę Writer lub utworzyć nową i zmienić klasę Worker (sprzeczność z zasadą OpenClose).
  • jeśli klasa Writer miałaby inne zależności, te zależności musiałby także zostać utworzone (lub w jakiś sposób przekazane) przez klasę Worker. Lub utworzone w klasie Writer, co jeszcze bardziej zacieśnia kod. Ponadto daje nam już zbyt dużo odpowiedzialności (możliwa sprzeczność z zasadą Single Responsibility) i zdecydowanie zaciemnia obraz.
  • taka implementacja nie nadaje się do testów jednostkowych

Te wszystkie problemy można rozwiązać stosując wstrzykiwanie zależności…

Siostro! Zastrzyk!

OK, teraz wyobraźmy sobie jak lepiej mogłaby wyglądać klasa Worker:

class Worker
{
    Writer writer;
    public Worker(Writer writer)
    {
        this.writer = writer;
    }

    public void Foo()
    {
        writer.Write("Rozpoczynam pracę...");
    }
}

Spójrz, co się stało. Wstrzyknęliśmy obiekt klasy Writer do Worker za pomocą konstruktora. Obiekt Writer w tym momencie jest już poprawnie stworzony (ma utworzone swoje wszystkie zależności) i można go używać. Klasa Worker nie musi tworzyć już tego obiektu i nie daj Boże innych jego zależności.

Po prostu Worker używa Writer. A skąd go ma? To w zasadzie nie jest istotne. Czy Ciebie interesuje kto zrobił Ci przelew na konto? Czy ważne, że masz te pieniądze? 😉

Jednak cały czas nie rozwiązaliśmy jednego problemu. Silnego związania. Klasa Worker cały czas jest silnie związana z Writer. A gdyby tak posłużyć się interfejsem?

interface IWriter
{
    void Write(string message);
}
class Writer: IWriter
{
    public void Write(string message)
    {
        Console.WriteLine(message);
    }
}

class Worker
{
    IWriter writer;
    public Worker(IWriter writer)
    {
        this.writer = writer;
    }

    public void Foo()
    {
        writer.Write("Rozpoczynam pracę...");
    }
}

Na początku zdefiniowaliśmy sobie interfejs IWriter – z jedną metodą. Potem utworzyliśmy klasę Writer implementującą ten interfejs i na koniec do klasy Worker wstrzyknęliśmy interfejs.

W .NET można wstrzykiwać zależności przez konstruktor (najczęściej używane), właściwość (częściej używane w Blazor, gdzie to jest jedyna możliwość w komponencie będącym widokiem), a nawet przez parametr (stosowane raczej w kontrolerze webowej aplikacji)

To nam rozwiązuje ostatni problem. Dlaczego? Bo możemy sobie teraz rozszerzyć naszą aplikację, pisząc nieco inną implementację klasy Writer:

class FileWriter : IWriter
{
    public void Write(string message)
    {
        File.AppendText(message);
    }
}

Teraz klasa Worker może dostać obiekt Writer lub FileWriter. Nie ma już silnego związania z klasą Writer. Otrzymaliśmy luźne powiązanie (loose coupling). Daje to też możliwość napisania oszukanej klasy (Fake), którą można wykorzystać później w testach automatycznych:

class FakeWriter : IWriter
{
    public void Write(string message)
    {
        //żadnego ciała albo Debug.WriteLine
    }
}

Powyższy przykład pokazuje również wzorzec projektowy „Strategia”. Wzorzec ten jest poniekąd jednym z przykładów wstrzykiwania zależności.

Kontenery IoC

Zostaje jeszcze pytanie, jak tworzyć obiekty jak np. Writer? NAJPROSTSZYM przykładem Dependency Injection jest po prostu:

Worker worker = new Worker(new Writer());

To jest NAJPROSTSZY przykład, najbardziej banalny i całkowicie bezużyteczny w prawdziwym świecie (chociaż czasem nie da się inaczej). Co więcej, powoduje dużo problemów. Załóżmy, że masz taki kod rozsiany po całej aplikacji i nagle konstruktor klasy Writer potrzebuje jeszcze jednego obiektu… Musisz to zmieniać w wielu miejscach. Zupełna strata czasu.

Na szczęście powstało coś takiego jak kontenery DI.

Czym jest kontener DI

To specjalny kontener (pomyśl o tym jak o klasie Dictionary<Type, object> na mocnych sterydach), który konfigurujesz podczas inicjalizowania aplikacji. Np. w metodzie Main. Możesz spotkać się też z określeniem „kontener IoC” – IoC to „Inversion of Control” – wzorzec projektowy, którego jedną z implementacji jest wstrzykiwanie zależności.

Konfigurując taki kontener, rejestrujesz w nim klasy, interfejsy, długości życia, a także sposoby w jakie konkretne obiekty mają zostać tworzone. Kontenery dają też możliwość rejestrowania własnych metod do tworzenia obiektów. Na końcu to właśnie kontener tworzy dla Ciebie w pełni działający obiekt.

W C# mamy do dyspozycji różne kontenery IoC. Najbardziej znane to chyba Autofac i Microsoft.Extensions.DependencyInjection. Autofac był wcześniej, natomiast w .NetCore przyszły mechanizmy z Microsoftu. Jako, że Autofac jest dużo starszy, PRAWDOPODOBNIE ma więcej możliwości, ale Microsoftowy odpowiednik jest wystarczający. Moim zdaniem jest też prostszy w użyciu i dlatego to nim się zajmiemy.

Długość życia serwisu

W związku z tym, że mechanizm DI musi widzieć kiedy tworzyć i zwalniać obiekty, podczas konfiguracji podajemy długość życia. Czyli mówimy kontenerowi jak długo obiekt powinien żyć, czy też kiedy go tworzyć. To może wyglądać strasznie, ale w rzeczywistości jest bardzo proste.

Niezależnie od tego, czy używasz Autofaca, Microsoft Dependency Injection, czy jeszcze innego mechanizmu, długości życia będą analogiczne:

Transient

Obiekt zarejestrowany jako transient będzie tworzony za każdym razem, gdy będzie potrzebny. To znaczy, że każda klasa, do której wstrzykujesz obiekt transient, będzie miała własną niepowtarzalną instancję, np:

class Worker
{
    IWriter writer;
    public Worker(IWriter writer)
    {
        this.writer = writer;
    }
}

class Manager
{
    IWriter writer;
    public Manager(IWriter writer)
    {
        this.writer = writer;
    }
}

Jeśli klasa Writer zostanie zarejestrowana jako transient, to instancje Writera w Worker i Manager zawsze będą różne. Po prostu klasa Writer zostanie utworzona na nowo przy każdym takim wstrzyknięciu.

Można by to przyrównać do tego kodu:

Writer w1 = new Writer();
Worker worker = new Worker(w1);

Writer w2 = new Writer();
Manager manager = new Manager(w2);

Scoped

W przypadku aplikacji desktopowych i mobilnych nie różni się to od singleton niczym (chyba że sam tworzysz scope). W przypadku aplikacji webowych, powstanie tylko jedna instancja takiego obiektu na żądanie http. Tzn.:

class Worker
{
    IWriter writer;
    public Worker(IWriter writer)
    {
        this.writer = writer;
    }
}

class Manager
{
    IWriter writer;
    public Manager(IWriter writer)
    {
        this.writer = writer;
    }
}

Jeśli teraz Worker zostanie zarejestrowany jako scoped i jesteśmy w obrębie jednego żądania HTTP, wtedy w Worker i Manager będziemy mieli tę samą instancję klasy Writer. Po prostu obiekt Writer zostanie utworzony raz i wstrzyknięty do wszystkich innych obiektów w ramach jednego żądania. Obiekt umiera, gdy żądanie się kończy i nie jest już dłużej używany.

Można by to zademonstrować takim kodem:

Response ManageRequest()
{
    Writer scopedWriter = new Writer();

    Worker worker = new Worker(scopedWriter);
    Manager manager = new Manager(scopedWriter);

    //tutaj praca na żądaniu..., a na koniec

    worker = null;
    manager = null;
    scopedWriter = null;
}

Singleton

Zostanie utworzona TYLKO JEDNA instancja takiej klasy w całej aplikacji. I ta jedna instancja będzie przekazywana innym obiektom przez cały okres działania aplikacji. Obiekt umiera wraz z aplikacją.

Można to porównać do takiego kodu:

if (mainWriter == null)
    mainWriter = new Writer();

Worker worker = new Worker(mainWriter);
Manager manager = new Manager(mainWriter);

DI od Microsoftu

Jeśli tworzysz aplikację webową, to masz już to w standardzie. Natomiast aplikacja konsolowa, WPF, czy WinForms wymaga, żebyś zainstalował paczkę NuGet: Microsoft.Extensions.DependencyInjection

Następnie musisz dodać do usings:

using Microsoft.Extensions.DependencyInjection;

Kontenery

Zwróć teraz uwagę na dwie klasy: ServiceCollection i ServiceProvider. W klasie ServiceCollection dodajemy wszystkie nasze serwisy – konfigurujemy kontener DI. Klasa ServiceProvider tworzy i zwraca nam konkretny obiekt, który potrzebujemy w danej chwili.

Weźmy teraz prostą aplikację konsolową:

class MainClass
{
    static void Main()
    {
        Console.Write("Podaj imię: ");
        string name = Console.ReadLine();

        Console.WriteLine($"Cześć {name}!");
        Console.ReadKey();
    }
}

Spróbujmy ją przerobić tak, żeby używała Dependency Injection. Standardowe podejście jest takie:

  • w metodzie main konfigurujemy dependency injection
  • pobieramy obiekt jakiejś głównej klasy (np. w WPF/WinForms byłoby to najpewniej MainForm, czy też MainWindow)
  • wywołujemy metodę z tej klasy

Ponieważ pracujemy na konsoli, musimy stworzyć główną klasę aplikacji. Klasa Main w tym wypadku służy tylko do konfiguracji:

class App
{
    public void Run()
    {

    }
}

W metodzie Main utworzymy instancję klasy App, następnie wywołamy metodę Run, która zrobi dokładnie to samo, co Main po staremu. Moglibyśmy tutaj użyć klasy Console, ale żeby pobawić się dependency injection, zrobimy to inaczej.

Używaj abstrakcji

Zamiast posługiwać się bezpośrednio klasą Console, utworzymy interfejs i zaimplementujemy go:

interface IUserInteraction
{
    void Print(string message);
    string Read();
}

class ConsoleInteraction : IUserInteraction
{
    public void Print(string message)
    {
        Console.Write(message);
    }

    public string Read()
    {
        return Console.ReadLine();
    }
}

Konfiguracja

Teraz w metodzie Main skonfigurujemy nasze DI. Aby to zrobić, najpierw trzeba utworzyć obiekt klasy ServiceCollection (pamiętaj, że w aplikacjach webowych masz już to dostępne):

class MainClass
{
    static void Main()
    {
        var serviceCollection = new ServiceCollection();
        serviceCollection.AddTransient<IUserInteraction, ConsoleInteraction>();
        serviceCollection.AddSingleton<App>();

        using(var serviceProvider = serviceCollection.BuildServiceProvider())
        {
            var app = serviceProvider.GetService<App>();
            app.Run();
        }
    }
}

W linijce 5 tworzymy ServiceCollection, a następnie rejestrujemy dwa serwisy:

  • ConsoleInteraction jako interfejs IUserInteraction – pamiętaj, że w Microsoft DI najpierw podajesz interfejs, a później klasę, która ten interfejs implementuje (jeśli zrobisz na odwrót, aplikacja się nie skompiluje). Teraz mechanizm DI wszędzie tam, gdzie zobaczy interfejs IUserInteraction, utworzy obiekt klasy ConsoleInteraction.
  • Klasę App jako singleton. Jak widzisz – nie jest wymagany interfejs żeby zarejestrować klasę. W przypadku klasy App interfejs nie ma większego sensu, bo to główna klasa aplikacji. Ale często interfejs ma sens (głównie tam, gdzie jest zależnością dla innej klasy), więc pamiętaj o tym.

Jeśli nie wiesz czym jest singleton to po prostu obiekt który jest utworzony raz w całej aplikacji i żyje przez cały cykl jej życia. Możesz się też spotkać z określeniem, że singleton jest antywzorcem, ale tu chodzi o inną sytuację – ręczne klasyczne tworzenie singletona. W naszym przypadku rejestrujemy klasę jako singleton w kontenerze DI i wszystko jest w porządku.

Teraz tak. Dlaczego klasę ConsoleInteraction zarejestrowaliśmy jako transient? W przypadku tej aplikacji nie ma to żadnego znaczenia, bo i tak użyjemy jej tylko w jednym miejscu. Chciałem po prostu pokazać, że tak to się robi. Zazwyczaj klasy logujące będziesz rejestrował jako singletony.

A dlaczego klasa App jest jako singleton? No przypomnij sobie czym jest singleton – jedna instancja klasy, która żyje przez cały czas życia aplikacji. Czyli idealny zakres dla klasy, która reprezentuje całą aplikację.

Pobieranie serwisów

W linijce 9 tworzymy ServiceProvider ze skonfigurowanego ServiceCollection. Od tego momentu ServiceProvider będzie dostarczał nam obiekty, których potrzebujemy. Nie można już niczego zmienić w service collection (oczywiście nikt Ci nie broni, żeby mieć kilka ServiceCollection i Providerów, ale jakoś nie widzę w tym sensu).

Na końcu używamy ServiceProvider, żeby otrzymać obiekt klasy App. Dosłownie –

– Ej Ty! ServiceProvider, dej mnie no w pełni działający obiekt klasy App!

To może nie jest niczym wyglądającym super. Po prostu nie musiałeś tworzyć obiektu przez new, tylko za pomocą ServiceProvidera. Ale pamiętasz IUserInteraction? Teraz dodajmy go do klasy App:

class App
{
    readonly IUserInteraction ui;
    public App(IUserInteraction ui)
    {
        this.ui = ui;
    }
    public void Run()
    {
        ui.Print("Podaj imię: ");
        string name = ui.Read();

        ui.Print($"Cześć {name}!");
        Console.ReadKey();
    }
}

W metodzie Main – już nic więcej nie musisz robić. Otrzymasz znowu w pełni działający obiekt klasy App! Klasa implementująca IUserInteraction zostanie automagicznie utworzona i wstrzyknięta do App.

ServiceProvider i zwalnianie zasobów

Pewnie chodzi Ci po głowie pytanie – co z klasami IDisposable? I czy są one zwalniane?

Zasadniczo tak. Spójrz, jak został utworzony ServiceProvider:

using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
    var app = serviceProvider.GetRequiredService<App>();
    app.Run();
}

Teraz wszystkie obiekty zostaną zwolnione po zwolnieniu ServiceProvidera. Natomiast ServiceProvider może też utworzyć tzw. zakres:

using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
    using (var scope = serviceProvider.CreateScope())
    {
        var app = scope.ServiceProvider.GetRequiredService<App>();
        app.Run();
    }                
}

W aplikacji możemy mieć wiele takich zakresów. Np. w aplikacji webowej taki zakres jest tworzony na całe żądanie HTTP. Obiekty zarejestrowanie jako scope i transient zostaną usunięcie po wyjściu z takiego zakresu, chyba że są zależnościami dla innych obiektów. Jeśli implementują interfejs IDisposable lub IAsyncDisposable, odpowiednie metody Dispose też zostaną automatycznie wywołane. Singletony zostaną zwolnione po zakończeniu życia ServiceProvidera.

Ale uwaga! Jeśli zarejestrowałeś singleton, który jest zależny od innych klas i np. taką zależność zarejestrowałeś jako transient (lub scoped), to zwróć uwagę na to, że te zależności zostaną zwolnione dopiero po śmierci singletona. Przecież singleton na nich polega, dlatego też musi mieć dostęp do nich przez cały czas. W niektórych bibliotekach (np. boost::di dla C++) musisz rejestrować zależności dla singletona jako singleton. W .NET tak nie jest (przynajmniej nie w chwili pisania artykułu), ale miej świadomość długości życia.

Więcej na ten temat w kolejnym artykule, opisujących typowo Microsoft Dependency Injection

Skąd ten ServiceProvider jest taki mądry?

W baaaardzo dużym skrócie można by opisać jego działanie tak:

  1. Jaki chcesz typ? App? OK, sprawdźmy go
  2. Typ App ma konstruktor z parametrami. Jaki jest pierwszy parametr? IUserInteraction
  3. OK, muszę stworzyć teraz obiekt IUserInteraction. Co go implementuje? Z konfiguracji wynika, że ConsoleInteraction.
  4. Jaki konstruktor ma ConsoleInteraction? Domyślny. No to tworzymy ConsoleInteraction
  5. Skoro mam utworzone ConsoleInteraction, mogę teraz utworzyć App

ServiceProvider przeleci przez wszystkie zależności (i zależności tych zależności) i utworzy je (jeśli musi) w odpowiedniej kolejności. Na koniec zwróci Ci w pełni działający obiekt, o który prosiłeś.

Możesz teraz uruchomić aplikację i zobaczyć jak działa. Przedebuguj ją sobie linijka po linijce i sprawdź kiedy wywołują się poszczególne konstruktory.

Wydajność aplikacji

Oczywiście taki mechanizm musi wpływać na wydajność aplikacji. Jednak w dzisiejszych czasach nie ma się tym co przejmować. Raczej nie powinno być to zauważalne. Używaj tego i będzie git 🙂 Pamiętaj, żeby nie optymalizować programu jeśli faktycznie nie musisz. Jeśli jest to problem, pomyśl czy klas, które są tworzone najdłużej nie zarejestrować jako singletony.


To na tyle, jeśli chodzi o wstrzykiwanie zależności. W innym artykule opiszę niedługo co jeszcze bardziej zaawansowanego można osiągnąć tym mechanizmem. Więc koniecznie zapisz się do newslettera, żeby nie pominąć go.

Jeśli masz pytania lub znalazłeś błąd w artykule, daj znać w komentarzu. Jeśli spodobał Ci się, udostępnij go 🙂

Podziel się artykułem na:
Entity Framework w osobnym projekcie

Entity Framework w osobnym projekcie

Jeśli szukasz szybkiego rozwiązania, kliknij tu. Jeśli chcesz się nieco więcej dowiedzieć, przeczytaj cały post.

Wstęp

Gdy tworzymy nową aplikację z identyfikacją użytkowników (Identity) w VisualStudio, domyślny kreator tworzy jeden projekt, do którego pcha wszystkie klasy. Do malutkich rzeczy, czy nauki to w zupełności wystarczy. Jednak w świecie rzeczywistym chcielibyśmy mieć osobny projekt do modeli i osobny projekt dla warstwy danych (Data Access Layer).

Niby nie jest to trudne, wystarczy przenieść nasz DbContext do innego projektu i już. A co z migracjami? Migracje nadal będą się tworzyć w projekcie głównym. Nie o to chodzi. Chcemy migracje też w projekcie z danymi.

Dlaczego to nie jest oczywiste?

Musisz zdać sobie sprawę z tego, jak działają migracje w Entity Framework (czy też EfCore), a także jak działa aktualizacja bazy danych.

Gdy uruchamiasz polecenie Add-Migration lub dotnet ef migrations add, narzędzie uruchamia Twoją główną aplikację. Uruchomienie aplikacji następuje w sposób normalny. Czyli przy aplikacji konsolowej, odpalona zostanie metoda Main. Przy aplikacji webowej, pójdzie cała konfiguracja.

Jednym z kroków jest inicjalizacja Entity Framework, np:

services.AddDbContext<ApplicationDbContext>(options =>
	options.UseSqlServer(
		Configuration.GetConnectionString("DefaultConnection")));

W tym momencie tworzymy połączenie z bazą danych i migracje mogą zostać utworzone. Pamiętaj, że do utworzenia migracji konieczne jest połączenie z bazą danych. Narzędzie musi sprawdzić, jak wygląda baza i jak wygląda model – musi mieć możliwość porównania tego.

Teraz jeśli uruchomisz migrację z parametrem -p, wskazując na konkretny projekt, np:

Add-Migration InitialDbCreate -p DataAccessLayer

Entity Framework będzie próbowało uruchomić projekt DataAccessLayer. Jeśli jest to zwykła biblioteka klas (class library), no to co się uruchomi? Nic. Dlatego też migracja nie będzie mogła się odbyć.

Ale można to nieco obejść. Narzędzie poszuka jeszcze klasy, która implementuje pewien interfejs. Jeśli znajdzie taką, utworzy jej obiekt i za jej pomocą skonfiguruje połączenie z bazą danych.

Rozwiązanie

  1. W swoim projekcie z danymi (tam, gdzie masz DbContext i chcesz mieć migracje) musisz utworzyć klasę implementującą specjalny interfejs IDesignTimeDbContextFactory. Ef właśnie tego poszuka (jeśli używasz Sql Servera, dodaj pakiet nuget: Microsoft.EntityFrameworkCore.SqlServer):
public class DbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
	public XMoneyDbContext CreateDbContext(string[] args)
	{
		DbContextOptionsBuilder<ApplicationDbContext> optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();

        optionsBuilder.UseSqlServer("tutaj Twój connection string")

        return new ApplicationDbContext(optionsBuilder.Options);
	}
}

Przeanalizujmy go:

  • deklarujesz fabrykę kontekstu bazy danych (Ef poszuka właśnie klasy implementującej ten interfejs), parametrem generycznym jest oczywiście Twój kontekst bazy danych.
  • najpierw tworzysz buildera do opcji kontekstu
  • ustawiasz opcje (np. UseSqlServer) i connection string
  • tworzysz swój kontekst i zwracasz go

I to właściwie tyle. Możesz już teraz uruchomić migrację z przełącznikiem -p:

Add-Migration NazwaMigracji -p NazwaTwojegoProjektu

lub

dotnet ef migrations add NazwaMigracji -p NazwaTwojegoProjektu

Podziel się artykułem na: