Read OSS

プロトコル層:クライアントとサーバーの通信のしくみ

上級

前提知識

  • 第1回:アーキテクチャ概要
  • RPC とメッセージパッシングパターンの基礎知識
  • TypeScript のジェネリクスとクラス階層の理解

プロトコル層:クライアントとサーバーの通信のしくみ

第1回では、Playwright がすべての API 呼び出しをクライアント側とサーバー側に分割し、プロトコルで接続するアーキテクチャを確認しました。今回はメッセージを端から端まで追いかけます。page.click('.button') を呼び出した瞬間から、サーバーが実際のクリックをブラウザへ送出するまでの一連の流れです。その過程で、ChannelOwner 基底クラス、Connection メッセージポンプ、サーバー側の Dispatcher 階層、オブジェクトのライフサイクル管理、そして同期から非同期へのブートストラップ最適化を順番に解説していきます。

プロトコルスキーマとコード生成

第1回で触れたように、protocol.yml が唯一の信頼できる情報源(single source of truth)です。YAML 内の各「チャンネル」は、実質的に3つのセクションを持つインターフェース定義です。

  • initializer: オブジェクトが最初に生成されるとき(__create__ メッセージ)に送られるプロパティ
  • commands: クライアントがサーバーに対して呼び出せるメソッド
  • events: サーバーからクライアントへプッシュされるメッセージ

たとえば packages/protocol/src/protocol.yml#L786-L801Playwright チャンネルには、ブラウザタイプを含む initializer と commands が宣言されています。chromiumfirefoxwebkit の各ブラウザタイプや newRequest などのコマンドが定義されています。

ビルド時に utils/generate_channels.js が3つの重要な成果物を生成します。

  1. 型定義 — 各チャンネルの params・results・events・initializers に対応する TypeScript インターフェース
  2. バリデーター — ワイヤ上のメッセージを検証・変換するランタイム関数
  3. Metainfo — 各メソッドのメタデータ(内部メソッドか? slowMo を発動するか?)

バリデーター関数は findValidator(type, method, kind) でランタイムに検索されます。kind'Params''Result''Event''Initializer' のいずれかです。クライアントとサーバーの両方がすべてのメッセージに対してバリデーターを呼び出します。クライアントは送信 params と受信 results を検証し、サーバーは受信 params と送信 results を検証します。

flowchart TD
    A["protocol.yml"] -->|"generate_channels.js"| B["channels.d.ts"]
    A -->|"generate_channels.js"| C["validator.ts"]
    A -->|"generate_channels.js"| D["protocolMetainfo.ts"]
    
    B --> E["Compile-time type safety"]
    C --> F["Runtime message validation"]
    D --> G["Method metadata<br/>(internal, slowMo)"]

ヒント: Playwright の API に新しいメソッドを追加するときは、まず protocol.yml を編集するところから始めます。生成された型がクライアント・サーバー双方の実装を導き、両者を常に同期させてくれます。

ChannelOwner:クライアント側の基底クラス

PageBrowserLocatorFrame といったクライアント側のオブジェクトはすべて、最終的に ChannelOwner を継承しています。packages/playwright-core/src/client/channelOwner.ts#L32-L64 で定義されたこの抽象基底クラスは、次の機能を提供します。

  • プロトコル境界をまたいでオブジェクトを一意に識別する _guid
  • メソッド呼び出しをインターセプトしてプロトコル経由でルーティングする _channel プロキシ
  • 親となる Connection への参照 _connection
  • ライフサイクル管理のための親子オブジェクトツリー

核心となるのが _channel プロキシです。page._channel.click(params) を呼び出したとき、メソッドが直接実行されるわけではありません。packages/playwright-core/src/client/channelOwner.ts#L145-L174_createChannel() 内にあるプロキシがその呼び出しをインターセプトします。

const channel = new Proxy(base, {
  get: (obj: any, prop: string | symbol) => {
    if (typeof prop === 'string') {
      const validator = maybeFindValidator(this._type, prop, 'Params');
      if (validator) {
        return async (params: any) => {
          return await this._wrapApiCall(async apiZone => {
            const validatedParams = validator(params, '', this._validatorToWireContext());
            return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone);
          });
        };
      }
    }
    return obj[prop];
  },
});

このプロキシは3つのことを行います。プロトコルスキーマに対して params を検証し、計装(スタックトレース・API ロギング)で呼び出しをラップし、connection 経由でメッセージを送信します。

classDiagram
    class ChannelOwner {
        +_guid: string
        +_type: string
        +_channel: T
        +_connection: Connection
        +_wrapApiCall()
        +_adopt()
        +_dispose()
    }
    class Playwright {
        +chromium: BrowserType
        +firefox: BrowserType
        +webkit: BrowserType
    }
    class BrowserType {
        +launch()
        +connect()
    }
    class Page {
        +click()
        +fill()
        +goto()
    }
    class Frame {
        +locator()
        +waitForSelector()
    }
    
    ChannelOwner <|-- Playwright
    ChannelOwner <|-- BrowserType
    ChannelOwner <|-- Page
    ChannelOwner <|-- Frame

