Fingerprinty w Hugo: cache-busting dla style.css bez walki z przeglądarką

Wstęp

W poprzednim wpisie opisałem diagnostykę cache dla statycznej strony Hugo działającej za Cloudflare, Traefikiem i nginx-em.

Po poprawieniu konfiguracji nginx-a statyczne zasoby zaczęły dostawać sensowne nagłówki cache. Fonty, obrazki i wideo mogły być cache’owane dłużej, a Cloudflare poprawnie respektował nagłówki z originu.

Pozostał jednak jeden ważny temat: własny plik CSS.

Typowy link do CSS wygląda tak:

<link rel="stylesheet" href="/css/style.css">

Problem polega na tym, że adres się nie zmienia, nawet jeśli zawartość pliku się zmieni.

Jeśli przeglądarka zapisze /css/style.css na dłużej, użytkownik może po deployu nadal widzieć starą wersję stylów. Serwer ma już nowy plik, ale przeglądarka nie musi go od razu pobrać.

Rozwiązaniem jest fingerprinting.

Problem ze stałym URL-em

Załóżmy, że mamy plik:

/css/style.css

i ustawimy dla niego długi cache, na przykład 30 dni albo rok.

Nagłówek może wyglądać tak:

Cache-Control: max-age=31536000
Expires: ...

Dla przeglądarki oznacza to mniej więcej:

Ten plik można trzymać lokalnie przez długi czas.
Nie muszę pytać serwera przy każdym wejściu na stronę.

To jest dobre dla wydajności, ale ryzykowne przy pliku, który często zmieniamy.

Po deployu zawartość style.css może być już inna, ale URL nadal jest taki sam:

/css/style.css

Przeglądarka nie ma prostego powodu, żeby od razu pobrać nową wersję.

Dlatego dla plików bez wersjonowania często daje się krótszy cache, na przykład:

expires 7d;

To jest rozsądny kompromis, ale nie rozwiązuje problemu całkowicie. Lepiej sprawić, żeby URL zmieniał się wtedy, gdy zmienia się zawartość pliku.

Co daje fingerprinting?

Fingerprinting polega na dodaniu hasha zawartości pliku do jego nazwy.

Zamiast:

/css/style.css

dostajemy na przykład:

/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css

Jeżeli zawartość CSS-a się zmieni, hash też się zmieni. A skoro zmieni się hash, zmieni się również URL.

Dla przeglądarki są to dwa różne pliki:

/css/style.aaaa.css
/css/style.bbbb.css

Dzięki temu można ustawić bardzo długi cache, bo nowa wersja CSS-a i tak dostanie nowy adres.

To jest klasyczny cache-busting.

static/ a assets/ w Hugo

W Hugo ważne są dwa katalogi:

static/
assets/

Na pierwszy rzut oka mogą wyglądać podobnie, ale robią zupełnie inne rzeczy.

static/

Katalog static/ służy do plików, które Hugo ma skopiować 1:1 do katalogu wynikowego public/.

Przykład:

static/favicon.ico
static/images/logo.webp
static/css/style.css

po buildzie stanie się:

public/favicon.ico
public/images/logo.webp
public/css/style.css

Hugo nie przetwarza tych plików. Nie hashuje ich, nie minifikuje i nie generuje dla nich integrity.

To jest dobre dla plików, które mają być po prostu publicznie dostępne pod stałą ścieżką.

assets/

Katalog assets/ jest wejściem do Hugo Pipes, czyli mechanizmu przetwarzania zasobów przez Hugo.

Pliki z assets/ można pobierać w szablonach przez:

resources.Get

a potem wykonywać na nich operacje, na przykład:

fingerprint
minify
concat
toCSS

Jeżeli chcemy użyć fingerprintu dla CSS-a, plik musi znajdować się w assets/, a nie w static/.

Czyli zamiast:

static/css/style.css

potrzebujemy:

assets/css/style.css

Przeniesienie style.css do assets/

Najprostsza migracja wygląda tak:

mkdir -p assets/css
cp static/css/style.css assets/css/style.css

