Java製マイクロサービスフレームワークから学ぶWebServerの仕組み#1【Helidon】

はじめに

いきなりですが、OSSから多くのことを吸収できると踏んでOSSのコードリーディングを通していろんなことを勉強するシリーズ第一弾。 今回はoracle製のマイクロサービスフレームワークのHelidonを題材に扱う。

github.com

今日特に参照するのは以下のあたりです。

helidon/docs-internal/connections.md at main · helidon-io/helidon · GitHub

Helidonとは

Helidon は Java 向けの軽量マイクロサービスフレームワークで、標準的な API(HTTP, gRPC, Config, Media, Observability 等)を小さいフットプリントで提供する。 HelidonはMicroProfileに準拠しており、WebServerインターフェースは基本的なサーバのライフサイクルに加えて、監視機能やルーティング、メトリクス等のクラウドネイティブ時代には必須と言われるようなこれらのAPIエンドポイントを提供している。

Helidonは以下のHelidon SE、HelidonMP、Helidon CDIから構成されており、それぞれが異なる特徴を持つ。

https://imgopt.infoq.com/fit-in/3000x4000/filters:quality(85)/filters:no_upscale()/articles/helidon-tutorial/ja/resources/13image003-1592833819383.jpg

WebServerの全体像と今日まとめる範囲

WebServerが起動するところから、リクエストを受け取ってレスポンスを返すまでの大まかな流れを書いてみた。 今日は黄色枠の部分で気になった部分を深堀りしてまとめてみる。 今日の四角で囲っている部分はすべて、サーバを起動するタイミングでHelidonが行う初期設定的なもので、この初期設定が完了してリクエストを受け取った時には、すでにListenerには登録されたSelectorをもとに処理が行われるもの。 (後述の内容を読んだ後に再度この図を振り返ると多少整理されるはずです)

flowchart LR
    Start[Server start] --> Config[Load config]
    Config --> Selectors[Build selectors HTTP1 HTTP2]
    Selectors --> Accept[Accept loop]

    Accept --> Bytes[Read initial bytes]
    Bytes --> Decide{HTTP2 preface?}
    Decide -->|Yes| H2[HTTP2 conn]
    Decide -->|No| H1[HTTP1 conn]

    H1 --> Upg{Upgrade?}
    Upg -->|h2c| H2
    Upg -->|websocket| WS[WebSocket]
    Upg -->|none| Req1[HTTP1 req]

    H2 --> Frames[Frames gRPC?]
    Frames -->|gRPC| GRPC[gRPC call]
    Frames -->|normal| Req2[HTTP2 req]

    WS --> Router[Router]
    Req1 --> Router
    Req2 --> Router
    GRPC --> Handler[Handler]
    Router --> Handler
    Handler --> Resp[Response]

    subgraph Covered[今日カバーする内容]
        Config
        Selectors
        Accept
        Bytes
        Decide
        H2
    end

WebServer

  • HelidonのWebServerはSPI(Service Provider Interface)を利用して受け取った接続をハンドリングする
    • SPIはOSSの利用者が機能を拡張するためのインターフェース
  • WebServerとしてのHelidon(他にも機能があるためここではあえてこの言い方をします)は、クライアントから接続を受けて、アプリケーションロジックに到達するまでにも色々な処理を行う。
flowchart LR
    TCP[TCP接続] --> Select[プロトコル判定]
    Select --> H1[HTTP1.1]
    Select --> H2[HTTP2]
    H1 -->|Upgrade h2c| H2
    H1 -->|Upgrade websocket| WS[WebSocket]
    H2 --> GRPC[gRPC]
    H1 --> Route[ルーター]
    H2 --> Route
    WS --> Route
    GRPC --> App[アプリ処理]
    Route --> App
  • WebServerが受けとった生のソケットの先頭のバイトを見てプロトコルがHTTP/1.1かHTTP/2なのかを判別
  • HTTP/1.1の場合は従来の通り要求と応答が1:1となる
  • HTTPプロトコルは、Upgradeヘッダーを使ってすでに確立されたプロトコルを別のプロトコルにアップデートできる仕組みがあり(参考: Mozilla プロトコルアップデートの仕組み)これを利用してHelidonでもプロトコルのアップデートが可能 developer.mozilla.org
  • HTTP/2.0上で動作するサブプロトコルであるgRPCを呼び出しgrpc用のハンドラを用いてアプリ処理を実行
  • リクエストを登録済みのエンドポイントにディスパッチする。

ここまでの話を実装で確認

ServerConnectionSelectorProvider

/**
 * {@link java.util.ServiceLoader} provider interface for server connection providers.
 * This interface serves as {@link ServerConnectionSelector} builder
 * which receives requested configuration nodes from the server configuration when server builder
 * is running.
 *
 * @param <T> type of the protocol config
 */
public interface ServerConnectionSelectorProvider<T extends ProtocolConfig> {
    /**
     * Type of configuration supported by this connection provider.
     *
     * @return type of configuration used by this provider
     */
    Class<T> protocolConfigType();

    /**
     * Type of protocol, such as {@code http_1_1}.
     *
     * @return type of this protocol, used in configuration
     */
    String protocolType();

    /**
     * Creates an instance of server connection selector.
     *
     * @param listenerName name of the listener this selector will be active on
     * @param config configuration of this provider
     * @param configs configuration of all protocols of this socket, to be used for nested protocol support, only providers
     *                that do have a configuration available should be created!
     * @return new server connection selector
     */
    ServerConnectionSelector create(String listenerName, T config, ProtocolConfigs configs);

}
  • 接続を受け取る部分のSPIのエントリポイント
  • プロトコル(HTTP/1.1、HTTP/2.0)ごとにインターフェースを実装したクラスがServiceLoaderによって発見され、サーバ構築時にCoufigと結び付けられる

