ในซีรีส์ Ruby Magic เราชอบที่จะแยกซอฟต์แวร์ออกจากกันเพื่อเรียนรู้วิธีทำงานภายใต้ประทุน มันคือทั้งหมดที่เกี่ยวกับกระบวนการ ผลลัพธ์สุดท้ายไม่ใช่สิ่งที่คุณจะใช้ในการผลิต เราเรียนรู้เกี่ยวกับการทำงานภายในของภาษา Ruby และไลบรารียอดนิยม เราเผยแพร่บทความใหม่ประมาณเดือนละครั้ง ดังนั้นอย่าลืมสมัครรับจดหมายข่าวของเราหากคุณชอบเรื่องแบบนี้ด้วย
ใน Ruby Magic รุ่นก่อนหน้า เราได้ติดตั้งเซิร์ฟเวอร์ HTTP 30 บรรทัดใน Ruby โดยไม่ต้องเขียนโค้ดจำนวนมาก เราสามารถจัดการคำขอ HTTP GET และให้บริการแอปพลิเคชัน Rack อย่างง่ายได้ คราวนี้ เราจะพาเซิร์ฟเวอร์ทำเองของเราไปอีกหน่อย เมื่อเสร็จแล้ว เราจะมีเว็บเซิร์ฟเวอร์ที่สามารถให้บริการบล็อกที่มีชื่อเสียงของ Rails เป็นเวลา 15 นาที ซึ่งช่วยให้คุณสร้าง อัปเดต และลบโพสต์ได้
จุดที่เราค้างไว้
ครั้งที่แล้ว เราได้ติดตั้งเซิร์ฟเวอร์ให้เพียงพอสำหรับให้บริการ Rack::Lobster เป็นตัวอย่างแอปพลิเคชัน
- การใช้งานของเราได้เปิดเซิร์ฟเวอร์ TCP และรอคำขอเข้ามา
- เมื่อสิ่งนั้นเกิดขึ้น บรรทัดคำขอ (
GET /?flip=left HTTP/1.1\r\n
) ถูกแยกวิเคราะห์เพื่อรับวิธีการร้องขอ (GET
) เส้นทาง (/
) และพารามิเตอร์การค้นหา (flip=left
) - วิธีการขอ พาธ และสตริงการสืบค้นถูกส่งไปยังแอป Rack ซึ่งส่งคืน Triplet พร้อมสถานะ ส่วนหัวการตอบกลับบางส่วน และเนื้อหาการตอบกลับ
- เมื่อใช้สิ่งเหล่านี้ เราสามารถสร้างการตอบสนอง HTTP เพื่อส่งกลับไปยังเบราว์เซอร์ ก่อนที่จะปิดการเชื่อมต่อเพื่อรอคำขอใหม่เข้ามา
# 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
เราจะดำเนินการต่อด้วยรหัสที่เราเขียนครั้งล่าสุด หากคุณต้องการปฏิบัติตาม นี่คือรหัสที่เราได้ลงท้ายด้วย
ชั้นวางและราง
เฟรมเวิร์กของ Ruby เช่น Rails และ Sinatra นั้นสร้างขึ้นบนอินเทอร์เฟซของ Rack เช่นเดียวกับตัวอย่าง Rack::Lobster
เรากำลังทดสอบเซิร์ฟเวอร์ของเราตอนนี้ Rails.application
. ของ Rails เป็นวัตถุแอปพลิเคชัน Rack ตามทฤษฎีแล้ว นี่หมายความว่าเซิร์ฟเวอร์ของเราควรจะสามารถให้บริการแอปพลิเคชัน Rails ได้แล้ว
เพื่อทดสอบว่า ฉันได้เตรียมแอปพลิเคชัน Rails อย่างง่าย มาโคลนมันในไดเร็กทอรีเดียวกันกับเซิร์ฟเวอร์ของเรากันเถอะ
$ 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
จากนั้นในเซิร์ฟเวอร์ของเรา ต้องใช้ไฟล์สภาพแวดล้อมของแอปพลิเคชัน Rails แทน rack
และ rack/lobster
และใส่ Rails.application
ใน app
ตัวแปรแทน Rack::Lobster.new
.
# http_server.rb
require 'socket'
require_relative 'blog/config/environment'
app = Rails.application
server = TCPServer.new 5678
# ...
การเริ่มต้นเซิร์ฟเวอร์ (ruby http_server.rb
) และการเปิด https://localhost:5678 แสดงให้เราเห็นว่าเรายังไปไม่ถึงจุดนั้น เซิร์ฟเวอร์ไม่ขัดข้อง แต่เราได้รับการต้อนรับด้วยข้อผิดพลาดเซิร์ฟเวอร์ภายในในเบราว์เซอร์
เมื่อตรวจสอบบันทึกของเซิร์ฟเวอร์ เราจะพบว่ามีบางสิ่งที่เรียกว่า rack.input
ขาดหายไป . ปรากฎว่าเราขี้เกียจในขณะที่ติดตั้งเซิร์ฟเวอร์ของเราครั้งล่าสุด ดังนั้นจึงยังมีงานอีกมากที่ต้องทำก่อนที่เราจะสามารถให้แอปพลิเคชัน Rails นี้ทำงานได้
$ ruby http_server.rb
GET / HTTP/1.1
Error during failsafe response: Missing rack.input
...
http_server.rb:15:in `<main>'
สภาพแวดล้อมของแร็ค
ย้อนกลับไปเมื่อเราใช้งานเซิร์ฟเวอร์ของเรา เราได้มองข้ามสภาพแวดล้อมของ Rack และละเว้นตัวแปรส่วนใหญ่ที่จำเป็นในการให้บริการแอปพลิเคชัน Rack อย่างเหมาะสม เราลงเอยด้วยการใช้ REQUEST_METHOD
. เท่านั้น , PATH_INFO
และ QUERY_STRING
ตัวแปร เนื่องจากสิ่งเหล่านี้เพียงพอสำหรับแอป Rack แบบง่ายของเรา
ดังที่เราได้เห็นจากข้อยกเว้นเมื่อเราพยายามเริ่มแอปพลิเคชันใหม่ Rails ต้องการ rack.input
ซึ่งใช้เป็นสตรีมอินพุตสำหรับข้อมูล HTTP POST ดิบ นอกจากนั้น ยังมีตัวแปรอื่นๆ ที่เราต้องส่งผ่าน เช่น หมายเลขพอร์ตของเซิร์ฟเวอร์ และข้อมูลคำขอคุกกี้
โชคดีที่ Rack มี Rack::Lint
เพื่อช่วยให้แน่ใจว่าตัวแปรทั้งหมดในสภาพแวดล้อมแร็คมีอยู่และถูกต้อง เราสามารถใช้มันเพื่อทดสอบเซิร์ฟเวอร์ของเราโดยใส่แอป Rails เข้าไปโดยเรียก 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
จะโยนข้อยกเว้นเมื่อตัวแปรในสภาพแวดล้อมหายไปหรือไม่ถูกต้อง ตอนนี้ การเริ่มเซิร์ฟเวอร์ของเราอีกครั้งและเปิด https://localhost:5678 จะทำให้เซิร์ฟเวอร์ขัดข้องและ Rack::Lint
จะแจ้งให้เราทราบถึงข้อผิดพลาดแรก:SERVER_NAME
ไม่ได้ตั้งค่าตัวแปร
~/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 `<main>'
โดยการแก้ไขข้อผิดพลาดที่เราส่งเข้ามา เราสามารถเพิ่มตัวแปรได้เรื่อยๆ จนถึง Rack::Lint
หยุดหยุดเซิร์ฟเวอร์ของเรา มาดูตัวแปรแต่ละตัวกัน Rack::Lint
จำเป็น
SERVER_NAME
:ชื่อโฮสต์ของเซิร์ฟเวอร์ เรากำลังเรียกใช้เซิร์ฟเวอร์นี้ในเครื่องเท่านั้น ดังนั้นเราจะใช้ "localhost"SERVER_PORT
:พอร์ตที่เซิร์ฟเวอร์ของเราทำงานอยู่ เราได้ฮาร์ดโค้ดหมายเลขพอร์ตแล้ว (5678) ดังนั้นเราจะส่งต่อไปยังสภาพแวดล้อมของแร็คrack.version
:แร็คเป้าหมาย โปรโตคอล หมายเลขเวอร์ชันเป็นอาร์เรย์ของจำนวนเต็ม[1,3]
ในขณะที่เขียนrack.input
:อินพุตสตรีมที่มีข้อมูลโพสต์ HTTP ดิบ เราจะพูดถึงเรื่องนี้ในภายหลัง แต่เราจะส่งStringIO
ที่ว่างเปล่า อินสแตนซ์ (ด้วยการเข้ารหัส ASCII-8BIT) สำหรับตอนนี้rack.errors
:สตรีมข้อผิดพลาดสำหรับRack::Logger
ที่จะเขียนถึง เรากำลังใช้$stderr
.rack.multithread
:เซิร์ฟเวอร์ของเราเป็นแบบเธรดเดียว จึงสามารถตั้งค่าเป็นfalse
.rack.multiprocess
:เซิร์ฟเวอร์ของเรากำลังทำงานอยู่ในกระบวนการเดียว จึงสามารถตั้งค่าเป็นfalse
เช่นกันrack.run_once
:เซิร์ฟเวอร์ของเราสามารถจัดการคำขอตามลำดับได้หลายรายการในกระบวนการเดียว ดังนั้นนี่คือfalse
ด้วยrack.url_scheme
:ไม่รองรับ SSL จึงสามารถตั้งค่าเป็น "http" แทน "https" ได้
หลังจากเพิ่มตัวแปรที่หายไปทั้งหมดแล้ว Rack::Lint
จะแจ้งให้เราทราบถึงปัญหาอื่นในสภาพแวดล้อมของเรา
$ 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 variable QUERY_STRING has non-string value nil (Rack::Lint::LintError)
...
from http_server.rb:18:in `<main>'
เมื่อไม่มีสตริงการสืบค้นในคำขอ เราจะส่ง nil
เป็น QUERY_STRING
ซึ่งไม่ได้รับอนุญาต ในกรณีนั้น Rack ต้องการสตริงว่างแทน หลังจากใช้ตัวแปรที่หายไปและอัปเดตสตริงการสืบค้น สภาพแวดล้อมของเราจะเป็นดังนี้:
# 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"
# ...
เมื่อรีสตาร์ทเซิร์ฟเวอร์และไปที่ https://localhost:5678 อีกครั้ง เราจะพบกับหน้า "You're on Rails!" ของ Rails ซึ่งหมายความว่าตอนนี้เรากำลังเรียกใช้แอปพลิเคชัน Rails จริงบนเซิร์ฟเวอร์ทำเองของเรา!
การแยกวิเคราะห์เนื้อหา HTTP POST
แอปพลิเคชันนี้เป็นมากกว่าหน้าดัชนีนั้น การเยี่ยมชม https://localhost:5678/posts จะแสดงรายการโพสต์ว่าง หากเราพยายามสร้างโพสต์ใหม่โดยกรอกแบบฟอร์มโพสต์ใหม่และกด "สร้างโพสต์" คุณจะได้รับการต้อนรับด้วย ActionController::InvalidAuthenticityToken
ข้อยกเว้น
โทเค็นความถูกต้องจะถูกส่งไปพร้อมกับการโพสต์แบบฟอร์มและใช้เพื่อตรวจสอบว่าคำขอมาจากแหล่งที่เชื่อถือได้หรือไม่ เซิร์ฟเวอร์ของเราเพิกเฉยต่อข้อมูล POST โดยสิ้นเชิงในขณะนี้ ดังนั้นจึงไม่ส่งโทเค็น และไม่สามารถตรวจสอบคำขอได้
ย้อนกลับไปเมื่อเราติดตั้งเซิร์ฟเวอร์ HTTP ครั้งแรก เราใช้ session.gets
เพื่อรับบรรทัดแรก (เรียกว่า Request-Line) และแยกวิเคราะห์เมธอด HTTP และพาธจากนั้น นอกจากการแยกวิเคราะห์ Request-Line แล้ว เรายังเพิกเฉยต่อคำขอที่เหลือ
เพื่อให้สามารถดึงข้อมูล POST ได้ ก่อนอื่นเราต้องเข้าใจว่าคำขอ HTTP มีโครงสร้างอย่างไร เมื่อดูตัวอย่าง เราจะเห็นว่าโครงสร้างคล้ายกับการตอบสนองของ HTTP:
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
เหมือนกับการตอบกลับ คำขอ HTTP ประกอบด้วย:
- บรรทัดคำขอ (
POST /posts HTTP/1.1\r\n
) ประกอบด้วยโทเค็นวิธีการ (POST
) URI คำขอ (/posts/
) และเวอร์ชัน HTTP (HTTP/1.1
) ตามด้วย CRLF (การขึ้นบรรทัดใหม่:\r ตามด้วยการป้อนบรรทัด:\n) เพื่อระบุจุดสิ้นสุดของบรรทัด - บรรทัดส่วนหัว (
Host: localhost:5678\r\n
). คีย์ส่วนหัว ตามด้วยโคลอน ตามด้วยค่า และ CRLF - ขึ้นบรรทัดใหม่ (หรือ CRLF คู่) เพื่อแยกบรรทัดคำขอและส่วนหัวออกจากเนื้อหา:(
\r\n\r\n
) - เนื้อหา POST ที่เข้ารหัส URL
หลังจากใช้ session.gets
เพื่อรับบรรทัดแรกของคำขอ (บรรทัดคำขอ) เราเหลือบรรทัดส่วนหัวและเนื้อหาบางส่วน ในการรับบรรทัดส่วนหัว เราต้องดึงบรรทัดจากเซสชันจนกระทั่งพบบรรทัดใหม่ (\r\n
)
สำหรับแต่ละบรรทัดส่วนหัว เราจะแยกเครื่องหมายทวิภาคแรก ทุกอย่างที่อยู่ข้างหน้าเครื่องหมายทวิภาคคือกุญแจ และทุกอย่างที่อยู่ข้างหลังคือค่า เรา #strip
ค่าที่จะลบขึ้นบรรทัดใหม่จากจุดสิ้นสุด
หากต้องการทราบจำนวนไบต์ที่เราต้องอ่านจากคำขอเพื่อรับเนื้อหา เราใช้ส่วนหัว "ความยาวเนื้อหา" ซึ่งเบราว์เซอร์จะรวมไว้โดยอัตโนมัติเมื่อส่งคำขอ
# 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)
# ...
ตอนนี้ แทนที่จะส่งวัตถุเปล่า เราจะส่ง StringIO
ตัวอย่างกับร่างกายที่เราได้รับผ่านคำขอ นอกจากนี้ เนื่องจากขณะนี้เรากำลังแยกวิเคราะห์คุกกี้จากส่วนหัวของคำขอ เราจึงสามารถเพิ่มคุกกี้เหล่านี้ไปยังสภาพแวดล้อม Rack ใน HTTP_COOKIE
ตัวแปรที่จะผ่านการตรวจสอบความถูกต้องของคำขอ
# 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,
# ...
})
# ...
เราจะไปที่นั่น. หากเรารีสตาร์ทเซิร์ฟเวอร์และพยายามส่งแบบฟอร์มอีกครั้ง คุณจะเห็นว่าเราสร้างโพสต์แรกบนบล็อกของเราสำเร็จแล้ว!
เราอัปเกรดเว็บเซิร์ฟเวอร์ของเราอย่างจริงจังในครั้งนี้ แทนที่จะรับแค่คำขอ GET จากแอป Rack ตอนนี้เราให้บริการแอป Rails แบบสมบูรณ์ที่จัดการคำขอ POST และเรายังไม่ได้เขียนโค้ดเกินห้าสิบบรรทัด!
หากคุณต้องการลองใช้เซิร์ฟเวอร์ใหม่ที่ได้รับการปรับปรุงของเรา นี่คือรหัส แจ้งให้เราทราบที่ @AppSignal หากคุณต้องการทราบข้อมูลเพิ่มเติมหรือมีคำถามเฉพาะ