RubyでHTTPサーバーをゼロから手作りする(翻訳)
何かを始めるときはとりあえず動かしてみることが大事ですが、プログラミングをレベルアップするには、それまで慣れ親しんできた抽象概念より数段下の裏舞台を知っておくことも肝心です。
Web開発ではHTTPのしくみを理解しておくことが重要です。そのためにはHTTPサーバーを自作するのが一番です。
そもそもHTTPとは
HTTPはTCP上で実装されたプレーンテキストのプロトコルなので、リクエストの内容を調べるのも簡単です(HTTP/2は実際にはプレーンテキストではなく、効率化のためバイナリになっています)。リクエストの構造を見る方法のひとつは、以下のようにcurl
コマンド に -v
(verbose)フラグを付けて実行することです。
curl http://example.com/something -H "x-some-header: value" -v
以下のようなリクエストが出力されます。
GET /something HTTP/1.1
Host: example.com
User-Agent: curl/7.64.1
Accept: */*
x-some-header: value
このときのレスポンスは以下のようになります。
HTTP/1.1 404 Not Found
Age: 442736
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Sat, 03 Jul 2021 15:02:03 GMT
Expires: Sat, 10 Jul 2021 15:02:03 GMT
...
Content-Length: 1256
<!doctype html>
<html>
<head>
...
実装の計画
実装で必要な手順を定義してみましょう。
- 受信するTCPコネクションをローカルソケットでリッスンする
- 受信したリクエストのデータ(テキスト)を読む
- リクエストのテキストを解析して「HTTPメソッド」「パス」「クエリ」「ヘッダ」「本文」を抽出する
- リクエストをアプリケーションに送信し、レスポンスを取得する
- コネクション経由でリモートソケットにレスポンスを送信する
- コネクションを閉じる
以上を踏まえて、プログラムの大まかな構成を考えてみましょう。
require 'socket'
class SingleThreadedServer
PORT = ENV.fetch('PORT', 3000)
HOST = ENV.fetch('HOST', '127.0.0.1').freeze
# バッファに保存する受信コネクション数
SOCKET_READ_BACKLOG = ENV.fetch('TCP_BACKLOG', 12).to_i
attr_accessor :app
# app: Rackアプリ
def initialize(app)
self.app = app
end
def start
socket = listen_on_socket
loop do # 新しいコネクションを継続的にリッスンする
conn, _addr_info = socket.accept
request = RequestParser.call(conn)
status, headers, body = app.call(request)
HttpResponder.call(conn, status, headers, body)
rescue => e
puts e.message
ensure # コネクションを常にクローズする
conn&.close
end
end
end
SingleThreadedServer.new(SomeRackApp.new).start
ソケットをリッスンする
listen_on_socket
の「完全な」実装は以下のような感じになります。
def listen_on_socket
Socket.new(:INET, :STREAM)
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
socket.bind(Addrinfo.tcp(HOST, PORT))
socket.listen(SOCKET_READ_BACKLOG)
end
ただし、これらについては定番の書き方が豊富に存在するので、以下のように書き換えられます。
def listen_on_socket
socket = TCPServer.new(HOST, PORT)
socket.listen(SOCKET_READ_BACKLOG)
end
リクエストを解析する
作業を始める前に、最終的な結果がどうあるべきかを定義しましょう。サーバーはRack互換にしたいと思います。以下は私が見つけた、Rackが環境にリクエストの一部として期待するパラメータの例です。
{"GATEWAY_INTERFACE"=>"CGI/1.1", "PATH_INFO"=>"/", "QUERY_STRING"=>"", "REMOTE_ADDR"=>"127.0.0.1", "REMOTE_HOST"=>"localhost", "REQUEST_METHOD"=>"GET", "REQUEST_URI"=>"http://localhost:9292/", "SCRIPT_NAME"=>"", "SERVER_NAME"=>"localhost", "SERVER_PORT"=>"9292", "SERVER_PROTOCOL"=>"HTTP/1.1", "SERVER_SOFTWARE"=>"WEBrick/1.3.1 (Ruby/2.2.1/2015-02-26)", "HTTP_HOST"=>"localhost:9292", "HTTP_ACCEPT_LANGUAGE"=>"en-US,en;q=0.8,de;q=0.6", "HTTP_CACHE_CONTROL"=>"max-age=0", "HTTP_ACCEPT_ENCODING"=>"gzip", "HTTP_ACCEPT"=>"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,/;q=0.8", "HTTP_USER_AGENT"=>"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36", "rack.version"=>[1, 3], "rack.url_scheme"=>"http", "HTTP_VERSION"=>"HTTP/1.1", "REQUEST_PATH"=>"/"}
これらのパラメータをすべて返すつもりはありませんが、少なくとも重要なパラメータは返しましょう。
最初に必要なのはリクエスト行の解析(parse)です。この構造にはおそらく見覚えがあるでしょう。
MAX_URI_LENGTH = 2083 # HTTP標準に準拠
def read_request_line(conn)
# 例: "POST /some-path?query HTTP/1.1"
# 改行に達するまで読み取る、最大長はMAX_URI_LENGTHを指定
request_line = conn.gets("\n", MAX_URI_LENGTH)
method, full_path, _http_version = request_line.strip.split(' ', 3)
path, query = full_path.split('?', 2)
[method, full_path, path, query]
end
リクエスト行の次にはヘッダーが来ます。
ヘッダーがどのようなものかを思い出しましょう。以下のようにヘッダーごとに改行をはさみます。
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Content-Length: 1256
MAX_HEADER_LENGTH = (112 * 1024) # WebrickやPumaなどのサーバーではこう定義する
def read_headers(conn)
headers = {}
loop do
line = conn.gets("\n", MAX_HEADER_LENGTH)&.strip
break if line.nil? || line.strip.empty?
# ヘッダー名と値はコロンとスペースで区切られる
key, value = line.split(/:\s/, 2)
headers[key] = value
end
headers
end
これで以下を得られます。
{
"Cache-Control" => "max-age=604800"
"Content-Type" => "text/html; charset=UTF-8"
"Content-Length" => "1256"
}
次は本文(body)の読み取りです。本文はすべてのリクエストにあるとは限らず、POSTとPUTにのみあることが期待されています。
def read_body(conn:, method:, headers:)
return nil unless ['POST', 'PUT'].include?(method)
remaining_size = headers['content-length'].to_i
conn.read(remaining_size)
end
これでブロックがひととおり揃ったので、シンプルな実装が完成しました。
class RequestParser
class << self
def call(conn)
method, full_path, path, query = read_request_line(conn)
headers = read_headers(conn)
body = read_body(conn: conn, method: method, headers: headers)
# リモート接続に関する情報を読み取る
peeraddr = conn.peeraddr
remote_host = peeraddr[2]
remote_address = peeraddr[3]
# 利用するポート
port = conn.addr[1]
{
'REQUEST_METHOD' => method,
'PATH_INFO' => path,
'QUERY_STRING' => query,
# rack.inputはIOストリームである必要がある
"rack.input" => body ? StringIO.new(body) : nil,
"REMOTE_ADDR" => remote_address,
"REMOTE_HOST" => remote_host,
"REQUEST_URI" => make_request_uri(
full_path: full_path,
port: port,
remote_host: remote_host
)
}.merge(rack_headers(headers))
end
# ... (上で実装したメソッド)
def rack_headers(headers)
# Rackは、全ヘッダーがHTTP_がプレフィックスされ
# かつ大文字であることを期待する
headers.transform_keys do |key|
"HTTP_#{key.upcase}"
end
end
def make_request_uri(full_path:, port:, remote_host:)
request_uri = URI::parse(full_path)
request_uri.scheme = 'http'
request_uri.host = remote_host
request_uri.port = port
request_uri.to_s
end
end
end
レスポンスを送信する
Rackアプリの実装はひとまず後回しにして、先にレスポンスの送信を実装しましょう。
class HttpResponder
STATUS_MESSAGES = {
# ...
200 => 'OK',
# ...
404 => 'Not Found',
# ...
}.freeze
# status: int
# headers: ハッシュ
# body: 文字列の配列
def self.call(conn, status, headers, body)
# ステータス行
status_text = STATUS_MESSAGES[status]
conn.send("HTTP/1.1 #{status} #{status_text}\r\n", 0)
# ヘッダー
# 送信前に本文の長さを知る必要がある
# それによってリモートクライアントが読み取りをいつ終えるかがわかる
content_length = body.sum(&:length)
conn.send("Content-Length: #{content_length}\r\n", 0)
headers.each_pair do |name, value|
conn.send("#{name}: #{value}\r\n", 0)
end
# コネクションを開きっぱなしにしたくないことを伝える
conn.send("Connection: close\r\n", 0)
# ヘッダーと本文の間を空行で区切る
conn.send("\r\n", 0)
# 本文
body.each do |chunk|
conn.send(chunk, 0)
end
end
end
これで以下のような例を送信できます。
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 53
<html>
<head></head>
<body>hello world</body>
</html>
Rackアプリ
Rackアプリはstatus
、headers
、body
を返す必要があります。ステータスは整数、本文は文字列(チャンク)の配列です。
以上を元に、リクエストパスに基づいてファイルシステムからファイルを読み込むアプリを作ってみましょう。
class FileServingApp
# リクエストで受け取ったパスを元にファイルシステムからファイルを読み取る
# 例: "/test.txt"
def call(env)
# セキュリティ的には非常によくないが、デモ用には十分
path = Dir.getwd + env['PATH_INFO']
if File.exist?(path)
body = File.read(path)
[200, { "Content-Type" => "text/html" }, [body]]
else
[404, { "Content-Type" => "text/html" }, ['']]
end
end
end
まとめ
かなりシンプルだと思いませんか?
それもそのはず、細かなエッジケースを丸ごとスキップしているからです。
もっと詳しく知りたい方は、ピュアRubyで実装されているWEBRickのコードをご覧になることをおすすめします。Rackについてはこちらの記事で詳しく説明されています。
今回書いたコードの完全版については、以下のGitHubリポジトリをどうぞ。
今後は、シングルスレッドサーバー、マルチスレッドサーバー、さらにはRuby 3のFibersとRactorなど、さまざまなリクエスト処理方法を試す予定です。パート2は以下をご覧ください。
概要
原著者の許諾を得て翻訳・公開いたします。