かみやんの技術者ブログ

主にプログラムの話です

Webシステムのフロントエンド高速化で最初にやるべきこと

前回のエントリーで、Dartの次は、TypeScriptを検証する。と書いたけど、なぜか自分のPCでは、VisualStudio for WebにTypeScriptのプラグインがインストールできなかったので、TypeScriptを使うことを諦めました。コマンドラインコンパイルはできたけど、それでは型付け言語のメリットであるIDEによる補完や参照検索やリネームリファクタリングが効かないので。ちなみにプログラマのPCではあっさりインストールできたとのこと。がっくり。

というわけで、Dartを実戦投入することを決定してDartで開発をしています。

フロントエンド高速化のExpiresヘッダ

さて、今日の本題。Webシステムのフロントエンド高速化のお話です。Webシステムの速度の大きなボトルネックとしてDB負荷がありますが、ブラウザ側のレンダリングを高速化する話としてフロントエンド高速化があります。一般には、

  • hmtl, css, JavaScriptなどのテキストデータはgzip圧縮転送しましょう
  • htmlからJavaScriptのインクルードは、</body>の直前に書きましょう
  • cssからcssをインポートする@importは使わないようにしましょう
  • htmlからcssのインクルードは、<head>タグの中などhtmlの先頭近くに置きましょう
  • JavaScriptcssはhtmlの中にインラインで極力書かずに別ファイルにしてブラウザのキャッシュを効かせましょう
  • 静的コンテンツにはExpiresヘッダをつけてブラウザのキャッシュを効かせましょう

などがあります。が、もっとも効果的なのは最後のExpiresヘッダの追加ではないか。と。特にスマホなどのモバイル端末からのアクセスなど回線が遅い場合には、絶大な効果を発揮するはず。と。
とにかく俺は高速化が大好きなのだ!!

Expiresヘッダとは

HTTPサーバがレスポンスを返すときに、

Expires: Sun, 17 Feb 2014 22:28:04 JST

のような日付を返すと、その時刻まではこのコンテントは変更しないよ。とブラウザに伝えるもので、上記だと「2014/2/17」まで変更しないよ。と伝えています。つまり、今から1年後を指定しています。
通常ブラウザは、Expiresの期限が来るまではコンテントをストレージにキャッシュし、二度とサーバに同じURLをリクエストしません(キャッシュがいっぱいになったので消すとかがなければ)。
ブラウザがHTTPリクエストを送らなくなるので、通信時間は減り、サーバが応対する必要がなく、AWSのようにトラフィック料がかかるサーバ費用も抑えられます。
という訳でExpiresヘッダの威力は絶大だ!

Apacheの場合

mod_expiresを追加して、

ExpiresDefault "access plus 1 years"

を書くだけでOKです。
MIME-Type別に指定する場合は、

ExpiresByType text/html "access plus 1 years"

のようにExpiresByTypeを利用すればOKです。
ただ、MIME-Typeだけでは静的コンテンツと動的コンテンツの区別がつかないので、僕は、JavaServlet側で対応しました(PHPとかRubyとかでも同様でしょう)。

コンテントの更新に対応

Expiresヘッダを追加した場合、二度とブラウザが同じURLをアクセスして来なくなる可能性が高いため、静的コンテントを更新したときに問題になります。その場合は、URLを変えるしかありません。通常はファイルのバージョン番号を入れるか最終更新日を入れるかです。

<img src="hello.png">

と書く代わりに

<img src="hello.png?20130217">

などのように書くということです。上記の20130217は、当然最終更新日が「2013/02/17」という意味です。hello.pngを更新する度にjspの?20130217を修正していくのは大変すぎるので、

<img src=<%=Resource.get("hello.png")%> >

というようにResource.get()を通すようにしました。Resourceクラスは、自作のクラスで静的コンテントのURLを入力しその最終更新日を「{URL}?lm=YYYYMMddHHmmss」形式で返すものです。
Resource#get()メソッドが最終更新日を得る方法ですが、デバッグモードとリリースモードの2つを用意しました。デバッグモードでは、リクエストされたURLが存在するローカルのファイルの最終更新日をみてそれを返します。
リリースモードでは、事前にバッチファイルで作成したlastModified.propertiesファイルを参照し高速にレスポンスするものです。lastModified.propertiesファイルは、キーがURL、値がYYYYMMddHHmmss形式の更新日になっています。本番環境にデプロイするときにバッチを起動してlastModified.propertiesを更新します。lastModified.propertiesファイルは更新されるとTomcatが感知して自動リロードしてくれます。それ以外のときのpropertiesファイルの参照ではJavaのライブラリ内でpropertiesファイルをキャッシュしてくれていてメモリアクセスしかないので高速です。

