HEAD 小乌龙

背景

手机 App

熟悉手机端的朋友一般都了解:

  1. iOS App 的文件格式是 .ipa ;Android App 的文件格式是 .apk。
  2. ipa 文件安装协议是苹果提供的形如:itms-services://?action=download-manifest&url=https://tommygirl.cn/app/download/plist 的链接。一系列的请求首先指向 plist 文件,plist 文件中会注明 ipa 文件的真实下载地址以及 App 的基本信息。在此不赘述。
  3. apk 文件相较于 ipa 的安装更简单一些,下载好 apk 文件以后跟随系统的提示进行安装即可。

提供给企业内部员工使用的 App 一般不会特意上线 AppStore 或者安卓各应用市场,通常自己维护一个下载机制供少数人安装即可。

服务端

基于手机端的背景,一个简单的下载机制可以是这样的:

  1. 提供一个统一的 App 下载(更新)接口:

    1
    https://tommygirl.cn/app/download
  2. 根据 HTTP 头部 User-Agent 字段来判断设备类型:PC、iOS、Android……等等。

    1
    User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36
  3. 根据不同设备在 Response 中返回不同的下载地址:

    如果是 Android 设备呢,1. 接口返回形如:

    1
    https://tommygirl.cn/app/download/ahaaaaa.apk

    如果是 iOS 设备呢,1. 接口返回形如:

    1
    itms-services://?action=download-manifest&url=https://tommygirl.cn/app/download/plist

    plist 指向 ipa:

    1
    https://tommygirl.cn/app/download/ahaaaaa.ipa
  4. 手机端根据各自系统的特点去下载新的安装包。

用户需求

用户要求统计 ipa 和 apk 的下载量,误差姑且接受 ±100 吧。哈哈哈,毕竟自己人可能反复测试产生一些误差数据🤓。

由于项目周期短,服务端的实现机制是统计两个文件所在位置的访问量。然鹅,同事测试的时候发现:下载量是翻倍在增长,这样下去我们 App 的下载量可以跟微信媲美了 😂。所以这个诡异的现象是为什么呢?

分析-抓包

从安卓手机安装的现象看,浏览器先访问了一次 apk 文件,但又主动取消了下载,要等用户手动再点一次下载,才会真正的开始接收数据。所以我猜测是不是先做了一次 HEAD 请求,第二次再做的 GET 请求下载数据。

  1. 先在 iOS 手机上抓了一下包,每点击一次下载按钮都会依次发出三个网络请求:

    1
    2
    3
    GET /app/download/plist HTTP/1.1
    HEAD /app/download/ahaaaaa.ipa HTTP/1.1
    GET /app/download/ahaaaaa.ipa HTTP/1.1

抓包内容:

这个时候我已经咋咋呼呼的跑去找后端同事炫耀自己的猜测了,自信的认为绝对是 HEAD 请求导致的,燃鹅……

  1. 在 Android 手机上抓包,两级反转了:

    1
    2
    GET /app/download/ahaaaaa.apk HTTP/1.1
    GET /app/download/ahaaaaa.apk HTTP/1.1

抓包内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//----- Android
{
"time_local": "30/Dec/2021:15:24:04 +0800",
"remote_addr": "",
"remote_user": "-",
"upstream_addr": "",
"time_total": "2.081",
"upstream_response_time": "2.081",
"status": "200",
"request": "GET /app/download/ahaaaaa.apk HTTP/1.1",
"method": "GET",
"body_bytes_sent": "13815405",
"http_user_agent": "Mozilla/5.0 (Linux; Android 9; MHA-AL00; HMSCore 6.2.0.301; GMSCore 19.6.29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.105 HuaweiBrowser/12.0.1.300 Mobile Safari/537.36"
}

{
"time_local": "30/Dec/202:15:24:12 +0800",
"remote_addr": "",
"remote_user": "-",
"upstream_addr": "",
"time_total": "4.410",
"upstream_response_time": "4.411",
"status": "200",
"request": "GET /app/download/ahaaaaa.apk HTTP/1.1",
"method": "GET",
"body_bytes_sent": "13815405",
"http_user_agent": "Mozilla/5.0 (Linux; Android 9; MHA-AL00; HMSCore 6.2.0.301; GMSCore 19.6.29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.105 HuaweiBrowser/12.0.1.300 Mobile Safari/537.36"
}

竟然是两个 GET 请求,第一个请求被浏览器主动断开了,所以时间也相对较短。这里我是挺不理解的,为什么不直接使用一个 HEAD 请求呢?所以这就是为什么我不爱用安卓手机🤓。

HEAD 请求

所以什么是 HEAD 请求呢?

HTTP HEAD 方法 请求资源的头部信息, 并且这些头部与 HTTP GET 方法请求时返回的一致. 该请求方法的一个使用场景是在下载一个大文件前先获取其大小再决定是否要下载, 以此可以节约带宽资源。

HEAD 方法的响应不应包含响应正文. 即使包含了正文也必须忽略掉. 虽然描述正文信息的 entity headers, 例如 Content-Length 可能会包含在响应中, 但它们并不是用来描述 HEAD 响应本身的, 而是用来描述同样情况下的 GET 请求应该返回的响应。

如果 HEAD 请求的结果显示在上一次 GET 请求后缓存的资源已经过期了, 即使没有发出GET请求,缓存也会失效。

除了上面提到的节约带宽以外,还可以探测文件的有效性、可用性、最近是否有修改等等。

302

苹果系统针对 ipa 文件应该是主动发的 HEAD 请求,这个相对好处理一些。但安卓系统内部的实现机制我们不太好猜了。最终的解决方案是通过一个 302 跳转规避这种情况,在 原方案 中的第三步修改为:

  1. 在 Response 中返回同一个地址(用于统计下载量的接口),但并不是真正的安装包下载地址。

For example 返回一个统计接口给手机端:

1
https://tommygirl.cn/app/download/file

如果是 Android 设备呢,统计接口再 302 指向:

1
https://tommygirl.cn/app/download/ahaaaaa.apk

如果是 iOS 设备呢,统计接口再 302 指向:

1
itms-services://?action=download-manifest&url=https://tommygirl.cn/app/download/plist

……后面的过程就一样了。

FYI: iOS 用的网络组件 AFNetworking 正常情况下是不支持 itms-services 协议的,所以要单独在失败回调中处理一下。

so……一个小乌龙就这样了