naritoブログ

このブログはDjangoとBootstrap4で作成されました
ソースコード

Pythonで、DjangoライクなWebフレームワークを作る〜②はじめの一歩

プログラミング関連 Django Python WSGI(wsgirefモジュール) 約95日前
2017年7月14日17:42
完成品
https://github.com/naritotakizawa/ngo

Pythonで、DjangoライクなWebフレームワークを作る〜①WSGIアプリケーションについて
https://torina.top/detail/348/

前回、WSGIについて書きました。
今回は具体的に実装していきたいと思います。

まずですが、以下のようなディレクトリの構造にします。
今回作るフレームワークは、「Django」をシンプルにしたものということで、DjangoからDjaを抜いて「ngo」とします。
manage.py
ngo/
    __init__.py
    response.py
    wsgi.py



ngo/wsgi.py


"""WSGIに関するモジュール."""
class WSGIRequest:
    """requestオブジェクトを作成するクラス."""

    def __init__(self, environ):
        self.environ = environ
        self.path_info = environ['PATH_INFO']


class WSGIHandler:
    """WSGIアプリケーションとなるクラス."""

    request_class = WSGIRequest

    def __call__(self, environ, start_response):
        """WSGI-interface."""
        request = self.request_class(environ)
        response = self.get_response(request)
        status = '{} {}'.format(
            response.status_code, response.reason_phrase
        )
        start_response(status, response.headers)
        return response

    def get_response(self, request):
        """対応するviewを呼び出し、HttpResponseオブジェクトを返す."""
        from ngo.response import HttpResponse
        return HttpResponse('Hello World')


def cast_finish_response(content):
    """WSGIアプリケーションがreturnする値として正しい形に変換する.
    引数として、以下のようなデータを受け取ることができます。
    >>> case1 = 'Hello'
    >>> case2 = ['Hello']
    >>> case3 = ['H', 'e', 'l', 'l', 'o']
    >>> case4 = b'Hello'
    >>> case5 = [b'Hello']
    >>> case6 = [b'H', b'e', b'l', b'l', b'o']
    >>> case7 = ''
    >>> case8 = b''
    >>> list(cast_finish_response(case1))
    [b'Hello']
    >>> list(cast_finish_response(case2))
    [b'Hello']
    >>> list(cast_finish_response(case3))
    [b'H', b'e', b'l', b'l', b'o']
    >>> list(cast_finish_response(case4))
    [b'Hello']
    >>> list(cast_finish_response(case5))
    [b'Hello']
    >>> list(cast_finish_response(case6))
    [b'H', b'e', b'l', b'l', b'o']
    >>> list(cast_finish_response(case7))
    [b'']
    >>> list(cast_finish_response(case8))
    [b'']
    """
    if isinstance(content, bytes):
        yield content
    elif isinstance(content, str):
        yield content.encode('utf-8')
    else:
        for char in content:
            if isinstance(char, bytes):
                yield char
            else:
                yield char.encode('utf-8')




このWSGIHandlerクラスが全ての出発点です。
https://github.com/django/django/blob/master/django/core/handlers/wsgi.py
のWSGIHandlerと大体の流れは同じです。
実際にサーバーを動かすと、サーバーにリクエストが来る度に__call__が呼び出され、WSGIRequestオブジェクトを作り、対応するviewを探し、そのviewに作ったWSGIRequestオブジェクトを渡し、viewではそのリクエストを使ってhtmlを作成し、HttpResponseオブジェクトを返す、という流れです。
まだ序盤なので、get_responseでは単純にHTTPResponseオブジェクトを返すだけにします。
class WSGIHandler:
    """WSGIアプリケーションとなるクラス."""

    request_class = WSGIRequest

    def __call__(self, environ, start_response):
        """WSGI-interface."""
        request = self.request_class(environ)
        response = self.get_response(request)
        status = '{} {}'.format(
            response.status_code, response.reason_phrase
        )
        start_response(status, response.headers)
        return response

    def get_response(self, request):
        """対応するviewを呼び出し、HttpResponseオブジェクトを返す."""
        from ngo.response import HttpResponse
        return HttpResponse('Hello World')



WSGIRequestクラスはでは、リクエストオブジェクトを作ります。
django.core.handlers.wsgi.WSGIRequest を基にしているのですが、今回はシンプルにpath_infoだけ格納しておきます。これはリクエストがあったURLの情報となります。
こいつを使い、対応するviewを呼び出す予定です。
class WSGIRequest:
    """requestオブジェクトを作成するクラス."""

    def __init__(self, environ):
        self.environ = environ
        self.path_info = environ['PATH_INFO']



WSGIHandlerの__call__では、イテラブルで、中身はそれぞれbytesなオブジェクトを返すというルールがあります。
そのルールに合わせるための関数がこのcast_finish_responseです。
"Hello World"というstrだろうとb"Hello world"というbytesだろうと、["Hello World"]というリストだろうと、良い塩梅に変換してくれます。
def cast_finish_response(content):
    """WSGIアプリケーションがreturnする値として正しい形に変換する.
    引数として、以下のようなデータを受け取ることができます。
    >>> case1 = 'Hello'
    >>> case2 = ['Hello']
    >>> case3 = ['H', 'e', 'l', 'l', 'o']
    >>> case4 = b'Hello'
    >>> case5 = [b'Hello']
    >>> case6 = [b'H', b'e', b'l', b'l', b'o']
    >>> case7 = ''
    >>> case8 = b''
    >>> list(cast_finish_response(case1))
    [b'Hello']
    >>> list(cast_finish_response(case2))
    [b'Hello']
    >>> list(cast_finish_response(case3))
    [b'H', b'e', b'l', b'l', b'o']
    >>> list(cast_finish_response(case4))
    [b'Hello']
    >>> list(cast_finish_response(case5))
    [b'Hello']
    >>> list(cast_finish_response(case6))
    [b'H', b'e', b'l', b'l', b'o']
    >>> list(cast_finish_response(case7))
    [b'']
    >>> list(cast_finish_response(case8))
    [b'']
    """
    if isinstance(content, bytes):
        yield content
    elif isinstance(content, str):
        yield content.encode('utf-8')
    else:
        for char in content:
            if isinstance(char, bytes):
                yield char
            else:
                yield char.encode('utf-8')


