Java製ミニマルサーブレットコンテナの構築:仕様準拠の実装ガイド

システム設計の基本概念

本プロジェクトの目的は、Servlet 6.0仕様に準拠した軽量なWebコンテナをゼロから実装することです。大規模なTomcatとは異なり、教育用・学習用としてコア機能のみを集約し、以下の設計原則を採用します。

  • プロトコル層:HTTP/1.x専用に固定(TLS処理はプロキシ担当)
  • 構成要素:単一コンテキスト(Context)によるWebアプリケーション格納
  • 拡張性:単一アプリ対応を前提とし、マルチテナント化はプロセス分離で代替

この単純化により、HTTPリクエストの受信からフィルタ経由でのサーブレット実行、セッション状態の維持、および外部WARファイルの動的展開に至るまでの全体フローを明確に追跡できます。

HTTPコネクタとデータ変換層

JDK標準の`com.sun.net.httpserver`モジュールを活用してTCPポートを監視し、着信リクエストを処理する基盤を作成します。この段階では生のパケット解析を排除し、高レベルAPIに統一することで開発負荷を削減します。

public class HttpInboundHandler implements HttpHandler {
    private static final Logger LOG = LoggerFactory.getLogger(HttpInboundHandler.class);

    @Override
    public void handle(HttpExchange transfer) throws IOException {
        String method = transfer.getRequestMethod();
        URI endpoint = transfer.getRequestURI();
        LOG.info("Received: {} {}", method, endpoint.getRawQuery());

        // レスポンスヘッダー設定
        HttpHeaders respHeaders = transfer.getResponseHeaders();
        respHeaders.set("Content-Type", "text/html; charset=UTF-8");
        respHeaders.set("Cache-Control", "no-store");
        
        // ステータスコード送信
        transfer.sendResponseHeaders(200, 0);
        
        // ペイロード出力
        try (OutputStream payload = transfer.getResponseBody()) {
            String body = "<h1>Welcome</h1><p>" + LocalDateTime.now() + "</p>";
            payload.write(body.getBytes(StandardCharsets.UTF_8));
        }
    }
}

標準サーブレットAPIとの互換性を確保するため、アダプターパターンを用いて変換レイヤーを構築します。これにより、下層のプロトコル実装と上層のビジネスロジックが完全に分離されます。

public class RequestResponseBridge implements IncomingProtocol {
    private final HttpExchange exchange;

    public RequestResponseBridge(HttpExchange transfer) {
        this.exchange = transfer;
    }

    @Override public String getMethod() { return exchange.getRequestMethod(); }
    @Override public URI getUri() { return exchange.getRequestURI(); }
    @Override public HttpHeaders getRespHeaders() { return exchange.getResponseHeaders(); }
    @Override public void sendStatus(int code, long length) throws IOException {
        exchange.sendResponseHeaders(code, length);
    }
    @Override public OutputStream getPayloadStream() { return exchange.getResponseBody(); }
}

上記ブリッジを経由して`HttpServletRequest`および`HttpServletResponse`インターフェースを実装したラッパーを生成します。これにより、既存のサーブレット仕様を満たすコードそのまま適用可能になります。

サーブレットコンテキストとルーティング制御

`ServletContext`はWebアプリケーションのグローバル実行環境を定義します。内部ではマッピング定義リストを保持し、アクセスパスとサーブレットインスタンスの対応関係を解決します。

public class ApplicationContextImpl implements ServletContext {
    private final List<RouteDefinition> routeRegistry = new ArrayList<>();
    
    public void process(IncomingRequest req, OutgoingResponse res) throws Exception {
        String targetPath = req.getRequestPath();
        Servlet target = resolveTarget(targetPath);
        
        if (target == null) {
            writeError(res, 404, "Not Found");
            return;
        }
        target.service(req, res);
    }

    private Servlet resolvePath(String path) {
        for (RouteDefinition def : routeRegistry) {
            if (def.pattern.matcher(path).matches()) {
                return def.servletInstance;
            }
        }
        return null;
    }
}

初期化フェーズでは、アノテーションスキャンを通じて登録されたサーブレットクラスを走査し、`init()`メソッドを呼び出してリソース準備を行います。その後、指定されたURLパターンを正規表現に変換して辞書登録します。

フィルタチェインの実装原理

リクエスト到達前に共通処理(ログ出力、認証チェック、文字コード変換など)を実行するには責任連鎖パターンが適しています。`FilterChainImpl`は自身の参照を次工程へ引き渡し、再帰的な実行構造を形成します。

public class ExecutionChain implements FilterChain {
    private final Filter[] pipeline;
    private final Servlet terminalPoint;
    private int pointer = 0;

    public ExecutionChain(Filter[] stages, Servlet finalTarget) {
        this.pipeline = stages;
        this.terminalPoint = finalTarget;
    }

    @Override
    public void execute(ServletRequest req, ServletResponse resp) throws Exception {
        if (pointer < pipeline.length) {
            int currentStep = pointer++;
            pipeline[currentStep].doFilter(req, resp, this);
        } else {
            terminalPoint.service(req, resp);
        }
    }
}

