【TCP/IP】勉強がてらHTTPクライアントもどきをPythonで作ってみる

最近、ソケット通信を使ってAndroid端末をサーバーとしてPCからデータを送る、というちょっと変わった仕組みを仕事で作っています。

今までは「通信」というとHTTP(S)くらいしか意識したことがなかったのですが、これを機に通信というものについてもっと知っておこうと思い、とりあえず「TCP/IPとは」みたいなところから勉強中です。

その勉強の一貫としてソケット通信を使ったHTTPクライアントの実装をしてみたので、その内容を紹介をしたいと思います。言語はPythonです。

その前に

そもそもソケット通信ってなによ、って方はひとまず以下のページをご参照ください。

ソケットプログラミング HOWTO | python

この記事もこのページに記載の通り、「ソケット通信と言ったらひとまずTCP」という前提で話を進めます。

また、勉強してみて分かったことをざっくりまとめてみます。

TCPとHTTPというプロトコルについてざっくり説明

プロトコル

TCP、HTTPの説明よりもさらに前に、「プロトコル」という言葉についても軽く触れておきます。(僕がいまいちわかっていなかったため)

プロトコルは、英辞郎先生によると「コンピューター間でデータを送受信するためのルール」だそうです。

世界中に存在する通信機器やその中で動作するソフトウェア(OSも含めて)は、当たり前ですが様々な会社・人によって製造・開発されています。そして、それぞれの機器・ソフトウェアはお互いが仕様を合わせることなく製造・開発されています。

そのような状況で、「じゃあ世界中の機械同士でデータのやりとりをしましょう」となっても共通の仕様がなければ「どのような仕組みの機械で」「どのように電波を送って」「その電波は何のデータを表していて」といった、実装に必要な情報が得られずに手を動かすことができません。

そこで生まれたのが「TCP/IP」という「ルール」です。TCP/IPに記載されているルールに沿って開発している限り、各社で打ち合わせなどを重ねなくてもデータの送受信が可能になるというワケです。

そして、その「ルール」をIT的な用語で「プロトコル」と呼ぶ、という訳です。

ちなみに、そんな世界共通のプロトコルをどうやって作ってどうやって広めたんだ?!という疑問については長くなるので省略します。「OSI参照モデル」という用語と併せて調べてみるとわかりやすかったです。

TCPとHTTP

TCP/IPは世界中の機器が通信するのに必要な一つ一つのプロトコルをまとめたものです。

プロトコルには、通信の方法や使いどころによっていくつもの種類があり、さらに物理的・ソフトウェア的なレイヤーによって4階層に分類されています。一番下の第1層はもはや「どんな機械で電波を送るのか」というレベルの内容です。

TCPは、その中でも第3層に位置し、「データの内容はともかくとして、2つのマシン間で通信内容を確実に送受信するためのルール」を定めているプロトコルです。

文字だけ見ると同じですが、「TCP/IP」と「TCP」ではその言葉の立ち位置自体が違い、「TCP/IPというプロトコル一覧の中のうちの一つ」がTCPということになります。

また、HTTPは第4層に位置し、「TCPにさらにルールを追加して、送受信されるデータの形式や送受信タイミングをWebサイト閲覧に最適化する形に定められたルール」です。

先ほど少し書きましたが、TCPは2つのマシン間で過不足なくデータのやりとりをするためのプロトコルですので、そこで送受信されるデータの内容がどんなアプリケーションで使われるどんな形式のものであるかは関係ありません。そのルールを決めるのはHTTPであり、SSHFTPといった第4層に位置する他のプロトコルたちです。何なら「クライアント」と「サーバー」という概念もTCP通信ではあまり違いがありません。先に接続するきっかけを作った方が「クライアント」、接続された方が「サーバー」となりますが、一度接続してしまったらデータの送受信はどちらもが同じようにできてしまいます。

これだけでも、「通信」と言った場合にHTTPが全てではないことが分かるかと思います。そのことを念頭に置きながら以降の内容を読んでみると理解しやすいかもしれません。

