こんにちわ。 CrowdWorks Advent Calendar の 19日目の記事になります。
この記事ではRailsエンジニアは案外知らない人が多い、HTTP通信の基本をおさらいしてみようと思います。
HTTPとは
(後で元気があれば説明を書く)
っ https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
実際に通信しているところを見てみる (curl編)
$ curl -v http://www.google.com/
* Trying 216.58.221.4...
* Connected to www.google.com (216.58.221.4) port 80 (#0)
> GET / HTTP/1.1
> Host: www.google.com
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 302 Found
< Cache-Control: private
< Content-Type: text/html; charset=UTF-8
< Location: http://www.google.co.jp/?gfe_rd=cr&ei=mLxvVpGsBYz-8wea-IHoCQ
< Content-Length: 261
< Date: Tue, 15 Dec 2015 07:09:12 GMT
< Server: GFE/2.0
<
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&ei=mLxvVpGsBYz-8wea-IHoCQ">here</A>.
</BODY></HTML>
* Connection #0 to host www.google.com left intact
-
*
で始まる行は curl の内部動作を示しています -
>
で始まる行は 自分のマシンから google 方向への通信内容を示しています -
<
で始まる行は google から 自分のマシン 方向への通信内容を示しています
curl は、googleのサーバに対して以下の内容を送っていることが確認できました。
> GET / HTTP/1.1
> Host: www.google.com
> User-Agent: curl/7.43.0
> Accept: */*
>
実際に通信してみる (echo && nc 編)
curl がどのようなリクエストを相手のサーバに投げているのか?を確認できたので、次は実際に自分で組み立ててみましょう。
早速RubyでHTTPクライアントを自作.. とも思ったのですが、まだシンプルなのでコマンドラインツールで試してみましょう。
$ echo -e "GET / HTTP/1.1\nHost: www.google.com\nUser-Agent: hogehoge-client\n" | nc www.google.com 80
HTTP/1.1 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.co.jp/?gfe_rd=cr&ei=Q7xvVr72HpH-8we_l4OIDg
Content-Length: 261
Date: Tue, 15 Dec 2015 07:07:47 GMT
Server: GFE/2.0
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&ei=Q7xvVr72HpH-8we_l4OIDg">here</A>.
</BODY></HTML>
-
echo
の-e
オプションは\n
で改行できるようにするため -
nc
コマンドは 対象ホスト名、対象ポートを指定し、標準入力から受け取ったデータを対象ホストに渡します
さきほどの curl を使った場合と同じような結果が得られました。
HTTPリクエストの構造
さきほど echo && nc でも試しましたが、以下のようなデータを相手のサーバに送ることで、自分が閲覧したいページの内容をゲットすることができます。
GET / HTTP/1.1
Host: www.google.com
User-Agent: curl/7.43.0
Accept: */*
ここから任意の項目を削除すると.. 以下の情報が最小限のHTTPリクエストデータとなります。
GET / HTTP/1.1
ここからはRubyを使ってHTTPクライアントを作ってみることにしましょう。
RubyでシンプルなHTTPクライアントを実装する
RubyでTCPSocketを使ってHTTP通信を行う
irb を立ち上げて、以下のコードを実行してみてください。これまでと同様に、Googleからの応答が得られるはずです。
require 'socket'
socket = TCPSocket.new('www.google.com', 80)
socket.write "GET / HTTP/1.0\r\nAccept: */*\r\nConnection: close\r\nHost: www.google.com\r\n\r\n"
response = ''; t = ''
response += t while t = socket.read(1024)
socket.close
puts response
実行結果:
irb(main):001:0> require 'socket'
... (snip) ...
irb(main):008:0* puts response
HTTP/1.0 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.co.jp/?gfe_rd=cr&ei=kshvVsisC4j-8wffoIywDw
Content-Length: 261
Date: Tue, 15 Dec 2015 08:00:18 GMT
Server: GFE/2.0
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&ei=kshvVsisC4j-8wffoIywDw">here</A>.
</BODY></HTML>
=> nil
HTTPクライアントっぽく使えるようにコードを書いてみる
このままでは、TCPSocketを直接呼んでいるだけでHTTPクライアントっぽくないですね。ということで、HTTPクライアント (Net::HTTPっぽい何か) として扱えるように、最小限の機能を実装してみましょう。
require 'socket'
class SimpleHttp
DEFAULT_PORT = 80
HTTP_VERSION = "HTTP/1.0"
DEFAULT_ACCEPT = "*/*"
SEP = "\r\n"
def initialize(address, port = DEFAULT_PORT)
@socket
@uri = {}
@uri[:address] = address
@uri[:port] = port ? port.to_i : DEFAULT_PORT
self
end
def address; @uri[:address]; end
def port; @uri[:port]; end
def get(path = "/", request = nil)
request("GET", path, request)
end
def post(path = "/", request = nil)
request("POST", path, request)
end
private
def request(method, path, req)
@uri[:path] = path
if @uri[:path].nil?
@uri[:path] = "/"
elsif @uri[:path][0] != "/"
@uri[:path] = "/" + @uri[:path]
end
request_header = create_request_header(method.upcase.to_s, req)
response_text = send_request(request_header)
end
def send_request(request_header)
@socket = TCPSocket.new(@uri[:address], @uri[:port])
@socket.write(request_header)
response_text = ""
while (t = @socket.read(1024))
response_text += t
end
@socket.close
response_text
end
def create_request_header(method, req)
req = {} unless req
str = ""
body = ""
str += sprintf("%s %s %s", method, @uri[:path], HTTP_VERSION) + SEP
header = {}
req.each do |key,value|
header[key.capitalize] = value
end
header["Host"] = @uri[:address] unless header.keys.include?("Host")
header["Accept"] = DEFAULT_ACCEPT unless header.keys.include?("Accept")
header["Connection"] = "close"
if header["Body"]
body = header["Body"]
header.delete("Body")
end
if method == "POST" && (not header.keys.include?("content-length".capitalize))
header["Content-Length"] = (body || '').length
end
header.keys.sort.each do |key|
str += sprintf("%s: %s", key, header[key]) + SEP
end
str + SEP + body
end
end
ここまで実装したものを実際に実行してみましょう。
irb(main):001:0> load 'path/to/simple_http.rb'
irb(main):002:0> puts SimpleHttp.new('www.google.com', 80).get
HTTP/1.0 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.co.jp/?gfe_rd=cr&ei=xcpvVo3pKvH98weUvoXwCA
Content-Length: 261
Date: Tue, 15 Dec 2015 08:09:41 GMT
Server: GFE/2.0
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&ei=xcpvVo3pKvH98weUvoXwCA">here</A>.
</BODY></HTML>
=> nil
(当たり前ですが) irb 上で直接 TCPSocket を使っていたときと同様の結果が得られるようになりました。
しかし、Rubyの Net::HTTP とはまだまだ使い勝手が異なりますね。。サーバからの応答が構造化されていない点が大きな違いではないでしょうか?
HTTPレスポンスを構造化する
HTTPの応答は、改行で「HTTPヘッダ」部分と「コンテンツ」部分に区切られています。このうち、「HTTPヘッダ」部分は行単位で :
で「ヘッダ名」と「値」に区切られています。
HTTP/1.0 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.co.jp/?gfe_rd=cr&ei=xcpvVo3pKvH98weUvoXwCA
Content-Length: 261
Date: Tue, 15 Dec 2015 08:09:41 GMT
Server: GFE/2.0
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&ei=xcpvVo3pKvH98weUvoXwCA">here</A>.
</BODY></HTML>
RubyのHTTPクライアントではHTTPステータスコードや、Content-Typeなどの情報を応答オブジェクトのメソッドで取り出せるので、そのように拡張してみましょう。
class SimpleHTTP
private
def request(method, path, req)
@uri[:path] = path
if @uri[:path].nil?
@uri[:path] = "/"
elsif @uri[:path][0] != "/"
@uri[:path] = "/" + @uri[:path]
end
request_header = create_request_header(method.upcase.to_s, req)
response_text = send_request(request_header)
SimpleHttpResponse.new(response_text) # この行を追加
end
class SimpleHttpResponse
SEP = SimpleHttp::SEP
def initialize(response_text)
@response = {}
if response_text.include?(SEP + SEP)
@response["header"], @response["body"] = response_text.split(SEP + SEP)
else
@response["header"] = response_text
end
parse_header
self
end
def [](key); @response[key]; end
def []=(key, value); @response[key] = value; end
def header; @response['header']; end
def body; @response['body']; end
def status; @response['status']; end
def code; @response['code']; end
def date; @response['date']; end
def content_type; @response['content-type']; end
def content_length; @response['content-length']; end
def each(&block)
if block
@response.each do |k,v| block.call(k,v) end
end
end
def each_name(&block)
if block
@response.each do |k,v| block.call(k) end
end
end
private
def parse_header
return unless @response["header"]
h = @response["header"].split(SEP)
if h[0].include?("HTTP/1")
@response["status"] = h[0].split(" ", 2).last
@response["code"] = h[0].split(" ", 3)[1].to_i
end
h.each do |line|
if line.include?(": ")
k,v = line.split(": ")
@response[k.downcase] = v
end
end
end
end
end
この実装を追加して、もう一度試してみましょう。
irb(main):002:0* load 'path/to/simple_http.rb'
=> true
irb(main):003:0> r = SimpleHttp.new('www.google.com', 80).get
=> #<SimpleHttp::SimpleHttpResponse:0x007fdca2be4b88 @response={"header"=>"HTTP/1.0 302 Found\r\nCache-Control: private\r\nContent-Type: text/html; charset=UTF-8\r\nLocation: http://www.google.co.jp/?gfe_rd=cr&ei=p81vVrrGIYiT8QfI6pOYCg\r\nContent-Length: 261\r\nDate: Tue, 15 Dec 2015 08:21:59 GMT\r\nServer: GFE/2.0", "body"=>"<HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\n<TITLE>302 Moved</TITLE></HEAD><BODY>\n<H1>302 Moved</H1>\nThe document has moved\n<A HREF=\"http://www.google.co.jp/?gfe_rd=cr&ei=p81vVrrGIYiT8QfI6pOYCg\">here</A>.\r\n</BODY></HTML>\r\n", "status"=>"302 Found", "code"=>302, "cache-control"=>"private", "content-type"=>"text/html; charset=UTF-8", "location"=>"http://www.google.co.jp/?gfe_rd=cr&ei=p81vVrrGIYiT8QfI6pOYCg", "content-length"=>"261", "date"=>"Tue, 15 Dec 2015 08:21:59 GMT", "server"=>"GFE/2.0"}>
irb(main):004:0> r.code # HTTPステータスコードを取り出せるようになりました
=> 302
irb(main):005:0> r.content_type # content-type を取り出せるようになりました
=> "text/html; charset=UTF-8"
irb(main):006:0> r.body
=> "<HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\n<TITLE>302 Moved</TITLE></HEAD><BODY>\n<H1>302 Moved</H1>\nThe document has moved\n<A HREF=\"http://www.google.co.jp/?gfe_rd=cr&ei=p81vVrrGIYiT8QfI6pOYCg\">here</A>.\r\n</BODY></HTML>\r\n"
まとめ
ということで、Rubyの上でTCPSocketを使ってHTTPクライアントを自作する流れを紹介しました。
コードの節々を見ていると、 Rubyっぽくない
と思われるコードの断片が多かったのではないでしょうか?このSimpleHttpライブラリは、2012年末時点のmrubyでTwitterクライアントを作成するために実装したコードがベースとなっています。
当時のmrubyにはStringの充実したメソッドも、正規表現も無かったため、 String#split
を駆使したparserになっていたりします
こんな形で、自分が普段つかっている技術の基本的な部分について「車輪の再発明」をしてみるのも、知識獲得の役に立つのではないか?というご紹介でした。
We are hiring !
CrowdWorksではHTTPやデータベースなどの低レイヤから、Railsアプリケーション、フロントエンドまでを幅広く触れる/触りたいエンジニアを募集しています。
興味のある方は Wantedly 経由でぜひご連絡ください!