拡張子

ちなみに僕が管理しているプロジェクトでは、静的コンテンツは*.css, *.js, *.pngなどのように通常の拡張子を使い、動的コンテンツは実態がServletでもJSPでもレスポンスがtext/htmlでもimage/pngでもtext/cssでもtext/javascriptでも拡張子を*.jspとしている。
CDNコンテンツデリバリネットワーク)などのキャッシュを効かせる時にも動的か静的かで分けたほうがよいので。

ベンチマーク

高速化を組み込んだ結果、

内容 高速化前 高速化後
3Gでのページが表示されるまでの時間 9秒 1秒
LANでのページが表示されるまでの時間 700ms 200ms
転送量 1Mbyte 70kbyte

という訳で劇的に高速化されました。ちなみに転送量70kbyteというのは静的コンテンツがすべてキャッシュにヒットした場合(つまり初めてサイトに訪れたときではなく2度目以降)です。転送量70kbyteというのは、すべてjspがレスポンスしたHTMLで、ここをgzip圧縮すると9kbyteに減りました。画像レスポンスはゼロに。
つまりgzip圧縮をして70kbyteが9kbyteになっても1Mbyteの画像が減らなければ効果なし。画像レスポンスを0にした上でgzip圧縮は効果あり。と。
あと転送量の影響はすくないもののcssのキャッシュヒットもページレンダリングに大きく影響します。なにせcssファイルが届かなければほぼレンダリングは進まないので。
高速化!高速化!高速化!

Resource.get()のソース

Resource.get()の実体は、下記のような感じ。

public class Resource {
    /**
     * URLを入力してYYYYMMddHHmmss形式の日付を返す<br>
     * 例) 入力 css/base.css 出力 201302101415
     * 
     * @param uri
     * @return みつからないときは、""
     */
    public static String getDate(String uri) {
        ResourceBundle settings = ResourceBundle.getBundle("settings");
        if ("true".equalsIgnoreCase(settings.getString("debug.mode")) == false) {// リリースモードのとき
            ResourceBundle lm = ResourceBundle.getBundle("lastModified");
            if (lm.containsKey(uri)) {
                return lm.getString(uri);
            } else {
                ErrorUtil.sendInfo(uri
                        + " is not found in lastModified.properties.");
                return "";
            }
        } else {// デバッグモードのとき
            try {
                SimpleDateFormat sdf = new SimpleDateFormat("YYYYMMddHHmmss");
                String path = settings.getString("servlet.context") + '/' + uri;
                Path p = FileSystems.getDefault().getPath(path);
                FileTime t = Files.getLastModifiedTime(p);
                Date d = new Date(t.toMillis());
                return sdf.format(d);
            } catch (IOException e) {
                ErrorUtil.sendInfo(uri
                        + " is not found at Resource#getDate() in debug mode.");
                return "";
            }
        }
    }

    /**
     * URLを入力して、 "{url}?YYYYMMddHHmmss" を返す(ダブルクォートあり版)<br>
     * 例) 入力 css/base.css 出力 "css/base.css?lm=201302101415"
     * 
     * @param uri
     * @return
     */
    public static String get(String uri) {
        String date = getDate(uri);
        return "\"" + (date.length() > 0 ? uri + "?lm=" + date : uri) + "\"";
    }
}

StaticResourceServlet.java

〜.cssや〜.pngや〜.jsの静的コンテンツをレスポンスするServletのソースは下記になります。

