ウェブサーバが配信するリソースのサイズは、ウェブサイトのパフォーマンスを左右する重要な要素。
画像や動画を最適化するのと同様に、テキストリソースのファイルサイズも削減することで、パフォーマンスによい影響を与える。
圧縮したテキストリソースを配信する際に使われる HTTP ヘッダのフィールドが、Accept-EncodingとContent-Encoding。この記事では、これらのフィールドの使い方などを見ていく。
動作確認は以下の環境で行った。
- Node.js
14.13.0 - Google Chrome
86.0.4240.111 - curl
7.54.0
Node.js で gzip を使う
今回は例として、index.htmlという名前の以下の HTML ファイルを、gzipという圧縮フォーマットで配信する。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Document</title> </head> <body> <p>abc</p> <p>abc</p> <p>abc</p> <p>123</p> <p>123</p> <p>123</p> </body> </html>
gzipは多くのウェブサーバやウェブクライアントが対応しており、広く利用されている。
Node.js でgzipを使うには、zlibという標準ライブラリを使う。
const zlib = require('zlib'); const fs = require('fs'); fs.readFile('index.html', 'utf-8', (err, text) => { zlib.gzip(Buffer.from(text), (err, compressedData) => { console.log(text.length); // 200 console.log(compressedData.byteLength); // 133 zlib.gunzip(compressedData, (err, decoded) => { const restoredText = decoded.toString('utf-8'); console.log(restoredText.length); // 200 }) }) })
zlib.gzipで圧縮を行い、zlib.gunzipで展開を行う。
ファイルサイズを見れば分かるように、200バイトだった HTML ファイルを133バイトまで削減できている。
このように、テキストリソースを圧縮してから配信することで、データ転送量が削減され、パフォーマンスが改善される。
ヘッダによるコンテントネゴシエーション
同一の URL に HTTP リクエストを出しても、同じリソースが返ってくるとは限らない。クライアントに応じて異なるリソースが返ってくる可能性がある。
例えば、多言語対応しており、クライアントが使っている言語と同じ言語のリソースを返す場合などがそれにあたる。
HTTP では、コンテントネゴシエーションという仕組みによって、クライアントが期待しているリソースを返せるようにしている。
リクエストヘッダに、クライアントが何を期待しているのかという情報を含める。そしてウェブサーバはそれを見てどのようなリソースを返すのか決めて、どのようなリソースを返したのかという情報をレスポンスヘッダに含める。
テキストリソースの圧縮の場合も、コンテントネゴシエーションが必要になる。
そうしないと、圧縮されたリソースをクライアントが扱えない可能性があるからだ。
例えば、使っているブラウザがgzipに対応していない場合、gzipで圧縮されたリソースを取得しても、それを展開することができない。
そのため、まずクライアントが、どの圧縮フォーマットなら扱えるのかをウェブサーバに伝える必要がある。
そのために使われるのが、Accept-Encodingフィールド。
以下のように、対応している圧縮フォーマットを列挙していく。この場合は、gzipとdeflateに対応していることを意味する。
Accept-Encoding: gzip, deflate
ウェブサーバはこの情報を元に、どのようなリソースを返すのかを判断する。
そしてレスポンスヘッダにContent-Encodingフィールドを含めて、どの圧縮フォーマットを使っているのかをクライアントに伝える。
その情報に基づいて、クライアントはリソースを展開する。
以下の例では、gzipで圧縮したリソースを返していることをクライアントに伝えている。
Content-Encoding: gzip
この仕組みによって、確実にクライアントが処理できるフォーマットで、リソースを返すことができる。
また、新しい圧縮フォーマットを積極的に導入していくことが可能になる。
コンテントネゴシエーションによって、「新しい圧縮フォーマットに対応しているクライアントにのみ、そのフォーマットを採用する」ということが可能になるからである。
実際に試してみる
まずはクライアント。
以下は、Google Chrome でリクエストを送ったときのAccept-Encoding。
Accept-Encoding: gzip, deflate, br
curl の場合、デフォルトでは、Accept-Encodingフィールドは付与されない。--compressedオプションを使うと、以下の内容が付与される。
Accept-Encoding: deflate, gzip
次にサーバ。
const zlib = require('zlib'); const fs = require('fs'); const http = require('http'); http.createServer((req, res) => { fs.readFile('index.html', 'utf-8', (err, text) => { const isGzip = (acceptEncoding) => { if (!acceptEncoding) return false; return acceptEncoding.split(',').map((item) => item.trim()).includes('gzip'); } res.setHeader('Content-Type', 'text/html'); if (isGzip(req.headers["accept-encoding"])) { zlib.gzip(Buffer.from(text), (err, compressedData) => { res.setHeader('Content-Encoding', 'gzip'); res.setHeader('Content-Length', compressedData.byteLength); res.writeHead(200); res.end(compressedData); }) } else { res.setHeader('Content-Length', text.length); res.writeHead(200); res.end(text); } }) }).listen(8080);
上記のコードを実行してhttp://localhost:8080/にアクセスすると、Accept-Encodingの内容に応じてリソースを返す。
Content-Lengthは、圧縮後の値を使う。
HEADメソッドのリクエストを送る-Iオプションを使って、確認してみる。
$ curl -I http://localhost:8080 HTTP/1.1 200 OK Content-Type: text/html Content-Length: 200 Date: Sat, 24 Oct 2020 06:42:11 GMT Connection: keep-alive Keep-Alive: timeout=5
$ curl -I --compressed http://localhost:8080 HTTP/1.1 200 OK Content-Type: text/html Content-Encoding: gzip Content-Length: 133 Date: Sat, 24 Oct 2020 06:42:14 GMT Connection: keep-alive Keep-Alive: timeout=5
--compressedオプションを使ってgzipに対応していることを伝えた場合は圧縮されたリソースが、そうでない場合は圧縮されていないリソースが返ってきていることが分かる。
前述の通り Google Chrome はgzipに対応しているので、圧縮されたリソースが返ってくる。
そしてリソースの展開は Google Chrome が行うので、http://localhost:8080/にアクセスすれば、ウェブページが正しく表示れる。
クライアントはContent-Encodingの値に基づいて展開を行うので、この値が間違っていると展開に失敗してしまう。
例えば、gzipで圧縮しているにも関わらずContent-Encoding: deflateとすると、クライアント側で以下のエラーが発生する。
Google Chrome の場合 net::ERR_CONTENT_DECODING_FAILED curl の場合 Error while processing content unencoding: invalid block type
また、圧縮しているにも関わらずContent-Encodingフィールドを付与しなかった場合、クライアントは展開を行わないため、文字化けしたかのような文字列を表示してしまう。
Vary フィールド
コンテントネゴシエーションの仕組みは、キャッシュサーバで問題になることがある。
例えば、まずgzipに対応しているクライアントからアクセスがあり、gzipで圧縮したリソースを返したとする。そしてその際に、キャッシュサーバがそのリソースをキャッシュする。そうするとそれ以降に同一 URL にアクセスがあった場合、キャッシュサーバがキャッシュ済みのリソースを返すようになる。
こうなると、gzipに対応していないクライアントからアクセスがあった場合も、gzipで圧縮したリソースを返すことになってしまう。
レスポンスヘッダにVaryフィールドを含めることで、この問題を回避できる。
リクエストヘッダの内容によって返すリソースが変わる場合に、変わる要因となっているフィールド名をVaryの値にする。
今回の例で言えばAccept-Encodingによってレスポンスの内容が変わっているので、以下のフィールドをレスポンスヘッダに含める。
Vary: Accept-Encoding
Varyフィールドはキャッシュサーバや検索エンジンに対するヒントとなるので、正しく使うことで、意図していないリソースが配信されたりインデックスされたりすることを防ぐことができる。