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

参考資料

ブラウザによるキャッシュの仕組み

ウェブサーバは、キャッシュに関する情報をレスポンスヘッダに含めることで、そのレスポンスで返したリソースをどのようにキャッシュすべきかをウェブクライアントに指示することができる。ウェブクライアントはその指示に基づいた処理を行う。
この記事では、キャッシュの制御に使われる HTTP ヘッダについて見ていく。

動作確認に使った環境は以下の通り。

  • ウェブサーバ
    • Deno 1.4.4
  • ウェブクライアント
    • Google Chrome 86.0.4240.75

Last-Modified と If-Modified-Since

Last-Modifiedは、当該リソースの最終更新日時を伝えるためのヘッダで、値として以下のようなRFC-1123形式の日時を設定する。

Wed, 14 Oct 2020 15:00:00 GMT

レスポンスヘッダにLast-Modifiedフィールドを含めると、次にそのリソースに対してリクエストを発行した際に、リクエストヘッダにIf-Modified-Sinceが含まれるようになる。

例えば、以下のサーバを起動した状態でhttp://localhost:8080/cacheにアクセスすると、レスポンスヘッダにlast-modified: Wed, 14 Oct 2020 15:00:00 GMTが含まれる。
そして、2 回目以降のアクセスでは、リクエストヘッダにIf-Modified-Since: Wed, 14 Oct 2020 15:00:00 GMTが含まれるようになる。
http://localhost:8080/ではlast-modifiedを返していないので、何度アクセスしてもリクエストヘッダにIf-Modified-Sinceが含まれることはない。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.74.0/http/mod.ts";

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  const body = `<html>
<head></head>
<body>
  <p>a</p>
  <p>
    <a href="/">/</a>
  </p>
  <p>
    <a href="/cache">/cache</a>
  </p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    case "/cache": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
          "Last-Modified": new Date(2020, 9, 15).toUTCString(),
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

今回は実装していないが、リクエストヘッダのIf-Modified-Sinceの日時とサーバ側が持っている「最終更新日時」を比較し、リソースの更新がなければステータスコード304を返してキャッシュを使わせる、ということが可能になる。

ETag と If-None-Match

Last-Modifiedはリソースの更新日時でキャッシュを利用すべきか判断するが、ETagはリソースの中身でキャッシュを利用すべきか判断する。
具体的には、リソースのハッシュ値などをETagフィールドの値として設定する。

以下のコードでは、レスポンスボディの MD5 ハッシュ値を、ETagの値として使っている。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.74.0/http/mod.ts";
import { createHash } from "https://deno.land/std@0.74.0/hash/mod.ts";

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  const body = `<html>
<head></head>
<body>
  <p>a</p>
  <p>
    <a href="/">/</a>
  </p>
  <p>
    <a href="/cache">/cache</a>
  </p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    case "/cache": {
      const md5 = (value: string) => {
        const hash = createHash("md5");
        hash.update(value);
        return hash.toString();
      };
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
          "ETag": md5(body),
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

http://localhost:8080/cacheにアクセスすると、レスポンスヘッダにetag: 434bb620aed649cf07bac5554569f60fが含まれる。
そして、2 回目以降のアクセスでは、リクエストヘッダにIf-None-Match: 434bb620aed649cf07bac5554569f60fが含まれるようになる。

リソースが変わらない限りはETagの値も変わらないので、ETagが変わらない限りはキャッシュを使わせる、ということが可能になる。
先程のコードに以下の変更を行い、実装してみる。

         hash.update(value);
         return hash.toString();
       };
-      req.respond({
-        status: 200,
-        headers: new Headers({
-          "Content-Type": "text/html",
-          "ETag": md5(body),
-        }),
-        body,
-      });
+      const isValidCache = (ifNoneMatch: string | null) => {
+        if (ifNoneMatch === null) return false;
+        return ifNoneMatch === md5(body);
+      };
+      if (isValidCache(req.headers.get("if-none-match"))) {
+        req.respond({
+          status: 304,
+          headers: new Headers({
+            "Content-Type": "text/html",
+          }),
+        });
+      } else {
+        req.respond({
+          status: 200,
+          headers: new Headers({
+            "Content-Type": "text/html",
+            "ETag": md5(body),
+          }),
+          body,
+        });
+      }
       break;
     }
     default:

