Edited at

最速・最高のファイルアップロードに近づくための1歩

More than 1 year has passed since last update.

Webアプリを作っていてよく出くわすのがファイルアップロードですね。単純にアップロードするだけなら実装自体はたいしたことないものですが、より良くしようと思うと想像以上に奥が深く…悩ましい沼感があります🤔

今回は今までファイルアップロードを実装していく中で手に入れた改善ポイントを紹介していきます。これで最速・最高のファイルアップロードに1歩でも近づけられればと思います。

なお、僕が普段開発をしているアーキテクチャの都合上、 nginx Rails の話が出てきますが一部を除きWebアプリなら普遍的に使える話だと思います。

2つの側面から紹介します。 UI編パフォーマンス編 です。

UI編は、HTML5を中心に使い勝手を向上させるためのポイントを紹介します。パフォーマンス編ではRailsのファイルアップロードを約10倍高速化⚡️した事例を紹介します。それでは長いですが、よろしくお願いします。


UI編


まえがき・注意書き

僕が使いやすいと思うポイントを紹介します。ただし、UIが優れているとか、使いやすいというのは、サービスのターゲットユーザや、使われるシーンや目的・環境に大きく左右されるものなので、これをやれば最強に使いやすいファイルアップロードUIになる!というものではないと思っています。下手にいじらずブラウザデフォルトのUIそのままのほうが効果がある場面もあります。実際にA/Bテストやユーザヒアリングを通じて取捨選択をしてください。

また、これから紹介するものは基本的にPC・スマートフォン両方に対応しています。サンプルでjQueryを使用していますが、記述量を抑えるためで、とくにjQuery固有の機能などは使っていません。


IE8/9を考慮

2017年を迎えようとしている今IE8/9を本当にサポートするべきかは検討の余地がかなりあります。まず実際にIE8/9を使ってアクセスしている人がどれだけいるのか検証をして、対応するコストとメリットをちゃんと見て判断しましょう。クライアントがいて説得が不可能な場合は、お金をもらいましょう。


input type="file"ボタンの装飾

まず最初に手を付けやすい部分としてファイルを参照するための<input type="file">ボタンをCSSで装飾します。


モダンブラウザ

IE8以下を除くブラウザであれば下記のようなCSSでボタンの代替ができると思います。iOSやAndroidでもきちんと使えます。

<div class="upload-box">

<button type="button">
upload
</button>
<input type="file" name="file">
</div>

.upload-box {

position: relative;
}

button {
width: 100px;
height: 100px;
border: none;
background-color: #1C90F3;
color: #ffffff;
}

input[type=file] {
height: 0px;
visibility: hidden;
position: absolute;
}

$("button").on("click", function() {

$("input[type=file]").click();
});


画像のプレビュー

次によくあるのが選択した画像ファイルをアップロード前にプレビューしたいというものです。これによってファイルの選択ミスを抑えることができます。

<form action="#">

<input type="file" name="file" id="file">
<div id="preview"></div>
</form>

#preview img{

width: 100px;
}

$('#file').change(function() {

var fr = new FileReader();
fr.onload = function() {
var img = $('<img>').attr('src', fr.result);
$('#preview').append(img);
};
fr.readAsDataURL(this.files[0]);
});


ドロップ

わざわざ参照ボタンを押してファイル選択ダイアログから選ぶのは面倒くさいというケースがあります。最近わりとスタンダードになった、ファイルをドロップしてアップロードするUIです。ただ、スマートフォンだとできないためレスポンシブデザインを考えると、サービスによっては採用しにくいUIなのかもしれないです。

<div id="drop_zone">

ドロップ
</div>

<div id="preview"></div>

<button type="button" id="post">post</button>

#drop_zone {

border: 5px dashed #000000;
padding: 50px;
margin: 20px;
font-size: 24px;
color: #000000;
background-color: #ffffff;
text-align: center;
}

#preview {
overflow: hidden;
}

#preview img {
width: 100px;
float: left;
margin: 10px;
cursor: move;
}

var formData = new FormData();

var dropZone = document.getElementById("drop_zone");

dropZone.addEventListener("dragover", function(e) {
e.stopPropagation();
e.preventDefault();

this.style.background = "#ff3399";
}, false);

dropZone.addEventListener("dragleave", function(e) {
e.stopPropagation();
e.preventDefault();

this.style.background = "#ffffff";
}, false);

