Read OSS

协议层:客户端与服务端如何通信

高级

前置知识

  • 第 1 篇:架构概览
  • 了解 RPC 与消息传递模式
  • TypeScript 泛型与类层次结构

协议层:客户端与服务端如何通信

在第 1 篇中,我们了解到 Playwright 将每一个 API 调用拆分为客户端和服务端两半,通过协议连接起来。本文将端到端地追踪一条消息的完整路径:从你调用 page.click('.button') 的那一刻,到服务端将实际的点击事件分发给浏览器为止。沿途我们将深入剖析 ChannelOwner 基类、Connection 消息泵、服务端 Dispatcher 层次结构、对象生命周期系统,以及同步转异步的启动优化策略。

协议 Schema 与代码生成

正如第 1 篇所介绍的,protocol.yml 是唯一的数据来源。YAML 中的每个"channel"本质上是一个接口定义,包含三个部分:

  • initializer:对象首次创建时发送的属性(即 __create__ 消息)
  • commands:客户端可以在服务端调用的方法
  • events:服务端主动推送给客户端的消息

packages/protocol/src/protocol.yml#L786-L801 中的 Playwright channel 为例,它在 initializer 中声明了 chromiumfirefoxwebkit 三种浏览器类型,以及 newRequest 等命令。

构建阶段,utils/generate_channels.js 会生成三个关键产物:

  1. 类型定义 — 每个 channel 的参数、返回值、事件和 initializer 对应的 TypeScript 接口
  2. 验证器 — 在传输层对消息进行校验和转换的运行时函数
  3. 元信息 — 每个方法的元数据(是否为内部方法?是否需要触发 slowMo?)

验证器函数在运行时通过 findValidator(type, method, kind) 查找,其中 kind 的取值为 'Params''Result''Event''Initializer'。客户端和服务端都会对每条消息调用这些验证器——客户端验证发出的参数和收到的返回值,服务端验证收到的参数和发出的返回值。

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];
  },
});

这个代理做了三件事:根据协议 schema 校验参数、用仪表层(调用栈追踪、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 与消息分发

客户端的 Connection 类定义于 packages/playwright-core/src/client/connection.ts#L67-L90,是整个消息泵的核心。它维护两个关键数据结构:

  • _objects:从 GUID 到 ChannelOwner 的映射,代表客户端对所有存活对象的视图
  • _callbacks:从消息 ID 到 pending promise 的 resolve/reject 对的映射

发送消息

packages/playwright-core/src/client/connection.ts#L127-L149 中的 sendMessageToServer() 执行以下步骤:

  1. 分配一个单调递增的 id
  2. 构造消息:{ id, guid, method, params }
  3. 附加元数据(调用栈帧、标题、stepId)
  4. 调用 this.onmessage(message),该函数已与传输层绑定
  5. 返回一个存储在 _callbacks 中的 promise

接收消息

packages/playwright-core/src/client/connection.ts#L159-L208 中的 dispatch() 方法处理三种类型的入站消息:

  1. 响应(含 id):查找对应的回调,校验返回值,resolve 或 reject promise
  2. 生命周期事件__create____adopt____dispose__):管理客户端对象树
  3. 协议事件:通过 GUID 找到目标对象,校验后在其 channel 上触发事件
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 在构造时会执行以下操作:

  1. DispatcherConnection 注册自身
  2. 向客户端发送 __create__ 消息,携带其类型、GUID 和 initializer
  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. 校验参数和元数据
  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 在两端分别维护一棵对象树,三条协议消息控制着这套生命周期:

  • __create__:服务端创建新 dispatcher → 客户端创建对应的 ChannelOwner
  • __adopt__:重新挂载对象的父节点(例如将 Page 在不同 context 间迁移)
  • __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 bucket"(通常以类型名称为单位)追踪 dispatcher 数量来防止堆内存无限增长。当某个 bucket 超过上限(JSHandle/ElementHandle 为 100K,其他类型为 10K)时,最旧的 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" 这个错误,说明你持有了过多 handle 而没有及时释放。请使用 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()——当客户端和服务端同处一个进程时,二进制数据(如截图)会直接以原始 Buffer 对象传递,而非 base64 编码,从而避免不必要的数据拷贝。

第 50 行的 toImpl 桥接是一个调试逃生舱口——它允许测试运行器穿透协议直接访问服务端对象,这对 fixture 系统至关重要(详见第 5 篇)。

下一步

了解了消息如何在客户端和服务端之间流转之后,下一篇文章将深入服务端:命令到达后发生了什么?Playwright 如何通过 BrowserType → Browser → BrowserContext → Page 的层次结构对三种不同的浏览器进行抽象?PageDelegate 接口是如何设计的?支撑追踪与调试的仪表系统又是怎样运作的?