Pythonで、WSGIミドルウェアを作ってみた

WSGIアプリケーションを作る中で、WSGIミドルウェアの存在を知りました。
第3回 WSGIミドルウェアの作成:WSGIとPythonでスマートなWebアプリケーション開発を|gihyo.jp … 技術評論社

 
そこで今回、WSGIミドルウェアを作ってみることにしました。

目次

 

環境

  • Windows10
  • Python3.5.2

   

ターミナルに出力するだけのWSGIミドルウェア

関数ベースのWSGIアプリの場合
# func_with_wsgi_middleware.py
from wsgiref.simple_server import make_server

# WSGI Middleware
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware")
        return self.app(environ, start_response)

# WSGI app
def hello_app(environ, start_response):
    print("WSGI app")
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, middleware."]

# Middlewareの追加
application = Middleware(hello_app)

if __name__ == "__main__":
    # Middlewareが含まれるWSGIアプリを起動
    httpd = make_server('', 8000, application)
    print("Serving on port 8000...")
    httpd.serve_forever()

 
実行すると、

$ python func_with_wsgi_middleware.py
Middleware
WSGI app

がターミナルに表示されました。WSGIミドルウェアが動作しているようです。

 

クラスベースのWSGIアプリの場合
# class_with_wsgi_middleware.py
# ミドルウェアは同じ

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("WSGI app")
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, class with middleware."]

# Middlewareを追加
application = WsgiApp()
application = Middleware(application)

で関数ベースと同じことができます。

 

複数のWSGIミドルウェアWSGIアプリに載せる場合

# multi_middleware.py
class Middleware1(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware1")
        return self.app(environ, start_response)

class Middleware2(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware2")
        return self.app(environ, start_response)

class Middleware3(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware3")
        return self.app(environ, start_response)

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("WSGI app")
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, class with middleware."]
    
application = WsgiApp()
application = Middleware1(application)
application = Middleware2(application)
application = Middleware3(application)

 
実行すると、

$ python multi_middleware.py
Middleware3
Middleware2
Middleware1
WSGI app

と、追加したのとは逆順でWSGIミドルウェアが動作しました。

 

レスポンスを返すWSGIミドルウェア

# middleware_response.py
# WSGI Middleware
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        if environ['QUERY_STRING']:
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"respond by middleware."]
        return self.app(environ, start_response)

# WSGIアプリは今までのものと同様

application = WsgiApp()
application = Middleware(application)

とすると、

  • クエリ文字列が無い場合、ブラウザにrespond by app.と表示
  • クエリ文字列がある場合、ブラウザにrespond by middleware.と表示

となります。

 

レスポンスを返すのはWSGIミドルウェアだけの場合

WSGIアプリとWSGIミドルウェアがあり、両方実行されるがレスポンスはWSGIミドルウェアのみというケースを考えます。

最初、

# only_middleware_response_without_app.py
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("middle ware")
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"respond by middleware."]

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("wsgi app")

application = WsgiApp()
applicatoin = Middleware(application)

としてみましたが、実行してみると

$ python only_middleware_response_without_app.py
middle ware 

と、"wsgi app"はprintされませんでした。

 
WSGIアプリを実行するには、

# only_middleware_response_with_app.py
class Middleware(object):
    def __call__(self, environ, start_response):
        print("middle ware")

        # WSGI appの処理をしたいときは、レスポンスを返す前に呼び出す
        self.app(environ, start_response)

        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"respond by middleware."]

と、レスポンスを返す前にWSGIアプリを呼び出す必要があります。

 

HTTPヘッダを追加するWSGIミドルウェア

HTTPヘッダを追加するWSGIミドルウェアが欲しかったため調べてみたところ、stackoverflowに情報がありました。
python - How to add http headers in WSGI middleware? - Stack Overflow

# add_header_middleware.py
class Middleware(object):
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        # __call__()メソッドの中に、start_responseをカスタマイズする関数を用意する
        # また、これは関数なので、第一引数にはselfを設定しないこと
        def start_response_with_cookie(status_code, headers, exc_info=None):
            print("custom start_response")
            headers.append(('Set-Cookie', "hoge=fuga"))
            return start_response(status_code, headers, exc_info)

        print("Middleware")
        return self.app(environ, start_response_with_cookie)

と、ミドルウェア__call__()メソッドの中にネストして、カスタマイズしたstart_response()関数を用意します。

 
実行すると、

$ python add_header_middleware.py
Middleware
WSGI app
custom start_response

となり、レスポンスヘッダにも以下が出力されていました。

Content-Type: text/plain
Set-Cookie: hoge=fuga

 

WSGIアプリの例外をハンドリングするWSGIミドルウェア

# exception_from_app.py
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware")
            return self.app(environ, start_response)
        except Exception:
            print("raised exception")
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"raised exception."]

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("app")
        raise Exception

application = WsgiApp()
application = Middleware(application)

としたところ、

$ python exception_from_app.py
Middleware
app
raised exception

となり、ブラウザには「raised exception.」と表示されました。

 

複数WSGIミドルウェアがあった時の例外の伝播

WSGIアプリで例外が発生した時に、複数WSGIミドルウェアがあった場合はどうなるかを調べるため、

class Middleware1(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware1")
            return self.app(environ, start_response)
        except Exception:
            print("catch middleware1")
            raise

class Middleware2(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware2")
            return self.app(environ, start_response)
        except Exception:
            print("catch middleware2")
            raise

class Middleware3(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware3")
            return self.app(environ, start_response)
        except Exception:
            print("catch middleware3")
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"catch exception middleware3."]

application = WsgiApp()
application = Middleware1(application)
application = Middleware2(application)
application = Middleware3(application)

としたところ、ブラウザに「raised exception.」と表示されました。

ターミナルには

$ python exception_from_app_with_multi_middleware.py
Middleware3
Middleware2
Middleware1
app
catch middleware1
catch middleware2
catch middleware3

となりました。

アプリの例外では、複数WSGIミドルウェアをさかのぼるようです。

WSGIミドルウェアで例外が出た場合

class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware")
        raise Exception

class WsgiApp(object):
    def __call__(self, environ, start_response):
        try:
            print("WSGI app")
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"Hello, class with middleware."]
        except:
            print("exception")

としたところ、ブラウザに「A server error occurred. Please contact the administrator.」と表示されました。

ターミナルには

$ python exception_from_middleware.py
Middleware
Traceback (most recent call last):
#...
  File "exception_from_middleware.py", line 12, in __call__
    raise Exception
Exception

と表示されました。

まだWSGIアプリが呼び出されていないため、WSGIアプリでの例外捕捉はできないようです。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_middleware_sample

mod_wsgiやuWSGIで使われる、デフォルトのWSGI callback名を調べてみた

WSGIアプリの書き方について調べている時、

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

や、

class MyWSGIApplication(object):
    def __call__(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, world."]

application = MyWSGIApplication()

と、applicationというcallback名を使っている例が多かったです。

WSGI callback名の決まりがあるのかを調べたところ、特に決まりはなさそうでした。
python - How do I actually use WSGI? - Stack Overflow

 
そんな中、WSGIアプリをmod_wsgiやuWSGIで動かす時に、デフォルトのWSGI callback名が気になったため、調べてみました。

 

mod_wsgi

applicationです。
WSGICallableObject — mod_wsgi 4.5.8 documentation

 
callback名を変更するには、WSGICallableObjectに設定をすれば良さそうでした。

 

uWSGI

applicationだと思います*1

 
callback名を変更するには、

を使うのが良さそうでした。

 

まとめ

特に指定のない限り、WSGI callback名はapplicationとしておけば良さそうです。

*1:公式ドキュメントには明記されていませんでしたが、Snippetsなどで使われていたため

#nseg #jxug #glnagano JXUGC No.19 Xamarin ハンズオン 長野大会 featuring NSEG に参加しました

11/19にギークラボ長野で開かれた、JXUGC No.19 Xamarin ハンズオン 長野大会 featuring NSEG に参加しました。
JXUGC #19 Xamarin ハンズオン 長野大会 featuring #nseg - connpass

 
Xamarinの話を聞くたびに、手元のMADOSMA(Windows10 mobile)にXamarinアプリがデプロイできるのか気になっていました。

そのため、イベント募集ページにはWindows10 mobileの記載がないものの、参加を決めました*1

 

開発環境

今回は以下のような環境にて参加し、VM上のWindowsでハンズオンアプリの作成を行いました。

  • ホスト

  • ゲスト

  • 実機

    • MADOSMA Q501 (Windows10 mobile ver.1607)
    • 接続
      • 物理的にはMacのUSBポートへ接続
      • 論理的にはVM上のWindowsへ接続
        • MADOSMAを挿したらVMware Fusionにて「Mac/Win、どちらに接続するか」と聞かれたため、Windowsへ接続
        • ドライバのインストールなどは不要

 
ただ、当日「デプロイできませんでした」では困るので、事前にデプロイできるのを確認しておきました。

当日も、ハンズオンアプリが無事にデプロイできました。

 

当日のメモ

自分が気にしていたのはWindows10 mobileまわりなので、そのあたりのメモになります。

  • XamarinのXAMLWPFとは違う
    • ネストするような表現がなく、今後も実装する予定はない
  • Xamarin.FormsもWindows10 mobileにデプロイできる
  • Windows10 mobileの場合、Xamarin NativeではUWPで作る
  • Xamarin Nativeで作っておけば、iOSやAndoroid、Windowsでも同じバックエンドを使い回せる
  • Windows10 mobileだけにデプロイするなら、UWPを使うのが良い
  • XamarinのMVVMライブラリは、Prismの事例が多い
  • XamarinやるならMacがおすすめ(iOSのビルドで必要になるため)
  • MacではWindows10 mobile向けのビルドはできない

 

その他

当日は受付係をしました。自分の準備不足があり、改めて裏方の大変さを感じました。

 
最後になりましたが、開催・運営をされたみなさま、ありがとうございました。

*1:バックアップとして、古いNexus7 2012を持参

Macで、uWSGIをインストールしたらエラーになった

MacPython環境にuWSGIをインストールしようとしたところ、

# Xcodeのバージョン確認
$ xcodebuild -version
Xcode 8.1
Build version 8B62

# pipでインストール
$ pip install uwsgi
...
  *** uWSGI linking ***
...
  ld: file not found: /usr/lib/system/libsystem_symptoms.dylib for architecture x86_64
  clang: error: linker command failed with exit code 1 (use -v to see invocation)
  *** error linking uWSGI ***
...

というエラーになったため、メモを残します。

 

環境

 

対応

uWSGIのGitHubにIssueがありました。
Error in pip install uwsgi in macos x · Issue #1364 · unbit/uwsgi

 
そこに記載されていた通り実行したところ、

# Issueの手順に従う
$ brew unlink libxml2
$ brew uninstall libxml2
$ brew install --with-python libxml2
$ brew link libxml2 --force

# 再度インストール
$ pip install uwsgi
...
Successfully installed uwsgi-2.0.14

インストールできました*1

*1:Issueの手順のうち「brew link libxmls2 --force」に、「libxml"s"2」と余計なsがあるので注意

Thunderbird Portableで未読メールや未読フォルダの色を変更する

Windows10のデフォルトフォントの場合、Thunderbirdでの表示フォントが少々細くなり、未読などが分かりづらくなりました。

そのため、未読メールや未読フォルダのフォント色を変更しようと考えました。

ただ、通常のThunderbirdだと色変更の設定情報があったものの、Thunderbird Portableの場合はどうすれば良いか悩んだため、メモを残します。

 

環境

なお、Thunderbird Portableは古いバージョンから使っているため、現在のフォルダとは位置が異なるかもしれません。

 

編集するファイル

path\to\ThunderbirdPortable\Data\profile\chromeの中に、userChrome.cssファイルを作成します。

なお、Data\profileの中にはchromeディレクトリがありませんでしたので、

という作業をしました。

 
設定内容は、普通のThunderbirdと変わりません。

@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
/* スレッドペインの未読メッセージの表示色をオレンジに変える */
treechildren::-moz-tree-cell-text(unread) {
color: deeppink !important;
}

/* フォルダペインの未読メッセージの表示色をオレンジに変える */
/* single folder with unread messages */
#folderTree > treechildren::-moz-tree-cell-text(hasUnreadMessages-true) {
color: deeppink !important;
}

/* closed folder containing subfolder with unread messages */
#folderTree > treechildren::-moz-tree-cell-text(closed, subfoldersHaveUnreadMessages-true) {
color: deeppink !important;
}

/* open folder containing subfolder with unread messages */
#folderTree > treechildren::-moz-tree-cell-text(subfoldersHaveUnreadMessages-true) {
color: deeppink !important;
}

 

参考

Pythonで、Cookieを扱うWSGIフレームワークを自作してみた

前回、書籍「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」をもとに、PythonでURLルーティング機能だけがあるWSGIフレームワークを自作しました。

gihyo.jp

 
今回は、以下のRFC6265やWebサイトを参考に、前回のWSGIフレームワークCookieを扱える機能を追加してみます。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 

Webサーバへ機能追加

前回までと同様、自作WSGIフレームワークは、WSGI準拠の自作Webサーバ上で動作させます。

ただ、前回までの自作WebサーバではCookieを扱うことができないため、機能を追加します。

 

Cookieを扱えるようにする

UAからCookieを含むHTTPリクエストを自作Webサーバへ送信したところ、

GET / HTTP/1.1
...
Cookie: visit=1

というリクエストラインが取得できました。
Cookieとセッション管理 - Qiita

 
RFC6265にて、UAからサーバへはCookieヘッダをいくつ送信可能なのかを調べたところ、

UA は HTTP リクエストを生成する際に、複数の Cookie ヘッダを添付してはならない。

5.4. Cookie ヘッダ | RFC 6265 — HTTP State Management Mechanism (日本語訳)

との記載がありました。一つだけと考えて良さそうです。

 
続いて、WSGIサーバからWSGIフレームワークCookieを渡す方法を探したところ、接頭辞HTTPを持つ変数をenviron辞書へ設定すれば良さそうでした。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
そこで

# "Cookie: "を削除
HTTP_HEADER_COOKIE = 'Cookie:'
request_cookies = [line.decode('utf-8').replace(HTTP_HEADER_COOKIE, '').lstrip() for line in byte_request_lines if line.decode('utf-8').find(HTTP_HEADER_COOKIE) == 0]

# environ辞書へ設定
env['HTTP_COOKIE']   = request_cookies[0]

のように実装しました。

 

サーバで生成するDateヘッダに、現在時刻をセットする

今まで、自作Webサーバでは固定値をセットしていました。

今回、CookieのMax-Age属性を試すときに分かりやすくするため、以下を参考に現在時刻を設定するように修正します。
http - RFC 1123 Date Representation in Python? - Stack Overflow

 
以上でWSGIサーバの機能追加は完了です。

 

WSGIフレームワークへ機能追加

自作WSGIフレームワークに対し、Cookieを扱う機能を追加します。

PythonCookieを簡単に扱う方法を調べたところ、標準ライブラリにあるhttp.cookies.SimpleCookieを使うのが良さそうでした。
21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント

 
文字列からSimpleCookieオブジェクトを生成する方法を調べるためにソースコードを読んでみました。

SimpleCookieクラスの__init__()メソッドに文字列を渡すと内部ではload()メソッドが呼ばれることから、文字列からSimpleCookieオブジェクトを生成できていました。
cpython/cookies.py - GitHub

 
そのため、自作WSGIフレームワークでは

def __call__(self, environ, start_response):
    self.cookie = SimpleCookie(environ['HTTP_COOKIE']) if 'HTTP_COOKIE' in environ else None
# ...

として、SimpleCookieオブジェクトを保持することにしました。

 

WSGIアプリへ機能追加

Cookieの挙動確認

機能を追加する前に、Cookieの挙動を確認します。

 

Cookieの属性について

RFC6265にて、

クッキーの属性は、 UA からサーバへは返されないことに注意。 特に,サーバは、単独の Cookie ヘッダからは,クッキーが[ いつ失効するのか?/ どのホストに対して有効なのか?/ どのパスに対して有効なのか?/ Secure や HttpOnly 属性を伴って設定されたものかどうか? ]を決定できない。

4.2.2. 意味論 | RFC 6265 — HTTP State Management Mechanism (日本語訳)

と記載されているため、その挙動を確認します。

 
cookie_test_set_property_only_once.py

# python server.py cookie_test_set_property_only_once:app
from http.cookies import SimpleCookie
from no1_1_cookie_framework import MyWSGIFramework

def cookie(environ, start_response):
    visit_in_html = 1
    headers = [('Content-Type', 'text/plain'),]

    if app.cookie:
        visit_in_html = int(app.cookie['http_only'].value) + 1
    else:
        http_only = SimpleCookie('http_only=1')
        http_only['http_only']['httponly'] = True
        headers.append(('Set-Cookie', http_only.output(header='')))
    
    start_response('200 OK', headers)
    return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]


# URLルーティング設定
app = MyWSGIFramework([
    ('/', cookie),
])

というWSGIアプリを作成・実行し、ヘッダを確認してみたところ、

# 一回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
...

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: http_only=1; HttpOnly
Date: Wed, 28 Sep 2016 08:06:36 GMT
Server: MyWSGIServer 0.3


#------------------------------
# 二回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
...
Cookie: http_only=1

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Wed, 28 Sep 2016 08:06:53 GMT
Server: MyWSGIServer 0.3

のように、二回目のRequest HeaderのCookieヘッダにはHttpOnly属性が含まれていませんでした。

 

CookieのMax-Age属性について

Max-Age属性ではCookieが失効するまでの秒数を指定できるため、この挙動も確認してみます。

 
cookie_test_set_max_age.py

# python server.py cookie_test_set_max_age:app
from http.cookies import SimpleCookie
from no1_1_cookie_framework import MyWSGIFramework

def cookie(environ, start_response):
    visit_in_html = 1
    headers = [('Content-Type', 'text/plain'),]

    if app.cookie:
        visit_in_html = int(app.cookie['http_only'].value) + 1
    else:
        http_only = SimpleCookie('http_only=1')
        http_only['http_only']['max-age'] = 5
        headers.append(('Set-Cookie', http_only.output(header='')))
    
    start_response('200 OK', headers)
    return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]


# URLルーティング設定
app = MyWSGIFramework([
    ('/', cookie),
])

というWSGIアプリを作成し、指定した秒数以上(今回は5秒以上)待ってから二回目のリクエストを送ったところ、

# 一回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: ja,en-US;q=0.8,en;q=0.6

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: http_only=1; Max-Age=5
Date: Wed, 28 Sep 2016 08:39:58 GMT
Server: MyWSGIServer 0.3


#------------------------------
# 二回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: ja,en-US;q=0.8,en;q=0.6

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: http_only=1; Max-Age=5
Date: Wed, 28 Sep 2016 08:40:51 GMT
Server: MyWSGIServer 0.3

と、二回目のRequest HeadersにCookieヘッダが含まれていませんでした。

 

複数のSet-Cookieヘッダを作成する機能を追加

RFC6265より、複数のCookieがある場合、その分Set-Cookie応答ヘッダを用意する必要があります。

Set-Cookie 応答ヘッダは[ ヘッダ名 "Set-Cookie", 文字 ":", 1個のクッキー ]の並びからなる。 各クッキーは、1個の name-value-pair ([ 名前 cookie-name, 値 cookie-value ]の組)から開始され,ゼロ個以上の属性([ 名前 attribute-name, 値 attribute-value ]の組)が後続する。

4.1.1. 構文 | RFC 6265 — HTTP State Management Mechanism (日本語訳)

 
そこで、複数のCookieがあるapp.cookie(SimpleCookieオブジェクト)の値を、output()メソッドを使って確認してみます。
BaseCookie.output() | 21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント

print('-'*10)
print('app_cookie:\n{}\n'.format(app.cookie.output(header='')))
print('-'*10)

結果は、

----------
app_cookie:
 hoge=fuga
 visit=2; HttpOnly

----------

となり、各Cookieが「CRLF + 半角スペース」で連結された状態で取得できました。

 
今回、HTTPレスポンスヘッダはheaders.append(('Set-Cookie', '<Cookie名>=<Cookie値>'))という形で作成するため、

server_cookies = app.cookie.output(header='').split('\r\n')
for sc in server_cookies:
    headers.append(('Set-Cookie', sc))

としました。

 

Cookieの処理をWSGIアプリからWSGIフレームワークへ移動

今までの実装では、Cookieの処理はWSGIアプリ側で行われていました。

これではWSGIアプリを作成するごとにCookieの処理も実装しなければならないため、WSGIフレームワーク側へと移動してみます。

 
WSGIフレームワークでは

の3つのメソッドを用意します。

def set_cookie(self, key, value, **kwargs):
    self.cookie[key] = value
    for k, v in kwargs.items():
        # max-age属性は引数名「max_age」として設定されてくる前提
        # Pythonではハイフンが引数名で使えないため
        self.cookie[key][k.replace('_', '-')] = v

def get_cookie(self, key):
    return self.cookie.get(key, None)

def generate_cookie_header(self):
    # HTTPリクエストヘッダのCookieに複数のCookieが含まれる場合、Cookie名ごとにSet-Cookieヘッダを生成する
    headers = []
    response_cookies = self.cookie.output(header='').split('\r\n')
    for cookie in response_cookies:
        headers.append(('Set-Cookie', cookie))

    return headers

 
それを使用するWSGIアプリは

def cookie(environ, start_response):
    visit_in_html = 1
    headers = [('Content-Type', 'text/plain'),]

    if app.cookie:
        v = app.get_cookie('visit')
        if v:
            visit_in_html = int(v.value) + 1
            app.set_cookie('visit', visit)
        else:
            app.set_cookie('visit', 1)
        app.set_cookie('fuga', 'fugafuga', httponly=True)

    else:
        app.set_cookie('visit', 1)

        # Cookie値の他、max-age属性もセットしてみる
        app.set_cookie('hoge', 'hogehoge', max_age=10)

        # Cookie値の他、httponly属性もセットしてみる
        app.set_cookie('fuga', 'fugafuga', httponly=True)

    # ここまでで作成したCookieをCookieヘッダに設定する
    # リストにリストを追加するので、append()ではなくextend()を使う
    headers.extend(app.generate_cookie_header())

    start_response('200 OK', headers)
    return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]

として、Cookie処理をWSGIフレームワーク側へと移動しました。

 

ソースコード

GitHubに上げました。cookieディレクトリの下が今回のソースコードになります。
thinkAmi-sandbox/wsgi_framework-sample

Pythonで、URLルーティング機能だけがあるWSGIフレームワークを自作してみた

以前、

を自作しました。

これらの自作により、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」の第3章以降もPythonで書く見通しが立ちました。

gihyo.jp

 
第3章以降では、URLルーティングやクッキー・セッションなどの機能が登場します。

ただ、それらの機能を

のどちらの形で実装するか悩みました。

それでも、せっかくなので、後者のWSGIフレームワークを自作してみようと考えました。

そこで今回、URLルーティング機能だけがあるWSGIフレームワークを自作してみました。

なお、学習用途で作ったため、セキュリティ面は考えていませんのであしからず。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 
なお、WSGI準拠のWebサーバは自作したものを使うため、server.pyとして保存しておきます。
wsgi_webserver-sample/multi_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

   

復習:単純なWSGIアプリケーション

まずは復習として、単純なWSGIアプリケーションを作ってみます。

no1_base_app.py

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
return [b"Hello, world."]

 
動作確認として、

(env) >python server.py no1_base_app:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world」 が表示されました。

URLルーティングは実装していないため、http://localhost:8888/hogeのようなURLでも、「Hello, world」が表示されます。

 

URLルーティング機能を作る(関数版)

続いて、URLルーティング機能を持ったアプリを作ります。

ただ、最初からデコレータを使ってURLルーティング機能を実装すると、自分の理解が追いつかないかもしれないと考えました。

そこで、その代わりとなる方法を調べてみたところ、以下の記事がありました。
Serving Static Files and Routing with WSGI

この記事の方法が分かりやすかったため、no2_router_separately_by_func.pyとして実装してみます。

 
まずはURLルーティングのためのタプルのリストを作ります。

routes = [
    ('/', get),
    ('/hoge', hoge),
]

その内容は

  • タプルは2つの要素を持つ
    • ひとつ目の要素は、対象のURLパス
    • ふたつ目の要素は、対象のURLパスにアクセスした時に呼ばれる関数

です。

 
続いて、WSGIサーバから呼ばれる関数を用意します。

def application(environ, start_response):
    for path, func in routes:
        if path == environ['PATH_INFO']:
            return func(environ, start_response)

    return not_found(environ, start_response)

タプルを順次読み込み、リクエストのパス(environ['PATH_INFO'])と一致した場合に、タプルで指定した関数を呼び出しています。

 
あとは、application関数から呼び出される関数を

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge"]

def not_found(environ, start_response):
    start_response('404 Not Found', [('Content-Type', 'text/plain')])
    return [b"Not found."]

と用意して完成です。

 
動作確認として、

(env) >python server.py no2_router_separately_by_func:application 

を実行し、ブラウザでアクセスすると以下のようになります。

アクセス先のURL 表示内容
http://localhost:8888/ Hello, world
http://localhost:8888/hoge hoge
http://localhost:8888/fuga Not found.

 

URLルーティング機能を作る(クラス版)

URLルーティング機能を、関数版からクラス版へと変更してみます。

no3_router_separately_by_class.py

class MyWSGIApplication(object):
    def __init__(self):
        self.routes = [
            ('/', self.get),
            ('/hoge', self.hoge),
        ]

    def get(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, world by class."]

    def hoge(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"hoge by class"]

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by class."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if path == environ['PATH_INFO']:
                return method(environ, start_response)

        return self.not_found(environ, start_response)

app = MyWSGIApplication()

 
動作確認として、

(env) >python server.py no3_router_separately_by_class:app

として、ブラウザでアクセスしても、末尾に「 by class.」が付くだけで、それ以外の動作は変わっていません。

 

フレームワーク

次は

の2つに機能を分割して、それぞれを実装します。

 
WSGIフレームワークno3_router_separately_by_class.pyからURLルーティング機能を移植します。

no4_1_framework.py

class MyWSGIFramework(object):
    def __init__(self, routes):
        self.routes = routes

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by framework."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

 
WSGIアプリはURLルーティング用のタプルのリストと、URLルーティングされた時に呼ばれる関数だけになります。

no4_2_app.py

from no4_1_framework import MyWSGIFramework

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world by framework."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge by framework"]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
])

 
動作確認として、

(env) >python server.py no4_2_app:app

として、ブラウザでアクセスしても、末尾に「by framework」が付くだけで、それ以外の動作は変わっていません。

 

HTMLテンプレートやCSS、画像の配信

最後に、Jinja2を使ってHTMLテンプレートやCSS・画像を表示できるようにします。

CSSや画像の出力については定型的な処理のため、今回はフレームワーク側で処理します。

画像ファイルについては、自作WSGIサーバではsocket.sendfile()を使って送信しています。そのため、画像ファイルをファイルライクオブジェクトの形にして自作WSGIサーバに渡す必要があります。

今回は、バイナリモードでファイルを読み込み、io.BytesIOにてバイナリストリームとすることで、ファイルライクオブジェクトの形にしています*1
16.2. io — ストリームを扱うコアツール — Python 3.5.1 ドキュメント

fullpath = './{}'.format(environ['PATH_INFO'])
with open(fullpath, "rb") as f:
    r = f.read()

binary_stream = io.BytesIO(r)

# 変数content_typeには、ファイルの種類により'text/css'か'image/png'が設定済
start_response('200 OK', content_type)

return binary_stream

 
それを受けて、自作WSGIサーバ(server.py)では、

if 'image' in str_response:
    # 画像データの場合
    self.connection.sendall(str_response.encode('utf-8'))
    self.connection.sendfile(byte_response_body)

else:
    # 画像データ以外の場合
    for byte_body in byte_response_body:
        str_response += byte_body.decode('utf-8')
    self.connection.sendall(str_response.encode('utf-8'))

のような形で、クライアントへデータを送信しています。

なお、PEP3333にはファイルの扱いに関しての記述もありましたが、今回は置いておきます。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
ソースコード全体ですが、WSGIフレームワーク

no5_1_framework.py

import re
import io

class MyWSGIFramework(object):
    def __init__(self, routes, css_dir=None, img_dir=None):
        self.routes = routes

        # 静的ファイル向けのディレクトリ設定
        self.static_dir = '/static'
        css_dir = css_dir if css_dir else "/css/"
        self.css_dir = '{static}{css}'.format(static=self.static_dir, css=css_dir)
        img_dir = img_dir if img_dir else "/images/"
        self.img_dir = '{static}{img}'.format(static=self.static_dir, img=img_dir)

    def static(self, environ, start_response):
        content_type = []
        if re.match(self.css_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'text/css'))
        elif re.match(self.img_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'image/png'))
        else:
            return self.not_found(environ, start_response)

        try:
            fullpath = './{}'.format(environ['PATH_INFO'])
            with open(fullpath, "rb") as f:
                r = f.read()
            binary_stream = io.BytesIO(r)
        except:
            start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
            return [b"Internal server error"]

        start_response('200 OK', content_type)
        return binary_stream

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found."]


    def __call__(self, environ, start_response):
        if re.match(self.static_dir, environ['PATH_INFO']):
            return self.static(environ, start_response) 

        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

となりました。

また、WSGIアプリは、

no5_2_app.py

# python server.py no5_2_app:app
from no5_1_framework import MyWSGIFramework
from jinja2 import Environment, FileSystemLoader

def get(environ, start_response):
# (省略)

def hoge(environ, start_response):
# (省略)

def index(environ, start_response):
    jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
    template = jinja2_env.get_template('index.html')
    html = template.render({'messages': ['hoge', 'fuga', 'piyo']})

    start_response('200 OK', [('Content-Type', 'text/html')])
    return [html.encode('utf-8')]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
    ('/index', index),
])

となりました。

 
動作確認として、

(env) >python server.py no5_2_app:app

として、ブラウザでhttp://localhost:8888/indexへとアクセスすると、HTMLテンプレートやCSS、画像が表示されました。

 

ソースコード

GitHubに上げました。routing_onlyディレクトリの下が、今回のサンプルです。
wsgi_framework-sample/routing_only at master · thinkAmi-sandbox/wsgi_framework-sample

 

参考

 
もし、FlaskやBottleのようにデコレータを使ったURLルーティング機能を実装する場合には、以下のBottleのソースコード解説も参考になるかもしれません。
Python Bottleのソースを読む ルータ編 - TAISA BLOG

 
2016/9/28 追記 ここから

PyConJP2016にてWSGIフレームワークの作り方に関する発表がありました。
#pyconjp でWeb(WSGI)フレームワークの作り方について話してきました - c-bata web

以前kobinのソースコードを読んだとき、ルーティング・リクエスト・レスポンスについて「なぜこのような形で実装したのか」をつみきれないところがありました。

上記の資料ではそれに対する回答が記載されていたため、スッキリするとともにとても参考になりました。ありがとうございました。

2016/9/28 追記 ここまで

*1:この辺りの説明が正しいかどうか分からないため、誤りがあればご指摘いただけるとありがたいです