進め方

では、具体的な実装の進め方です。

本当であればちゃんとRFCを読んだり他のHTTPクライアントの実装を見ながら設計を考えたうえで実装するのが正しいのかと思いますが、何も見ずにいろいろ試行錯誤して自分で推測したり考えたりしながら進めた方が頭に入るかな?ということで、今回は以下のような進め方で進めています。

  1. 自分の知っているHTTPを頭に浮かべながら、なんとなくHTTPっぽく使えるクライアントを実装する
  2. RFCや既存のHTTPクライアントライブラリの実装をみながらちゃんとしたHTTPクライアントに近づける

この記事では、1つ目の自分で考えながらとりあえず作ってみる部分について書いています。

実装してみる

というわけで実装を始めてみます。
コードはGitHubでも参照できます。

ChooyanHttp - GitHub

使い方イメージ

http_client.py
if __name__ == '__main__':
    resp = ChooyanHttpClient.request('127.0.0.1', 8010)
    if resp.responce_code == 200:
        print(resp.body)

まずは自作HTTPクライアントの使い方イメージです。
ホストとポートを渡したらレスポンスデータを保持したオブジェクトが取得できる感じを目指します。

何もしないクラスを作る

http_client.py
class ChooyanHttpClient:

    def request(host, port=80):
        response = ChooyanResponse()
        return response

class ChooyanResponse:
    def __init__(self):
        self.responce_code = None
        self.body = None

if __name__ == '__main__':

... 以下略

diffはこちら

次に、先ほどの使い方イメージに沿って ChooyanHttpClient クラスと ChooyanResponse クラスを追加します。

追加はしましたが、まだ特になにもしません。

今回は、この response オブジェクトにリクエスト結果となるレスポンスコードとボディがちゃんと入るところまでを目指します。

socketモジュールを使う

次に、通信するためのsocketモジュールを追加します。

http_client.py
import socket

class ChooyanHttpClient:

    def request(host, port=80):
        response = ChooyanResponse()

        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((host, port))

        return response

class ChooyanResponse:

... 以下略

diffはこちら

先ほど説明したとおり、第4層のHTTPプロトコルは第3層のTCPプロトコルを使ってさらにルールを追加する形で作られています。

今回はそのHTTPプロトコルに沿って通信するライブラリを実装するのが目的ですので、そのベースとなるTCP通信をするsocketモジュールをインポートする、というわけです。

socketモジュールの使い方は先ほどのソケットプログラミング HOWTO
ページに簡単に記載されています。この記事でもそれを参考にしながら実装を進めます。

ここで追加したのは、socketモジュールを使って、指定したホスト、ポートに該当するマシンとの接続を確立するところまでです。

実際にこれを実行すると、指定したサーバーへの通信を開始します。(画面には何も出ないのでよく分かりませんが)

リクエストしてみる

さて、ここからが大変です。

先ほどsocketモジュールを使って指定したホスト、ポートのマシンに接続するところまではできました。

しかしこのままではまだデータは何も帰ってきません。接続を確立したはいいものの、まだこちらから「リクエスト」にあたるデータを送信していないので当たり前ですね。

ということで、今からサーバーにリクエストを送信するコードを書いてみます。

http_client.py
import socket

class ChooyanHttpClient:

    def request(host, port=80):
        response = ChooyanResponse()

        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((request.host, request.port))
        request_str = 'GET / HTTP/1.1\nHost: %s\r\n\r\n' % (host)
        s.send(request_str.encode('utf-8'))

        return response

class ChooyanResponse:

... 以下略

diffはこちら

send()関数を実行する行と、そこに渡す文字列を一度組み立てるための行をそれぞれ追加しました。

これで、サーバーへ(とりあえず固定の)データを送ることができるようになりました。

実行すると、サーバー側のアクセスログにこのリクエストが出てくるのではないかと思います。

