Wprowadzenie
Wraz z rozwojem aplikacji oraz zwiększaniem się liczby użytkowników stajemy przed coraz większymi problemami związanymi z wydajnością. Szczególnie istotne jest to w momencie, gdy na przykład ruch na stronie jest na tyle duży, że serwer, z którego korzystamy, jest wykorzystywany w 100%. Jednym z sposobów, który w miarę szybko daje spore efekty, jest dodanie mechanizmu cache’owania często używanych danych. W tym wpisie pokażę Ci bibliotekę CacheManager (https://github.com/MichaCo/CacheManager), która ułatwia dodawanie cache’owania w aplikacjach .NET.
W przykładzie zobaczysz, w jaki sposób dodać do aplikacji ASP.NET MVC wielopoziomowy cache (pierwszy poziom to pamięć procesu, natomiast drugi to Redis) z wykorzystaniem CacheManagera, dzięki któremu cały proces jest bardzo łatwy. Pokazany mechanizm można również wykorzystać do innych typów aplikacji.
Czym jest cache’owanie danych?
W aplikacji niektóre dane wykorzystuje się częściej niż inne. Z tego powodu można rozważyć zapisanie ich w jakimś tymczasowym miejscu (np. pamięć RAM), z którego odczyt danych jest dużo szybszy niż na przykład z bazy danych. Dzięki temu aplikacja działa szybciej z punktu widzenia użytkownika, a ponadto możemy odciążyć część serwerów poprzez zminimalizowanie ilości wykonywanych operacji (np. zapytań do bazy).
W cache możemy zapisywać różne rzeczy. Mogą to być dane pobrane bezpośrednio z bazy lub jakieś usługi, mogą to być jakieś przetworzone dane. W jednej aplikacji zapisywaliśmy informacje o menu oraz strukturze strony dla użytkownika, ponieważ mechanizm uprawnień był dość rozbudowany i sprawdzanie tego za każdym razem było kosztowne. Mogą to być również wyniki operacji, na przykład html wygenerowanej strony lub jej fragment.
Cykl życia informacji w cache może być różny. Dane mogą znajdować się w nim przez określony czas (np. 5 minut). Czas ten może się resetować przy każdym pobraniu danych lub być absolutny, a dane usuną się po określonym czasie od dodania. W sytuacji, gdy kontrolujemy edycję oraz usuwanie danych lub mamy o tym powiadomienia, możemy usuwać z cache informację o zmodyfikowanych elementach, aby zminimalizować wyświetlanie nieaktualnych danych.
Dane możemy zapisywać w różnych miejscach. Może to być pamięć RAM dla najczęściej używanych informacji czy dysk twardy dla wygenerowanego htmla. W rozbudowanych systemach, gdzie aplikacja działa na kilku serwerach, możemy skorzystać z rozproszonych systemów cache’owania, takich jak Redis. Wtedy użyjemy wielopoziomowego cache’owania danych oraz jakichś mechanizmów, które umożliwią nam synchronizowanie informacji z Redisa z informacjami w lokalnym cache w pamięci RAM.
Opcji i możliwości jest dużo. W zależności od specyfiki aplikacji oraz środowiska, na którym jest wdrożona, możemy wybrać to, co najlepiej spełni nasze wymagania. Mając już obraną strategię cache’owania danych, możemy przejść do właściwej implementacji.
CacheService
W swoich aplikacjach staram się (o ile jest to możliwe oraz nie jest czasochłonne) odizolowywać używanie bibliotek od reszty systemu. Tak jest w przypadku obsługi cache’owania. Mam własny interfejs, którego używam w całej aplikacji, i nie korzystam bezpośrednio z CacheManagera. W tym przypadku głównym powodem takiego podejścia jest to, że CacheManager ma trochę inne API niż to, którego chcę używać. Sam interfejs dla CacheService wygląda tak:
public interface ICacheService | |
{ | |
T GetOrAdd<T>(string key, Func<T> getFunc); | |
T GetOrAdd<T>(string key, string region, Func<T> getFunc); | |
void Remove(string key); | |
void Remove(string key, string region); | |
void Clear(); | |
void ClearRegion(string region); | |
} |
Jak widać, nie jest on skomplikowany. Tak naprawdę są to trzy metody (każda ma po dwa warianty). Najczęściej używaną metodą jest GetOrAdd. W pierwszej kolejności ma ona sprawdzić, czy w cache jest wartość pod określonym kluczem, i ją zwrócić. W przeciwnym przypadku wywołuje przekazaną funkcję jako drugi parametr, cache’uje jej wynik i zwraca wartość.
Dwie pozostałe metody służą do usuwania danych z cache. Remove usuwa konkretny klucz, natomiast Clear czyści wszystkie dane z cache.
W cache’owaniu często wykorzystuję regiony (dlatego każda z metod ma dwie wersje). Regiony służą do grupowania danych w cache (np. oddzielne regiony dla każdego z użytkowników). Dzięki temu możemy trzymać różne dane dla tych samych kluczy, ale w różnych regionach (np. inna cena produktu dla poszczególnych użytkowników w zależności od udzielonego rabatu). Możemy również później łatwo usunąć wszystkie powiązane wartości w cache dla danego regionu (np. w trakcie wylogowywania użytkownika usuwamy jego wszystkie dane).
CacheService a czas życia danych w cache
Możesz również zauważyć, że w interfejsie nie ma możliwości określenia czasu, jak długo dana informacja będzie przechowywana w cache. Jest to celowe działanie. Z czasem zauważyłem, że lepiej nie określać czasu życia danych w cache na poziomie kodu. Późniejsza zmiana wymaga przekompilowania aplikacji, co w przypadku produkcji może nie być takie proste. Dużo lepiej jest konfigurować to na poziomie web.config lub app.config. Dzięki temu możemy to znacznie łatwiej zmienić na produkcji. Dodatkowo możemy mieć różne konfiguracje w zależności od środowiska – na przykład na produkcji 10 minut staging to 5 minut, na środowisku testerskim 1 minuta, natomiast na środowisku developerskim – 0 minut.
W sytuacji, gdy jednak potrzebuję mieć różne konfiguracje w zależności od danych, wtedy w aplikacji mam kilka instancji usługi (zamiast jednego singletona). CacheManager umożliwia zdefiniowanie wielu konfiguracji cache’owania w pliku konfiguracyjnym i możemy podczas tworzenia instancji CacheService załadować odpowiednią – w zależności od tego, czego potrzebujemy.
Czym jest CacheManager?
CacheManager (https://github.com/MichaCo/CacheManager) jest jedną z dostępnych bibliotek, którą możemy wykorzystać. Ma kilka fajnych funkcji, które warto przejrzeć.
Przede wszystkim wspiera kilka różnych providerów, których możemy użyć w aplikacji. Wszystko może zostać skonfigurowane w web.config lub app.config, dzięki czemu wystarczy, że raz napiszemy kod. Dodatkowo biblioteka wspiera wielopoziomowy cache, który przydaje się w środowisku rozproszonym. To też jest konfigurowalne. Przy tym samym kodzie możemy w wersji testowej mieć tylko cache w RAM-ie, natomiast na produkcji dodatkowo dorzucić drugi poziom cache w Redisie.
Biblioteka wspiera też kilka różnych sposobów serializacji danych w cache. Domyślnie serializuje dane binarnie, ale możemy użyć też innego sposobu, jak na przykład Json, Protobuf lub inne.
Inną ciekawą funkcjonalnością jest metoda Update, która przydaje się szczególnie w środowisku wielowątkowym. Za jej pomocą nieco inaczej aktualizujemy dane w cache. Zamiast przekazać nową wartość, która zastąpi starą, przekazujemy po prostu delegat, który wykona się i zaktualizuje wartość w cache. Warto przejrzeć przykład z licznikiem w dokumentacji, który pokazuje działanie tej metody – http://cachemanager.michaco.net/documentation/CacheManagerUpdateOperations.
Implementacja CacheService
Poniżej znajduje się implementacja CacheService z wykorzystaniem biblioteki CacheManager:
public class CacheService : ICacheService | |
{ | |
private ICacheManager<object> _cache; | |
private const string _defaultCacheName = "defaultCache"; | |
public CacheService() | |
{ | |
var builder = ConfigurationBuilder.LoadConfiguration(_defaultCacheName).Builder; | |
builder.WithMicrosoftLogging(s => s.AddNLog()); | |
_cache = CacheFactory.FromConfiguration<object>(_defaultCacheName, builder.Build()); | |
} | |
public T GetOrAdd<T>(string key, Func<T> getFunc) | |
{ | |
return (T)_cache.GetOrAdd(key, k => getFunc()); | |
} | |
public T GetOrAdd<T>(string key, string region, Func<T> getFunc) | |
{ | |
return (T)_cache.GetOrAdd(key, region, (k, r) => getFunc()); | |
} | |
public void Remove(string key) | |
{ | |
_cache.Remove(key); | |
} | |
public void Remove(string key, string region) | |
{ | |
_cache.Remove(key, region); | |
} | |
public void Clear() | |
{ | |
_cache.Clear(); | |
} | |
public void ClearRegion(string region) | |
{ | |
_cache.ClearRegion(region); | |
} | |
} |
W przykładzie wykorzystuję tylko jedną instancję usługi, która podczas tworzenia wczytuje konfigurację „defaultCache” z web.configu, dodaje logowanie i tworzy instancję CacheManagera.
Tutaj właśnie widać różnicę w api między moją usługą a biblioteką. CacheManager jest generycznym typem, w którym dla każdego typu, który chcemy przechowywać, tworzymy nową instancję. Ja natomiast w przypadku CacheService wolę jednak określać typ na poziomie parametru generycznego metody GetOrAdd.
CacheManager korzysta z Microsoft.Extensions.Logging do logowania informacji. Istnieją już dostępne adaptery, które umożliwiają zapis generowanych logów z biblioteki Microsoftu na przykład do nloga. Wystarczy w konfiguracji CacheManagera użyć odpowiedniego adapteru – w przykładzie logi są przekazywane do nloga i zapisywane w plikach w App_Data/logs.
Użycie CacheService
W przykładzie użyłem CacheService na poziomie logiki biznesowej. Przez nią przechodzą takie operacja, jak edycja czy usuwanie, dzięki czemu mogę po ich wykonaniu usuwać klucze z cache. W zależności od potrzeb możemy używać usługi również w innych częściach aplikacji.
Usługa jest w tym momencie użyta bezpośrednio w kodzie logiki. W przyszłości pokażę jeszcze, jak to można zrobić transparentnie, bez pisania dodatkowego kodu w logice biznesowej.
public class ProductLogic : IProductLogic | |
{ | |
private Lazy<IProductRepository> _repository; | |
protected IProductRepository Repository | |
{ | |
get { return _repository.Value; } | |
} | |
private Lazy<ICacheService> _cacheService; | |
protected ICacheService CacheService | |
{ | |
get { return _cacheService.Value; } | |
} | |
public ProductLogic(Lazy<IProductRepository> repository, | |
Lazy<ICacheService> cacheService) | |
{ | |
_repository = repository; | |
_cacheService = cacheService; | |
} | |
public IEnumerable<Product> GetAll() | |
{ | |
return Repository.GetAll(); | |
} | |
public Product GetById(int id) | |
{ | |
return CacheService.GetOrAdd(CacheKey(id), () => Repository.GetById(id)); | |
} | |
public Product Update(Product product) | |
{ | |
Repository.Update(product); | |
CacheService.Remove(CacheKey(product.Id)); | |
return product; | |
} | |
public void Delete(Product product) | |
{ | |
Repository.Delete(product); | |
CacheService.Remove(CacheKey(product.Id)); | |
} | |
private string CacheKey(int id) | |
{ | |
return $"Product-{id}"; | |
} | |
} |
Jak widać na listingu, w logice biznesowej obok standardowego repozytorium wstrzykujemy również CacheService. Znajduje się tam również prywatna metoda CacheKey, która jest odpowiedzialna za przygotowanie klucza, pod którym będzie cache’owany obiektu produktu. W metodzie GetById dodajemy produkt do cache za pomocą metody GetOrAdd z CacheService, natomiast w Update oraz Delete usuwamy dane z wykorzystaniem metody Remove.
CacheService a EntityFramework
Mechanizm śledzenia zmian w Entity Framework jest bardzo fajny. Ułatwia on aktualizacje danych w bazie. Niestety pojawiają się w nim problemy w momencie, kiedy zaczniemy wrzucać do cache obiekty pobrane z bazy, tak jak to jest pokazane w przykładzie. Problem polega na tym, że obiekt w cache żyje dużo dłużej niż obiekt kontekstowy, który na ogół jest tworzony na potrzeby pojedynczego żądania HTTP. Dlatego musimy pamiętać, aby przed aktualizacją obiektu powiązać go z nowym obiektem kontekstowym, tak jak to widać w repozytorium poniżej:
public class ProductRepository : IProductRepository | |
{ | |
private Lazy<DataContext> _db; | |
protected DataContext Db | |
{ | |
get { return _db.Value; } | |
} | |
public ProductRepository(Lazy<DataContext> db) | |
{ | |
_db = db; | |
} | |
public IEnumerable<Product> GetAll() | |
{ | |
return Db.Products; | |
} | |
public Product GetById(int id) | |
{ | |
return Db.Products.FirstOrDefault(p => p.Id == id); | |
} | |
public void Update(Product product) | |
{ | |
Db.Products.Attach(product); | |
Db.Entry(product).State = EntityState.Modified; | |
Db.SaveChanges(); | |
} | |
public void Delete(Product product) | |
{ | |
Db.Products.Attach(product); | |
Db.Products.Remove(product); | |
Db.SaveChanges(); | |
} | |
} |
Podobna sytuacja jest z lazy loadingiem. Bez podłączenia do nowego obiektu kontekstowego powiązane obiekty nie zastaną załadowane. Dlatego warto w cache trzymać obiekty, które już zawierają wszystkie informacje. Wystarczy podczas ich pobierania skorzystać z metody Include.
CacheManager konfiguracja
Ostatnim elementem całej układanki jest konfiguracja biblioteki. Jak wspomniałem wcześniej, w swoich projektach dodaję ją w web.config lub app.config, aby móc łatwo to zmieniać. Poniżej znajduje się fragment web.configu, który konfiguruje wielopoziomowy cache w testowej aplikacji. W pierwszym poziomie dane są zapisywane w pamięci RAM, natomiast drugi poziom to Redis:
<cacheManager.Redis> | |
<connections> | |
<connection id="redis" | |
database="0" | |
password="password" | |
ssl="true"> | |
<endpoints> | |
<endpoint host="host" | |
port="port" /> | |
</endpoints> | |
</connection> | |
</connections> | |
</cacheManager.Redis> | |
<cacheManager xmlns="http://cachemanager.michaco.net/schemas/CacheManagerCfg.xsd"> | |
<managers> | |
<cache name="defaultCache" | |
enableStatistics="false" | |
enablePerformanceCounters="false" | |
backplaneName="redis" | |
backplaneType="CacheManager.Redis.RedisCacheBackplane, CacheManager.StackExchange.Redis" | |
serializerType="CacheManager.Serialization.Json.JsonCacheSerializer, CacheManager.Serialization.Json"> | |
<handle name="handleName" | |
ref="systemRuntimeHandle" | |
expirationMode="Absolute" | |
timeout="1m" /> | |
<handle name="redis" | |
ref="redisHandle" | |
expirationMode="Sliding" | |
timeout="10m" | |
isBackplaneSource="true" /> | |
</cache> | |
</managers> | |
<cacheHandles> | |
<handleDef id="systemRuntimeHandle" | |
type="CacheManager.SystemRuntimeCaching.MemoryCacheHandle`1, CacheManager.SystemRuntimeCaching" /> | |
<handleDef id="redisHandle" | |
type="CacheManager.Redis.RedisCacheHandle`1, CacheManager.StackExchange.Redis" /> | |
</cacheHandles> | |
</cacheManager> |
W pierwszej części konfiguracji zapisana jest informacja o połączeniu do Redisa (np. usługa cache z azure). Druga część zawiera konfigurację domyślnego cache, który zapisuje dane do dwóch miejsc. Każda z lokalizacji ma swoją konfigurację czasu przechowywania danych. W RAM-ie dane są przechowywane przez 1 minutę w sposób absolutny (czyli dane zostaną usunięte po 1 minucie od ich dodania), natomiast w Redisie przez 10 minut, a każde pobranie danych resetuje ten czas (opcja sliding).
Dodatkowo Redis jest skonfigurowany jako backplane. Oznacza to, że CacheManager będzie synchronizował dane w cache między poziomami. Jest to szczególnie istotne, gdy aplikacja jest hostowana na kilku serwerach. Wtedy zmiana danych w Redisie rozszerzy się na wszystkie serwery.
W przykładzie dane do cache są serializowane do json, co widać na poniższym zrzucie z danymi dla Product-1 w Redisie. Użyłem tej serializacji, aby było łatwiej sprawdzić, że w Redisie faktycznie znajdują się te dane. W realnej aplikacji lepiej skorzystać z serializacji binarnej, która jest wydajniejsza.
Przykład
W repozytorium na githubie (https://github.com/danielplawgo/CacheExample) znajduje się przykład do tego wpisu. Aby go uruchomić, trzeba zmienić dwie rzeczy w web.config:
- Ustawić connection stringa do bazy danych
- Dodać dane do połączenia do Redisa (można skorzystać z Redisa w azure) – lub w konfiguracji CacheManager usunąć używanie Redisa.
Po tych dwóch krokach można uruchomić przykład i sprawdzić w praktyce, jak działa CacheManager.
Podsumowanie
Dodanie cache’owania do aplikacji nie jest czymś skomplikowanym, w szczególności gdy korzystamy z jakiejś gotowej biblioteki, która to ułatwia. Warto zainteresować się CacheManagerem, który jest łatwy w użyciu, a daje sporo możliwości, takich jak m.in. wielopoziomowy cache.
Warto również pamiętać o tym, że czasami dodanie cache do aplikacji może powodować drobne problemy – tak jak dzieje się to w przypadku Entity Framework. Dlatego warto już wcześniej myśleć o cache’owaniu, aby później nie mieć niespodziewanych sytuacji.
A jakie są Twoje doświadczenia z cache’owaniem danych?
1 thought on “Jak cache’ować dane w .NET? Kilka słów o CacheManagerze oraz Redisie”