ETag/If-None-Match

ETag/If-None-Match

Scritto da Francesco Di Donato 21 novembre 2023 5 minuti di lettura

Codice di supporto 🔗

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.