PHPでzipファイルをダウンロードさせる仕様で取得したzipファイルが壊れている原因とは・・・?

ob_end_clean 関数を呼び出力バッファをクリアしないとUTF-8 BOM付きのPHPソースが実行された場合BOM(Byte Order Mark)が付加されてしまい、zipファイルが正しく生成されないことになってしまう。

実証コード

<?php
	$zip = new ZipArchive();
	$temp_dir = sys_get_temp_dir();
	$zip_name = 'test.zip';
	if (!$zip->open($temp_dir . $zip_name, ZIPARCHIVE::CREATE)){
		exit();
	}
	$zip->addFile('test.txt', '/hoge/test.txt');
	$zip->close();
	header('Content-Type: application/zip');
	header('Content-Length: '.filesize($temp_dir . $zip_name));
	header('Content-Disposition: attachment; filename="'.$zip_name.'"');
	readfile($temp_dir . $zip_name);
?>

ダウンロードしたファイルを解凍(Windowsのzip解凍アプリケーションによってはエラーになって書庫が参照できない)
$ unzip test.zip
Archive: test.zip
warning [test.zip]: 3 extra bytes at beginning or within zipfile
(attempting to process anyway)
warning: stripped absolute path spec from /hoge/test.txt
inflating: hoge/test.txt

$ od -tx1z -Ax test.zip
000000 ef bb bf 50 4b 03 04 14 00 00 00 08 00 69 72 48  >...PK........irH<
000010 4e 07 19 91 61 09 00 00 00 09 00 00 00 0e 00 00  >N...a...........<
000020 00 2f 68 6f 67 65 2f 74 65 73 74 2e 74 78 74 cb  >./hoge/test.txt.<
000030 c8 4f 4f cd 00 62 2e 00 50 4b 01 02 00 00 14 00  >.OO..b..PK......<
000040 00 00 08 00 69 72 48 4e 07 19 91 61 09 00 00 00  >....irHN...a....<
000050 09 00 00 00 0e 00 00 00 00 00 00 00 00 00 00 00  >................<
000060 00 00 00 00 00 00 2f 68 6f 67 65 2f 74 65 73 74  >....../hoge/test<
000070 2e 74 78 74 50 4b 05 06 00 00 00 00 01 00 01 00  >.txtPK..........<
000080 3c 00 00 00 35 00 00 00 00 00                    ><...5.....<
00008a

先頭にある ef bb bf がBOMである。この例では、readfileをする前に、 ob_end_clean 関数 を呼ぶことでBOMを出力しなくなる。

<?php
        $zip = new ZipArchive();
        $temp_dir = sys_get_temp_dir();
        $zip_name = 'test.zip';
        if (!$zip->open($temp_dir . '/' . $zip_name, ZIPARCHIVE::CREATE)){
                exit();
        }
        $zip->addFile('test.txt', '/hoge/test.txt');
        $zip->close();
        header('Content-Type: application/zip');
        header('Content-Length: '.filesize($temp_dir . '/' . $zip_name));
        header('Content-Disposition: attachment; filename="'.$zip_name.'"');
        ob_end_clean();
        readfile($temp_dir . '/' . $zip_name);
?>

以下のようにエラーが発生しないようになった。

$ unzip test.zip
Archive: test.zip
warning: stripped absolute path spec from /hoge/test.txt
inflating: hoge/test.txt

関連した話として、RedHatEnterpriseLinuxもしくはCentOSではバージョン7よりSystemdのPrivateTmpという機能が有効になっている。sys_get_temp_dir()ではデフォルトでは、/tmp/以下のディレクトリが取得できるが、実際には/tmp/systemd-private-xxxxx-httpd.service-xxxx/tmp/ に書き出しされる。デバッグする際にファイルを取り出したい場合などにファイルが見当たらないといったことになるので、注意が必要。

MariaDBでBLOBを取扱時の注意事項

BLOBでバイナリデータをPHPから書き込みするときにはまったポイントをいくつか。。。MariaDBでの発生事例だが、MySQLでも同様だと思う。

