何かを書き留める何か

数学や読んだ本について書く何かです。最近は社会人として生き残りの術を学ぶ日々です。

PythonによるエコーHTTPサーバーの実装

前回requestsモジュールなしでHTTPクライアント側を実装した。

xaro.hatenablog.jp

サーバー側は適当に実装したが、今回はもう少しまじめにエコーHTTPサーバーを実装してみる。 目指すは開発時に手元でさっと起動できるHTTPサーバースクリプトである。 なお、『The Python 3 Standard Library by Example』を参照した。*1

www.informit.com

$ python -m http.serverの中身

Python標準モジュールであるhttp.serverを使えば起動したディレクトリ以下にあるファイルをそのままHTTPで扱うことができる。 デフォルトのポートは8000であるが引数を渡せば別のポートでも起動ができる。

$ python -m http.server 8888

これを実行すると何が起きているのか? 実際にソースコードを参照すると次のような処理が行われている。

def test(HandlerClass=BaseHTTPRequestHandler,
         ServerClass=HTTPServer, protocol="HTTP/1.0", port=8000, bind=""):
    """Test the HTTP request handler class.
    This runs an HTTP server on port 8000 (or the port argument).
    """
    server_address = (bind, port)

    HandlerClass.protocol_version = protocol
    with ServerClass(server_address, HandlerClass) as httpd:
        sa = httpd.socket.getsockname()
        serve_message = "Serving HTTP on {host} port {port} (http://{host}:{port}/) ..."
        print(serve_message.format(host=sa[0], port=sa[1]))
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\nKeyboard interrupt received, exiting.")
            sys.exit(0)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--cgi', action='store_true',
                       help='Run as CGI Server')
    parser.add_argument('--bind', '-b', default='', metavar='ADDRESS',
                        help='Specify alternate bind address '
                             '[default: all interfaces]')
    parser.add_argument('port', action='store',
                        default=8000, type=int,
                        nargs='?',
                        help='Specify alternate port [default: 8000]')
    args = parser.parse_args()
    if args.cgi:
        handler_class = CGIHTTPRequestHandler
    else:
        handler_class = SimpleHTTPRequestHandler
    test(HandlerClass=handler_class, port=args.port, bind=args.bind)

細かい点はいくつもあるが、次の2つを実装すれば自分の好きなHTTPサーバーが実装できそうである。

  • サーバー(HTTPServerの代わりになるもの)
  • ハンドラー (BaseHTTPRequestHandlerの代わりになるもの)

HTTPサーバー

HTTPサーバーに関してはできることは少なく、http.serverにあるhttp.server.HTTPServerをそのまま使うことになる。 http.server.HTTPServersocketserver.TCPServerの単純なサブクラスである。

class HTTPServer(socketserver.TCPServer):

    allow_reuse_address = 1    # Seems to make sense in testing environment

    def server_bind(self):
        """Override server_bind to store the server name."""
        socketserver.TCPServer.server_bind(self)
        host, port = self.server_address[:2]
        self.server_name = socket.getfqdn(host)
        self.server_port = port

今回は開発時にさっと使えるHTTPサーバーを目指すので並列処理や並行処理を考慮するのはオーバースペックであるが、socketserver.ThreadingMixInsocketserver.ForkingMixInクラスを使えば複数の処理を非同期に処理するHTTPサーバーを実装できる。実装も簡単でsocketserver.ThreadingMixInsocketserver.ForkingMixInに多重継承すればよい。

class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
    """HTTPリクエストをスレッドに分割して処理するHTTPサーバー"""
    pass

class ForkedHTTPServer(socketserver.ForkingMixIn, http.server.HTTPServer):
    """HTTPリクエストをプロセスに分割して処理するHTTPサーバー"""
    pass

なお、socketserver.ForkingMixInos.fork()が利用可能な環境のみ利用できる。つまりWindowsでは動作しない。

