36
13

More than 3 years have passed since last update.

Rack入門 Rack Application編 (2/3)

Last updated at Posted at 2020-01-03

前回 はRackが必要とされた背景と、基本的な概念について説明しました。
今回は実際にRackプロトコルを使いアプリサーバーと通信するプログラムを作りながら、Rackに関する理解を深めていきます。

目次

  1. Rack入門 概念編(1/3)
  2. [本記事] Rack入門 Rack Application編 (2/3)
  3. Rack入門 Rack Middleware編 (3/3)

Hello Rack Application

基本を理解するために、簡単なRackアプリケーションを作ってみます。ブラウザーからアクセスされたら、「Hello Rack!」と返すだけのシンプルなアプリです。

まずは以下Gemfileを用意し、bundle installをしてください。アプリサーバーはpumaを使います。

source "https://rubygems.org"

gem "rack"
gem "puma"

次に config.ru というファイルを作成します。拡張子がruですが、中身はrubyのコードです。

class HelloRackApp
  # callメソッドはenvを受け取り、3つの値(StatusCode, Headers, Body)を配列として返す
  def call(env)
    [200, { "Content-Type" => "text/plain" }, ["Hello Rack!"]]
  end
end

# callメソッドを呼び出せるObjectをrunに渡し、rackアプリを起動する
run HelloRackApp.new

ターミナル上で以下のコマンドを実行し、Rackアプリを起動します。

bundle exec rackup -s puma

rackupコマンドはデフォルトではconfig.ruを読み込み、Rackアプリを起動します。
bundle exec rackup config.ru としてファイルを指定することもできます。
-s pumaはアプリサーバーの指定です。指定しない場合はpumaかthinかwebrickのいずれかが使われます。

$ bundle exec rackup -s puma

Puma starting in single mode...
* Version 4.3.1 (ruby 2.7.0-p0), codename: Mysterious Traveller
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://127.0.0.1:9292
* Listening on tcp://[::1]:9292
Use Ctrl-C to stop

port9292でRackアプリが起動しました。
pumaはWebサーバーとしての機能も持っているため、ブラウザーやコマンドからもアクセスできます。
curlでアクセスしてみます。

