前回 はRackが必要とされた背景と、基本的な概念について説明しました。
今回は実際にRackプロトコルを使いアプリサーバーと通信するプログラムを作りながら、Rackに関する理解を深めていきます。
目次
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
も含まれています。ブラウザーからもアクセスしてみます。
Rackアプリケーションの形式
Rackアプリはアプリサーバーと通信するために、以下の形式(プロトコル)にしたがって作成する必要があります。
- RackアプリはObjectであり、callメソッドを呼び出せること。callメソッドは引数を一つ受け取ること
- callメソッド呼び出し後、以下3つの値を配列として返すこと
- (HTTP) Status Code
- Headers
- Body
- Status Codeは3桁のHTTPのステータスコード(100以上の数値)であること
- Headersはeachメソッドを実装し、yieldの際にkey/valueのペアを渡すこと。key/valueは必ずStringであること
- 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形式で格納されています。
-
HTTPリクエストヘッダー
- HTTP_VERSIONやHTTP_USER_AGENTなど、HTTPリクエストヘッダーの値がkey/value形式で入っている
- Keyは他の情報と区別するために、
HTTP_
というprefixを付与している
-
HTTPリクエスト情報
- QUERY_STRING や REQUEST_URIなど、リクエストヘッダー以外のリクエスト情報が入っている
-
pumaやrackなどのアプリからの情報
- rack.version や puma.config など、各種アプリが付与した情報が入っている
envの値を利用することで、リクエストに応じたレスポンスを返せます。
試しに、以下アプリを作ってみます。
-
http://localhost:9292/hello
にアクセスしたら、Hello!
を返す - 上記以外の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
上記コードにブラウザーからアクセスしてみます。
確かにこれでも動きはしますが、ファイル全体を一度メモリ上に読み出して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は以下メソッドを持っています。
-
eachメソッド
- eachを使えば、一定データ量ずつファイルから読み出せます
- 例えば
File.each(100)
とすれば、ファイルを100バイトずつ読み出せます
-
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
rack
やpuma
関係のファイルをrequireし、Rack::Builder
で初期化したものをPumaのHandlerに渡せばよいです。
rackupコマンドは、このあたりのファイル読み込みをあらかじめ自動で行ってくれる便利コマンドです。
まとめ
- Rackアプリはあらかじめ決められたRackプロトコルにしたがって作成する
- Specの詳細は https://github.com/rack/rack/blob/master/SPEC に記載されている
- Rackアプリはcallメソッドを持ったオブジェクトであること
- callメソッドは、引数を1つ受け取り、StatusCode・Headers・Bodyのペアを返す
- callメソッドの引数(env)から、リクエスト情報を取り出せる