`

HTTPリクエストハンドラー

ハンドラーはhttp.server.BaseHTTPRequestHandlerを継承してdo_METHODという名前のメソッドを実装するのが基本である。 HTTP 1.0の範囲ではGET, POST, HEADができれば十分である。 今回はエコーサーバーを目指すので、受け取ったパラメータをそのまま表示させればよい。 パラメータに関してはドキュメントにある属性を参照すればよい。

曲者はPOSTされたデータをどうするかである。 『The Python 3 Standard Library by Example』ではcgi.FieldStorageを駆使して処理する方法が紹介されていた。 流れとしてはBaseHTTPRequestHandler.rfilecgi.FieldStorageに渡してPOSTされた中身を処理する、となる。

スクリプトの全体図

import argparse
import cgi
import http.server
import io
import socketserver
import sys
import urllib.parse


class EchoHandler(http.server.BaseHTTPRequestHandler):
    """リクエストを処理するハンドラ"""

    def gen_message(self):
        """HTTPメソッドに共通したメッセージを生成する"""
        parsed_path = urllib.parse.urlparse(self.path)
        message_parts = [
            "CLIENT_VALUES:",
            "client_address={} ({})".format(self.client_address, self.address_string()),
            "command={}".format(self.command),
            "path={}".format(self.path),
            "real_path={}".format(parsed_path.path),
            "query={}".format(parsed_path.query),
            "request_version".format(self.request_version),
            "",
            "SERVER VALUES:",
            "server_version={}".format(self.server_version),
            "sys_version={}".format(self.sys_version),
            "protocol_version={}".format(self.protocol_version),
            "",
            "HEADERS RECEIVED:"
        ]
        for name, value in sorted(self.headers.items()):
            message_parts.append("{}={}".format(name, value.rstrip()))
        else:
            message_parts.append("")
        message = "\n".join(message_parts)
        return message

    def do_GET(self):
        message = self.gen_message()
        self.send_response(http.HTTPStatus.OK)
        self.send_header('Content-length', len(message))
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()
        self.wfile.write(message.encode("utf8"))

    def do_HEAD(self):
        message = self.gen_message()
        self.send_response(http.HTTPStatus.OK)
        self.send_header('Content-length', len(message))
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()

    def do_POST(self):
        form = cgi.FieldStorage(
            fp=self.rfile,
            headers=self.headers,
            environ={
                "REQUEST_METHOD": "POST",
                "CONTENT_TYPE": self.headers["Content-Type"],
            },
        )
        message = self.gen_message()
        self.send_response(http.HTTPStatus.OK)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()

        self.wfile.write(message.encode("utf8"))
        self.wfile.write("\n".encode("utf8"))

        out = io.TextIOWrapper(
            self.wfile,
            encoding="utf8",
            line_buffering=False,
            write_through=True,
        )

        out.write("FORM DATA:\n")

        for field in form.keys():
            field_item = form[field]
            if field_item.filename:
                file_data = field_item.file.read()
                file_len = len(file_data)
                del file_data
                out.write("Upload {} as {!r} ({} bytes)\n".format(field, field_item.filename, file_len))
            else:
                out.write("{}={}\n".format(field, form[field].value))
        else:
            out.detach()

class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
    """HTTPリクエストをスレッドに分割して処理するHTTPサーバー"""
    pass

class ForkedHTTPServer(socketserver.ForkingMixIn, http.server.HTTPServer):
    """HTTPリクエストをプロセスに分割して処理するHTTPサーバー"""
    pass

def runserver(server, handler, protocol="HTTP/1.0", port=8000, bind=""):
    server_address = (bind, port)
    handler.protocol_version = protocol
    httpd = server(server_address, handler)
    sa = httpd.socket.getsockname()
    serve_message = "Serving HTTP on {host} port {port} (http://{host}:{port}/) ..."
    print(serve_message.format(host=sa[0], port=sa[1]))
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nKeyboard interrupt received, exiting.")
        sys.exit(0)


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--thread', action='store_true', help='Run Threading HTTP Server')
    parser.add_argument('--process', action='store_true', help='Run Processing HTTP Server')
    parser.add_argument('--bind', '-b', default='', metavar='ADDRESS',
                        help='Specify alternate bind address '
                             '[default: all interfaces]')
    parser.add_argument('port', action='store',
                        default=8000, type=int,
                        nargs='?',
                        help='Specify alternate port [default: 8000]')
    args = parser.parse_args()

    if args.thread:
        server = ThreadedHTTPServer
    elif args.process:
        server = ForkedHTTPServer
    else:
        server = http.server.HTTPServer

    handler = EchoHandler
    runserver(server=server, handler=handler, port=args.port, bind=args.bind)

使用例

開発用にさっと使えるHTTPサーバーであるので、http.serverと同じように使える。 同じように書いたのだから当たり前である。

実行例は次の通りになる。

GET

$ curl -v -X GET http://127.0.0.1:8000/foo?q=bar
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET /foo?q=bar HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.52.1
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: BaseHTTP/0.6 Python/3.5.3
< Date: Fri, 28 Jul 2017 14:07:55 GMT
< Content-length: 302
< Content-Type: text/plain; charset=utf-8
<
CLIENT_VALUES:
client_address=('127.0.0.1', 60502) (127.0.0.1)
command=GET
path=/foo?q=bar
real_path=/foo
query=q=bar
request_version

SERVER VALUES:
server_version=BaseHTTP/0.6
sys_version=Python/3.5.3
protocol_version=HTTP/1.0

HEADERS RECEIVED:
Accept=*/*
Host=127.0.0.1:8000
User-Agent=curl/7.52.1
* Curl_http_done: called premature == 0
* Closing connection 0

HEAD

$ curl -v -I http://127.0.0.1:8000/foo?q=bar
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> HEAD /foo?q=bar HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.52.1
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
HTTP/1.0 200 OK
< Server: BaseHTTP/0.6 Python/3.5.3
Server: BaseHTTP/0.6 Python/3.5.3
< Date: Fri, 28 Jul 2017 14:09:41 GMT
Date: Fri, 28 Jul 2017 14:09:41 GMT
< Content-length: 303
Content-length: 303
< Content-Type: text/plain; charset=utf-8
Content-Type: text/plain; charset=utf-8

<
* Curl_http_done: called premature == 0
* Closing connection 0

POST

$ curl -v -X POST http://127.0.0.1:8000/ -F name=dhellmann -F foo=bar -F datafile=@profiles.ini
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> POST / HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.52.1
> Accept: */*
> Content-Length: 527
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------0508b2c32fa178f2
>
* Done waiting for 100-continue
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: BaseHTTP/0.6 Python/3.5.3
< Date: Fri, 28 Jul 2017 14:10:42 GMT
< Content-Type: text/plain; charset=utf-8
<
CLIENT_VALUES:
client_address=('127.0.0.1', 60509) (127.0.0.1)
command=POST
path=/
real_path=/
query=
request_version

SERVER VALUES:
server_version=BaseHTTP/0.6
sys_version=Python/3.5.3
protocol_version=HTTP/1.0

HEADERS RECEIVED:
Accept=*/*
Content-Length=527
Content-Type=multipart/form-data; boundary=------------------------0508b2c32fa178f2
Expect=100-continue
Host=127.0.0.1:8000
User-Agent=curl/7.52.1

FORM DATA:
name=dhellmann
foo=bar
Upload datafile as 'profiles.ini' (122 bytes)
* Curl_http_done: called premature == 0
* Closing connection 0

この先は

方向性としては、WSGIを調べる、socketserverを調べるとなるが、なかなか大変そうである。 これ以上複雑なHTTPサーバーが必要ならばNginxをインストールしたほうが楽そうではある。

*1:紀伊國屋で購入したが、ちょっとタイミングが悪く高値をつかんでしまった。