Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。
- Google App API Protocol - Text Search
- Google App API Protocol - Voice Search
- Google App API Protocol - Search History
HTTPS 抓包分析
Google Service 已经全面普及了 HTTPS 接入,所以想探索 Google 的通讯协议,首先必备的是 HTTPS 抓包能力。所谓的 HTTPS 抓包,实质上是通过代理服务器实现对测试手机上 HTTPS 连接的中间人攻击,所以必须在测试手机上安装代理服务器的 CA 证书,才能保证测试手机相信 HTTPS 连接是安全的。
有很多测试用代理服务器支持 HTTPS 抓包,例如 Fiddler 和 Charles,HTTPS 配置具体可以参见它们的官方文档:Configure Fiddler to Decrypt HTTPS Traffic 和 SSL CERTIFICATES.
文本 IS 请求
Google App 上的文本搜索请求,并不一定以用户按下搜索按钮才开始,而是在输入过程中就可能发生,类似于 Instant Search 即时搜索。这一切发生在输入文本过程中的 "/s?" 请求内。
Google 在接口上,已经将搜索推荐和即时搜索合二为一,通过发起对 "https://www.google.com[.hk]/s?" 的 GET 请求,根据用户已经输入的短语,获取搜索推荐词。如果 Google 认为用户已经完成输入,它会在这个请求的应答消息中直接返回搜索结果。
以小米手机上安装的 Google App 为例,当用户输入『上海』这个词时,GET 请求的参数,主要有以下这些:
noj 1 q 上海 tch 6 ar 0 br 0 client ms-android-xiaomi hl zh-CN oe utf-8 safe images gcc cn ctzn Asia/Shanghai ctf 1 v 5.9.33.19.arm biw 360 bih 615 padt 200 padb 640 ntyp 1 ram_mb 870 qsd 3670 qsubts 1458723210129 wf pp1 action devloc pf i sclient qsb-android-asbl cp 2 psi bLxzwaswPFc.1458723200693.3 ech 2 gl us sla 1
请求的 HTTP Header,主要有以下这些:
Connection keep-alive Cache-Control no-cache, no-store Date Wed, 23 Mar 2016 08:53:26 GMT X-Client-Instance-Id c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62f... Cookie ****** X-Geo w CAEQDKIBBTk6MTox X-Client-Opt-In-Context H4sIAAAAAAAAAONi4WAWYBD4DwOMUiwcj... X-Client-Discourse-Context H4sIAAAAAAAAAB1PS07DMBSUehe... X-Client-Data CM72hgQIjfeGBAiP94YECKj4hgQIy... User-Agent Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile Safari/537.36 GSA/5.9.33.19.arm Accept-Encoding gzip, deflate, sdch
文本 IS 应答消息解码
文本 IS 应答消息的 HTTP Header 如下所示:
HTTP/1.1 200 OK Content-Type application/x-protobuffer Date Wed, 23 Mar 2016 08:56:47 GMT Expires -1 Cache-Control no-store Content-Disposition attachment; filename="f.txt" Content-Encoding gzip Server gws X-XSS-Protection 1; mode=block X-Frame-Options SAMEORIGIN Alternate-Protocol 443:quic,p=1 Alt-Svc quic=":443"; ma=2592000; v="31,30,29,28,27,26,25" Transfer-Encoding chunked
这里最值得关注的,是 Content-Type。application/x-protobuffer 不是一个常见的 Media Type,起初我以为它就是简单的 protobuffer message 序列化二进制内容,搜索到的一些信息也是这样说的,但用 protobuf 对其 Decode,并不能正确解析消息体。后来我还是在 Charles 的这篇文档中找到了思路,其实 application/x-protobuffer 在实现时区分单个消息和多个消息的格式(但 Content-Type 里并不显式说明)。多个消息,即 Dilimited List,的封包协议是这样的:
(Varint of Msg Len)(Msg)(Varint of Msg Len)(Msg)...(Varint of Msg Len)(Msg)EOF
解包时也很简单,Protobuf 的 Java 库提供了 Message.Builder.mergeDelimitedFrom(...) 来直接从 InputStream 里循环读取多个 Message 的封包数据。
但这时候我们还不知道应答消息里的 protobuf Message 格式,无法构建 Message 的 Builder。这时候有个简单的办法去逐步推导,也就是新建一个 Empty Message,如下所示:
$ more textsearch.proto message Empty {}
用这个 Empty Message 构建 Builder,对 IS 的应答消息进行 Decode,将 Decode 结果打印出来时会发现所有的字段都是无名字段。然后根据对 protobuf wire data 的理解,逐步推导它的 Message 格式,尽最大的努力去猜测各个字段的作用,最终推导出来 IS 应答消息的 .proto 可能是这样的:
$ more textsearch.proto package com.google.search.app; message SearchResponse { required string search_id = 1; optional uint32 msg_type = 4; optional SearchResultPart sug = 100; optional SearchResultPart result = 101; optional Empty SR102 = 102; } message SearchResultPart { optional uint32 fin_stream = 1; optional string text_data = 2; optional string html_data = 7; optional string encoding = 8; } message Empty { }
基于这个 .proto,对 Query 『上海天气』 IS 的应答消息进行 Decode,最终的结果摘要如下:
$ more s.txt #### Proto: Textsearch.SearchResponse with Size=758 search_id: "iVnyVrChHsTAjwPdh4PwBA" msg_type: 97000 sug { fin_stream: 1 text_data: "[\"上海天气\",[[\"上海天气\",35,[39,70],......}]" } #### Proto: Textsearch.SearchResponse with Size=10561 search_id: "iVnyVsnMH8TAjwPdh4PwBA" msg_type: 97000 result { fin_stream: 0 text_data: "上海天气" html_data: "<!doctype html><html itemscope=\"\" ......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } #### Proto: Textsearch.SearchResponse with Size=16951 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "<style data-jiis=\"cc\" id=\"gstyle\">......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } #### Proto: Textsearch.SearchResponse with Size=1511 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "<title>上海天气 - Google 搜索</title></head><body ......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } #### Proto: Textsearch.SearchResponse with Size=68 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "" encoding: "text/html; charset=UTF-8" 3: 0 9: 1 } SR102 { 1: 1 4: "" } #### Proto: Textsearch.SearchResponse with Size=104557 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "<div data-jiis=\"cc\" id=\"doc-info\">......</script>" encoding: "text/html; charset=UTF-8" 4: 8 4: 10 4: 6 4: 13 4: 12 4: 2 4: 21 4: 20 9: 1 10: 1 } #### Proto: Textsearch.SearchResponse with Size=2198 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "<script>google.y.first.push(function()......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } #### Proto: Textsearch.SearchResponse with Size=2146 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "<script>......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } #### Proto: Textsearch.SearchResponse with Size=7261 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 0 html_data: "<script>......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } #### Proto: Textsearch.SearchResponse with Size=1202 search_id: "iVnyVsnMH8TAjwPdh4PwBA" result { fin_stream: 1 html_data: " <div id=\"main-loading-icon\" ......</div></body></html>" encoding: "text/html; charset=UTF-8" 9: 1 }
观察到 sug_data 看似是 JSON 格式的数据,专门对 sug_data 进行 JSON Decode,得到以下结果:
## JSONArray: sug_data ## [ "上海天气", [ [ "上海天气", 35, [ 39, 70 ], { "ansc": "1458723207451", "ansb": "2338", "du": "/complete/deleteitems?client=qsb-android-asbl&delq=上海天气 &deltok=AKtL3uTL0hK_EMlKCgutzvQvzXfh2VRAgg", "ansa": {"l": [ {"il": {"t": [{ "tt": 13, "t": "上海天气" }]}}, {"il": { "at": { "tt": 12, "t": "周三" }, "t": [ { "tt": 1, "t": "54" }, { "tt": 3, "t": "°F" } ], "i": { "d": "//ssl.gstatic.com/onebox/weather/128/partly_cloudy.png", "t": 3 } }} ]}, "zc": 602 } ], [ "上海天气<b>预报<\/b>", 0, [], {"zc": 601} ], [ "上海天气<b>预报10天<\/b>", 0, [], {"zc": 600} ], [ "上海天气<b>预报15天<\/b>", 0, [], {"zc": 551} ] ], { "q": "W9D2ISTC-TXK4QauyTt2SFqJzvo", "n": 0 } ]
非 IS 搜索请求和搜索应答解码
当 IS 应答消息里有搜索结果时,点击搜索按钮不会再发起一次搜索。但如果 IS 应答只有 SUG,没有搜索结果,Google App 就会发起一次非 IS 的正常搜索请求。这次请求除了请求的 URL path 从 "/s" 变成 "/search" 以外,主要的 GET 参数保持一致,会有部分附加参数的不同。以『上海天气』(有 IS 结果)和『上海天气好不好呢』(无 IS 结果)为例,GET 参数有以下区别:
-qsd 3670 -pf i -sclient qsb-android-asbl -cp 4 -psi bLxzwaswPFc.1458723200693.3 -ech 3 -gl us -sla 1 +gs_lp EhBxc2ItYW5kcm9pZC1h... +source and/assist +entrypoint android-assistant-query-entry
而非 IS 搜索应答和 IS 应答对比,区别主要在于非 IS 搜索应答消息中,没有搜索词 SUG Message 包。
协议分析和启发
请求消息协议
搜索请求是通过 GET 协议实现的,所以请求主要分为两部分:HTTP 头和 GET 参数。从请求上来看,Google 对 GET 参数的使用是非常节省的,很多字段都是极其精简的缩写。但它倒是在 HTTP 头里放了很多比较大的数据字段,从 Header 名来猜测,应该是跟设备、登录用户相关的一些加密字段。
因为搜索请求是 App 发出的,所以理论上 GET 请求和 POST 请求的实现难度是差不多的,POST 的时候可以进行数据压缩,Header 倒是不能压缩(HTTP 1.x)。那为什么 Google 反而选择把这么长的数据放在 HTTP Header 里呢?我的猜测是为了充分利用 HTTP/2 的特性。在 HTTP/2 里有个特性,叫做 Header Compression,在多次请求时,同一个 Header 原文仅需要压缩传输一次即可。但由于现在还没有 HTTP/2 的抓包工具,所以还无法判断 Google App 是已经用上了这个特性,还是为未来的使用做好准备。不过这至少给了我们一个启发,为了充分利用底层协议的特性,应用层约定可能也需要一些适配工作。
应答消息协议
Google App 的搜索结果,并没有像普通网站服务一样,直接用标准的 HTML 协议返回一个 Web Page。而是将渲染好的 Web Page 分段放到应答消息中,由 App 端提取、拼接成最终的搜索结果页。猜测有以下几点考虑:
- 便于与 SUG 服务集成。很多搜索框都提供 Sug 功能,但 Google 为了让用户感觉更快,在输入过程中不仅有 Sug,还会直接显示搜索结果,桌面版上叫做『即时搜索』。移动版 App 的做法跟桌面版类似,但实现上有不同的地方。移动 App 的输入框不是 HTML 的 <input> 标签,而是一个系统原生的输入框,所以无法依赖 Javascript 去响应事件,刷新结果页。而且网络请求是 App 发起的,为了充分利用网络连接,将搜索结果集成到 SUG 结果里也是顺理成章的事情。不过这还意味着在 App 上,不能仅返回数据通过 Ajax 技术无缝刷新结果页,必须得在消息里返回整个搜索结果页。
- 减少解码内存使用,改善性能。如果将整个搜索结果页放到一个 Protobuf Message 中,客户端为了解码这个消息,需要申请很大一块内存。而在移动设备上,内存是很 critical 的资源,尤其是在 Android 设备上,使用大块内存会导致频繁的 GC,性能很差。
- 模拟 Chunked 编码。Web 服务器可以通过 HTTP 1.1 引入的 Chunked transfer encoding 将网页分块传输给浏览器,浏览器无需等待网页传输结束,就能够开始页面渲染。当网页通过 Protobuf Message 传输时,无法利用浏览器的 chunked 处理技术,只好用分拆为多个 Message 的方式模拟 chunked 模式。虽然 Android 原生的 Webview 并没有支持 chunked 的 LoadData 接口,相信 Google 自己的 App 实现一个类似功能并不困难。
但 Google 这种直接下发网页数据的做法,也存在一个问题,就是没有合法的网页 URL。合法的网页 URL,有以下几个潜在的作用,Google App 做了一些额外的工作来处理:
- 刷新页面。Google App 没有提供页面刷新功能;
- 前进后退。Google App 没有提供前进后退功能,而是通过 Search History 来满足后退功能。
- 浏览历史。Google App 没有提供浏览历史,只提供了搜索历史。
- 页面分享。Google App 没有提供页面分享功能。
Android 版本的 Google App 连搜索结果都需要用第三方浏览器打开,结合上述功能处理,可以发现 Android 版本 Google App 只是想做一个精悍的搜索应用,无意于把自己变成一个完善的浏览器。但 iOS 版本 Google App 对上述问题的处理略有不同,iOS 版本内置了一个浏览器,搜索结果可以在 App 内打开。但主要的搜索结果页,仍然是采用类似于 Android 的方式处理。也就是说,iOS 版本的浏览器,可能仅仅是一个 UIWebview。
狂赞不止