Ajaxによるmultipart/postでの画像ファイルアップロード その3

こんにちは。内藤です。

前回
http://blog.asial.co.jp/1271
前々回
http://blog.asial.co.jp/1260
は、それぞれFormタグ、Cameraプラグインで画像を取得し、それをFormDataオブジェクトにBlobとして付与して、それをAjaxでサーバーにアップロードする方法について紹介しました。

今回は、FormDataではなく、手動でMultipart Postを生成してアップロードする方法について紹介します。FormDataは便利なのですが、Multipartの仕組みがすべてブラックボックス化されてしまっているため、内部の動作がよく分かりません。今回紹介する方法は、Multipartを自分で作成するため、他の言語で同様の機能を実装するのにも役立つかと思います。

かつては、JavaAppletなどでMultipart送信をするためによく使われた方法なのですが、最近ではあまり情報がないようなので、まとめてみました。

Multipartの基本的な仕組み

Multipartはもともと1つのメールに複数の添付ファイルを付与する方法を実現するために提唱されたもので、簡単にいうと、複数の添付ファイル(データ)を、boundaryと呼ばれる区切り文字で区切って、つなげたものをやり取りします。詳しくは
http://www.atmarkit.co.jp/ait/articles/0104/18/news002.html
などを確認してみて下さい。

WebのPostでもメールと同様に1回のPostに複数の添付ファイルを付与することが出来るようになっています。ただし、メールと異なる部分もあるので、注意が必要です。
http://www.ietf.org/rfc/rfc2616.txt
http://www.spencernetwork.org/reference/rfc2616-ja-HTTP1.1.txt
の19.4に、違いが記されています。特に、Content-Transfer-Encodingが利用出来ないことには注意です。このため、画像などは(base64ではなく)binaryとして送信する必要があります。

簡単なMultipartのサンプル

まずは、テキストのみでMultipartを実現する方法を確認してみます。


