Software Development

Caching w ASP .Net Core 3.1 oraz Redis

Październik 1, 2020 0
Podziel się:

Ostatnio dostałem kilka projektów – w kilku z nich miałem wprowadzić caching z powodu długiego oczekiwania na odpowiedź z serwera. W innych przypadkach – zacząłem testować zużycie pamięci i procesora, długość odpowiedzi i częstotliwość pobierania tych samych danych z bazy. W konsekwencji, zaproponowane przeze mnie dodanie cachingu do aplikacji i zalety z tego płynące zostały chętnie przyjęte jako dalsze zadania do zrealizowania w projekcie. Mimo zwiększonego budżetu projektów końcowy efekt był lepszy dla biznesu i dawało to większe zadowolenie klienta.

Caching

Czym jest Caching? Jest to technika przechowywania często pobieranych danych z wolniejszych mediów na szybsze. Dane te pobierane są np. z bazy danych, dysku lub innego magazynu i zapisane do miejsca, które jest szybsze – najczęściej jest to pamięci RAM. Dzięki temu odpowiedzi na zapytania do serwera mogą zostać obsłużone szybciej i zużyć mniej mocy operacyjnej.

Dane w projektach możemy przechowywać zarówno po stronie klienta w przeglądarce/aplikacji oraz po stronie serwera.

Po stronie przeglądarki dane możemy zapisywać do localStorage, sessionStorage lub IndexedDB dla większej ilości danych. Taki ich zapis ma swoje zalety, ale także wady. Odciążony będzie serwer aplikacji, minusem jednak będzie zaistnienie sytuacji, w której użytkownik posiada już przestarzałe dane.

W tym artykule skupimy się na cachingu po stronie serwera w technologii ASP .NET Core. Wyróżniamy w niej dwa typy przechowywania danych:

  • In-Memory Caching – dane są zachowane w pamięci RAM w serwerze i przypisane do procesu aplikacji,
  • Distributed Caching – dane są zachowane na zewnętrznym serwerze.

Caching w projektach C#

In-Memory Cache

Użycie klasy MemoryCache w ASP .Net Core wymaga instalacji pakietu Microsoft.Extensions.Caching.Memory do projektu. Pozwala on na łatwy dostęp do pamięci RAM komputera, zapis i odczyt do niej. Należy pamiętać że mamy dostęp tylko do obszaru przydzielonego dla naszego procesu.

Aby móc wykorzystać klasę MemoryCache w programie, należy w klasie Startup ustawić:

        public void ConfigureServices(IServiceCollection services)
        {
            …
            services.AddMemoryCache();
 
            …
        }

W kontrolerze można użyć DI. Do tego należy dodać w konstruktorze jako parametr IMemoryCache:

        …
        private IMemoryCache _cache;
 
        public WeatherForecastController(IMemoryCache memoryCache)
        {
            _cache = memoryCache;
        }
        …

Przez konstruktor, w 4 linii, zostaje wstrzyknięty obiekt MemoryCache i zostaje on przypisany do prywatnej zmiennej klasy kontrolera.

Przykładowa implementacja w metodzie może wyglądać następująco:

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            List<WeatherForecast> forecasts = null;
 
            if (!_cache.TryGetValue(CacheKey, out forecasts))
            {
                forecasts = new ForecastService().GetForecast().ToList();
 
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetAbsoluteExpiration(TimeSpan.FromMinutes(15));
 
                _cache.Set(CacheKey, forecasts.ToList(), cacheEntryOptions);
            }
 
            return forecasts;
        }

Należy pamiętać, że przy dobrej architekturze – takie operacje będą znajdować się w odpowiednich klasach serwisowych lub fabrykach.

Implementacja wykonuje swoje zadanie. W metodzie Get najpierw jest zapytanie do pamięci o dane (w linii 6). W przypadku ich braku zostają one pobrane z serwisu (linia 7). W środku warunku zostaje utworzony obiekt z opcjami dla wpisu do MemoryCache, następnie jest on przypisany przez metodę Set.

Problemem w powyższym kodzie jest safe-thread dla operacji odczytu i zapisu danych, aby temu zapobiec należy użyć obiektu dla blokady operacji. Zostaje ona dodana do głównych zmiennych klasy jako statyczną.

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
    List<WeatherForecast> forecasts = null;
 
    _cache.TryGetValue(CacheKey, out forecasts);
    if (forecasts != null)
        return forecasts;
 
    lock (lockObject)
    {
        _cache.TryGetValue(CacheKey, out forecasts);
 
        if (forecasts == null)
        {
            forecasts = new ForecastService().GetForecast().ToList();
            _cache.Set(CacheKey, forecasts.ToList(), TimeSpan.FromMinutes(15));
        }
    }
 
    return forecasts;
}

