Trong loạt bài về Ruby Magic, chúng tôi muốn tách phần mềm ra để tìm hiểu cách hoạt động của nó. Đó là tất cả về quy trình; kết quả cuối cùng không phải là thứ bạn sẽ sử dụng trong sản xuất, chúng tôi tìm hiểu về hoạt động bên trong của ngôn ngữ Ruby và các thư viện phổ biến của nó. Chúng tôi xuất bản một bài báo mới khoảng mỗi tháng một lần, vì vậy hãy đảm bảo đăng ký nhận bản tin của chúng tôi nếu bạn cũng quan tâm đến vấn đề này.
Trong phiên bản trước của Ruby Magic, chúng tôi đã triển khai một máy chủ HTTP 30 dòng trong Ruby. Không cần phải viết nhiều mã, chúng tôi có thể xử lý các yêu cầu HTTP GET và phục vụ một ứng dụng Rack đơn giản. Lần này, chúng tôi sẽ đưa máy chủ nhà sản xuất của mình đi xa hơn một chút. Khi chúng tôi hoàn tất, chúng tôi sẽ có một máy chủ web có thể phục vụ blog mười lăm phút nổi tiếng của Rails, cho phép bạn tạo, cập nhật và xóa các bài đăng.
Nơi chúng ta đã dừng lại
Lần trước, chúng tôi đã triển khai vừa đủ một máy chủ để nó phục vụ Rack ::Lobster làm ứng dụng mẫu.
- Việc triển khai của chúng tôi đã mở một máy chủ TCP và chờ một yêu cầu đến.
- Khi điều đó xảy ra, dòng yêu cầu (
GET /?flip=left HTTP/1.1\r\n
) đã được phân tích cú pháp để lấy phương thức yêu cầu (GET
), đường dẫn (/
) và các tham số truy vấn (flip=left
). - Phương thức yêu cầu, đường dẫn và chuỗi truy vấn đã được chuyển đến ứng dụng Rack, ứng dụng này trả về một bộ ba với trạng thái, một số tiêu đề phản hồi và nội dung phản hồi.
- Sử dụng những thứ đó, chúng tôi có thể tạo một phản hồi HTTP để gửi lại trình duyệt, trước khi đóng kết nối để chờ một yêu cầu mới đến.
# 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
Chúng tôi sẽ tiếp tục với mã mà chúng tôi đã viết lần trước. Nếu bạn muốn làm theo, đây là mã chúng tôi đã kết thúc.
Rack and Rails
Các khung công tác Ruby như Rails và Sinatra được xây dựng trên giao diện Rack. Cũng giống như trường hợp của Rack::Lobster
chúng tôi đang sử dụng để kiểm tra máy chủ của mình ngay bây giờ, Rails 'Rails.application
là một đối tượng ứng dụng Rack. Về lý thuyết, điều này có nghĩa là máy chủ của chúng tôi đã có thể phục vụ ứng dụng Rails.
Để kiểm tra điều đó, tôi đã chuẩn bị một ứng dụng Rails đơn giản. Hãy sao chép nó vào cùng một thư mục với máy chủ của chúng tôi.
$ 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
Sau đó, trong máy chủ của chúng tôi, yêu cầu tệp môi trường của ứng dụng Rails thay vì rack
và rack/lobster
và đặt Rails.application
trong ứng dụng app
thay vì Rack::Lobster.new
.
# http_server.rb
require 'socket'
require_relative 'blog/config/environment'
app = Rails.application
server = TCPServer.new 5678
# ...
Khởi động máy chủ (ruby http_server.rb
) và mở https:// localhost:5678 cho chúng ta thấy rằng chúng ta vẫn chưa hoàn thành. Máy chủ không gặp sự cố, nhưng chúng tôi được chào đón với lỗi máy chủ nội bộ trong trình duyệt.
Kiểm tra nhật ký máy chủ của chúng tôi, chúng tôi có thể thấy rằng chúng tôi đang thiếu một thứ gọi là rack.input
. Hóa ra lần trước chúng ta đã lười biếng khi triển khai máy chủ của mình, vì vậy, còn nhiều việc phải làm trước khi chúng ta có thể làm cho ứng dụng Rails này hoạt động.
$ ruby http_server.rb
GET / HTTP/1.1
Error during failsafe response: Missing rack.input
...
http_server.rb:15:in `<main>'
Môi trường Rack
Quay lại khi chúng tôi triển khai máy chủ của mình, chúng tôi đã đánh bóng môi trường Rack và bỏ qua hầu hết các biến được yêu cầu để phục vụ đúng các ứng dụng Rack. Chúng tôi đã kết thúc chỉ triển khai REQUEST_METHOD
, PATH_INFO
và QUERY_STRING
, vì những biến đó đã đủ cho ứng dụng Rack đơn giản của chúng tôi.
Như chúng ta đã thấy từ ngoại lệ khi chúng tôi cố gắng khởi động ứng dụng mới của mình, Rails cần có rack.input
, được sử dụng làm luồng đầu vào cho dữ liệu HTTP POST thô. Bên cạnh đó, có một số biến khác mà chúng tôi cần chuyển, như số cổng của máy chủ và dữ liệu cookie yêu cầu.
May mắn thay, Rack cung cấp Rack::Lint
để giúp đảm bảo tất cả các biến trong môi trường Rack đều có mặt và hợp lệ. Chúng tôi có thể sử dụng nó để kiểm tra máy chủ của mình bằng cách gói ứng dụng Rails của chúng tôi trong đó bằng cách gọi Rack::Lint.new
và chuyển Rails.application
.
# http_server.rb
require 'socket'
require_relative 'blog/config/environment'
app = Rack::Lint.new(Rails.application)
server = TCPServer.new 5678
# ...
Rack::Lint
sẽ ném ra một ngoại lệ khi một biến trong môi trường bị thiếu hoặc không hợp lệ. Ngay bây giờ, khởi động lại máy chủ của chúng tôi và mở https:// localhost:5678 sẽ làm hỏng máy chủ và Rack::Lint
sẽ thông báo cho chúng tôi về lỗi đầu tiên:SERVER_NAME
biến chưa được đặt.
~/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>'
Bằng cách sửa từng lỗi xảy ra với chúng tôi, chúng tôi có thể tiếp tục thêm các biến cho đến khi Rack::Lint
ngừng sự cố máy chủ của chúng tôi. Hãy xem qua từng biến Rack::Lint
yêu cầu.
-
SERVER_NAME
:tên máy chủ của máy chủ. Hiện chúng tôi chỉ chạy cục bộ máy chủ này, vì vậy chúng tôi sẽ sử dụng "localhost". -
SERVER_PORT
:cổng mà máy chủ của chúng tôi đang chạy. Chúng tôi đã mã hóa số cổng (5678), vì vậy chúng tôi sẽ chỉ chuyển số đó vào môi trường Rack. -
rack.version
:giao thức Rack được nhắm mục tiêu số phiên bản dưới dạng một mảng số nguyên.[1,3]
tại thời điểm viết bài. -
rack.input
:luồng đầu vào chứa dữ liệu bài đăng HTTP thô. Chúng ta sẽ làm điều này sau, nhưng chúng ta sẽ chuyển mộtStringIO
trống hiện tại (với mã hóa ASCII-8BIT). -
rack.errors
:dòng lỗi choRack::Logger
để viết thư cho. Chúng tôi đang sử dụng$stderr
. -
rack.multithread
:máy chủ của chúng tôi là một luồng, do đó, máy chủ này có thể được đặt thànhfalse
. -
rack.multiprocess
:máy chủ của chúng tôi đang chạy trong một quy trình duy nhất, do đó, điều này có thể được đặt thànhfalse
nữa. -
rack.run_once
:máy chủ của chúng tôi có thể xử lý nhiều yêu cầu tuần tự trong một quá trình, vì vậy đây làfalse
quá. -
rack.url_scheme
:không hỗ trợ SSL, vì vậy bạn có thể đặt cài đặt này thành "http" thay vì "https".
Sau khi thêm tất cả các biến còn thiếu, Rack::Lint
sẽ thông báo cho chúng tôi về một vấn đề nữa trong môi trường của chúng tôi.
$ 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>'
Khi không có chuỗi truy vấn trong yêu cầu, bây giờ chúng ta sẽ chuyển nil
dưới dạng QUERY_STRING
, điều này không được phép. Trong trường hợp đó, Rack mong đợi một chuỗi trống thay thế. Sau khi triển khai các biến còn thiếu và cập nhật chuỗi truy vấn, đây là môi trường của chúng ta trông như thế nào:
# 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"
# ...
Khởi động lại máy chủ và truy cập lại vào https:// localhost:5678, chúng ta sẽ được chào đón với trang "You're on Rails!" - của Rails, nghĩa là hiện chúng ta đang chạy một ứng dụng Rails thực tế trên máy chủ do chúng ta tạo ra!
Phân tích cú pháp nội dung HTTP POST
Ứng dụng này không chỉ là trang chỉ mục. Truy cập https:// localhost:5678 / posts sẽ hiển thị danh sách bài viết trống. Nếu chúng tôi cố gắng tạo một bài đăng mới bằng cách điền vào biểu mẫu bài đăng mới và nhấn "Tạo bài đăng", chúng tôi sẽ được chào đón bởi một ActionController::InvalidAuthenticityToken
ngoại lệ.
Mã thông báo xác thực được gửi cùng khi đăng biểu mẫu và được sử dụng để kiểm tra xem yêu cầu có đến từ một nguồn đáng tin cậy hay không. Máy chủ của chúng tôi đang hoàn toàn bỏ qua dữ liệu POST ngay bây giờ, vì vậy mã thông báo không được gửi và không thể xác minh yêu cầu.
Quay lại khi lần đầu tiên chúng tôi triển khai máy chủ HTTP của mình, chúng tôi đã sử dụng session.gets
để lấy dòng đầu tiên (được gọi là Dòng yêu cầu) và phân tích cú pháp phương thức và đường dẫn HTTP từ đó. Bên cạnh việc phân tích cú pháp Dòng yêu cầu, chúng tôi đã bỏ qua phần còn lại của yêu cầu.
Để có thể trích xuất dữ liệu POST, trước tiên chúng ta cần hiểu cách cấu trúc một yêu cầu HTTP. Nhìn vào một ví dụ, chúng ta có thể thấy rằng cấu trúc giống như một phản hồi 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
Giống như một phản hồi, một yêu cầu HTTP bao gồm:
- Dòng yêu cầu (
POST /posts HTTP/1.1\r\n
), bao gồm một mã thông báo phương thức (POST
), một URI yêu cầu (/posts/
) và phiên bản HTTP (HTTP/1.1
), theo sau là CRLF (ký tự xuống dòng:\ r, theo sau là nguồn cấp dòng:\ n) để biểu thị cuối dòng - Dòng tiêu đề (
Host: localhost:5678\r\n
). Khóa tiêu đề, theo sau là dấu hai chấm, sau đó đến giá trị và CRLF. - Một dòng mới (hoặc một CRLF kép) để tách dòng yêu cầu và tiêu đề khỏi phần nội dung:(
\r\n\r\n
) - Nội dung POST được mã hóa URL
Sau khi sử dụng session.gets
để lấy dòng đầu tiên của yêu cầu (Dòng Yêu cầu), chúng ta còn lại một số dòng tiêu đề và phần nội dung. Để lấy các dòng tiêu đề, chúng ta cần truy xuất các dòng từ phiên cho đến khi chúng ta tìm thấy một dòng mới (\r\n
).
Đối với mỗi dòng tiêu đề, chúng tôi sẽ phân chia trên dấu hai chấm đầu tiên. Mọi thứ trước dấu hai chấm là khóa và mọi thứ sau dấu hai chấm là giá trị. Chúng tôi #strip
giá trị để xóa dòng mới ở cuối.
Để biết chúng tôi cần đọc bao nhiêu byte từ yêu cầu để lấy nội dung, chúng tôi sử dụng tiêu đề "Độ dài nội dung", mà trình duyệt tự động đưa vào khi gửi yêu cầu.
# 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)
# ...
Bây giờ, thay vì gửi một đối tượng trống, chúng tôi sẽ gửi một StringIO
ví dụ với phần thân mà chúng tôi đã nhận được qua yêu cầu. Ngoài ra, vì chúng tôi hiện đang phân tích cú pháp cookie từ tiêu đề của yêu cầu, chúng tôi có thể thêm chúng vào môi trường Rack trong HTTP_COOKIE
biến để vượt qua kiểm tra tính xác thực của yêu cầu.
# 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,
# ...
})
# ...
Chúng ta bắt đầu. Nếu chúng tôi khởi động lại máy chủ và thử gửi lại biểu mẫu, bạn sẽ thấy rằng chúng tôi đã tạo thành công bài đăng đầu tiên trên blog của mình!
Chúng tôi đã nghiêm túc nâng cấp máy chủ web của mình lần này. Thay vì chỉ chấp nhận các yêu cầu GET từ ứng dụng Rack, chúng tôi hiện đang cung cấp một ứng dụng Rails hoàn chỉnh xử lý các yêu cầu POST. Và chúng tôi vẫn chưa viết tổng cộng hơn 50 dòng mã!
Nếu bạn muốn thử với máy chủ mới và cải tiến của chúng tôi, đây là mã. Hãy cho chúng tôi biết tại @AppSignal nếu bạn muốn biết thêm hoặc có câu hỏi cụ thể.