孔明……いや、octet-streamの罠

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
確かに罠ではなかったかな……

おまとめ三行

このデコ助野郎がぁっ!
前戯は大事って言うじゃない?
気になるあの子のスカートの中とか

AWSのS3を使っているときの話です。もうちょい正確に言うと、PHPでファイルアップロードの機能を作って、ユーザーさんがブラウザからアップしたファイルを、API……というか、AWSが提供しているPHPのSDKを使ってS3にアップするときのお話。

今までそんなの気にしたことなかったけど、どうやら気にしておいた方が良いポイントがあるみたい。一言で言うと、Content-Typeに気をつける必要があるみたい。

他の条件でも起こり得るのかもしれないけど、とりあえず僕に確認できたのは、S3を使った場合に起こりました。

じゃ、どーゆーことかってのを具体的に。



AWSのSDKについて少々

「すでにバリバリ使ってるから読む必要などないわこのデコ助野郎がぁっ!」って人は飛ばして下さい。

一応、簡単にPHPのSDKを使ってS3にファイルをアップロードするコードを書いておきます。たぶん、これ書いといた方が後で説明しやすい……かもしれない。

SDKを使うには、インストールが必要です。インストールする方法は3つほどあるみたいなんですが、僕にとってはzipファイルを落として来てサーバーにごそっと上げる方法が楽なんで、それ採用しました。ってか、composerとか未だによく分かってない。pharって何よ。しばらく息を止めた後のリアクションですかい? プハァー。

インストール方法はこの辺を読むと良いと思います。zipファイルが置いてあるページへのリンクとかもこのページの中にある。

SDKのインストール

zipファイルを上げた場合、フォルダの中に「aws-autoloader.php」ってのがあるんで、それをrequire()するとSDKが使えるようになります。

んじゃあ、面倒な細かい説明は抜きにして、ファイルをアップロードするためのコードを書いてみやしょう。

require 'aws-autoloader.php';
use Aws\S3\S3Client;
use Guzzle\Http\EntityBody;

$s3Client = S3Client::factory(array(
  'key' => 取得したアクセスキー,
  'secret' => 取得したシークレットアクセスキー,
));

$s3Client->putObjectAcl(array(
  'Bucket' => 作成したバケット名,
  'Key' => EntityBody::factory(fopen(ファイルのパス, 'rb')),
));

シンプルに書くと、これでファイルをS3にアップロードすることができます。

アクセスキーは、AWSのコンソールにある「IAM」ってところで新規にユーザーを作成すると取得できます。ユーザーを作る際にはまずグループを作成するのですが、そのときポリシーテンプレートってやつを選択する画面が出て来るので、そこで「Amazon S3 Full Access」を選択します。

グループ作成

アクセスキー作成の詳しいやり方については以下のページを読めば手順が書いてあります。英語ですが。そのうち日本語のページもできるでしょう。そう信じましょう。AWSならやってくれるさ。

アクセスキーの取得方法

バケットの作成はSDKを使ってやることもできますが、運用次第だけどたぶんそんな頻繁にバケットを作ることってないと思うし、SDKを使ってプログラムを組むまでもないってことで、今回はやりません。コンソールのS3にアクセスすれば簡単に作れるので、そこから作るのが良いでしょう。僕もそうやった。

バケット作成

S3にアクセスすると画面の左上に「Create Bucket」ってのがあるので、それクリックしてバケット名を入れれば作成できます。お手軽だーね。

ファイルはサーバー上に置いてあるファイルのパスを入れれば良いです。「/var/www/image.jpg」とか「/home/hogehoge/audio.mp3」とか。上のコードだと「EntityBody::factory(fopen(ファイルのパス, ‘rb’))」とか書いてありますが、たぶん「fopen(ファイルのパス, ‘rb’)」でも普通にアップロードはできると思う。ただSDKのドキュメントにこういう風に書いてあったから、僕もそれに倣いました。

やってみると、そんなに難しくはないです。たった数行のコードで実行できる。便利な世の中になったもんです。ま、140文字以内のコードでテトリスとか作れる時代だものね。



ファイルのアップロード画面と組み合わせる

では、次はファイルのアップロード画面と組み合わせてみます。

といっても、こっちもコードはシンプルっす。ここでは仮に、http://hogehoge.com/post.phpというファイルでアップロードを行うってことにしときましょう。

//post.phpのコード
<?php
if(!empty($_FILES)) {
  require 'aws-autoloader.php';
  use Aws\S3\S3Client;
  use Guzzle\Http\EntityBody;

  $s3Client = S3Client::factory(array(
    'key' => 取得したアクセスキー,
    'secret' => 取得したシークレットアクセスキー,
  ));

  $s3Client->putObjectAcl(array(
    'Bucket' => 作成したバケット名,
    'Key' => EntityBody::factory(fopen($_FILES['file']['tmp_name'], 'rb')),
  ));
}
?>

<form method="post" action="http://hogehoge.com/post.php" enctype="multipart/form-data">
  <input type="file" name="file" />
  <input type="submit" value="アップロード" />
