运行 Rack:Ruby HTTP 服务器如何运行 Rails 应用
最初发表于blog.appsignal.com/2017/01/24/ruby-magic-building-a-ruby-http-server-part-2-running-a-rails-app.html,日期为 2017 年 1 月 24 日。
在 Ruby Magic 系列中,我们喜欢拆解软件,探究其底层工作原理。重点在于过程;最终成果并非用于生产环境,而是深入研究 Ruby 语言及其常用库的内部运作机制。我们每月发布一篇新文章,如果您也对此类内容感兴趣,请务必订阅我们的新闻邮件。
在之前的 Ruby Magic 中,我们用 Ruby 实现了一个 30 行代码的 HTTP 服务器。无需编写大量代码,我们就能够处理 HTTP GET 请求并运行一个简单的 Rack 应用程序。这次,我们将进一步完善这个自制服务器。完成后,我们将拥有一个能够运行 Rails 著名的“十五分钟博客”的 Web 服务器,该博客允许用户创建、更新和删除文章。
我们上次说到哪儿了?
上次,我们只实现了足够多的服务器来运行Rack::Lobster作为示例应用程序。
- 我们的实现方式是打开一个 TCP 服务器,并等待请求传入。
- 当这种情况发生时,请求行(
GET /?flip=left HTTP/1.1\r\n)被解析以获取请求方法(GET)、路径(/)和查询参数(flip=left)。 - 请求方法、路径和查询字符串被传递给 Rack 应用程序,该应用程序返回一个包含状态、一些响应标头和响应正文的三元组。
- 利用这些信息,我们能够构建一个 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
我们将继续使用上次编写的代码。如果您想跟着操作,以下是我们最终得到的代码。
货架和轨道
像 Rails 和 Sinatra 这样的 Ruby 框架都是基于 Rack 接口构建的。就像Rack::Lobster我们现在用来测试服务器的实例一样,Rails 的实例Rails.application也是一个 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)并打开http://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` <Rack_name>` PATH_INFO、`<Rack_name>` 和 `<Rack_name>`这QUERY_STRING几个变量,因为这些对于我们简单的 Rack 应用来说已经足够了。
正如我们在尝试启动新应用程序时遇到的异常所显示的那样,Rails 需要rack.input一个用于接收原始 HTTP POST 数据的输入流。除此之外,我们还需要传递一些其他变量,例如服务器端口号和请求 cookie 数据。
幸运的是,Rack 提供了Rack::Lint确保 Rack 环境中所有变量都存在且有效的机制。我们可以通过将 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当环境中缺少变量或变量无效时,会抛出异常。目前,重新启动服务器并打开http://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 环境即可。rack.version:目标 Rack协议版本号,以整数数组形式表示。[1,3]截至撰写本文时。rack.input输入流包含原始 HTTP POST 数据。我们稍后会详细介绍,但StringIO现在我们先传递一个空实例(采用 ASCII-8 位编码)。rack.errorsRack::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>` 作为参数传递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"
# ...
重启服务器并再次访问http://localhost:5678,我们将看到 Rails 的“You're on Rails!”页面,这意味着我们现在正在我们自建的服务器上运行一个真正的 Rails 应用程序!
解析 HTTP POST 请求体
这个应用程序不仅仅是首页那么简单。访问http://localhost:5678/posts会显示一个空的帖子列表。如果我们尝试通过填写新帖子表单并点击“创建帖子”来创建新帖子,则会抛出一个ActionController::InvalidAuthenticityToken异常。
提交表单时会发送身份验证令牌,用于检查请求是否来自可信来源。目前我们的服务器完全忽略 POST 数据,因此不会发送令牌,请求也无法验证。
在我们最初实现HTTP服务器时,我们通常session.gets会获取请求的第一行(称为请求行),并从中解析HTTP方法和路径。除了解析请求行之外,我们忽略了请求的其余部分。
为了提取 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: http://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: http://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) - URL 编码的 POST 请求体
使用 `get session.gets()` 获取请求的第一行(请求行)后,剩下的是一些头部信息和请求体。为了获取头部信息,我们需要从会话中逐行检索,直到找到换行符(`\n` \r\n)。
对于每一行标题,我们将以第一个冒号为分割点。冒号之前的所有内容是键,冒号之后的所有内容是值。我们将#strip值末尾的换行符移除。
为了知道需要从请求中读取多少字节才能获取请求体,我们使用“Content-Length”标头,浏览器在发送请求时会自动包含该标头。
# 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包含请求正文的实例。此外,由于我们现在可以从请求头中解析 cookie,因此可以将它们添加到 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,
# ...
})
# ...
好了。如果我们重启服务器并再次尝试提交表单,您就会看到我们已经成功创建了博客上的第一篇文章!
这次我们对 Web 服务器进行了彻底的升级。以前我们只接收来自 Rack 应用的 GET 请求,现在我们运行的是一个完整的 Rails 应用,可以处理 POST 请求。而且我们总共写的代码还不到五十行!
如果您想体验我们全新升级的服务器,这里是代码。如果您想了解更多信息或有任何疑问,请随时联系我们!
文章来源:https://dev.to/appsignal/running-rack-how-ruby-http-servers-run-rails-apps-5bi5


