Konrad Kowalski (rootsher)Principal Platform & Reliability Architect111111011010010011001001011111011100111101100010

SSG: renderowanie zanim pojawi się użytkownik

data
kategoria
Frontend
czytanie
3 min / 568 słów

SSR skraca drogę do pierwszej treści, ale ma koszt: renderowanie odbywa się na ścieżce requestu.

Jeśli użytkownik otwiera stronę produktu, serwer może pobrać dane, zbudować drzewo komponentów i wygenerować HTML. To działa dobrze, dopóki naprawdę potrzebujemy robić tę pracę przy każdym wejściu.

Ale co jeśli treść zmienia się raz dziennie?

Wtedy renderowanie per request zaczyna wyglądać jak marnowanie CPU.

Przenieśmy render jeszcze wcześniej

SSR wykonuje:

text
request
  |
  v
render
  |
  v
HTML

SSG przesuwa ten sam render do build-time:

text
build
  |
  v
render
  |
  v
HTML artifact
  |
  v
deploy
  |
  v
request
  |
  v
serve file

Dla użytkownika różnica jest ogromna.

Serwer nie musi już uruchamiać logiki renderującej dla każdego requestu. Gotowy dokument może leżeć na CDN i zostać zwrócony praktycznie jak zwykły plik.

To właśnie Static Site Generation.

SSG to precomputation

Najprostszy mental model:

text
HTML = render(data)

Jeżeli data jest znane wcześniej, wynik tej funkcji też możemy policzyć wcześniej.

Zamiast robić:

text
for every request:
    render(product)

robimy:

text
during build:
    render(product)
    save result

Jeśli mamy 10 000 produktów:

text
/products/1.html
/products/2.html
/products/3.html
...

powstaje zestaw gotowych artefaktów.

Request użytkownika nie uruchamia już renderera. Dostaje wcześniej przygotowany wynik.

Komercyjny przykład: dokumentacja

Dokumentacja techniczna jest niemal idealnym przypadkiem SSG.

Treść:

text
zmienia się stosunkowo rzadko
jest publiczna
powinna być szybko dostępna globalnie
nie zależy zwykle od konkretnego użytkownika

Po każdym merge'u można wykonać:

text
Markdown
  |
  v
build
  |
  v
HTML
  |
  v
CDN

Użytkownik później dostaje gotowy dokument.

Dlatego systemy dokumentacyjne, blogi, landing pages czy strony marketingowe bardzo dobrze pasują do statycznego generowania.

Frameworki takie jak Astro, Next.js czy Nuxt potrafią wykorzystywać ten model, ale sama idea jest dużo starsza od współczesnych meta-frameworków.

Dlaczego to jest tak szybkie

Request-time path staje się bardzo krótki:

text
browser
  |
  v
CDN
  |
  v
cached HTML

Nie ma:

text
query do bazy
uruchamiania aplikacji
renderowania komponentów
template engine
server-side data fetching

Co ważniejsze, gotowy HTML można kopiować do wielu lokalizacji geograficznych.

Nie musimy więc pytać centralnego originu o każdą stronę.

To daje bardzo dobry profil:

text
low latency
+
high cacheability
+
almost zero render CPU per request

Brzmi idealnie.

Problem zaczyna się wtedy, gdy spojrzymy na koszt builda.

Renderowanie nie zniknęło

SSG nie eliminuje pracy.

Przenosi ją w czasie.

Załóżmy:

text
100 pages
x
50 ms render
=
5 seconds

Nie ma problemu.

Teraz:

text
100 000 pages
x
50 ms
=
5 000 seconds

To ponad godzina samego renderowania przy naiwnym wykonaniu sekwencyjnym.

Oczywiście realne systemy równoleglą pracę, cache'ują rezultaty i wykonują buildy inkrementalne, ale fundamentalny problem pozostaje:

koszt rośnie wraz z liczbą materializowanych wariantów.

Ciekawostka

SSG można potraktować jak materialized view z baz danych.

Mamy funkcję:

text
page = f(content, configuration, code)

Zamiast obliczać f() przy każdym odczycie, przechowujemy jej wynik.

To daje bardzo szybki read, ale tworzy klasyczny problem systemów z precomputed data:

kiedy wynik przestaje być aktualny?

Dokładnie ten sam trade-off występuje w cache'ach, indeksach czy materialized views:

text
cheaper reads
<->
more expensive updates

SSG jest więc mniej „techniką frontendową”, a bardziej konkretnym przypadkiem precomputation.

Freshness staje się problemem

Załóżmy, że wygenerowaliśmy:

html
<p>Price: 129 zł</p>

O 12:00 cena zmienia się na:

text
119 zł

Statyczny artefakt sam się nie zmieni.

Trzeba:

text
data change
  |
  v
trigger build
  |
  v
render page again
  |
  v
deploy new artifact
  |
  v
invalidate / replace cache

Dla dokumentacji jest to zwykle akceptowalne.

Dla ceny produktu, availability albo wyników meczu już znacznie mniej.

Dlatego SSG działa najlepiej wtedy, gdy tolerancja na stale content jest zgodna z częstotliwością rebuildów.

Problem kombinacji

Jeszcze ciekawiej robi się przy stronach generowanych z wielu parametrów.

Załóżmy marketplace:

text
category
x
country
x
language
x
page
x
filters

Jeśli próbujemy materializować wszystkie kombinacje:

text
100 categories
x 20 countries
x 10 languages
x 100 pages
=
2 000 000 variants

A to jeszcze przed filtrami.

Nie każda możliwa strona zostanie kiedykolwiek odwiedzona.

Generowanie wszystkiego podczas builda oznacza więc wykonywanie dużej ilości pracy dla hipotetycznych requestów.

SSG zmienia ekonomię requestu

W SSR płacimy za render wtedy, kiedy użytkownik przychodzi:

text
request -> compute

W SSG płacimy wcześniej:

text
build -> compute -> store

Późniejszy request jest bardzo tani.

Można więc patrzeć na wybór między SSR i SSG jak na decyzję:

kiedy chcemy zapłacić koszt renderowania?

text
przed ruchem
czy podczas ruchu?

Jeżeli strona będzie odwiedzana milion razy, jeden wcześniejszy render może być świetną inwestycją.

Jeśli strona nigdy nie zostanie odwiedzona, ten sam render był całkowicie zbędny.

I właśnie tutaj SSG wpada na swój naturalny limit.

Nie chcemy renderować wszystkiego przy każdym requeście.

Ale nie chcemy też generować podczas builda milionów stron, których być może nikt nie otworzy.

Potrzebujemy trzeciej opcji:

wygeneruj stronę dopiero wtedy, gdy ktoś naprawdę jej potrzebuje, a potem zachowaj wynik.

Tak dochodzimy do deferred generation.