Skip to content

源码教程:Chrome 走 CDP 与 CDP 客户端 ​

本机 Google Chrome 一律通过 CDP(Chrome DevTools Protocol) 驱动:服务自己拉进程、开远程调试端口、用协议接上。为此工程里多了一个独立的协议客户端,包名 nexus.io.chromium.cdp,它不依赖工程内其它包,可以整包拿走单独用。

前文 05 讲的是「用哪个浏览器、用哪份 profile」的表现,本文讲这条启动路径本身怎么实现、为什么这么实现、以及踩过哪些坑。

一、为什么本机 Chrome 走 CDP ​

理由说明
不再要求「先关掉你正在用的 Chrome」CDP 这条路用的是托管 profile,不是用户日常那份 User Data,两者互不抢目录。而 Playwright 的持久化上下文用管道调试,Chrome 从 136 起拒绝在默认用户数据目录上开启远程调试,那条路要用真实 profile 就必须先关掉浏览器并放开机器策略
视口语义天然正确这条路不套视口模拟,页面尺寸一直跟着真实窗口走 —— 正好是 browser.viewport=window 想要的「所见即所得」,不必再靠「不给默认视口」绕(见 06)
创建期参数可以自己补持久化上下文有一批创建时才能给的选项(下载目录、权限、UA、HTTP 认证)。走 CDP 之后这些没有现成入口,改为用协议命令补,补不上的会在回执里写明原因而不是静默退化 —— 清单见本文第五节

内置 Chromium 仍走 Playwright 的持久化上下文:它不需要「本机安装」这个概念,开发态还没有内嵌可执行文件(交给 Playwright 自己解析),换成自己拉进程只会多一份要维护的启动逻辑。Edge 走 CDP 更早,原因在沙箱(见 05 第六节)。

二、启动链路 ​

