30
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

クラウドワークスAdvent Calendar 2015

Day 19

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

Last updated at Posted at 2015-12-18

こんにちわ。 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 経由でぜひご連絡ください!

30
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?