</form>

たぶん、こんなんで良いでしょう。フォームにアップロードしたいファイルを入れてデータをPOSTすれば、S3にファイルが上がるという流れです。アップロードするファイルの情報は「$_FILES[‘file’]」に入るものとしています。さっきのコードにフォームが追加されたくらいなんで、そんなに複雑なコードではないはず。

一応これで、アップロードしたファイルがS3に上がりはします。僕はCakePHPで開発しているので、SDKの処理はモデルに書いてあったりフォームはビューに書いてあったりと、上記とは違う書き方をしてるんですが、たぶんPHPべた書きならこれで正常には動く。もしまるっとコピペしたのに動かなかったぜって人がいたら、「間違った情報載せてんじゃねえぞこのデコ助野郎がぁっ!」ってコメント下されば、すかさず土下座した後にそっこーでティータイムを挟んで速やかに修正します。



Content-Typeの罠

さて、実はここまでは前置きで、こっからが今日の本題です。毎度のことながら前置きが長くてすいません。でもほら、あれのときだって、前戯は大事って言うじゃない? 時間をかけてたっぷりするのが良いって言うじゃない? あれと同じよ。同じだってことでここは一つひらに。

上記のやり方は、テンポラリファイルをそのままS3に上げています。$_FILES[‘file’][‘tmp_name’]ってのがそうです。この中にテンポラリファイルのパスが入ってる。

たいした問題はないと言えばないんですけど、どうやら何も考えずにテンポラリファイルを直接S3にアップロードすると、ファイルのContent-Typeが「binary/octet-stream」というのになってしまう。これはそのファイルがバイナリファイルですよってことを表しています。

通常、例えばmp3の音声ファイルだったらContent-Typeは「audio/mpeg」とかになります。テキストファイルだったら「text/plain」かな。でも上記のやり方だと、いずれも「binary/octet-stream」ってのになるみたいです。

絶対そうなるかは分かりません。ただ、僕が書いたコードではそうなった。

この場合、何が問題になるかっていうと、例えば音声ファイルが、ブラウザ上で再生されないケースが出てくる。僕が今回検証した中では、Firefoxで音声を再生しようとしたときに、上手くいかなかった。ChromeやSafariは大丈夫でした。Filrefoxでmp3ファイルのURLに直接アクセスすると、「audio/mpeg」だと何事もなくブラウザ上で再生できるんですけど、「binary/octet-stream」だとファイルのダウンロードが始まっちゃうんですね。なので、jqueryのプラグインとか使って音声を再生させようとしても、mp3を読み込んでくれない。他の形式のファイルについても似たような現象が起こるかもしれない。

正直、Content-Typeに原因があるなんて全く思ってなかったから、どうしてFirefoxでだけ再生されないのか、突き止めるまでにものすげー無駄に時間を使ってしまいました。あまりにも分からないもんだから、もう少しで「このFirefoxのデコ助野郎がぁっ!」とか「このS3のデコ助野郎がぁっ!」という責任転嫁を行ってしまうところでした。でもやっぱり原因は僕の方にあった。AWSのエンジニアと僕のどっちが間違える可能性が高いかっつったら、そりゃあ僕の方に決まってるっちゅーねんな。



罠を回避する

どうすれば問題を回避できるかってことなんですが、答えはこれまたシンプルです。Content-Typeを指定し忘れないようにすれば良い。アップロードしたファイルのContent-Typeは$_FILESの中に入ってます。

$s3Client->putObjectAcl(array(
  'Bucket' => 作成したバケット名,
  'Key' => EntityBody::factory(fopen('/tmp/sample.mp3', 'rb')),
  'ContentType' => $_FILES['file']['type'],
));

ちなみに一度テンポラリファイルからどっかに移せば、ContentTypeを指定しなくてもoctet-streamにはならないようです。

//ファイルをリネーム
rename($_FILES['file']['tmp_name'], '/tmp/sample.mp3');

//リネームしたファイルをS3に
$s3Client->putObjectAcl(array(
  'Bucket' => 作成したバケット名,
  'Key' => EntityBody::factory(fopen('/tmp/sample.mp3', 'rb')),
));

例えばこんな風に、一度任意の場所にリネームしてあげると、望んだContent-Typeになってくれる。リネームする場所はたぶんどこでも良い。砂漠の真ん中とか宇宙の果てとか気になるあの子のスカートの中とか、そんなトチ狂ったことを言い出さなければ大丈夫だぜぃ。





ってな感じで、Content-Typeに関するお話でした。別に孔明の罠ではないです。単に僕が無知だっただけっつーお話です。誰か無知な僕をムチで叩いてくれ。

あれなのかな。ちゃんと明示してやらないとテンポラリファイルがただのバイナリファイルとしか扱われないってことは、僕が知らなかっただけでわりと常識的なことなのかな。よく分かりません。

まだまだ知らないことがいっぱいありますな。気になるあの子のスカートの中とか。

まだコメントはいただけてないみたい……
もしかしたら何か関連しているかも?