Http2ConnectionProvider

/**
 * {@link io.helidon.webserver.spi.ServerConnectionSelectorProvider} implementation for HTTP/2 server connection provider.
 */
public class Http2ConnectionProvider implements ServerConnectionSelectorProvider<Http2Config> {
    /**
     * HTTP/2 server connection provider configuration node name.
     */
    static final String CONFIG_NAME = "http_2";

    // ServiceLoaderからHttp2SubProtocolProviderの実装クラスを取得
    private final List<Http2SubProtocolProvider> subProtocolProviders = HelidonServiceLoader.create(
                    ServiceLoader.load(Http2SubProtocolProvider.class))
            .asList();

    /**
     * Creates an instance of HTTP/2 server connection provider.
     *
     * @deprecated to be used solely by {@link java.util.ServiceLoader}
     */
    @Deprecated
    public Http2ConnectionProvider() {
    }

    @Override
    public Class<Http2Config> protocolConfigType() {
        return Http2Config.class;
    }

    @Override
    public String protocolType() {
        return CONFIG_NAME;
    }


    @SuppressWarnings({"rawtypes", "unchecked"})
    @Override
    public ServerConnectionSelector create(String listenerName, Http2Config config, ProtocolConfigs configs) {

        // HTTP2のサブプロトコル実装クラスを探し出し、見つけたものをすべてHttp2ConnectionSelectorとして生成する
        var subProtocolSelectors = new ArrayList<Http2SubProtocolSelector>();
        for (Http2SubProtocolProvider subProtocolProvider : subProtocolProviders) {
            List<ProtocolConfig> providerConfigs = configs.config(subProtocolProvider.protocolType(),
                                                                  subProtocolProvider.protocolConfigType());
            for (ProtocolConfig providerConfig : providerConfigs) {
                subProtocolSelectors.add(subProtocolProvider.create(providerConfig, configs));
            }
        }

        return new Http2ConnectionSelector(config, subProtocolSelectors);
    }
}
  • ServerConnectionSelectorProviderに対する実装クラスでありHttp2上で動くサブプロトコル(grpcとか)に関するSelectorを生成する
  • Http2SubProtocolProviderをInterfaceを実装したクラスをServiceLoader経由で取得し、createメソッド側でServerConnectionSelectorを生成

Http2ConnectionSelector

/**
 * HTTP/2 server connection selector.
 */
public class Http2ConnectionSelector implements ServerConnectionSelector {

    private final Http2Config http2Config;
    private final List<Http2SubProtocolSelector> subProviders;

    // Creates an instance of HTTP/2 server connection selector.
    Http2ConnectionSelector(Http2Config http2Config, List<Http2SubProtocolSelector> subProviders) {
        this.http2Config = http2Config;
        this.subProviders = subProviders;
    }


   // 受信したバッファデータの先頭バイト列を判断してHTTP/2 の prior knowledge prefaceかどうかを判定し対応可否を返すメソッド
    @Override
    public Support supports(BufferData request) {
        byte[] prefaceBytes = new byte[PREFACE_LENGTH];
        request.read(prefaceBytes, 0, PREFACE_LENGTH);

        // now we can ask protocol handler to identify this protocol
        if (isPreface(prefaceBytes)) {
            // this is HTTP/2 prior knowledge
            return Support.SUPPORTED;
        }

        return Support.UNSUPPORTED;
    }

    @Override
    public Set<String> supportedApplicationProtocols() {
        return Set.of("h2");
    }

    @Override
    public ServerConnection connection(ConnectionContext ctx) {
        Http2Connection result = new Http2Connection(ctx, http2Config, subProviders);
        result.expectPreface();

        return result;
    }
}
  • isPrefaceメソッドではPrior Knowledgeが受信バイトに含まれているかを確認する
  • 受け取ったバイト配列にPrior Knowledgeが含まれているかどうかでbooleanを返却する
public final class Http2Util {

   // これが最初のバイト配列に含まれる事前知識(Prior Knowledge)
    private static final byte[] PRIOR_KNOWLEDGE_PREFACE =
            "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.UTF_8);
    /**
     * Length of prior knowledge preface.
     */
    public static final int PREFACE_LENGTH = PRIOR_KNOWLEDGE_PREFACE.length;

    private Http2Util() {
    }

    /**
     * Check if the bytes provided start with the prior knowledge preface.
     *
     * @param bytes bytes to check
     * @return {@code true} if the bytes are preface bytes
     */
    public static boolean isPreface(byte[] bytes) {
        return Arrays.compare(PRIOR_KNOWLEDGE_PREFACE, 0, PREFACE_LENGTH,
                              bytes, 0, PREFACE_LENGTH) == 0;
    }
}
  • ※Prior Knowledgeについて余談
    • クライアントとサーバが、「私たちはHTTP/2で会話します」と事前に双方理解している場合にはPRI * HTTP/2.0\r\n\r\nSM\r\n\r\nというバイト配列が最初のバイトに含むようになる
    • 初めからHTTP/2で会話することが分かっているので、HTTP/1.1からUpgradeを行わなくてよい接続方式となる

まとめ

WebServerの内容とプロトコル毎にハンドリングしつつ適切なSelectorを生成するところまでを追ってみた。ここで生成したSelectorは、この後WebServerのリスナーが管理するSelectorとして管理されることになるが、その辺も次回まとめてみる。 HTTP/2 prior knowledgeとか、HTTP/1.1のUpgrade等は初耳の内容だったので、WebServeの実装からこの辺の話を知れて良い収穫だった。