HTTP Caching w ASP.NET Core

Czas czytania ~ 200 sekund.

Nie będzie tutaj powszechnie znanych słów Phila Karltona o dwóch najtrudniejszych rzeczach. Będzie za to problem z życia wzięty. Mamy duży zasób, wynik ciężkiej operacji bazodanowej. Wiemy, że dane na których wykonują się obliczenia, zmieniają się raz dziennie. Co zrobić, żeby za każdym razem nie odpytywać bazy danych / serwera aplikacyjnego ?

Fajną analogią, pomagającą zrozumieć, czym jest Caching jest ten obrazek ze strony Drupala (oryginalny link):

squirrel20cache

Po co więc biegać gdzieś daleko i szukać orzechów (zasobów), skoro możemy je zmagazynować u siebie (na dysku komputera klienta).

Czy warto cache’ować zasoby ?

Na stronie httparchive.org można znaleźć bardzo wymowny wykres, pokazujący, że co drugi zasób zwracany jako odpowiedź HTTP mógłby być cache’owany.

arcgive

Zysk wydaje się być oczywisty. Odciążamy bazę danych, odciążamy serwer aplikacyjny, jeśli składujemy dane bezpośrednio w przeglądarce użytkownika to odciążamy również całą masę serwerów proxy i routerów znajdujących się między klientem i serwerem.Dodatkowo w przypadku aplikacji mobilnych liczy się, by nie zjadały zbyt dużo transferu, a więc w przypadku dobrego cache’owania jest nie tylko szybciej ale również taniej.

Memory Cache

Zacznijmy od poziomu serwera aplikacyjnego. Roundtripy do innych serwerów również wchodzą w skład opóźnienia, jakie wystąpi po stronie użytkownika końcowego. ASP.NET Core udostępnia interfejsy  IMemoryCacheIDistibutedCache. W tym drugim przypadku może konfigurować większe mechanizmy cache w oparciu o Redis czy SqlServer. Bardzo często jednak wystarczy nam prosty cache w pamięci serwera aplikacyjnego. Instalujemy NuGeta:

memor

Po instalacji, w metodzie ConfigureServices musimy wywołać extension metodę AddMemoryCache. Dzięki temu będziemy mogli wstrzykiwać IMemory Cache do naszych kontrolerów. Sam kod to typowy przykład pracy z cache, sprawdź, czy jest coś pod podanym kluczem, jeśli nie ma to wykonaj operację i wstaw dane na określony czas pod ten klucz.

public class CachePlaygroundController : Controller
{
    private IMemoryCache _memoryCache;

    public CachePlaygroundController(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        byte[] bytes;
        if (!_memoryCache.TryGetValue(id, out bytes))
        {
            bytes = HeavyBytes(id * 1000);

            _memoryCache.Set(id, bytes,
                new MemoryCacheEntryOptions()
                .SetAbsoluteExpiration(TimeSpan.FromHours(1)));
        }
        return new JsonResult(bytes);
    }
}

W bardzo podobny sposób działają przeglądarki. Kluczem do ich cache jest adres URL, pozostałe wartości musimy im podpowiedzieć wykorzystując odpowiednie nagłówki HTTP.

Nagłówki HTTP

Pierwszą rzeczą, którą możemy określić są reguły cache’owania. Nagłówek Cache-Control wysyłany przez serwer przyjmuje kilka grup wartości. Możemy ustawić kontrolę jako:

  • private – tylko w przeglądarce (wrażliwe dane użytkownika)
  • public – przeglądarka oraz pośredniczące serwery proxy
  • no-cache – cache nie może wykorzystać tej odpowiedzi bez zwalidowania jej aktualności (o czym za chwilę)
  • no-store – odpowiedź nigdy nie może być zapisywana przez żaden cache

Kolejną wartością jest max-age czyli przez ile sekund zasób ma być uznawany jako aktualny. Istnieje również wartość s-max-age, gdzie s pochodzi od shared caches, czyli ta wartość nie będzie respektowana przez prywatny cache. Możemy także wymusić walidację aktualności zasobu poprzez dyrektywę must-revalidate.

Przykładowo, aby wyłączyć cache’owanie pobranego zasobu zwrócimy nagłówek:

