提出问题
之前我的一篇博客说的是怎么利用 selenium 来做自动化监控。当出现异常时,我们需要记录页面源码、网络请求数据、截图等信息来方便我们诊断问题,基本上就够用了。但是,这两天遇到一个棘手的异常,时不时页面会弹出:“系统繁忙,请稍候再试!”,这时候我们去看网络请求数据,结果状态码全部都是 200,没有其它信息,这压根没法定位不了问题。
这就说明:网络出现异常的时候,仅靠状态码是不够的。我们最好能拿到 http 所有数据,包括:请求头、响应头、请求体、响应体。其中请求头、响应头,可以通过 PERFORMANCE_LOG 拿到,问题都不大。但是请求体与响应体,我们可以拿到么?
分析过程
这个问题困扰了我整整一天的时间,终于解决了。为什么这么困难?
我们先来看 selenium,它为什么不直接支持这个功能呢?因为开发人员觉得这不是他们目标:
we will not be adding this feature to the WebDriver API as it
falls outside of our current scope (emulating user actions).
然后我继续翻网络,发现谷歌的 devtools-protocol 明确是支持的:
那我有没有什么办法能调用这两个方法呢?这就很麻烦了,我根据这篇文章的思路去直连谷歌的 Remote Port。
看这篇文章真的很美,但实际上到我这个项目并不可行,为什么?
原因在于这篇文章所用的 PyChromeDevTools 是基于 websocket 的,而且是在请求一个链接后,立即去读取 chrome 吐出来的响应数据。
而在监控这种场景下,是在请求已经完成之后才会收集 PerformanceLog,然后根据其中的请求 ID 去问 chrome 要数据。一个是推,一个是拉,这是两种模式。所以非常不幸,解决不了我的问题。
但是给我了我一个思路,我去找找有没有类似 Java 的组件。这时候,我从 GitHub 上找到了 cdp4j,这是一个跟 chrome 打交道的包,它有一个很迷人的 API:
// 获取请求返回内容
session.getCommand().getNetwork().getResponseBody("requestIdxxxxx");
这个方法我试验了很久,结果仍然不行,调用时一直返回的是:
No resource with given identifier found
我确认了很久,确认 requestId 是没有问题的,为什么拿不到数据?我试了很久,最后放弃了,因为我发现是这样的:
Java 的 Selenium 通过 chromedriver 开启了一个与 Chrome 的 session,cdp4j 是没有办法直接绑到这个 session 上面的(理论上是可能的,但是 cdp4j 的扩展性太差,我实在懒得去改)。这就意味着 chromdriver 的请求数据无法通过 cdp4j 来获取到。
既然 Java 的 Selenium 其实没并有直连 Chrome,而是通过 chromedriver 去跟 Chrome 打交道的。我们能不能从 chromedriver 上看看有没有直接获取 responseBody 的接口呢?
所以,我开始找 chromedriver 的文档,文档真的非常少。不知道从哪里我了解到 chromedriver 是根据 w3c 的协议开发的,我看看 w3c 的 webdriver 协议里能不能找到答案。
结果仍然很让人沮丧,我翻了很久,发现 w3c 的 webdriver 协议没有定义 Network 相关的操作。
然后我就开始仔细分析 selenium 的源码,发现了 AbstractHttpCommandCodec 里有与 chromedriver 相关的操作定义。
/**
* A command codec that adheres to the W3C's WebDriver wire protocol.
*
* @see <a href="https://w3.org/tr/webdriver">W3C WebDriver spec</a>
*/
public abstract class AbstractHttpCommandCodec implements CommandCodec<HttpRequest> {
//...
public AbstractHttpCommandCodec() {
defineCommand(STATUS, get("/status"));
defineCommand(GET_ALL_SESSIONS, get("/sessions"));
defineCommand(NEW_SESSION, post("/session"));
defineCommand(GET_CAPABILITIES, get("/session/:sessionId"));
defineCommand(QUIT, delete("/session/:sessionId"));
// ...
// Mobile Spec
defineCommand(GET_NETWORK_CONNECTION, get("/session/:sessionId/network_connection"));
defineCommand(SET_NETWORK_CONNECTION, post("/session/:sessionId/network_connection"));
defineCommand(SWITCH_TO_CONTEXT, post("/session/:sessionId/context"));
defineCommand(GET_CURRENT_CONTEXT_HANDLE, get("/session/:sessionId/context"));
defineCommand(GET_CONTEXT_HANDLES, get("/session/:sessionId/contexts"));
}
// ...
}
解读源码后发现,其实这些操作就是发送 get/post 请求到 chromedriver,由 chromedriver 来处理,这里没有我们想要的接口。但是给我一个思路,如果我能拿到 chromedriver 的所有接口,是不是就可以确认有没有我们想要的 getResponseBody 接口呢?
嘿嘿,这是个很大的突破口。其实早该想到的,直接去看 的源码,找出所有暴露的接口:
# https://github.com/bayandin/chromedriver/blob/master/server/http_handler.cc
//...
CommandMapping(kDelete, "session/:sessionId",
base::BindRepeating(
&ExecuteSessionCommand, &session_thread_map_, "Quit",
base::BindRepeating(&ExecuteQuit, false), true)),
// No W3C equivalent.
CommandMapping(kDelete, "session/:sessionId/session_storage",
WrapToCommand("ClearSessionStorage",
base::BindRepeating(&ExecuteClearStorage,
kSessionStorage))),
CommandMapping(kPost, "session/:sessionId/chromium/send_command",
WrapToCommand("SendCommand",
base::BindRepeating(&ExecuteSendCommand))),
CommandMapping(
kPost, "session/:sessionId/goog/cdp/execute",
WrapToCommand("ExecuteCDP",
base::BindRepeating(&ExecuteSendCommandAndGetResult))),
CommandMapping(
kPost, "session/:sessionId/chromium/send_command_and_get_result",
WrapToCommand("SendCommandAndGetResult",
base::BindRepeating(&ExecuteSendCommandAndGetResult))),
//...
看到上面的"session/:sessionId/goog/cdp/execute"了么,兴不兴奋?
虽然没能找到我们想要的 Network.getResponseBody,但是我们得到了一个可以执行所有 Chrome Devtool 协议的通用接口!真是不枉费我花了这么久,然后我们看看要传什么参数,找 ExecuteSendCommandAndGetResult 的实现:
Status ExecuteSendCommandAndGetResult(Session* session,
WebView* web_view,
const base::DictionaryValue& params,
std::unique_ptr<base::Value>* value,
Timeout* timeout) {
std::string cmd;
if (!params.GetString("cmd", &cmd)) {
return Status(kInvalidArgument, "command not passed");
}
const base::DictionaryValue* cmdParams;
if (!params.GetDictionary("params", &cmdParams)) {
return Status(kInvalidArgument, "params not passed");
}
return web_view->SendCommandAndGetResult(cmd, *cmdParams, value);
}
根据代码,我只要传 cmd 与 params 命令就可以调用这个接口了。我们在 Postman 里试一试:
总算成功了!一天已经过去了,不过没有白费。
接下来我们只要转化到代码里就行了。一开始我试图集成进 Selenium 的 AbstractHttpCommandCodec,结果没能成功。原因有两个,一个是 Selenium 扩展性太差,没有办法直接增加进去; 另一个原因,我修改源码覆盖的时候发现有一些奇奇怪怪的问题。
解决方案
最后,我就用 HttpClient 调用的方式来实现了。源码如下:
public class ChromeDriverProxy extends ChromeDriver {
private static final int COMMAND_TIMEOUT = 5000;
// 必须固定端口,因为ChromeDriver没有实时获取端口的接口;
private static final int CHROME_DRIVER_PORT = 9999;
private static ChromeDriverService driverService = new ChromeDriverService.Builder().usingPort(CHROME_DRIVER_PORT).build();
public ChromeDriverProxy(ChromeOptions options) {
super(driverService, options);
}
// 根据请求ID获取返回内容
public ResponseBodyVo getResponseBody(String requestId) {
ResponseBodyVo result = null;
try {
// CHROME_DRIVER_PORT chromeDriver提供的端口
String url = String.format("http://localhost:%s/session/%s/goog/cdp/execute",
CHROME_DRIVER_PORT, getSessionId());
HttpPost httpPost = new HttpPost(url);
JSONObject object = new JSONObject();
JSONObject params = new JSONObject();
params.put("requestId", requestId);
object.put("cmd", "Network.getResponseBody");
object.put("params", params);
httpPost.setEntity(new StringEntity(object.toString()));
RequestConfig requestConfig = RequestConfig
.custom()
.setSocketTimeout(COMMAND_TIMEOUT)
.setConnectTimeout(COMMAND_TIMEOUT).build();
CloseableHttpClient httpClient = HttpClientBuilder.create()
.setDefaultRequestConfig(requestConfig).build();
HttpResponse response = httpClient.execute(httpPost);
JSONObject data = JSONObject.parseObject(EntityUtils.toString(response.getEntity()));
return JSONObject.toJavaObject(data, ResponseBodyVo.class);
} catch (IOException e) {
logger.error("getResponseBody failed!", e);
}
return result;
}
}
这样就完成了网络请求返回内容的处理。
调用方法:
public static List<String> saveHttpTransferDataIfNecessary(ChromeDriverProxy driver) {
Logs logs = driver.manage().logs();
Set<String> availableLogTypes = logs.getAvailableLogTypes();
if(availableLogTypes.contains(LogType.PERFORMANCE)) {
LogEntries logEntries = logs.get(LogType.PERFORMANCE);
List<ResponseReceivedEvent> responseReceivedEvents = new ArrayList<>();
for(LogEntry entry : logEntries) {
JSONObject jsonObj = JSON.parseObject(entry.getMessage()).getJSONObject("message");
String method = jsonObj.getString("method");
String params = jsonObj.getString("params");
if (method.equals(NETWORK_RESPONSE_RECEIVED)) {
ResponseReceivedEvent response = JSON.parseObject(params, ResponseReceivedEvent.class);
responseReceivedEvents.add(response);
}
}
doSaveHttpTransferDataIfNecessary(driver, responseReceivedEvents);
}
}
// 保存网络请求
private static void saveHttpTransferDataIfNecessary(ChromeDriverProxy driver, List<ResponseReceivedEvent> responses) {
List<String> content = new ArrayList<>(1024);
for(ResponseReceivedEvent response : responses) {
String url = response.getResponse().getUrl();
boolean staticFiles = url.endsWith(".png")
|| url.endsWith(".jpg")
|| url.endsWith(".css")
|| url.endsWith(".ico")
|| url.endsWith(".js")
|| url.endsWith(".gif");
if(!staticFiles && url.startsWith("http")) {
content.add(url);
content.add(response.getResponse().getRequestHeadersText());
content.add(response.getResponse().getHeadersText());
// 使用上面开发的接口获取返回数据
ResponseBodyVo body = driver.getResponseBody(response.getRequestId());
if(body != null && body.getStatus() == 0) {
content.add("base64Encoded:" + body.getValue().getBase64Encoded());
content.add("body:\n" + body.getValue().getBody());
}
content.add("\n");
}
}
// 写文件至本地
}
至于 getRequestPostData 也是类似的逻辑,这样不再赘述。
参考资料
https://github.com/ChromeDevTools/awesome-chrome-devtools#developing-with-the-protocol
https://github.com/marty90/PyChromeDevTools/blob/master/PyChromeDevTools
https://yq.aliyun.com/articles/656018
https://github.com/webfolderio/cdp4j
https://stackoverflow.com/questions/6509628/how-to-get-http-response-code-using-selenium-webdriver
https://stackoverflow.com/questions/28430479/using-google-chrome-remote-debugging-protocol
https://chromedevtools.github.io/devtools-protocol/tot/Network
https://github.com/bayandin/chromedriver/
Issue #141 · seleniumhq/selenium-google-code-issue-archive
https://www.w3.org/TR/webdriver/#take-element-screenshot
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于