dropZone.addEventListener("drop", function(e) {
e.stopPropagation();
e.preventDefault();

this.style.background = "#ffffff";

var files = e.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
(function() {
var fr = new FileReader();
fr.onload = function() {
var div = document.createElement('div');

var img = document.createElement('img');
img.setAttribute('src', fr.result);
div.appendChild(img);

var preview = document.getElementById("preview");
preview.appendChild(div);
};
fr.readAsDataURL(files[i]);
})();

formData.append("file", files[i]);
}
}, false);

var postButton = document.getElementById("post");
postButton.addEventListener("click", function() {
var request = new XMLHttpRequest();
request.open("POST", "POST_URL");
request.send(formData);
});


並び替え

アップロードしたファイルの並び順を変更したいニーズのときにもDragイベントは役に立ちます。基本の流れとしては以下のような形で実装をしてみました。ソースが長いので処理が複雑そうに見えますが、やってることは簡単です。


  • ドロップゾーンにファイルをドロップする

  • ドロップされたタイミングで、サーバにファイルをアップロードする

  • サーバはファイルを保存して、ユニークなファイルIDをJSONで返却する

  • プレビューで<img>タグを生成

  • サーバ側で発行されたファイルIDを<img>タグに埋め込む


  • dataTransferを使って、並び替え時にドラッグしているファイルのDOMを保存

  • ドロップ先のDOMと入れ替え

  • アップロードファイルのDOM全てのファイルIDと並び順をサーバにPOST

  • サーバはファイルIDを元にデータベースを並び替え

<div id="drop_zone">

ドロップ
</div>
<div id="preview">
</div>

var dragSrcObj;

var handleDragStart = function(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.innerHTML);
dragSrcObj = this;
};

var handleDragOver = function(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
};

var handleDragEnter = function(e) {};

var handleDragLeave = function(e) {};

var handleDrop = function(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (dragSrcObj != this) {
dragSrcObj.innerHTML = this.innerHTML;
this.innerHTML = e.dataTransfer.getData('text/html');

var reorders = [].map.call(document.querySelectorAll('.upload-file'), function(v, index) {
return [v.getAttribute("data-file-id"), index]
});

var request = new XMLHttpRequest();
request.open("POST", "./upload.php");
request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
request.send(JSON.stringify(reorders));
}

return false;
};

var handleDragEnd = function(e) {};

function upload(file, dom) {
var formData = new FormData();
formData.append("file", file);

var request = new XMLHttpRequest();
request.responseType = "json";
request.addEventListener('loadend', function() {
if (this.status === 200) {
dom.setAttribute("data-file-id", this.response.file_id);
}
});
request.open("POST", "./upload-reorder.php");
request.send(formData);
}

var dropZone = document.getElementById("drop_zone");

dropZone.addEventListener("dragover", function(e) {
e.stopPropagation();
e.preventDefault();
}, false);

dropZone.addEventListener("drop", function(e) {
e.stopPropagation();
e.preventDefault();

var files = e.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
(function() {
var fr = new FileReader();
fr.onload = function() {
var div = document.createElement('div');
div.setAttribute('draggable', 'true');
div.addEventListener('dragstart', handleDragStart, false);
div.addEventListener('dragenter', handleDragEnter, false);
div.addEventListener('dragover', handleDragOver, false);
div.addEventListener('dragleave', handleDragLeave, false);
div.addEventListener('drop', handleDrop, false);
div.addEventListener('dragend', handleDragEnd, false);

var img = document.createElement('img');
img.classList.add('upload-file');
img.setAttribute('src', fr.result);
div.appendChild(img);

var preview = document.getElementById("preview");
preview.appendChild(div);
upload(files[i], img);
};
fr.readAsDataURL(files[i]);
})();
}
}, false);


dataTransfer

dataTransferを使ったファイルの並び替え処理が分かりにくいので抜粋して説明します。

var dragSrcObj;

var handleDragStart = function(e) {
e.dataTransfer.setData('text/html', this.innerHTML);
dragSrcObj = this;
};

var handleDrop = function(e) {
if (dragSrcObj != this) {
dragSrcObj.innerHTML = this.innerHTML;
this.innerHTML = e.dataTransfer.getData('text/html');
}
};

handleDragStartでドラッグを開始したときに、該当ファイルのDOMをdataTransferに保存します。また、ドロップしたときに入れ替えられるようにオブジェクトをdragSrcObjに退避しておきます。

その後、ドロップ先のdropイベントが発火したときにドラッグ中のオブジェクトと自身を比較して違う場合はDOMを入れ替えるということをやっています。これで見た目上のファイルの入れ替えが行われていると思います。