W liniach od 4 do 6 jest próba odczytania danych z pamięci Cache, następnie zwrócić je jeśli istnieją, jeśli nie – w linii 8 obiekt (lockObject) jest blokowany. W środku zostaje jeszcze raz sprawdzone czy inny wątek dodał już dane do pamięci. Następnie dane są pobierane z serwisu (linia 14) i zapisane do Cache’a (linia 15).

Plusy rozwiązania przechowywania danych przez klasę MemoryCache to:

  • Łatwa implementacja (nie wliczając w to blokad na bezpieczeństwo wątków),
  • dobrze pasujące do małych aplikacji,
  • nie są wymagane dodatkowe serwery czy ich ustawienia.

In-Memory Cache posiada także swoje wady, którymi są :

  • Konieczność limitowania i poprawnego konfigurowania ilości zajętej pamięci, w przeciwnym przypadku pamięć serwera może zostać szybko zajęta i spowolni to aplikację zamiast ją przyspieszyć,
  • małe możliwości podczas skalowania aplikacji,
  • domyślna implementacja (nawet z metodą GetOrCreate) ma problemy z Thread-safe i może powodować niechciane skutki uboczne[1],
  • czyszczenie danych w pamięci podczas resetu lub zamknięcia procesu lub aplikacji,
  • może wymagać zwiększenia pamięci RAM – w konsekwencji np. wykupienia droższego planu serwera tylko ze względu na Cache.

LazyCache.AspNetCore

LazyCache to biblioteka dla tworzenia cachingu. Zapewnia ona prosty dostęp do pamięci oraz jest bezpieczna wątkowo (ang. Thread-Safe). Biblioteka LazyCache.AspNetCore zapewnia dodatkowy interfejs IAppCache, który dostarcza możliwość wstrzyknięcia serwisu w kontroler lub inną docelową klasę. Biblioteka ta korzysta z bibliotek .Net MemoryCache oraz Lazy.

public void ConfigureServices(IServiceCollection services)
{
    …
    services.AddLazyCache();
    …
}

Na powyższym listingu kodu pokazana została rejestracja LazyCache, dzięki której serwis CachingService będzie dostępny wszędzie w kodzie za pomocą DI przez interfejs IAppCache.

…
private readonly IAppCache _cache;
 
public WeatherForecastController(IAppCache cache)
{
    _cache = cache;
}
…

W linii 4 powyższego kodu wstrzykujemy serwis CachingService do kontrolera WeatherForecast, do prywatnej zmiennej _cache.

…
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
    return _cache.GetOrAdd(CacheKey, () => new ForecastService().GetForecast().ToList(), TimeSpan.FromMinutes(15));
}
…

Na powyższym przykładzie ukazany jest przykład użycia wcześniej wstrzykniętego serwisu. W 5 linii kodu użyta jest metoda GetOrAdd przyjmująca trzy parametry, są to:

  • Unikalny klucz w pamięci dla danych,
  • funkcja lambda, która ma zostać wykonana przy braku danych do ich pobrania i wstawienia do listy,
  • znacznik czasu, który wskazuje przez jaki czas mają być dane przechowywane w pamięci podręcznej serwera.

Jak w poprzednim przykładzie – wady i zalety pokrywają się z wyjątkiem łatwiejszej implementacji dla wielowątkowej aplikacji. Takie rozwiązanie nadal jest przydatne tylko przy mniej wymagających aplikacjach, które są używane głównie w biznesie np. w jednej firmie lub przy ograniczonej liczbie użytkowników.

Redis

Redis jest używana jako baza danych, cache i pośrednik wiadomości, których struktura danych jest przechowywana w pamięci. Zapewnia ona aplikacjom dostęp do danych o dużej przepustowości i z małymi opóźnieniami.

Konfiguracja Redis na Azure oraz Docker

Utworzenie instancji na portalu Azure jest bardzo proste.

  1. Należy wejść na odpowiedną subskrypcję na portalu Azure,
  2. wyszukać w marketplace Azure Cache for Redis,
  3. wypełnić wymagające pola m.in. nazwa, subskrypcję, warstwę cenową etc. i kliknąć przycisk utwórz.

