RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

Node.js+フレームワーク「Express」構成でのWEBサービスでコンテンツのgzip配信を独自実装した件について

f:id:tech-rakus:20201111161621p:plain

はじめに

こんにちは、YSです。
今回は、Node.js+フレームワーク「Express」を使用して作成されたwebアプリケーションで
静的コンテンツをgzip配信できるように独自で対応した件について共有させていただきます。



基本知識

まず前提となるNode.jsとフレームワーク「Express」について簡単に解説させていただきます。
こちらは初心者向けの説明となりますので、不要な方は読み飛ばしてください。

Node.jsとは?

Node.jsは、サーバサイドのJavaScriptです。
一般的なJavaScriptは、クライアントのブラウザ上で動作するものでしたが、
Node.jsではJavaScriptがサーバサイドで動作し、WEBサービス等を立ち上げることができます。

Node.jsはシングルスレッドで実行され、非同期処理が行えます。
これらの特性により、負荷に強く大量アクセスによるC10K問題が起こらないように設計されています。

C10K問題とは、サーバサイドの処理において、クライアント数が約1万台に達するとメモリの不足・プロセス数の上限・ファイルディスクリプタの上限・コンテキストスイッチのコスト増加等が発生し、リソースが不足し処理性能が劣化してしまう問題です。

Node.jsは次のような仕組みにより、負荷に強い仕組みが実現されています。

  • シングルスレッド

    スレッドを単体にすることにより、大量のプロセスやファイルディスクリプタを発生させないようになっています。

  • イベントループ

    イベントをキューとして扱い、順番に処理していくモデルです。

  • ノンブロッキングI/O

    データのI/O処理の完了待ちによるブロックを発生させずに並列に処理できます。

Expressとは?

Node.js用のWebアプリケーションのMVCフレームワークです。

Node.jsで一番メジャーなフレームワークであり、Node.jsのWebアプリケーションに関する書籍や記事などはExpressありきで書かれている事が多いです。

Expressはガッツリとした多機能なフレームワークではなく、Webアプリケーションを開発するにあたって必要な部品が揃った軽量なフレームワークであり、自由度も高いです。

Expressのインストール方法

次のコマンドでインストールできます。 ※npmはNode.jsのパッケージ管理システムです。

npm install express --save
プロジェクトの作成

いちから自作でプロジェクトを作成することもできますが、大変なので express-generator を使用して雛形を作成します。
次のコマンドでインストールできます。

npm install express-generator -g

プロジェクトの作成はexpressコマンドにプロジェクト名を指定し実行するだけです。

express 【プロジェクト名】
Expressの使い方

1. express-generatorを使用して雛形を作成します。 ※プロジェクト名は「testApp」とします。

express testApp

2. 関連パッケージのインストールを行います。

cd testApp
npm install

3. サービスを立ち上げ方です。

npm start
プロジェクトの構成について

express-generatorにより、次のようなファイルが作成されます。

testAppフォルダ配下の構成

public/
public/javascripts/
public/images/
public/stylesheets/
public/stylesheets/style.css
routes/
routes/index.js
routes/users.js
views/
views/error.jade
views/index.jade
views/layout.jade
app.js
package.json
bin/
bin/www

  • app.js

    メインファイルです。
    アプリケーション全体の処理や設定を記述します。

  • public/

    静的コンテンツを格納するディレクトリです。
    ファイルを設置するだけで、自動的に静的コンテンツとして利用できます。

  • routes/

    ルーティングファイルを格納するディレクトリです。
    ルーティングファイルでは、URL毎の処理やビューファイルの呼び出し等を行います。

  • views/

    ビューファイルを格納するディレクトリです。

  • bin/

    起動ファイル「www」が格納されています。

express-generatorにより、シンプルな構成のindex画面を返す雛形が自動生成されます。
シンプルな雛形なので、アプリケーション独自のフォルダやファイルを追加しカスタマイズすることが可能です。


本題

さて、ここからが今回の本題になります。

私が開発に携わっているWEBアプリケーションのサービスで、通信量を減らしたいという要望があがり
静的コンテンツをgzip圧縮して配信することになりました。

