Filefoxでファイルの非同期アップロード with 複数パラメータ

1ヶ月前に書こうと思ってたネタなんですが,後回しにしていて今に至ります…
HTML5のFile APIを使って,って内容にするつもりだったんですが,W3CのHTML5の仕様にあるのはDrag and Dropだけで,ひょっとすると他の機能はFirefoxの独自機能じゃないかと思ってHTML5とは言わないことにします.

このネタを書こうと思ったのはこちらの記事でやってることが私にとって難しすぎたので,同じように感じた人の理解の助けになればと思ってです.

さてさて,FirefoxからサーバにAjaxでデータをアップロードする場合,画像などのバイナリデータを扱うことが多いかと思います.
しかし,XMLHttpRequestのsendはバイナリセーフじゃないので,次のようにFirefoxの独自機能であるsendAsBinaryで送ることになります.
せっかくなんで,HTML5の一機能であるドラッグアンドドロップで受け取った場合を示します.

function drop(e){
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "upload.php");
    var dt = e.dataTransfer;
    xhr.overrideMimeType('text/plain; charset=x-user-defined-binary');
    xhr.sendAsBinary(dt.files[0].getAsBinary());
}

e.dataTransferはドロップされたデータを保持していて,e.dataTransfer.filesにはそのデータのリストが格納されています.(※ファイルが1つでも配列です)
それをgetAsBinaryでバイナリデータを取り出し,sendAsBinaryでサーバに送っています.
ちなみに,Firefoxの独自機能を使わない場合は次のようになるはずです.(動作確認はしていません!)

function drop(e){
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "upload.php");
    var dt = e.dataTransfer;
    var reader = new FileReader();
    reader.onload = function(e) {
        xhr.send(encodeURIComponent(btoa(reader.result)));
    };
    reader.readAsBinaryString(files[0]);
}

