CLOVER🍀

That was when it all began.

PythonのHTTPServer/BaseHTTPRequestHandlerを使って、簡単なHTTPサーバーを書く

これは、なにをしたくて書いたもの?

Pythonにはhttp.serverというライブラリがあり、簡単にHTTPサーバー(Webサーバー)を起動することができます。

21.22. http.server --- HTTP サーバ — Python 3.6.9 ドキュメント

こんな感じで起動して、簡単にカレントディレクトリをドキュメントルートにしたHTTPサーバーを立てられます。

$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

これでカレントディレクトリ配下にあるファイルを公開できるわけですが、そうではなくてhttp.serverライブラリを使って自分でHTTPサーバーを
書く方法について調べてみました、と。

環境

今回の環境は、こちら。

$ python3 -V
Python 3.6.8

BaseHTTPRequestHandlerのサブクラスを作成する

答えとしては、BaseHTTPRequestHandlerを継承したクラスを作成し、HTTPServerのハンドラとして設定します。

http.server.BaseHTTPRequestHandler

こんな感じで。

from http.server import BaseHTTPRequestHandler, HTTPServer

address = ('localhost', 8080)

class MyHTTPRequestHandler(BaseHTTPRequestHandler):
  ...

with HTTPServer(address, MyHTTPRequestHandler) as server:
    server.serve_forever()

BaseHTTPRequestHandlerを継承したクラスでは、呼び出されたHTTPメソッドに応じてdo_XXXメソッドが呼び出されるように
なっているので、対応するメソッドを作成すればOKです。

https://github.com/python/cpython/blob/v3.6.8/Lib/http/server.py#L399

例えば、do_GETやdo_POSTなど。

リクエストの情報は、MyHTTPRequestHandlerのインスタンス自身(self)に格納されています。

例えば、リクエストされたパスはpath、HTTPヘッダーはheaders、HTTPボディはrfileなどです。

path

headers

rfile

レスポンスを返す時は、send_responseでHTTPステータスコードを指定し、必要に応じてHTTPヘッダーをsend_headerで指定し、
HTTPヘッダーの終了をend_headersで指定。HTTPボディを送るにはwfileを使用します。

send_response

send_header

end_headers

wfile

このあたりを使って、簡単なHTTPサーバーを書いてみます

サンプル

GETとPOSTを簡単に扱う、HTTPサーバーを書いてみましょう。

こんな感じで。
simple_http_server.py

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse

address = ('localhost', 8080)

class MyHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        print('path = {}'.format(self.path))

        parsed_path = urlparse(self.path)
        print('parsed: path = {}, query = {}'.format(parsed_path.path, parse_qs(parsed_path.query)))

        print('headers\r\n-----\r\n{}-----'.format(self.headers))
       
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write(b'Hello from do_GET')

    def do_POST(self):
        print('path = {}'.format(self.path))

        parsed_path = urlparse(self.path)
        print('parsed: path = {}, query = {}'.format(parsed_path.path, parse_qs(parsed_path.query)))

        print('headers\r\n-----\r\n{}-----'.format(self.headers))

        content_length = int(self.headers['content-length'])
        
        print('body = {}'.format(self.rfile.read(content_length).decode('utf-8')))
        
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write(b'Hello from do_POST')
        

with HTTPServer(address, MyHTTPRequestHandler) as server:
    server.serve_forever()

まずは、do_GETメソッドから。

self.pathにリクエストされたパスが入っているのですが、QueryStringも一緒に入っているので、こちらをパースしたければurllibなどを使う必要が
あります。self.headersには、HTTPヘッダーの内容が辞書として入っています。

        print('path = {}'.format(self.path))

        parsed_path = urlparse(self.path)
        print('parsed: path = {}, query = {}'.format(parsed_path.path, parse_qs(parsed_path.query)))

        print('headers\r\n-----\r\n{}-----'.format(self.headers))

レスポンスを返す時は、こんな感じで。send_headerは使わなくてもいいですが、end_headersは呼び出しておく必要があります。

        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write(b'Hello from do_GET')

HTTPボディはwfileで書き出すわけですが、こちらの実体はBufferedIOBaseです。

io.BufferedIOBase

この状態でサーバーを起動して

$ python3 simple_http_server.py

curlでアクセス。

$ curl -i 'localhost:8080?param1=value1&param2=value2'
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.6.8
Date: Mon, 12 Aug 2019 12:42:28 GMT
Content-Type: text/plain; charset=utf-8

Hello from do_GET

この時のサーバー側のログとしては、こんな感じのものが出力されます。

path = /?param1=value1&param2=value2
parsed: path = /, query = {'param1': ['value1'], 'param2': ['value2']}
headers
-----
Host: localhost:8080
User-Agent: curl/7.58.0
Accept: */*

-----
127.0.0.1 - - [12/Aug/2019 21:42:28] "GET /?param1=value1&param2=value2 HTTP/1.1" 200 -

アクセスログっぽいものが出力されていますね。これは、send_responseを呼び出した時にlog_requestを介して、log_messageが
呼び出されるからです。

https://github.com/python/cpython/blob/v3.6.8/Lib/http/server.py#L483

https://github.com/python/cpython/blob/v3.6.8/Lib/http/server.py#L535-L536

https://github.com/python/cpython/blob/v3.6.8/Lib/http/server.py#L552-L572

log_messageを使うと、アクセスログのようなフォーマットでログ出力ができますが、出力先は標準エラー出力(sys.stderr)なので、ご注意を。

log_message

続いて、do_POST。

こちらは、do_GETの内容に加えて、HTTPボディをログ出力するように変更しています。

        print('path = {}'.format(self.path))

        parsed_path = urlparse(self.path)
        print('parsed: path = {}, query = {}'.format(parsed_path.path, parse_qs(parsed_path.query)))

        print('headers\r\n-----\r\n{}-----'.format(self.headers))

        content_length = int(self.headers['content-length'])
        
        print('body = {}'.format(self.rfile.read(content_length).decode('utf-8')))

rfile.readする時に、Content-Lengthで読み出す長さを指定しないと、ずっと待ち続けるので注意を…。

残りは同じように。

        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write(b'Hello from do_POST')

curlで動作確認。意図的にQueryStringも仕込んでいます。

$ curl -i localhost:8080?query_param=query_value1 -d 'param1=value1&param2=value2'
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.6.8
Date: Mon, 12 Aug 2019 12:54:29 GMT
Content-Type: text/plain; charset=utf-8

Hello from do_POST

ログ。

path = /?query_param=query_value1
parsed: path = /, query = {'query_param': ['query_value1']}
headers
-----
Host: localhost:8080
User-Agent: curl/7.58.0
Accept: */*
Content-Length: 27
Content-Type: application/x-www-form-urlencoded

-----
body = param1=value1&param2=value2
127.0.0.1 - - [12/Aug/2019 21:54:29] "POST /?query_param=query_value1 HTTP/1.1" 200 -

とまあ、こんな感じで。

これで、簡単なHTTPサーバーは書けそうですね。

なお、このサーバーをマルチスレッドで動かしたい場合は、こちらを参照。

PythonのTCPServer/HTTPServerをマルチスレッドで使う - CLOVER🍀