Rack応用 - HTTP圧縮エンコーディング最適化

## はじめに

[前回の投稿](http://qiita.com/higuma/items/838f4f58bc4a0645950a)ではRackの原理的な部分を説明しましたが、今回は応用編としてファイルの圧縮エンコーディングを取り上げます。まず最適化の方法を説明し、その後でgemモジュールを紹介します。

## HTTPの圧縮エンコーディング

HTTPプロトコルではサーバのデータをgzipやdeflateなどで圧縮して取得(ダウンロード)することができます。

> 逆の状況としてアップロード時に同様の機能があるかどうかも調べてみましたが、まだそのような仕様は策定されていないようです。

まずプロトコルを確認しておきます。まずブラウザからサーバにデータを要求する際リクエストヘッダに次の一行を設定して圧縮に対応している事をサーバに通知します。現行ブラウザの最も一般的な設定を示します。

```
Accept-Encoding: gzip, deflate
```

このヘッダを認識したサーバは応答時にこれらのどれか一つを使いボディのデータを圧縮して送信し、その際レスポンスヘッダでブラウザに圧縮方式を伝えます。

```
Content-Encoding: gzip
```

これを受信したブラウザは受信したボディを指定された圧縮エンコーディング(この場合はgzip)で解凍して処理します。特にテキストファイルに対しては大きな効果があり、(おおまかな目安として)30-40%程度まで通信時間を短縮できます。

## Rack::Deflaterによる対応

Rackにはこのための標準ミドルウエアとしてRack::Deflaterがあります。前回の解説で簡単な応用例を示しています。

<http://qiita.com/higuma/items/838f4f58bc4a0645950a#2-5>

RackミドルウエアにRack::Deflaterを挿入するだけで自動的に上記リクエストヘッダを認識し、ボディを圧縮してレスポンスヘッダをセットして応答します。大部分のケースではこの対応で十分です。

## 事前圧縮による最適化

しかし大量のデータを扱ったりサーバ負荷が高い場合は改善の余地があります。Rack::Deflaterは毎回要求がある度にサーバサイドでデータ圧縮処理を行いますが、事前に圧縮しておけばサーバの負担を減らせます。

> Nginxにはgzip_staticというまさにこの目的のためのモジュールがあります。またRuby on Railsではasset pipelineがこの部分の担当で、productionモードに類似の機能があるようです。詳しくはRails Guidesをご覧下さい。
> 
> <http://guides.rubyonrails.org/asset_pipeline.html#gzip-compression>

今回はこのためのRackミドルウエア(厳密にはエンドポイント)を実際に作ってみます。まず前処理として最適化するファイルをgzip圧縮しておきます。例えばindex.htmlなら同じディレクトリにindex.html.gzを作り、元のindex.htmlを消去すれば準備完了です。これで容量も削減できます。

なお元のファイルを残しておけば処理は簡単になりますがその分容量が増加します(従量制サーバでは課金増大要因になります)。今回は圧縮非対応の場合はサーバ側で解凍して応答します。

> この場合にサーバ側で解凍処理が発生しますが、今ではgzip非対応クライアントはめったにありません(curlやwgetを使う時くらいでしょう)。ここでは圧縮前のファイルを消去してサーバリソースを削減する方を優先します。

作成するRackアプリケーション名はGzipFileとします。仕様はRack::Fileと同じですが、次の拡張機能があります。

* ファイルは.gz付きのパスを先に探す
* .gzファイルが見つかったら必ずそちらを返す
    * クライアントがgzip対応ならヘッダの付け替えのみで対応
    * gzip非対応の場合は自分で解凍してそれを返す
* .gzファイルがない場合はRack::Fileと同じ動作

内部の処理はRack::Fileに任せ、自分は.gzファイルがあった場合のヘッダ情報設定のみを行います、ただしクライアントがgzip非対応の場合は自分で解凍して対応します。ここでは説明用として基本部分のみのコードをコメント付きで示します。

``` ruby
require 'rack'

class GzipFile
  def initialize(root)
    @file = Rack::File.new(root)
  end

  def call(env)
    path = env['PATH_INFO']                 # 元のパスを保存
    env['PATH_INFO'] = path + '.gz'         # .gzを追加
    status, headers, body = @file.call(env) # Rack::Fileに処理させる
    if status == 200    # .gzがある場合
      # まずMIME typeがapplication/gzipになっているので正しく付け直す
      mime = Rack::Mime.mime_type(File.extname(path), 'text/plain')
      headers['Content-Type'] = mime if mime
      # クライアントがgzipに対応しているかチェック
      accept_enc = env['HTTP_ACCEPT_ENCODING']
      if accept_enc && accept_enc.include?('gzip')
        # 対応している場合はヘッダ設定だけでOK
        headers['Content-Encoding'] = 'gzip'
      else
        # 非対応の場合は自分で読み込んで解凍する
        body = [Zlib::GzipReader.open(body.to_path) {|gz| gz.read }]
        headers['Content-Length'] = body[0].bytesize.to_s   # 要設定
        headers.delete 'Content-Encoding'                   # 消去
      end
      [code, headers, body]
    else                        # .gzがなかったら...
      env['PATH_INFO'] = path   # パスを元に戻してリトライ
      @file.call(env)
    end
  end
end
```

## Gemモジュールの紹介

上記コードを元にしたgemモジュールを作成しました。

<https://github.com/higuma/rack-gzip-file>

RubyGems登録済で、次でインストールできます。

```
$ gem install rack-gzip-file
```

次の2つのクラスを利用できます。どちらも対応する標準Rackアプリケーションの上位互換です。

* Rack::GzipFile - Rack::Fileのgzip事前圧縮対応版
* Rack::GzipStatic - Rack::Staticのgzip事前圧縮対応版

特にサーバ容量を削減したい方におすすめです。ぜひご利用下さい(ライセンスはMITです)。