・BLOBは65535Bytesしか格納できない。BLOBよりも大きなサイズの格納が必要であれば、MEDIUMBLOB、LONGBLOBを利用する必要がある。
・BLOB型にデータをinsertするときにはバイナリデータをストリームのまま登録することはできないので、PHPであればbin2hex関数でいったん16進数に変換してinsertした後、selectするときにhex2bin関数で戻す必要がある。また、BLOB型で65,535byteを超えるデータをinsertすると先頭65,535byteだけが登録されてしまう(insert時にエラーにならない)ので、insert前にデータサイズのチェックが必要。
・パケットサイズを大きくする必要がある。max_allowed_packet という設定値だが、デフォルトでは1MBなので、設定値を超えるデータをinsertしようとするとエラーが発生する。mysqlで大きなファイルを保存するための設定 を参考にログファイルサイズの設定値も変更したほうが良いかもしれない。
・PHPの設定項目値として、php.ini でupload_max_filesize設定値を確認する。この値を超えたデータがPOSTされても、$_FILES[name][name]でファイル名は設定されるが、データは一時ディレクトリに保存されず、
$_FILES[name][tmp_name] は値がセットされない。また、環境によってはset_time_limit関数を使ってタイムアウト時刻を調整したほうが良い。

HTTP_RequestをHTTP_Request2に書き換える

PHP 5.4以降でE_STRICTによるメッセージが出るようになったこととHTTP_Requestがデサポになっていることから、ようやくHTTP_RequestをHTTP_Request2に書き換えてみることにした。
参考にしたサイトは[php][pear]HTTP_Request2のサンプル#1 : うえちょこ@ぼろぐ
互換性があるライブラリと思いきや、かなり非互換。
コンストラクタから違う。上記参考サイトとあわせて下記参考にしてみてください。
HTTP_Request
HTTP_Request HTTP_Request( [string $url = ”], [array $params = array()])
HTTP_Request2
HTTP_Request2 __construct( [string|Net_Url2 $url = null], [string $method = self::METHOD_GET], [array $config = array()])

CentOS 5.5にPHP 5.2をインストールする

CentOS 5.5のリポジトリでは、PHP 5.1.6までしかインストールできない。
PHPならびに対応するmemcachedのライブラリのインストール方法について「CentOS5.2にPHP5.2.6とmemcachedをインストールする」に記載があるので、こちらを利用するとよい。
ちなみに追加したリポジトリは次の通り。/etc/yum.repos.d/CentOS-Testing.repo として保存するとよい。
[c5-testing]
name=CentOS-5 Testing
baseurl=http://dev.centos.org/centos/$releasever/testing/$basearch/
enabled=1
gpgcheck=1
gpgkey=http://dev.centos.org/centos/RPM-GPG-KEY-CentOS-testing
includepkgs=php*
priority=1

CacheをMemcachedにするとエラーとなる

Memcachedをインストールしたうえで、cakePHPのcore.php設定ファイルにあるcache設定をengine => ‘File’ から engine=>’Memcache’に変更すると以下のエラーメッセージが表示されてしまう。
Warning (512): Cache not configured properly. Please check Cache::config(); in APP/config/core.php
デバッグして原因を調査したところ、php.ini に
extension=memcache.so
の設定がないためにMemcacheクラスが呼べなかったためだった。
エラーメッセージが適切?ではないために小一時間はまってしまった。
なお、memcache.soはphp-pecl-memcacheパッケージをインストールする必要がある。

pageTitleが利用できない

新しいアプリケーション「oh-toilet.com」をcakePHP+Ktai Libraryで作成していたら、なぜかタイトルが編集できない。
いつも通り
$this->pageTitle = ‘タイトル’;
にしているのに。。。。
原因を探してみると、Ver.1.3から廃止になっているじゃないですか。。。

View::$pageTitleは削除されました。代わりに$this->set(‘title_for_layout’, $var);を使用してください。

セキュリティ要件でない限りは、マイナバージョンアップ時に下位互換性をなくすのはやめてほしいと思うのは僕だけでしょうか?
下位互換性がなくなるということは、cakePHPのバージョンアップをするときには、前ページの動作確認をしなければならないことを意味しています。(もちろんドキュメントに書いてあることがすべてならばキーワードだけで探して対象を絞り込むだけでよいのかもしれませんが。。。)

携帯サイトをキャッシュする

