PHP gzip圧縮転送支援スクリプト

主に**mod_deflate**モジュールによる圧縮転送が利用できないサーバで圧縮転送を行なうためのスクリプトです。
対象ファイルがリクエストされた際にPHPスクリプトでGZIP圧縮して送信しますが、一度圧縮したファイルはサーバ上にキャッシュファイルとして保存し2度目以降のリクエスト時にはキャッシュを利用しますので、**mod_deflate**での圧縮負荷を抑えたい場合にも利用できるかと思います。

本スクリプトで圧縮可能なデータは静的ファイルのみで、CMS等で動的に出力されるHTMLデータは対象外となります。

##設置方法##
gzip圧縮転送したいファイルのあるディレクトリに**gzenc.php**を置き、同階層の **.htaccess** に以下を追記してください。

####.htaccessへの追記内容####

~~~plain:.htaccess
#BEGIN gzenc.php
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_URI} ^.*\.(html?|css|js|xml|txt)$ [NC]
    RewriteRule ^.*$ gzenc.php?f=$0 [L]
</IfModule>
#END gzenc.php
~~~

**mod_rewrite**が利用できることが前提になります。
指定した拡張子のファイルに対してアクセスがあった際、URLを変えずそのファイル名を**gzenc.php**へ渡すよう設定しています。

####gzenc.php####

~~~php:gzenc.php
<?php

// 有効な拡張子とcontent-typeのリスト
// .htaccessへのRewriteCond %{REQUEST_URI}設定とペアになるよう指定
$enabledExtensions = [
    'html'  => 'text/html',
    'htm'   => 'text/html',
    'css'   => 'text/css',
    'js'    => 'application/javascript',
    'xml'   => 'application/xml',
    'txt'   => 'text/plain',
];

// レスポンスへのETag出力及びリクエストのIf-None-Matchとの照合
define('USE_ETAG', false); // 使用:true / 非使用:false

// Accept-Encodingを確認しての圧縮可否判断(falseの場合はAccept-Encodingに関わらず常に圧縮)
define('CHECK_ACCEPT_ENCODING', true); // 使用:true / 非使用:false

// GZIP圧縮レベル
define('COMPRESS_LEVEL', -1); // -1(デフォルト), 0(非圧縮), 1(低圧縮低負荷) ～ 9(高圧縮高負荷)
/**
 *
 */

// キャッシュ保存ディレクトリ
define('CACHE_DIR', __DIR__. '/gzenc_cache');

// キャッシュ保存ディレクトリが無ければ作成してダミーhtml作成
if(!file_exists(CACHE_DIR)) {
    mkdir(CACHE_DIR, 0700);
    touch(CACHE_DIR. '/index.html');
}

