[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側のparam
Hashのキーとなる文字列を、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ひとつのままになっています。
[]
を加える -> OK
inputタグのname属性の文字列の最後に、ここで問題になっているのは、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_query
exampleの部分 ↩︎ ↩︎ ↩︎ -
正確には、
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