源码教程: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 第六节)。
二、启动链路
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 客户端再连一次,补创建期参数并回报浏览器身份
两条连接并存不是重复:前者负责全部 121 个命令,后者负责「持久化上下文原本在创建时给好、而 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),否则新页签永远白屏。这个客户端在打开开关的同时就注册了「收到挂载事件立刻放行」的监听器,不把「忘了放行」留给调用方。
六、回执里多了什么
{"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 目录。
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 的权限名确实会被协议拒绝,把这个容易犯、后果又静默的错误钉住。
