Zwracamy wynik operacji do WebApi

Zwracamy wynik operacji do WebApi

Wstęp

To jest artykuł z cyklu „Kij w mrowisko” i jestem pewien, że duża część czytelników się ze mną nie zgodzi. I mają do tego prawo tak samo jak ja mam prawo do wyrażenia własnego zdania 😀

Zwracanie wyniku operacji z serwisu do kontrolera WebApi bywa czasem problematyczne. Zwłaszcza widzę to u młodych osób, ale nie tylko. W tym artykule przedstawię sposoby, które znam i sposób, który polecam.

ProblemDetails

Gwoli przypomnienia, od prawie 10 lat istnieje standard, który opisuje jak powinniśmy zwracać błędy z samego WebApi do klienta. To się nazywa ProblemDetails, o którym już pisałem w tym artykule. Ale ten tekst ma być o zwracaniu rezultatu z samego serwisu, a więc lećmy dalej.

Używanie wyjątków

To chyba sposób, który budzi najwięcej kontrowersji. Ja jestem mu zdecydowanie przeciwny, ale to też „zależy”. Zatem zobaczmy, jak mogłoby to wyglądać.

Spójrzmy na fragment przykładowego serwisu, gdzie dodajemy sobie do bazy danych jakiś TodoItem:

public async Task<ToDoItem> AddItem(string title, string description, DateTimeOffset expireDate)
{
    if(_timeProvider.GetUtcNow() > expireDate)
    {
        throw new ArgumentException("Expire Date nie może być w przeszłości!");
    }

    //dalej dodajemy do bazy danych i zwracamy utworzony obiekt
    return addedItem;
}

Od strony kontrolera mogłoby to wyglądać w skrócie tak:

public async Task<IActionResult> AddItem(ToDoDto item)
{
  try
  {
      var result = await _service.AddItem(item.Title, item.Description, item.ExpireDate);
      return Created(result);
  }catch(ArgumentException ex)
  {
      return ProblemDetails(ex.Message);
  }
}

Zwracam uwagę jeszcze na to, że zamiast rzucać i łapać ArgumentException, moglibyśmy stworzyć sobie jakiś ServiceException i łapać go na poziomie middleware zamiast bezpośrednio w kontrolerze. Takie podejście zdecydowanie jest ładniejsze, czystsze i dużo bardziej użyteczne. Natomiast chodzi tutaj o samo rzucanie wyjątków.

Czy wyjątki są wyjątkowe?

Tutaj wielu czytelników się ze mną nie zgodzi. Natomiast wyjątki powinny być stosowane do obsługi wyjątkowych sytuacji. Co to jest „wyjątkowa sytuacja”? Problem, którego nie jesteśmy w stanie przewidzieć albo jesteśmy w stanie przewidzieć, ale jego wystąpienie powoduje, że dalsze działanie aplikacji (czy też konkretnej akcji) nie może zostać ukończone ani w żaden sposób poprawione.

To teraz sobie odpowiedzmy na pytanie – czy nieprawidłowe dane podane przez użytkownika to jest wyjątkowa sytuacja? Zdecydowanie nie. To jest naturalne, że użytkownik poda nieprawidłowe dane (albo przypadkiem, albo ze względu na brak walidacji na froncie, albo po prostu próbuje atakować nasz system). Nie jest to zatem sytuacja wyjątkowa i w mojej ocenie nie powinna być obsługiwana za pomocą wyjątków.

Wyjątkową sytuacją w takim systemie mógłby być problem z dobiciem się do zewnętrznego API (jeśli używamy), problem z połączeniem z bazą danych, brak pamięci na dysku/w systemie itp. Sytuacje, w których nie możemy nic zrobić, żeby system zadziałał. Nie mamy na nie w ogóle wpływu.

A jeśli użytkownik podaje nieprawidłowe dane? Możemy go poprosić o prawidłowe.

Problemy z wyjątkami

To nie jest tylko kwestia „widzi mi się”. Nie raz słyszeliśmy, że obsługa wyjątków jest wolna. Owszem, pewnie w większości przypadków w normalnym użyciu systemu nikt z nas nie zauważy tego spowolnienia. Jednak tutaj problemem może być jeszcze jedna rzecz.

Wyjątki są wolne. Co oznacza, że w specyficznych sytuacjach mogą nam dać podatność na atak DDoS. Jeśli system jest publiczny (każdy może z niego korzystać za darmo lub za opłatą), wtedy rodzi się pewne niebezpieczeństwo, że ktoś będzie chciał nas hackować. A używanie wyjątków w celu walidacji danych daje hackerowi dodatkowe możliwości.

Kiedy używać?

Oczywiście tutaj też trzeba podejść do sprawy zdroworozsądkowo. Bo jednak użycie wyjątków w takiej sytuacji jest niesamowicie wygodne. Jeśli tworzysz system tylko do użytku wewnętrznego lub jest to coś niezbyt wartego uwagi, to śmiało. Nic złego raczej się nie stanie.

Jeśli jednak to system publiczny, a nie daj Boże jakiś SaaS, to zalecam jednak unikać takiego wykorzystania wyjątków. Możesz się tu ze mną kłócić. Historia nas oceni 😉

Używanie tupli

Innym często spotykanym rozwiązaniem jest użycie prostej tupli. Powyższy kod mógłby wyglądać tak (serwis):

public async Task<(ToDoItem? Result, string? Error)> AddItem(string title, string description, DateTimeOffset expireDate)
{
    if(_timeProvider.GetUtcNow() > expireDate)
    {
        return new(null, "Expire Date nie może być w przeszłości!");
    }

    //dalej dodajemy do bazy danych i zwracamy utworzony obiekt
    return new(addedItem, string.Empty);
}

A od strony kontrolera:

public async Task<IActionResult> AddItem(ToDoDto item)
{
      var result = await _service.AddItem(item.Title, item.Description, item.ExpireDate);
      if(string.IsNullOrEmpty(result.Error))
          return Created(result.Result);
      else
          return ProblemDetails(result.Error);
}

Jest to proste rozwiązanie. Gdy idę w tym kierunku, zawsze jednak staram się tą Tuple zamieniać na zwykły rekord:

public record AddItemResult(ToDoItem? Item, string? Error);

Jest to nieco czystszym rozwiązaniem.

I o ile tutaj nie ma żadnych pozornych problemów, to nie radziłbym tego używać w większych systemach. Takie zwracanie rezultatu nie jest po prostu uniwersalne. Jednak w małych systemach działa całkiem elegancko.

Jeśli masz system, gdzie masz tylko kilka endpointów i to się nie będzie rozrastać jakoś szybko, to zdecydowanie jest to szybkie i skuteczne rozwiązanie. Ale można lepiej…

FluentResult

FluentResult to bardzo fajna biblioteka open source dostępna na Nuget. Pozwala zwrócić zarówno błędy (w liczbie mnogiej) jak i sam rezultat operacji. Jej użycie w systemie może być bardzo uniwersalne, eleganckie i wygodne.

W każdym razie powyższy kod mógłby wyglądać tak (serwis):

public async Task<Result<ToDoItem>> AddItem(string title, string description, DateTimeOffset expireDate)
{
    if(_timeProvider.GetUtcNow() > expireDate)
    {
        return Result.Fail("Expire Date nie może być w przeszłości!");
    }

    //dalej dodajemy do bazy danych i zwracamy utworzony obiekt
    return addedItem; //klasa Result sama ogarnie konwersję
}

No i od strony kontrolera:

public async Task<IActionResult> AddItem(ToDoDto item)
{
      var result = await _service.AddItem(item.Title, item.Description, item.ExpireDate);
      if(result.IsFailed)
          return ProblemDetails(result.Reasons[0].Message);
      else
        return Created(result.Value);
}

FluentResult obsługuje zarówno wyjątki, jak i obiekty implementujące jego interfejs IError. Przykład, który widzisz wyżej (Result.Fail(„błąd”)) tak naprawdę pod spodem tworzy obiekt klasy Error (która implementuje IError) i przepisuje ten błąd do niej. Potem możesz to sobie odczytać.

FluentResult ma wiele użytecznych rzeczy out-of-the-box. Potrzebujesz błąd razem z jakimś jednoznacznym kodem?

Error err = new Error("Expire Date nie może być w przeszłości!")
    .WithMetadata("ErrorCode", ValidationError);

return Result.Fail(err);

Przy czym ValidationError to object, czyli może być czymkolwiek. Intem, stringiem, kotkiem, czym sobie chcesz.

Po stronie kontrolera też to możesz odczytać na różne sposoby. Nie chcę się za bardzo rozpisywać na temat tej biblioteki, ponieważ dokumentacja jest wystarczająca.

Podsumuję tylko, że ja jej bardzo chętnie używam, a tam gdzie potrzebuję większej uniwersalności (np. konwersja błędów na ProblemDetails) wystarczy jedna mała extension method. Super też się sprawdza podczas testów jednostkowych.

To jest właśnie ten sposób, który ja polecam i sam używam.


A Wy czego używacie? Używacie wyjątków? Sprawiły Wam kiedykolwiek jakieś problemy? A może macie zupełnie inny sposób? Koniecznie podzielcie się w komentarzu.

Dzięki za przeczytanie artykułu. Jeśli coś jest niejasne albo znalazłeś gdzieś jakiś błąd, daj znać w komentarzu.

Obrazek wyróżniający: Obraz autorstwa freepik

Podziel się artykułem na:
MemoryCache i jego pułapki

MemoryCache i jego pułapki

Wstęp

IMemoryCache to jeden z mechanizmów pamięci podręcznej (cache) jakie mamy dostępne w .NET. Wykorzystujemy go w sytuacji, kiedy nasza aplikacja działa tylko na jednej maszynie (nie jest skalowana).

Jednakże domyślna implementacja tego interfejsu może czasem odnieść odwrotny skutek lub przyprawić nas o dziwne problemy. Chyba że poznamy jej zawiłości. I o tym jest ten artykuł.

IMemoryCache czyli najprostsze cachowanie w pamięci

IMemoryCache domyślnie jest zaimplementowany przez… niesamowite… MemoryCache. A, żeby móc tego użyć, wystarczy zarejestrować sobie ten cache podczas rejestracji usług:

builder.Services.AddMemoryCache();

To rejestruje nam klasę MemoryCache jako singleton z ewentualnymi opcjami. Żeby potem użyć tego w swoim serwisie, wystarczy wstrzyknąć IMemoryCache.

Pobieranie i dodawanie danych

I tutaj zaczyna się ciekawie. Najpierw powiedzmy sobie o ICacheEntry.

ICacheEntry