Potrzebny connection string znajdziemy w sekcji Settings -> Access keys, znajduje się tam wpis Primary connection string (można użyć także Secondary). Należy go skopiować do pliku appsettings.json, który finalnie powinien go w sobie zawierać i wyglądać w następujący sposób:

{
  "ConnectionString": "<cache name>.redis.cache.windows.net,abortConnect=false,ssl=true,password=<primary-access-key>"
}

Niestety sama instrancja Redisa jest już płatna (na chwile obecną brak darmowej). Dla programistów może być pomocne użycie obrazu z Dockera na systemie Windows 10. Należy zainstalować Docker Desktop, następnie uruchomić następującą komendę w konsoli:

docker run –name myredis -p 6379:6379 redis

Gdzie:

  • run – uruchamia obraz,
  • –name myredis – tak nazywa się instancja Redisa,
  • -p 6379:6379 – otwiera port,
  • redis – nazwa obrazu w Docker Hub do pobrania i uruchomienia.,

Lokalny connection string będzie wyglądał następująco:

"127.0.0.1:6379"
…
public void ConfigureServices(IServiceCollection services)
{
 
    …
    services.AddStackExchangeRedisCache(options => options.Configuration = Configuration.GetValue<string>("ConnectionString"));
    …
}
…

Powyżej znajduje się konfiguracja serwisów w ASP. Net Core, która ma za zadanie dodanie obsługi wstrzykiwania serwisów Redisa poprzez interfejs IDistributedCache.

…
private readonly IDistributedCache distributedCache;

public WeatherForecastController(IDistributedCache distributedCache)
{
    this.distributedCache = distributedCache;
}
…
[HttpPost]
public async Task<IEnumerable<WeatherForecast>> GetAsync()
{
    var redisCustomerList = await distributedCache.GetStringAsync(CacheKey);
    if (redisCustomerList != null)
    {
        return JsonConvert.DeserializeObject<List<WeatherForecast>>(redisCustomerList);
    }
    else
    {
        var dbWeatherForecasts = new ForecastService().GetForecast().ToList();
        var serializedDbWeatherForecasts = JsonConvert.SerializeObject(dbWeatherForecasts);
 
        var options = new DistributedCacheEntryOptions()
            .SetAbsoluteExpiration(DateTime.Now.AddMinutes(10));
 
        await distributedCache.SetStringAsync(CacheKey, serializedDbWeatherForecasts, options);
 
        return dbWeatherForecasts;
    }
}
…

W linii 5 zostaje przypisany wcześniej wstrzyknięty serwis Redisa do prywatnej zmiennej. W funkcji GetAsync sprawdzana jest najpierw zawartość klucza na serwerze cachingu. Jeśli jest – zostaje to deserializowane na obiekty i zwrócone do klienta. Jeżeli nadal ich brakuje w pamięci zostają ona pobrane z pamięci, a następnie serializowane i dodane za pomocą serwisu w linii 23. Zostają także dodane opcje DistributedCacheEntryOptions, które powodują skasowanie danych po 10 minutach.

Zaletami tego rozwiązania jest osobny serwer z pamięcią wykorzystaną głównie do przechowywania danych i dającą dużą przewagę nad poprzednimi rozwiązaniami. Można wykorzystać instancję Redisa także do innych web aplikacji.

Użycie Redisa ma swoje wady, główną z nich jest potrzeba połączenia się z serwerem i czekanie na odpowiedź. Powinno się w tej sytuacji tworzyć docelowe serwery (zarówno na instancję do cachingu i web aplikacji) w tych samych regionach. Brakuje także wbudowanych mechanizmów zapobiegania nadpisywania i ponownego pobierania danych przez serwisy.

Redis nadaje się już do większych projektów oraz takich, które wymagają rozproszonego systemu przechowywania danych do szybkiego dostępu.

Dodatki

Problem z wydajnością serializacji danych

Serializacje danych, które zostaną zapisane do pamięci cache, spowoduje kolejny spadek wydajności serwerów, który może zwiększyć nasz czas odpowiedzi do klienta. Najlepszym sposobem na to jest napisanie własnego adaptera do serializacji danych, dokładne oznaczenie właściwości jak mają być dodane do pamięci (czy w ogóle mają zostać tam zapisać). Dobrze także przejrzeć benchmarki znanych lub nowych bibliotek do tego napisanych. Np. na tej stronie.