サービスの構成

・ロードバランサ
 Nginx

・WEBアプリケーション
 Node.js+フレームワーク「Express」

要件

1. 静的コンテンツをgzip圧縮し配信したい。
2. リクエストの度にgzip圧縮するとCPUのコストがかかるので、あらかじめgzip圧縮しておきリクエスト時に返すようにしたい。
3. 今後の開発・運用での作業ミスの防止のためにも、gzip圧縮されたコンテンツは意識せずとも最新の内容が反映されるようにしたい。

検討

これらの要件を踏まえ、パッと対応できそうなものは次のようなものでした。

  • ロードバランサ等のMW側の機能を利用する。
  • Node.jsのnpmパッケージでExpressのgzip配信機能を導入する。

しかし実際に調査を進めてみると、単純に静的コンテンツをgzip圧縮された形式で配信するだけなら対応できますが
すべての要件をかなえるものは見つからず、断念しました。
※2年ほど前の話ですので、現在は要件を満たすロードバランサの機能やNode.jsのnpmパッケージがあるかもしれません。

検討した結果、すべての要件を満たすために静的コンテンツのgzip圧縮配信を独自で実装することになりました。

仕様

Expressの静的コンテンツ配信機能とは別に、独自で静的コンテンツを配信する仕組みを設ける。
Expressの静的コンテンツフォルダ「public」と同レベルに「public_cache」というフォルダを作成し、配下のファイルをgzip圧縮して配信できる仕組みを設ける。

f:id:ys1977:20201105183144p:plain

サービス起動時の処理

サービス起動時に静的コンテンツを取得しgzip圧縮する。
無圧縮・gzip圧縮された2種類のコンテンツデータをサービスのメモリ上に保持する。

  1. アプリケーションのサービス起動時にフォルダ「public_cache」内のファイルの情報を取得する。

  2. ファイルをgzip圧縮したデータを作成する。

  3. サービスのメモリ上に無圧縮のデータとgzip圧縮したデータを格納する。

f:id:ys1977:20201105185936p:plain

静的コンテンツがリクエストされた際の処理

静的コンテンツへのリクエストが来た際に、サービスのメモリ上に保持されたコンテンツデータをレスポンスデータとして返す。
リクエストヘッダ「accept-encoding」がgzipに対応していたら、gzip圧縮したデータを返す。
gzip非対応であれば無圧縮のデータを返す。

f:id:ys1977:20201105190011p:plain

その他

今回あわせて下記のような仕様を盛り込みました。

・静的コンテンツのリアルタイム反映

コンテンツが最新化されると、次回リクエスト時に最新ファイルの内容でコンテンツデータを最新化するようにしています。
これにより、サービス停止なしで静的コンテンツに関する軽微な不具合対応等が行えるようになっています。

・セキュリティ対策

自動的にJavaScriptのコメントを除去するようにしており、開発者向けのコメントを外部に見られることを防いでいます。


サンプルコード

※サンプルですので、Headerの設定等の詳細なコードは省いています。

※静的コンテンツのリストは「lib/public_cache.js」の変数「files」に定義しています。
 ここについては、フォルダ「public_cache」内の情報から自動的にリストにすることも可能だと思います。

lib/public_cache.js

静的コンテンツを読み込み管理するためのライブラリ
※libはサンプル独自のディレクト

