深入淺出 rack

NickWarm
Nickwarm Journey
Published in
25 min readMar 14, 2017

rack是一個協定(protocol),是一個接口(interface),來傳遞 HTTP request/response。透過rack能夠連結到我們的Ruby web app。

一個簡單的rack app

現在我們先來寫個簡單的rack app,如果你的還沒裝rack,可以下指令gem install rack來安裝。

# first_rack_app.rb

require "rack"

class FirstRackApp
def call(env)
status = 200
headers = {"Content-Type" => "text/html"}
body = ["This is my first web app"]

return [status, headers, body]
end
end

Rack::Handler::WEBrick.run(FirstRackApp.new, Port:3001)

執行ruby first_rack_app.rb後,去http://localhost:3001/就能看到 "This is my first web app" 這行字了,若想要讓它中斷下指令ctrl c即可。

由於Ruby可以省略return以及()的特性,所以這段code可以更簡潔寫成

# first_rack_app.rb

require "rack"

class FirstRackApp
def call(env)
status = 200
headers = {"Content-Type" => "text/html"}
body = ["This is my first web app"]

[status, headers, body]
end
end

Rack::Handler::WEBrick.run FirstRackApp.new, Port:3001

這段code很簡單,我寫了一個rack app,裡面定義了一個call method,它回傳一個 array 。依序是

  1. Status Code:200
  2. Header:一個hash,我請求的Content-Type是text,於是我可以在body寫一串text
  3. Body:一個array,這array裡面有著一串string

若是不懂Status code、Header是什麼,可以參考下面兩篇

1. The Will Will Web | 網頁開發人員應了解的 HTTP 狀態碼

2. List of HTTP header fields — Wikipedia

於是,你可以在一些rack文章上面看到更簡潔的寫法

# first_rack_app.rbrequire "rack"class FirstRackApp
def call(env)
[200, {"Content-Type" => "text/html"}, ["This is my first web app"]]
end
end
Rack::Handler::WEBrick.run FirstRackApp.new, Port:3001

rack app的結構很簡單:

1. 一個call method

2. 回傳一個Array,依序是:HTTP status, header, body

env是什麼?

我們來改一下code,看看這個env是什麼吧

# first_rack_app.rb

require "rack"

class FirstRackApp
def call(env)
[200, {}, [env.inspect]]
end
end

Rack::Handler::WEBrick.run FirstRackApp.new, Port:3001

接著,我們在Terminal下開一個分頁,然後到first_rack_app.rb所在的路徑位置,下指令curl http://localhost:3001/,於是我們看到了一大串hash。

$ curl http://localhost:3001/
{
"GATEWAY_INTERFACE"=>"CGI/1.1",
"PATH_INFO"=>"/",
"QUERY_STRING"=>"",
"REMOTE_ADDR"=>"::1",
"REMOTE_HOST"=>"::1",
"REQUEST_METHOD"=>"GET",
"REQUEST_URI"=>"http://localhost:3001/",
"SCRIPT_NAME"=>"",
"SERVER_NAME"=>"localhost",
"SERVER_PORT"=>"3001",
"SERVER_PROTOCOL"=>"HTTP/1.1",
"SERVER_SOFTWARE"=>"WEBrick/1.3.1(Ruby/2.4.0/2016-12-24)",
"HTTP_HOST"=>"localhost:3001",
"HTTP_USER_AGENT"=>"curl/7.43.0",
"HTTP_ACCEPT"=>"*/*",
"rack.version"=>[1, 3],
"rack.input"=>#<StringIO:0x007fd4a894fde8>,
"rack.errors"=>#<IO:<STDERR>>,
"rack.multithread"=>true,
"rack.multiprocess"=>false,
"rack.run_once"=>false,
"rack.url_scheme"=>"http",
"rack.hijack?"=>true, "rack.hijack"=>#<Proc:0x007fd4a894fb18@/Users/nicholas/.rvm/gems/ruby-2.4.0/gems/rack-2.0.1/lib/rack/handler/webrick.rb:74 (lambda)>,
"rack.hijack_io"=>nil,
"HTTP_VERSION"=>"HTTP/1.1",
"REQUEST_PATH"=>"/"}

env也就是environment是一個hash。

env被傳到call method裡去,於是Rack environment知道了這個request的很多資訊。我們可以看到,這個request是送出一個GET請求("REQUEST_METHOD"=>"GET"),也可以看到我們在code裡定義的port 3001("SERVER_PORT"=>"3001")。

欲了解這些Rack environment的定義可以看Rack doc

為何rack app要定義call method

