提出问题 之前我的一篇博客说的是怎么利用 selenium 来做自动化监控。当出现异常时,我们需要记录页面源码、网络请求数据、截图等信息来方便我们诊断问题,基本上就够用了。但是,这两天遇到一个棘手的异常,时不时页面会弹出:“系统繁忙,请稍候再试!”,这时候我们去看网络请求数据,结果状态码全部都是 200,没有其它信息, ..

selenium 获取请求返回内容的解决方案

本贴最后更新于 263 天前,其中的信息可能已经物是人非

提出问题

之前我的一篇博客说的是怎么利用 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明确是支持的:

imagepng

那我有没有什么办法能调用这两个方法呢?这就很麻烦了,我根据这篇文章的思路去直连谷歌的 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 里试一试:

imagepng

总算成功了!一天已经过去了,不过没有白费。

接下来我们只要转化到代码里就行了。一开始我试图集成进 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/
https://github.com/seleniumhq/selenium-google-code-issue-archive/issues/141
https://www.w3.org/TR/webdriver/#take-element-screenshot

  • B3log

    B3log 是一个开源组织,名字来源于“Bulletin Board Blog”缩写,目标是将独立博客与论坛结合,形成一种新的网络社区体验,详细请看 B3log 构思。目前 B3log 已经开源了多款产品:PipeSoloSymWide 等,欢迎大家加入,贡献开源。

    1650 引用 • 3449 回帖 • 567 关注
  • Selenium
    12 引用 • 9 回帖
  • Chrome

    Chrome 又称 Google 浏览器,是一个由谷歌公司开发的网页浏览器。该浏览器是基于其他开源软件所编写,包括 WebKit,目标是提升稳定性、速度和安全性,并创造出简单且有效率的使用者界面。

    37 引用 • 242 回帖 • 649 关注
  • webdriver
    1 引用 • 2 回帖
2 回帖
请输入回帖内容...
  • haonit
    该回帖仅作者和楼主可见
  • tangtanyu
    该回帖仅作者和楼主可见