ETag/If-None-Match

Scritto da Francesco Di Donato • 21 novembre 2023 • 5 minuti di lettura
Nel post precedente 🔗, abbiamo esplorato l’utilità degli header di risposta Last-Modified
e di richiesta If-Modified-Since
. Funzionano molto bene quando si ha a che fare con un endpoint che restituisce un file.
Ma cosa succede con i dati recuperati da un database o assemblati da fonti diverse?
Richiesta | Risposta | Esempio di valore |
---|---|---|
Last-Modified |
If-Modified-Since |
Thu, 15 Nov 2023 19:18:46 GMT |
ETag |
If-None-Match |
75e7b6f64078bb53b7aaab5c457de56f |
Anche qui, abbiamo una coppia di header. Uno deve essere fornito dal richiedente (ETag
), mentre l’altro viene restituito dal mittente (If-None-Match
). Il valore è un hash generato sul contenuto della risposta.
Se vuoi passare direttamente all’uso degli header, vai alla sezione endpoint. Altrimenti, osserva (ma non dedicarci troppo tempo) l’implementazione.
Preparazione
Per semplicità, usiamo un DB in memoria. È esposto tramite l’endpoint /db
. Contiene una lista di posts
. Ogni post contiene un title
e un tag
. I post possono essere aggiunti tramite POST
e modificati tramite PATCH
.
Il recupero avviene tramite una funzione GET
, che opzionalmente filtra per tag
.
import { getJSONBody } from './utils.mjs';
const POSTS = [
{ title: 'Caching', tag: 'code' },
{ title: 'Headers', tag: 'code' },
{ title: 'Dogs', tag: 'animals' }
];
export function GET(tag) {
let posts = POSTS;
if (tag) posts = posts.filter((post) => post.tag === tag);
return posts;
}
export default async function db(req, res) {
switch (req.method) {
case 'POST': {
const [body, err] = await getJSONBody(req);
if (err) {
res.writeHead(500).end('Qualcosa è andato storto');
return;
}
POSTS.push(body);
res.writeHead(201).end();
return;
}
case 'PATCH':
const [body, err] = await getJSONBody(req);
if (err) {
res.writeHead(500).end('Qualcosa è andato storto');
return;
}
POSTS.at(body.index).title = body.title;
res.writeHead(200).end();
return;
}
}
export function getURL(req) {
return new URL(req.url, `http://${req.headers.host}`);
}
export async function getJSONBody(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('error', (err) => resolve([null, err]));
req.on('end', () => resolve([JSON.parse(body), null]));
});
}
Endpoint
Registrando il db, saremo in grado di modificare il contenuto delle risposte in tempo reale, apprezzando l’utilità dell’ETag.
Inoltre, registriamo e creiamo l’endpoint /only-etag
.
import { createServer } from 'http';
import db from '.src/db.mjs';
import onlyETag from './src/only-etag.mjs';
import { getURL } from './src/utils.mjs';
createServer(async (req, res) => {
switch (getURL(req).pathname) {
case '/only-etag':
return await onlyETag(req, res);
case '/db':
return await db(req, res);
}
}).listen(8000, '127.0.0.1', () => console.info('Esposto su [http://127.0.0.1:8000](http://127.0.0.1:8000)'));
L’endpoint onlyETag
accetta un parametro di query opzionale tag
. Se presente, viene utilizzato per filtrare i post recuperati.
Quindi, il template viene caricato in memoria.
<html>
<body>
<h1>Tag: %TAG%</h1>
<ul>
%POSTS%
</ul>
<form method="GET">
<input type="text" name="tag" id="tag" autofocus />
<input type="submit" value="filtra" />
</form>
</body>
</html>
Quando inviato, il form usa come action
la rotta corrente (/only-etag
) aggiungendo come parametro di query l’attributo name
. Ad esempio, digitando code
nell’input e inviando il form si otterrebbe GET /only-etag?name=code
), Nessun JavaScript richiesto!
E i post vengono iniettati al suo interno.
import * as db from './db.mjs';
import { getURL, getView, createETag } from './utils.mjs';
export default async (req, res) => {
res.setHeader('Content-Type', 'text/html');
const tag = getURL(req).searchParams.get('tag');
const posts = await db.GET(tag);
let [html, errView] = await getView('posts');
if (errView) {
res.writeHead(500).end('Errore Interno del Server');
return;
}
html = html.replace('%TAG%', tag ?? 'all');
html = html.replace('%POSTS%', posts.map((post) => `<li>${post.title}</li>`).join('\n'));
res.setHeader('ETag', createETag(html));
res.writeHead(200).end(html);
};
Come puoi notare, prima di inviare la risposta, l’ETag viene generato e incluso nell’header di risposta ETag
.
import { createHash } from 'crypto';
export function createETag(resource) {
return createHash('md5').update(resource).digest('hex');
}
Modificare il contenuto della risorsa cambia l’Entity Tag.
Eseguendo la richiesta dal browser puoi ispezionare gli Header di Risposta tramite la scheda Network dei Developer Tools.
HTTP/1.1 200 OK
Content-Type: text/html
ETag: 4775245bd90ebbda2a81ccdd84da72b3
Se aggiorni la pagina, noterai che il browser aggiunge l’header If-None-Match
alla richiesta. Il valore corrisponde, ovviamente, a quello che ha ricevuto prima.
GET /only-etag HTTP/1.1
If-None-Match: 4775245bd90ebbda2a81ccdd84da72b3
Come visto nei post precedenti per Last-Modified
e If-Modified-Since
, istruiamo l’endpoint a gestire If-None-Match
.
export default async (req, res) => {
res.setHeader("Content-Type", "text/html");
recupera i post (filtrati); // come visto prima
carica l'html; // come visto prima
popola il template; // come visto prima
const etag = createETag(html);
res.setHeader("ETag", etag);
const ifNoneMatch = new Headers(req.headers).get("If-None-Match");
if (ifNoneMatch === etag) {
res.writeHead(304).end();
return;
}
res.writeHead(200).end(html);
};
Infatti, le richieste successive sulla stessa risorsa restituiscono 304 Not Modified
, istruendo il browser a usare le risorse memorizzate in precedenza. Richiediamo:
/only-etag
tre volte di seguito;/only-etag?tag=code
due volte;/only-etag?tag=animals
due volte;/only-etag
, senza tag, ancora una volta;
La presenza del parametro di query determina un cambiamento nella risposta, quindi nell’ETag.
Nota l’ultimo. Non importa che ci siano state altre richieste nel frattempo; il browser mantiene una mappa di richieste (inclusi i parametri di query) ed ETag.
Rilevare la modifica dell’entità
Per sottolineare ulteriormente l’importanza di questa funzionalità, aggiungiamo un nuovo post al DB da un altro processo.
curl -X POST [http://127.0.0.1:8000/db](http://127.0.0.1:8000/db) \
-d '{ "title": "ETag", "tag": "code" }'
E richiediamo di nuovo /only-etag?tag=code
.
Dopo che il db è stato aggiornato, la stessa richiesta ha generato un ETag diverso. Quindi, il server ha inviato al client una nuova versione della risorsa, con un ETag appena generato. Le richieste successive torneranno al comportamento previsto.
Lo stesso accade se modifichiamo un elemento della risposta.
curl -X PATCH [http://127.0.0.1:8000/db](http://127.0.0.1:8000/db) \
-d '{ "title": "Amazing Caching", "index": 0 }'
Sebbene l’ETag sia una soluzione più versatile, applicabile indipendentemente dal tipo di dati poiché si basa sul contenuto, si deve considerare che il server deve comunque recuperare e assemblare la risposta, quindi passarla alla funzione di hashing e confrontarla con il valore ricevuto.
Grazie a un altro header, Cache-Control
, è possibile ottimizzare il numero di richieste che il server deve elaborare.