上面的範例,我們的rack app裡面,只有定義一個call method,為何rack要定義這個call method呢?

在其他Rack的文章你可能會常看到,rack app接一個proc或lambda,像是:

app = proc do |env|
['200', {'Content-Type' => 'text/html'}, ['Hello, Rack!']]
end

application = lambda do |env|
[200, { "Content-Type" => "text/html" }, ["Yay, your first web application! <3"]]
end

這就是Rack會定義call method的主要原因。

當我們的Ruby web app 被Rack app呼叫(call)時,Rack app 的實體(instance)其實是一個proc或lambda instance,然後它調用了proc與lambda的default method,也就是call。不得不說Rack的作者真的很聰明,想到這寫法。

如果對proc或lambda不熟,可以參考我寫的block、proc、yield、lambda之間的關係

更細膩地操作call:語意化method與path

Rack app裡頭只有一個call method,env是一大串hash,當我們需要hash裏頭的一些特定資訊時,我的call method要怎麼寫呢?

在此,以env中的"REQUEST_METHOD""PATH_INFO"為例,我現在定義一個Rack app

# some_rack_app.rb

class SomeRackApp
def call
handle_request(env['REQUEST_METHOD'], env['PATH_INFO'])
end

private
def handle_request(method, path)
if method == "GET"
get(path)
else
method_not_allowed(method)
end
end

def get(path)
[200, {"Content-Type" => "text/html"}, ["You have requested the path #{path}, using GET"]]
end

def method_not_allowed(method)
[405, {}, ["Method not allowed: #{method}"]]
end
end

這段code很簡單,當rack app調用call時,會把env hash得到的REQUEST_METHODPATH_INFO傳進我們自己定義的handle_request method裡面。

接著用一個if..else做判斷,如果REQUEST_METHOD是GET,就調用自定義的get method來回傳一組array,如果REQUEST_METHOD不是GET,就用自定義的method_not_allowed,回傳另一組array

如果我們把所有的邏輯都寫在call裡頭,時間久了後再去讀code就很難讀,但我們把它語意化包成一個個method,就會非常直覺了。

private在Ruby裡面是一個method,在Ruby的private method其實不只Class自己內部可以存取,它的SubClass也可以存取。

如果對private的意義不熟,可以參考這篇文章:

Public, Protected and Private Method in Ruby - 高見龍

Rack::Handler

回到最前面最簡單的rack app,first_rack_app.rb最結尾有一行Rack::Handler::WEBrick.run FirstRackApp.new, Port:3001,這個Rack::Handler是什麼?WEBrick是什麼?

Rack用handler來跑(run) Rack app,在此我們選的是WEBrick這個handler。

我們可以在env hash中看到"SERVER_SOFTWARE"=>"WEBrick/1.3.1(Ruby/2.4.0/2016-12-24)"

WEBrick是ruby內建的HTTP server,其他還有些常見的還有:Puma、Unicorn, Thin, Apache via Passenger...。

假如我們要用Puma的話,就寫

require "rack/handler/puma"

...

Rack::Handler::Puma.run FirstRackApp.new, Port:3001

Rack::Builderconfig.ru

原本要啟動rack app,必須寫一個Ruby檔,然後require "rack",啟動時也要寫一大串Rack::Handler::WEBrick.run

若想要用更簡潔地寫法啟動rack app,則我們可以透過Rack::Builder。首先新增一個config.ru

# config.ru

class FirstRackApp
def call(env)
[200, {"Content-Type" => "text/html"}, ["This is my first web app"]]
end
end

run FirstRackApp.new

然後在config.ru所在的位置,下指令rackup -p 3001,接著去瀏覽器http://localhost:3001/就能看到 "This is my first web app"

config.ru中的ru就是指rackup,所以才會在Rebuilding rails上看到用rackup -p 3001這指令來啟動我們的Ruby web app。

Rack::Builder is a pattern that allows us to more succinctly define our application and the middleware stack surrounding it.

ref:What is Rack? | Ruby on Rails Tutorial by thoughtbot

推薦這篇文章Rack::Builder這節,說明了不用config.ru是怎麼寫Rack::Builder

ref:Advanced Rack — Gabe Berke-Williams

in config.ru, use adds middleware to the stack, run dispatches to an application. You can use map to construct a Rack::URLMap in a convenient way.

ref:Class: Rack::Builder — Documentation for rack/rack (master)

這樣寫,並不只是為了更簡潔,而是要帶入middleware的概念

Middleware的概念

Middleware本身就是一個rack app。