Cache-Control: no-cache, no-store, must-revalidate

Natomiast jeśli chcemy, by jakiś statyczny zasób np. obrazek był cache’owany przez jeden dzień na wszystkich pośredniczących serwerach oraz w przeglądarce, zwrócimy taki nagłówek:

Cache-Control:public, max-age=86400

W sytuacjach, w których nie wiemy dokładnie, kiedy zasób przestanie być aktualny możemy też skorzystać z mechanizmu ETag-ów oraz nagłówka Last-Modified. Przy kolejnej próbie użycia zasobu przeglądarka wykona do serwera zapytanie z prośbą o informację, czy wartość ETag-a się zmieniła w stosunku do posiadanej lub czy data ostatniej modyfikacji jest inna od posiadanej. W przypadku zmiany którejś z tych wartości serwer zwróci cały zasób. Sam ETag może być np. skrótem MD5 z zawartości zasobu.

Przykład:

Po wpisaniu w pasku adresu Google Chrome chrome://cache/ możemy podejrzeć wszystkie zasoby, które przeglądarka ma zapisane w swoim cache. Po wybraniu obrazka promuj ze strony dotnetomaniak możemy podejrzeć, jaką odpowiedź zwrócił serwer. Ponowne wejście na ten adres nie wykonuje żadnej akcji HTTP (ze względu na max-age).

promuj

Inny zasób, obrazek gif z serwerów telerika nie ma ustawionego Cache-Control, natomiast ma dwa pozostałe nagłówki, dlatego kolejna próba pobrania go przez przeglądarkę wygląda tak:

telerik

Serwer sprawdza dwa nagłówki If-None-Match (dla Etagów) i If-Modified-Since i zwraca odpowiedź 304 / Not Modified. Wspomniana dyrektywa no-cache informuje, że należy wykonać właśnie takie sprawdzenie.

Vary

Jest jeszcze jeden bardzo ważny nagłówek, o którym warto pamiętać: Vary. Klucze w cache’ach budowane są na podstawie ścieżek URL. Niby ok, ale problem pojawi się, gdy stosujemy mechanizm Content Negotiation, a więc ten sam adres zwraca te same dane, ale w różnych postaciach w zależności od nagłówka Accept (np. wspieramy JSON i XML). W takim przypadku Cache oczywiście zapisze pierwszą odpowiedź. Ten sam problem może wystąpić, gdy zwracamy różne HTML-e w zależności od przeglądarki użytkownika (nagłówek User-Agent). Aby wspomóc mechanizmy cache’ujące możemy za pomocą nagłówka Vary podać, co oprócz adresu ma być brane przy okazji budowy klucza w cache’u. Na przykład:

Vary: User-Agent, Accept

ResponseCache

W ASP.NET Core mamy do dyspozycji atrybut ResponseCache, który możemy umieszczać nad metodami i kontrolerami. Możemy w nim za każdym razem wyspecyfikować opcje takie jak Vary, Max-Age. Jeśli nie chcemy duplikować kodu, możemy również przy starcie aplikacji zdefiniować nazwany profil cache’owania i odwołać się później do niego w kontrolerach.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(options =>
    {
        options.CacheProfiles.Add(
            "StareInternetExplorery",
            new CacheProfile()
            {
                Location = ResponseCacheLocation.None,
                NoStore = true
            }
        );

        options.CacheProfiles.Add(
            "PublicznieNaDzien",
            new CacheProfile()
            {
                Location = ResponseCacheLocation.Any,
                VaryByHeader = "Accept",
                NoStore = false,
                Duration = 60 * 60 * 24
            }
        );
    });
}

I później nad metodami kontrolera wskazujemy tylko wybrany profil

[ResponseCache(CacheProfileName ="PublicznieNaDzien")]
public IActionResult Get(int id)
{
    //...
    return new JsonResult(data);
}

 

Reasumując, cache’ować warto, mamy do tego dedykowane mechanizmy, zarówno we frameworkach jak i na warstwie HTTP. Wszystko zależy jednak od tego, jaki charakter mają dane zwracane przez nasze endpointy. Bo jak wiemy nie ma nic trudniejszego, niż nazywanie rzeczy i ….

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s