effectAllowed / dropEffect

これらのプロパティでドラッグ操作で許可したい操作を指定することができます。

var handleDragStart = function(e) {

e.dataTransfer.effectAllowed = 'move';
};

var handleDragOver = function(e) {
e.dataTransfer.dropEffect = 'move';
};

ただし実際にユーザの行動が制限されるわけではなく、単純にカーソルのアイコンが変わるだけのようです。とはいえ視覚的にどんな操作をしようとしているのかを教えてくれるので指定したほうが良いと思います。


アップロード進行度を表示

大きめのファイルをアップロードするような場合、進行度をわかりやすく示すプログレスバーを導入すると良いと思います。ファイルが軽すぎると一瞬なので見せ方の工夫をしたほうが良さそうです。

XMLHttpRequestprogressイベントを使えばファイルアップロードのバイトサイズが得られるので、そこから進行割合を計算しています。もちろんjQueryの$.ajaxでも同様のことができます。

<progress value="0" max="100"></progress>

<input type="file" name="file" id="file">
<button type="button" id="post">post</button>

<script>
function updateProgress(e) {
if (e.lengthComputable) {
var percent = e.loaded / e.total;
$("progress").attr("value", percent * 100);
}
}

$("#post").on("click", function() {
var formData = new FormData();
formData.append("file", document.getElementById("file").files[0]);

var request = new XMLHttpRequest();
request.upload.addEventListener("progress", updateProgress, false);
request.open("POST", "./upload");
request.send(formData);
});
</script>


その他


複数ファイル

ファイル選択ダイアログで複数のファイルを選ぶことが出来るようになります。

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


ファイルの種類制限

どんなファイルでもアップロード可能な機能はあまりなくて、画像や動画だけにファイルタイプをしぼっているものが大半だと思います。サーバサイドでのファイルチェックはあるにしても事前に選択できるものをしぼった状態で作れればより良いと思います。

<input type="file" name="file" accept="image/*,.png,.jpg,.jpeg,.gif">

MIMEタイプや、拡張子での指定がワイルドカードでできます。カンマ区切りで複数できます。注意しなくてはいけないのは、ここでファイルタイプを絞った結果を無条件で信用しないでください。これはあくまでユーザビリティの向上という点での機能だと考えたほうが良いです(HTMLは改ざんできる)。

ファイル選択ダイアログで、指定されたMIME/拡張子以外はグレーアウトされ、選択ができなくなります。


画像編集

アップロードした画像を編集する機能を自前でやるのはさすがに死ぬので、ライブラリの紹介に留めます。


パフォーマンス編

ちょっとしたファイルのアップロードであれば、パフォーマンスはあまり気にする必要はないと思いますが、数十MB〜数GBのファイルをアップロードするとなると話が変わってきます。

今回は約70MBのファイルをアップロードするのに50秒かかっていたのを5秒に改善した事例を紹介します。アーキテクチャはよくあるRailsの構成です。nginx -> unicorn -> rails -> S3 です。なお先に断っておくとPHPのプロジェクトであれば、なにもしなくても5秒くらいのパフォーマンスが出ると思います。


なぜRailsで大きなファイルをアップロードすると遅いのか

僕が知っている限りRailsで数十MB程度のファイルをアップロードすると、CPUが100%近くRubyに使われ時間が非常にかかります。同様のことをPHPでやってもそうはなりません。stackprofというgemを使ってプロファイルを取るとRack::Multipart::Parser#handle_mime_bodyがCPUのリソースを食っているのが分かりました。

$ stackprof tmp/stackprof-cpu-8634-1480990047.dump --text --limit 10

==================================
Mode: cpu(1000)
Samples: 5099 (66.11% miss rate)
GC: 196 (3.84%)
==================================
TOTAL (pct) SAMPLES (pct) FRAME
4415 (86.6%) 4379 (85.9%) Rack::Multipart::Parser#handle_mime_body
92 (1.8%) 79 (1.5%) block in delegating_block
45 (0.9%) 38 (0.7%) Sprockets::Cache::FileStore#safe_open
34 (0.7%) 34 (0.7%) Rack::Multipart::Parser#rx
42 (0.8%) 21 (0.4%) ActiveSupport::FileUpdateChecker#max_mtime
34 (0.7%) 17 (0.3%) #<Module:0x1fb1034>.touch
15 (0.3%) 15 (0.3%) URI::RFC3986_Parser#split
43 (0.8%) 14 (0.3%) ActionView::PathResolver#query
28 (0.5%) 14 (0.3%) ActionView::PathResolver#find_template_paths
13 (0.3%) 13 (0.3%) #<Class:0x23dbb00>#__getobj__