レスポンスを受け取る

これで指定したホストにGETリクエスト(を表すデータ)を送信することができるようになったわけですが、これだけではまだサーバーとのやりとりはできません。「受信する」にあたるコードがないためです。

「え、リクエスト送ったんだからレスポンスって返ってくるものじゃないの?」と思われた方、それは市販のHTTPクライアントライブラリがちゃんとそう作ってあるからそうなのです。ここではまだちゃんと作れていないため、レスポンスは受け取れません。

TCPではサーバーとクライアントそれぞれのデータ送信のタイミングについては特にルールがありません。つまり、お互いが「好きなときに好きなデータを送りつけられる」のです。

しかしそれだけしか決まっていないと、作り手の違うサーバーとクライアントがお互いにどのようなタイミングでどのようなデータを送りつけてくるのかが分からないため、ある程度その自由を制限し、ルールとして共通認識を持つ必要があります。その共通認識のうちの一つがHTTPプロトコルである、というわけです。

つまり、HTTPでは「リクエストを送ったら、レスポンスが返ってくる」というのがルールですので、クライアントは「リクエストを送ったら、レスポンスの受信を待ち受ける」よう実装する必要があるのです。

コードにするとこのようになります。

http_client.py
import socket

class ChooyanHttpClient:

    def request(request):

... 省略

        s.connect((request.host, request.port))
        request_str = 'GET / HTTP/1.1\nHost: %s\r\n\r\n' % (host)
        s.send(request_str.encode('utf-8'))
        response = s.recv(4096)

... 以下略

diffはこちら

recv()関数を1行追加しました。これで、サーバーからデータが送りつけられるまでこのプログラムの処理はこの行でブロックされます。

しかし、これではまだ問題があります。

詳しいことは(僕もちゃんと理解できていないため)省略しますが、ソケット通信において、データは一度に全てを受信できるものではありません。というか、先述の通りソケット通信では「好きなときに好きなデータを送りつけられる」ものなので、どこまでが「1回」なのかも決まっていません。

そのためプログラムは、このままではどこまでがひとかたまりのデータなのかも分からないですし、いつ接続を切れば良いかもわかりません。1

先ほどのrecv()関数も、「全部」ではなく何かデータを切りの良いところまで(もしくは引数に指定したバイト数まで)受信したら一旦次の処理に進んでしまいます。

つまり、このコードでは最大でも4096バイトまでの応答しか受け取れないのです。
そこで、データを十分に受信できるようにコードを修正します。

http_client.py
import socket

class ChooyanHttpClient:

    def request(request):
... 省略

        s.send(request_str.encode('utf-8'))

        data = []
        while True:
            chunk = s.recv(4096)
            data.append(chunk)

        response.body = b''.join(data)
        return response

... 以下略

diffはこちら

無限ループで最大4096バイトずつ受信し、配列にどんどん追加していきます。最後にそれを連結してあげればサーバーからのデータを取りこぼしなく受信することができる、というわけです。

しかし、これでもまだ不完全です。このコードを実行しても処理が無限ループから出られず、呼び出し元に結果を返せません。

先ほども書きましたが、ソケット通信には「1回」の概念がなく、通信の終わりもありません。
これではプログラムはどこで無限ループを終われば良いか分からないのです。

それではHTTPの特徴である「1回送ったら、1回返ってくる」が実現できませんので、HTTPではContent-Lengthヘッダを使って(body部の)データサイズを明示するよう決められています。次のコードでは、それを読み取る仕組みを作ります。

http_client.py
import socket

class ChooyanHttpClient:

    def request(request):

