В этой истории я собираюсь повторно использовать пример, который я написал для Как кодировать ответ Node.js с нуля, чтение этой истории полезно только в том случае, если вы хотите научиться кодировать свои ответы с нуля без сторонних модулей. , Я использую его повторно, потому что хочу использовать заголовок Content-Encoding
из ответов.
Кеширование браузера идет прямо из коробки, сервер должен отправить правильную информацию о кешировании в заголовке ответа, чтобы браузеры могли кэшировать файлы на своей стороне.
В этом примере я буду использовать заголовки Cache-Control
, Vary
, If-None-Match
и ETag
.
Cache-Control
Содержит инструкции по кэшированию, его значение представляет собой список директив, разделенных запятыми; этот заголовок можно использовать в запросах и ответах, в этом примере он будет использоваться только в ответе.Vary
указывает, какие заголовки использовать для кэширования на стороне клиента, если ответ соответствует значениям из уже сохраненного кеша, он будет использовать файлы кеша вместо отправки запроса; этот заголовок предоставляется ответом.ETag
Содержит значение, однозначно определяющее тело / файл ответа; этот заголовок указан в ответе.If-None-Match
Содержит значение ETag кэшированного файла для конкретного запроса; заголовок указан в запросе.
Теперь, когда мы знаем, какие заголовки вызывают кеширование, давайте перейдем к коду.
В фрагменте ниже я покажу, как включаю кеширование для всех файлов, запрошенных по пути images/
, и просто чтобы освежить память, наш HTML-сайт читает с этого пути только тег <img>
, как показано ниже <img id=”ramboImg” alt=”Rambo Gif” src=”images/rambo.gif”/>
В http.createServer()
первое, что я делаю, - это определяю, исходит ли запрос от image/
, используя startsWith
в URL-адресе запроса, а затем в качестве дополнительной проверки я хочу принимать только запросы get
и head
для этого URL:
if (request.url.startsWith('/images/') && /(get|head)/.test(request.method.toLowerCase())) {
Затем, поскольку наши внутренние изображения не находятся на том же сервере, где запущен Node.js, мы должны исправить путь к нашему FTP-серверу, на котором находятся изображения:
let filePath = `uploads${request.url}`
Затем мы устанавливаем набор значений заголовка с информацией, которую мы уже знаем, мы устанавливаем значение Content-Type
, полученное из самого запроса, используя вспомогательную функцию, которая возвращает правильное Content-Type
для каждого расширения изображения, затем мы устанавливаем Cache-Control
на истечение через 1 год и чтобы всегда проверять содержимое с сервером, указывая no-cache
, наконец, мы устанавливаем заголовок Vary
для использования ETag
и Content-Encoding
в качестве заголовков, которые система кэширования должна использовать для сопоставления кэшированных файлов на стороне клиента, важно включить Content-Encoding
, потому что файл может содержат одинаковые ETag
с разными кодировками или вообще без кодировки:
response.setHeader('Content-Type', getContentType(filePath)) response.setHeader('Cache-Control', `max-age=31536000, no-cache`) response.setHeader('Vary', 'ETag, Content-Encoding')
Затем мы проверяем, содержит ли запрос if-none-match
заголовок, который может содержать значение ETag
из более ранних ответов сервера, мы собираемся использовать это позже, чтобы сравнить его с недавно вычисленным ETag
.
let ifNoneMatchValue = request.headers['if-none-match']
После того, как приложение успешно установило FTP-соединение, я получаю дату последнего изменения, хэширую ее с помощью SHA1 и отправляю в качестве значения ответа ETag
, в реальных приложениях я бы кэшировал вычисленную контрольную сумму байтов реального файла и сохранял ее в база данных для более быстрого поиска вместо того, чтобы рассчитывать ее для каждого запроса:
let lastMod = await ftpClient.lastMod(filePath) let lastModHash = getSHA1(lastMod.toString()) response.setHeader('ETag', lastModHash)
Затем я сравниваю предоставленное запросом значение ETag из if-non-match и сравниваю его с вновь вычисленным ETag, если они совпадают, мы устанавливаем код состояния ответа на 304 и завершаем ответ, не отправляя больше байтов:
if (ifNoneMatchValue && ifNoneMatchValue === lastModHash) { response.statusCode = 304 body.end() }
Если запрос является запросом HEAD, не отправляйте байты независимо от того:
if (request.method.toLowerCase() === 'head') { body.end() }
Если запрос не заканчивается ни в одном из двух вышеуказанных условий, мы должны отправить фактические байты изображения, я делаю это с помощью клиентского модуля ftp:
await ftpClient.downloadTo(body, filePath)
Вы можете использовать браузер Chrome, чтобы убедиться, что кеширование работает правильно, в Chrome выберите View ›Developer› Developer Tools.
Инструменты разработчика могут открываться справа или внизу, мне нравится внизу, потому что в нем больше места для просмотра деталей, поэтому я всегда помещаю его туда:
Далее Щелкните вкладку сети:
На изображении выше не отображается никакой информации, потому что панель была закрыта при загрузке страницы, вы можете нажать CMD + R, чтобы перезагрузить сайт, или щелкнуть адресную строку и нажать Enter, чтобы отобразить трафик, и для этого примера сфокусируйтесь на rambo.gif запрос выделен ниже:
Как вы можете видеть, размер rambo.gif составляет 1,5 МБ, а для загрузки изображения потребовалось 360 мс, я сделал принудительное обновление (CMD + Shift + R) на Chrome, чтобы заставить Chrome вытащить все ресурсы с сервера вместо кеша для приведенный выше снимок экрана.
При следующем посещении сайта или при обновлении сайта (CMD + R) вы увидите, что размер запроса rambo.gif составляет всего 260 Б, а для его выполнения потребовалось 74 мс, что меньше, чем значок избранного! ха-ха; но он все равно немного медленнее, чем favicon.ico, потому что сервер проверяет ETag, отправленный запросом.
Если вы нажмете на rambo.gif, он покажет вам заголовки запроса и ответа:
В общем разделе вы можете увидеть, что сервер вернул 304 Not Modified.
Проверьте полный исходный код ниже
// Author: Salvador Guerrero | |
// This project uses supports gzip, deflate and br encoding. | |
// Uses ETag for caching up to one year for images | |
// References: | |
// A Web Developer's Guide to Browser Caching: https://www.codebyamir.com/blog/a-web-developers-guide-to-browser-caching | |
// If-None-Match: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match | |
// Vary: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary | |
'use strict' | |
const http = require('http') | |
const fs = require('fs') | |
const { pipeline, PassThrough } = require('stream') | |
// Third-party modules | |
const ftp = require("basic-ftp") | |
// Project modules | |
const { getSupportedEncoderInfo } = require('./EncodingUtil') | |
async function createDefaultFtpClient() { | |
const client = new ftp.Client(/*timeout = 180000*/) // 2min timeout for debug | |
// client.ftp.verbose = true | |
try { | |
await client.access({ | |
host: "localhost", | |
user: "anonymous", | |
password: "", | |
secure: false | |
}) | |
return client | |
} catch (e) { | |
throw new Error(`Could not connect to FTP server`) | |
} | |
} | |
function getSHA1(string) { | |
const crypto = require('crypto'); | |
const shasum = crypto.createHash('sha1'); | |
shasum.update(string) | |
return shasum.digest('hex') | |
} | |
function getContentType(filename) { | |
// TODO(sal): use real mapping | |
// here's an example with mappings: https://www.lifewire.com/mime-types-by-content-type-3469108 | |
let extension = filename.split('.').pop().toLowerCase(); | |
return `image/${extension}` | |
} | |
http.createServer((request, response) => { | |
let encoderInfo = getSupportedEncoderInfo(request) | |
if (!encoderInfo) { | |
// Encoded not supported by this server | |
response.statusCode = 406 | |
response.setHeader('Content-Type', 'application/json') | |
response.end(JSON.stringify({error: 'Encodings not supported'})) | |
return | |
} | |
let body = response | |
response.setHeader('Content-Encoding', encoderInfo.name) | |
// If encoding is not identity, encode the response =) | |
if (!encoderInfo.isIdentity()) { | |
const onError = (err) => { | |
if (err) { | |
// If an error occurs, there's not much we can do because | |
// the server has already sent the 200 response code and | |
// some amount of data has already been sent to the client. | |
// The best we can do is terminate the response immediately | |
// and log the error. | |
response.end() | |
console.error('An error occurred:', err) | |
} | |
} | |
body = new PassThrough() | |
pipeline(body, encoderInfo.createEncoder(), response, onError) | |
} | |
if (request.url === '/' && request.method.toLowerCase() === 'get') { | |
response.setHeader('Content-Type', 'text/html') | |
const stream = fs.createReadStream(`${__dirname}/index.html`) | |
stream.pipe(body) | |
} | |
else if (request.url === '/favicon.ico' && request.method.toLowerCase() === 'get') { | |
response.setHeader('Content-Type', 'image/vnd.microsoft.icon') | |
const stream = fs.createReadStream(`${__dirname}/rambo.ico`) | |
stream.pipe(body) | |
} | |
else if (request.url.startsWith('/images/') && /(get|head)/.test(request.method.toLowerCase())) { | |
let filePath = `uploads${request.url}` | |
response.setHeader('Content-Type', getContentType(filePath)) | |
// Cache for one year, but verify every on each request with no-cache | |
response.setHeader('Cache-Control', `max-age=31536000, no-cache`) | |
// Use Etag and content encoding for caching control on the client | |
response.setHeader('Vary', 'ETag, Content-Encoding') | |
let ifNoneMatchValue = request.headers['if-none-match'] | |
createDefaultFtpClient().then(ftpClient => { | |
(async () => { | |
try { | |
let lastMod = await ftpClient.lastMod(filePath) | |
let lastModHash = getSHA1(lastMod.toString()) | |
// In real life apps, for ETag use something more bullet proof like the hash of the bytes of the file. | |
response.setHeader('ETag', lastModHash) | |
if (ifNoneMatchValue && ifNoneMatchValue === lastModHash) { | |
// Content is cached, don't return a body | |
response.statusCode = 304 | |
body.end() | |
} else if (request.method.toLowerCase() === 'head') { | |
// This was a head request, don't send the actual bytes. | |
body.end() | |
} else { | |
// Content not cached, download the content from FTP and send the data | |
await ftpClient.downloadTo(body, filePath) | |
} | |
} catch (error) { | |
console.error(error) | |
response.statusCode = 500 | |
response.setHeader('Content-Type', 'application/json') | |
body.end(JSON.stringify({error: error.message})) | |
} | |
})() | |
}).catch(error => { | |
// There was a problem connecting to the FTP server | |
console.error(error) | |
response.statusCode = 500 | |
response.setHeader('Content-Type', 'application/json') | |
body.end(JSON.stringify({error: error.message})) | |
}) | |
} | |
// Error on any other path | |
else { | |
response.setHeader('Content-Type', 'text/html') | |
response.statusCode = 404 | |
body.end('<html lang="en"><body><h1>Page Doesn\'t exist<h1></body></html>') | |
} | |
}).listen(3000, () => { | |
console.log(`Server running at http://localhost:3000/`); | |
}) |
// Author: Salvador Guerrero | |
'use strict' | |
// https://nodejs.org/api/zlib.html | |
const zlib = require('zlib') | |
const kGzip = 'gzip' | |
const kDeflate = 'deflate' | |
const kBr = 'br' | |
const kAny = '*' | |
const kIdentity = 'identity' | |
class EncoderInfo { | |
constructor(name) { | |
this.name = name | |
} | |
isIdentity() { | |
return this.name === kIdentity | |
} | |
createEncoder() { | |
switch (this.name) { | |
case kGzip: return zlib.createGzip() | |
case kDeflate: return zlib.createDeflate() | |
case kBr: return zlib.createBrotliCompress() | |
default: return null | |
} | |
} | |
} | |
class ClientEncodingInfo { | |
constructor(name, qvalue) { | |
this.name = name | |
this.qvalue = qvalue | |
} | |
} | |
exports.getSupportedEncoderInfo = function getSupportedEncoderInfo(request) { | |
// See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3 | |
let acceptEncoding = request.headers['accept-encoding'] | |
let acceptEncodings = [] | |
let knownEncodings = [kGzip, kDeflate, kBr, kAny, kIdentity] | |
// If explicit is true, then it means the client sent *;q=0, meaning accept only given encodings | |
let explicit = false | |
if (!acceptEncoding || acceptEncoding.trim().length === 0) { | |
// If the Accept-Encoding field-value is empty, then only the "identity" encoding is acceptable. | |
knownEncodings = [kIdentity] | |
acceptEncodings = [new ClientEncodingInfo(kIdentity, 1)] | |
} else { | |
// NOTE: Only return 406 if the client sends 'identity;q=0' or a '*;q=0' | |
let acceptEncodingArray = acceptEncoding.split(',') | |
for (let encoding of acceptEncodingArray) { | |
encoding = encoding.trim() | |
if (/[a-z*];q=0$/.test(encoding)) { | |
// The "identity" content-coding is always acceptable, unless | |
// specifically refused because the Accept-Encoding field includes | |
// "identity;q=0", or because the field includes "*;q=0" and does | |
// not explicitly include the "identity" content-coding. | |
let split = encoding.split(';') | |
let name = split[0].trim() | |
if (name === kAny) { | |
explicit = true | |
} | |
knownEncodings.splice(knownEncodings.indexOf(name), 1) | |
} else if (/[a-z*]+;q=\d+(.\d+)*/.test(encoding)) { | |
// This string contains a qvalue. | |
let split = encoding.split(';') | |
let name = split[0].trim() | |
let value = split[1].trim() | |
value = value.split('=')[1] | |
value = parseFloat(value) | |
acceptEncodings.push(new ClientEncodingInfo(name, value)) | |
} else { | |
// No qvalue, treat it as q=1.0 | |
acceptEncodings.push(new ClientEncodingInfo(encoding.trim(), 1.0)) | |
} | |
} | |
// order by qvalue, max to min | |
acceptEncodings.sort((a, b) => { | |
return b.qvalue - a.qvalue | |
}) | |
} | |
// `acceptEncodings` is sorted by priority | |
// Pick the first known encoding. | |
let encoding = '' | |
for (let encodingInfo of acceptEncodings) { | |
if (knownEncodings.indexOf(encodingInfo.name) !== -1) { | |
encoding = encodingInfo.name | |
break | |
} | |
} | |
// If any, pick a known encoding | |
if (encoding === kAny) { | |
for (let knownEncoding of knownEncodings) { | |
if (knownEncoding === kAny) { | |
continue | |
} else { | |
encoding = knownEncoding | |
break | |
} | |
} | |
} | |
// If no known encoding was set, then use identity if not excluded | |
if (encoding.length === 0) { | |
if (!explicit && knownEncodings.indexOf(kIdentity) !== -1) { | |
encoding = kIdentity | |
} else { | |
console.error('No known encoding were found in accept-encoding, return http status code 406') | |
return null | |
} | |
} | |
return new EncoderInfo(encoding) | |
} |
<html lang="en"> | |
<head> | |
<title>Encoding and Caching</title> | |
</head> | |
<body> | |
<h3>Encoding & Caching Example</h3> | |
<img id="ramboImg" alt="Rambo Gif" src="images/rambo.gif"/> | |
</body> | |
</html> |
Хорошо, на сегодня все, статья, которая мне очень помогла в понимании кеширования, находится здесь, я рекомендую проверить ее, а также Документы Mozilla.
Se y’all later ✌️