該当のRackコードを参照するとmultipartのバウンダリーを見つけるためにアップロードしたバッファをなめてるように見えます。つまり、ファイルサイズが大きければ大きいほど処理が重い…。(間違っていたらご指摘ください🙇)

def handle_mime_body

if @buf =~ rx
# Save the rest.
if i = @buf.index(rx)
@collector.on_mime_body @mime_index, @buf.slice!(0, i)
@buf.slice!(0, 2) # Remove \r\n after the content
end
@state = :CONSUME_TOKEN
@mime_index += 1
else
:want_read
end
end

https://github.com/rack/rack/blob/master/lib/rack/multipart/parser.rb#L265

ここから対策を考えた時、以下の3つを考えました。


  • Rackの該当部分を直す(モンキーパッチ? プルリク?)

  • Rackで処理をさせない

  • ファイルを分割してアップロードする

さすがにRack自体を直すのはコストが高すぎると判断したので、Rackで処理をさせない/ファイル分割アップロードの2つを試しました。


分割アップロード


  • FileAPIを使って指定バイトをslice使って分割

  • 抜き出したものを逐次ajaxでPOST

  • headersに何分割かと何番目なのかの情報を追加

  • サーバサイドで受け取って全部揃ったら順番通りに結合

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

<button type="button" id="btn">upload</button>

$("#btn").on("click", function() {

readBlob();
});

function upload(blob, id, maxId) {
$.ajax({
url: "./upload",
type: 'POST',
data: blob,
processData: false,
contentType: 'application/octet-stream',
headers: {
"Chunk-Id": id,
"Chunk-Max-Id": maxId
}
}).done(function(data) {
}).fail(function(data) {
});
}

function chunk(file, i, chunks, chunkSize) {
var reader = new FileReader();
var start = i * chunkSize;
var stop = start + chunkSize;
var blob = file.slice(start, stop);
reader.onloadend = function(evt) {
if (evt.target.readyState == FileReader.DONE) {
var blob = evt.target.result;
(upload(blob, i, chunks - 1));
}
};
reader.readAsArrayBuffer(blob);
}

function readBlob(chunkSize) {
chunkSize = chunkSize || 1024 * 1000 * 5;
var files = document.getElementById('file').files;
var file = files[0];
var chunks = Math.ceil(file.size / chunkSize);
var promises = [];
for (var i = 0; i < chunks; i++) {
chunk(file, i, chunks, chunkSize);
}
}

サーバサイドは性能検査が手軽だったのでPHPです。

$fp = fopen("/tmp/chunk-" . $_SERVER["HTTP_CHUNK_ID"], "w");

fputs($fp, file_get_contents('php://input'));
fclose($fp);

$all_ok = true;
for ($i = 0; $i <= $_SERVER["HTTP_CHUNK_MAX_ID"]; $i++) {
if (!file_exists("/tmp/chunk-" . $i)) {
$all_ok = false;
}
}
if ($all_ok) {
$fp = fopen("/tmp/all", "w");
for ($i = 0; $i <= $_SERVER["HTTP_CHUNK_MAX_ID"]; $i++) {
$data = file_get_contents("/tmp/chunk-" . $i);
fputs($fp, $data);
}
fclose($fp);
}

こんな感じでリクエストが複数飛んでます。

結果、分割して一つ一つの処理は軽くなったものの、10MB分割したとして100MBのファイルは10回リクエストが飛ぶことになります。いくら単発の処理は軽減されたとしても、あまり改善は見込めなさそうでした。Promise.allなどを使って並行処理をして高速アップロード!という夢も見たんですが…。場面や実装方法に寄っては高速化できるかもしれないので、のちのち検証していきたいです。


nginxでファイルアップロード

次のアイデア、Rackで処理をさせないを検証しました。やり方としては単純でnginxでアップロードを完結させるパターンです。ブラウザから送信されたファイルのバッファは、ブラウザ -> nginx -> Railsと2段階になるため、無駄が発生します。nginxでファイルを保存して、Railsにはそのファイルパスだけ教えてあげればRackが処理しなくて済みます。

やり方は以下の2つを考えました。

