Ruby

RubyでHTTPクライアントを書いてみる

More than 3 years have passed since last update.

こんにちわ。 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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;ei=p81vVrrGIYiT8QfI6pOYCg\">here</A>.\r\n</BODY></HTML>\r\n"


まとめ

ということで、Rubyの上でTCPSocketを使ってHTTPクライアントを自作する流れを紹介しました。

コードの節々を見ていると、 Rubyっぽくない と思われるコードの断片が多かったのではないでしょうか?このSimpleHttpライブラリは、2012年末時点のmrubyでTwitterクライアントを作成するために実装したコードがベースとなっています。

当時のmrubyにはStringの充実したメソッドも、正規表現も無かったため、 String#split を駆使したparserになっていたりします :smirk:

こんな形で、自分が普段つかっている技術の基本的な部分について「車輪の再発明」をしてみるのも、知識獲得の役に立つのではないか?というご紹介でした。


We are hiring !

CrowdWorksではHTTPやデータベースなどの低レイヤから、Railsアプリケーション、フロントエンドまでを幅広く触れる/触りたいエンジニアを募集しています。

興味のある方は Wantedly 経由でぜひご連絡ください!