A Day In The Boy's Life -18ページ目

A Day In The Boy's Life

とあるエンジニアのとある1日のつぶやき。

ファイルダウンロード処理とかよくあるものですが、Laravel標準での実装をすると一部問題が出たりもしたので、その辺の対策などのまとめです。

環境はLaravel4.2です。



Laravel標準実装のお作法


Laravelでの標準的なファイルダウンロードの実装方法は、Response::download()を利用する方法です。


public function anyIndex()
{
    $path = "/path/to/download/file.doc";
    $name = "ダウンロード.doc";
    $mimeType = "application/msword";
    $headers = array(
        'Content-Type' => $mimeType,
        'Content-Length' => filesize($path)
    );
    return Response::download($path, $name, $headers);
}

ただ、マニュアルの注記 にあるように、ファイル名はASCIIにする必要があると書いてたりします。

実際に上記のようにダウンロードファイル名にマルチバイトを渡すと、バージョンによってはInternet Explorerでは文字化けが発生します(見た限り FireFoxやChromeだと問題ない)。


ファイル名をASCIIにする必要があるって注記があるのにASCII以外を渡しても問題ないケースがあるのが変ではあるんですけど根本的な原因は、ダウンロード時のレスポンスヘッダにある下記のヘッダがブラウザによってうまく解釈できないことにあるようです。


Content-Disposition
attachment; filename="ダウンロード.doc"; filename*=utf-8''%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89.doc

一応、Response::download()の処理の流れもざっと追ってみると


public static function download($file, $name = null, array $headers = array(), $disposition = 'attachment')
{
    $response = new BinaryFileResponse($file, 200, $headers, true, $disposition);
    if ( ! is_null($name))
    {
        return $response->setContentDisposition($disposition, $name, str_replace('%', '', Str::ascii($name)));
    }
    return $response;
}

にあるように、ファイル名を指定した場合はBinaryFileResponse/setContentDisposition()が呼び出されます。

その際に、


laravel/vendor/laravel/framework/src/Illuminate/Support/Str.php

のascii()を通っていて(実態としてはlaravel/vendor/patchwork/utf8/class/Patchwork/Utf8.phpのtoAscii()メソッド)、この際に ファイル名をUTF-8からASCIIに変換かけてそれ以外の文字は切り捨てる処理をしてたりします。

setContentDispositionの実態を見てみると


public function setContentDisposition($disposition, $filename = '', $filenameFallback = '')
{
    if ($filename === '') {
        $filename = $this->file->getFilename();
    }

    $dispositionHeader = $this->headers->makeDisposition($disposition, $filename, $filenameFallback);
    $this->headers->set('Content-Disposition', $dispositionHeader);
    return $this;
}

となっていて、第3引数で渡したファイル名は実際にはfallback filenameとあるので代替のファイル名のようです。

さらに実際にDispositionヘッダを生成しているmakeDisposition()を追うと


if ($filename !== $filenameFallback) {
    $output .= sprintf("; filename*=utf-8''%s", rawurlencode($filename));
}

のようにあるので、ここで実際のマルチバイトのファイル名と、ASCII変換によって切り捨てられた代替ファイル名が一致しないため、最初に書いたようなヘッダが作成されるようです。

何れにせよこのヘッダはRFC6266でちゃんと定義されているそうなので、古いブラウザ側がクソ仕様で動いているせいっぽいです。


PHPでダウンロードさせるファイル名がIEで文字化けする件 @Qiita


一応、その他の方法でもLaravelでファイルダウンロードを実装する方法があります。

それは、Response::make()を利用する方法です。


public function anyIndex()
{
    // ダウンロード対象ファイル
    $path = "/path/to/download/file.doc";
    $name = "ダウンロード.doc";
    $mimeType = "application/msword";
    // レスポンスヘッダ
    $header = array(
        'Content-Type' => $mimeType,
        'Content-Length' => filesize($path),
        'Content-Disposition' => attachment; filename=\"{$name}\""
    );

    $handle = fopen($path, 'r');
    $contents = "";
    while (!feof($handle)){
        $contents .= fread($handle, 4096);
    }
    fclose($handle);

    return Response::make($contents, 200, $header);
}