const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const publicCachePath = module.exports.appPath + '/public_cache/';
// ※現時点ではUTF-8固定にしております。
let files = {
    'hoge.html': {},
    'js/jquery.js': {},
    'js/hoge.js': {},
    'css/main.css': {},
    'css/icon.css': {}
};
module.exports.files = files;
// ファイルを読み込み保持する
function setFileData (fileName) {
    let mimeType = '';
    // 拡張子の判断
    let ext = path.extname(fileName).toLowerCase();
    if (ext) {
        // 一文字目に「.」が付いているので除去する
        ext = ext.substr(1);
    }
    // mime type 設定
    switch (ext)
    case 'js':
        mimeType = 'application/javascript;';
        break;
    case 'css':
        mimeType = 'text/css;';
        break;
    case 'html':
    case 'htm':
        mimeType = 'text/html;';
        break;
    default:
        mimeType = 'text/plain;';
        break;
    }
    mimeType += ' charset=UTF-8';
    let data = fs.readFileSync(publicCachePath + fileName, 'utf-8');
    if (ext == 'js') {
        // JSの場合は、行コメントを取り除く
        data = data.replace(/^\s\/\/.$/gm, '');
    }
    let gzipData = zlib.gzipSync(data, {level: 6});
    let fileStats = fs.statSync(publicCachePath + fileName);
    files[fileName] = {
        plain: data,
        gzip: gzipData,
        mtime: fileStats.mtime,
        mime_type: mimeType
    };
}
// ファイル情報を読み込む
function loadFileData () {
    for (let fileName of Object.keys(files)) {
        setFileData(fileName);
    }
}
module.exports.loadFileData = loadFileData;
// ファイル情報を取得する
function getFileData (fileName) {
    let fileStats = fs.statSync(publicCachePath + fileName);
    if (String(fileStats.mtime) != String(files[fileName].mtime)) {
        // 更新日時が違っている場合には読み込み直す
        setFileData(fileName);
    }
    return files[fileName];
}
module.exports.getFileData = getFileData;

routes/publicCache.js

静的コンテンツのルーティング


const express = require('express');
const publicCacheLib = require('../lib/public_cache.js');
const router = express.Router();
router.get('/', function (req, res, next) {
    try {
        let fileName = req.baseUrl.replace(/^\//, '');
        let fileData = publicCacheLib.getFileData(fileName);
        if (fileData) {
            res.set({
                'Content-Type': fileData.mime_type,
                'Last-Modified': fileData.mtime.toUTCString()
            });
            let acceptEncoding = req.headers['accept-encoding'];
            if (acceptEncoding.match(/\bgzip\b/)) {
                // gzipでレスポンス
                res.set({'content-encoding': 'gzip'});
                res.send(fileData.gzip);
            } else {
                // 通常データでレスポンス
                res.send(fileData.plain);
            }
            res.end();
        } else {
            res.render('error');
        }
    } catch (e) {
        throw e;
    }
});
module.exports = router;

app.js

app.jsに静的コンテンツ用の処理を追記する。


// 静的コンテンツのルーティング
let publicCache = require('./routes/publicCache');
// 静的コンテンツを読み込み管理するためのライブラリ
let publicCacheLib = require('./lib/public_cache.js');
// 静的コンテンツのリストからルーティングを設定
let files = publicCacheLib.files;
for (let fileName of Object.keys(files)) {
    app.use('/' + fileName, publicCache);
}
// 静的コンテンツの読み込み
publicCacheLib.loadFileData();


まとめ

今回、独自で静的コンテンツのgzip圧縮配信を実現することができました。

また、コンテンツをメモリ上にキャッシュしたことにより
ディスク読み込み負荷なくレスポンスを返す事ができます。

今回の対応を行うと、静的コンテンツを読み込んで管理するという特性を利用して、読み込み時に内容を加工することができます。

こちらを利用して、サンプルコードではJavaScriptの行コメントを削除しています。

独自実装することでパフォーマンスの劣化が生じる懸念もありましたが
速度テストの結果、特に低下は見られませんでした。
また、サービスのメモリ使用量の増加も微々たるものでした。

注意点としては、今回の対応は大容量の静的コンテンツがある場合には
メモリを大量に消費するためお勧めできません。

実際にサービスで実装した際には、容量が少なく圧縮効果のあるHTML/CSS/JavaScript形式のファイルのみ対応を行いました。
画像ファイル等は圧縮しても効果が薄く容量も大きいため、Express既存の静的コンテンツとして扱っています。
※Expressの静的コンテンツ配信の仕組みと併用可能です。

今回のような独自の仕様をフレームワークに組み込めるという自由度の高さも、フレームワーク「Express」の魅力だと思います。

Copyright © RAKUS Co., Ltd. All rights reserved.