ブラウザからのファイルアップロードいろいろ

2021/04/04更新

目次

ファイルアップロードの基本

HTMLの記述

HTMLの<form>でファイルを選択させるには、<input type="file">を使って以下のように記述する。

<form action="myForm.cgi" method="post" enctype="multipart/form-data">
<input type="file" name="myFile" id="myFile">
<input type="submit" value="送信">
</form>

ファイルを送るにはPOSTリクエストを送る必要があるため、<form>タグにはmethod="post"が必要である。また、ファイルの中身を送信するためにエンコード方式としてenctype="multipart/form-data"を指定する必要がある。

サーバー側での受け取り

multipart/form-data形式でファイル送信されたPOSTリクエストは、サーバー側から見ると、以下のようなHTTPヘッダが付いている。

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

boundary=の後には区切り文字として自動的に任意の文字列が指定されている。また、リクエストボディは以下のようになる。

--abcde12345
Content-Disposition: form-data; name="myFile"; filename="sample.txt"
Content-Type: text/plain

ここからファイルの中身
 …
ここまでファイルの中身
--abcde12345--

ファイルは、--区切り文字から始まり、ヘッダ、空行、本文、--区切り文字--というMIMEのマルチパートの形式で直列化して送られてくる。Content-Dispositionには、<inupt>タグで設定した名前と実際のファイル名(ディレクトリパスは含まれない)、Content-TypeにはファイルのMIMEタイプが渡される。ちなみに、ヘッダや区切り文字の後の改行はCRLF(0x0D 0x0A)となっている。

上記はテキストファイルを送信した場合の例であるが、バイナリファイルを送った場合でも特にエンコードはされず、同じフォーマットでバイト列として丸ごと送られてくる。

通常は、このmultipart/form-dataの形式を生で解釈することはほとんどなく、たいていのプログラミング言語で用意されているライブラリなどを使って読み込むことになる。

複数ファイルのアップロード

複数のファイルを同時に送れるようにするには、<input>タグにmultiple属性を付けることで、複数ファイルの選択が可能になる。

<input type="file" name="myFile" id="myFile" multiple>

これで複数のファイルを送ると、サーバー側では以下のようになる。

--abcde12345
Content-Disposition: form-data; name="myFile"; filename="sample1.txt"
Content-Type: text/plain

ここから1つ目のファイルの中身
 …
ここまで1つ目のファイルの中身
--abcde12345
Content-Disposition: form-data; name="myFile"; filename="sample2.txt"
Content-Type: text/plain

ここから2つ目のファイルの中身
 …
ここまで2つ目のファイルの中身
--abcde12345
Content-Disposition: form-data; name="myFile"; filename="sample3.txt"
Content-Type: text/plain

ここから3つ目のファイルの中身
 …
ここまで3つ目のファイルの中身
--abcde12345--

このように--区切り文字で区切られながら複数のファイルが直列化され、最後は--区切り文字--で終わる。

複数ファイルの送信で注意が必要なのは、同じ<input>で送信したファイルは全てnameが同じ名前になっている点である。プログラミング言語やライブラリによってはこれを配列のように扱う方法もあったりするが、名前をキーにして連想配列のように扱う場合などは注意が必要となる。

他のフォーム項目との同時送信

<input type="file">以外にも通常の<input type="text"><input type="hidden">などの入力項目が同じフォーム内にあった場合は、以下のようになる。

--abcde12345
Content-Disposition: form-data; name="myFile"; filename="sample.txt"
Content-Type: text/plain

ここからファイルの中身
 …
ここまでファイルの中身
--abcde12345
Content-Disposition: form-data; name="myText"

他の入力項目の値
--abcde12345

このように、各項目がファイルと同様に--区切り文字で区切られ、名前がContent-Dispositionnameに指定され、値が本文として渡される。

JavaScriptからの操作

選択されたファイルの内容を取得

JavaScriptのFile APIにより、例えば画像を選択したら、その場ですぐ表示することなどが可能となっている。