ここで使われているのがいわゆるHTML5のFile APIです.
※PHPの場合,ファイルデータはfile_get_contents(“php://input”)で取得できるみたいです

このとき問題になるのは,ファイルデータ以外サーバに渡すことができないということです.
つまり,サーバ側で保存したい好きなファイル名をパラメータとして渡したりということができません.
それを解決するために複雑になっているのが先ほど挙げたこちらの記事です.
何をやっているのかというと,フォームを使ってファイルをアップロードする時に使われる multipart/form-data 形式でデータを送っています.

その multipart/form-data 形式というのはどういうものか?実際に見てみましょう.
次のようなフォームからファイルを選択してサーバにデータを渡します.

<form action="multipart.php" method="POST" enctype="multipart/form-data">
   <input type="hidden" value="This is A" name="a">
   <input type="hidden" value="これはBだよ" name="b">
   <input type="file" name="file[]"><br>
   <input type="submit" value="送信">
</form>

HTTPリクエストは次のようになります.(日本語とPNGデータは正しく表示されていません)

http://localhost/test/php/multipart.php



POST /test/php/multipart.php HTTP/1.1

Host: localhost

User-Agent: Mozilla/5.0 (X11; U; Linux i686; ja; rv:1.9.2.6pre) Gecko/20100605 Ubuntu/10.04 (lucid) Namoroka/3.6.6pre FirePHP/0.4

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

Accept-Language: ja,en-us;q=0.7,en;q=0.3

Accept-Encoding: gzip,deflate

Accept-Charset: Shift_JIS,utf-8;q=0.7,*;q=0.7

Keep-Alive: 115

Connection: keep-alive

Referer: http://localhost/test/php/multipart.php

Content-Type: multipart/form-data; boundary=---------------------------1300510839126694128622064124

Content-Length: 3480

-----------------------------1300510839126694128622064124

Content-Disposition: form-data; name="a"



This is A

-----------------------------1300510839126694128622064124

Content-Disposition: form-data; name="b"



〓&#129;&#147;〓&#130;&#140;〓&#129;〓B〓&#129;&#160;〓&#130;&#136;


-----------------------------1300510839126694128622064124

Content-Disposition: form-data; name="file[]"; filename="hoge.png"

Content-Type: image/png



&#137;PNG



-----------------------------1300510839126694128622064124--

boundary=—————————1300510839126694128622064124
という記述がありますが,このboundaryは各データの境界を表すもので,データの中には絶対に現れない(はずの)文字列です.
multipart/form-data 形式の各データの最初は–boundaryで始まり,最後のデータの後には–boundary–が付与されます.
また,改行コードはCR+LFでないといけません.

ここまで理解できたらあとは簡単で,この multipart/form-data 形式のデータを自分で作成して,そのデータを送ればいいわけです.
次のソースではlocalhostのupload.phpに multipart/form-data 形式のデータを渡しています.

fileapi.js

function multipartFormat(data, boundary){
    var stream = "--" + boundary;
    for(var name in data) {
	if(name == "file") {
	    var files = data[name];
	    for (var i = 0, len = files.length; i < len; i++){
		stream += "\r\n";
	    	stream += "Content-Disposition: form-data; name=\""
                    + i + "\"; filename=\"" + files[i].name + "\"\r\n";
	    	stream += "Content-Type: "+ files[i].type + "\r\n\r\n";
	    	stream += files[i].getAsBinary() + "\r\n";
	    	stream += "--" + boundary;
	    }	    
	} else {
	    stream += "\r\n";
	    stream += "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n";
	    stream += encodeURIComponent(data[name]) + "\r\n";
	    stream += "--" + boundary;
	}	
    }
    stream += "--";

    return stream;
}

function dragenter(e){
    e.stopPropagation();
    e.preventDefault();
}

function dragover(e){
    e.stopPropagation();
    e.preventDefault();
}

function drop(e){
    e.stopPropagation();
    e.preventDefault();
    
    var dt = e.dataTransfer;
    var data = { a: "This is A", b: "これはBだよ", file: dt.files };
    var boundary = "---------------------------17206603573748375831527334924";
    var stream = multipartFormat(data, boundary);

    var xhr = new XMLHttpRequest();
    xhr.open("POST", "upload.php");
    xhr.setRequestHeader("Content-type", "multipart/form-data; boundary=" + boundary);
    xhr.setRequestHeader("Content-length", stream.length);
    xhr.sendAsBinary(stream);
    
    xhr.onreadystatechange = function (evt) {
	if (xhr.readyState == 4) {	    
	    if(xhr.status == 200) {
		var data = eval("(" + xhr.responseText + ")");
		console.log(data.status);
		if(data.status == "success") {
		    alert("アップロードが完了しました.");
		    console.log(data.post.a + " / " + data.post.b);
		} else {
		    alert("アップロードに失敗しました.");
		}
	    } else {
		alert("サーバにアクセスできません.");
	    }
	}
    }
}

function init() {
    document.getElementById("droparea").addEventListener("dragenter", dragenter, false);
    document.getElementById("droparea").addEventListener("dragover", dragover, false);
    document.getElementById("droparea").addEventListener("drop", drop, false);
}

データを受け取るupload.phpは次のようになっています.

upload.php

<?php
require('FirePHPCore/fb.php');
fb(array_map(urldecode, $_POST));
foreach($_FILES as $file) {
  if(is_uploaded_file($file["tmp_name"]))
    $status = move_uploaded_file($file['tmp_name'], $file['name']) ? 'success' : 'error';
}

echo json_encode(array('status' => $status, 'post' => array_map(urldecode, $_POST)));
?>

ドラッグアンドドロップを試すページは以下のようなものです.

index.html

<html>
<head>
<title>File API Test</title>
<script type="text/javascript" src="fileapi.js"></script>
</head>
<body onLoad="init()">
<div id="droparea" style="width: 400px; height: 200px; border: solid 1px; text-align: center;">
  ここにファイルをドラッグ&ドロップ
</div>
</body>
</html>

これでファイルをドラッグアンドドロップしてみると…
20100720081555

うん,ちゃんとファイルがアップロードできて,パラメータも渡すことができてますね.満足まんぞく.

参考