$ curl -v http://localhost:9292

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 9292 (#0)
> GET / HTTP/1.1
> Host: localhost:9292
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
Hello Rack!* Closing connection 0

Hello Rack!とレスポンスが返ってきました。Statusは200 OK、Headerには指定したContent-Type: text/plain も含まれています。ブラウザーからもアクセスしてみます。

rack02_01.png

Rackアプリケーションの形式

Rackアプリはアプリサーバーと通信するために、以下の形式(プロトコル)にしたがって作成する必要があります。

  1. RackアプリはObjectであり、callメソッドを呼び出せること。callメソッドは引数を一つ受け取ること
  2. callメソッド呼び出し後、以下3つの値を配列として返すこと
    • (HTTP) Status Code
    • Headers
    • Body
  3. Status Codeは3桁のHTTPのステータスコード(100以上の数値)であること
  4. Headersはeachメソッドを実装し、yieldの際にkey/valueのペアを渡すこと。key/valueは必ずStringであること
  5. Bodyはeachメソッドを実装し、yieldの際にStringを渡すこと

実際はさらに細かく仕様が決まっていますが、要はこれだけです。
Rackの詳細な仕様は https://github.com/rack/rack/blob/master/SPEC に記載されています。

yieldの際にStringを渡すこと、というのがわかりにくいですね。
rubyのyieldについて復習しておきます。

[1,2,3,4,5].each { |v| puts v }

上記コードの実行結果は以下となります。

1
2
3
4
5

このvがyieldしたときに渡ってくる値です。eachは以下のように実装できます。

class MyArray
  def initialize(values)
    @values = values
  end

  def each
    for v in @values
      yield v
    end
  end
end

MyArray.new([1,2,3,4,5]).each { |v| puts v }

RackのHeadersはkey/valueの2つの値を渡しますが、この値がStringであれば良いです。
Bodyは、yieldの引数として渡す値がStringであれば良いですね。

Rackアプリが満たすべき要求はこれだけです。
もう一度最初のRackアプリのコードを見てみます。

class HelloRackApp
  def call(env)
    [ 
      200,                                 # 1番目はStatusで、3桁の数値を返す

      { "Content-Type" => "text/plain" },  # 2番目はHeaders, Hashはeachメソッドを実装済
                                           # key/valueはStringあること

      ["Hello Rack!"]                      # 3番目はBody, Arrayはeachメソッドを実装済み
                                           # 中身はStringであること
    ]
  end
end
run HelloRackApp.new

上記の形式に従っていれば、立派なRackアプリケーションの完成です。

HelloRackAppはclassである必要はありません。callメソッドを持ったObjectであればよくて、Headers/Bodyもeachを持ってさえいればよいので、以下のコードでも動きます。

class MyHeader
  def initialize(bodies)
    @bodies = bodies
  end

  def each
    yield "Content-Type", "text/plain"
    yield "Content-Length", @bodies.join.bytesize.to_s # NOTE: ValueはStringにしないといけない
  end
end

class MyBody
  def initialize(bodies)
    @bodies = bodies
  end

  def each
    for v in @bodies
      yield v
    end
  end
end

# NOTE: procはcallで呼び出し可能
app = proc do |env|
  bodies = ["こんにちはRack!\n\n", "Hello Rack!\n"]

  [200, MyHeader.new(bodies), MyBody.new(bodies)]
end

run app
$ curl -v http://localhost:9292

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 9292 (#0)
> GET / HTTP/1.1
> Host: localhost:9292
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 34
<
こんにちはRack!

Hello Rack!
* Connection #0 to host localhost left intact
* Closing connection 0

envを使ってリクエストを取り出す

callメソッドの引数envには、ブラウザーなどからのリクエストが格納されています。
以下のコードで中身を覗いてみます。

class HelloRackApp
  def call(env)
    require "pp"
    pp env

    [200, {}, []]
  end
end

run HelloRackApp.new

rackup後、サーバーにアクセスして結果を見てみます。

{"rack.version"=>[1, 3],
 "rack.errors"=>
  #<Rack::Lint::ErrorWrapper:0x00007f90a41c0c00 @error=#<IO:<STDERR>>>,
 "rack.multithread"=>true,
 "rack.multiprocess"=>false,
 "rack.run_once"=>false,
 "SCRIPT_NAME"=>"",
 "QUERY_STRING"=>"",
 "SERVER_PROTOCOL"=>"HTTP/1.1",
 "SERVER_SOFTWARE"=>"puma 4.3.1 Mysterious Traveller",
 "GATEWAY_INTERFACE"=>"CGI/1.2",
 "REQUEST_METHOD"=>"GET",
 "REQUEST_PATH"=>"/",
 "REQUEST_URI"=>"/",
 "HTTP_VERSION"=>"HTTP/1.1",
 "HTTP_HOST"=>"localhost:9292",
 "HTTP_USER_AGENT"=>"curl/7.64.1",
 "HTTP_ACCEPT"=>"*/*",
 "puma.request_body_wait"=>0,
 "SERVER_NAME"=>"localhost",
 "SERVER_PORT"=>"9292",
 "PATH_INFO"=>"/",
 "REMOTE_ADDR"=>"::1",
 "puma.socket"=>#<TCPSocket:fd 18, AF_INET6, ::1, 9292>,
 "rack.hijack?"=>true,
 "rack.hijack"=>
  #<Proc:0x00007f90a41c0fc0 /Users/nishio/.rbenv/versions/2.7.0/lib/ruby/gems/2.7.0/gems/rack-2.0.8/lib/rack/lint.rb:525>,
 "rack.input"=>
  #<Rack::Lint::InputWrapper:0x00007f90a41c0c28
   @input=#<Puma::NullIO:0x00007f90a585a8f8>>,
 "rack.url_scheme"=>"http",
 "rack.after_reply"=>[],
 "puma.config"=> #<Puma::Configuration:0x00007f90a41d7798>
 "rack.tempfiles"=>[]
 }

envには以下の全てが混ざったデータがHash形式で格納されています。

  1. HTTPリクエストヘッダー

    • HTTP_VERSIONやHTTP_USER_AGENTなど、HTTPリクエストヘッダーの値がkey/value形式で入っている
    • Keyは他の情報と区別するために、HTTP_ というprefixを付与している
  2. HTTPリクエスト情報

    • QUERY_STRING や REQUEST_URIなど、リクエストヘッダー以外のリクエスト情報が入っている
  3. pumaやrackなどのアプリからの情報

    • rack.version や puma.config など、各種アプリが付与した情報が入っている

envの値を利用することで、リクエストに応じたレスポンスを返せます。
試しに、以下アプリを作ってみます。

  1. http://localhost:9292/hello にアクセスしたら、Hello!を返す
  2. 上記以外のURIにアクセスしたら、404 Not Found を返す
class App
  def call(env)
    # NOTE:
    #   REQUEST_PATHも利用できるが、実装されていないケースが存在する
    #   PATH_INFOは必ず存在するので、urlはPATH_INFOを使って取り出す方が安全である
    #   https://github.com/envato/jwt_signed_request/issues/15
    path = env["PATH_INFO"]

    if path == "/hello"
      [200, { "Content-Type" => "text/plain" }, ["HELLO!"]]
    else
      [404, { "Content-Type" => "text/plain" }, ["404 Not Found"]]
    end
  end
end

run App.new

上記アプリにアクセスしてみましょう。

$ curl -v http://localhost:9292/hello

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 9292 (#0)
> GET /hello HTTP/1.1
> Host: localhost:9292
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
HELLO!* Closing connection 0
$ curl -v http://localhost:9292/testtest

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 9292 (#0)
> GET /testtest HTTP/1.1
> Host: localhost:9292
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Type: text/plain
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
404 Not Found* Closing connection 0

ファイルを返すアプリを作成する

画像ファイルをレスポンスとして返すアプリを作ってみます。
RackのBodyは文字列でさえあればなんでもよいので、以下のように実装すれば画像を返せます。

class App
  def call(env)
    path = "./neko.jpg"  # 画像ファイルは好きなものを用意

    file = File.open(path, "rb")
    image_data = [file.read]  # Bodyは必ずeachメソッドを持つ必要があるのでArrayとする

    [200, { "Content-Type" => "image/jpeg" }, image_data]
  ensure
    file.close
  end
end

run App.new

上記コードにブラウザーからアクセスしてみます。

rack02_02.png

確かにこれでも動きはしますが、ファイル全体を一度メモリ上に読み出してBodyに詰める必要があります。
読み込み効率が悪いので、レスポンスがしんどいですね。

Rackはファイルをレスポンスすることも考え、以下のようなコードを記述できます。

class App
  def call(env)
    path = "./neko.jpg"  # 画像ファイルは好きなものを用意

    file = File.open(path, "rb")

    [200, { "Content-Type" => "image/jpeg" }, file]
  end
end

run App.new

RubyのFileは以下メソッドを持っています。

  1. eachメソッド

    • eachを使えば、一定データ量ずつファイルから読み出せます
    • 例えば File.each(100) とすれば、ファイルを100バイトずつ読み出せます
  2. closeメソッド

    • ファイルはopenしたら必ずcloseする必要があります

Rackは、 close メソッドを持ったオブジェクトをbodyとして渡すと、各種処理を実行後にcloseを呼び出してくれます。

このように、Fileオブジェクトを渡すことを想定した仕様があらかじめ定義されています。

rackupコマンドを使わずrackアプリを起動する

最後に、rackupコマンドを使わずにアプリを起動するコードを紹介します。

$ bundle exec ruby config.ru

実装は以下コードとなります。

class App
  def call(env)
    [200, { "Content-Type": "text/plain" }, ["Hello Rack!"]]
  end
end

require "rack"

app = Rack::Builder.new do
  run App.new
end

require "rack/handler/puma"
Rack::Handler::Puma.run app

rackpuma関係のファイルをrequireし、Rack::Builderで初期化したものをPumaのHandlerに渡せばよいです。
rackupコマンドは、このあたりのファイル読み込みをあらかじめ自動で行ってくれる便利コマンドです。

まとめ

  • Rackアプリはあらかじめ決められたRackプロトコルにしたがって作成する
  • Rackアプリはcallメソッドを持ったオブジェクトであること
  • callメソッドは、引数を1つ受け取り、StatusCode・Headers・Bodyのペアを返す
  • callメソッドの引数(env)から、リクエスト情報を取り出せる
36
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
13