// type="file"の<input>タグ
const input = document.getElementById('myFile');
// 値に変更があった場合
input.addEventListener('change', function() {
    // FileReaderでファイルを読み込む
    const reader = new FileReader();
    // 読み込みが完了したら、<img>タグで表示
    reader.onload = function() {
        const img = document.createElement('img');
        img.src = reader.result;
        document.body.appendChild(img);
    };
    // 選択された1つ目のファイルをData URLとして読み込む
    reader.readAsDataURL(input.files[0]);
});

<input>タグの.filesプロパティで取得できるのは、個々のファイルを表すFileオブジェクトを束ねたFileListオブジェクトである。multiple属性で複数ファイル選択可能にした場合は、ここに複数のファイルが含まれることになる。FileListオブジェクトは、配列と同様に[n]で各ファイルを取得でき、ファイルの個数も.lengthで取得できる。

また、Fileオブジェクトからは以下の情報を取得できるため、ファイル読み込みやアップロードの前に、拡張子やファイルサイズでフィルタリングをかけることもできる。ちなみに、typeで取得できるMIMEタイプは、ブラウザが拡張子に基づいて推測したものであって、中身を見て実際に判定したものではなく、空文字列になることもある。

プロパティ

意味

値の例

lastModified

最終更新日(Unix Timeのミリ秒)

1577804400000

name

ファイル名

myImage.png

size

ファイルサイズ(バイト)

16384

type

ファイルのMIMEタイプ

image/png

ファイル読み込みに使うFileReaderオブジェクトのメソッドとしては、readAsDataURL()の他に、テキストとして読み込むreadAsText()などがある。このようなメソッドを使って事前にファイルを読み込んでしまえば、multipart/form-data形式に従わなくても、独自の形式でファイル送信することも可能となる。これ以降で説明する方法は全てmultipart/form-data形式での送信となる。

Ajaxでのアップロード

Ajaxでアップロードする場合、FormDataオブジェクトを作り、そこにFileオブジェクトを追加していけばよい。

// FileListオブジェクトを受け取り、FormDataオブジェクトを返す関数
const createFormData = function(files) {
    // FormDataオブジェクトにFileオブジェクトを追加する
    const formData = new FormData();
    for (const file of files) {
        formData.append('myFile', file);
    }
    // ファイル以外のフォームデータがあれば同様に追加する
    formData.append('myText', 'hogehoge');
    return formData;
};

// FormDataオブジェクトを受け取り、XMLHttpRequestでAjax送信する関数
const fileUpload = function(formData) {
    // XMLHttpRequestでフォームデータをPOST送信する
    const xhr = new XMLHttpRequest();
    xhr.open('post', 'myForm.cgi');
    xhr.onload = function() {
        // 送信成功時の動作をここに記述する
    };
    xhr.send(formData);
};

jQueryでAjax送信する場合は、以下のように$.ajax()を使い、POST送信指定をして、さらにcontentType: falseprocessData: falseを指定する必要がある。

// FormDataオブジェクトを受け取り、jQueryでAjax送信する関数
const fileUploadJQuery = function(formData) {
    // $.ajaxでフォームデータをPOST送信する
    $.ajax({
        url: 'myForm.cgi',
        type: 'post',
        data: formData,
        contentType: false,
        processData: false
    }).then(function(res) {
        // 送信成功時の動作をここに記述する
    });
};

ドラッグ&ドロップでアップロード

ドラッグ&ドロップでファイルアップロードできるようにするには、まずドロップされたファイルを受け取るエリアをHTMLで作る。このようなエリアは、視認性の向上を図ってか、角丸の粗い破線で囲われたデザインになっているのをよく見かける。

<style>
#drop_area {
    width: 600px;
    height: 300px;
    border: 3px dashed #666666;
    border-radius: 16px;
    text-align: center;
    line-height: 300px;
}
</style>
 :
<div id="drop_area">ここにファイルをドラッグ&ドロップして下さい。</div>

次いで、以下のようにドラッグ&ドロップ関連のイベントをハンドリングし、ドロップされたファイルをdataTransferプロパティからFileListオブジェクトとして取得することで、アップロードが可能となる。

