ウェブサーバが配信するリソースのサイズは、ウェブサイトのパフォーマンスを左右する重要な要素。
画像や動画を最適化するのと同様に、テキストリソースのファイルサイズも削減することで、パフォーマンスによい影響を与える。
圧縮したテキストリソースを配信する際に使われる 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
フィールドはキャッシュサーバや検索エンジンに対するヒントとなるので、正しく使うことで、意図していないリソースが配信されたりインデックスされたりすることを防ぐことができる。