この記事はアピリッツの技術ブログ「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アプリに接続してみたり色々と試してみよう。