// ドロップされたファイルを受け取るエリアのHTML要素
const dropArea = document.getElementById('drop_area');

// ドラッグした状態で要素内に入ったとき
dropArea.addEventListener('dragover', function(e) {
    // デフォルトの動作を止めて、要素のスタイルを変える
    e.stopPropagation();
    e.preventDefault();
    dropArea.style.background = '#cccccc';
});

// ドラッグした状態で要素から出たとき
dropArea.addEventListener('dragleave', function(e) {
    // 要素のスタイルを元に戻す
    dropArea.style.background = '#ffffff';
});

// ファイルが要素内でドロップされたとき
dropArea.addEventListener('drop', function(e) {
    // デフォルトの動作を止めて、要素のスタイルを元に戻す
    e.preventDefault();
    dropArea.style.background = '#ffffff';
    // dataTrasnferプロパティからドロップされたファイルのFileListオブジェクトが取得できる
    const files = e.dataTransfer.files;
    // 前述の関数を使ってFormDataの作成およびAjax送信
    const formData = createFormData(files);
    fileUpload(formData);
});

jQueryを使う場合は以下のようになる。

// ドロップされたファイルを受け取るエリアのHTML要素
const $dropArea = $('#drop_area');

// ドラッグした状態で要素内に入ったとき
$dropArea.on('dragover', function(e) {
    // デフォルトの動作を止めて、要素のスタイルを変える
    e.stopPropagation();
    e.preventDefault();
    $dropArea.css('background', '#cccccc');
});

// ドラッグした状態で要素から出たとき
$dropArea.on('dragleave', function(e) {
    // 要素のスタイルを元に戻す
    $dropArea.css('background', '#ffffff');
});

// ファイルが要素内でドロップされたとき
$dropArea.on('drop', function(e) {
    // デフォルトの動作を止めて、要素のスタイルを元に戻す
    e.preventDefault();
    $dropArea.css('background', '#ffffff');
    // dataTrasnferプロパティからドロップされたファイルのFileListオブジェクトが取得できる
    const files = e.originalEvent.dataTransfer.files;
    // 前述の関数を使ってFormDataの作成およびAjax送信
    const formData = createFormData(files);
    fileUploadJQuery(formData);
});

ディレクトリ丸ごとのアップロード

通常、ドラッグ&ドロップで受け取れるのは、1つまたは複数のファイルがドロップされた場合で、ディレクトリがドロップされてもその中のファイルを受け取ることはできないが、非標準のFile System APIを使用することで、ディレクトリの中のファイルも再帰的に全て読み込むことが可能となる。

ドラッグ&ドロップした際のdataTransferから、itemsというプロパティを読み取ることで、DataTransferItemオブジェクトのリストを得ることができる。DataTrasnferItemオブジェクトにはwebkitGetAsEntry()という非標準のメソッドがあり、これを呼ぶとファイルシステム上のファイルエントリまたはディレクトリエントリとして扱うことができるようになる。ファイルエントリの場合はFileオブジェクトの取得、ディレクトリエントリの場合はそのディレクトリ内のファイルエントリの取得が可能となる。Fileオブジェクトさえ取得できれば、前述の方法でアップロードが可能となる上、ファイルエントリからは最初のディレクトリからの相対パスも取得できるため、サブディレクトリの構造の情報も送信することができるようになる。

