Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RubyでHTTPサーバーをゼロから手作りする(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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アプリはstatusheadersbodyを返す必要があります。ステータスは整数、本文は文字列(チャンク)の配列です。

以上を元に、リクエストパスに基づいてファイルシステムからファイルを読み込むアプリを作ってみましょう。

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は以下をご覧ください。

Ruby 3: FiberやRactorでHTTPサーバーを手作りする(翻訳)

関連記事

Rails: リクエストのライフサイクルとRackを理解する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。