@WebServlet(urlPatterns = { "*.css", "*.js", "*.png", "*.jpg", "*.jpeg", "*.gif" })
public class StaticResourceServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;

    /**
     * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse
     *      response)
     */
    protected void doGet(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        try {
            doNext(request, response);
        } catch (Exception e) {
            ErrorUtil.sendError(e);
            throw new ServletException();
        }
    }

    /**
     * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse
     *      response)
     */
    protected void doPost(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        try {
            doNext(request, response);
        } catch (Exception e) {
            ErrorUtil.sendError(e);
            throw new ServletException();
        }
    }

    /**
     * メイン処理
     * 
     * @param request
     * @param response
     * @throws Exception
     */
    private void doNext(HttpServletRequest req, HttpServletResponse res)
            throws Exception {
        String uri = req.getRequestURI();
        String contextPath = req.getServletContext().getContextPath() + '/';
        String fname = uri.substring(contextPath.length());
        String realPath = req.getServletContext().getRealPath("")
                + File.separator + fname;
        if (uri.indexOf("WEB-INF") != -1) {// WEB-INFが含まれているとき
            throw new Exception("URLにWEB-INFが含まれています");
        }
        if (uri.indexOf("..") != -1) {// ..が含まれているとき
            throw new Exception("URLに..が含まれています");
        }
        if (uri.endsWith(".js")) {// JavaScriptのとき
            res.setCharacterEncoding("UTF-8");
            res.setContentType("text/javascript");
        } else if (uri.endsWith(".css")) {// CSSのとき
            res.setCharacterEncoding("UTF-8");
            res.setContentType("text/css");
        } else if (uri.endsWith(".png")) {// pngのとき
            res.setContentType("image/png");
        } else if (uri.endsWith(".jpg") || uri.endsWith(".jpeg")) {// jpegのとき
            res.setContentType("image/jpeg");
        } else if (uri.endsWith(".gif")) {// gifのとき
            res.setContentType("image/gif");
        } else {
            throw new Exception("サポートしていない拡張子");
        }
        ResponseUtil.setExpiresHeader(res);// Expiresヘッダ
        if (req.getParameter("lm") == null) {// lmパラメータがないとき
            ErrorUtil
                    .sendInfo(uri
                            + "のリクエストでlmパラメータがついていません。Expiresヘッダ高速化に必要です。 at StaticResourceServlet");
        }
        ByteArrayOutputStream baos = null;
        BufferedInputStream bis = null;
        ByteArrayInputStream bais = null;
        try {
            baos = new ByteArrayOutputStream();
            bis = new BufferedInputStream(new FileInputStream(realPath));
            // ファイルからメモリへコピー
            byte[] buf = new byte[4096];
            int size;
            while ((size = bis.read(buf)) >= 0) {
                baos.write(buf, 0, size);
            }
            // メモリからレスポンスへ出力
            res.setContentLength(baos.size());// Content-Lengthヘッダ
            bais = new ByteArrayInputStream(baos.toByteArray());
            ServletOutputStream out = res.getOutputStream();
            while ((size = bais.read(buf)) >= 0) {
                out.write(buf, 0, size);
            }
            out.flush();
        } finally {
            if (bis != null) {
                try {
                    bis.close();
                } catch (Exception e) {
                }
            }
            if (baos != null) {
                try {
                    baos.close();
                } catch (Exception e) {
                }
            }
            if (bais != null) {
                try {
                    bais.close();
                } catch (Exception e) {
                }
            }
        }
    }
}

その他のパターン

ファイルシステム上にそのままファイルが置かれている静的コンテンツ以外に、DBに静的コンテンツがある場合は、DBのフィールドにLastUpdateなどのTIMESTAMP型を用意して、Resource.get()で取れるようにしています。
あと、JavaScriptから静的コンテンツの最終更新日を取る部分では、JavaScript版のResource.get()相当のメソッド(内容はURLと更新日の連想配列データを埋め込んだもの)をサーバがレスポンスするようにしています。

最初にやりましょう!

この仕組みですが、プロジェクトの開発終了直前でやるはめになりましたが、Resource.get()に書き換え箇所が膨大だったため、死にそうでした。
死にそうでもやる価値はあるので皆さんやった方がよいですが、できればプロジェクトの開始時に仕組みを導入しておいた方が賢明です。
高速化!高速化!高速化!

――
アプリ受託開発、Webシステム受託開発も行っています。以下自社製品。
URL:ibisMail Freeのダウンロード、レビューはこちら
URL:ibisMail for iPhoneのダウンロード、レビューはこちら
URL:ibisMail for iPadのダウンロード、レビューはこちら
URL:ibisPaintのダウンロード、レビューはこちら サイトは、ibispaint.com
ご意見ご要望はTwitterから: @kamiyan