To właśnie obiekty implementujące ICacheEntry mogą być elementami IMemoryCache. ICacheEntry trzyma w sobie kilka przydatnych dla mechanizmu informacji. Poza oczywiście wartością, którą trzymamy w cache, posiada jeszcze informacje na temat długości swojego życia, priorytetu itd. Dojdziemy do szczegółów później.

Domyślnie ICacheEntry jest implementowany przez wewnętrzną klasę CacheEntry. Wewnętrzną (internal), a więc nie możemy utworzyć sami jej obiektu.

Dodawanie elementu do cache

IMemoryCache ma kilka metod do dodawania i pobierania danych, które jednak potrafią nam dołożyć kilku siwych włosów. Zacznijmy bez extensionów, od zupełnej podstawy

{
    using var entry = memoryCache.CreateEntry(city);
    entry.Value = new WeatherForecast();
}

Żeby lepiej to uwidocznić, to:

var entry = memoryCache.CreateEntry(city);
entry.Value = new WeatherForecast();
entry.Dispose();

W powyższych kodach city to po prostu miasto dla którego chcemy zapamiętać pogodę, a więc KLUCZ, pod którym zapisujemy konkretną wartość.

Tak, żeby dodać wartość do cache, musimy:

  • utworzyć CacheEntry za pomocą metody CreateEntry z IMemoryCache – inaczej się nie da, bo CacheEntry jest tylko internal, nie możemy sami utworzyć jego instancji,
  • przypisać wartość do CacheEntry – bez tego nie zadziała
  • wywołać Dispose na rzecz tego CacheEntry – albo jawnie, albo tak jak wyżej za pomocą using – bez tego nie zadziała.

Jak widać, musimy zrobić z tym CacheEntry zrobić dwie rzeczy:

  • dodać wartość (Value)
  • wywołać Dispose()

Dyskusji na temat tego Dispose() było już kilka, m.in. tu: https://github.com/dotnet/runtime/pull/42355 i tu: https://github.com/dotnet/runtime/issues/36392.

Nie jest to naturalne użycie, ponieważ po tym jak wywołamy Dispose(), to CacheEntry dopiero jest wrzucane do MemoryCache i tam wykorzystywane (sic!). Nie mniej jednak MS tłumaczy, że zrobienie tego inaczej byłoby breaking changem (prawda) i nie przyniosłoby wartości (zasadniczo też prawda). Więc… „jest jak jest”. I trzeba o tym pamiętać.

Musimy ten Dispose() potraktować bardziej jako „koniec konfiguracji CacheEntry” niż koniec jego życia.

Dodawanie za pomocą rozszerzenia

Dodawanie CacheEntry za pomocą metody rozszerzeń jest zdecydowanie bardziej naturalne dla nas. Rozszerzenie już samo w sobie pamięta o wykonaniu Dispose(), więc my się tym nie musimy przejmować. Wystarczy:

memoryCache.Set(city, new WeatherForecast());

Jak widać, takie dodawanie jest zdecydowanie bardziej naturalne. Musimy jednak być świadomi, że Set jest tu metodą rozszerzenia (extension method).

Używając powyższych rozwiązań musimy zrobić dwie rzeczy, żeby cache zadziałał. Spójrz na poniższy kod:

public async Task<WeatherForecast?> GetWeatherFor(string city)
{
    if (memoryCache.TryGetValue<WeatherForecast>(city, out var weather))
    {
        return weather!;
    }

    return memoryCache.Set(city, new WeatherForecast());
}

Jeśli mamy w serwisie metodą GetWeatherFor, która w parametrze przyjmuje nazwę miasta, dla którego chcemy zwrócić prognozę pogody, to żeby to wszystko elegancko zadziałało z cachem, musimy:

  • sprawdzić, czy mamy taką wartość już w cache zapisaną, jeśli tak, zwrócić ją stamtąd
  • jeśli wartości nie ma w cache, musimy ją w jakiś sposób zdobyć (np. poprzez zewnętrzne API – nie jest to pokazane w kodzie) i dopiero potem zapisać w cache i zwrócić. Metoda Set na szczęście zwraca nam wartość, którą przekazaliśmy w parametrze, więc możemy po prostu zrobić return z niej.

To jest taka opcja „krok po kroku”, która zasadniczo nie powinna rodzić pytań. Wszyscy się spodziewamy, że cache właśnie tak działa. Ale jest inna fajna metoda rozszerzenia:

public async Task<WeatherForecast?> GetWeatherFor(string city)
{
    return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
    {
        await Task.Delay(1000); //kwestia poglądowa, symulacja długiej operacji

        var result = new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = "Sunny"
        };

        entry.SetValue(result);
        return result;
    });
}

GetOrCreateAsync. Co to robi? W pierwszej kolejności próbuje zwrócić z cache istniejącą wartość na podstawie przekazanego klucza. Jeśli jest, to super, zwraca i tyle. Jeśli jednak takiej wartości nie ma, to ta metoda odpala naszą funkcję, którą podaliśmy w drugim parametrze – to jest to miejsce do pobrania wartości i SKONFIGUROWANIA CacheEntry.

Co się dzieje w tej lambdzie?

Na początku robię jakiś Task.Delay, ale tylko jako kwestia poglądowa, że tu może mieć miejsce jakaś długotrwała operacja (np. pobranie danych z zewnętrznego API).

Dalej w jakiś sposób pozyskuję WeatherForecast, ustawiam go jako wartość w entry i zwracam.

Parametr entry, który otrzymujemy w parametrze naszej funkcji to jest właśnie utworzony CacheEntry. Dlatego w naszej funkcji musimy go skonfigurować.

Którą opcję wybierzesz do dodawania elementu do cache jest Twoją indywidualną sprawą. Wszystkie te metody zasadniczo robią to samo, tylko w nieco inny sposób. No i oczywiście GetOrCreateAsync jest na tyle uniwersalne, że daje nam jakby 100% użyteczności całego cache.

Typ klucza

Każda wartość w cache musi mieć swój klucz. Inaczej nie wiedzielibyśmy co dodajemy lub pobieramy. I mimo, że parametr klucz jest zawsze typu object, to tak naprawdę może być czystym stringiem. Ważne, żeby klucz miał dobrze zaprogramowaną metodęy GetHashCode i Equals.

To może być szczegół implementacyjny, ale pod spodem mamy dwa słowniki, które przechowują nasze wartości. A konkretniej dwa ConcurrentDictionary (ale nie daj się zwieść, że MemoryCache jest thread safe – o tym później). Jeden z tych słowników przechowuje wartości z kluczem object, a drugi ze stringiem. I po prostu mechanizm sam rozpoznaje, czy podaliśmy w kluczu string, czy nie (if key is string...). Następnie dodaje wartość do odpowiedniego słownika.

Dlaczego tak? To jest tak naprawdę zależne od wersji .NET. Domyślny hash stringów kiedyś pozwalał na atak Hash Collision Atack. Natomiast teraz używany jest przy stringach „Marvin Hash”, który jest odporny na te ataki, czyli mamy zwiększone bezpieczeństwo.

Jednak trzeba pamiętać, że stringi są porównywane w zależności od wielkości znaków. Czyli klucze „Łódź” i „łódź” będą innymi kluczami.

Ustawienia CacheEntry

CacheEntry, jak i całe MemoryCache ma swoje ustawienia, które KONIECZNIE powinniśmy uwzględnić. Albo podczas dodawania (konfiguracji tak naprawdę) CacheEntry do cache, albo podczas rejestracji usług.

Co jest problemem, jeśli tego nie zrobimy? Cache jest przechowywany w pamięci RAM. Domyślnie wszelkie wartości jakie wrzucamy, nigdy nie giną. No, dopóki aplikacja żyje oczywiście 🙂

Co nam to daje? Pewnie się domyślasz. Ciągle tylko dokładamy do RAMu nowe dane, co w końcu doprowadzi do Out Of Memory.

Zatem musimy koniecznie zatroszczyć się o odpowiedni czas przechowywania danych w cache. Co to znaczy „odpowiedni”? To oczywiście zależy od konkretnej aplikacji. Bo czasem wartości mogą żyć dłużej (jak w przypadku prognozy pogody), czasem powinny nieco krócej, a w specyficznych aplikacjach używanie cache w ogóle nie ma sensu. Dlatego sam musisz odpowiedzieć na pytanie – jak długo ta dana może leżeć w cache.

Czas życia CacheEntry

Mamy kilka metod, żeby określić czas życia konkretnej danej w cache:

  • AbsoluteExpiration – gdzie podajemy konkretną datę i czas, do której wartość ma żyć (np. do 5 stycznia 2026 roku, do godziny 17:03)
  • AbsoluteExpirationRelativeToNow – zdecydowanie bardziej użyteczna, gdzie podajemy po prostu ile czasu ma żyć jako TimeSpan. Czyli konkretnie: 10 minut, 1 dzień itd.
  • SlidingExpiration – analogicznie jak AbsoluteExpirationRelativeToNow z tą różnicą, że ten czas odświeża się zawsze po pobraniu danej.
  • ExpirationToken – o tym niżej.

Czyli jeśli aktualnie mamy godzinę 17:00:00 i ustawimy AbsoluteExpirationRelativeToNow na 10 minut, to niezależnie od tego, ile razy w ciągu tych 10 minut będziemy pobierać daną z cache, to o 17:10:00 skończy się jej życie i zostanie usunięta.

Jeśli jednak użyjemy SlidingExpiration, to te 10 minut będzie liczone od nowa po każdym pobraniu danej. Czyli jeśli daną pobierzemy o 17:05, to życie skończy się jej dopiero o 17:15. Ale jeśli pobierzemy ją znowu o 17:07, to wydłuży jej życie do 17:17. Właściwość SlidingExpiration możesz znać z mechanizmu ciastek, gdzie działa dokładnie w taki sam sposób.

Ale uwaga, tutaj jest kolejna pułapka. Mechanizm odpowiedzialny za usuwanie danych z cache, bo ich czas się skończył, nie obsługuje żadnego timera. Czas ich życia jest sprawdzany po prostu przy każdej operacji z nimi związanej, czyli dodawanie nowej wartości, odczyt itd. Więc w bardzo specyficznych warunkach (raz dodamy daną do cache i nigdy nic więcej z tym cachem nie zrobimy) wartość może żyć wiecznie 🙂 Ale to raczej warunki niespotykane.

ExpirationToken