Na czas testów można plik skopiować. Po upewnieniu się, że wszystko działa, warto usunąć starą wersję ze static/, żeby nie utrzymywać dwóch kopii tego samego CSS-a.

rm static/css/style.css

Jeżeli plik zostanie równocześnie w static/ i assets/, strona może nadal działać, ale łatwo później edytować nie ten plik, który faktycznie jest używany przez szablon.

Użycie resources.Get i fingerprint

W partialu head.html można było wcześniej mieć coś takiego:

<link rel="stylesheet" href="{{ "/css/style.css" | relURL }}">

Po przeniesieniu pliku do assets/css/style.css można użyć Hugo Pipes:

{{ $style := resources.Get "css/style.css" | fingerprint }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}">

Warto pamiętać, że:

resources.Get "css/style.css"

szuka pliku w:

assets/css/style.css

a nie w:

static/css/style.css

Jeżeli pliku nie ma w assets/, Hugo zwróci błąd podobny do:

error calling fingerprint: <nil> can not be transformed

To oznacza, że resources.Get nie znalazło pliku, więc fingerprint nie ma czego przetwarzać.

Przykład head.html

Fragment head.html może wyglądać tak:

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    {{ $description := .Description | default .Site.Params.description | default .Site.Params.subtitle }}
    {{ with $description }}
    <meta name="description" content="{{ . | plainify | htmlEscape }}">
    {{ end }}

    <title>{{ .Title }}</title>

    <link rel="stylesheet" href="{{ "/vendor/bootstrap/5.3.8/css/bootstrap.min.css" | relURL }}">

    {{ $style := resources.Get "css/style.css" | fingerprint }}
    <link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}">

    <link rel="icon" type="image/x-icon" href="/favicon.ico">
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
    <link rel="manifest" href="/site.webmanifest">
</head>

Bootstrap w tym przykładzie zostaje w static/, ponieważ jego URL zawiera wersję:

/vendor/bootstrap/5.3.8/css/bootstrap.min.css

To też jest forma cache-bustingu. Jeżeli kiedyś Bootstrap zostanie zaktualizowany do innej wersji, zmieni się ścieżka.

Największy sens fingerprintu jest tutaj dla własnego pliku:

style.css

bo to on zmienia się najczęściej.

Efekt po buildzie Hugo

Po uruchomieniu:

hugo

w HTML-u pojawia się link w stylu:

<link rel="stylesheet" href="/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css" integrity="sha256-OmNsUw/lwwtR9ZORoOFCCSHTkTFCX7aU9oovNRsacxQ=">

To oznacza, że Hugo:

znalazło plik w assets/css/style.css
policzyło hash zawartości
wygenerowało nowy URL z hashem
dodało atrybut integrity

Atrybut integrity to SRI, czyli Subresource Integrity. Dzięki niemu przeglądarka może sprawdzić, czy pobrany plik ma dokładnie taką zawartość, jakiej oczekuje HTML.

Sprawdzenie wygenerowanego HTML-a

Po buildzie warto sprawdzić, czy HTML faktycznie wskazuje na fingerprintowany plik:

grep -R "style.*css" public/index.html

Albo po deployu:

curl -s https://logos.net.pl/ | grep 'style.*css'

Przykładowy wynik:

<link rel="stylesheet" href="/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css" integrity="sha256-OmNsUw/lwwtR9ZORoOFCCSHTkTFCX7aU9oovNRsacxQ=">

Jeżeli nadal widać:

/css/style.css

to znaczy, że szablon nadal używa starego linku albo build/deploy nie został wykonany z aktualną wersją plików.

Długi cache dla fingerprintowanego CSS-a

Po włączeniu fingerprintu dla CSS-a można bezpiecznie ustawić długi cache dla plików CSS i JS.

Przykład nginx:

location ~* \.(?:css|js)$ {
  try_files $uri =404;
  expires 1y;
  add_header Cache-Control "public, immutable" always;
  access_log off;
}

Dlaczego tym razem immutable ma sens?

Bo URL zawiera hash zawartości pliku. Jeżeli zawartość się zmieni, zmieni się również URL.