Connection とメッセージディスパッチ

packages/playwright-core/src/client/connection.ts#L67-L90 にあるクライアント側の Connection クラスがメッセージポンプです。2つの重要なデータ構造を管理しています。

  • _objects:GUID から ChannelOwner へのマップ。生きているすべてのオブジェクトをクライアント側で把握するためのもの
  • _callbacks:メッセージ ID から、保留中の Promise の resolve/reject ペアへのマップ

メッセージの送信

packages/playwright-core/src/client/connection.ts#L127-L149sendMessageToServer() が呼ばれると、次の処理が行われます。

  1. 単調増加する id を割り当てる
  2. { id, guid, method, params } 形式のメッセージを構築する
  3. メタデータ(スタックフレーム・タイトル・stepId)を付加する
  4. this.onmessage(message) を呼び出す(トランスポートに接続されている)
  5. _callbacks に格納した Promise を返す

メッセージの受信

packages/playwright-core/src/client/connection.ts#L159-L208dispatch() メソッドは、受信メッセージを3種類に分けて処理します。

  1. レスポンスid を持つ):コールバックを探し、結果を検証し、Promise を resolve または reject する
  2. ライフサイクルイベント__create____adopt____dispose__):クライアント側のオブジェクトツリーを管理する
  3. プロトコルイベント:GUID でターゲットオブジェクトを探し、検証し、そのチャンネルでイベントを emit する
sequenceDiagram
    participant User as User Code
    participant CO as ChannelOwner
    participant Conn as Connection
    participant Transport as Transport Layer

    User->>CO: page.click('.btn')
    CO->>CO: _channel proxy intercepts
    CO->>CO: validate params
    CO->>Conn: sendMessageToServer()
    Conn->>Conn: Assign id, store callback
    Conn->>Transport: onmessage({id, guid, method, params})
    Transport-->>Conn: Response {id, result}
    Conn->>Conn: dispatch() - find callback
    Conn->>Conn: validate result
    Conn-->>CO: resolve promise
    CO-->>User: return result

オブジェクトファクトリー

サーバーが新しいオブジェクト(たとえば新しい Page)を生成すると、__create__ メッセージが送られてきます。packages/playwright-core/src/client/connection.ts#L232-L342_createRemoteObject() メソッドは、型名をコンストラクターにマッピングする大きな switch 文でこれを処理します。

switch (type) {
  case 'Browser':
    result = new Browser(parent, type, guid, initializer);
    break;
  case 'Page':
    result = new Page(parent, type, guid, initializer);
    break;
  // ... 30+ more types
}

ここでプロトコルの型が具体的なクライアントオブジェクトになります。あなたが操作するすべてのオブジェクトは、この switch を通じて生成されています。

Dispatcher:サーバー側のブリッジ

サーバー側では、ChannelOwner と鏡合わせになる存在が Dispatcher です。packages/playwright-core/src/server/dispatchers/dispatcher.ts#L49-L83 で定義されており、各 DispatcherSdkObject(実際の実装)をラップしてプロトコルへとつなぎます。

Dispatcher が構築されると、次の処理が行われます。

  1. 自身を DispatcherConnection に登録する
  2. 自身の型・GUID・initializer を含む __create__ メッセージをクライアントに送る
  3. 古い dispatcher をガベージコレクションすべきかどうかを確認する

packages/playwright-core/src/server/dispatchers/dispatcher.ts#L103-L111_runCommand() メソッドは、タイムアウト管理のためにすべてのコマンド実行を ProgressController でラップします。

async _runCommand(callMetadata: CallMetadata, method: string, validParams: any) {
  const controller = ProgressController.createForSdkObject(this._object, callMetadata);
  this._activeProgressControllers.add(controller);
  try {
    return await controller.run(progress => (this as any)[method](validParams, progress), validParams?.timeout);
  } finally {
    this._activeProgressControllers.delete(controller);
  }
}

DispatcherConnection メッセージルーター

packages/playwright-core/src/server/dispatchers/dispatcher.ts#L197-L207 にあるサーバー側の DispatcherConnection は、クライアントからの受信メッセージを処理します。299行目dispatch() メソッドは次の順で動作します。

  1. GUID でターゲットとなる dispatcher を探す
  2. params とメタデータを検証する
  3. タイミング・帰属情報・位置情報を含む CallMetadata を構築する
  4. トレース用に instrumentation.onBeforeCall() を発火する
  5. _runCommand() でコマンドを実行する
  6. 結果を検証する
  7. instrumentation.onAfterCall() を発火する
  8. レスポンス(またはエラー)をクライアントに返送する