財テク.jpのモバイルサイトは、CakePHPにKtai Libraryを使って動かしているのだが、Viewキャッシュをしてしまうと、キャリアごとの絵文字に対応できないことと、PCサイトがUTF-8だった場合に、headerでのcharset指定がUTF-8となってしまうため、キャッシュされたページは文字化けしてしまう可能性がある。
前者については絵文字を利用しない方法で対応可能だが、後者についてはキャッシュ済みの場合には、controllerすら通らず、エンコード指定を切り換える対応方法が分からなかったため、しばらくキャッシュさせないようにしていたが、データが多くなってくるとレスポンスが悪くなってきているため、キャッシュの方法を考えてみた。
Viewキャッシュが無理な場合には、modelキャッシュということで、CakePHP1.2 Behaviorでモデルのメソッドキャッシュを行うを使って、modelでキャッシュさせることにした。
結果的には、携帯サイトはmodelキャッシュ、PCサイトはmodelキャッシュ+ビューキャッシュとなりキャッシュによる効果は高くなったように思う。

logrotate

ログを出力するプログラムはたくさんあるが、1ファイルに書き出し続けると不要な過去のログを保持し続けて容量不足になってしまったりすることがある。
logrotateを使えば、出力側のプログラムを変更することなく、きめられたタイミングで現在のファイルをリネームしたうえで、新しいログファイルを作成してくれる。
Fedora Coreであれば、
/etc/logrotate.d/ の下にファイルを作成して、
/var/www/XXX/logs/*log {
daily
missingok
}
とすれば、cakePHPで出力されるdebug_logとerror_logを1日単位でローテートしてくれる。
create (パーミッション) (ユーザー名) (グループ名)のオプションを付ければ、空のファイルを作成してくれるし、apacheプロセスのようにログ出力プログラムがログファイルが無くなってしまうとそれ以降ログを書き出さないような仕組みなのであれば、postrotateオプションをつかってプロセスを再起動させることもできる。
詳しいオプションは@ITが参考になる。

ShellとControllerで処理を共有する

cakePHPでCronなどでタスク処理をしたい場合やコマンドライン実行をしたい場合にロジック部分を提供するShellクラスとWebアプリケーションのロジック部分を提供するController。いずれも同じロジック部分だけに、共有したいことが多い。
しかしながら、いずれもクラスはObjectから継承されたものであり、Objectクラスにゴリゴリ書かない限りは共有できないが、バージョンアップ時のメンテナンス負荷が高くなってしまう。
そしたら多重継承だ!という考えもPHPでは残念ながらできず、代わりにPHPにはinterfaceという仕組みが用意されているが、残念ながらcakePHPで使う方法がよくわからなかった(おそらくrequireするとか強引な手法であればできるはず)。
実はcakePHPにはコンポーネントという方法で対応することができる。しかし、このコンポーネントも癖があって、通常なら、
var $components = array(‘コンポート名‘);
とすれば
$this->コンポート名
で使えるようになるのだが、Shell側はそうはいかない。
App::import(‘Component’, ‘コンポート名‘);
と定義したうえで、
function startup(){
  $this->コンポート名 = new コンポート名Component();
}
を定義しなければ、Controllerと同等には使えない。
ただ標準で用意されているEmailコンポーネントなどは、上記手法を使う必要がなく、$components変数に突っ込めば使えるようになっている。もう少し調べてみる必要がありそうだ。

Prefix利用時のPaginatorHelperが吐くURLが正しく表示されない

財テク.jp 価格比較サイトモバイルサイトをPrefixを使って実現しようとしたのだが、Prefix利用時のPaginatorHelperが吐くURLが正しく表示されないにて指摘されているようにURLが正しく設定されない。そのため、このスレ主が情報提供していただいていると思われるブログを元に同様にURL置換を行うことで対応した。
ビューで置換するのは根本的な対応とは言えないが、逆にビューで置換するので、routes.phpで変更を加えるより、影響が(想定)限定されるともいえる。
今回もKtai Libraryを使って非常に簡単に作成することができた。バージョンが0.2.0になっており、cakeHPでモバイルサイトを作るならKtai Libraryとなりつつあり、今後もますます注目度が高いライブラリである。