マッチング対象となるフィルターは、リクエストパスに対してプリフィックスまたはワイルドカード比較を行い、配列に格納します。この配列を初期値としてチェインオブジェクトを構築すれば、透過的なリクエスト処理フローが完成します。

ステートフルセッションの管理戦略

クライアント識別子を維持するにはCookieベースのトークン分配が一般的です。サーバー側ではマップ型ストアとクリーンアップジョブを併用して状態を制御します。

public class StateTracker {
    private final ConcurrentHashMap<String, SessionRecord> activeSessions = new ConcurrentHashMap<>();
    private final int timeoutSeconds;

    public SessionRecord acquire(String token) {
        return activeSessions.computeIfAbsent(token, k -> {
            SessionRecord record = new SessionRecord(UUID.randomUUID().toString(), timeoutSeconds);
            return record;
        });
    }

    public void invalidate(SessionRecord record) {
        activeSessions.remove(record.getId());
    }
}

リクエストオブジェクト内にてCookieペアを探索し、不一致の場合は新たなIDを発行して`Set-Cookie`ヘッダーに乗せます。バックグラウンドデモンプロセスは所定間隔で全エントリをスキャンし、最終アクセス時刻と有効期限の差分判定を行って不要レコードを破棄します。スレッドセーフ要件が高いため、属性読み書きには`ConcurrentHashMap`を直接利用します。

イベント通知機構

ライフサイクルや属性変更などの発火点は、Observerパターンに基づくディスパッチャーで一元管理します。

public class EventHub {
    private final List<SessionAttributeListener> attrListeners = new CopyOnWriteArrayList<>();
    
    public void notifyAdded(String key, Object value, SessionRecord ctx) {
        SessionBindingEvent evt = new SessionBindingEvent(ctx, key, value);
        attrListeners.forEach(l -> l.attributeAdded(evt));
    }
}

登録処理は`addXXXListener()`インタフェース経由で行われ、型判定によって適切なコレクションへ振り分けます。実行タイミングは該当するサーブレットライフサイクルメソッドの前後に挿入することで、仕様準拠の動作を保証します。

WARパッケージの動態展開

標準クラスローダーではランタイム時にパスを追加できません。そのため、独立したアプリケーション空間を作成するためにカスタム`URLClassLoader`を注入する必要があります。

public class AppDomainLoader extends URLClassLoader {
    public AppDomainLoader(Path extractedRoot) throws MalformedURLException {
        super(new URL[]{extractedRoot.resolve("WEB-INF/classes").toUri().toURL()}, 
              AppDomainLoader.class.getParentClassLoader());
    }
}

起動時パラメータからWARファイルを一時領域へ解凍し、`classes`ディレクトリおよび`lib`配下のJAR群をURL一覧として渡します。重要なのは、リクエストハンドリングスレッドの文脈クラスローダーを切り替える処理です。

public void dispatch(HttpExchange exchange) {
    Thread prevCL = Thread.currentThread().getContextClassLoader();
    try {
        Thread.currentThread().setContextClassLoader(appDomainLoader);
        bridge.process(exchange); // ここでClass.forName等が発生
    } finally {
        Thread.currentThread().setContextClassLoader(prevCL);
    }
}

これにより、フレームワーク内部的なリフレクション呼び出しが正しくターゲットアセンブリを参照できるようになります。

ミドルウェア連携と運用範例

独自コンテナ上でSpring MVC系アプリケーションを動作させる場合、`DispatcherServlet`を起点としたDIコンテナ初期化フローが必要です。XML設定ファイル依存を廃止し、プログラムティックな初期化アノテーションを付与したサブルクラスを用意することで自動検出に対応させます。

@WebServlet(urlPatterns = "/", initParams = {
    @WebInitParam(name = "contextClass", value = "AnnotationConfigWebApplicationContext"),
    @WebInitParam(name = "configLocation", value = "com.example.RootConfig")
})
public class DispatcherLauncher extends DispatcherServlet {
    // 追加ロジックなし
}

静态資源配信やデータベースコネクションプール設定も通常通りBean定義で完結します。非同期処理やストリーミングWebSocket等功能は今回の範囲外とするため、プロキシ層か専用ライブラリへ委譲することを推奨します。

実装上の技術的注意点

  • 出力ストリーム競合回避:`getWriter()`と`getOutputStream()`は排他制御が必要。フラグ変数で片方の呼出を検知したら他方を拒否し例外を投げる。
  • 未処理ヘッダーの強制送信:ステータスコードのみ設定して即戻り値した場合、コネクタ側にフックを設け空白ボディ付きレスポンスを強制的にflushする。
  • マッピング優先順位:接頭辞一致 > ファイル末尾一致 > デフォルトパス。正規表現エンジンではなく順序検証アルゴリズムを適用するのが堅牢。
  • 仮想スレッド対応:`Executors.newVirtualThreadPerTaskExecutor()`へ置き換えればブロッキングI/O時のメモリ消費を劇的に抑制可能。

タグ: jakarta-servlet tomcat-architecture httpserver-api classloader-isolation filter-chain-pattern

5月23日 07:06 投稿