// dataTransfer.itemsを受け取り、FormDataオブジェクトを返す非同期関数
const readDirectory = async function(items) {
    // FormDataオブジェクトを作成
    const formData = new FormData();
    // ディレクトリエントリからエントリ一覧を取得する非同期関数
    const readAllEntries = function(reader) {
        return new Promise(function(resolve, reject) {
            const allEntries = [];
            const readEntries = function() {
                // reader.readEntries()は一度に最大100エントリまでしか返さないので、全てのエントリを読み終えるまでくり返し呼び出す
                reader.readEntries(function(entries) {
                    if (entries.length > 0) {
                        allEntries.push(...entries);
                        readEntries();
                    } else {
                        // 全てのエントリを読み出せたらPromiseを解決する
                        resolve(allEntries);
                    }
                });
            };
            readEntries();
        });
    };
    // エントリを受け取り、再帰的に読み込む非同期関数
    const appendFile = function(entry) {
        return new Promise(async function(resolve, reject) {
            // ファイルエントリの場合
            if (entry.isFile) {
                // Fileオブジェクトを取得する
                entry.file(function(file) {
                    // 最初のディレクトリからの相対パスを名前にしてFormDataオブジェクトに追加
                    const path = encodeURIComponent(entry.fullPath);
                    formData.append(path, file);
                    resolve();
                });
            // ディレクトリエントリの場合
            } else if (entry.isDirectory) {
                // ディレクトリ内のエントリ一覧を取得する
                const entries = await readAllEntries(entry.createReader());
                // 全てのエントリについて再帰的に処理
                const procs = [];
                for (const entry of entries) {
                    procs.push(appendFile(entry));
                }
                await Promise.all(procs);
                resolve();
            } else {
                resolve();
            }
        });
    };
    // 渡された全てのファイル・ディレクトリについて再帰的に処理
    const procs = [];
    for (const item of items) {
        procs.push(appendFile(item.webkitGetAsEntry()));
    }
    await Promise.all(procs);
    // ディレクトリ内の全ファイルを含んだFormDataオブジェクトを返す
    return formData;
};

上記の関数は、dropイベントハンドラ内で以下のようにして使うことができる。File System APIの一部がPromiseを返す非同期関数となっているので、asyncの付け忘れや非同期的な動作などに注意する。

// ファイルが要素内でドロップされたとき
dropArea.addEventListener('drop', async function(e) {
    // デフォルトの動作を止めて、要素のスタイルを元に戻す
    e.preventDefault();
    dropArea.style.background = '#ffffff';
    // 前述の関数を使ってFormDataの作成およびAjax送信
    const formData = await readDirectory(e.dataTransfer.items);
    fileUpload(formData);
});

このようにして、例えば、以下のようなディレクトリ「myDir」をドラッグ&ドロップしたとする。

myDir/
  mySubDir1/
    myFile1.txt
  mySubDir2/
    myFile2.txt
    myFile3.txt

すると、サーバー側では以下のようになる。

--abcde12345
Content-Disposition: form-data; name="%2FmyDir%2FmySubDir1%2FmyFile1.txt"; filename="myFile1.txt"
Content-Type: text/plain

myDir/mySubDir1/myFile1.txtの中身
--abcde12345
Content-Disposition: form-data; name="%2FmyDir%2FmySubDir2%2FmyFile2.txt"; filename="myFile2.txt"
Content-Type: text/plain

myDir/mySubDir2/myFile2.txtの中身
--abcde12345
Content-Disposition: form-data; name="%2FmyDir%2FmySubDir2%2FmyFile3.txt"; filename="myFile3.txt"
Content-Type: text/plain

myDir/mySubDir2/myFile3.txtの中身
--abcde12345--

各ファイルの名前に相対パスを設定しているので、これを読み取れば、サーバー側でサブディレクトリの構造も再現することができる。

ただし、これだとファイルが1個も無い空のディレクトリの情報は送れないので注意が必要である。また、全てのサブディレクトリを辿っていってしまうため、送信サイズが巨大化しないよう、ファイルの数や合計サイズなどで何らかの制限を設けた方が良いだろう。

また、上記のコード内にも書いているが、ディレクトリエントリを読み出すreadEntries()は、一度に返るエントリの数に上限がある(Chromeでは100エントリ)ので注意が必要である。全てのエントリを読み出すには、上記のコード例のようにエントリが読み出せなくなるまで同じreaderから複数回readEntries()を読み出す工夫が必要となる。(※FileSystemDirectoryReader.readEntries() - Web APIs | MDNに「Note that to read all files in a directory, readEntries needs to be called repeatedly until it returns an empty array.」との記載がある。)

外部リンク