... 省略
        s.send(request_str.encode('utf-8'))

        headerbuffer = ResponseBuffer()
        allbuffer = ResponseBuffer()
        while True:
            chunk = s.recv(4096)
            allbuffer.append(chunk)

            if response.content_length == -1:
                headerbuffer.append(chunk)
                response.content_length = ChooyanHttpClient.parse_contentlength(headerbuffer)

            else:
                if len(allbuffer.get_body()) >= response.content_length:
                    break

        response.body = allbuffer.get_body()
        response.responce_code = 200

        s.close()
        return response

    def parse_contentlength(buffer):
        while True:
            line = buffer.read_line()
            if line.startswith('Content-Length'):
                return int(line.replace('Content-Length: ', ''))
            if line == None:
                return -1

class ChooyanResponse:
    def __init__(self):
        self.responce_code = None
        self.body = None
        self.content_length = -1

class ResponseBuffer:
    def __init__(self):
        self.data = b''

    def append(self, data):
        self.data += data

    def read_line(self):
        if self.data == b'':
            return None

        end_index = self.data.find(b'\r\n')
        if end_index == -1:
            ret = self.data
            self.data = b''
        else:
            ret = self.data[:end_index]
            self.data = self.data[end_index + len(b'\r\n'):]
        return ret.decode('utf-8')

    def get_body(self):
        body_index = self.data.find(b'\r\n\r\n')
        if body_index == -1:
            return None
        else:
            return self.data[body_index + len(b'\r\n\r\n'):]

... 以下略

diffはこちら

だいぶ長くなってきてしまいましたが、やろうとしていることを順番に説明します。

Content-Length行を探す

HTTPレスポンスの書式として、

  • レスポンスヘッダは1行にひとつ
  • body部の前は空行

というのが決まっています。

そのため、まずはデータを受信するたびに手前から順番に

  • 1行(改行コードから改行コードまで)を取り出す
  • Content-Lengthで始まるかどうかをチェックする
  • Content-Lengthで始まっていれば、数値部分だけを取り出す

ということをしています。これで、Content-Lengthが取り出せます。

しかし、Content-Lengthはあくまでbody部のサイズを記載しているだけです。ヘッダや1行目のレスポンスコードなどは含まれません。

そのため、受信している全てのデータを使って、2回連続の改行よりあと(つまり最初に現れる空行より後のbody部)のデータのサイズとContent-Lengthを比較するようにしています。

これでContent-Lengthサイズとbody部のサイズが一致したら(コードでは念のため「Content-Length以上になったら」としています)、ループを抜けて呼び出し元にデータを返すことができます。

改良する

さて、これでようやくリクエストの送信とレスポンスの受信ができるようになったわけですが、まだまだHTTPクライアントとしては使い物になりません。

リクエストはGETメソッド限定、ルートパス限定、リクエストヘッダなしというひどいものですし、レスポンスもレスポンスコードやヘッダを含めたすべてのデータをバイト列でそのまま返却しているだけです。

このあたりのデータの整形であったり、ヘッダに応じた挙動の変更だったり、送受信のタイミングの細かな調整やタイムアウト処理などなど、まだやることは数多くあるのですが、この記事はだいぶ長くなってきてしまったため、そのあたりは次回にしたいと思います。

一旦まとめ

とりあえずHTTPクライアントっぽい処理を実装してみましたが、これだけでもだいぶTCPとHTTPについて理解を深めることができたような気がします。HTTPクライアントライブラリ作るのって大変なんだなあ、、、requestsとかurllibとか、どんな実装になってるんだろう。

というわけで次回に続きます。

参考

参考というか、こちらの記事を見て自分も同じような試みで勉強してみようと思いました。
こちらの記事ではHTTP「サーバー」の方を作っていましたが、クライアントを作る上でもとても参考になる内容が多かったです。

ソケット通信について、とても軽いノリで、且つわかりやすく説明してくれていて、ソケット通信勉強中の身としてはとても参考になりました。Pythonのドキュメントですが、言語に関わらず参考になります。



  1. と思っていたら、これはHTTP1.1で追加されたKeepAliveヘッダによるものでした。これを無効にした場合、サーバーはデータを最後まで送信するとコネクションを切断し、クライアント側のrecv()関数は0を返すようになりますので、これを検知してループを抜けることができます。