nginx-upload-moduleは使いやすくて良いんですが、数年ほどメンテナンスされておらずnginx1.10以降では動きません。一応プルリクが出ているのでそれをマージして使えば最新版でも使えます。ただ、自分たちでメンテナンスを続ける覚悟がないと採用しないほうが良いと思います。

上記の理由から標準機能でやりたかったので、まずはclient_body_in_file_onlyで検証しました。


client_body_in_file_only

設定は簡単です。以下の3つがポイントです。



  • client_body_temp_path : ファイルを保存するディレクトリ


  • proxy_set_header : バックエンドにファイルパスを伝える


  • proxy_set_body : リクエストボディを空にする

location /upload {

client_body_temp_path /tmp/;
client_body_in_file_only on;
client_body_buffer_size 128K;
client_max_body_size 1000M;

proxy_pass_request_headerson;
proxy_set_header X-FILE-PATH $request_body_file;
proxy_set_body no;
proxy_pass http://unicorn_server/upload;
}

これによってリクエストボディが空で、ヘッダにファイルパスだけ追記されたリクエストをバックエンドのRailsに飛ばすことができます。

ただ、一つだけ注意点があります。このパターンでは普通にPOSTすると以下のようにmultipartのバウンダリーも含めて全て保存されてしまいます。当然ですが…。

------WebKitFormBoundaryNVDSqY9GijhutrDm

Content-Disposition: form-data; name="product_file[file]"; filename="b6932a25.jpg"
Content-Type: image/jpeg

こうしないためにXMLHttpRequest2でバイナリだけをリクエストする必要があるためフロントの実装にも影響を与えてしまいます。curlでいうとこんな感じのリクエストですね。

curl --data-binary '@file' http://localhost/upload

というわけで、このやりかたは諦めました。


nginx-upload-module

結果的には、メンテをする覚悟をしてnginx-upload-moduleを採用しました。設定ファイルは以下のような感じです。詳しくはRails + unicorn で nginx-upload-moduleを使ってみたを参照してください🙇🏻

location @rails {

proxy_redirect off;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_pass http://unicorn_server;
}

location / {
try_files $uri @rails;
if ($args ~ nginx_upload_module=on ) {
upload_pass @rails;
upload_store /tmp/uploads;
upload_store_access user:rw group:rw all:rw;
upload_set_form_field "$upload_field_name[filename]" "$upload_file_name";
upload_set_form_field "$upload_field_name[type]" "$upload_content_type";
upload_set_form_field "$upload_field_name[tempfile]" "$upload_tmp_path";
upload_aggregate_form_field "$upload_field_name[md5]" "$upload_file_md5";
upload_aggregate_form_field "$upload_field_name[size]" "$upload_file_size";
upload_pass_form_field ".*";
upload_cleanup 200-599;
}
}

これによってリクエストURLにnginx_upload_module=onというクエリストリングがついている場合、ファイルを保存して、ファイル情報をパラメータに含めてくれます。

Rails側では通常のアップロードも考慮すると以下のようなコードになります。

def load_uploaded_file

unless params[:file].is_a?(ActionDispatch::Http::UploadedFile)
file = ActionDispatch::Http::UploadedFile.new(
filename: params[:file][:filename],
type: params[:file][:type],
tempfile: File.open(params[:file][:tempfile]))

params[:file] = file
end
end

通常のファイルアップロードであればActionDispatch::Http::UploadedFileオブジェクトがparamsに格納されていますが、nginx-upload-moduleを経由するとファイルパスやMIME-TYPEなどの情報が入っています。それを元に分岐してFile.openで同じオブジェクトを手動で作ってあげればOKです。


結果

newrelicの結果です。左の大きな山が改善前、右の小さな山が改善後です。左側は紫色のMiddleware(Rack)が大きく占めていて、左側は水色のRuby(Rails)になっているのが分かります。CPU使用率も100%だったのが15%程度に安定していて、多人数がファイルをアップロードしても耐えられるようになりました😂


S3に直接アップロード

今回は採用しませんでしたが、ブラウザから直接S3にアップロードできるJavaScriptSDKがあるので、こちらを使ってみても良いのかもしれません。


さいごに

今回紹介したポイント以外にもファイルアップロードを改善するところはいっぱいあると思います。例えばExcelなどのオフィスファイルの解析であったり、画像のサムネイル処理をする部分であったり。一度作って完成!なんてことはないので、周辺技術の進化やサービスの状況などに応じて日々最適化を模索していきたいと思っています。最後までお読みいただきありがとうございました😀