Mamy w .NET taki interfejs jak IChangeToken. Służy on do implementacji klas, które „monitorują” jakieś zdarzenia. Ustawienie ExpirationToken spowoduje usunięcie wartości z cache, gdy dane zdarzenie wystąpi. A co to może być za zdarzenie? Jakiekolwiek. Zmiana zawartości pliku na dysku, event związany z bazą danych albo cokolwiek sobie wymyślimy. Weźmy pod uwagę przykładowo zmianę zawartości pliku na dysku.

Scenariusz jest taki, że trzymamy dane z pliku w cache i monitorujemy zmianę tego pliku. Można by to ogarnąć w taki sposób:

Najpierw rejestrujemy odpowiedniego FileProvidera:

builder.Services.AddSingleton<IFileProvider>(new PhysicalFileProvider(@"C:\temp"));

A teraz możemy już pobrać token, który powie nam, czy plik się zmienił i dodać go do CacheEntry:

public class WeatherService(IMemoryCache memoryCache, 
    IFileProvider _fileProvider) : IWeatherService
{
    public async Task<WeatherForecast?> GetWeatherFor(string city)
    {
        return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
        {
            // zaczynamy plik obserwować
            IChangeToken changeToken = _fileProvider.Watch("data.txt");

            // pobieramy dane z pliku
            var data = await File.ReadAllTextAsync(@"C:\temp\data.txt");

            // dodajemy token zmiany do pamięci podręcznej
            entry.AddExpirationToken(changeToken);

            // zapisujemy dane do pamięci podręcznej
            entry.SetValue(data);

            return JsonSerializer.Deserialize<WeatherForecast>(data);
        });
    }
}

Tylko UWAGA!

Tutaj za czas życia wartości w cache odpowiada ten token, a nie czas. Natomiast zasada sprawdzania, czy czas życia danej w cache się skończył jest dokładnie taka sama jak w poprzednim przypadku. Czyli mechanizm sprawdza tylko przy jakiejś operacji na danych (dodawanie, odczyt, usuwanie).

Możemy też jednocześnie ustawić konkretny czas życia (np. na 5 minut), jak również dodać token. Jednak nie łączmy ze sobą różnych form wygasania po czasie, bo to po prostu nie ma sensu.

A może by tak IDisposable?

A gdybyśmy chcieli trzymać obiekt implementujący IDisposable? Hmm. Stwórzmy sobie do tego celu czystą klasę implementującą IDisposable:

public class MyDisposable : IDisposable
{
    private bool disposedValue;

    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            disposedValue = true;
        }
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

I teraz lekko zmienię metodę GetWeatherFor, żebyśmy mogli tę klasę wykorzystać:

public async Task<IDisposable?> GetWeatherFor(string city)
{
    return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
    {
        var d = new MyDisposable();
        entry.SetValue(d);
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
        return d;
    });
}

Tworzymy sobie nasz obiekt i dodajemy go do cache w taki sposób, żeby żył tylko 10 sekund. I co się okazuje? Klops, dupa zbita. Obiekt implementujący IDisposable nigdy nie jest zwalniany, co może doprowadzić do poważnych wycieków pamięci i w gruncie rzeczy do Out Of Memory.

Jak sobie z tym poradzić?

Na szczęście CacheEntry ma specjalną metodę, którą wywołuje w momencie gdy jest usuwany z cache. Czyli nasz kod, powinniśmy zmienić na taki:

public async Task<IDisposable?> GetWeatherFor(string city)
{
    return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
    {
        var d = new MyDisposable();
        entry.SetValue(d);
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
        entry.RegisterPostEvictionCallback((key, value, reason, state) =>
        {
            if (value is IDisposable disposable)
            {
                disposable.Dispose();
            }
        });
        return d;
    });
}

Metoda rozszerzenia RegisterPostEvictionCallback rejestruje nam w prosty sposób właśnie taki callback, który zostanie wywołany, gdy z jakiegoś powodu wartość będzie usuwana z cache. To może być po prostu koniec życia albo po prostu zwykłe usunięcie przez użytkownika. To jest to miejsce, gdzie możemy po sobie posprzątać.

A kiedy wybuchnie?

Niestety trzymanie w cache obiektu implementującego IDisposable niesie za sobą jeszcze jedno niebezpieczeństwo. Mianowicie możemy ten obiekt zdisposować w innym miejscu.

Wtedy w cache będziemy przetrzymywać obiekt, który… no jest trupem. Takie „zombie”. Jeśli teraz pobierzemy takie zombie i będziemy chcieli użyć, no to BUM!

Zatem rada jest taka:

Nie przechowuj w cache obiektów implementujących IDisposable

A jak z wielowątkowością?

Memory cache NIE JEST thread safe

W związku z tym, że MemoryCache pod spodem używa ConcurrentDictionary, uważa się, że jest wątkowo bezpieczny. Ale nie jest tak do końca.

Fakt – MemoryCache jest bezpieczny pod kątem samego dodawania lub pobierania wartości (w końcu mamy Concurrent Dictionary). Ale problemem jest to, co dzieje się na zewnętrz tego cache – w naszym kodzie.

Możemy doświadczyć race condition i innych problemów z wątkami. Przykładowo callback zarejestrowany w CacheEntry może przyjść z zupełnie innego wątku, niż ten, w którym utworzyliśmy i dodaliśmy obiekt do cache. To może być istotne w pewnych sytuacjach. A co do race condition… Spójrz na ten prosty kodzik:

if (!cache.TryGetValue("key", out var value))
{
    // <- w tym momencie inny wątek może już dodać wartość
    value = LoadFromDb();
    cache.Set("key", value);
}

Co się może zadziać w tym przypadku?

  • wątek A robi TryGetValue – dostaje false, bo w cache nie ma jeszcze takiej wartości
  • wątek B robi TryGetValue – dostaje false, bo w cache nie ma jeszcze takiej wartości
  • oba wątki pobierają dane z bazy
  • oba wątki dodają wartość do cache

W efekcie:

  • powielamy zapytanie do bazy danych (co może być kosztowną operacją)
  • jeden wpis nadpisuje drugi

Ale jeszcze ciekawszy przypadek, który może pomóc zobrazować skalę problemu:

int data = 0;

Parallel.For(0, 10, _ =>
{
    var cachedItem = memoryCache.GetOrCreate<int>("data", _ => Interlocked.Increment(ref data));
    Console.WriteLine($"Cached Item: {cachedItem}");
});

Co tu chcemy osiągnąć?

Robimy równoległą pętlę, która ma 10 iteracji. W każdej iteracji pętli pobieramy z cache wartość pod kluczem „data” lub dodajemy, jeśli takiej nie ma. W efekcie chcielibyśmy otrzymać na konsoli dziesięć jedynek – ponieważ na początku dodaliśmy wartość 1 pod kluczem data. A co otrzymujemy? U mnie to było:

Cached Item: 3
Cached Item: 5
Cached Item: 6
Cached Item: 2
Cached Item: 1
Cached Item: 4
Cached Item: 4
Cached Item: 4
Cached Item: 4
Cached Item: 7

U Ciebie będą pewnie nieco inne wyniki, natomiast nie uzyskamy tego, czego się spodziewamy. Ponieważ operujemy na MemoryCache z różnych wątków.

Zatem mimo wątkowo bezpiecznych mechanizmów w środku, MemoryCache nie jest wątkowo bezpieczny.

Jak temu zaradzić? Mamy dwie opcje:

  • używać podwójnych locków (podobnie jak przy klasycznym singletonie)
  • używać biblioteki LazyCache.

Biblioteka LazyCache gwarantuje nam już jednorazową inicjalizację cache nawet z wielu wątków. Nie jest jednak spójna z interfejsem IMemoryCache, bo służy do trochę innych rzeczy. Ale to nie jest artykuł o tej bibliotece.

Usuwanie danych

Pomówmy sobie jeszcze o usuwaniu danych. Mamy do tego właściwie trzy możliwości:

  • automatyczne usuwanie danych na podstawie czasu ich życia lub tokenu,
  • usunięcie danych ręcznie,
  • usuwanie danych, gdy cache przekroczy ustawiony limit.

O pierwszym sposobie już pisałem wyżej. Drugi – no to po prostu wywołanie metody Remove, więc nie ma o czym gadać. Natomiast dość ciekawy jest trzeci sposób.

Limitowanie wielkości cache

Jak już pisałem wyżej, domyślnie MemoryCache nie ma żadnych ograniczeń. Więc jeśli nie ustawiamy czasu życia poszczególnych elementów, możemy dojść do OutOfMemory.

Ale jest dodatkowa opcja limitowania cache, którą warto rozważyć. A mianowicie ustawianie limitu wielkości cache. Robimy to podczas tworzenia cache lub jego rejestracji:

builder.Services.AddMemoryCache(o =>
{
    o.SizeLimit = 100;
    o.CompactionPercentage = 0.2f;
});

I co tu się zadziało?

Ustawiliśmy limit cache na 100. Teraz pytanie – czym jest to 100. To jest jednostka wybrana przez Ciebie. Sam decydujesz o tym, ile to jest 1. Tu nie chodzi o bajty, megabajty ani inne takie. Programista sam sobie ustala jednostkę, a potem musi się jej trzymać.

To teraz drugie pytanie – skąd cache ma wiedzieć, kiedy doszedł do limitu pamięci?

Ponieważ, gdy ustawiasz limit pamięci, to każdemu elementowi, który wkładasz do cache, musisz ustawić rozmiar, jaki zajmuje w TWOJEJ JEDNOSTCE (jeśli tego nie ustawisz, apka się wywali).

Np.:

memoryCache.Set("test", 200, new MemoryCacheEntryOptions
{ 
    Size = 1
});

Dodaję wartość 200 do cache. Dodatkowo podaję, ile przestrzeni zajmuje w moich jednostkach. W moim przypadku wartość 200 zajmuje 1.

Takie zarządzanie ma swoje plusy i minusy. Z jednej strony, przy moim pierwszym spotkaniu z tym limitem byłem przekonany, że wszędzie są po prostu bajty. A to oczywiście bzdura. Dajmy na to, jeśli w cache umieszczasz stringi, sam określasz ich długość. Czy to jest JEDEN string, czy ten string zajmuje przestrzeń równą ilości znaków.

Czyli sam sobie ustalasz tę jednostkę i musisz się jej spójnie trzymać w systemie.

To teraz kolejne pytanie – czym jest CompactionPercentage?

To jest informacja, o ile procent ma się zmniejszyć cache w momencie przekroczenia limitu.

Czyszczenie cache po przekroczeniu limitu

Weźmy pod uwagę taki kodzik:

for (int i = 0; i < 200; i++)
{
    memoryCache.Set("test" + i, i, new MemoryCacheEntryOptions
    { 
        Size = 1
    });
}

