ใน Ruby Magic series เราชอบแยก software ส่วนๆ เพื่อเรียนรู้ว่ามัน function ยังไงใต้ hood มันทั้งหมดเกี่ยวกับ process; end result ไม่ใช่สิ่งที่คุณใช้ใน production เราเรียนรู้ internal workings ของ Ruby language และ popular libraries เผยแพร่ article ใหม่ประมาณเดือนละครั้ง subscribe newsletter ของเราถ้าคุณชอบแบบนี้ด้วย
ใน earlier edition ของ Ruby Magic เรา implement 30-line HTTP server ใน Ruby โดยไม่ต้องเขียนโค้ดมาก เรา handle HTTP GET requests และ serve simple Rack application ได้ ครั้งนี้เราจะพา home made server ไปไกลกว่านั้น เมื่อเสร็จ เราจะมี web server ที่ serve Rails' famous fifteen minute blog ที่ allow create, update และ delete posts
Where We Left Off
Last time เรา implement แค่พอให้ server serve Rack::Lobster เป็น example application
- Implementation เปิด TCP server และรอ request เข้ามา
- เมื่อเกิด request-line (
GET /?flip=left HTTP/1.1\r\n) ถูก parse ได้ request method (GET) path (/) และ query parameters (flip=left) - Request method, path และ query string ส่งไป Rack app ซึ่งคืน triplet พร้อม status, response headers และ response body
- ใช้เหล่านั้นเราสร้าง HTTP response ส่งกลับ browser ก่อน close connection รอ request ใหม่
# http_server.rb
require 'socket'
require 'rack'
require 'rack/lobster'
app = Rack::Lobster.new
server = TCPServer.new 5678
#1
while session = server.accept
request = session.gets
puts request
#2
method, full_path = request.split(' ')
path, query = full_path.split('?')
#3
status, headers, body = app.call({
'REQUEST_METHOD' => method,
'PATH_INFO' => path,
'QUERY_STRING' => query
})
#4
session.print "HTTP/1.1 #{status}\r\n"
headers.each do |key, value|
session.print "#{key}: #{value}\r\n"
end
session.print "\r\n"
body.each do |part|
session.print part
end
session.close
end
เราจะ continue ด้วยโค้ดที่เราเขียน last time หากอยาก follow along นี่คือโค้ดที่เราจบ
Rack และ Rails
Ruby frameworks อย่าง Rails และ Sinatra built on top of Rack interface เหมือน instance ของ Rack::Lobster ที่เราใช้ test server ตอนนี้ Rails.application ของ Rails เป็น Rack application object ในทฤษฎี นี่หมายความว่า server ของเราควร serve Rails application ได้แล้ว
เพื่อ test นั้น ฉันเตรียม simple Rails application ไว้ ให้ clone เข้า directory เดียวกับ server
$ ls
http_server.rb
$ git clone https://github.com/jeffkreeftmeijer/wups.git blog
Cloning into 'blog'...
remote: Counting objects: 162, done.
remote: Compressing objects: 100% (112/112), done.
remote: Total 162 (delta 32), reused 162 (delta 32), pack-reused 0
Receiving objects: 100% (162/162), 29.09 KiB | 0 bytes/s, done.
Resolving deltas: 100% (32/32), done.
Checking connectivity... done.
$ ls
blog http_server.rb
จากนั้นใน server ให้ require Rails application's environment file แทน rack และ rack/lobster และวาง Rails.application ใน app variable แทน Rack::Lobster.new
# http_server.rb
require 'socket'
require_relative 'blog/config/environment'
app = Rails.application
server = TCPServer.new 5678
# ...
Start server (ruby http_server.rb) และเปิด https://localhost:5678 แสดงว่าเรายังไม่ถึงที่ นั่น server ไม่ crash แต่เราเจอ internal server error ใน browser
เช็ค server logs เราเห็นว่าเราขาด rack.input 原来是我们在实现 server 时偷懒了 还有更多工作要做才能让 Rails application 跑起来
$ ruby http_server.rb
GET / HTTP/1.1
Error during failsafe response: Missing rack.input
...
http_server.rb:15:in `'
The Rack Environment
ตอนเรา implement server เรา gloss over Rack environment และ ignore 大部分 variables ที่ required ให้ serve Rack applications อย่างถูกต้อง เราจบแค่ implement REQUEST_METHOD, PATH_INFO, และ QUERY_STRING variables เพราะพอสำหรับ simple Rack app
จาก exception ตอนเราลอง start application ใหม่ เราเห็นว่า Rails ต้องการ rack.input ใช้เป็น input stream สำหรับ raw HTTP POST data นอกจากนี้ยังมี variables อื่นๆ ที่ต้องส่ง เช่น server's port number และ request cookie data
โชคดีที่ Rack มี Rack::Lint ช่วยให้แน่ใจว่า variables ทั้งหมดใน Rack environment present และ valid เราใช้ test server ได้โดย wrap Rails app ในมันด้วยการ call Rack::Lint.new ส่ง Rails.application
# http_server.rb
require 'socket'
require_relative 'blog/config/environment'
app = Rack::Lint.new(Rails.application)
server = TCPServer.new 5678
# ...
Rack::Lint จะ throw exception เมื่อ variable ใน environment missing หรือ invalid เริ่ม server อีกครั้งและเปิด https://localhost:5678 จะ crash server และ Rack::Lint จะแจ้ง error แรก: SERVER_NAME variable ไม่ได้ set
~/Appsignal/http-server (master) $ ruby http_server.rb
GET / HTTP/1.1
/Users/jeff/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/rack-2.0.1/lib/rack/lint.rb:20:in `assert': env missing required key SERVER_NAME (Rack::Lint::LintError)
...
from http_server.rb:15:in `'
ด้วยการ fix แต่ละ error ที่มองมา เราเพิ่ม variables จน Rack::Lint หยุด crash server มาดูแต่ละ variable ที่ Rack::Lint ต้องการ
SERVER_NAME: server's hostname เรารัน server locally ตอนนี้ จึงใช้ "localhost"SERVER_PORT: port ที่ server รัน เรา hardcode port number (5678) จึงส่งไป Rack environmentrack.version: targeted Rack protocol version number as array of integers[1,3]เวลาเขียนrack.input: input stream containing raw HTTP post data เราจะไปถึงนี้หลัง แต่ส่ง emptyStringIOinstance (with ASCII-8BIT encoding) ตอนนี้rack.errors: error stream สำหรับRack::Loggerเขียน เราใช้$stderrrack.multithread: server single-threaded จึงตั้งfalserack.multiprocess: server รันใน single process จึงตั้งfalseด้วยrack.run_once: server handle multiple sequential requests ใน one process จึงเป็นfalserack.url_scheme: ไม่มี SSL support จึงตั้ง "http" แทน "https"
หลังเพิ่ม missing variables ทั้งหมด Rack::Lint จะแจ้งอีก one more problem ใน environment
$ ruby http_server.rb
GET / HTTP/1.1
/Users/jeffkreeftmeijer/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/rack-2.0.1/lib/rack/lint.rb:20:in `assert': env variable QUERY_STRING has non-string value nil (Rack::Lint::LintError)
...
from http_server.rb:18:in `'
เมื่อไม่มี query string ใน request เราส่ง nil เป็น QUERY_STRING ซึ่งไม่ allowed ในกรณีนี้ Rack คาดหวัง empty string แทน หลัง implement missing variables และ update query string environment ของเราเป็นแบบนี้:
# http_server.rb
# ...
method, full_path = request.split(' ')
path, query = full_path.split('?')
input = StringIO.new
input.set_encoding 'ASCII-8BIT'
status, headers, body = app.call({
'REQUEST_METHOD' => method,
'PATH_INFO' => path,
'QUERY_STRING' => query || '',
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => '5678',
'rack.version' => [1,3],
'rack.input' => input,
'rack.errors' => $stderr,
'rack.multithread' => false,
'rack.multiprocess' => false,
'rack.run_once' => false,
'rack.url_scheme' => 'http'
})
session.print "HTTP/1.1 #{status}\r\n"
# ...
Restart server และ visit https://localhost:5678 อีกครั้ง เราจะได้ Rails' "You're on Rails!"-page หมายความว่าเรารัน actual Rails application บน home made server แล้ว!
Parsing HTTP POST Bodies
Application นี้มากกว่าแค่ index page Visit https://localhost:5678/posts จะแสดง empty list of posts หากเราลอง create new post โดย fill new post form และกด "Create post" เราจะเจอ ActionController::InvalidAuthenticityToken exception
Authenticity token ส่งพร้อมเมื่อ post form และใช้ check ถ้า request มาจาก trusted source Server ของเรา ignore POST data ทั้งหมดตอนนี้ ดังนั้น token ไม่ส่ง และ request ไม่ verify ได้
ตอนเรา implement HTTP server ครั้งแรก เราใช้ session.gets ได้ first line (Request-Line) และ parse HTTP method และ path จากนั้น นอกเหนือจาก parse Request-Line เรา ignore ส่วนที่เหลือของ request
เพื่อ extract POST data เราต้องเข้าใจก่อนว่า HTTP request structured อย่างไร ดูตัวอย่าง เราเห็น structure resembles HTTP response:
POST /posts HTTP/1.1\r\n
Host: localhost:5678\r\n
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n
Accept-Encoding: gzip, deflate\r\n
Accept-Language: en-us\r\n
Content-Type: application/x-www-form-urlencoded\r\n
Origin: https://localhost:5678\r\n
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/602.2.14 (KHTML, like Gecko) Version/10.0.1 Safari/602.2.14\r\n
Cookie: _wups_session=LzE0Z2hSZFNseG5TR3dEVEwzNE52U0lFa0pmVGlQZGtZR3AveWlyMEFvUHRPeXlQUzQ4L0xlKzNLVWtqYld2cjdiWkpmclZIaEhJd1R6eDhaZThFbVBlN2p6QWpJdllHL2F4Z3VseUZ6NU1BRTU5Y1crM2lLRVY0UzdSZkpwYkt2SGFLZUQrYVFvaFE0VjZmZlIrNk5BPT0tLUpLTHQvRHQ0T3FycWV0ZFZhVHZWZkE9PQ%3D%3D--4ef4508c936004db748da10be58731049fa190ee\r\n
Connection: keep-alive\r\n
Upgrade-Insecure-Requests: 1\r\n
Referer: https://localhost:5678/posts/new\r\n
Content-Length: 369\r\n
\r\n
utf8=%E2%9C%93&authenticity_token=3fu7e8v70K0h9o%2FGNiXxaXSVg3nZ%2FuoL60nlhssUEHpQRz%2BM4ZIHjQduQMexvXrNoC2pjmhNPI4xNNA0Qkh5Lg%3D%3D&post%5Btitle%5D=My+first+post&post%5Bcreated_at%281i%29%5D=2017&post%5Bcreated_at%282i%29%5D=1&post%5Bcreated_at%283i%29%5D=23&post%5Bcreated_at%284i%29%5D=18&post%5Bcreated_at%285i%29%5D=47&post%5Bbody%5D=It+works%21&commit=Create+Post
เหมือน response HTTP request ประกอบด้วย:
- Request-Line (
POST /posts HTTP/1.1\r\n) ประกอบ method token (POST), request URI (/posts/), HTTP version (HTTP/1.1) ตามด้วย CRLF (carriage return: \r, line feed: \n) indicate end of line - Header lines (
Host: localhost:5678\r\n) Header key, ตามด้วย colon, value, และ CRLF - Newline (double CRLF) separate request line และ headers จาก body: (
\r\n\r\n) - URL encoded POST body
หลังใช้ session.gets เอา first line ของ request (Request-Line) เราเหลือ header lines และ body เพื่อได้ header lines เราต้อง retrieve lines จาก session จนพบ newline (\r\n)
สำหรับแต่ละ header line เรา split on first colon ทุกอย่างก่อน colon คือ key ทุกอย่างหลังคือ value เรา #strip value ลบ newline ท้าย
รู้ว่าเราต้อง read กี่ bytes จาก request เพื่อได้ body เราใช้ header "Content-Length" ซึ่ง browser auto include เมื่อส่ง request
# http_server.rb
# ...
headers = {}
while (line = session.gets) != "\r\n"
key, value = line.split(':', 2)
headers[key] = value.strip
end
body = session.read(headers["Content-Length"].to_i)
# ...
ตอนนี้ แทนที่จะส่ง empty object เราส่ง StringIO instance พร้อม body ที่รับมาผ่าน request เพื่อเรา parse cookies จาก request's header เราสามารถเพิ่มไป Rack environment ใน variable HTTP_COOKIE เพื่อผ่าน request authenticity check
# http_server.rb
# ...
status, headers, body = app.call({
# ...
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_COOKIE' => headers['Cookie'],
'rack.version' => [1,3],
'rack.input' => StringIO.new(body),
'rack.errors' => $stderr,
# ...
})
# ...
นั่นคือมัน Restart server และลอง submit form อีกครั้ง คุณจะเห็นว่าเราสร้าง first post บน blog สำเร็จ!
เรา upgrade web server อย่างจริงจังครั้งนี้ แทนที่จะแค่ accept GET requests จาก Rack app เราตอนนี้ serve complete Rails app ที่ handle POST requests และเรายังเขียนไม่เกิน 50 lines of code!
หากอยากเล่นกับ server ใหม่และปรับปรุงแล้ว นี่คือโค้ด บอกเราที่ @AppSignal หากอยากรู้เพิ่ม หรือมีคำถามเฉพาะ