Kaigi on Rails 2023の登壇資料です!
1HTTPを手で書いて学ぶファイルアップロードの仕組みikuma-t
View Slide
2ikuma-tIkumaTadokoroikumatdkrikuma-t.comikumatadokoro株式会社エンペイで働く、フロントエンドが好きなエンジニア。最近はよくパンケーキを焼いています。
3「ファイルアップロード」はWebアプリケーションにおいて割とよくある機能かと思います
4わたしとファイルアップロードExcelファイルアップロードがなぜかバグるので、途中の通信を眺めるmultipart/form-dataが使えないツールでBase64エンコードでアップロードフロント側でリッチなアップロード作るオブジェクトストレージにアップロードするいずれも特段珍しくない体験
5どれも割と普通の機能だけど仕事で初めて実装した時は毎回ググりまくっていた
6発表のゴールファイルアップロードの処理を抽象化して理解できるようになること個別の事象で都度ググらなくても(GPTらなくても?)大丈夫になりたいよくわからないけど、とりあえず実装できているこのファイル形式の場合はどうすればいいんだろう?とりあえず書いてある通りにしたけど、なんでBase64エンコードしているんだろう?先輩が作ってくれた共通処理にパラメータ突っ込んでいるけど、どういうリクエストなのかはよくわからないフロントから直接オブジェクトストレージにアップする処理、とりあえずドキュメントコピペしてみたand more...「何がわからないのか」がわかるファイルアップロードの全体地図が理解できているので、最小限の調査で実装できる。新しい仕組みができても既存知識をもとに理解できる。
7注意事項発表中における「HTTP」のバージョンは1.1とします。HTTPとは何か?といった話はしません。高トラフィックにも耐えられる画像アップロードサーバを作ろう!といった尖ったケースの話はしません。タイトルに「手書き」とありますが、毛筆でHTTPを書いてHTTPの気持ちになる、といった発表ではありません。悪しからず。時間配分に失敗したので、早口です!!ごめんなさい!!!
8Chapter 1「ファイル」ってなんだろう?理解のための最初のロードマップ
9「ファイル」アップロードと一口に言うけれど…プレーンテキスト画像ファイルPDFファイルOfficeファイル…などそれぞれのアップロード処理をまったく別物として理解すると、その分検索回数が増えていく(「画像 ファイルアップロード」、「Excel ファイルアップロード」)ファイルも様々な種類がある
10ファイルの種類が違っても処理フローは大体同じ結局みんなバイナリ
11ファイル = バイナリファイルはすべてバイナリで表現されるテキストエディタで読めるものをテキストデータそれ以外をバイナリデータと分類・対比することも多いが、テキストもバイナリの一種である多くの場合、最初の数バイトに「マジックナンバー」と呼ばれる識別子があり、これによってファイル種別を識別する
12ファイルの種類が違っても処理フローは大体同じファイル種別ごとに送信されたデータを解釈するためのルールは異なるが、抽象レベルでは同じような処理になるサーバブラウザユーザーサーバブラウザユーザーファイルを選択(`input type="file"`)任意の処理(バリデーションなど)を行う送信ボタンやドラッグ&ドロップをトリガーに送信処理が実行されるHTTPリクエストによってファイルを送信HTTPリクエストでファイルを受け取る任意の処理(データの抽出・加工・保存など)を行う
13理解のための最初のロードマップ各アプリケーションによらないHTTPリクエストを理解し、そこにつながるブラウザ・サーバ側の処理を明らかにすることで、ファイルアップロード全体の理解を深めたい。
14Chapter 2POSTを使ったアップロードファイルアップロードの基本形
15POSTを使ったファイルアップロードの形式アップロードによって新規にリソースを作成するのでPOST(HTTPメソッドの一般的な使い方)
16POSTを使ったファイルアップロードの形式メソッドがPOSTと決まっても、コンテンツをどうやって伝搬するかの方法はいくつかある
17POSTを使ったファイルアップロードの形式HTMLのform要素で扱える2つの形式、application/x-www-form-urlencoded とmultipart/form-data から見ていきます。
18form要素で使えるアップロード1. application/x-www-form-urlencodedバイナリをテキストベースのフォーマットに含める要素を使って実行できる2つのアップロード2. multipart/form-dataバイナリをそのままリクエストに含める
19application/x-www-form-urlencodedデータを送信する際に使用されるMIMEタイプ(データの形式)の1種&と =という2つのテキストを意味のある文字(制御文字)として扱い、コンテンツを表現する以降、便宜上「テキストで構文をつくるMIMEタイプ」を「テキストベースのMIMEタイプ」と呼びます。6 firstname=hoge&lastname=fuga1 POST /upload HTTP/1.12 Host: localhost3 Content-Type: application/x-www-form-urlencoded4 Content-Length: 285
20application/x-www-form-urlencoded(parse)仕様がいろんなところでされているのでややこしいですが、一例としてURL Standardに定義があります。
21&や=がコンテンツに入っていたらどうなる…?たとえば以下のようなリクエストを送ったらどうなるだろう?送りたいのは「ruby&friends」6 name=error.txt&file=ruby&friends1 POST /upload HTTP/1.12 Host: localhost3 Content-Length: 324 Content-Type: application/x-www-form-urlencoded5
22実際にやってみよう!(HTTPサーバ)※WEBRickでのデモですが、仕様に則っていれば他のサーバでも同じ挙動になるはずです。9 File.write(File.join(PATH, name), file)10 res.body = 'File received'1 require 'webrick'2 require 'base64'34 server = WEBrick::HTTPServer.new({ Port: 2000 })56 server.mount_proc '/upload' do |req, res|7 # { name:リクエストのnameの値, file:リクエストのfileの値 }8 req.query.transform_keys(&:to_sym) in name:, file:11 end1213 server.startデモあり
23実際にやってみよう!(HTTPリクエスト)ncコマンドでリクエストを送ります2 POST /upload HTTP/1.13 Host: localhost4 Content-Length: 325 Content-Type: application/x-www-form-urlencoded67 name=error.txt&file=ruby&friends1 printf "$(cat <8 !9 )" | nc localhost 2000デモあり
24実際にやってみよう(結果)1. HTTP本文がそのまま届く(共通)`socket.read()`で中身を覗き見ると、自分が送ったHTTPリクエストが文字列として入っていた。2. Content-Typeに則ってパース(共通)0x26(&)で区切ってnameとvalueのペアにしていた。application/x-www-form-urlencodedの仕様に沿った実装3. コンテンツが誤って解釈される本来はruby&friendsが正しいが、&がそのまま入っていることでそこで区切りが入ってしまっている。これはかなり単純化した例だけれど、フォーマットで使われている文字とコンテンツが重複しないための工夫が必要であることがわかる
25コンテンツが正しく届けられるための仕組み:エンコードパーセントエンコード& %26%のあとに2桁の16進数が続く形式。非英数文字(&やスペース)を安全に送付するために利用。formからsubmitする場合はこのエンコードが適用される。Base64エンコードあ 44GCA~Z, a~z, 0~9, +, /の64種類の文字で構成された文字列に変換する。3バイトのバイナリが4バイトに変換される。歴史的に使われてきた背景や変換後のデータ量から、バイナリを埋め込む際にはBase64エンコードがよく使われる。
26Base64エンコードを行なってアップロード1. HTTPリクエスト: Base64エンコードしたコンテンツを埋め込む2. HTTPサーバ:コンテンツをBase64デコードしてから任意処理を行う変更部分のみ抜粋1 Content-Length: 36 #ここも変更2 Content-Type: application/x-www-form-urlencoded34 name=error.txt&file=cnVieSZmcmllbmRz # Base64でエンコードしたもの1 #アプリケーションロジック(ファイルを保存する)の前にデコード2 decoded_file = Base64.decode64(file)3 File.write(File.join(PATH, name), decoded_file)デモあり
27他のテキストベースのフォーマットでも同様に変換が必要例: application/jsonJSONもJSON形式を表すためにいくつかの特殊文字を使用するので、同様に変換(クライアント側でBase64などでエンコードし、サーバ側でデコード)する必要がある。他のテキストベースのフォーマットについても同様。1 {2 "name": "error.txt",3 "file": "cnVieSZmcmllbmRz"4 }
28ここまでの整理テキストベースのフォーマットを使ったアップロードでは、制御文字との都合でBase64なりでテキストに変換する必要がある。変換できない場合は安全にアップロードできない。
29form要素からのアップロード2. multipart/form-dataバイナリをそのままリクエストに含める要素を使って実行できる2つのアップロード1. application/x-www-form-urlencodedバイナリをテキストベースのフォーマットに含める
30multipart/form-data異なる種類のデータを1つのHTTPリクエスト内で複数の部分に分割して送信することができる。境界が明示的に分離されているので、バイナリをそのまま埋め込むことができる。1 POST /upload HTTP/1.12 Host: localhost3 Content-Type: multipart/form-data; boundary=--KaigiOnRailsBoundary2023xyz4 Content-Length: xxx56 --KaigiOnRailsBoundary2023xyz #パート17 Content-Disposition: form-data; name="name"8 Content-Type: text/plain910 David11 --KaigiOnRailsBoundary2023xyz #パート212 Content-Disposition: form-data; name="avatar" filename="avatar.png"13 Content-Type: image/png1415 \x89\x50\...16 --KaigiOnRailsBoundary2023xyz--
31multipartのpartMIMEタイプ「multipart」の各part1 require 'js'2 require 'securerandom'34 def characters5 [*'0'..'9', *'A'..'Z', *'a'..'z', "'", '-', '_']6 end78 def boundary9 Array.new(rand(0..70)) do10 characters.sample(random: SecureRandom)11 end.join12 end1314 # payloadの表示・処理方法や、表示や処理に関わるファイル名等の付15 def content_disposition(name, filename = nil)16 filename = filename ? "; filename=#{filename}" : '17 "Content-Disposition: form-data; name=#{name}#{fil18 end1920 def part(name, filename = nil, type = 'text/plain')21 part_boundary = boundary22 <<~PART23 --#{part_boundary}24 #{content_disposition(name, filename)}25 Content-Type: #{type}2627 ※コンテンツ(バイナリの場合はそのまま)がここに入ります実行結果がここに表示されます。Key名値を入力してくださいファイル名値を入力してください種類値を入力してください実行する
32multipart/form-dataのリクエストを生成する基本的にはapplication/x-www-form-urlencodedと変わらない。単純にファイルがバイナリそのまま埋め込めるようになったので、デコード等が不要になる。HTTPサーバはboundaryを認識して値を取得する。デモあり
33その他のバイナリをそのまま埋め込める形式application/octet-stream任意の(もしくは不明な)バイナリを示すMIMEタイプ。こちらはdiscrete(個別)タイプなので単一のバイナリファイルを直接コンテンツに放り込む。(MIMEタイプはタイプ/サブタイプの形式を取り、タイプについて単一ファイルを取るdiscreteタイプか、複数を表すmultipartに分かれる)FormDataJavaScript側では`FormData`を使うことで、`multipart/form-data`と同じ形式で値の送信ができる。1 const formData = new FormData()2 formData.append("description", "いい写真です")3 formData.append("avatar", blob, "avatar.png")
34ここまでのまとめ一部のMIMEタイプではバイナリをそのまま埋め込める。form要素からはmultipart/form-data、JavaScriptからはFormData経由でmultipart/form-dataを使ったり、application/octet-streamで直接埋め込むことができる。
35Chapter 3PUTを使ったアップロードオブジェクトストレージへのアップロード / 再開可能なアップロード
36PUTを使ったファイルアップロードの形式アップロードによって既存リソースを更新するのでPUT(HTTPメソッドの一般的な使い方)
37オブジェクトストレージへのアップロードストレージサービスクライアントアプリユーザーストレージサービスクライアントアプリユーザーファイルアップロードを要求署名付きURLやトークンを要求(ここはPOST)署名付きURLやトークンを発行ファイルアップロードUIを提供ファイルを選択する署名付きURLやトークンを用いてファイルをアップロードする(ここはPUT)ファイルアップロード完了ユーザーにファイルアップロード完了をフィードバックオブジェクトを更新することからPUTを使っているだけであって、適切なMIMEタイプを指定して、バイナリを直接コンテンツに含める部分はこれまでと特に変わらない。
38オブジェクトストレージならではの仕様いくつかの要件に対応するため、オブジェクトストレージへのアップロード時には追加でいくつかのヘッダフィールドを指定することができる。例えば大規模ファイルをいくつかの単位に分割してアップロードする際、アップロード結果が正しいことをチェックするために、Content-MD5ヘッダが使われることがある。その他にも各ストレージサービスが用意した独自ヘッダがいくつかある。そちらについては各サービスのドキュメント参照。
39Content-Rangeによる再開可能なアップロード一部のストレージサービスでは、ファイルアップロードがネットワーク回線不調等によって失敗したい場合に備え、再開可能なアップロードをできるようにしている。このユースケースにあわせ、HTTPの意味内容を決めるRFC「HTTP Semantics」ではContent-Rangeを含めたPUTリクエストについて、オプション的な位置付けではあるが使用を認めている。refs: HTTP Semantics - 14.4 Content-Range: (https://www.ietf.org/archive/id/draft-ietf-httpbis-semantics-16.html#section-14.4)ヘッダフィールドの一例として再開可能なアップロードをやってみる
40Content-Rangeによる再開可能なアップロード「再開可能」にするための仕組みは「今どこまでアップロードできているか」を知り、それを踏まえて「アップロードできていない部分からアップロードする」こと。これを実現するために Content-Rangeヘッダが使われることがある。クライアントのアップロードが停止した際は、サーバ側に「今どこまで完了しているのか」をHTTPで確認し、その情報を元にContent-Rangeヘッダを付与してアップロードを途中から再開する。1 PUT /resumable-upload?xxx HTTP/1.12 Host: localhost3 Content-Length: 1000004 Content-Range: bytes 10000-20000/20000デモあり
41超簡易な再開可能なアップロードをやってみる擬似的にアップロードを止めて、途中から再開する1 printf "$(cat <2 PUT /resumable-upload HTTP/1.13 Host: localhost4 Content-Length: 27056 $(tail box/sample.txt)7 !8 )" | nc localhost 7111 nc localhost 711 <2 GET /resumable-upload HTTP/1.13 Host: localhost45 !1 printf "$(cat <2 PUT /resumable-upload HTTP/1.13 Host: localhost4 Content-Length: 1805 Content-Range: bytes 90-270/27067 $(tail -c +91 box/sample.txt)8 !9 )" | nc localhost 7111 printf "$(cat <2 PUT /resumable-upload HTTP/1.13 Host: localhost4 Content-Length: 905 Content-Range: bytes 180-270/27067 $(tail -c +181 box/sample.txt)8 !9 )" | nc localhost 711
42Conclusionまとめ
43発表のゴールファイルアップロードの処理を抽象化して理解できるようになること個別の事象で都度ググらなくても(GPTらなくても?)大丈夫になりたいよくわからないけど、とりあえず実装できているこのファイル形式の場合はどうすればいいんだろう?とりあえず書いてある通りにしたけど、なんでBase64エンコードしているんだろう?先輩が作ってくれた共通処理にパラメータ突っ込んでいるけど、どういうリクエストなのかはよくわからないフロントから直接オブジェクトストレージにアップする処理、とりあえずドキュメントコピペしてみたand more...「何がわからないのか」がわかるファイルアップロードの全体地図が理解できているので、最小限の調査で実装できる。新しい仕組みができても既存知識をもとに理解できる。
44ファイルアップロードの全体地図
45ファイルアップロードの全体地図
46ファイルアップロードの全体地図
47ファイルアップロードの全体地図
48ファイルアップロードの全体地図
49例示は理解の試金石今回はRubyとシェルコマンドで動くサンプルを作り、理解の曖昧なファイルアップロードについて理解を掘り進めてきました。自分の理解が届く範囲で、動く最小のサンプルを作って理解を深めてみてはいかがでしょうか——これは、僕たちが大事にしているスローガンだ。抽象的なことや複雑なことを「理解した」かどうかを試すには、「例を作る」のがいいという意味になる。理解しているかどうか不安になったら、「例」を作ろう。 – 数学ガール
50ご清聴いただきありがとうございました。