Dla przeglądarki oznacza to:

Ten konkretny plik pod tym konkretnym URL-em nigdy się nie zmieni.
Jeżeli aplikacja będzie potrzebowała nowej wersji, dostanie nowy URL.

To jest bezpieczne dla fingerprintowanych zasobów.

HTML nadal powinien mieć krótki cache

Ważny szczegół: długi cache dla CSS-a nie oznacza długiego cache dla HTML-a.

To właśnie HTML zawiera link do aktualnego pliku CSS:

<link rel="stylesheet" href="/css/style.<hash>.css">

Jeżeli HTML byłby cache’owany zbyt długo, użytkownik mógłby nadal dostawać starą stronę wskazującą na stary CSS.

Dlatego HTML powinien mieć krótki cache albo no-cache.

Przykład nginx:

location ~* \.(?:manifest|appcache|html?|xml|json)$ {
  expires -1;
  add_header Cache-Control "no-cache" always;
}

Można też spotkać bardziej restrykcyjne:

add_header Cache-Control "no-cache, no-store, must-revalidate" always;

Dla statycznej strony często wystarczy no-cache, ponieważ pozwala przeglądarce przechować plik, ale wymusza sprawdzenie, czy wersja na serwerze się nie zmieniła.

Test nagłówków po deployu

Po wdrożeniu warto sprawdzić fingerprintowany CSS:

curl -I https://logos.net.pl/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css \
  | grep -Ei 'cache-control|expires|cf-cache-status|age'

Docelowo dla fingerprintowanego CSS-a chcemy zobaczyć coś w stylu:

cache-control: public, immutable
expires: ...
cf-cache-status: HIT

albo:

cache-control: public, max-age=31536000, immutable

W zależności od konfiguracji nginx-a nagłówki mogą wyglądać trochę inaczej, ale najważniejsze są trzy rzeczy:

długi max-age
immutable
nowy URL po zmianie zawartości pliku

Warto też sprawdzić HTML:

curl -I https://logos.net.pl/ \
  | grep -Ei 'cache-control|expires|cf-cache-status|age'

HTML nie powinien być cache’owany agresywnie.

Co z Bootstrapem?

W tym przypadku Bootstrap został pod ścieżką:

/vendor/bootstrap/5.3.8/css/bootstrap.min.css

To oznacza, że wersja biblioteki jest częścią URL-a.

Jeżeli kiedyś Bootstrap zostanie zaktualizowany, ścieżka może zmienić się na przykład na:

/vendor/bootstrap/5.3.9/css/bootstrap.min.css

Dla przeglądarki to będzie nowy zasób. Dlatego Bootstrap nie musi od razu przechodzić przez Hugo Pipes, chociaż technicznie też można go fingerprintować.

Najprostsza zasada:

własny CSS, często zmieniany:
  fingerprint

vendor z wersją w ścieżce:
  może zostać jako static

favicony, obrazy, pliki do pobrania:
  zwykle static

Wnioski

Fingerprinting rozwiązuje klasyczny problem cache dla CSS-a:

chcę długi cache, ale nie chcę starego CSS-a po deployu

Dzięki Hugo Pipes można zrobić to bez Node.js, npm, webpacka i całego frontowego zaplecza.

Wystarczy:

przenieść style.css z static/ do assets/
użyć resources.Get
dodać fingerprint
linkować wygenerowany RelPermalink
ustawić długi cache dla fingerprintowanych zasobów
zostawić krótki cache dla HTML-a

Najważniejsze rozróżnienie w Hugo jest takie:

static/  -> kopiuj 1:1 do public/
assets/  -> przetwarzaj przez Hugo Pipes
public/  -> wynik builda

Po tej zmianie deploy nowego CSS-a przestaje być walką z przeglądarką i Cloudflare.

Zmiana stylu oznacza zmianę hasha. Zmiana hasha oznacza nowy URL. Nowy URL oznacza, że przeglądarka pobiera nowy plik.

I dokładnie o to chodzi w cache-bustingu.