📚

[Ruby] Sinatraで複数のファイルをアップロードする

2022/06/13に公開

まとめ

HTMLのファイルアップロード部分を、以下のように指定します:

  • formタグ:

    • enctype属性を"multipart/form-data"にする

      • form中にtype="file"inputタグを含むため[1]
    • method属性を"post"にする

  • inputタグ:

    • type属性を"file"にする

    • name属性を、"files[]"のように[]つきの名前にする

    • multiple属性を指定する

サンプルコード

Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "sinatra"
gem "puma"
sample.rb
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]を得ることができます。

...と文字だけで書いても分かりづらいので、実際にコードを動かして確認しましょう。

Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "sinatra"
gem "puma"
app_1.rb
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(" ", "&nbsp;")
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"に変更

app_2.rb
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(" ", "&nbsp;")
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とします:

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(" ", "&nbsp;")
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]。)

form_get.rb
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(" ", "&nbsp;")
end

実行(ファイル名はform_get.rb):

$ bundle exec ruby form_get.rb

URL: http://localhost:4567

その他の使用例

file以外にも、checkboxなど、特定の同じキーについてArrayでひとまとめにして返してほしい場面があります。その際にも、同様のお作法を使います。

以下に、幾つか例を挙げます:

  • checkbox

    <input type="checkbox" name="versions[]" value="scarlet">
    <input type="checkbox" name="versions[]" value="violet">
    
    実行用サンプルコード
    checkbox.rb
    require "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(" ", "&nbsp;")
    end
    

    実行(ファイル名はcheckbox.rb):

    $ bundle exec ruby checkbox.rb
    

    URL: http://localhost:4567

  • text box

    <input type="text" name="favorites[]">
    <input type="text" name="favorites[]">
    
    実行用サンプルコード
    text_box.rb
    require "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(" ", "&nbsp;")
    end
    

    実行(ファイル名はtext_box.rb):

    $ bundle exec ruby text_box.rb
    

    URL: http://localhost:4567

  • 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.rb
    require "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(" ", "&nbsp;")
    end
    

    実行(ファイル名はmix.rb):

    $ bundle exec ruby mix.rb
    

    URL: http://localhost:4567

もっと複雑なパターン

実は、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]。)

deepen.rb
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(" ", "&nbsp;")
end

実行(ファイル名はdeepen.rb):

$ bundle exec ruby deepen.rb

URL: http://localhost:4567

もっと色々ありますので、RackプロジェクトのRspec: rack/test/spec_utilsの、Rack::Utils.parse_nested_queryの部分[2:1]を覗いてみてください。

(補足)アップロードされたファイルの取り扱い

アップロードされたファイルの中身は、paramの中にTempfileオブジェクトとして格納されています。

params[<name属性のキー>]["tempfile"]で取り出すことができます(ファイル一つのみの単純な場合)。
ですが、ファイル選択なしHTTP POSTを許容する場合など、該当のキー項目がないケースを考えて、Hash#digを使うほうが便利かもしれません。

例(name="uploaded_file"として送信)
tempfile = params["uploaded_file"]["tempfile"] # => Tempfile

# Hash#digの方が実用性は高いかも
tempfile = params.dig("uploaded_file", "tempfile") #=> Tempfile

基本的には、Fileオブジェクトのインスタンスメソッドが同じように使えます:

Tempfile#close!で、すぐに削除する

ページ遷移すると、paramが参照を手放すため、GCがどこかのタイミングでTempfileのファイル実体を削除してくれます(自分で新たに参照を持ち続けなければ)。

しかし、ここでのTempfileの用途的に、ファイルの中身データを拝借したあとは、Tempfileの実体自体はすぐに消したほうが良いように思います。実際、ページ遷移後ファイル実体がすぐ消されるわけではありません。実行用サンプルコードで実験してみてください。

そこで、Tempfileのデータにアクセスしたあとは、

tempfile.close!

で、明示的にTempfileを削除するのが良いと思います。

実行用サンプルコード

Tempfileがすぐに消えないことの実験方法:

  1. tempfile.close!の行をコメントアウトして、プログラムを実行する

  2. ファイルをアップロードし、ブラウザに表示されるtempfileのpathを控えておく

  3. 「戻る」ボタンでページ遷移する

  4. 別のターミナルウィンドウで同じサーバにログインし、2で控えたファイルが存在するかどうかをlsコマンドなどで確認する