ngo/response.py


"""HTTPレスポンスに関するモジュール."""
from http.client import responses
from wsgiref.headers import Headers
from ngo.wsgi import cast_finish_response


class HttpResponse:
    """HTTPレスポンス情報を格納するクラス."""

    def __init__(self, content=b'',
                 content_type='text/html; charset=UTF-8', status_code=200):
        if isinstance(content, bytes):
            self.content = content
        elif isinstance(content, str):
            self.content = content.encode('utf-8')

        self.content_type = content_type
        self.status_code = status_code
        self.reason_phrase = responses.get(status_code, 'Unknown Status Code')
        self.headers = [
            ('Content-Type', self.content_type),
            ('Content-Length', str(len(self.content)))
        ]
        self.headers_dict = Headers(self.headers)

    def __iter__(self):
        return cast_finish_response(self.content)



reason_phraseは、Status Code:200 OK のOKの部分です。
404ならば、NOT_FOUNDとなります。
標準ライブラリのhttp.client.responsesを使うことで、簡単に取得できます。
self.reason_phrase = responses.get(status_code, 'Unknown Status Code')


これは少し説明が必要でしょう。
        self.headers = [
            ('Content-Type', self.content_type),
            ('Content-Length', str(len(self.content)))
        ]
        self.headers_dict = Headers(self.headers)


WSGIの仕様としては、HTTPヘッダは以下のように定義する必要があります。
def simple_app(environ, start_response):
    status = '200 OK'  # HTTP Status
    # HTTP ヘッダー
    headers = [
        ('Content-Type', self.content_type),
        ('Content-Length', '0')
    ]
    start_response(status, headers)
    return b"Hello World"


もしheadersに追加したい場合は、以下のようになるでしょう。
これは実際、面倒です。
    headers = [
        ('Content-Type', self.content_type),
        ('Content-Length', '0')
    ]
    headers.append(
        (ヘッダの名前, 値 ),
    )


理想を言えば、たとえば以下のようにできるのが良いはずです。
操作しやすいし、直感的です。
    headers[ヘッダの名前] = 値


これを実現するために、wsgiref.headers.Headersを利用しています。
以下のように、self.headers_dict['Accept-Language'] = 'ja'と入れてみて、self.headersを出力してみます。
        self.headers = [
            ('Content-Type', self.content_type),
            ('Content-Length', str(len(self.content)))
        ]
        self.headers_dict = Headers(self.headers)
        self.headers_dict['Accept-Language'] = 'ja'
        print(self.headers)


すると、ちゃんとself.headersに正しい形で追加されています。非常に便利です。
[('Content-Type', 'text/html; charset=UTF-8'), ('Content-Length', '11'), ('Accept-Language', 'ja')]


先程のcast_finish_responseを使っています。
WSGIHandlerの__call__では最終的にこのHTTPResponseオブジェクトが返され、その後に__iter__が呼び出されます。
WSGIHandler側で上手いこと正しい形にしてreturn しても良いのですが、DjangoもHttpResponseを返し、__iter__を実装する形にしているので合わせました。
    def __iter__(self):
        return cast_finish_response(self.content)



manage.py


このmanage.pyを実行すると、開発用サーバーが起動します。
from wsgiref.simple_server import make_server
from wsgiref.validate import validator
from ngo.wsgi import WSGIHandler


def runserver(ip='127.0.0.1', port='8000'):
    """開発用サーバーを起動する"""
    application = validator(WSGIHandler())
    with make_server(ip, int(port), application) as httpd:
        print('Serving HTTP on %s:%s...' % (ip, port))
        httpd.serve_forever()
    

if __name__ == '__main__':
    runserver()


前回もやりましたが、make_serverにapplicationを指定して起動しているだけです。
しかし、validatorという関数を使っています。
application = validator(WSGIHandler())


このvalidatorは何かというと、公式ドキュメント日本語訳から抜粋します。
とりあえず使っておくと便利そうです。


WSGI アプリケーションのオブジェクト、フレームワーク、サーバまたはミドルウェアの作成時には、その新規のコードを wsgiref.validate を使って準拠の検証をすると便利です。このモジュールは WSGI サーバやゲートウェイと WSGI アプリケーションオブジェクト間の通信を検証する WSGI アプリケーションオブジェクトを作成する関数を提供し、双方のプロトコル準拠をチェックします。



上のようにwsgiアプリケーションはラップしていくことができます。
これによりWSGIミドルウェアを作成することも簡単で、リスクエストURLの最後に「/」がなければ/をつけてリダイレクトさせる、というようなWSGIミドルウェアを後で作成する予定です。


python manage.py で起動し、実際にブラウザでhttp://127.0.0.1:8000/ にアクセスしてみましょう。
Hello Worldと表示されればここまでは順調です。