Wcześniej ustawiłem rozmiar cache na 100. Tutaj każdy element ma rozmiar 1. To znaczy, że w cache mogę mieć 100 takich elementów. W momencie, gdy będę chciał dodać kolejny, mechanizm się jorgnie, że cache przekroczyłby swój limit, zatem robi „kompakt”. A więc usuwa inne elementy. Ile? Tyle procent, ile ma określone w CompactionPercentage. Określiłem to na 20%, więc w tym wypadku usunie 20 elementów (bo każdy z nich ma rozmiar 1). A skąd wie, co ma usunąć?

Najpierw usuwa elementy, którym skończył się czas życia (jeśli w ogóle takie są).

Jeśli nadal musi usuwać elementy, to usuwa elementy względem nadanego im priorytetu:

  • Niski – CacheItemPriority.Low
  • Normalny (domyślny priorytet dla CacheEntry) – CacheItemPriority.Normal
  • Wysoki – CacheItemPriority.High

Z każdej tej listy usuwa elementy zgodnie z polityką LRU – Least Recently Used. To znaczy, że zaczyna usuwać najpierw te elementy, które były najdłużej nieużywane.

Mechanizm cache po każdym pobraniu elementu zmienia czas jego ostatniego użycia. To znaczy, że jeśli długo nie odczytujesz jakiegoś elementu, to ostatnio był używany „dawno temu”. Dla cache to informacja, że możliwe że już nie będzie używany.

Więc usuwa te elementy w konkretnej kolejności – od najdłużej nieużywanego (czas ostatniego dostępu najmniejszy).

Rzecz jasna po usunięciu każdego elementu sprawdza, ile odzyskał miejsca. Jeśli dojdzie do tej wartości określonej w CompactionPercentage przestaje usuwać dalej.


Chyba nie ma więcej problemów związanych z tym mechanizmem, jednak jeśli mi coś umknęło koniecznie daj znać w komentarzu.

Dzięki za przeczytanie tego artykułu. Jeśli coś nie jest jasne lub zauważyłeś gdzieś błąd, koniecznie daj znać. A może chcecie więcej artykułów o cache? W sumie i tak zamierzam je napisać 😀

Podziel się artykułem na:
Jak trzymać tokeny w SPA, czyli Backend For Frontend

Jak trzymać tokeny w SPA, czyli Backend For Frontend

Wstęp

Gdy łączymy aplikację frontową (typu SPA) z jakąś formą autoryzacji, np. OAuth, bardzo chętnie trzymamy sobie tokeny autoryzacyjne gdzieś na froncie. Spora ilość odpowiedzi na pytanie „gdzie trzymać tokeny” sugeruje, że to powinien być local storage. A niektóre, że ciastko. Ale odpowiedź jest jedna:

Nigdy nie trzymaj tokenów autoryzacyjnych na froncie.

W tym artykule pokażę jak to zrobić poprawnie – i będziemy mimo wszystko robić tylko backend. Artykuł nie ma nic wspólnego z OAuth, jest bardziej ogólny, ale to co tutaj zrobimy bez problemu nadaje się do zaadaptowania w systemie, w którym dostajemy jakiś token autoryzacyjny.

Przygotowałem też na GitHub prostą implementację tego, co tu robimy. Uwaga! Potraktuj ten kod jako wyznacznik, a nie jako gotową implementację. Ten kod raczej obrazuje jak takie BFF zrobić i należy go dostosować do własnych wymagań. Pobierz go stąd.

NuGet gotowy do użytku

Jeśli chcesz użyć gotowego NuGeta, który zapewnia Ci funkcjonalność BFF, to stworzyłem coś specjalnie na tą okazję. Do pobrania:

dotnet add package Nerdolando.Bff.AspNetCore

No i do poczytania na GitHub: https://github.com/Nerdolando/Nerdolando.Bff

To jest jednocześnie mój pierwszy taki OpenSource, więc zachęcam do współpracy 🙂

Dlaczego nie mogę trzymać tokenu na froncie?

Odpowiedź na to pytanie jest szalenie prosta. Nigdy nie wiesz, jaki kod działa na froncie. I to właściwie tyle. Token autoryzacyjny może zostać wykradziony. Wtedy dupa zbita. Konto może zostać przejęte.

I już widzę te słowa oburzenia: „Jak to nie wiem, jaki kod działa na froncie? W końcu sam go pisałem!”.

Jesteś pewien, że jeśli tworzysz front to sam piszesz kod? 🙂

Przy Blazor jest trochę lepiej, ale jeśli piszesz w JavaScript, używasz różnych bibliotek, które też mają swoje zależności, a te zależności mają inne zależności. Niestety mało kto zwraca uwagę na bezpieczeństwo na etapie zależności bibliotek, chociaż npm ma do tego jakieś narzędzia.

I co w związku z tym? Na jakimś etapie możesz niejawnie wykorzystywać bibliotekę, która jest podatna na różne ataki.

Ale ok, załóżmy że masz super restrykcyjną politykę bezpieczeństwa i każda zależność każdej zależności dla każdej biblioteki którą używasz jest sprawdzana i walidowana. I tu wchodzi rola użytkownika 😉

Użytkownik może mieć zainstalowany w przeglądarce złośliwy lub podatny dodatek. No i sorry – na to już żadnego wpływu nie masz.

Czyli – nigdy nie wiesz, jaki kod jest wykonywany na froncie. A jeśli kod Twojej aplikacji ma dostęp do tokenu, to każdy kod, który działa na Twoim froncie też ma do niego dostęp.

No to gdzie mam trzymać tokeny?

Nigdzie. Jeśli tworzysz aplikację typu SPA nigdy nie trzymaj tokenu. Istnieją dwie inne opcje, którymi możesz się posłużyć.

Service Worker

Service Worker to stosunkowo młody byt. To w gruncie rzeczy jakiś fragment funkcjonalności, który pełni rolę reverse proxy. Ale jeśli chciałbyś prawilnie i bezpiecznie używać np. OAuth (albo innego systemu autoryzacji), to musiałbyś całą funkcjonalność z tym związaną przenieść do Service Workera, co jednak nie jest tak prostym i trywialnym zadaniem.

Nie będziemy się zagłębiać w ten temat, bo jest to blog jednak backendowy, ale jeśli chcesz poczytać więcej na ten temat, to masz tutaj dokumentację: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API

BFF, czyli Backend For Frontend

No i teraz dochodzimy do sedna. BFF to zwykła aplikacja backendowa, która w całym systemie pełni rolę prostego reverse proxy. Zasada jest taka, że to BFF otrzymuje tokeny autoryzacyjne. Co więcej, to BFF komunikuje się z WebApi, które gdzieś tam jest pod spodem, a nie aplikacja frontowa. Podsumowując:

  • BFF to proste reverse proxy
  • Front komunikuje się TYLKO z BFF
  • BFF komunikuje się z serwerem autoryzacyjnym i WebApi
  • BFF przechowuje i/lub szyfruje tokeny
  • Front NIGDY nie komunikuje się z WebApi bezpośrednio

Jeśli weźmiemy sobie pod uwagę autoryzację OAuth, to uwzględniając BFF będzie to wyglądało tak:

Oczywiście istotne jest, że jeśli używamy OAuth, to musimy w tym momencie przestać używać przestarzałego Implicit flow, a zamiast tego użyć prawilnego Authorization Code flow i nasze BFF wtedy przejmuje rolę klienta, a nie apka frontowa.

Po lewej stronie mamy aplikację SPA, po środku nasze BFF, na górze „fabryka” reprezentuje serwer autoryzacyjny, a na niebiesko po prawej stronie przedstawione jest nasze API, do którego dobijamy się tokenem.

I teraz tak:

  1. Aplikacja SPA strzela do serwera autoryzacyjnego po Authorization Code
  2. Serwer autoryzacyjny oddaje SPA authorization code
  3. Aplikacja SPA przekazuje Authorization Code do BFF i BFF rozpoczyna już prawilną sesję logowania
  4. BFF otrzymuje w końcu od serwera autoryzacyjnego Access Token i opcjonalny Refresh Token
  5. BFF albo to sobie gdzieś zapisuje albo szyfruje i następnie tą informację wysyła do frontu jako HTTP Only, Secure cookie.
  6. Aplikacja z każdym żądaniem do BFF dołącza to cookie
  7. BFF odczytuje sobie dane z cookie i pobiera / odszyfrowuje tokeny
  8. BFF dołącza tokeny do requestu wysyłanego do WebApi
  9. BFF wysyła request do WebApi

Jak niby aplikacja SPA ma dołączyć ciastko, które jest HttpOnly?

No to tak. Ciastko HttpOnly to takie ciastko, którego żaden JavaScript nie będzie w stanie odczytać. To jasne – tu jesteśmy bezpieczni. Nie dość, że jeśli access token jest w ciastku, to jest też tak zaszyfrowany, że tylko BFF potrafi go odszyfrować. No i fajnie, bo Access Tokeny nie są przewidziane do używania na froncie. Czyli jesteśmy podwójnie chronieni nawet jeśli ten token jest zapisany w ciastku.

Ale, ale. Na froncie są metody, żeby dołączyć takie ciastko do wysyłanego żądania. Niezależnie od tego, czy używasz fetcha, axiosa, czy czegoś innego, każda z tych bibliotek ma (albo mieć powinna) taką opcję jak credentials, withCredentials, includeCredentials itp. W zależności od biblioteki. Przy takim ustawieniu HttpOnly cookie zostanie dołączone do requesta.

Jedziemy z implementacją