// mod_rewriteでのリダイレクトではなくスクリプト名のURLで直にリクエストされた場合は終了
if(isset($_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME'])) {
    if(strpos($_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME']) === 0) notFoundExit();
}

// ファイル名取得
if(isset($_GET['f'])) {
    $filePath = realpath($_GET['f']);
    // ディレクトリトラバーサル対策
    // 指定ファイルパスが本スクリプトのカレント配下でない場合は終了
    if(strpos($filePath, __DIR__) !== 0) notFoundExit();
} else {
    // ファイル名未指定時は終了
    notFoundExit();
}

// 指定ファイルが存在しなければ終了
if(!file_exists($filePath)) notFoundExit();

// 指定ファイルの拡張子取得
$ext = strtolower(preg_replace('/.*\.(\w+)$/', "$1", $filePath));

// 取得した拡張子が有効リストに無ければ終了
if(!array_key_exists($ext, $enabledExtensions)) notFoundExit();

// リクエストヘッダAccept-Encodingにgzipが含まれていなければ元ファイルをそのまま渡して終了
if(CHECK_ACCEPT_ENCODING &&
    in_array('gzip', preg_split('/,\s+/', filter_input(INPUT_SERVER, 'HTTP_ACCEPT_ENCODING'))) === false) {
    header(
        sprintf('Content-type: %s; charset=%s',
            $enabledExtensions[$ext],
            mb_detect_encoding(file_get_contents($filePath, false, NULL, 0, 2048), 'utf-8, sjis, euc, jis')
        )
    );
    readfile($filePath);
    exit;
}

// キャッシュファイルパス
$md5FilePath = md5($filePath);
$cacheFilePath = CACHE_DIR. '/'. $md5FilePath. '.gz';
$jsonFilePath = CACHE_DIR. '/'. $md5FilePath. '.json';

// キャッシュファイルの有無確認
if(file_exists($cacheFilePath)) {
    // キャッシュより実体または本スクリプトのほうが新しい場合
    // 実体または設定が更新されているとみなしキャッシュを削除
    if(filemtime($cacheFilePath) < filemtime($filePath) ||
        filemtime($cacheFilePath) < filemtime(__FILE__)
    ) {
        unlink($cacheFilePath);
        unlink($jsonFilePath);
    }
}

// クライアントからのIf-None-Matchを取得
$ifNoneMatch = filter_input(INPUT_SERVER, 'HTTP_IF_NONE_MATCH');

// キャッシュファイルあり
if(file_exists($cacheFilePath)) {
    // キャッシュに付随するプロパティファイルを取得
    $properties = file_exists($jsonFilePath) ?
        json_decode(file_get_contents($jsonFilePath), true) : [];

    // ETagチェック
    if(isset($properties['etag']))
        checkEtag($ifNoneMatch, isset($properties['etag']) ? $properties['etag'] : '');

    // レスポンスヘッダ出力
    header('Content-type: '. $enabledExtensions[$ext].
        (isset($properties['charset']) && $properties['charset'] ?
            "; charset={$properties['charset']}" : '') );
    header('Content-Encoding: gzip');
    if(isset($properties['content_length']))
        header('Content-Length: '. $properties['content_length']);
    if(USE_ETAG && isset($properties['etag']))
        header('Etag: '. $properties['etag']);
    if(!USE_ETAG && isset($properties['last_modified']))
        header('Last-Modified: '. $properties['last_modified']);

    // データ出力
    readfile($cacheFilePath);
}

// キャッシュファイル無し
else {
    // ファイル読込み
    $file = file_get_contents($filePath);

    // エンコーディング取得
    $enc = mb_detect_encoding($file, 'utf-8, sjis, euc, jis');

    // gzencode
    $file = gzencode($file, COMPRESS_LEVEL);

    // キャッシュファイル保存
    file_put_contents($cacheFilePath, $file);

    $eTag = sprintf('"%s"', md5($file). '-gzip');
    $lastModified = gmdate('D, d M Y H:i:s T', filemtime($filePath));
    $contentLength = strlen($file);

    // プロパティファイル保存
    file_put_contents($jsonFilePath,
        json_encode([
            'charset' => $enc,
            'etag' => $eTag,
            'content_length' => $contentLength,
            'last_modified' => $lastModified,
        ])
    );

    // ETagチェック
    checkEtag($ifNoneMatch, $eTag);

    // レスポンスヘッダ出力
    header('Content-type: '. $enabledExtensions[$ext]. ($enc ? "; charset={$enc}" : '') );
    header('Content-Encoding: gzip');
    header('Content-Length: '. $contentLength);
    if(USE_ETAG) header('Etag: '. $eTag);
    if(!USE_ETAG) header('Last-Modified: '. $lastModified);

    // データ出力
    echo $file;
}

exit;

// ETagチェック
function checkEtag($ifNoneMatch, $eTag) {
    if(USE_ETAG && $ifNoneMatch !== '' && $eTag !== '' && $ifNoneMatch === $eTag) {
        // If-None-MatchとETagが共に空ではなく双方が同一なら304を返して終了
        header('HTTP/1.1 304 Not Modified');
        exit;
    }
}

// NotFound
function notFoundExit() {
    header('HTTP/1.1 404 Not Found');
    print '<html><body>Not Found</body></html>';
    exit;
}
~~~

###キャッシュディレクトリ###
実行するとスクリプトと同階層にキャッシュファイル保存用に**gzenc_cache**ディレクトリを作成します。
一度圧縮したコンテンツデータはその中へ保存して二度目以降のリクエスト時にはキャッシュファイルを利用します。


###スクリプトの設定項目など###

~~~php
// レスポンスへのETag出力及びリクエストのIf-None-Matchとの照合
define('USE_ETAG', false); // 使用:true / 非使用:false
~~~

**true**に設定すると**ETag**を出力するようになります。
**ETag**込みのブラウザキャッシュは内容の変化に対しては追従させやすくなりますが、ブラウザは次回表示時にブラウザキャッシュの内容が更新されていないかサーバに対して問い合わせるプロセスが発生しますので、何度も呼ばれる割に更新頻度は低いといったファイルが多い場合はかえってもたつきを感じることもあるかもしれません。

~~~php
// Accept-Encodingを確認しての圧縮可否判断(falseの場合はAccept-Encodingに関わらず常に圧縮)
define('CHECK_ACCEPT_ENCODING', true); // 使用:true / 非使用:false
~~~

通常は**true**のままで大丈夫です。
**true**の場合はブラウザから受け取る**Accept-Encoding**の内容によって圧縮データと非圧縮データどちらを送信するかを振り分けます。
サーバ側でWAFを使用している場合など稀に**Accept-Encoding**が削除されている場合があり、そういった場合でも圧縮転送を行いたい場合は**false**にします。
**false**にすることで強制的に圧縮したデータを送るようになりますが、非対応のごく古いブラウザなどでは表示できなくなります。


##使用中止する場合##
設置の際に **.htaccess**に追記した部分、及び**gzenc.php**とキャッシュディレクトリ**gzenc_cache**を削除して下さい。

##参考##
[mod_rewrite | Apache](https://httpd.apache.org/docs/current/mod/mod_rewrite.html)
[Media Types | IANA](https://www.iana.org/assignments/media-types/media-types.xhtml)
[HTTPヘッダー | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers)
[HTTPキャッシュ | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Caching)
[ETag | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/ETag)