[Ruby] Sinatraで複数のファイルをアップロードする
まとめ
HTMLのファイルアップロード部分を、以下のように指定します:
-
formタグ:-
enctype属性を"multipart/form-data"にする- form中に
type="file"のinputタグを含むため[1]
- form中に
-
method属性を"post"にする
-
-
inputタグ:-
type属性を"file"にする -
name属性を、"files[]"のように[]つきの名前にする-
RackプロジェクトのRspec: rack/test/spec_utils -
Rack::Utils.parse_nested_queryの部分[2]で色々なパターンを確認できる
-
-
multiple属性を指定する
-
サンプルコード
# frozen_string_literal: true
source "https://rubygems.org"
gem "sinatra"
gem "puma"
require "sinatra"
get "/" do
<<~HTML
<p>適当なファイルを2つ以上選択してアップロードしてください:</p>
<form method="post" action="/" enctype="multipart/form-data">
<input type="file" name="uploaded_files[]" multiple>
<button>ファイル送信</button>
</form>
HTML
end
post "/" do
<<~HTML
<p><tt>params["uploaded_files"]</tt>に、複数ファイル分のデータが格納されている:</p>
<p><tt>#{params["uploaded_files"].join("<br>")}</tt></p>
HTML
end
上記Gemfile, sample.rbの2つを置いて、以下の要領で実行します。
$ ls
Gemfile sample.rb
$ bundle install
# 出力省略
$ bundle exec ruby sample.rb
# => http://localhost:4567 にブラウザでアクセス
ブラウザでhttp://localhost:4567にアクセスし、PCローカルの適当な複数ファイルを選択してSubmitすると、paramsにその複数ファイルのデータが格納されていることを確認できます。
ファイルのアップロード(ひとつだけ)
まず、ファイルを一つだけアップロードするパターンをおさらいしておきます。
Sinatraで実装するWebアプリケーションの場合、HTML側で以下のようにします:
-
formタグで、method="post",enctype="multipart/form-data"を指定[1:1] -
inputタグで、type="file"を指定。また、Ruby側のparamHashのキーとなる文字列を、name属性に指定
こうすると、params["<nameで指定したキー>"]で、ファイル情報が格納されたHash[3]を得ることができます。
...と文字だけで書いても分かりづらいので、実際にコードを動かして確認しましょう。
# frozen_string_literal: true
source "https://rubygems.org"
gem "sinatra"
gem "puma"
require "sinatra"
require "pp"
get "/" do
<<~HTML
<form method="post" action="/" enctype="multipart/form-data">
<input type="file" name="uploaded_file">
<button>Submit</button>
</form>
HTML
end
post "/" do
<<~HTML
<p>params:<br> <tt>#{pp(params)}</tt></p>
<hr>
<p>params["uploaded_file"]:<br> <tt>#{pp(params["uploaded_file"])}</tt></p>
<p>params["uploaded_file"].class: #{params["uploaded_file"].class}</p>
HTML
end
def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ")
end
上記2ファイルを適当なディレクトリに置いて、以下のようにして実行します(Rubyファイル名は、数字付きのapp_1.rbとしています):
$ ls
Gemfile app_1.rb
$ bundle install
# 出力は省略
$ bundle exec ruby app_1.rb
# => http://localhost:4567 にブラウザでアクセス
ブラウザでhttp://localhost:4567にアクセスし、適当なファイルを選択してSubmitボタンを押すと、paramsの中身が以下のようになっていることが確認できます:
{"uploaded_file"=>
{"filename"=>"sample.txt",
"type"=>"text/plain",
"name"=>"uploaded_file",
"tempfile"=>#,
"head"=>
"Content-Disposition: form-data; name=\"uploaded_file\"; filename=\"sample.txt\"\r\n" +
"Content-Type: text/plain\r\n"}}
inputタグのname属性に指定した"uploaded_file"をキーとして、ひとつ分のファイル情報がHashとして値に入っています。
(本筋とは外れますが、具体的にこのアップロードされたファイルをどう操作するのかは、(補足)アップロードされたファイルの取り扱いに書いています。)
ファイルのアップロード(複数ファイル)
では、複数ファイルのアップロードをやってみましょう。
inputタグにmultiple属性を加えるだけ -> ダメ
まず試すのは、単純な「inputタグにmultiple属性を指定する」という方法です...が、これはうまくいきません。
先程のapp_1.rbを以下のように変更した、app_2.rbを作成します:
-
inputタグにmultiple属性を追加 -
ついでに、
inputタグのname属性の値・paramsでアクセスするキーの値を、複数形の"uploaded_files"に変更
require "sinatra"
require "pp"
get "/" do # ここのinputタグ部分変更!
<<~HTML
<form method="post" action="/" enctype="multipart/form-data">
<input type="file" name="uploaded_files" multiple>
<button>Submit</button>
</form>
HTML
end
post "/" do # キーを'uploaded_file' -> 'uploaded_files'に変更!
<<~HTML
<p>params:<br> <tt>#{pp(params)}</tt></p>
<hr>
<p>params["uploaded_files"]:<br> <tt>#{pp(params["uploaded_files"])}</tt></p>
<p>params["uploaded_files"].class: #{params["uploaded_files"].class}</p>
HTML
end
def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ")
end
app_2.rbを指定して実行し、
$ bundle exec ruby app_2.rb # ファイル名はapp_2.rb!
ブラウザでhttp://localhost:4567にアクセスします。
適当なファイルをふたつ以上選択し、Submitボタンを押すと、paramsの中身は以下のようになっています:
{"uploaded_files"=>
{"filename"=>"sample.txt",
"type"=>"text/plain",
"name"=>"uploaded_file",
"tempfile"=>#,
"head"=>
"Content-Disposition: form-data; name=\"uploaded_file\"; filename=\"sample.txt\"\r\n" +
"Content-Type: text/plain\r\n"}}
あれれ、複数選択して送信したはずなのに、"uploaded_files"キーに対応する値は、Hashひとつのままになっています。
inputタグのname属性の文字列の最後に、[]を加える -> OK
ここで問題になっているのは、HTTP POSTで複数ファイルの情報がサーバに送られてきているものの、最終的にSinatraが渡してくれるRubyオブジェクトのparamの中には、ファイルひとつ分のオブジェクトしか渡してもらえない...ということです。

