プロトコル層:クライアントとサーバーの通信のしくみ
前提知識
- ›第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-L801 の Playwright チャンネルには、ブラウザタイプを含む initializer と commands が宣言されています。chromium・firefox・webkit の各ブラウザタイプや newRequest などのコマンドが定義されています。
ビルド時に utils/generate_channels.js が3つの重要な成果物を生成します。
- 型定義 — 各チャンネルの params・results・events・initializers に対応する TypeScript インターフェース
- バリデーター — ワイヤ上のメッセージを検証・変換するランタイム関数
- 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:クライアント側の基底クラス
Page・Browser・Locator・Frame といったクライアント側のオブジェクトはすべて、最終的に 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-L149 の sendMessageToServer() が呼ばれると、次の処理が行われます。
- 単調増加する
idを割り当てる { id, guid, method, params }形式のメッセージを構築する- メタデータ(スタックフレーム・タイトル・stepId)を付加する
this.onmessage(message)を呼び出す(トランスポートに接続されている)_callbacksに格納した Promise を返す
メッセージの受信
packages/playwright-core/src/client/connection.ts#L159-L208 の dispatch() メソッドは、受信メッセージを3種類に分けて処理します。
- レスポンス(
idを持つ):コールバックを探し、結果を検証し、Promise を resolve または reject する - ライフサイクルイベント(
__create__・__adopt__・__dispose__):クライアント側のオブジェクトツリーを管理する - プロトコルイベント: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 で定義されており、各 Dispatcher は SdkObject(実際の実装)をラップしてプロトコルへとつなぎます。
Dispatcher が構築されると、次の処理が行われます。
- 自身を
DispatcherConnectionに登録する - 自身の型・GUID・initializer を含む
__create__メッセージをクライアントに送る - 古い 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() メソッドは次の順で動作します。
- GUID でターゲットとなる dispatcher を探す
- params とメタデータを検証する
- タイミング・帰属情報・位置情報を含む
CallMetadataを構築する - トレース用に
instrumentation.onBeforeCall()を発火する _runCommand()でコマンドを実行する- 結果を検証する
instrumentation.onAfterCall()を発火する- レスポンス(またはエラー)をクライアントに返送する
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-L297 の maybeDisposeStaleDispatchers() メソッドは、「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 インターフェース、そしてトレースやデバッグを支える計装システムについて解説します。