function sendPost() {
    var request = new XMLHttpRequest();
    request.open("POST",'http://your.server.url',true);
    var boundary = createBoundary();
    request.setRequestHeader( "Content-Type", 'multipart/form-data; boundary=' + boundary );
    var body = '';
    body += '--' + boundary + '\r\n' + 'Content-Disposition: form-data; name="myfield"\r\n\r\n';
    body += "hogehoge";
    body += '\r\n';
    body += '--' + boundary + '--';
    request.send( body );
    alert("send!");
function createBoundary() {
    var multipartChars = "-_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    var length = 30 + Math.floor( Math.random() * 10 );
    var boundary = "---------------------------";
    for (var i=0;i < length; i++) {
        boundary += multipartChars.charAt( Math.floor( Math.random() * multipartChars.length ) );
    }
    return boundary;
}

<div onClick="sendPost();">Click!</div>

こんな感じになります。これは、HTMLでいうと、次のものと同じになります。


<form action="your.server.url" method="post" enctype="multipart/form-data">
  <input type="text" name="myfield" />
  <input type="submit" name="Send" />
</form>

boundary文字列は、適当に作ったものなので、文字種と長さが正しければ、なんでも構いません。

画像をUploadする場合

テキストと同じように、画像のUploadも出来るのですが、一つ大きな問題があります。
それは、画像がバイナリファイルであるため、テキストのように文字列オブジェクトで送ることが出来ないことです。

メールの場合は、base64などのエンコードを行って添付することも出来るのですが、Webページでは先に記述したようにContent-Transfer-Encodingに対応していないため出来ません。

そこで、ArrayBufferまたはUint8Array、Blobなどの形式で送ることが必要になります。これは、XHR2 (XmlHttpRequest 2)が必要となります。残念ながら、Android 2.3のストックブラウザはXHR2に対応していないので、この方法は利用出来ません。Android 2.3の場合、サーバー側でbase64エンコードしたファイルを扱うようにするか、もしくは、CordovaのFileTransferオブジェクトを利用して実現するのが良いと思います。

話を元に戻して、XHR2が使える前提で考えてゆきます。例えば、sample.jpgという画像(wwwの直下の画像)をアップロードする場合、sendPost関数は次のようになります。


function sendPost() {
    
    var oReq = new XMLHttpRequest();
    oReq.open("GET","sample.jpg", true);
    oReq.responseType = "arraybuffer";
    oReq.onload = function(oEvent) {
        var arrayBuffer = oReq.response;
        console.log( "len = " +  arrayBuffer.byteLength );
        
        var request = new XMLHttpRequest();
        request.open("POST",'http://your.server.url',true);
        var boundary = createBoundary();
        request.setRequestHeader( "Content-Type", 'multipart/form-data; boundary=' + boundary );
        
        var buffer = unicode2buffer( 
            '--' + boundary + '\r\n' + 'Content-Disposition: forname="userfile"; filename="myimage.png"\r\n'
                                     + 'Content-Type: image/jpeg\r\n\r\n'
        );
        
        var buffer = appendBuffer( buffer , 
            arrayBuffer
        );
        
        var buffer = appendBuffer( buffer , 
            unicode2buffer(
                '\r\n' + '--' + boundary + '--'
            )
        );
        
        request.send( buffer );
        alert("send!");
    }
    oReq.send(null);
    
}
function unicode2buffer(str){
    var n = str.length,
        idx = -1,
        byteLength = 512,
        bytes = new Uint8Array(byteLength),
        i, c, _bytes;
    for(i = 0; i < n; ++i){
        c = str.charCodeAt(i);
        if(c <= 0x7F){
            bytes[++idx] = c;
        } else if(c <= 0x7FF){
            bytes[++idx] = 0xC0 | (c >>> 6);
            bytes[++idx] = 0x80 | (c  & 0x3F);
        } else if(c <= 0xFFFF){
            bytes[++idx] = 0xE0 | (c >>> 12);
            bytes[++idx] = 0x80 | ((c >>> 6)  & 0x3F);
            bytes[++idx] = 0x80 | (c  & 0x3F);
        } else {
            bytes[++idx] = 0xF0 | (c >>> 18);
            bytes[++idx] = 0x80 | ((c >>> 12)  & 0x3F);
            bytes[++idx] = 0x80 | ((c >>> 6)  & 0x3F);
            bytes[++idx] = 0x80 | (c  & 0x3F);
        }
        if(byteLength - idx <= 4){
            _bytes = bytes;
            byteLength *= 2;
            bytes = new Uint8Array(byteLength);
            bytes.set(_bytes);
        }
    }
    idx++;
    
    var result = new Uint8Array(idx);
    result.set(bytes.subarray(0,idx),0);
    
    return result.buffer;
}
function appendBuffer(buf1,buf2) {
    var uint8array = new Uint8Array(buf1.byteLength + buf2.byteLength);
    uint8array.set(new Uint8Array(buf1),0);
    uint8array.set(new Uint8Array(buf2),buf1.byteLength);
    return uint8array.buffer;
}

Stringではなく、ArrayBufferとしてContentを結合してゆきます。
なお、サーバー側のコードは、以前
http://blog.asial.co.jp/1260
と同じです。

Formのように画像を選択せずに、直接、ファイルをアップロードすることが出来ます。

Android 2.3への対応

先にも述べたように、XHR2に対応していないAndroid 2.3では、上記の方法は使えません。
http://blog.asial.co.jp/1313
では、Android 2.3用にArrayBufferやUint8Arrayクラスを作成しましたが、これは、あくまでCordovaを騙してArrayBufferやUint8Arrayがあるように見せかけるためのクラスなので、ブラウザの標準機能のみで実装する場合は、この方法は使えません。

そこで、ここでは簡単にFileTransferプラグインを利用する方法を示します。

上記と同様の処理を実現するためには、


function sendPost6() {
    var oReq = new XMLHttpRequest();
    oReq.open("GET","sample.jpg", false);
    oReq.overrideMimeType('text/plain; charset=x-user-defined');
    oReq.onload = function(oEvent) {
        var response = oReq.responseText;
        var length = response.length;
        console.log( "length = " + length);
        
        var array = new Array();
        for (var i=0;i<length;i++) {
            array.push(  response.charCodeAt(i)  & 0xff );
        }
        var uint8array = new Uint8Array( array );
        var b64data = b64utils.encode( uint8array );
        var imageUrl = "data:image/jpeg;base64," + b64data;
        var options = new FileUploadOptions();
        options.fileKey = "userfile";
        options.fileName = "myimage.png";
        var ft = new FileTransfer();
        ft.upload( imageUrl , encodeURI("http://your.server.url"), 
          function() { alert("success!"); },
          function() { alert("fail!"); }  ,
          options );
        
    }
    oReq.send(null);
}

のようになります。

ここで、Uint8Arrayやb64utilsは、
http://blog.asial.co.jp/1313
で使ったものと同じものです。

ローカルのsample.imgを読み込むときも工夫していることに注意して下さい。
http://www.html5rocks.com/ja/tutorials/file/xhr2/
に述べられていますが、XHR1でバイナリファイルを読み込むために事実上標準的に用いられる方法です。

この場合、1バイトが(UTF8の)1文字として格納されますが、当然、UTF8の1文字は1バイトとは限らないので、1文字ずつ取り出して0xffで和をとり、array型に代入しています。
それから、Uint8Array型に変換しているのは、以前のサブルーチンを再利用するためだけなのですが、b64utilsを使って、base64化し、データスキームでURLを作ります。
あとは、FileTransferでアップロードを行えば、完了です。

まとめ

Ajaxによるmultipart/postでの画像ファイルアップロードについて紹介しました。
これと、CordovaのFile APIを使うと、画像ファイル(バイナリファイル)の取り回しが自由になります。
ぜひ、アプリ開発の参考にして下さい。

おまけ

UTF8の文字列と、そのバイト表現についての対応を記載しておきます。

1文字cが

c <= 0x7F の場合
1バイト表現
1バイト目 0xxxxxxx

0x7F < c <= 0x7FF の場合
2バイト表現
1バイト目 上位5ビット 110xxxxx
2バイト目 下位6ビット 10xxxxxx

0x7FF < c <= 0xFFFF の場合
3バイト表現
1バイト目 上位4ビット 1110xxxx
2バイト目 中位6ビット 10xxxxxx
3バイト目 下位6ビット 10xxxxxx

0xFFFF < c <= 0x1FFFFF の場合
4バイト表現
1バイト目 上位3ビット 11110xxx
2バイト目 中上位6ビット 10xxxxxx
3バイト目 中下位6ビット 10xxxxxx
4バイト目 下位6ビット 10xxxxxx