この方法は、Dispositionヘッダを自分で生成する分、ダウンロードファイル名が文字化けするといったことは回避できるのですが、これはこれで問題があるようで、Response::make()の場合はファイルの中身を引数に渡しているんですが、呼び出した際に通るsetContent()の中で


public function setContent($content)
{
    if (null !== $content && !is_string($content) && !is_numeric($content) && !is_callable(array($content, 

'__toString'))) {
        throw new \UnexpectedValueException(sprintf('The Response content must be a string or object implementing 

__toString(), "%s" given.', gettype($content)));
    }

    $this->content = (string) $content;

    return $this;
}

データ自体をキャストしてコピーしているため、倍のメモリを食ったりします。

ですので、かなり大容量のファイルをダウンロードさせようとした場合に、そもそもその中身を取り出すのにメモリを消費し、さらに コピーされることでメモリを食ってといったことでメモリ枯渇のエラーとかを起こす可能性があります(実際に起きました)。



大容量のファイルをダウンロードさせる


ってことで話がかなり長くなりましたが、結論としては(元も子もないですけど)Laravelの作法を無視してPHP標準のやり方で実装しました。


// ダウンロード対象ファイル
$path = "/path/to/download/file.doc";
$name = "ダウンロード.doc";
$mimeType = "application/msword";

header("Content-Length: " . filesize($path));
header("Content-Disposition: attachment; filename=\"{$fileName}\"");
header("Accept-Ranges: bytes");
header("Content-type: {$mimeType}");

// 実ファイル呼び出し
$handle = fopen($path, 'rb');
while (!feof($handle)){
    echo fread($handle, 4096);
    ob_flush();
    flush();
}
fclose($handle);

4096Byteごとにflush()するので、メモリを大量に消費するということもありません。

まぁ、フレームワークを使う以上はその作法に則るのがベストだとは思いますが、ここで書いた諸々の事情により、一部のブラウザに配慮するのであれば今のところはこの ようにするのが良さそうな気がします。





昔々は同僚を中心に数人のシステム開発・保守部隊が構成されていたんですが、次々と辞めていったり他のチームに異動したりで最終的には2人になってしまったことがありました。

しかも、もう一人というのが中途入社で経験が浅かったこともあって実質そのシステムの細部までわかるのが自分一人となり、設計やらの上流工程や調達・決裁などの事務処理まで細々とした仕事までをほぼ請け負わないといけない状況となってました。


こういう状況でも何とか仕事を回していたのですが、特に上の人から見ればどうにかなっているように見えてしまうんですけど、実際のところどうにもならない状況だったりします。



現状維持さえできない


こういう状況になってくると、新しいシステム開発プロジェクトを立ち上げるなんて以ての外で、取りあえず今あるシステムの細かい改修要望を対応したり、バグを潰していったりといった取りあえず現状を維持する日々となってきます。

ただ、実際には現状維持なんてこともできていないわけですよ。

システムは耐用年数があったりしますので次のリプレイスまでのカウントダウンが刻々と迫ってきますし、技術は進化するので今のものがどんどんレガシー化しますし、それに伴って保守・運用コストが増加していって日々の業務時間を圧迫してくることになりますし、次々と新しい脅威やセキュリティホールが見つかるといったリスクも増大していったりします。


結局システムも生き物みたいなもので、日々メンテナンスをしないとどんどん弱っていきますし、病気が見つかれば処置が必要です。

2人じゃただ単にその対応に追われるだけで、そのサービスのあるべき姿なんて描かれても進捗することもできません。

それでも、周りからすれば現状のシステムの課題やあるべき論を展開して日々改善要求を突きつけてくるわけで、妥協案を探ってできるレベルまで落としこんだ上で何とかつないでいく中で、周りにはそれを現状を維持していると思い込んでしまうわけです。


現状維持という観点では、やっている本人もそういう思考に偏ってきて、そもそも手一杯の状況では新たに仕事をとってくることも生み出すことも自分たちの首を絞めることになるわけですから、そういう行動に移りたがらなかったりします。

例え新しいことをやりたいと思っていても、無理だ諦めろという考えに染まっていくわけで、そんな状況ですから運営側にも活気が全くなくなるわけです。


実際に開発・保守要員が1人でも2人でも現行のサービスを継続させることはできるかもしれません。

が、実際には問題を先延ばしにしているだけで内部的なリスクというのはどんどん増大していったりしてます。



エンジニアを育てることなんてできない


もう一つは、そういった状況に陥るとエンジニアのスキルアップもできなくなるので、成長させるといったことも難しくなります。

今の業務をこなすことはうまくなるかもしれませんが、そのものすごく狭い世界と固定化されたスキルでは他の仕事をすることも難しいので企業内におけるエンジニアの価値も低下していくことになります。

一番怖いのは、毎日の仕事はあるわけでそれを単調にこなしていく日々に慣れてしまって思考停止に陥ることです。

小規模なチームで固定の保守・運用業務を長年やらせているとこういった人がどんどん増えてきてしまって、他のチームへの人員配置も難しいようなことなるのを結構見たりしてます。


開発メンバーが複数人いると、それぞれの得意分野持っているスキルやノウハウも異なるため、それを補完しあうことで互いを高められるということができたりもします。

高スキルの人の影に埋もれてしまったり、チーム内の権威的な階層によって積極的に情報共有やスキルアップを図ろうとしない人もいたりするので、チームの人数が多ければいいという話ではないのですが、それでも切磋琢磨し合える仲間がいるというのはモチベーションにつながったりもします


高度なスキルを持った優秀なエンジニア二人ということであれば、それぞれを高めあう努力を継続することはできるかもしれませんが、そういった人は稀ですし実際もう一人のほうはあまりその辺が積極的ではなかったこともあって自分もエンジニアとしてのモチベーションはかなり低下してたりしました。


最終的には、なんとか今の状況と面倒を見ているサービスの重要性を説いて体制を強化してもらい、その後も徐々に大きくすることができました。

組織の体制として余分な人員配置や遊びの要員を抱えているというのはよろしくないこととは思いますけど、少なくとも現状維持できるだけの体制というのは実際のところ退化しているに等しいので、成長させていきたいという展望があるのであれば、それに見合った体制作りというのは当然必要になってくるのではないかと思います。





コードキャッシュといえばAPC だったんですけど、どうも最近開発がされていないっぽいので代用を探していたんですが、PHP5.5.0以上であればOPcache がデフォルトでバンドルされているのでこれを使ってみたいと思います。



OPcacheをインストールする


今回の環境では、PHP5.6.8/CentOS6.6にてOPcacheを使ってみます。

PHP5.4以前のバージョンではPECL経由でインストールできるみたいですが、詳細はマニュアル を参照してみてください。

インストールはPHPをコンパイルする際に--enable-opcacheオプションを付けるだけで利用できるようになります。


$ ./configure --enable-opache
-- その他のオプション --

make、make installまで完了するとopcache.soファイルが作成されるのでパスを確認しておきます。

例えば、下記のようなパスに作成されます。


/path/to/php/extensions/no-debug-non-zts-20131226/opcache.so

あとは、php.iniを編集してOPcacheをロード、有効化すれば使えます。


[opcache]
zend_extension=/path/to/php/extensions/no-debug-non-zts-20131226/opcache.so
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=4000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
opcache.enable_cli=1

ちなみに、マニュアル上ではXdebugと同時に使っている場合は、(理由はよくわかりませんけど)OPcacheのほうを先にロードしろとあるので、この設定はXdebugの設定より設定ファイル上先に書いておいたほうが良さそうです。


この設定は、マニュアル上で推奨している値そのままです。

その他にも幾つも設定項目があるので、詳細は実行時設定のマニュアル を参照。

opcache.save_commentsとか有効にしたほうが良さそうなのにコメントに依存するアプリケーションを破壊するかもしれないとか怖いこと書いていて、実際にそんなことしているソースあるんだとか思ったり(独り言)。


まぁ、設定変更するとしてもOPcacheが使うメモリ量(opcache.memory_consumption)やファイルのタイムスタンプを確認する頻度(opcache.revalidate_freq)を変えるぐらいでしょうか。

opcache.revalidate_freqに関しては、プログラムをリリースしても反映までにこの秒数またされるっぽいので、OPcacheが原因でプログラムが即時に反映されないといった記事をよくみます。

また、キャッシュをさせたくない場合は、opcache.enable=0にすればすぐに無効化されます(要Apache再起動)。


CLI上でもOPcacheを有効にしている関係で、下記のように利用できるかどうか簡単に確認できます。


# php - v
PHP 5.6.8 (cli) (built: Jun 10 2015 09:50:05) 
Copyright (c) 1997-2015 The PHP Group
Zend Engine v2.6.0, Copyright (c) 1998-2015 Zend Technologies
    with Zend OPcache v7.0.4-dev, Copyright (c) 1999-2015, by Zend Technologies
    with Xdebug v2.3.2, Copyright (c) 2002-2015, by Derick Rethans

もちろんphpinfo()からでも。


ZendOPcache-1



OPcacheのキャッシュ状況を確認する


APCの場合、キャッシュ状況やメモリ使用量などをグラフ化してくれるプログラムが付属していますがOPcacheではありません。

ただ、GitHub上にそれっぽいことをしてくれるソースが公開されています。


https://gist.github.com/ck-on/4959032


このプログラムを適当な場所に設置してアクセスしてみるとOPcacheのキャッシュやメモリ使用状況が確認できます。


ZendOPcache-2


で、実際のところの効果はということでLaravelのプログラムに対してApacheBenchでアクセスしてテストしてみます。

まずは、OPcacheを無効にした場合


$ ab -n 100 -c 100 http://localhost/foo
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient).....done

Server Software:        Apache/2.2.15
Server Hostname:        localhost
Server Port:            80

Document Path:          /foo
Document Length:        2617 bytes

Concurrency Level:      100
Time taken for tests:   11.095 seconds
Complete requests:      100
Failed requests:        0
Write errors:           0
Total transferred:      288300 bytes
HTML transferred:       261700 bytes
Requests per second:    9.01 [#/sec] (mean)
Time per request:       11095.334 [ms] (mean)
Time per request:       110.953 [ms] (mean, across all concurrent requests)
Transfer rate:          25.37 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        4   24   5.5     24      49
Processing:   904 7264 3788.2   8550   11062
Waiting:      896 7252 3783.8   8472   11061
Total:        924 7288 3789.9   8574   11091

次に、OPcacheを有効にした場合


$ ab -n 100 -c 100 http://localhost/foo
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient).....done

Server Software:        Apache/2.2.15
Server Hostname:        localhost
Server Port:            80

Document Path:          /foo
Document Length:        2617 bytes

Concurrency Level:      100
Time taken for tests:   4.670 seconds
Complete requests:      100
Failed requests:        2
   (Connect: 0, Receive: 0, Length: 2, Exceptions: 0)
Write errors:           0
Total transferred:      282534 bytes
HTML transferred:       256466 bytes
Requests per second:    21.41 [#/sec] (mean)
Time per request:       4670.117 [ms] (mean)
Time per request:       46.701 [ms] (mean, across all concurrent requests)
Transfer rate:          59.08 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        3   24   3.4     25      25
Processing:   879 2815 1274.5   2877    4643
Waiting:        0 2791 1306.5   2871    4643
Total:        902 2838 1275.4   2902    4667

結果を見るとRequests per secondが9.01に対して有効にした場合は21.41に、Time per request(mean, across all concurrent requests)が、110.953に対して有効にした場合は46.701となっているので、大体倍ぐらいの性能は出るようになったっぽいです。


フレームワークとか多数のライブラリが読み込まれる場合は結構有効に働いてくれる気がします。

もちろん、より多くのメモリを食ったりキャッシュが利いてプログラムが更新されないといった弊害もあったりしますのでチューニングは必要になってくるかと思います。

でも、デフォルトでバンドルされているものなので使わない手は無いと思いますが。