sequenceDiagram
    participant Client as Client Connection
    participant DC as DispatcherConnection
    participant D as PageDispatcher
    participant SO as Page (SdkObject)
    participant Inst as Instrumentation

    Client->>DC: {id, guid, method:"click", params}
    DC->>DC: Find dispatcher by GUID
    DC->>DC: Validate params
    DC->>Inst: onBeforeCall()
    DC->>D: _runCommand("click", params)
    D->>D: ProgressController.run()
    D->>SO: Execute click
    SO-->>D: Result
    D-->>DC: Result
    DC->>DC: Validate result
    DC->>Inst: onAfterCall()
    DC-->>Client: {id, result}

オブジェクトのライフサイクルと GC

Playwright はクライアントとサーバーの両側でオブジェクトのツリーを管理しています。このライフサイクルを制御するプロトコルメッセージは3種類あります。

  • __create__:サーバーが新しい dispatcher を生成 → クライアントが対応する ChannelOwner を生成する
  • __adopt__:オブジェクトの親を付け替える(例:Page をコンテキスト間で移動する)
  • __dispose__:オブジェクトとその子をすべて破棄する

packages/playwright-core/src/client/channelOwner.ts#L117-L128 のクライアント側 _dispose() は、子を再帰的に破棄します。

_dispose(reason: 'gc' | undefined) {
  if (this._parent)
    this._parent._objects.delete(this._guid);
  this._connection._objects.delete(this._guid);
  this._wasCollected = reason === 'gc';
  for (const object of [...this._objects.values()])
    object._dispose(reason);
  this._objects.clear();
}

サーバー側にはさらにガベージコレクション層が存在します。packages/playwright-core/src/server/dispatchers/dispatcher.ts#L283-L297maybeDisposeStaleDispatchers() メソッドは、「GC バケット」(通常は型名)ごとに dispatcher を追跡します。これによりヒープの無制限な増大を防ぎます。バケットが上限(JSHandle/ElementHandle は10万件、それ以外は1万件)を超えると、古いものから10%が reason: 'gc' で破棄されます。

flowchart TD
    A["New Dispatcher Created"] --> B{"Bucket size ><br/>maxDispatchers?"}
    B -->|No| C["Register normally"]
    B -->|Yes| D["Dispose oldest 10%"]
    D --> E["Send __dispose__(reason:'gc')<br/>to client"]
    E --> F["Client marks<br/>_wasCollected = true"]

ヒント: Playwright で「The object has been collected to prevent unbounded heap growth」というエラーが出た場合、dispose せずに大量のハンドルを保持し続けていることが原因です。await handle.dispose() を使うか、サーバー側の参照を持たない Locator を活用しましょう。

インプロセス配線と同期→非同期ブートストラップ

packages/playwright-core/src/inProcessFactory.ts#L26-L58 のインプロセス配線は注目に値します。重要な手順を順に見ていきましょう。

// Step 1: Synchronous dispatch
dispatcherConnection.onmessage = message => clientConnection.dispatch(message);
clientConnection.onmessage = message => dispatcherConnection.dispatch(message);

// Step 2: Create and initialize
const rootScope = new RootDispatcher(dispatcherConnection);
new PlaywrightDispatcher(rootScope, playwright);
const playwrightAPI = clientConnection.getObjectWithKnownName('Playwright');

// Step 3: Switch to async
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));
clientConnection.onmessage = message => setImmediate(() => dispatcherConnection.dispatch(message));

同期フェーズが重要です。PlaywrightDispatcher が構築されると、Playwright オブジェクト・BrowserType・その他の初期オブジェクトに対して __create__ メッセージが送信されます。ディスパッチが同期的なので、これらのメッセージはその場で処理され、getObjectWithKnownName('Playwright') は完全に初期化されたオブジェクトを返します。

初期化が完了したあと、setImmediate() に切り替えることでスタックオーバーフローの可能性を排除しています。深くネストしたプロトコル呼び出しが連鎖した場合、同期のままではコールスタックが際限なく積み上がってしまうからです。

また clientConnection.useRawBuffers() にも注目してください。クライアントとサーバーが同一プロセス内にある場合、バイナリデータ(スクリーンショットなど)は base64 エンコードせずに生の Buffer オブジェクトとして渡されるため、不要なコピーを回避できます。

50行目の toImpl ブリッジはデバッグ用の脱出ハッチです。テストランナーがプロトコルを越えてサーバー側のオブジェクトに直接アクセスできるようにするもので、フィクスチャシステム(第5回で詳しく扱います)に欠かせない仕組みです。

次回予告

クライアントとサーバーの間でメッセージがどのように流れるかを理解したところで、次回はコマンドがサーバー側に到着してからの処理を深掘りします。BrowserType → Browser → BrowserContext → Page という階層を通じて3つの異なるブラウザをどのように抽象化しているか、PageDelegate インターフェース、そしてトレースやデバッグを支える計装システムについて解説します。