(filename文字列のエンコーディング情報をUTF-8に変更する部分(String#force_encoding)で、いわゆるぼっち演算子&.[5]を使っています。)

handle_uploaded_file.rb
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(" ", "&nbsp;")}</tt>
    </p>

    <a href="/">戻る</a>
  HTML
end

def pp(obj) # HTML上で改行、whitespaceを反映させ、オブジェクト出力を見やすいようにする
  obj.nil? ? "" : PP.pp(obj, '').gsub("\n", "<br>").gsub(" ", "&nbsp;")
end

実行(ファイル名はhandle_uploaded_file.rb):

$ bundle exec ruby handle_uploaded_file.rb

URL: http://localhost:4567

HTML inputタグname属性のお作法は、Rack由来

今回の、params変数に入ってくるname属性のお作法は、実はSinatraではなくRackの方の、もう少し正確に言うとRack::QueryParser#parse_nested_queryの仕様から来ています。これを確認してみましょう。

確認の仕方

私達がrequire "sinatra"して(あるいはModular Appとしてrequire "sinatra/base"して)、getpostメソッドブロックの中でparamsと書く時、呼び出されるのはSinatra::Request#params[6]です。

以下を確認すれば、「HTML inputタグname属性のお作法は、Rack由来」ということが確認できるでしょう:

  1. メソッドSinatra::Request#paramsを呼び出すと、その過程でRack::QueryParser#parse_nested_queryが呼び出されていること
  2. このRack::QueryParser#parse_nested_queryが「name属性のお作法」を決めていること

いきなりソースコードを見ながら、このメソッドはここで...と追いかけてもいいのですが、ハードルが高いので、以下の手順で確認していくことにします:

  1. 天下り的に「Sinatra::Request#paramsRack::QueryParser#parse_nested_queryを呼ぶらしい」ことを知っているとして、実際本当にそういう動作になっているのかどうか、をデバッガでまず確かめる
  2. Rack::QueryParser#parse_nested_queryの中身を見て、そこで今回の「name属性のお作法」が決まっていることを確認する

デバッガとしてはdebug.gemを使って、Sinatra::Request#paramsの動きを追跡するところから始めましょう。

debug.gemを活用して確認してみよう

まず、新しいディレクトリを作成して、その中に以下の2ファイルを作成してください:

Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "sinatra"
gem "puma"
gem "debug"
trace_params.rb
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:の方を使うことにします:

trace_params.rb
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ですが、スタックトレース行#5Sinatra::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]は次のようになっています:

sinatra-3.0.6/lib/sinatra/base.rb
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]の中身は、以下のようになっています:

rack-2.2.7/lib/rack/request.rb
def params
  @params ||= super
end

ここのsuperでは、Rack::Requestincludeして[14]いるRack::Request::Helpersの同名メソッドRack::Request::Helpers#params[15]を呼び出します:

rack-2.2.7/lib/rack/request.rb
def params
  self.GET.merge(self.POST)
end

Rack::Request::Helpers#GET[16]の中身は、以下のようになっています:

rack-2.2.7/lib/rack/request.rb
# 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]の中身は、次のようになっています:

rack-2.2.7/lib/rack/request.rb
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]に渡しています:

rack-2.2.7/lib/rack/query_parser.rb
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メソッドの中身
rack-2.2.7/lib/rack/query_parser.rb
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]

rack-2.2.7/lib/rack/utils.rb
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の仕様由来なんだよ!」ということでした。お疲れ様でした。

脚注
  1. formタグのenctype属性について (<form>: フォーム要素 - mdn web docs) ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. RackプロジェクトのRspec: rack/test/spec_utilsの、Rack::Utils.parse_nested_query exampleの部分 ↩︎ ↩︎ ↩︎

  3. 正確には、Sinatra::IndifferentHashですが、今回は通常のHashと同じと捉えても問題ありません。
    (主に、HashのキーのSymble <-> Stringの相互変換をしなくても良い(foo["bar"]でもfoo[:bar]でも値にアクセスできるようになる)という効果がある。) ↩︎ ↩︎

  4. #parse_nested_query - Class: Rack::QueryParser(RubyDoc.info) ↩︎ ↩︎

  5. xxx&.yyyの項(Ruby リファレンスマニュアル > Rubyで使われる記号の意味(正規表現の複雑な記号は除く)) ↩︎

  6. Sinatra::Request#params ↩︎ ↩︎

  7. Ruby 3.1 の debug.gem を自慢したい(クックパッド開発者ブログ) ↩︎ ↩︎

  8. ruby/debug(GitHub) ↩︎ ↩︎

  9. [Ruby] Bundled gemsはGemfileに指定して使おう(Zenn) ↩︎

  10. binding.break method(ruby/debug GitHub) ↩︎

  11. Modify source code with binding.break (similar to binding.pry or binding.irb)(ruby/debug GitHub) ↩︎

  12. class Sinatra::Request ↩︎

  13. Rack::Request#params ↩︎ ↩︎

  14. include Helpers ↩︎

  15. Rack::Request::Helpers#params ↩︎

  16. Rack::Request::Helpers#GET ↩︎

  17. Rack::Request::Helpers#parse_query ↩︎

  18. Rack::QueryParser#parse_nested_query ↩︎

  19. #normalize_params - Class: Rack::QueryParser(RubyDoc.info) ↩︎

  20. Rack::QueryParser#normalize_params ↩︎

  21. How Does Rack Parse Query Params? With parse_nested_query(Codefol.io) ↩︎

  22. Rack::Utils#parse_nested_query ↩︎

Discussion