Publisher-Subscriber

private const string Topic = "MyTopic";

private static void Subscriber(ConnectionMultiplexer connection)
{
    var pubsub = connection.GetSubscriber();
 
    pubsub.Subscribe(Topis, (channel, message) => Console.WriteLine(message));
}
 
private static void Publisher(ConnectionMultiplexer connection)
{
    var pubsub = connection.GetSubscriber();
 
    pubsub.Publish(Topis, DateTime.Now.ToString());
}

Redis posiada także możliwość utworzenia wzorca Publish-Subscriber. Można utworzyć jeden temat, do którego podpina się kilka klientów i go subskrybuje. Kiedy zostanie wysłana wiadomość do tematu, zostaje ona wysłana do subskrybentów i obsłużona przez nich. Mogą to być np. zmiany na żywo w poście, live coding, czat czy powiadamianie o zmianach.

Z ConnectionMultiplexer zostaje pobrany Subsriber. Następnie za pomocą metod Publish można wysłać wiadomość na dany temat. Metoda Subscribe pobiera przyszłe komunikaty w temacie, drugi parametr jest to przypisana funkcja, która obsługuje wysłaną wiadomość.

Batch operacje

private static void BatchOps(IDatabase cache)
{
    var taskList = new List<Task>();
    var batch = cache.CreateBatch();
    for (int i = 0; i < 10; i++)
        taskList.Add(batch.StringSetAsync($"key{i}", i));
 
    batch.Execute();
    Task.WaitAll(taskList.ToArray());
}

Powyższy kod pokazuje w jaki sposób możemy stworzyć wiele kluczy naraz jeśli zajdzie taka potrzeba. Może to być np. odświeżenie wielu postów, nawigacji czy profili użytkowników. W linii 4 tworzony jest obiekt Batch. Można na nim wykonywać zwykłe operacje, tak jak na interfejsie IDistributedCache. Aby zostały one wysłane do serwera należy wywołać metodę Execute.

Cache-Aside

…
[HttpPost]
public async Task<IActionResult> SetAsync(IEnumerable<WeatherForecast> weatherForecasts)
{
    new ForecastService().SetForecast(weatherForecasts);
    await distributedCache.RemoveAsync(CacheKey);
 
    return Ok();
}
…

Wzorzec Cache-Aside polega na zapisaniu danych z serwisu do podręcznej pamięci RAM (lub innego szybkiego nośnika). Kiedy następuje kolejna próba ich odczytania – sprawdzane jest czy istnieją one w pamięci – jeśli tak, to zostają one zwrócone. Jeśli dane zostają zaktualizowane lub dodane, to są one kasowane z pamięci cache.

Powyżej jest metoda, która zapisuje nowe dane do serwisu. Następnie ich stare wersje zostają skasowane z pamięci w 5 linii.

Update cache in the background

Warto przemyśleć sprawę o aktualizacji instancji Redis lub wewnętrznej pamięci Cache. Może być w tym przypadku przydatny WebJob lub BackgroundService. Uruchomionie w nocy, wspomoże serwery w ciągu dnia z utrzymaniem dobrej wydajności oraz szybkimi odpowiedziami.

Podsumowanie

W artykule zostały przedstawione różne sposoby implementacji buforowania danych po stronie serwera. Warto przeanalizować projekty i wymagania klienta przed dobraniem odpowiedniej opcji. Tak jak, dla mniejszych aplikacji, przy mniejszym ruchu użytkowników może wystarczyć standardowy MemoryCache. Przy dużej infrastrukturze może być już potrzebny Redis oraz odpowiednia konfiguracja serwerów instancji cachingu i aplikacji klienta.

[1] https://blog.novanet.no/asp-net-core-memory-cache-is-get-or-create-thread-safe/

5 / 5
Michał Świtalik
Autor: Michał Świtalik
Software Engineer w Centrum Kompetencyjnym Office 365 w Sii. W pracy zajmuje się tworzeniem solucji dla obrotu i udostępnianiem dokumentów i informacji w biznesie, które wykorzystują technologie SharePointa. Po pracy lubię rozwijać swoje umiejętności w frameworkach JS (Angular, React, SPFX...), .Net oraz architektury.

Imię i nazwisko (wymagane)

Adres email (wymagane)

Temat

Treść wiadomości

Zostaw komentarz