その他
    ホーム 技術発信 DoRuby 作ろう、HTTPサーバ
    作ろう、HTTPサーバ
     

    作ろう、HTTPサーバ

    この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。

    エンジニアをやっていると色々なモノの気持ちにならないといけない。バイナリツリーの気持ちになったり、CPUの気持ちになったり、メモリの気持ちになったり……。あ、書いたコードが低レイヤーでどんな挙動をするのか考えるっていう意味だよ。

    普段使っているHTTPサーバの気持ちはどうだろう? 実際に書いて見ればわかるんじゃないかな。というわけで書いてみよう、Rubyで。

    いきなりコーディング

    HTTP通信の基本は知ってるよね。クライアントはTCPで80番ポートに接続してリクエストを投げ、サーバからレスポンスを受ける。いわゆるリクエスト-レスポンス型プロトコルだ。

    だからまずはTCPで80番ポートをlistenしてブラウザにリクエストを送ってみてもらおう。

    require 'socket'
    module ReWheel
      class Server
        def initialize
          @tcp_server = TCPServer.new 80
        end
    
        def listen
          loop do
            client = @tcp_server.accept
            request = Array.new
            while request_line = client.gets
              break if request_line.chomp.empty?
              request << request_line.chomp
            end
            puts request.join("\n")
            client.close
          end
        end
      end
    end
    ReWheel::Server.new.listen
    

    sudo ruby server.rb

    ここでsudoをつけて実行することに注意。実はsudoを外すとエラーが発生して動かない。理由はHTTPの80番など1023以下のポート番号はLinux上で特権ポートとされているため。プロセスがこれらのポート番号をバインド(束縛)するにはroot権限で実行されている必要がある。

    バインドというのは、あるポート番号をあるプロセスが占有するということだ。つまり、既にあるプロセスがバインドしているポート番号は、送られて来る通信を他のプロセスが受け取ることは出来ない。もしも、既にプロセスがバインドされている番号を新たに起動するプロセスが利用しようとすればエラーが発生する。試してみたければ上のスクリプトを二重起動すればいい。

    ( ˘⊖˘)。o(待てよ、受け取った通信を他のポートにもコピペで渡すアプリケーションを作れば一つのポートを実質的に複数プロセスで共有出来るのでは?)

    と思った初心者はかしこい。それはnginxなどが持つリバースプロキシと呼ばれる機能の考え方だ。この機能があるからWebサーバは負荷分散や機能分担のために80/443ポートで受け取ったリクエストを他のポート番号でlistenしている同一マシン内のunicornやpumaに処理させたり出来るってワケ。

    ま、とにかくこのコードを実際に使ってみよう。

    ブラウザから来るリクエスト

    では、サーバを起動した状態でブラウザからアクセスしてみよう。標準出力にこんな表示が出来て来るはずだ。

    GET / HTTP/1.1
    Host: 203.XXX.XXX.XXX
    Connection: keep-alive
    Cache-Control: max-age=0
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36
    Upgrade-Insecure-Requests: 1
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8
    Accept-Encoding: gzip, deflate
    Accept-Language: ja,en-US;q=0.8,en;q=0.6

    改めて考えるとちょっと不思議だが問答無用でHTTP/1.1のリクエストが送られて来る。サーバがどんなプロトコルに対応しているかは不明な時点で決め打ちされてくるワケだ。

    では例えばHTTP/2.0の通信はどうするかというと、最初のリクエスト自体はHTTP/1.1で送られ、ヘッダ内にHTTP/2.0に対応可能である旨が記述される。そして、サーバ側もHTTP/2.0で対応可能であった場合はプロトコルが変更されてHTTP/2.0の通信が始まるというワケ。

    次は送られてきたこのリクエスト文について読み解いて行こう。

    [構成]

    HTTPの規格については当然、というより言うまでもなくRFCのドキュメントを読むのが最も正しい。RFCではリクエストやレスポンスの構文はABNFで定義されている。この前書いた記事「作ろう、ミニ言語」でBNFが登場したがその拡張版にあたる言語がABNFだ。BNFみたいに自己参照しなくても繰り返しが表現出来たり色々と便利。
    (作ろう、ミニ言語 https://doruby.jp/users/yam/entries/mini_lang)

    HTTP-message
      = start-line
       *( header-field CRLF ) CRLF
       [ message-body ]

    (出典:RFC 7230 の総集的 ABNF
    https://triple-underscore.github.io/RFC723X-ABNF-ja.html)

    うん、日本語にしよう。

    1行目 開始行
    2〜N行 ヘッダー
    N+1行目 区切りのための空白行
    N+2行目 メッセージ本文(オプション)

    この形式はリクエストもレスポンスも共通であり、どちらも最低限に必要なのは1行目の開始行だけだ。まずは開始行の構文について説明しよう。前述したリクエストのサンプルで例えるとこうだ。

    GET / HTTP/1.1
    {メソッド} {URI} {プロトコル}

    区切りは半角スペース。難しいことはなし。ヘッダについても項目がたくさんあるというだけで構文は難しくない。

    Accept-Encoding: gzip, deflate
    {パラメータ名}: {値}

    じゃ次はサーバ側でレスポンスをしてみよう。レスポンスの場合はリクエストと開始行の構文が違う。先にABNFの方で見てみよう。

    status-line
    = HTTP-version SP status-code SP reason-phrase CRLF

    SPは半角スペース、つまり”{HTTPバージョン} {ステータスコード} {理由句} 改行コード”という形式になる。ちょっと当てはめてみよう、このサーバはごく単純にHTTP 0.9で実装しておくとして、正常にGetできるのでコードは200、理由句はOKだ。

    HTTP/0.9 200 OK

    OK! ところで書いていて疑問に想ったことが一つ。ステータスコードはどういうステータスなのかが定義されている。では、わざわざ理由を書かないといけない理由はなんだ? もしかして自由に書いてもいいのか?

    RFCのABNFを読んでみるとreason-phrase = *( HTAB / SP / VCHAR / obs-text )obs-text = %x80-FF、としか書かれていない。つまり省略してもいいし何を書いてもいい。普段、我々が目にするあのNot Foundといったフレーズはあくまでも推奨される1案らしい。どころかクライアントソフトでは無視することが推奨されている(RFC 7230)とのこと。ふーん…。
    (HTTP responseのstatus-line、reason-phraseの内容にどこまでこだわるか?:
    http://blog.magnolia.tech/entry/2017/10/09/224148)

    返事をしてみる

    じゃあさっきのサーバスクリプトにレスポンスを返すように書き加えてみよう。

        def listen
          loop do
            client = @tcp_server.accept
            request = Array.new
            while request_line = client.gets
              break if request_line.chomp.empty?
              request << request_line.chomp
            end
            puts request.join("\n")
            response_root(client)
            client.close
          end
        end
    
        def response_root(client)
          client.puts <<-EOS
    HTTP/0.9 200 OK
    Content-Type: text/html
    
    Hello, <b>neko</b>.
          EOS
        end
    

    サーバを起動し直してブラウザで接続。猫に会えたかな。基本はこれだけだ。しかしGoogleChromeで接続するとプロトコルはH1.0扱いされるな…0.9は対応外なのか、あるいは足して2で割られているのかは不明。

    さて、今までのところは全くリクエストの内容を解釈していない。リクエストもレスポンスも好き勝手に投げているだけだ。ドッジボール・プロトコル…。次はキャッチボールをしよう。要求されたURLに応じて異なるコンテンツを返す。Railsなんかのルーティングに相当する処理だ。

    その前にリクエストをパースする必要があり、ABNFが定義されているしミニ言語のようにちゃんとlex&parseしても良いわけだけれど…今日はそこまでやる気がない。String#stripで乗り切る。

    require 'socket'
    module ReWheel
      class Server
        def initialize
          @tcp_server = TCPServer.new 80
        end
    
        def listen
          loop do
            client = @tcp_server.accept
            raw_request = Array.new
            while request_line = client.gets
              break if request_line.chomp.empty?
              raw_request << request_line.chomp
            end
            request = Request.create_from_raw_text raw_request.join("\n")
            response = route_and_response(request)
            client.puts response.to_text
            client.close
          end
        end
    
        def route_and_response(response)
          case response.url
          when '/'
            Response.new.tap do |res|
              res.code = '200'
              res.body = 'Im root.'
            end
          when '/neko'
            Response.new.tap do |res|
              res.code = '200'
              res.body = 'Im a neko.'
            end
          when '/coffee'
            Response.new.tap do |res|
              res.code = '418'
              res.body = 'Im a tea-pot.'
            end
          else
            Response.new.tap do |res|
              res.code = '404'
              res.body = 'Not Found'
            end
          end
        end
      end
    
      class Request # ヘッダは面倒なので省略
        attr_accessor :method, :protocol, :url
        def self.create_from_raw_text(text)
          request_line = text.split("\n").map(&:chomp)
          start_line = request_line[0]
          self.new.tap { |req| req.method, req.url, req.protocol = start_line.split(' ') }
        end
      end
    
      class Response # reason-phraseは不要らしいので省略
        attr_accessor :protocol, :code, :body
        def initialize
          @protocol = 'HTTP/1.0'
        end
    
        def to_text
          start_line = [@protocol, @code].join(' ')
          [start_line, '', @body].join("\n")
        end
      end
    end
    
    ReWheel::Server.new.listen
    

    書けた、第三部完。せっかくステータス・コードが選べるのでtea potも実装しておいた。厳密に言えばHTTPプロトコルではない(下記リンク参照)けど、遊びのスクリプトで書かなかったらどこで書いていいのかわからないよ。
    (https://tools.ietf.org/html/rfc2324)

    発展の話

    というわけで基本的なことが出来るようになった。これをHTTP/2.0対応にしたり、あるいは自分だけのHTTPプロトコルを設計したり、unixドメインソケットでRackアプリに接続してみたり色々と試してみよう。