config.ru裡可以用use調用先前編寫的一支支Middlewares,例如:

# config.ru

require './app.rb'
require './middleware1.rb'
require './middleware2.rb'
require './middleware3.rb'

use Middleware1
use Middleware2
use Middleware3

run App.new

如上面的code所示,Rack::Builder依序使用了三個middleware,最後才執行App。用這樣的方式產生出一個Rack app

這段code的效果等同如下

rack_app = Middleware1.new(Middleware2.new(Middleware3.new(App)))

文章開頭時,我們看到下面這張圖,幫助我們理解rack與user以及我們的Ruby web app的關係。

我們可以看到user送出的request先通過rack server然後才送到Ruby web app。

也就是說,user送出的request先經過rack app的處理後,才送進我們的ruby web app,透過下面這張圖,能直觀地了解rack app做了什麼事

一層又一層的Middleware攔截了我們的request並對他做處理與修改,如此一來Ruby web app所收到的request,就只會有這app所需要的資訊,而不會有其他多餘的資訊。

這樣的模式稱之為pipeline design pattern,推薦閱讀下面兩篇,尤其是維基百科的那張圖,看了會很有感覺

1. 指令管線化(Instruction pipeline) — 維基百科

2. comment: ruby on rails — What is Rack middleware? — Stack Overflow

Rack middleware is more than “a way to filter a request and response” — it’s an implementation of the pipeline design pattern for web servers using Rack.

It very cleanly separates out the different stages of processing a request — separation of concerns being a key goal of all well designed software products.

Middleware與config.ru

config.ru裡,可以用runmapuse這三個method

  • run:執行一個proc或lambda的instance
  • use:告訴我們的Rack app要使用什麼middleware
  • map:告訴Rack app什麼路徑(path),要用什麼middleware與rack app來處理

在這邊給一個map很具體的例子

# config.ru

require './main_rack_app.rb'
require './admin_rack_app.rb'
require './first_middleware.rb'
require './second_middleware.rb'

map '/' do
use FirstMiddleware
run MainRackApp.new
end

map '/admin' do
use SecondMiddleware
run AdminRackApp.new
end

當送過來的requets,它的"REQUEST_PATH"=>"/"就給FirstMiddlewareMainRackApp處理。

當送過來的requets,它的"REQUEST_PATH"=>"/admin"就給SecondMiddlewareAdminRackApp處理。

如此一來,我們就能依照不同路由,來給我們的request通過不同的middleware與rack app進行處理。

rack也有提供不少包好的middleware讓人使用。

Middleware的具體細節

這邊就直接給一個簡單的例子,延續先前的config.ru範例

原本我們知道的config.ru大概會這樣寫

# 原始config.ru

class MyApp
def call(env)
[200, {"Content-Type" => "text/html"}, ["This is my app"]]
end
end

run FirstRackApp.new

現在我把rack app的部分獨立成my_app.rb

# my_app.rb

class MyApp
def call(env)
[200, {"Content-Type" => "text/html"}, ["This is my app."]]
end
end

這個時候,config.ru應該改寫成

# config.ru

require "./my_app.rb"

app = MyApp.new

run app

接著我寫一個簡單的middleware,my_app_middleware.rb

Middleware會用class包起來,先定義initialize method,再定義call method。

# my_app_middleware.rb

class MyAppMiddleware
def initialize(app)
@app = app
end

def call(env)
code, headers, body = @app.call(env)
message = "This is my app middleware."
body << message

[code, headers, body]
end
end

如上面code所示,我先透過initialize method去撈出rack app,接著再定義call method。

在middleware的call method裡,一開始先用@app.call去撈出我原本在my_app.rb裡定義call食撈出的array,然後存成codeheadersbody

接著我塞一串字串進到body裡去,最後再回傳一個array。

我回到config.ru來使用我剛剛寫好的middleware

require "./my_app.rb"
require "./my_app_middleware.rb"

app = MyApp.new

use MyAppMiddleware

run app

然後一樣下指令rackup -p 3001,接著你就能在http://localhost:3001/看到 " This is my app.This is my app middleware. "

再來個map的具體實例吧。

現在我創造另一個middleware hello_nick_middleware.rb

# hello_nick_middleware.rb

class HelloNickMiddleware
def initialize(app)
@app = app
end

def call(env)
code, headers, body = @app.call(env)
body << "Hello, Nick"
[code, headers, body]
end
end

然後再修改一下config.ru

# config.ru

require "./my_app.rb"
require "./my_app_middleware.rb"
require "./hello_nick_middleware.rb"
require "./sao.rb"