text
PlaywrightService.launchChromiumFamily
  → 解析出固定的 cdp 托管 profile 目录(ChromeBrowser.cdpManagedProfileDir)
  → PlaywrightService.cdpArgs:拼启动参数,含 --remote-debugging-port=<配置端口或 0>
  → ChromeLauncher.launch:ProcessBuilder 拉进程,边读 stderr 边等调试地址
       DevTools listening on ws://127.0.0.1:<port>/...
  → 把 ws 地址换成 HTTP 端点(http://127.0.0.1:<端口>)
  → Playwright 的 connectOverCDP 接上(全量命令仍走这条已有的执行链)
  → CdpLaunchSupport.apply:用独立 CDP 客户端再连一次,补创建期参数并回报浏览器身份

两条连接并存不是重复:前者负责全部 122 个命令,后者负责「持久化上下文原本在创建时给好、而 CDP 没有对应入口」的那几项,并且把浏览器亲口报的版本写进回执,让 mode:cdp 这句自述有第三方证据。

ChromeLauncher 有几处刻意的实现选择:

  • 从 stderr 读调试地址,而不是读 DevToolsActivePort 文件:那个文件只在非默认用户数据目录下才会被写出来。托管 profile 恰好是非默认目录,但用户 profile 不是,两条路统一走 stderr,少一个分支。
  • 区分「拒绝」与「提前退出」:Chrome 明确拒绝远程调试时会打印 DevTools remote debugging requires a non-default data directory,此时它不会自己退出(会变成一个普通浏览器继续跑),所以服务必须自己把它收掉;而 profile 被占用时新进程会立刻退出(见第八节)。
  • 不读输出会把浏览器堵死:读线程必须一直把 stdout/stderr 读干净。

三、独立客户端 nexus.io.chromium.cdp ​

类型职责
CdpConnection一条 WebSocket 连接:命令—回执配对、事件路由、失败分型、关闭
CdpSession连到某个 target 的一条逻辑会话;按会话发命令、订阅事件(跨域 iframe 的基础)
CdpBrowser浏览器级门面:版本、target 与页签、会话挂载、下载目录、权限、自动挂载
CdpPage页面级门面:导航与等待、求值、截图、UA 覆盖、原生弹窗处理
CdpTarget一个 target 的概要(类型、地址、标题、所属浏览器上下文)
CdpException失败分型:协议级拒绝 / 传输断 / 发出去没等到回执
CdpEventListener事件回调接口

为什么自己写而不是引一个协议库。 现成的选择分两类,都不合适:一类是多年没有发版的社区客户端;另一类是按浏览器里程碑逐个发版的协议绑定 —— 用它等于把「升级浏览器」和「升级依赖」绑在一起,而这个工程本来就自己内嵌浏览器、自己控制版本。这个客户端只需要「连上任意版本的 Chromium 系浏览器、收发协议消息」,自己实现反而更小:只用 JDK 自带的 WebSocket 客户端加上工程已有的 JSON 工具,没有新增依赖。

这个包不 import 工程内任何其它包,所以它可以被整包复制到别的项目里。它的自测也刻意不借用工程的启动工具:src/test/java/nexus/io/chromium/cdp/CdpClientTest.java 自己探测浏览器可执行文件、自己挑空闲端口、自己拉进程、自己等端口就绪,覆盖连接、导航、求值、异步求值、截图、会话隔离、target 列表、失败分型与连接状态自述。

四、三个关键机制 ​

4.1 命令与回执靠自增 id 配对 ​

每条命令分配一个自增 id 放进消息,回执按 id 唤醒等待方。调用方可以从任意线程发命令;事件回调则在读 WebSocket 的那条线程上执行,所以回调里不要做阻塞的协议同步调用(那会把后续所有事件堵在后面)。

4.2 事件按 sessionId 路由 ​

浏览器级命令不带 sessionId,页签 / 子 frame 级的命令与事件带。扁平模式(挂载 target 时带 flatten)下不再需要把消息层层套进 sendMessageToTarget,所有命令与事件都走同一条 WebSocket,靠 sessionId 区分。

为什么必须按 target 分会话:跨域 iframe 在 Chromium 里是独立渲染进程,它是自己的 target、有自己的执行上下文。在顶层会话里求值,document 永远是顶层文档的;要进 iframe 内部求值,只能用属于那个 frame 的会话,或在该会话里指定执行上下文。

4.3 失败要分得清 ​

形态判据调用方该怎么办
协议级拒绝回执里带 error(方法不存在、参数不合法、权限名不认识)改参数或改方法,重试无用
传输断了连接已关闭 / WebSocket 出错重建连接
发出去没等到回执超时不能当成没生效 —— 尤其点击、提交类命令

第三类是最容易被写错的一类:超时只说明「没等到回执」,动作可能已经落到页面上。把它统一当失败然后自动重发,就是重复提交的来源。

五、创建期参数怎么补 ​

CdpLaunchSupport 在接上浏览器之后补两件事,并把结果写进 start 回执的 data.browser.cdp.notes:

补什么协议命令不补的后果
下载目录Browser.setDownloadBehavior页面触发的下载落到浏览器自己的默认下载目录,而调用方还在约定目录里数文件 —— 表现是「点了下载却什么都没发生」
权限Browser.grantPermissions剪贴板类操作直接抛权限错误,站点侧只表现为「按钮点了没反应」

权限名不是 Playwright 那一套。 Playwright 的 setPermissions 收 clipboard-read / clipboard-write,而协议的 PermissionType 里叫 clipboardReadWrite / clipboardSanitizedWrite。照抄 Playwright 的名字会得到 Unknown permission type —— 而这类错误一旦被兜住,表现就是剪贴板按钮静默失效,在站点侧完全看不出是权限问题。自测里专门留了一条反向用例把这个坑钉住。

两条都是尽力而为:失败只记警告并写进 notes,绝不让 start 失败。用「少一个下载目录设置」换「整个任务起不来」是明显的亏本买卖;但也不能不吭声,所以失败原因必须出现在回执里。

另外一个协议特性值得单独提:打开自动挂载并让新 target 停在调试器前时,必须有人放行(Runtime.runIfWaitingForDebugger),否则新页签永远白屏。这个客户端在打开开关的同时就注册了「收到挂载事件立刻放行」的监听器,不把「忘了放行」留给调用方。

六、回执里多了什么 ​

json
{"data":{"browser":{"type":"chrome","engine":"chromium","mode":"cdp","userProfile":false,
  "profileDir":"<托管 profile 绝对路径>","headless":false,
  "cdp":{"product":"Chrome/<版本>","protocolVersion":"<协议版本>",
    "notes":["下载目录:<路径>(Browser.setDownloadBehavior)","已授权限:clipboardReadWrite / clipboardSanitizedWrite / notifications"]}}}}

mode 是服务自己的说法,cdp.product 是浏览器亲口报的(Browser.getVersion 的 product)。排查版本相关问题时先看它。notes 记录补设置的实际结果,成功与失败都在里面。

七、profile:固定一份,不按端口派生 ​

本机 Chrome 走 CDP 时用的托管 profile 默认是 ~/.config/browseruse/profiles/shared-default,由 browser.chrome.cdpProfileDir 控制。

为什么不按端口派生。 按端口派生(shared-<端口>)是为了让多个服务实例各自一份 profile、不抢锁。但这条路用的是托管 profile,本来就不会和用户正在开的 Chrome 抢目录;再按端口分只会带来一个纯粹损失:换端口等于换一套登录态。固定成 shared-default 之后,换端口不换登录态。

解析优先级:browser.chrome.cdpProfileDir > browser.profileDir(显式配置永远优先,想跟旧的托管目录共用一份登录态就指过去)> shared-default。想用用户自己那份 Chrome profile(现成的 Google 登录态)请打开 browser.chrome.useUserProfile,那用的是系统默认的 User Data,与这一份无关 —— 详见 05 第四节。

从「按端口派生的托管 profile」切到 shared-default 时,登录态不会跟着走:那是两个不同的目录。要沿用旧目录,用 browser.chrome.cdpProfileDir 或 browser.profileDir 显式指过去。

八、排障 ​

8.1 启动后立即退出、而且「没有输出」 ​

现象:start 失败,报「浏览器启动后立即退出(退出码 21):(Chrome 没有输出)」。

原因几乎只有一个:这份托管 profile 已经被另一个 Chrome 占着。新进程把命令行交给已有实例后自己退出,而输出也被那个实例吃掉了,所以只看启动器的报错会得到一句「没有输出」,完全指不到方向。

正常只可能有两个来源:上一次服务被强杀留下的孤儿浏览器,或者人手工起的调试实例。查法:看哪个 chrome 进程的命令行里带着这个 profile 目录。

powershell
Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
  Where-Object { $_.CommandLine -like '*shared-default*' } |
  Select-Object ProcessId, CommandLine

8.2 调试端口:为什么不写 9222 ​

9222 不是浏览器的内置默认值:Chromium 系浏览器要求显式传 --remote-debugging-port。9222 只是 Puppeteer / Selenium / 各类编辑器插件约定俗成用的端口。

跟着用它的代价是双向的:任何同样按默认值连 9222 的工具都会连到我们的浏览器上;反过来,别人手工起的调试实例会占着端口与 profile,让我们的启动失败 —— 8.1 那个「退出码 21、没有输出」就是这么来的。

所以默认值是 0:让浏览器自己挑一个空闲端口,端口号从它的 stderr 里读出来。这样不会有端口被占用的启动失败,同一台机器跑多个实例也不会互相踩,挑出来的端口一定在 10000 以上。需要稳定端点(想用别的工具接上来看、或要固定防火墙规则)时,用 browser.chrome.debugPort 钉一个 10000 以上的端口。

8.3 set_credentials 在这条路上用不了 ​

HTTP 基本认证的凭据只能在创建上下文时设置,而这条路的上下文不是由 Playwright 创建的,也没法重建。需要它时改用 browser=chromium(内置 Chromium,走持久化上下文)或 browser=firefox。失败信息里会直接给出这两个取值。

九、自测怎么证明它真的能用 ​

协议库这种「薄」组件最容易出现「编译过了、其实没连上」的情况,所以自测是一条不依赖工程其它代码的端到端用例:

  • 自己探测浏览器可执行文件;找不到就整类跳过(这台机器上没装浏览器不算失败)。
  • 自己挑空闲端口、用临时 profile 目录拉一个无头浏览器,不碰开发机上那份登录态。
  • 断言覆盖:浏览器版本前缀、协议版本、导航与标题、document 求值、异步表达式(Promise)求值、截图字节是合法 PNG、两个页签的会话不串台、target 列表能解析、页面内异常与协议级拒绝分型正确、连接对象能自述存活状态。
  • 反向用例:Playwright 的权限名确实会被协议拒绝,把这个容易犯、后果又静默的错误钉住。

配置与运维视角的清单见 12,浏览器与 profile 的表现见 05。