CDP WebSocket 客户端实现:命令/响应匹配与事件广播
本文是「agent-browser 代码原理」系列第 2 篇,深入剖析 agent-browser 的 CDP WebSocket 客户端实现。
一、CDP 通信模型
Chrome DevTools Protocol 基于 JSON-RPC over WebSocket,有三种消息类型:
json
// 1. 命令(Client → Chrome)
{"id": 1, "method": "Runtime.evaluate", "params": {"expression": "document.title"}}
// 2. 响应(Chrome → Client)
{"id": 1, "result": {"value": "Example Domain"}}
// 3. 事件(Chrome → Client,无 id)
{"method": "Page.loadEventFired", "params": {"timestamp": 1234567890}}
关键挑战 :WebSocket 是全双工、异步、无序的。客户端发送命令 1、2、3 后,可能先收到事件 A,再收到响应 2,然后是事件 B,最后是响应 1。
二、CdpClient 设计
cdp/client.rs 仅 360 行 Rust 代码,却解决了一个生产级问题:如何在异步环境中可靠地匹配请求和响应。
2.1 数据结构
rust
pub struct CdpClient {
// WebSocket 发送端(需要 Mutex,因为多个任务可能同时发送)
ws_tx: Arc<Mutex<SplitSink<WebSocketStream, Message>>>,
// 自增命令 ID(AtomicU64 保证线程安全)
next_id: AtomicU64,
// 等待中的命令:id → oneshot::Sender<CdpMessage>
// 当收到对应 id 的响应时,通过 Sender 通知等待方
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>,
// 事件广播:所有 CDP 事件通过 broadcast channel 分发给订阅者
event_tx: broadcast::Sender<CdpEvent>,
// 原始消息广播:用于 inspect proxy(DevTools 前端转发)
raw_tx: broadcast::Sender<RawCdpMessage>,
// Reader 任务和 Keepalive 任务的句柄
_reader_handle: tokio::task::JoinHandle<()>,
_keepalive_handle: tokio::task::JoinHandle<()>,
}
2.2 为什么用 oneshot channel?
oneshot::channel 是 Tokio 提供的一次性异步通道: - 发送端(Sender)只能发送一次 - 接收端(Receiver)只能接收一次 - 非常适合"请求-响应"模式------每个命令只期待一个响应
对比其他选择:
| 方案 | 问题 |
|---|---|
mpsc |
多生产者多消费者,过于复杂 |
broadcast |
适合事件,不适合一对一匹配 |
oneshot |
✅ 完美匹配请求-响应语义 |
三、连接建立
3.1 WebSocket 握手
rust
pub async fn connect(url: &str) -> Result<Self, String> {
// 1. 将 URL 转换为 WebSocket 请求
let mut request = url.into_client_request()
.map_err(|e| format!("Invalid WebSocket URL: {}", e))?;
// 2. 配置 WebSocket 参数
let ws_config = WebSocketConfig {
max_message_size: None,
max_frame_size: None,
..Default::default()
};
// 3. 异步连接
let (ws_stream, _) = tokio_tungstenite::connect_async_with_config(
request, Some(ws_config), false
).await.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
// 4. 启用 TCP Keepalive
enable_tcp_keepalive(ws_stream.get_ref());
// 5. 分离发送端和接收端
let (ws_tx, mut ws_rx) = ws_stream.split();
let ws_tx = Arc::new(Mutex::new(ws_tx));
// ... 初始化 pending、event_tx、raw_tx
// 6. 启动 Reader 任务
let reader_handle = tokio::spawn(async move {
// 持续读取消息...
});
// 7. 启动 Keepalive 任务
let keepalive_handle = tokio::spawn(async move {
// 每 30 秒发送 Ping...
});
Ok(Self { ws_tx, next_id, pending, event_tx, raw_tx,
_reader_handle: reader_handle, _keepalive_handle: keepalive_handle })
}
3.2 支持自定义 Headers
某些 CDP 代理(如 Browserless)需要认证 Header:
rust
pub async fn connect_with_headers(
url: &str,
headers: Option<Vec<(String, String)>>,
) -> Result<Self, String> {
let mut request = url.into_client_request()?;
if let Some(hdrs) = headers {
let req_headers = request.headers_mut();
for (key, value) in hdrs {
if let (Ok(name), Ok(val)) = (
key.parse::<HeaderName>(),
value.parse::<HeaderValue>(),
) {
req_headers.insert(name, val);
}
}
}
// ... 后续与 connect 相同
}
四、命令发送与响应匹配
4.1 发送流程
rust
pub async fn send_command(
&self,
method: &str,
params: Option<Value>,
session_id: Option<&str>,
) -> Result<Value, String> {
// Step 1: 生成唯一 ID
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
// Step 2: 序列化命令
let cmd = CdpCommand {
id,
method: method.to_string(),
params,
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
};
let json = serde_json::to_string(&cmd)
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
// Step 3: 创建 oneshot channel,将 Sender 存入 pending
let (tx, rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(id, tx);
}
// Step 4: 发送 WebSocket 消息
{
let mut ws_tx = self.ws_tx.lock().await;
ws_tx.send(Message::Text(json)).await
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
}
// Step 5: 等待响应(30 秒超时)
let response = match tokio::time::timeout(
std::time::Duration::from_secs(30), rx
).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
Err(_) => {
// 超时:从 pending 中移除,防止内存泄漏
self.pending.lock().await.remove(&id);
return Err(format!("CDP command timed out: {}", method));
}
};
// Step 6: 处理错误响应
if let Some(error) = response.error {
return Err(format!("CDP error ({}): {}", method, error));
}
Ok(response.result.unwrap_or(Value::Null))
}
关键点 : 1. pending.insert(id, tx) 必须在发送消息之前 完成,防止响应在插入前到达 2. 超时后必须 remove(&id),否则 pending HashMap 会无限增长 3. Ordering::SeqCst 保证 ID 生成的原子性和顺序性
4.2 类型安全的命令封装
agent-browser 还提供了类型安全版本:
rust
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
&self,
method: &str,
params: &P,
session_id: Option<&str>,
) -> Result<R, String> {
let params_value = serde_json::to_value(params)
.map_err(|e| format!("Failed to serialize params: {}", e))?;
let result = self.send_command(method, Some(params_value), session_id).await?;
serde_json::from_value(result)
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
}
使用示例:
rust
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: "document.title".to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
).await?;
五、Reader 任务:消息解析与分发
Reader 任务是 CdpClient 的核心,负责持续读取 WebSocket 消息并分发给正确的消费者。
5.1 完整 Reader 逻辑
rust
let reader_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
// 处理不同类型的 WebSocket 消息
let text = match msg {
Ok(Message::Text(text)) => text,
Ok(Message::Binary(data)) => match String::from_utf8(data) {
Ok(text) => text,
Err(_) => continue, // 二进制数据非法 UTF-8,跳过
},
Ok(Message::Close(frame)) => {
// 连接关闭,退出循环
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
let reason = frame.as_ref()
.map(|f| format!("code={}, reason={}", f.code, f.reason))
.unwrap_or_else(|| "no frame".to_string());
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
}
break;
}
Ok(Message::Pong(_)) => continue, // Pong 响应,忽略
Ok(_) => continue, // 其他类型(如 Ping),忽略
Err(e) => {
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
}
break;
}
};
// 广播原始消息(供 inspect proxy 使用)
if raw_tx_clone.receiver_count() > 0 {
let session_id = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
let _ = raw_tx_clone.send(RawCdpMessage {
text: text.clone(),
session_id,
});
}
// 解析为结构化消息
let parsed: CdpMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(_) => continue, // 非法 JSON(如 inspect proxy 的负 ID 消息)
};
if let Some(id) = parsed.id {
// ===== 响应消息 =====
let mut pending = pending_clone.lock().await;
if let Some(tx) = pending.remove(&id) {
let _ = tx.send(parsed); // 通知等待方
}
} else if let Some(ref method) = parsed.method {
// ===== 事件消息 =====
let event = CdpEvent {
method: method.clone(),
params: parsed.params.clone().unwrap_or(Value::Null),
session_id: parsed.session_id.clone(),
};
let _ = event_tx_clone.send(event); // 广播给所有订阅者
}
}
// ===== 连接断开处理 =====
// 清空所有 pending,让等待方立即收到 channel-closed 错误
pending_clone.lock().await.clear();
// 通知 keepalive 任务停止
let _ = cancel_tx.send(true);
});
5.2 为什么要支持 Binary 帧?
CDP 标准使用 Text 帧,但某些远程 CDP 代理(如 Browserless)可能发送 Binary 帧:
rust
Ok(Message::Binary(data)) => match String::from_utf8(data) {
Ok(text) => text,
Err(_) => continue,
},
这是防御性编程------兼容非标准实现。
六、事件订阅机制
6.1 订阅事件
rust
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
self.event_tx.subscribe()
}
使用示例(等待页面加载完成):
rust
let mut events = client.subscribe();
// 发送导航命令
client.send_command("Page.navigate", Some(json!({"url": "https://example.com"})), None).await?;
// 等待 Page.loadEventFired 事件
while let Ok(event) = events.recv().await {
if event.method == "Page.loadEventFired" {
break;
}
}
6.2 多订阅者支持
broadcast::channel(4096) 支持任意数量的订阅者: - snapshot 模块订阅 Page.screencastFrame 事件 - network 模块订阅 Network.requestWillBeSent 和 Network.responseReceived 事件 - 用户代码也可以同时订阅事件
七、Keepalive 机制
7.1 为什么需要 Keepalive?
在以下场景中,空闲的 WebSocket 连接可能被中间层关闭: - nginx 默认 60 秒超时 - Envoy / OpenResty / 云负载均衡器 - 企业防火墙 / 代理服务器
7.2 双保险设计
agent-browser 实现了两层保活机制:
Layer 1: TCP Keepalive(操作系统层)
rust
fn enable_tcp_keepalive(stream: &MaybeTlsStream<TcpStream>) {
let tcp_stream = match stream {
MaybeTlsStream::Plain(s) => s,
MaybeTlsStream::Rustls(s) => s.get_ref().0,
_ => return,
};
let sock = socket2::SockRef::from(tcp_stream);
let keepalive = TcpKeepalive::new()
.with_time(Duration::from_secs(30)); // 30 秒后开始探测
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
let keepalive = keepalive.with_interval(Duration::from_secs(10)); // 每 10 秒探测一次
let _ = sock.set_tcp_keepalive(&keepalive); // 失败静默处理
}
Layer 2: WebSocket Ping 帧(应用层)
rust
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
let keepalive_handle = tokio::spawn(async move {
let interval = Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
loop {
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = cancel_rx.changed() => break, // Reader 退出时停止
}
let mut tx = keepalive_tx.lock().await;
if tx.send(Message::Ping(Vec::new())).await.is_err() {
break; // 发送失败,连接已断开
}
}
});
八、Inspect Proxy
agent-browser 还内置了一个 DevTools Inspect 代理服务器,允许用户使用 Chrome DevTools 前端调试正在自动化的页面。
8.1 InspectProxyHandle
rust
pub struct InspectProxyHandle {
ws_tx: WsTx, // 向 Chrome 发送消息
raw_tx: broadcast::Sender<RawCdpMessage>, // 接收 Chrome 的消息
}
8.2 消息转发
DevTools 前端 ←→ Inspect Server ←→ CdpClient ←→ Chrome
- DevTools 发送的消息 → 通过
send_raw转发给 Chrome - Chrome 返回的消息 → 通过
subscribe_raw转发给 DevTools
九、总结
agent-browser 的 CDP 客户端设计精妙之处在于:
| 设计点 | 实现 | 价值 |
|---|---|---|
| 请求-响应匹配 | HashMap<id, oneshot::Sender> |
可靠匹配异步响应 |
| 事件广播 | broadcast::channel(4096) |
支持多模块并发订阅 |
| 双保活机制 | TCP Keepalive + WebSocket Ping | 穿透各类中间代理 |
| Binary 帧兼容 | String::from_utf8(data) |
兼容非标准 CDP 代理 |
| 连接断开处理 | pending.clear() + cancel_tx |
优雅退出,无资源泄漏 |
这些设计使得 agent-browser 的 CDP 客户端不仅功能完整,而且在生产环境中非常可靠。
系列文章 : 1. agent-browser 架构概览:从 CLI 到 CDP 的分层设计 2. CDP WebSocket 客户端实现:命令/响应匹配与事件广播 ← 本文 3. Accessibility Tree 快照原理:如何让 AI 看懂网页 4. Chrome 进程管理与多 Backend 架构 5. Network 拦截与路由:Fetch Domain 实战