これを解決する...つまり今回の場合だと「inputタグのname属性に指定したHashのキーに対応する値に、ファイルデータのArrayを入れた状態のparamをSinatraに作ってもらう」ためには、
- Sinatraのお作法に従って、
inputタグのname属性の値末尾に[]をつける
ということをする必要があります。
実際にやってみましょう。app_2.rbのinputタグのname属性を"uploaded_files[]"に変更し、これをapp_3.rbとします:
require "sinatra"
require "pp"
get "/" do # ここのinputタグのname属性のみ変更!
<<~HTML
<form method="post" action="/" enctype="multipart/form-data">
<input type="file" name="uploaded_files[]" multiple>
<button>Submit</button>
</form>
HTML
end
post "/" do
<<~HTML
<p>params:<br> <tt>#{pp(params)}</tt></p>
<hr>
<p>params["uploaded_files"]:<br> <tt>#{pp(params["uploaded_files"])}</tt></p>
<p>params["uploaded_files"].class: #{params["uploaded_files"].class}</p>
HTML
end
def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ")
end
app_3.rbを指定して実行し、
$ bundle exec ruby app_3.rb # ファイル名はapp_3.rb!
ブラウザでhttp://localhost:4567にアクセスします。
適当なファイルをふたつ以上選択し、Submitボタンを押します。今回は、paramsの中身は以下のようになっています:
{"uploaded_files"=>
[{"filename"=>"20210223.txt",
"type"=>"text/plain",
"name"=>"uploaded_files[]",
"tempfile"=>#,
"head"=>
"Content-Disposition: form-data; name=\"uploaded_files[]\"; filename=\"20210223.txt\"\r\n" +
"Content-Type: text/plain\r\n"},
{"filename"=>"20210830-rust-notes.txt",
"type"=>"text/plain",
"name"=>"uploaded_files[]",
"tempfile"=>#,
"head"=>
"Content-Disposition: form-data; name=\"uploaded_files[]\"; filename=\"20210830-rust-notes.txt\"\r\n" +
"Content-Type: text/plain\r\n"}]}
キーが[]なしの"uploaded_files"で、その値がファイル情報のArrayになっています。成功です!
name属性のお作法についてもう少し
HTTP GETでformデータを送信する場合も同じ
ファイルアップロードを扱ったので「formデータをHTTP POSTで送信する場合」の例を示していましたが、name属性のお作法は、テキストフォームなどで「formデータをHTTP GETでURL文字列として送信する場合」にも有効です。
(つまりは、formタグのmethod属性を"get"にした場合。)
例えば、URL文字列のformデータ部分(<url>?以降)が
foo[]=bar&foo[]=baz
だったとすると、これに対応するparamsの中身は
{"foo" => ["bar", "baz"]}
となります。
具体的には、以下のサンプルコードを実行してみてください。
実行用サンプルコード
(formタグのenctype="multipart/form-data"の指定を外しています[1:2]。)
require "sinatra"
require "pp"
get "/" do
<<~HTML
<form method="get" action="/result">
<div>
<input type="checkbox" name="foo[]" value="bar" id="check-bar">
<label for="check-bar">BAR</label>
</div>
<div>
<input type="checkbox" name="foo[]" value="bar" id="check-baz">
<label for="check-baz">BAZ</label>
</div>
<button>Submit</button>
</form>
HTML
end
get "/result" do
<<~HTML
<p>URLバーの文字列もチェック!</p>
<hr>
<p>params: <tt>#{pp(params)}</tt></p>
<hr>
<p>params["foo"]: <tt>#{pp(params["foo"])}</tt></p>
<p>params["foo"].class: #{params["foo"].class}</p>
HTML
end
def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ")
end
実行(ファイル名はform_get.rb):
$ bundle exec ruby form_get.rb
その他の使用例
file以外にも、checkboxなど、特定の同じキーについてArrayでひとまとめにして返してほしい場面があります。その際にも、同様のお作法を使います。
以下に、幾つか例を挙げます:
-
checkbox
<input type="checkbox" name="versions[]" value="scarlet"> <input type="checkbox" name="versions[]" value="violet">実行用サンプルコード
checkbox.rbrequire "sinatra" require "pp" get "/" do <<~HTML <form method="post" action="/"> <div> <input type="checkbox" name="versions[]" value="scarlet" id="scarlet-ver"> <label for="scarlet-ver">Scarlet</label> </div> <div> <input type="checkbox" name="versions[]" value="violet" id="violet-ver"> <label for="violet-ver">Violet</label> </div> <button>Submit</button> </form> HTML end post "/" do <<~HTML <p>params: <tt>#{pp(params)}</tt></p> <hr> <p>params["versions"]: <tt>#{pp(params["versions"])}</tt></p> <p>params["versions"].class: #{params["versions"].class}</p> HTML end def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ") end実行(ファイル名はcheckbox.rb):
$ bundle exec ruby checkbox.rb -
text box
<input type="text" name="favorites[]"> <input type="text" name="favorites[]">実行用サンプルコード
text_box.rbrequire "sinatra" require "pp" get "/" do <<~HTML <form method="post" action="/"> <div> <label>Favorite 1</label> <input type="text" name="favorites[]" id="fav-1"> </div> <div> <label>Favorite 2</label> <input type="text" name="favorites[]" id="fav-2"> </div> <button>Submit</button> </form> HTML end post "/" do <<~HTML <p>params: <tt>#{pp(params)}</tt></p> <hr> <p>params["favorites"]: <tt>#{pp(params["favorites"])}</tt></p> <p>params["favorites"].class: #{params["favorites"].class}</p> HTML end def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ") end実行(ファイル名はtext_box.rb):
$ bundle exec ruby text_box.rb -
Mix
name属性の値を揃えれば、以下のcheckbox + text boxのように、複数種類のinputの値をまとめることもできます。<input type="checkbox" name="languages[]" value="ruby"> <input type="checkbox" name="languages[]" value="python"> <input type="text" name="languages[]">実行用サンプルコード
mix.rbrequire "sinatra" require "pp" get "/" do <<~HTML <form method="post" action="/"> <div> <input type="checkbox" name="languages[]" value="ruby" id="lang-ruby"> <label for="lang-ruby">Ruby</label> </div> <div> <input type="checkbox" name="languages[]" value="python" id="lang-python"> <label for="lang-python">Python</label> </div> <div> <label for="lang-input"> <input type="text" name="languages[]" id="lang-input"> </div> <button>Submit</button> </form> HTML end post "/" do <<~HTML <p>params: <tt>#{pp(params)}</tt></p> <hr> <p>params["languages"]: <tt>#{pp(params["languages"])}</tt></p> <p>params["languages"].class: #{params["languages"].class}</p> HTML end def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ") end実行(ファイル名はmix.rb):
$ bundle exec ruby mix.rb
もっと複雑なパターン
実は、name属性のお作法には、もっとたくさんのパターンがあります。
例えば、
<input type="checkbox" name="foo[a][]" value="bar">
<input type="checkbox" name="foo[a][]" value="baz">
<input type="checkbox" name="foo[b][]" value="bar">
<input type="checkbox" name="foo[c]" value="bar">
のようにした場合、HTTP GETでformデータを送信すると、URL中のデータ文字列部分は以下のようになります:
foo[a][]=bar&foo[a][]=baz&foo[b][]=bar&foo[c]=bar
これがparamsではどう解釈されているのかというと、次のようになります:
{"foo"=>{"a"=>["bar", "baz"], "b"=>["bar"], "c"=>"bar"}}
実行用サンプルコード
(formタグのenctype="multipart/form-data"の指定を外しています[1:4]。)
require "sinatra"
require "pp"
get "/" do
<<~HTML
<p>HTTP GETでformデータを送信します</p>
<form method="get" action="/result">
<div>
<input type="checkbox" name="foo[a][]" value="bar" id="a-bar">
<label for="a-bar">A bar</label>
</div>
<div>
<input type="checkbox" name="foo[a][]" value="baz" id="a-baz">
<label for="a-baz">A baz</label>
</div>
<div>
<input type="checkbox" name="foo[b][]" value="bar" id="b-bar">
<label for="b-bar">B bar</label>
</div>
<div>
<input type="checkbox" name="foo[c]" value="bar" id="c-bar">
<label for="c-bar">C bar (not Array)</label>
</div>
<button>Submit</button>
</form>
HTML
end
get "/result" do
<<~HTML
<p>URLバーの文字列もチェック!</p>
<hr>
<p>params: <tt>#{pp(params)}</tt></p>
HTML
end
def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ")
end
実行(ファイル名はdeepen.rb):
$ bundle exec ruby deepen.rb
もっと色々ありますので、RackプロジェクトのRspec: rack/test/spec_utilsの、Rack::Utils.parse_nested_queryの部分[2:1]を覗いてみてください。
(補足)アップロードされたファイルの取り扱い
アップロードされたファイルの中身は、paramの中にTempfileオブジェクトとして格納されています。
params[<name属性のキー>]["tempfile"]で取り出すことができます(ファイル一つのみの単純な場合)。
ですが、ファイル選択なしHTTP POSTを許容する場合など、該当のキー項目がないケースを考えて、Hash#digを使うほうが便利かもしれません。
tempfile = params["uploaded_file"]["tempfile"] # => Tempfile
# Hash#digの方が実用性は高いかも
tempfile = params.dig("uploaded_file", "tempfile") #=> Tempfile
基本的には、Fileオブジェクトのインスタンスメソッドが同じように使えます:
- Tempfile オブジェクトはFileクラスへのDelegatorとして定義されており、Fileクラスのオブジェクトと同じように使うことができます。(Ruby リファレンスマニュアルのTempfileのページ)
Tempfile#close!で、すぐに削除する
ページ遷移すると、paramが参照を手放すため、GCがどこかのタイミングでTempfileのファイル実体を削除してくれます(自分で新たに参照を持ち続けなければ)。
しかし、ここでのTempfileの用途的に、ファイルの中身データを拝借したあとは、Tempfileの実体自体はすぐに消したほうが良いように思います。実際、ページ遷移後ファイル実体がすぐ消されるわけではありません。実行用サンプルコードで実験してみてください。
そこで、Tempfileのデータにアクセスしたあとは、
tempfile.close!
で、明示的にTempfileを削除するのが良いと思います。
実行用サンプルコード
Tempfileがすぐに消えないことの実験方法:
-
tempfile.close!の行をコメントアウトして、プログラムを実行する -
ファイルをアップロードし、ブラウザに表示されるtempfileのpathを控えておく
-
「戻る」ボタンでページ遷移する
-
別のターミナルウィンドウで同じサーバにログインし、2で控えたファイルが存在するかどうかを
lsコマンドなどで確認する
(filename文字列のエンコーディング情報をUTF-8に変更する部分(String#force_encoding)で、いわゆるぼっち演算子&.[5]を使っています。)
require "sinatra"
require "pp"
get "/" do
<<~HTML
<p>.txtファイルをアップロードしてください。</p>
<form method="post" action="/" enctype="multipart/form-data">
<input type="file" name="uploaded_file" accept=".txt">
<button>アップロード</button>
</form>
HTML
end
post "/" do
filename = params.dig("uploaded_file", "filename")&.force_encoding(Encoding::UTF_8)
tempfile = params.dig("uploaded_file", "tempfile")
path = "(no file uploaded.)"
contents = ""
if tempfile
path = tempfile.path
tempfile.each_with_index do |line, index|
contents += "[#{index + 1}] #{line}"
end
tempfile.close!
end
contents.force_encoding(Encoding::UTF_8)
<<~HTML
<p>params: <tt>#{pp(params)}</tt></p>
<hr>
<p>filename: <tt>#{filename}</tt></p>
<p>tempfile.class: <tt>#{tempfile.class}</tt></p>
<p>tempfile.path: <tt>#{path}</tt></p>
<p>
ファイルの中身(UTF-8で書かれていることを想定しています):
<tt>#{contents.gsub("\n", "<br>").gsub(" ", " ")}</tt>
</p>
<a href="/">戻る</a>
HTML
end
def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", " ")
end
実行(ファイル名はhandle_uploaded_file.rb):
$ bundle exec ruby handle_uploaded_file.rb
HTML inputタグname属性のお作法は、Rack由来
今回の、params変数に入ってくるname属性のお作法は、実はSinatraではなくRackの方の、もう少し正確に言うとRack::QueryParser#parse_nested_queryの仕様から来ています。これを確認してみましょう。
確認の仕方
私達がrequire "sinatra"して(あるいはModular Appとしてrequire "sinatra/base"して)、getやpostメソッドブロックの中でparamsと書く時、呼び出されるのはSinatra::Request#params[6]です。
以下を確認すれば、「HTML inputタグname属性のお作法は、Rack由来」ということが確認できるでしょう:
- メソッド
Sinatra::Request#paramsを呼び出すと、その過程でRack::QueryParser#parse_nested_queryが呼び出されていること - この
Rack::QueryParser#parse_nested_queryが「name属性のお作法」を決めていること
いきなりソースコードを見ながら、このメソッドはここで...と追いかけてもいいのですが、ハードルが高いので、以下の手順で確認していくことにします:
- 天下り的に「
Sinatra::Request#paramsはRack::QueryParser#parse_nested_queryを呼ぶらしい」ことを知っているとして、実際本当にそういう動作になっているのかどうか、をデバッガでまず確かめる -
Rack::QueryParser#parse_nested_queryの中身を見て、そこで今回の「name属性のお作法」が決まっていることを確認する
デバッガとしてはdebug.gemを使って、Sinatra::Request#paramsの動きを追跡するところから始めましょう。
debug.gemを活用して確認してみよう
まず、新しいディレクトリを作成して、その中に以下の2ファイルを作成してください:
# frozen_string_literal: true
source "https://rubygems.org"
gem "sinatra"
gem "puma"
gem "debug"
require "sinatra"
require "debug"
debugger
get "/" do
end
そして、Ruby 3.1以降で実行してください(ファイル名はtrace_params.rb)。
$ bundle exec ruby trace_params.rb
そうすると、trace_params.rbの4行目、debuggerの行に到達した時点でデバッグコンソールが開きます。
$ bundle exec ruby trace_params.rb
[1, 7] in params_route.rb
1| require "sinatra"
2| require "debug"
3|
=> 4| debugger
5|
6| get "/" do
7| end
=>#0 <main> at params_route.rb:4
(rdbg)
作戦としては、このあとWebブラウザで"/"のページを開いた時...つまり、
get "/" do
end
のブロックが呼ばれた時にparamsの値セットが行われるはずなので、その際にRack::QueryParser#parse_nested_queryを通過するかどうかを引っ掛けよう、という方針で行きます。
ブレイクポイントを設置して、Rack::QueryParser#parse_nested_queryを通過するかどうか確認する
まず、breakコマンド(短縮形はb)で、Rack::QueryParser#parse_nested_queryにブレイクポイントを仕掛けておきます。
(rdbg) b Rack::QueryParser#parse_nested_query # break command
#0 BP - Method Rack::QueryParser#parse_nested_query at /home/shuichi/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/query_parser.rb:68
(rdbg)
次に、continueコマンド(短縮形はc)で、プログラムの実行を再開します。
trace_params.rbのファイル全体の実行が完了し、Webアプリケーションが起動します(つまりdebugなしで普通に実行した時と同じ状態になる)。
(rdbg) c # continue command
== Sinatra (v3.0.6) has taken the stage on 4567 for development with backup from Puma
Puma starting in single mode...
* Puma version: 6.2.2 (ruby 3.2.2-p53) ("Speaking of Now")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 16353
* Listening on http://127.0.0.1:4567
* Listening on http://[::1]:4567
Use Ctrl-C to stop
この状態で、Webブラウザでhttp://localhost:4567を開くと...
[64, 73] in ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/query_parser.rb
64| # types are Arrays, Hashes and basic value types. It is possible to supply
65| # query strings with parameters of conflicting types, in this case a
66| # ParameterTypeError is raised. Users are encouraged to return a 400 in this
67| # case.
68| def parse_nested_query(qs, d = nil)
=> 69| params = make_params
70|
71| unless qs.nil? || qs.empty?
72| (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p|
73| k, v = p.split('=', 2).map! { |s| unescape(s) }
=>#0 Rack::QueryParser#parse_nested_query(qs="", d="&;") at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/query_parser.rb:69
#1 Rack::Request::Helpers#parse_query(qs="", d="&;") at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/request.rb:590
# and 35 frames (use `bt' command for all frames)
Stop by #0 BP - Method Rack::QueryParser#parse_nested_query at /home/shuichi/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/query_parser.rb:68
(rdbg)
Rack::QueryParser#parse_nested_queryを通過したので、仕掛けておいたブレイクポイントで停止しました!
ソースコード中にブレイクポイントを設置する
今回は、初めからブレイクポイントを仕掛けたい場所(Rack::QueryParser#parse_nested_query)がわかっているので、ソースコード中に指定してしまうのもよいでしょう。
具体的には、debuggerの後ろに、以下のいずれかでデバッグのコマンドを指定します[10]:
-
do: "command":デバッグのコマンドcommandを実行して、そのまま通り過ぎる -
pre: "command":デバッグのコマンドcommandを実行して、その地点でプログラム実行を止める
今回であれば、"command"の部分は"b Rack::QueryParser#parse_nested_query"ですね。
ブレイクポイントを仕掛けたらそのまま実行してしまいたいので、do:の方を使うことにします:
require "sinatra"
require "debug"
debugger do: "b Rack::QueryParser#parse_nested_query"
get "/" do
end
このtrace_params.rbを実行します:
$ bundle exec ruby trace_params.rb
すると、ブレイクポイントをRack::QueryParser#parse_nested_queryに仕掛けた状態で、プログラムが完全に実行されます:
$ bundle exec ruby trace_params.rb
[1, 7] in trace_params.rb
1| require "sinatra"
2| require "debug"
3|
=> 4| debugger do: "b Rack::QueryParser#parse_nested_query"
5|
6| get "/" do
7| end
=>#0 <main> at trace_params.rb:4
(rdbg:#debugger) b Rack::QueryParser#parse_nested_query
#0 BP - Method Rack::QueryParser#parse_nested_query at /home/shuichi/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/query_parser.rb:68
== Sinatra (v3.0.6) has taken the stage on 4567 for development with backup from Puma
Puma starting in single mode...
* Puma version: 6.2.2 (ruby 3.2.2-p53) ("Speaking of Now")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 16325
* Listening on http://127.0.0.1:4567
* Listening on http://[::1]:4567
Use Ctrl-C to stop
なお、debuggerはエイリアスです。エイリアス元であるbinding.breakや、他のエイリアスであるbinding.bも使えます[11]。
バックトレースを確認する
さて、実際にこれがSinatraのparamsによるものなのか、またどういうメソッド探索でここまでたどり着いたのかを見たいので、backtraceコマンド(省略形はbt)で、バックトレースを表示してみます。
(rdbg) bt # backtrace command
=>#0 Rack::QueryParser#parse_nested_query(qs="", d="&;") at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/query_parser.rb:69
#1 Rack::Request::Helpers#parse_query(qs="", d="&;") at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/request.rb:590
#2 Rack::Request::Helpers#GET at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/request.rb:430
#3 Rack::Request::Helpers#params at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/request.rb:469
#4 Rack::Request#params at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rack-2.2.7/lib/rack/request.rb:32
#5 Sinatra::Request#params at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sinatra-3.0.6/lib/sinatra/base.rb:79
# (中略)
#34 Puma::Server#process_client(client=#<Puma::Client:0x89f8 @ready=true>) at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/puma-6.2.2/lib/puma/server.rb:431
#35 block {|client=#<Puma::Client:0x89f8 @ready=true>|} in run at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/puma-6.2.2/lib/puma/server.rb:233
#36 block {|spawned=1|} in spawn_thread at ~/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/puma-6.2.2/lib/puma/thread_pool.rb:147
上の出力例ではカットしていますが、Rackアプリケーションらしく、Pumaから幾つかのRack Middlewareを通ってSinatra::Base#callまでやって来ていることがわかります。
そして私達がSinatraを使う際のparamsですが、スタックトレース行#5のSinatra::Request#paramsがそれですね。
ここから上に遡ると、Sinatra::Request#paramsが呼び出すメソッドを確認できます。
#0 Rack::QueryParser#parse_nested_query(qs="", d="&;")
#1 Rack::Request::Helpers#parse_query(qs="", d="&;")
#2 Rack::Request::Helpers#GET
#3 Rack::Request::Helpers#params
#4 Rack::Request#params
#5 Sinatra::Request#params
確かにRack::QueryParser#parse_nested_queryが呼び出されていることが確認できます。
ソースコードでも、Sinatra::Request#paramsが、Rack::QueryParser#parse_nested_queryを呼び出すことを確認する
ソースコードでも、Sinatra::Request#paramsが、Rack::QueryParser#parse_nested_queryを呼び出すことを確認する
バックトレースからメソッドの呼び出され方がわかったので、ソースコードから、実際にどう各メソッドを呼び出していっているのかを確認してみましょう。先程表示したバックトレースを参考にしてください:
#0 Rack::QueryParser#parse_nested_query(qs="", d="&;")
#1 Rack::Request::Helpers#parse_query(qs="", d="&;")
#2 Rack::Request::Helpers#GET
#3 Rack::Request::Helpers#params
#4 Rack::Request#params
#5 Sinatra::Request#params
(上記では省略していますが、実際のバックトレースにはソースコードの行数も書いくれているので、ソースコードを読み解く際の参考にしてください。)
Sinatra::Request#paramsの中身[6:1]は次のようになっています:
def params
super
rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e
raise BadRequest, "Invalid query parameters: #{Rack::Utils.escape_html(e.message)}"
rescue EOFError => e
raise BadRequest, "Invalid multipart/form-data: #{Rack::Utils.escape_html(e.message)}"
end
ここで、superによりSinatra::Requestが継承[12]しているRack::Requestの同名のメソッドRack::Request#params[13]が呼ばれています。
Rack::Request#params[13:1]の中身は、以下のようになっています:
def params
@params ||= super
end
ここのsuperでは、Rack::Requestがincludeして[14]いるRack::Request::Helpersの同名メソッドRack::Request::Helpers#params[15]を呼び出します:
def params
self.GET.merge(self.POST)
end
Rack::Request::Helpers#GET[16]の中身は、以下のようになっています:
# Returns the data received in the query string.
def GET
if get_header(RACK_REQUEST_QUERY_STRING) == query_string
get_header(RACK_REQUEST_QUERY_HASH)
else
query_hash = parse_query(query_string, '&;')
set_header(RACK_REQUEST_QUERY_STRING, query_string)
set_header(RACK_REQUEST_QUERY_HASH, query_hash)
end
end
ここで呼び出されているRack::Request::Helpers#parse_query[17]の中身は、次のようになっています:
def parse_query(qs, d = '&')
query_parser.parse_nested_query(qs, d)
end
というわけで、最終的にRack::QueryParser#parse_nested_query[18]に到達します。
Rack::QueryParser#parse_nested_queryの中身
Rack::QueryParser#parse_nested_queryのドキュメント[4:1]に
parse_nested_query expands a query string into structural types. Supported types are Arrays, Hashes and basic value types. ...
とあるように、このメソッドがクエリ文字列(例:foo[a][]=bar&foo[a][]=baz&foo[b][]=bar&foo[c]=bar)をよしなに解釈して、フクザツな構造(例:{"foo"=>{"a"=>["bar", "baz"], "b"=>["bar"], "c"=>"bar"}})を作ってくれています。
メソッドの実際の中身は以下のようになっていて、Sinatra::Request#paramsの場合は、クエリ文字列を&で分割し、ひとまとまりのfoo[a][]=barみたいな部分を=の両端でさらに分割して、Rack::QueryParser#normalize_params[19]に渡しています:
def parse_nested_query(qs, d = nil)
params = make_params
unless qs.nil? || qs.empty?
(qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p|
k, v = p.split('=', 2).map! { |s| unescape(s) }
normalize_params(params, k, v, param_depth_limit)
end
end
return params.to_h
rescue ArgumentError => e
raise InvalidParameterError, e.message, e.backtrace
end
このRack::QueryParser#normalize_paramsが、フクザツな構造を整形しています:
Rack::QueryParser#normalize_paramsメソッドの中身は[20]長いので畳みます。
Rack::QueryParser#normalize_paramsメソッドの中身
def normalize_params(params, name, v, depth)
raise ParamsTooDeepError if depth <= 0
name =~ %r(\A[\[\]]*([^\[\]]+)\]*)
k = $1 || ''
after = $' || ''
if k.empty?
if !v.nil? && name == "[]"
return Array(v)
else
return
end
end
if after == ''
params[k] = v
elsif after == "["
params[name] = v
elsif after == "[]"
params[k] ||= []
raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
params[k] << v
elsif after =~ %r(^\[\]\[([^\[\]]+)\]$) || after =~ %r(^\[\](.+)$)
child_key = $1
params[k] ||= []
raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key)
normalize_params(params[k].last, child_key, v, depth - 1)
else
params[k] << normalize_params(make_params, child_key, v, depth - 1)
end
else
params[k] ||= make_params
raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k])
params[k] = normalize_params(params[k], after, v, depth - 1)
end
params
end
このRack::QueryParser#normalize_paramsメソッドの動作実験例は、注釈のリンク先[21]などを参照してください。
ちなみに、Rack::Utils#parse_nested_queryも、Rack::QueryParser#parse_nested_queryを呼んでいます[22]。
def parse_nested_query(qs, d = nil)
Rack::Utils.default_query_parser.parse_nested_query(qs, d)
end
したがって、RackプロジェクトのRspec: rack/test/spec_utilsの、Rack::Utils.parse_nested_query exampleの部分[2:2]も、Rack::QueryParser#parse_nested_queryの動きの把握に役立つでしょう。
...長くなりましたが、「inputタグname属性のお作法は、Sinatraじゃなくて、Rackの仕様由来なんだよ!」ということでした。お疲れ様でした。
-
formタグのenctype属性について (<form>: フォーム要素 - mdn web docs) ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ -
RackプロジェクトのRspec: rack/test/spec_utilsの、
Rack::Utils.parse_nested_queryexampleの部分 ↩︎ ↩︎ ↩︎ -
正確には、
Sinatra::IndifferentHashですが、今回は通常のHashと同じと捉えても問題ありません。
(主に、HashのキーのSymble <-> Stringの相互変換をしなくても良い(foo["bar"]でもfoo[:bar]でも値にアクセスできるようになる)という効果がある。) ↩︎ ↩︎ -
#parse_nested_query - Class: Rack::QueryParser(RubyDoc.info) ↩︎ ↩︎
-
xxx&.yyyの項(Ruby リファレンスマニュアル > Rubyで使われる記号の意味(正規表現の複雑な記号は除く)) ↩︎
-
Modify source code with binding.break (similar to binding.pry or binding.irb)(ruby/debug GitHub) ↩︎
-
#normalize_params - Class: Rack::QueryParser(RubyDoc.info) ↩︎
-
How Does Rack Parse Query Params? With parse_nested_query(Codefol.io) ↩︎
Discussion