Implementacja tego cuda jest zasadniczo prosta. Istnieją jakieś biblioteki, które nam w tym pomagają (np. Duende coś ma, no i oczywiście moja darmowa wyżej wspomniana: https://github.com/Nerdolando/Nerdolando.Bff), ale polecam napisać własny fragment kodu, który będzie stosowany tylko w naszej apce. Dzięki temu możesz poznać mechanizm bardzo głęboko. Niemniej jednak będziemy posługiwać się biblioteką Yarp.ReverseProxy. (w moim NuGet nie stosujemy całego Yarpa, tylko jakąś jego część).

A więc w pierwszej kolejności utwórz sobie nowy projekt WebApi, z którego zrobimy BFF i zainstaluj ReverseProxy:

dotnet add package Yarp.ReverseProxy

Jeśli nie posługiwałeś się wcześniej tą biblioteką, możesz sobie poczytać więcej w dokumentacji.

W każdym razie takie ReverseProxy trzeba wstępnie skonfigurować, można to zrobić albo przez appsettings albo w kodzie. Ja Ci pokażę to na przykładzie appsettings, żeby nie zaciemniać obrazu.

Konfiguracja proxy

Zrobimy podstawową konfigurację w pliku appsettings.json. Można oczywiście zrobić konfigurację w odpowiednich klasach w runtime, ale na potrzeby prostego BFF taka konfiguracja jest wystarczająca.

"ProxyConfig": {
  "Routes": {
    "ApiRoute": {
      "ClusterId": "ApiCluster",
      "Match": {
        "Path": "/api/{**catch-all}"
      }
    }
  },
  "Clusters": {
    "ApiCluster": {
      "Destinations": {
        "ApiDestination": {
          "Address": "https://localhost:7034/"
        }
      }
    }
  }
}

Co się dzieje w tej konfiguracji?

To najprostsza chyba konfiguracja Reverse Proxy. Czyli: „gdy przyjdzie żądanie na Route o danym adresie (Match/Path), prześlij je na Cluster o danym Id (ClusterId)”.

U nas wszystkie żądania jakie przyjdą do BFF na adres /api/* zostaną przesłane dalej na adres odpowiedniego klastra, czyli w tym przypadku – klaster o Id ApiCluster, który ma zdefiniowany adres: https://localhost:7034. W rzeczywistej aplikacji to będzie adres Twojego WebApi.

Teraz trzeba dodać sobie Proxy do naszej apki. Po zainstalowaniu pakietu Yarp.ReverseProxy, nasz plik Program.cs może wyglądać tak:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

        var app = builder.Build();

        // Configure the HTTP request pipeline.

        app.UseHttpsRedirection();

        app.MapReverseProxy();

        app.Run();
    }
}

Dodajemy sobie serwisy odpowiedzialne za działanie Reverse Proxy (linia 7) wraz z konfiguracją (linia 8) i wpinamy proxy do pipeline (linia 16) – bo tak trzeba.

W tym momencie mamy już działające proxy. Ale to jeszcze nie jest BFF, to jest samo właściwie proxy, które nie robi niczego poza przekazywaniem żądania dalej.

Dodajemy CORSy

Kolejny krokiem jest obsługa CORS. Jeśli nie wiesz, czym jest CORS, miałem o tym artykuł: Ten cholerny CORS dogłębnie.

Teraz metoda Main może wyglądać tak:

public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);

    // Add services to the container.

    builder.Services.AddReverseProxy()
        .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

    var corsOrigins = builder.Configuration.GetSection("CorsOrigins").Get<string[]>()!;
    var corsExposedHeaders = builder.Configuration.GetSection("CorsExposedHeaders").Get<string[]>()!;
    builder.Services.AddCors(o =>
    {
        o.AddDefaultPolicy(builder =>
        {
            builder.AllowAnyMethod()
                .AllowAnyHeader()
                .WithOrigins(corsOrigins)
                .AllowCredentials()
                .WithExposedHeaders(corsExposedHeaders);
        });
    });

    var app = builder.Build();

    // Configure the HTTP request pipeline.

    app.UseHttpsRedirection();
    app.UseCors();

    app.MapReverseProxy();

    app.Run();
}

ZAKŁADAJĄC, że w konfiguracji mamy dozwolone originy, z których przychodzą żądania (sekcja CorsOrigins), musimy je dodać do metody WithOrigins (linia 18). Analogicznie z nagłówkami. Jeśli nasze PROXY może emitować niestandardowe nagłówki, musimy je dodać do WithExposedHeaders.

Tutaj cholernie ważna jest metoda AllowCredentials. Bez tego nie otrzymamy z frontu ciastka HttpOnly. A właściwie żądanie z takim ciastkiem zostanie zablokowane przez politykę CORS, więc zakończy się failem. Dlatego AllowCredentials musi tutaj być.

Przypominam – nie wiesz dokładnie co to CORS? To przeczytaj tutaj.

Transformacje – czyli serce BFF

Ok, mamy już reverse proxy, mamy politykę CORS, ale brakuje nam ostatniej rzeczy.

Jakoś tokeny autoryzacyjne musimy zapisać i jakoś je musimy odczytać. Do tego służą transformacje, które są częścią Yarp.ReverseProxy.

Czym jest transformacja? Generalnie możemy w jakiś sposób zmienić request, który do nas przychodzi (np. z frontu) przed przesłaniem go do WebApi, ale też możemy zmienić odpowiedź, która do nas przychodzi np. z WebApi zanim zostanie przekazana na front.

W związku z tym mamy jakby dwa rodzaje transformacji:

  • RequestTransform
  • ResponseTransform

Weźmy się najpierw za RequestTransform, czyli transformacja, gdzie działamy na danych pochodzących z frontu.

Request Transform

Najpierw musimy stworzyć sobie klasę dziedziczącą po RequestTransform. Pusta wygląda tak:

internal class AccessTokenRequestTransform : RequestTransform
{
    public override async ValueTask ApplyAsync(RequestTransformContext context)
    {
        
    }
}

Metoda ApplyAsync zostanie wywołana, gdy przyjdzie do nas żądanie z frontu, zanim zostanie przekazane dalej. W parametrze context mamy wszystko, co potrzebujemy, żeby bawić się żądaniem.

I w gruncie rzeczy to, jak powinna wyglądać ta metoda dokładnie, to już zależy od konkretnego systemu. Ja Ci podam tylko przykład, który wydaje się być w miarę uniwersalny.

UWAGA! W moim przykładzie trzymam zaszyfrowane tokeny w ciastku. Można też to zrobić inaczej. W ciastku trzymać jakieś Id (zaszyfrowane najlepiej), które doprowadzi do tokenu zapisanego w jakiejś bazie danych (chociażby Redis).

Tutaj scenariusz jest taki, że BFF po otrzymaniu tokenów szyfruje je, wrzuca w ciastko i wysyła na front.

Gdy front chce wysłać żądanie do API, przesyła z nim to ciastko (oczywiście HttpOnly, Secure) i BFF je odczytuje, a więc bardzo abstrakcyjnie:

internal class AccessTokenRequestTransform(IAuthCookieService _authCookieService, 
    IAccessTokenEncoder _tokenEncoder) : RequestTransform
{
    public override async ValueTask ApplyAsync(RequestTransformContext context)
    {
        var cookieValue = _authCookieService.ReadFromRequest(context);
        if (string.IsNullOrEmpty(cookieValue))
            return;

        var tokens = _tokenEncoder.DecodeTokens(cookieValue!);
        if (tokens != null)
            tokens = await RefreshTokensIfNeeded(context, tokens);

        if (tokens != null)
            AddAuthHeader(context, tokens.AccessToken);

        _authCookieService.RemoveAuthenticationCookie(context);
    }

    private Task<AuthTokenModel> RefreshTokensIfNeeded(RequestTransformContext context, AuthTokenModel tokens)
    {
        tokens.ValidTo = tokens.ValidTo.AddMinutes(15); //duży skrót myślowy, tu powinno być prawilne odświeżenie
        return Task.FromResult(tokens);
    }

    private void AddAuthHeader(RequestTransformContext context, string accessToken)
    {
        context.ProxyRequest.Headers.Add("Authorization", $"Bearer {accessToken}");
    }
}

Zadziało się sporo, ale już wszystko tłumaczę.

Na początku wstrzykujemy sobie dwa HIPOTETYCZNE serwisy, których zadaniem jest:

  • IAuthCookieService – odczyt danych z konkretnego ciasteczka
  • IAccessTokenEncoder – szyfrowanie i deszyfrowanie tokenów

Podstawowa implementacja tych serwisów jest zrobiona na GitHub. Nie ma tam niczego ciekawego, więc się tym nie zajmujemy.

I teraz metoda ApplyAsync:

  1. Pobieramy wartość z otrzymanego ciasteczka (powinien tam być zaszyfrowany token).
  2. Jeśli wartości nie ma (nie ma ciasteczka) nie robimy transformacji. Request jest po prostu przekazywany dalej bez żadnych zmian.
  3. Jeśli wartość była, to ją odszyfrowujemy. Po odszyfrowaniu powinniśmy otrzymać jakiś obiekt, który przechowuje token i informacje o nim.
  4. Sprawdzamy, czy token należy odświeżyć. W tym przykładowym systemie token powinniśmy odświeżyć jeśli wygaśnie w ciągu 5 minut. Pamiętaj, że to jest tylko przykład. W Twoim systemie wymaganie może być zupełnie inne. Zwróć też uwagę na metodę RefreshTokensIfNeeded – gdzie pokazałem zupełnie niepoprawne odświeżanie tokenów. Jednak przykładowy kod nie ma żadnego miejsca, które tokeny wydaje, więc potraktuj metodę RefreshTokensIfNeeded jako duży skrót myślowy.
  5. Dalej mamy metodę AddAuthHeader, która dodaje AccessToken do nagłówka autoryzacyjnego. Tutaj to też jest tylko przykład. Ale w prawdziwym systemie tokeny będą pewnie dodawane do jakiegoś nagłówka.
  6. No i na koniec USUWAMY ciasteczko z requestu, bo nie chcemy go przesyłać do WebApi.

Response Transform

W znakomitej większości przypadków nie będziesz potrzebował tej transformacji. Tutaj wchodzi żądanie z WebApi i przekazywane jest na front.

Jednak tokeny autoryzacyjne przeważnie będziesz otrzymywał z innego źródła. Jednak jeśli Twoje WebApi wystawia taki token, w takim przypadku powinieneś utworzyć taką transformację. Tutaj też tylko zarys, bo zasada jest dokładnie taka sama jak wyżej:

internal class AccessTokenResponseTransform(IAccessTokenEncoder _tokenEncoder,
    IAuthCookieService _authCookieService) : ResponseTransform
{
    public override async ValueTask ApplyAsync(ResponseTransformContext context)
    {
        var tokenModel = await ReadTokenModelFromBody(context) ?? context.HttpContext.GetTokens();
        if (tokenModel == null)
            return;

        var cookieValue = _tokenEncoder.EncodeTokens(tokenModel);
        _authCookieService.AddAuthCookieToResponse(context, cookieValue);
    }


    private async Task<AuthTokenModel?> ReadTokenModelFromBody(ResponseTransformContext context)
    {
        if (context.ProxyResponse == null)
            return null;

        if (!context.ProxyResponse.Headers.HasAuthToken())
            return null;

        var result = await context.ProxyResponse.Content.ReadFromJsonAsync<AuthTokenModel>();
        context.SuppressResponseBody = true;
        return result;
    }
}

Przyjrzyjmy się od razu metodzie ApplyAsync.

Ona w jakiś sposób pobiera sobie token, wygenerowany przez WebApi. Taki token może siedzieć albo w BODY, albo w jakimś nagłówku. Stąd najpierw jest próba pobrania go z BODY, a potem z nagłówka.

To teraz spójrz na metodę ReadTokenModelFromBody. Tu się dzieje jedna istotna rzecz.

Odczytujemy dane z BODY. To jest ważne – ODCZYTUJEMY DANE Z BODY. Po takim odczycie koniecznie trzeba przypisać TRUE do SupressResponseBody w kontekście. Jeśli to jest ustawione na TRUE, proxy podczas przesyłania requestu dalej, nie bierze pod uwagę zawartości BODY.

Niestety odczytujemy tutaj całe body, czyli niejako pobieramy je z oryginalnego żądania. Kradniemy je z niego. W oryginalnym żądaniu nie ma już tego body po naszym odczycie, jednak cały czas jest ustawiony nagłówek Content-Length, który mówi o tym, jak długie jest Body. W efekcie wszystko wybuchnie 🙂

To jest pewna pułapka biblioteki Yarp.ReverseProxy. Jeśli musisz odczytać body, żeby przykładowo pobrać jakąś wartość, ale chcesz żeby body było później przesłane dalej, to musisz je na nowo zapisać.

W przypadku tego kodu nie ma takiej potrzeby. Odczytujemy Body i jesteśmy z tym ok, że dalej nie pójdzie. Bo w MOIM systemie ja wiem, że jeśli metoda z linii 26: context.ProxyResponse.Headers.HasAuthToken() zwróci mi TRUE, to znaczy, że w body mam TYLKO tokeny autoryzacyjne.

Oczywiście to jest domena TYLKO I WYŁĄCZNIE mojej aplikacji i moich założeń. A czym jest metoda HasAuthToken? To jakieś rozszerzenie, które może sprawdzać, czy istnieje jakiś nagłówek w żądaniu. Czyli coś w stylu: „Jeśli w żądaniu występuje nagłówek MOJA-APKA-MAM-TOKEN, to znaczy, że w body znajduje się token autoryzacyjny. Chcę, żeby to dobrze wybrzmiało.

To teraz wróćmy do głównej metody – ApplyAsync:

    public override async ValueTask ApplyAsync(ResponseTransformContext context)
    {
        var tokenModel = await ReadTokenModelFromBody(context) ?? context.HttpContext.GetTokens();
        if (tokenModel == null)
            return;

        var cookieValue = _tokenEncoder.EncodeTokens(tokenModel);
        _authCookieService.AddAuthCookieToResponse(context, cookieValue);
    }

Co tu się konkretnie dzieje?

  1. Pobieramy sobie token z body lub jakiegoś nagłówka.
  2. Jeśli istnieje, to szyfrujemy go.
  3. Tworzymy ciasteczko z tym zaszyfrowanym tokenem i dodajemy je do żądania, które jest dalej przesyłane na front.

I to w zasadzie tyle.

Rejestracja transformacji

Rejestracja transformacji nie jest taka oczywista w Yarp.ReverseProxy. Można to zrobić na kilka sposobów. Ale jeśli używamy dependency injection w naszych transformacjach, powinniśmy stworzyć sobie jeszcze jedną klasę: TransformProvider:

internal class TransformProvider : ITransformProvider
{
    public void Apply(TransformBuilderContext context)
    {
        var requestTransform = context.Services.GetRequiredService<AccessTokenRequestTransform>();
        var responseTransform = context.Services.GetRequiredService<AccessTokenResponseTransform>();
        context.RequestTransforms.Add(requestTransform);
        context.ResponseTransforms.Add(responseTransform);
    }

    public void ValidateCluster(TransformClusterValidationContext context)
    {

    }

    public void ValidateRoute(TransformRouteValidationContext context)
    {

    }
}

Wystarczy zaimplementować interfejs ITransformProvider i wypełnić metodę Apply. Potem tworzymy nasze transformacje (w tym przypadku pobieramy z dependency injection) i dodajemy je do odpowiednich kolekcji. Bo transformacji możemy mieć wiele – zarówno dla requestu jak i dla responsu.

Na koniec trzeba to porejestrować:

builder.Services.AddSingleton<AccessTokenRequestTransform>();
builder.Services.AddSingleton<AccessTokenResponseTransform>();
builder.Services.AddReverseProxy()
    .AddTransforms<TransformProvider>()
    .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

W przykładowej solucji na GitHub są dwa projekty, które muszą zostać uruchomione razem – BFF i WebApi. W projekcie BFF w pliku BFF.http jest napisane podstawowe żądanie, które możesz uruchomić, żeby zobaczyć sobie jak to wszystko działa.

Zachęcam Cię do pobrania sobie tego „szkieletu” i pobawienie się trochę nim.


Jeśli pracujesz (albo jesteś częścią teamu) nad aplikacją SPA i jest tam jakaś forma autoryzacji z tokenami, to BFF jest poprawną drogą. Jest to również oficjalne zalecenie w OAuth, żeby używać BFF przy aplikacji typu SPA.

Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie zrozumiałeś lub zauważyłeś gdzieś błąd, koniecznie daj znać w komentarzu 🙂

Jeśli masz dodatkowe pytania – też napisz. Z chęcią odpowiem.

Podziel się artykułem na:
Przegląd typów kolekcji – ściąga

Przegląd typów kolekcji – ściąga

Wstęp

Pomijam fakt, że większość osób, którym robiłem techniczne review, jedyne typy kolekcji jakie znała to List<T>, Dictionary<K, V> i to właściwie wszystko (czasem jeszcze HashSet), to jest jeszcze jeden problem. Różne typy kolekcji są lepsze w niektórych przypadkach, a gorsze w innych. Głównie chodzi tutaj o performance. Ja wiem… Nie optymalizujemy za wcześnie. Niemniej jednak warto wiedzieć, które kolekcje sprawdzają się lepiej w konkretnych sytuacjach.

U jednego ze swoich klientów, zmieniając tylko typy niektórych kolekcji, udało mi się zauważalnie skrócić czas głównej operacji.

W tym artykule robię zatem krótki przegląd większości kolekcji, jakie mamy do dyspozycji.

Dla maruderów

Nie opisuję tutaj rzeczy takich jak BitArray (i podobnych) ani kolekcji niegenerycznych. Niektóre klasy są wysoko wyspecjalizowane do konkretnych zadań (jak np. BitArray), a kolekcji niegenerycznych… po prostu nie powinniśmy raczej używać.

Jakie mamy kolekcje?

Pierwszy podział będzie ze względu na „thread safety”.

Kolekcje dla scenariuszy jednowątkowych

List<T>

To zdecydowanie najbardziej znana kolekcja w C#. Lista to po prostu tablica na sterydach i nic więcej. Elementy listy zajmują ciągły obszar w pamięci. Lista na dzień dobry rezerwuje sobie pewien obszar o wielkości określonej w Capacity (capacity to ilość elementów, jakie chcemy mieć, a nie wielkość wyrażona w bajtach). Gdy dodajemy nowe elementy i Capacity się kończy, wtedy lista rezerwuje nowy obszar w pamięci o rozmiarze Capacity * 2 (szczegół implementacyjny) i kopiuje tam swoje elementy. Itd. To oznacza, że dodawanie elementów do listy w pewnych sytuacjach może być kosztowne. Niemniej jednak jest najpopularniejszą kolekcją. Dostęp do elementu mamy przez jego indeks.

W skrócie:

  • kolejność elementów zachowana
  • elementy umieszczone w ciągłym miejscu w pamięci
  • może zawierać duplikaty
  • łatwy dostęp do poszczególnych elementów za pomocą indeksu

SortedList<K, V>

Lista sortowana. Coś w stylu połączenia List<V> z SortedDictionary<K, V>, chociaż nieco bliżej jej do słownika niż do listy. Elementy znajdują się wciąż w tablicach, czyli zajmują ciągłe miejsce w pamięci. Ale uwaga, kolekcja tak naprawdę zawiera dwie tablice – jedna trzyma klucze, druga wartości. Oczywiście wszystko ze sobą jest sprytnie zsynchronizowane. Uważam to jednak za szczegół implementacyjny, który być może kiedyś ulegnie zmianie.

Elementy są sortowane za pomocą klucza, czyli klucz powinien implementować interfejs IComparable<K>.

W skrócie:

  • elementy sortowane na podstawie klucza
  • elementy zajmują ciągłe miejsce w pamięci (osobne tablice dla kluczy i wartości)
  • nie może zawierać duplikatów
  • łatwy dostęp do poszczególnych elementów za pomocą indeksu

LinkedList<T>

To jest prawilna lista dwukierunkowa (kłaniają się struktury danych :)). Składa się z elementów (LinkedListNode<T>), które oprócz konkretnej wartości trzymają jeszcze referencje do następnego i poprzedniego elementu. Przez co pobieranie konkretnego elementu, np. 5tego może być nieco problematyczne, bo tak naprawdę wymaga przejścia przez wszystkie poprzednie elementy, aby uzyskać ten nas interesujący.

W skrócie:

  • kolejność elementów zachowana
  • elementy umieszczone w różnych miejscach w pamięci
  • może zawierać duplikaty
  • dostęp do poszczególnych elementów jest łatwy, ale może być wolniejszy

ObservableCollection<T>

To bardzo dobrze znają osoby piszące w WPF. Ta kolekcja po prostu daje Ci znać, kiedy coś się w niej zmieni. Tzn. element zostanie dodany, usunięty lub zamieniony. Pod spodem jest zwykła lista.

Queue<T>, Stack<T>

To po prostu typowa kolejka FIFO (queue) i stos LIFO (stack). Przydają się wtedy, kiedy chcemy się pozbyć elementu po zdjęciu go z kolekcji i chcemy mieć zapewniony konkretny porządek.

W skrócie:

  • queue to kolejka FIFO, a stack to stos LIFO
  • elementy umieszczone w ciągłym miejscu w pamięci
  • może zawierać duplikaty
  • element jest automatycznie zdejmowany po odczytaniu go z kolejki (stosu)

PriorityQueue<T, P>

Kolejka priorytetowa. Zawiera elementy typu T i priorytet typu P. Przydatna kiedy chcemy niektóre elementy w kolejce traktować priorytetowo.

W skrócie:

  • kolejka z priorytetem
  • brak kolejności elementów
  • może zawierać duplikaty
  • element jest automatycznie usuwany po odczytaniu go z kolejki

Dictionary<K, T>

Słownik to kolekcja, która przetrzymuje obiekty na podstawie jakiegoś klucza. Klucz jest obliczany za pomocą metody GetHashCode z obiektu typu K. A wartości to typy T. Wyróżnia się szybkim dostępem do danych. Oczywiście to jeszcze zależy jak napiszesz swoją metodę GetHashCode, ale trzymając się standardowych podejść, wszystko powinno być dobrze 🙂

Słownik może mieć tylko jeden wpis z danym kluczem. Jeśli spróbujemy dodać kolejny z kluczem, który już istnieje w słowniku, rzucony będzie wyjątek.

UWAGA! NIGDY nie przechowuj wartości zwracanej z GetHashCode między uruchomieniami aplikacji. Po zmianie wersji .NET (nawet minor albo build) może okazać się, że GetHashCode zwraca inne wartości. Microsoft nie daje gwarancji, że tak nie będzie. A i ja się miałem okazję kiedyś przekonać o tym, że nie można na tym polegać. GetHashCode jest użyteczne tylko w tej samej wersji frameworka.

W skrócie:

  • brak kolejności elementów
  • elementy umieszczone w różnych miejscach w pamięci
  • nie może zawierać duplikatów
  • szybki dostęp do elementu po kluczu

ReadOnlyDictionary<K, T>

Daję go w sumie jako ciekawostkę. To słownik tylko do odczytu. Każda zmiana elementów po utworzeniu tego słownika kończy się rzuceniem wyjątku. Ja zdecydowanie wolę posługiwanie się odpowiednim interfejsem (np. IReadOnlyDictionary) niż tym słownikiem.

SortedDictionary<K, T>

To po prostu słownik, który jest posortowany. Każdy element w słowniku jest sortowany na podstawie klucza. Jeśli klucz implementuje interfejs IComparable<K>, wtedy ten właśnie komparator jest używany do porównania wartości. Sortowanie odbywa się automatycznie po zmianie na liście elementów. Natomiast klucz musi być niezmienny.

Potrzebuje nieco więcej pamięci niż SortedList, za to operacje dodawania i usuwania elementów są szybsze.

W skrócie:

  • elementy są posortowane względem klucza
  • elementy umieszczone w różnych miejscach w pamięci
  • nie może zawierać duplikatów
  • szybki dostęp do elementu po kluczu

FrozenDictionary<K, V>

To jest nowość w .NET 8. Pisałem już o tym w tym artykule. Generalnie jest to słownik tylko do odczytu, który jest zoptymalizowany pod kątem szukania elementów. I naprawdę daje radę. Zresztą zobacz sobie porównanie w moim artykule o nowościach w .NET 8.

W skrócie:

  • brak kolejności elementów
  • elementy umieszczone w różnych miejscach w pamięci
  • nie może zawierać duplikatów
  • szybki dostęp do elementów po kluczu (szybszy niż w Dictionary<K, V>)
  • TYLKO DO ODCZYTU

HashSet<T>

To jest zbiór niepowtarzalnych elementów o typie T. „Powtarzalność” jest sprawdzana na podstawie metody GetHashCode. Można go uznać za coś w rodzaju słownika bez wartości.

W skrócie:

  • brak kolejności elementów
  • elementy umieszczone w różnych miejscach w pamięci
  • nie może zawierać duplikatów
  • szybki dostęp po kluczu

SortedSet<T>

To, podobnie jak HashSet, też jest formą zbioru. Ale SortedSet to struktura drzewa binarnego (czerwono-czarne drzewo binarne). Elementy są posortowane zgodnie z przekazanym w konstruktorze Comparerem (lub domyślnym, jeśli nie przekazano).

Utrzymuje porządek sortowania bez dodatkowego narzutu podczas dodawania lub usuwania elementów. Nie daje jednak bezpośredniego dostępu do konkretnego elementu. Są oczywiście rozszerzenia, które to umożliwiają, np. IEnumerable.ElementAt, jednak związane jest to z przechodzeniem przez drzewo. Zatem używaj tylko wtedy, jeśli potrzebujesz mieć posortowane elementy, gdzie dodawanie i usuwanie nie daje dodatkowego narzutu.

  • elementy posortowane według przekazanego lub domyślnego Comparera
  • elementy umieszczone w różnych miejscach w pamięci
  • nie może zawierać duplikatów
  • brak bezpośredniego dostępu do konkretnego elementu
  • struktura drzewiasta

FrozenSet<T>

Analogicznie jak FrozenDictionary. Tylko reprezentuje HashSet zoptymalizowany do odczytu.

W skrócie:

  • brak kolejności elementów
  • elementy umieszczone w różnych miejscach w pamięci
  • nie może zawierać duplikatów
  • szybki dostęp do elementów po kluczu (szybszy niż w HashSet<T>)
  • TYLKO DO ODCZYTU

Immutable*<T>

Jest do tego kilka kolekcji niezmienialnych, które są oparte dokładnie na tych wymienionych wyżej. Jednak… nie można ich zmienić. To znaczy, że jeśli np. umożliwiają dodawanie lub usuwanie elementów, to tworzą i zwracają tym samym zupełnie nową kolekcję, a oryginalna pozostaje bez zmian. Czyli przy każdym dodaniu/usunięciu, cała kolekcja jest kopiowana.

Kolekcje dla scenariuszy wielowątkowych

Tutaj najpierw słowo wstępu. Istnieje namespace System.Collections.Concurrent i to klasami z tego namespace powinniśmy się posługiwać jeśli chodzi o wielowątkowość. Są jeszcze inne kolekcje w innych namespace’ach (starszych), jak np. SynchronizedCollection opisane niżej. Ale tych raczej powinniśmy unikać, chyba że naprawdę z jakiegoś powodu musimy tego użyć.

SynchronizedCollection<T>

Czuję się trochę w obowiązku wspomnieć o tym starym tworze. TEORETYCZNIE to coś w rodzaju Listy do scenariuszy wielowątkowych. Jednak jego „thread-safe” ogranicza się tylko do tego, że każda operacja jest zamknięta w instrukcji lock. A blokowany jest obiekt dostępny przez SyncRoot.

Ostatecznie może to prowadzić do problemów w sytuacjach wielowątkowych, ponieważ operacje nie są atomowe. Czyli jeśli przykładowo próbujesz wykonać dwie operacje, które zasadniczo logicznie są jedną, wtedy może to prowadzić do poważnych problemów. Przykład?

if(!collection.Contains(element))
    collection.Add(element);

Tutaj po wywołaniu metody Contains, wątek może się przełączyć i ostatecznie możesz skończyć z kilkoma takimi samymi elementami w kolekcji.

ConcurrentBag<T>

O, to chyba zasługuje na osobny artykuł. Ale krótko mówiąc, to taka kolejka, która działa najlepiej we wzorcu Producer-Consumer. Słowo „kolejka” jest tutaj bardzo dobrym wyznacznikiem, bo ConcurrentBag to kolejka. Tyle że to nie jest to ani LIFO, ani FIFO – ot taka kolejka o nieokreślonej kolejności 🙂

Kilka wątków (A, B, C) może wkładać do niej elementy. Kolejka ma swoją własną listę elementów dla każdego wątku. Jeśli teraz pobieramy z niej elementy w wątku A, a ConcurrentBag nie ma elementów dla wątku A, wtedy „kradnie” element z puli innego wątku. Tak to mniej więcej można opisać. Więc najlepiej sprawdza się, gdy wątek często obsługuje własne zadania. Wtedy jest najbardziej efektywny.

W skrócie:

  • brak zachowania kolejności elementów
  • elementy umieszczone w różnych miejscach w pamięci
  • może zawierać duplikaty
  • brak dostępu do konkretnego elementu, można jedynie pobrać kolejny element z kolejki

ConcurrentStack<T>, ConcurrentQueue<T>

To zasadniczo są odpowiedniki zwykłego Stack i Queue, tyle że wątkowo bezpieczne. Tzn., że różne wątki mogą zapisywać i odczytywać elementy z kolejek w tym samym czasie. Oczywiście elementy będą zdejmowanie zgodnie z porządkiem kolejki.

W skrócie:

  • queue to kolejka FIFO, a stack to stos LIFO
  • elementy umieszczone w różnych miejscach w pamięci – w przeciwieństwie do jednowątkowych odpowiedników
  • może zawierać duplikaty
  • element jest automatycznie zdejmowany po odczytaniu go z kolejki (stosu)

BlockingCollection<T>

To też jest rodzaj kolejki, ale nie musi. W swojej domyślnej postaci, w środku ma ConcurrentQueue<T>. Podczas tworzenia można jej podać maksymalną ilość elementów. Wtedy, jeśli jakiś wątek będzie chciał dodać kolejny element (gdy już cała kolekcja jest zapełniona), wątek zostanie wstrzymany aż inny pobierze coś z kolekcji i zwolni miejsce. W drugą stronę zadziała to podobnie – jeśli wątek będzie chciał pobrać coś z pustej kolekcji, to będzie czekał.

Podczas tworzenia kolekcji można podać dowolny „kontener” dla itemów, który implementuje interfejs IProducerConsumerCollection<T>.

W skrócie:

  • elementy mogą być różnie umieszczone w zależności od przekazanego wewnętrznego kontenera – domyślnie jest to ConcurrentQueue
  • może zawierać duplikaty (jeśli wewnętrzny kontener na to pozwala)
  • brak dostępu do dowolnego elementu – można tylko ściągnąć aktualny element

UWAGA! Tutaj występuje jeszcze taka metoda jak CompleteAdding(). Wywołanie jej mówi kolekcji: „Słuchaj misiu, już nikt Ci nigdy niczego nie doda, a jeśli będzie próbował, zdziel go w twarz wyjątkiem”. To kwestia optymalizacyjna. Jeśli kolekcja jest oznaczona w ten sposób, wtedy wątek, który próbuje coś pobrać przy pustej kolekcji, nie będzie czekał, bo wie, że już nic nie zostanie dodane.

ConcurrentDictionary<K, T>

Nie ma co tu dużo mówić. To po prostu zwykły słownik przeznaczony do scenariuszy wielowątkowych. Różne wątki mogą jednocześnie zapisywać i odczytywać elementy. Poza tym niczym szczególnym nie różni się od zwykłego Dictionary<K, T>.


To tyle jeśli chodzi o standardowe kolekcje, których możemy używać. Dzięki za przeczytanie artykułu. Jeśli czegoś nie zrozumiałeś albo znalazłeś w tekście jakiś błąd, koniecznie daj znać w komentarzu.

A, no i koniecznie podziel się tym artykułem z osobami, które znają jedynie listę i słownik 😉

Podziel się artykułem na:
Czym jest ten Span<T>?

Czym jest ten Span<T>?

Wstęp

W pewnym momencie powstał (a właściwie „został odkryty”) typ Span<T>. Nagle Microsoft zaczął go wszędzie używać, twierdząc że jest zajebisty. No dobra, ale czym tak naprawdę jest ten Span? I czy faktycznie jest taki zajebisty? Zobaczmy.

Jedno jest pewne. Używasz go, być może nawet o tym nie wiedząc (jest rozlany wszędzie we Frameworku).

Czym jest Span<T>?

Żeby była jasność. Span to NIE JEST kontener.

W zasadzie Span<T> to takie oczko, które gapi się na przekazany fragment pamięci:

I właściwie mógłbym na tym artykuł skończyć, bo obrazek powyżej idealnie oddaje sens i działanie Spanu. Ale ok, jeśli chcesz więcej, to czytaj dalej 🙂

Przede wszystkim musisz zrozumieć różnice między stosem i stertą. Jeśli nie jesteś pewien, koniecznie przeczytaj ten artykuł, w którym nieco nakreślam sytuację.

Span jako wskaźnik

Span<T> jest w pewnym sensie wskaźnikiem w świecie zarządzanym. Wskazuje na pewien obszar pamięci na stercie (zarządzanej i niezarządzanej). Ale może też wskazywać na pamięć na stosie. Pamiętaj, że jako wskaźnik, sam Span jest alokowany na stosie i zasadniczo składa się z dwóch elementów:

  • adresu miejsca, na który wskazuje
  • wielkości tego miejsca

Można by powiedzieć, że Span<T> wygląda mniej więcej tak:

public readonly ref struct Span<T>
{
    private readonly ref T _pointer;
    private readonly int _length;
}

Tak, mniej więcej taka jest zawartość spanu (+ do tego kilka prostych metod i rozszerzeń). Z tego wynika pierwsza rzecz:

  • Span jest niemutowalny (immutable) – raz utworzonego Spanu nie można zmienić. Tzn., że raz utworzony Span zawsze będzie wskazywał na to samo miejsce w pamięci.

W pamięci może to wyglądać mniej więcej tak:

Na stosie mamy dwie wartości, które składają się na Span. Pointer, jak to pointer, wskazuje na jakiś obszar w pamięci na stercie, natomiast length posiada wielkość tego obszaru. A co to za obszar? Ten, który wskażesz przy tworzeniu Spana.

Uniwersalność tworzenia

Utworzyć Spana możemy właściwie ze wszystkiego. Z tablicy, z listy, z Enumerable, a nawet ze zwykłego niskopoziomowego wskaźnika. To jest jego główna moc i jeden z powodów jego powstania.

I, powtarzam – Span to NIE JEST żadna kolekcja. To NIE JEST tablica. Span nie służy do przechowywania danych, tylko pokazuje Ci fragment pamięci z tymi danymi, które zostały zaalokowane gdzieś wcześniej.

A po co to?

Wyobraź sobie teraz, że masz listę intów, której elementy chcesz zsumować. Możesz przecież zrobić coś takiego:

static void Main(string[] args)
{
    List<int> list = [1, 2, 3];
    var value = Sum(list);
}

private static int Sum(List<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

I to zadziała super. Masz metodę, która sumuje jakieś dane.

Ok, a teraz załóżmy, że w pewnym momencie ktoś zamiast listy chce dać Ci tablicę. I czym to się kończy? Czymś takim:

static void Main(string[] args)
{
    int[] arr = [1, 2, 3];
    var value = Sum(arr.ToList());
}

private static int Sum(List<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

Czy to jest w porządku?

Spójrz co się dzieje w linii 4. TWORZYSZ listę. W sensie dosłownym – tworzysz zupełnie nowy obiekt (wywołując ToList()). Wszystkie wartości tablicy są kopiowane i jest tworzona nowa lista, która będzie obserwowana przez Garbage Collector na stercie zarządzanej.

Czyli masz już dwa obiekty obserwowane na tej stercie – tablicę i listę. Pamiętaj, że tworzenie obiektów na stercie jest stosunkowo kosztowne. Poza tym, gdy pracuje Garbage Collector, Twoja aplikacja NIE pracuje, tylko czeka.

I teraz z pomocą przychodzi Span:

static void Main(string[] args)
{
    int[] arr = [1, 2, 3];
    var span = new Span<int>(arr);
    var value = Sum(span);
}

private static int Sum(Span<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

Co się dzieje w tym kodzie? Przede wszystkim zwróć uwagę, że metoda Sum w ogóle się nie zmieniła (poza typem argumentu, który przyjmuje). Span daje Ci możliwość iterowania bez żadnych przeszkód.

To, co zostało zmienione, to zamiast Listy na stercie, utworzony został Span. A gdzie? Na STOSIE! Ponieważ, jak pisałem na początku – Span to STRUKTURA i jako taka jest tworzona na stosie. Nie ma też narzutu związanego z kopiowaniem danych i tworzeniem obiektu na stercie.

Dlaczego nie ma narzutu związanego z kopiowaniem danych? Bo nic nie jest kopiowane – powtarzam – Span to wskaźnik – on wskazuje na obszar zajmowany przez dane w tablicy arr. I to jest też mega ważne – wskazuje na konkretne dane, a nie na cały obiekt. Innymi słowy, wskazuje na miejsce arr[0]. I to jest właśnie druga główna supermoc Spana (tak samo wskaże na początek danych listy itd).

Porównanie

Zróbmy sobie teraz małe porównanie. Span idealnie działa ze stringami, gdzie widać jego moc już na pierwszy rzut oka. Więc napiszmy sobie prostą apkę, która zwróci ze stringa jakieś obiekty. String będzie w tej postaci:

string data = "firstname=John;lastname=Smith";

Stwórzmy też wynikową klasę:

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

Aby wykonać to zadanie bez użycia Spana, napisalibyśmy kod mniej więcej taki:

private static Person ProcessDataUsingString(string data)
{
    var values = data.Split(";"); //alokacja dwóch nowych stringów i tablicy
    var firstName = values[0].Substring(values[0].IndexOf('=') + 1); //alokacja nowego stringu
    var lastName = values[1].Substring(values[1].IndexOf('=') + 1); //alokacja nowego stringu

    return new Person
    {
        FirstName = firstName,
        LastName = lastName
    };
}

Natomiast ze Spanem mogłoby to wyglądać tak:

private static Person ProcessDataUsingSpan(string data)
{
    var span = data.AsSpan();

    var fNameValues = span.Slice(0, span.IndexOf(';'));
    var lNameValues = span.Slice(span.IndexOf(';') + 1);

    var firstName = fNameValues.Slice(fNameValues.IndexOf('=') + 1);
    var lastName = lNameValues.Slice(lNameValues.IndexOf('=') + 1);

    return new Person
    {
        FirstName = firstName.ToString(), //alokacja nowego stringu
        LastName = lastName.ToString() //alokacja nowego stringu
    };
}

Zrobiłem Benchmark dla jednego takiego rekordu:

MethodMeanErrorStdDevRatioGen0AllocatedAlloc Ratio
ProcessWithString48.81 ns0.231 ns0.193 ns1.000.0421264 B1.00
ProcessWithSpan26.69 ns0.534 ns0.525 ns0.550.0179112 B0.42

Jak widać Span jest zdecydowanie bardziej wydajny. Nie tylko pod względem czasu wykonania, ale i alokacji pamięci. Właściwie jedyne alokacje, jakie się tu odbyły są już podczas tworzenia nowego obiektu Person – gdy przypisywane są konkretne nowe stringi do obiektu.

Span tylko do odczytu

Zwykły Span pozwala na zmianę konkretnych danych w pamięci. A jeśli jednak chciałbyś użyć jego bezpieczniejszej wersji, to możesz ReadOnlySpan. Działa dokładnie tak samo, tylko nie umożliwia zmiany danych.

I ta właśnie wersja jest zwracana przez stringa – czyli wciąż nie możesz zmienić raz utworzonego stringa (bez użycia kodu unsafe i niskopoziomowych wskaźników).

Możesz mieć tu teraz mały mindfuck – jak to span jest niemutowalny, ale można zmienić mu dane?

Span jest niemutowalny pod tym względem, że raz utworzony zawsze będzie wskazywał na ten sam fragment pamięci. Ale to, co jest w tej pamięci możesz zmienić (chyba że masz ReadOnlySpan). Zobaczysz to dalej w artykule.

Na co nie pozwala?

Nie można zmienić stringu

Jak już pisałem, Span nie pozwala na zmianę elementów stringu. String jest zawsze niemutowalny i nic na to nie poradzisz (poza niskopoziomowymi wskaźnikami).

Nie może być częścią klasy

Z tego powodu, że Span MUSI BYĆ alokowany na stosie, nie może być elementem klasy. W sensie właściwością, czy polem. Elementy klasy mogą być alokowane na stercie, a Span nie może, bo ma to zabronione. Za to Span może być częścią ref struct. Ale w takiej sytuacji musisz uważać.

Musisz używać go mądrze

Pamiętasz jak pisałem, że raz utworzony Span zawsze będzie wskazywał dokładnie to samo miejce w pamięci? Musisz wiedzieć, co masz w tym miejscu, żeby nie doszło do wykrzaczenia:

byte[] arr = [1, 2, 3];
var span = new Span<byte>(arr);
arr = null!;

GC.Collect();

span[0] = 10;

Spójrz, najpierw alokujemy pamięć na tablicę. Na stosie znajduje się adres do elementów tej tablicy (czyli zmienna arr). Tworzymy sobie Span dla tej tablicy – Span teraz wskazuje na elementy tablicy na stercie.

Następnie usuwamy naszą tablicę – w efekcie nic tego miejsca już nie używa. Garbage Collector w pewnym momencie może je wyczyścić albo mogą zostać tam zaalokowane inne rzeczy. Natomiast Span cały czas wskazuje na tę pamięć. I przypisując jakąś wartość możesz doprowadzić do Access Violation.

Więc pod tym względem musisz być uważny.

Nie możesz go używać w asynchronicznych operacjach

Jako, że każdy wątek ma swój oddzielny stos, a Stack jest alokowany na stosie, to nie może być używany w operacjach asynchronicznych. Jeśli potrzebujesz czegoś takiego, użyj Memory<T>. O tym może też coś napiszę.

Czy Span jest zajebisty?

To w sumie tylko narzędzie. Odpowiednio użyte może sprawdzić, że aplikacje w C# będą naprawdę wydajne. Niemniej jednak cieszę się, że coś takiego powstało, ponieważ to jest jedna z rzeczy, których brakowało mi trochę, a które używałem w innych językach. Czyli wskaźniki. I to bez użycia kodu unsafe 🙂


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

Podziel się artykułem na: