WSGI ミドルウェアの仕組み

WSGI ミドルウェアは、WSGI アプリケーションとサーバーの間に挿入される中間層である。リクエストの前処理やレスポンスの後処理を、アプリケーション本体のコードを変更せずに追加できる仕組みだ。認証、ロギング、エラーハンドリング、セッション管理など、多くの横断的関心事(cross-cutting concerns)をミドルウェアとして実装できる。

ミドルウェアの基本構造

ミドルウェアは「WSGI アプリケーションをラップして、別の WSGI アプリケーションを返す」という構造を持つ。つまり、ミドルウェア自身も WSGI アプリケーションであり、内部に別のアプリケーションを保持している。

class SimpleMiddleware:
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        # リクエストの前処理をここに書く
        print(f"Request: {environ['REQUEST_METHOD']} {environ['PATH_INFO']}")
        
        # 元のアプリケーションを呼び出す
        response = self.app(environ, start_response)
        
        # レスポンスの後処理をここに書く
        print("Response sent")
        
        return response

このミドルウェアを適用するには、元のアプリケーションをラップする。

def application(environ, start_response):
    status = '200 OK'
    headers = [('Content-Type', 'text/plain')]
    start_response(status, headers)
    return [b'Hello, World!']

# ミドルウェアを適用
wrapped_app = SimpleMiddleware(application)

wrapped_app を WSGI サーバーに渡せば、すべてのリクエストとレスポンスがミドルウェアを通過する。

ミドルウェアのスタック

複数のミドルウェアを重ねて適用することで、機能を積み上げていける。これを「ミドルウェアスタック」と呼ぶ。

app = application
app = LoggingMiddleware(app)
app = AuthMiddleware(app)
app = ErrorHandlerMiddleware(app)

リクエストは外側のミドルウェアから順に通過し、レスポンスは内側から外側へと戻っていく。

リクエスト受信

ErrorHandlerMiddleware → AuthMiddleware → LoggingMiddleware → application

レスポンス返却

この構造により、関心事を分離しながら柔軟に機能を組み合わせられる。

実践的なミドルウェア例

ロギングミドルウェア

リクエストの情報とレスポンス時間を記録するミドルウェアを作成する。

import time

class LoggingMiddleware:
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        start_time = time.time()
        method = environ['REQUEST_METHOD']
        path = environ['PATH_INFO']
        
        # レスポンスステータスを捕捉するためのラッパー
        response_status = []
        
        def custom_start_response(status, headers, exc_info=None):
            response_status.append(status)
            return start_response(status, headers, exc_info)
        
        response = self.app(environ, custom_start_response)
        
        elapsed = time.time() - start_time
        status = response_status[0] if response_status else 'unknown'
        print(f"{method} {path} - {status} - {elapsed:.3f}s")
        
        return response

start_response をラップすることで、アプリケーションが設定したステータスコードを取得している。これはミドルウェアでよく使われるテクニックだ。

認証ミドルウェア

特定のパスへのアクセスを制限する認証ミドルウェアの例を示す。

import base64

class BasicAuthMiddleware:
    def __init__(self, app, username, password, protected_paths=None):
        self.app = app
        self.username = username
        self.password = password
        self.protected_paths = protected_paths or ['/admin', '/api']
    
    def __call__(self, environ, start_response):
        path = environ['PATH_INFO']
        
        # 保護対象のパスかチェック
        needs_auth = any(path.startswith(p) for p in self.protected_paths)
        
        if needs_auth:
            auth_header = environ.get('HTTP_AUTHORIZATION', '')
            
            if not self._check_auth(auth_header):
                status = '401 Unauthorized'
                headers = [
                    ('Content-Type', 'text/plain'),
                    ('WWW-Authenticate', 'Basic realm="Protected"')
                ]
                start_response(status, headers)
                return [b'Authentication required']
        
        return self.app(environ, start_response)
    
    def _check_auth(self, auth_header):
        if not auth_header.startswith('Basic '):
            return False
        
        try:
            encoded = auth_header[6:]
            decoded = base64.b64decode(encoded).decode('utf-8')
            user, pwd = decoded.split(':', 1)
            return user == self.username and pwd == self.password
        except Exception:
            return False

認証に失敗した場合は、元のアプリケーションを呼び出さずに 401 レスポンスを返す。このように、ミドルウェアはリクエストを途中で遮断することもできる。

CORS ミドルウェア

クロスオリジンリクエストを許可するための CORS ヘッダーを付与するミドルウェアだ。

class CORSMiddleware:
    def __init__(self, app, allowed_origins=None):
        self.app = app
        self.allowed_origins = allowed_origins or ['*']
    
    def __call__(self, environ, start_response):
        origin = environ.get('HTTP_ORIGIN', '')
        
        # プリフライトリクエストの処理
        if environ['REQUEST_METHOD'] == 'OPTIONS':
            status = '200 OK'
            headers = self._get_cors_headers(origin)
            headers.append(('Content-Length', '0'))
            start_response(status, headers)
            return [b'']
        
        # CORS ヘッダーを追加するラッパー
        def custom_start_response(status, headers, exc_info=None):
            cors_headers = self._get_cors_headers(origin)
            headers = list(headers) + cors_headers
            return start_response(status, headers, exc_info)
        
        return self.app(environ, custom_start_response)
    
    def _get_cors_headers(self, origin):
        if '*' in self.allowed_origins:
            allow_origin = '*'
        elif origin in self.allowed_origins:
            allow_origin = origin
        else:
            allow_origin = ''
        
        return [
            ('Access-Control-Allow-Origin', allow_origin),
            ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'),
            ('Access-Control-Allow-Headers', 'Content-Type, Authorization'),
        ]

プリフライトリクエスト(OPTIONS メソッド)を処理し、通常のリクエストには CORS ヘッダーを付与している。

レスポンスボディを加工するミドルウェア

レスポンスボディを加工するミドルウェアは、やや複雑になる。WSGI のレスポンスはイテラブルなので、それを消費して加工し、新たなイテラブルを返す必要がある。

class GzipMiddleware:
    def __init__(self, app, min_length=500):
        self.app = app
        self.min_length = min_length
    
    def __call__(self, environ, start_response):
        # クライアントが gzip をサポートしているか確認
        accept_encoding = environ.get('HTTP_ACCEPT_ENCODING', '')
        if 'gzip' not in accept_encoding:
            return self.app(environ, start_response)
        
        response_started = []
        captured_headers = []
        
        def custom_start_response(status, headers, exc_info=None):
            response_started.append(status)
            captured_headers.extend(headers)
            # 実際の start_response は後で呼ぶ
            return lambda s: None  # write() は使わない想定
        
        # レスポンスボディを収集
        response = self.app(environ, custom_start_response)
        body = b''.join(response)
        
        # 小さいレスポンスは圧縮しない
        if len(body) < self.min_length:
            start_response(response_started[0], captured_headers)
            return [body]
        
        # gzip 圧縮
        import gzip
        compressed = gzip.compress(body)
        
        # ヘッダーを更新
        new_headers = [
            (k, v) for k, v in captured_headers
            if k.lower() != 'content-length'
        ]
        new_headers.append(('Content-Encoding', 'gzip'))
        new_headers.append(('Content-Length', str(len(compressed))))
        
        start_response(response_started[0], new_headers)
        return [compressed]

レスポンスボディを一度メモリに読み込む必要があるため、大きなファイルのストリーミングには適さない。用途に応じた設計が求められる。

関数スタイルのミドルウェア

クラスではなく関数でミドルウェアを書くこともできる。クロージャを活用したシンプルな書き方だ。

def timing_middleware(app):
    def middleware(environ, start_response):
        import time
        start = time.time()
        response = app(environ, start_response)
        elapsed = time.time() - start
        print(f"Request took {elapsed:.3f}s")
        return response
    return middleware

# 使用例
app = timing_middleware(application)

デコレータとして使う場合も自然に書ける。

def with_timing(app):
    def wrapper(environ, start_response):
        import time
        start = time.time()
        result = app(environ, start_response)
        print(f"Elapsed: {time.time() - start:.3f}s")
        return result
    return wrapper

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

ミドルウェア設計のポイント

ミドルウェアを設計する際に意識すべき点をまとめる。

単一責任の原則

一つのミドルウェアには一つの責任だけを持たせる。ロギングと認証を同じミドルウェアに詰め込まない。

順序への配慮

ミドルウェアの適用順序は動作に影響する。例えば、認証ミドルウェアはロギングミドルウェアの内側に置くことで、認証失敗もログに記録できる。

また、start_responseexc_info 引数を正しく扱うことも重要だ。例外発生時にヘッダーを再送信するケースに対応するためである。

ミドルウェアパターンを理解すれば、Flask の before_request や Django の MIDDLEWARE 設定が何をしているのか、より深く理解できるようになる。