http://localhost:8080/cacheへの初回アクセスではステータスコード200を返すが、それ以降は304を返し、ブラウザにキャッシュを使わせるようになる。
<p>a</p>の部分を<p>b</p>に変えると、isValidCachefalseを返すようになるため、最新のリソースをステータスコード200で返す。
そしてまた、リソースが更新されない限りは304を返すようになる。

Expires

ここまでの例では、キャッシュを使う場合も HTTP リクエスト自体は発生していた。
Expiresフィールドを使うと、HTTP リクエストを抑制できる。
このフィールドを使って有効期限を設定することで、その期限が切れるまではクライアントは HTTP リクエスト自体を行わず、キャッシュを使い続けるようになる。
値のフォーマットは、Last-Modifiedと同様に、RFC-1123形式。

以下のコードでは、Expiresとして、サーバへのアクセスがあった20秒後の時間を設定している。
そのため、http://localhost:8080/cacheにアクセスしてから20秒間は、何度http://localhost:8080/cacheにアクセスしても、HTTP リクエストが発行されない。
サーバへのリクエストそのものが発生していないため、例えリソースが更新されたとしてもそれに気付くことができない。そのため、Expires以降にアクセスを行ってリクエストが発生するまでは、キャッシュされた古いリソースを使い続けることになる。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.74.0/http/mod.ts";

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  const body = `<html>
<head></head>
<body>
  <p>a</p>
  <p>
    <a href="/">/</a>
  </p>
  <p>
    <a href="/cache">/cache</a>
  </p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    case "/cache": {
      const now = new Date();
      now.setSeconds(now.getSeconds() + 20);
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
          "Expires": now.toUTCString(),
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

ここまで紹介してきたLast-ModifiedETagと組み合わせて使うことも可能。
その場合、Expiresが切れるまではキャッシュを使い続け、それ以降のアクセスについては、サーバがリソースの更新日時やハッシュ値を検証して、キャッシュを使うかどうかクライアントに指示を出すことになる。

Cache-Control

Cache-Controlによる指示は強力で、ここまで紹介してきた機能よりも優先される。

例えば、"Cache-Control": "no-store"は「キャッシュを利用しない」を意味しており、この指定がある場合、キャッシュを利用させようとする他の指示は無効化される。
先程の例に"Cache-Control": "no-store"を追加してみる。

         headers: new Headers({
           "Content-Type": "text/html",
           "Expires": now.toUTCString(),
+          "Cache-Control": "no-store",
         }),
         body,

Expiresで「20秒間は HTTP リクエストを行わずキャッシュを使う」と指示しているが、それは無効化され、アクセスの度に HTTP リクエストが発行される。

そしてno-storeは、Last-ModifiedETagも無効化する。
具体的には、レスポンスヘッダにLast-ModifiedETagを含めても、次回以降のリクエストヘッダにIf-Modified-SinceIf-None-Matchも含まれなくなる。
そのため、常に初回アクセスと同様に扱われ、キャッシュが使われない。

Cache-Controlには他に、no-cachemax-age=nなどを指定することができる。

no-cacheは、常に HTTP リクエストを発生させ、キャッシュを使うかどうかをサーバに判断させる。
こちらはIf-Modified-SinceIf-None-Matchは消されないため、これらの値を使って、キャッシュを使うかどうかをサーバに判断させることができる。

max-age=nは、n秒間は HTTP リクエストを行わず、キャッシュを使う。それ以降はIf-Modified-SinceIf-None-Matchを使って、キャッシュを使うかどうかサーバが判断する。

no-cachemax-age=nでもExpiresは無効化される。
Expiresの値がなんであれ、no-cacheの場合は常に HTTP リクエストが発行されるし、max-age=nの場合はExpiresの日時ではなく「n秒間はキャッシュを使う」という指定が採用される。

参考資料