30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

テキストリソースを圧縮してウェブサイトのパフォーマンスを改善する

ウェブサーバが配信するリソースのサイズは、ウェブサイトのパフォーマンスを左右する重要な要素。
画像や動画を最適化するのと同様に、テキストリソースのファイルサイズも削減することで、パフォーマンスによい影響を与える。
圧縮したテキストリソースを配信する際に使われる HTTP ヘッダのフィールドが、Accept-EncodingContent-Encoding。この記事では、これらのフィールドの使い方などを見ていく。

動作確認は以下の環境で行った。

  • Node.js14.13.0
  • Google Chrome86.0.4240.111
  • curl7.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フィールド。
以下のように、対応している圧縮フォーマットを列挙していく。この場合は、gzipdeflateに対応していることを意味する。

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フィールドはキャッシュサーバや検索エンジンに対するヒントとなるので、正しく使うことで、意図していないリソースが配信されたりインデックスされたりすることを防ぐことができる。

参考資料