Auf dieser Seite wird beschrieben, wie man mittels Cacheable-Response einen In-Memory Request Cache in eine React bzw. Next.JS Applikation einbaut. In diesem Beispiel wird Express Server verwendet, es kann aber ohne weiteres auf React und NextJS angewandt werden.
Das Problem
Setzt man React ein, so denken viele im ersten Moment eher an das Entwickeln eine modernen, „geilen“ Applikation in einem Tech-Stack, der Spaß macht. Wenige denken bereits so früh an den Live-Betrieb der Applikation. Spätestens aber, wenn das Thema SEO und Indizierbarkeit angegangen wird, kommt Next.JS ins Spiel.
Mit Next.JS lassen sich React Applikation isomorph serverseitig rendern und ausspielen (SSR). Der Benutzer bekommt also eine fertig gerenderte Seite bevor es dann weitergeht mit dem gewohnten API-basierten Browsen.
Wirft man dann jedoch einen Blick auf die Performance von Next.JS sieht man:
Next.JS ist langsam! (Insbesondere bei komplexen Seiten)
Dies hat zur Folge, dass die SEO-relevante Time-To-First-Byte (TTFB), also die Zeit, die verstreicht, bis der Webserver das erste Byte der Seite überträgt stark ansteigt (TTFB). In meinen Projekten lag diese (trotz starker Server im Produktionsbetrieb) bei teilweise > 1.7 Sekunden! Viel zu lang.
Wie üblich in einem solchen Fall würde man das Problem auf der Infrastruktur-Ebene Lösen und einen Proxy Cache vorschalten (z.B. nginx). Im Folgenden möchte ich aber den „React“-Weg aufzeigen. Dieser hat folgende Vorteile:
Leichtes Testen des Cachings während dem Entwickeln
Individuelle Cache-Konfiguration auf Basis von Express- bzw. NextJS Routen möglich
Keine zusätzliche Komplexität – Man kann wie gewohnt Express-Server nutzen, ohne sich mit Betriebsthematiken näher auseinanderzusetzen
Bei Bedarf ist die Cache-Configuration leicht über Umgebungs-Variablen zu managen
Deployment in Container bleibt weiterhin einfach und Skalierbar
Weniger Hops eingehender Requests durch zusätzlichen Reverse-Proxy
Blaupause für weitere Caches (z.B. API Request Caches)
Lösung
Um einen Request Cache in React einzubauen geht man also wie folgt vor:
Cache-Store / Cache-Manager einrichten
Nicht-Zu-Cachende Routen ausschließen (z.B. alles unter /_next/*)
Zu-Cachende Routen über den Cache-Manager laufen lassen.
URL-Basiertes Cache-Purging einrichten
Gesamten-Cache-Leeren Funktion einrichten
Simple Lösung
Zunächst muss das Paket installiert werden:
npm install --save cacheable-response
Mit Cacheable-Response einen einfachen Cache-Manager anlegen. Die Funktion im get-Block beschreibt die Funktion, die genau dann ausgeführt wird, wenn Daten erstmalig oder erneut gecached werden sollen.
Der send Block beschreibt die Funktion bei Cache-Zugriff. Weitere Informationen zu Cacheable-Response findet man hier: https://www.npmjs.com/package/cacheable-response
const express = require('express');
const next = require('next');
const cacheableResponse = require('cacheable-response')
const isDevEnvironment = process.env.NODE_ENV !== 'production'
const nextApp = next({dev: isDevEnvironment, dir: './src'});
const defaultRequestHandler = nextApp.getRequestHandler();
const cacheManager = cacheableResponse({
ttl: 1000 * 60 * 60, // 1hour
get: async ({req, res, pagePath, queryParams}) => {
try {
return {data: await nextApp.renderToHTML(req, res, pagePath, queryParams)}
} catch (e) {
return {data: "error: " + e}
}
},
send: ({data, res}) => {
res.send(data);
}
});
nextApp.prepare()
[...]
Anschließend muss man den Render- bzw. Handler-Befehl durch den Cache Manager ersetzen:
//server.get('*', (req, res) => app.render(req, res, '/index'));
//server.get('*', (req, res) => app.render(req, res, req.url, req.query));
//server.get('*', (req, res) => handle(req, res);
// Serving next data directly without the cache
server.get('/_next/*', (req, res) => {
defaultRequestHandler(req, res);
});
server.get('*', (req, res) => {
if (isDevEnvironment || req.query.noCache)
res.setHeader('X-Cache-Status', 'DISABLED');
defaultRequestHandler(req, res);
} else {
cacheManager({req, res, pagePath: req.path});
}
});
Wichtig! pagePath beschreibt hier die jeweilige Next.JS Page (in diesem Fall ‚pages/index.js)
Die komplette server.js sieht dann wie folgt aus:
const express = require('express');
const next = require('next');
const cacheableResponse = require('cacheable-response')
const isDevEnvironment = process.env.NODE_ENV !== 'production'
const nextApp = next({dev: isDevEnvironment, dir: './src'});
const defaultRequestHandler = nextApp.getRequestHandler();
const cacheManager = cacheableResponse({
ttl: 1000 * 60 * 60, // 1hour
get: async ({req, res, pagePath, queryParams}) => {
try {
return {data: await nextApp.renderToHTML(req, res, pagePath, queryParams)}
} catch (e) {
return {data: "error: " + e}
}
},
send: ({data, res}) => {
res.send(data);
}
});
nextApp.prepare()
.then(() => {
const server = express();
// Serving next data directly without the cache
server.get('/_next/*', (req, res) => {
defaultRequestHandler(req, res);
});
server.get('*', (req, res) => {
if (isDevEnvironment || req.query.noCache) {
res.setHeader('X-Cache-Status', 'DISABLED');
defaultRequestHandler(req, res);
} else {
cacheManager({req, res, pagePath: req.path, queryParams: req.query});
}
});
server.listen(3000, (err) => {
if (err) throw err
console.log('> Ready on http://localhost:3000')
})
})
.catch((ex) => {
console.error(ex.stack)
process.exit(1)
});
Komplex (mit Clear Cache / Cache Purging)
Jetzt wird das obige Beispiel um ein paar nützliche Funktionen erweitert:
Eigener Key-Generator (der default Algorithmus von ‚Cacheable-Response‘ wird für das Purging verwendet) – Damit kann man dedizierte URLs aus dem Cache entfernen
CacheStore manuell verwalten – So kann man überhaupt den Cache verwalten
Eigenes Purging von einzelnen URLs
Löschen des gesamten Caches
Kompression des Caches um Speicher zu sparen
Zunächst wird eine weitere Abhängigkeit installiert:
npm install --save iltorb cacheable-response
Im Folgenden wird der Cache mit dem HTTP Kommando PURGE gelöscht. Eine Alternative / Ergänzung wäre ein Löschen mittels Request Parameter. Die PURGE-Variante ist allerdings der sauberere Weg.
const express = require('express');
const next = require('next');
const Keyv = require('keyv');
const {resolve: urlResolve} = require('url');
const normalizeUrl = require('normalize-url');
const cacheableResponse = require('cacheable-response');
const isDevEnvironment = process.env.NODE_ENV !== 'production';
const nextApp = next({dev: isDevEnvironment, dir: './src'});
const defaultRequestHandler = nextApp.getRequestHandler();
const cacheStore = new Keyv({namespace: 'ssr-cache'});
const _getSSRCacheKey = req => {
const url = urlResolve('http://localhost', req.url);
const {origin} = new URL(url);
const baseKey = normalizeUrl(url, {
removeQueryParameters: [
'embed',
'filter',
'force',
'proxy',
'ref',
/^utm_\w+/i
]
});
return baseKey.replace(origin, '').replace('/?', '')
};
const cacheManager = cacheableResponse({
ttl: 1000 * 60 * 60, // 1hour
get: async ({req, res, pagePath, queryParams}) => {
try {
return {data: await nextApp.renderToHTML(req, res, pagePath, queryParams)}
} catch (e) {
return {data: "error: " + e}
}
},
send: ({data, res}) => {
res.send(data);
},
cache: cacheStore,
getKey: _getSSRCacheKey,
compress: true
});
function clearCompleteCache(res, req) {
cacheStore.clear();
res.status(200);
res.send({
path: req.hostname + req.baseUrl,
purged: true,
clearedCompleteCache: true
});
res.end();
}
function clearCacheForRequestUrl(req, res) {
let key = _getSSRCacheKey(req);
console.log(key);
cacheStore.delete(key);
res.status(200);
res.send({
path: req.hostname + req.baseUrl + req.path,
key: key,
purged: true,
clearedCompleteCache: false
});
res.end();
}
nextApp.prepare()
.then(() => {
const server = express();
// Do not use caching for _next files
server.get('/_next/*', (req, res) => {
defaultRequestHandler(req, res);
});
server.get('*', (req, res) => {
if (isDevEnvironment || req.query.noCache) {
res.setHeader('X-Cache-Status', 'DISABLED');
defaultRequestHandler(req, res);
} else {
cacheManager({req, res, pagePath: req.path});
}
});
server.purge('*', (req, res) => {
if (req.query.clearCache) {
clearCompleteCache(res, req);
} else {
clearCacheForRequestUrl(req, res);
}
});
server.listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000')
})
})
.catch((ex) => {
console.error(ex.stack);
process.exit(1)
});
Um zu testen, ob der Cache funktioniert, kann man sich den Response Header anschauen. Dort sollte beim ersten Aufrauf der Eintrag „X-Cache-Status: MISS“, im zweiten der Eintrag „X-Cache-Status: HIT“ und in der DEV-Umgebung bzw. wenn der Parameter „?noCache=true“ mitgegeben wurde ein „X-Cache-Status: DISABLED“ stehen:
Vor- und Nachteile
Mit Cacheable-Response hat man den Vorteil, dass einem viel Boylerplate Code abgenommen wird.
Der Nachteil ist aber ganz klar, dass man keine Kontrolle darüber hat, wie groß der Cache wird. Insbesondere bei großen Seiten mit einer komplexen Sitemap kann unter Umständen der Speicherverbrauch stark ansteigen.
In einem weiteren Artikel werde ich beschreiben, wie man dieses Problem mit einem etwas komplexeren Ansatz mittels LRU-Cache lösen kann. Eine einfach Variante findet man aber auch hier: Speeding up next.js application (server side in-memory caching via LRUcache).
Fazit
Auch wenn ich normalerweise immer gerne mit der einfacheren Lösung gehe, finde ich hier eine Implementation mit einem LRU Cache sinnvoller. Insbesondere die Möglichkeit, die Größe des Caches zu konfigurieren ist in meinen Augen eine Stärke, um das Risiko von Memoryleaks durch Caching zu reduzieren.
Spielt Speicher jedoch keine Rolle (weil z.B. genügend vorhanden ist oder die Applikation nicht so groß ist, dass man hier in Bedrängnis geraten könnte) ist ein Request Cache mit „Cacheable-Response“ alleine schon wegen der einfachen und kompfortablen Benutzbarkeit meine erste Wahl.
Ich hoffe, ich konnte jemandem weiterhelfen 😁
Referenzen
ReactJS: https://reactjs.org/
Isomorphes Rendern: https://blog.jscrambler.com/build-isomorphic-apps-with-next-js/
TTFB: https://developers.google.com/web/tools/lighthouse/audits/ttfb
TTFB SEO Impact: https://moz.com/blog/how-website-speed-actually-impacts-search-ranking
npm Package Cacheable-Response: https://www.npmjs.com/package/cacheable-response
NGinx Proxy Cache: https://docs.nginx.com/nginx/admin-guide/content-cache/content-caching/
Next.JS: https://nextjs.org/
Speeding up next.js application (server side in-memory caching via LRUcache): https://medium.com/@az/i18n-next-js-app-with-server-side-rendering-and-user-language-aware-caching-part-1-ae1fce25a693