app = MyApp.new
sao = SAO.new

map '/' do
use MyAppMiddleware
run app
end

map '/nick' do
use HelloNickMiddleware
run sao
end

rackup之後,你可以去http://localhost:3001/nick看到我們第二個middleware所插入進去的字串

另外要注意一下,config.ru的top-level context只能有一個run,像是

# config.ru

... #上面略

map '/admin' do
use MyAppMiddleware
run app
end

map '/nick' do
use HelloNickMiddleware
run sao
end

run myapp

top-level所run的rack app,會預設是/路徑的request,依此邏輯在top level裡run兩個不同的rack app就沒有意義了。

若不清楚什麼是top level可以參考下面兩篇文章

1. ruby的top-level

2. What is the Ruby Top-Level? | Like Dream of Banister Fiend

一些常用的東西

在前面的「更細膩地操作call:語意化method與path」這節,曾看到用env["REQUEST_METHOD"]來查看用什麼request method,rack提供更便利的方式來操作env這hash裡的 key-value pairs。

request = Rack::Request.new(env)

Rack::Request提供了一些method讓我們不用直接操作env的hash

# 等同於 env["REQUEST_METHOD"]
request.request_method
# 等同於 env["rack.request.query_hash"] + env["rack.input"]
request.params
# 等同於 env["REQUEST_METHOD"] == "GET"
request.get?

最後簡單兩個範例常見的Rack:RequestRack:Response

Rack:Request的例子

建一個my_app1.rb

require 'rack'class MyApp1
def call(env)
# 创建一个request对象,就能享受Rack::Request的便捷操作了
@request = Rack::Request.new env
method = @request.request_method
info = @request.path_info
puts "This website request method is `#{method}` "
puts "This website path info is `#{info}`"
[200, {}, ['hello world']]
end
end
Rack::Handler::WEBrick.run MyApp1.new, Port:3001

執行ruby my_app1.rb然後去http://localhost:3001/,接著你可以在Terminal看到

$ ruby my_app1.rb
[2017-03-13 16:14:58] INFO WEBrick 1.3.1
[2017-03-13 16:14:58] INFO ruby 2.2.2 (2015-04-13) [x86_64-darwin14]
[2017-03-13 16:14:58] INFO WEBrick::HTTPServer#start: pid=61940 port=3001
This website request method is `GET`
This website path info is `/`
localhost - - [13/Mar/2017:16:15:01 CST] "GET / HTTP/1.1" 200 11
- -> /
This website request method is `GET`
This website path info is `/favicon.ico`

我們確實可以看到這個request用的method與path information

This website request method is `GET`
This website path info is `/`

PS:會看到/favicon.ico是因為瀏覽器會去抓網站的logo,可以參考下面兩篇

1. The Will Will Web | 介紹 Favicon.ico 重點觀念

2. [架站] 為什麼網站的根目錄最好有 favicon.ico 和 robots.txt 存在? | jjdai Blog

Rack::Response的例子

建一個my_app2.rb

require 'rack'class MyApp2
def call(env)
# 创建一个request对象,就能享受Rack::Request的便捷操作了
@response = Rack::Response.new env
@response.set_cookie('token', 'xxxxxxx123')
@response.headers['Content-Type'] = 'text/plain'
puts @response.inspect
[200, {}, ['hello world']]
end
end
Rack::Handler::WEBrick.run MyApp2.new, Port:3001

執行ruby my_app2.rb然後去http://localhost:3001/,接著你可以在Terminal看到很長一大串

$ ruby my_app2.rb
[2017-03-13 16:33:03] INFO WEBrick 1.3.1
[2017-03-13 16:33:03] INFO ruby 2.2.2 (2015-04-13) [x86_64-darwin14]
[2017-03-13 16:33:03] INFO WEBrick::HTTPServer#start: pid=62319 port=3001
#<Rack::Response:0x007f957b99b520 @status=200, @header={"Content-Length"=>"7805", "Set-Cookie"=>"token=xxxxxxx123", "Content-Type"=>"text/plain"}, @writer=#<Proc:0x007f957b99aad0@/Users/nicholas/.rvm/gems/ruby-2.4.0/gems/rack-2.0.1/lib/rack/response.rb:32 (lambda)>,......
(後面省略)

不過你可以看到,我們在程式裡寫的cookie與header都成功寫到response裡去了

更多method可以參考Rack doc

ref

--

--

NickWarm
Nickwarm Journey

Rubyist。Nicholas aka Nick. Experienced in Ruby, Rails. I like to share the experiences what I learned.