post

很长一段时间来,Maipo/WeiboX 的 Bug 修复方式,只有“在 AppStore 发布版本更新”一种,在热更新这么热门的今天显得有些落伍,主要原因还是精力不足:

还有那么多想做的功能没有实现呢!

其实 AppStore 发版很多时候是可以满足要求的,现在审核的速度也越来越快,基本 1 天左右就能完成。但难免有时候问题影响范围太大,用户的反馈还是会搞得我手忙脚乱,最后也只能看着”Waiting for Review” 干着急。
这个时候就会想,如果可以热更新,就不用这样大费周章了。

所以在近期的版本里,我还是给 Maipo 加入了热更新的机制。

JSPatch

在众多热更新方案里,JSPatch 是国内比较成熟的一个,执行过程简单直接,自己也在其他生产环境用到过,对其比较熟悉,因此很自然地选择了 JSPatch。

JSPatch 的具体原理这里不再讨论,如果感兴趣可以移步作者的Wiki

投放平台

最初我也了解了 JSPatch 官方的投放平台,但发现免费的“基础版”服务只可以承载日均一万的请求量,无法满足 Maipo 的要求,而更高请求量需要至少 399元/月 的服务费,这对 Maipo 这样一个免费应用来说有些过于昂贵了。虽然 Maipo 不赚钱,也不能一直赔钱吧,哈哈。

Maipo 的网站一直架设在 Sina SAE 上,因此也决定使用 SAE 作为平台,自己实现一套简单的投放策略

投放策略

既然策略可以自己实现了,就希望能设计得简单高效。回顾了过往遇到的问题之后,结合自己时间精力上的限制,给 Maipo 的热更新投放方案定了一些要求:

  • 能根据客户端版本下发不同 Patch
  • 通过静态的配置方式,以减小开发成本
  • 客户端能够有效缓存下发结果,以减少 SAE 资费
  • 既然是静态配置,没有办法灰度投放,所以如果 Patch 出现问题,需要能够撤回或者覆盖

最终 Maipo 的实现方案如下:

服务端部署

Patch 文件静态部署在 HTTP 服务上,部署的路径根据规则设置:

1
https://www.example.com/rc/{BUNDLE_VERSION_SHORT}/{BUNDLE_VERSION}.config

例如需要对 Maipo 3.4.0 (18221) 版本投放 Patch 时,将文件上传到以下路径即可

1
https://www.example.com/rc/3.4.0/18221.config

对于 Patch 本地调试阶段,也设置了一个专用的路径:

1
https://www.example.com/rc/debug/debug.config

客户端加载

客户端在一些特定的时机,对 Patch 文件进行加载,如:

  • 应用启动时
  • 应用从后台被激活,且距离上次加载达到一定时间间隔时

加载按照部署时的规则进行,如果此版本没有进行 Patch 投放,则服务端会返回 404 Not Found,不会有额外的带宽消耗。

Patch 加载完成后,调用 JSPatch 的相关 API 执行。

客户端缓存

Cache Policy

Maipo 利用了 HTTP 的原生缓存机制,实现了在服务端文件未变化时,不再重复下载 Patch 文件:

  1. 当客户端下载到 Patch 文件,会将文件缓存到磁盘,同时将 Response Header 中的 Last-Modified 字段内容缓存到另一个文件中
  2. 在下载时,将 Last-Modified 内容作为 Request Header If-Modified-Since 上传
  3. 如果服务端文件有变化,则会响应 200 OK 和具体文件内容;如果没有变化,则响应 304 Not Modified,此时服务端不会下发文件内容了

中间人攻击

客户端动态执行代码很强大,但如果被坏人利用,会变得很危险:

  • 对应用来说,可以轻易通过伪造 Patch,调用到应用中任意代码,造成安全隐患
  • 对用户来说,如果通过 DNS 劫持等手段伪造 Patch,也会有密码、隐私泄漏等严重的安全问题

这里的攻击不一定发生在网络传输缓解,由于 macOS 文件系统是开放的,通过简单修改 Patch 缓存,也可以轻易发起攻击。

RSA 签名校验

Maipo 为了解决这个问题,给 Patch 加入了签名校验的机制:

  • 首先生成了 RSA 公私密钥对,其中公钥编码在客户端中,私钥存储在我个人的签名脚本中;
  • 每次部署前,用签名脚本对 Patch 内容进行签名,将签名结果放在 Patch 文件的第一行
1
$ openssl dgst -sha256 -sign private_key.pem patch.config
1
2
3
// sign: abcd123

defineClass(...); // 具体的 Patch 代码
  • 客户端每次执行 Patch 文件时,用公钥对文件第一行之外的内容进行签名校验,只有校验通过,才执行脚本
1
2
3
4
5
6
// 生成 SHA256 Digest
NSMutableData * digest = [[NSMutableData alloc] initWithLength:CC_SHA256_DIGEST_LENGTH];
CC_SHA256([inputData bytes], (CC_LONG)[inputData length], digest.mutableBytes);

// 校验 SHA256 与签名是否匹配
return SecKeyVerifySignature(publicKey, kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256, digest, signature, NULL);

由于校验发生在 Patch 实际执行之前,所以无论是新下发的还是缓存的 Patch,均无法被其他人篡改。

Anti-MITM

覆盖策略

Maipo 的部署策略非常简单,静态部署的方式暂时无法实现 Patch 的灰度下发,所以对于已经下发的 Patch,需要能够及时覆盖或撤销。

但对于已经打上 Patch 1 的客户端, Patch 2 能否直接覆盖,是需要视情况而定的,如果对同一个方法重复进行 Patch,可能导致意外的后果。

因此 Maipo 的 Patch 可以定义自身的覆盖方式,有三种选项:

枚举 定义 场景
0 WaitForNextLaunch 下次启动时才执行 对时效要求不高,且不希望打扰用户
1 AskForNextLaunch 询问用户是否要重启 对时效要求高,且不能直接覆盖时
2 Overwrite 直接执行 对时效要求高,且可以直接覆盖时

每次投放 Patch 时,我可以根据实际情况进行选择,同样的这种配置也是放在 Patch 的实际内容上方进行下发

1
2
3
4
5
// sign: abcd123

// <MPOMeta> ApplyPolicy:1

defineClass(...); // 具体的 Patch 代码

对于撤回 Patch 的场景,服务端将 Patch 直接删除,客户端收到 404 Not Found 之后,会默认以 AskForNextLaunch 方式,提示用户重启客户端。当然如果不希望打扰用户,我也可以选择自定义覆盖策略,下发一个没有实际代码的 Patch 进行覆盖。

写在最后

Maipo 的热更新机制其实很简单,只是针对 Maipo 的量级和痛点做的一个轻量级实现,不过也暂时满足了我的需求。

实际上刚刚上线的 Maipo 3.4.0 版本已经投出了一个 Patch,解决了部分用户无法高级授权的问题。而 SAE 上的资源消耗并没有明显增加,我没有对 Patch 部分单独统计,但观察总体的资源报表,新版本上线投放前后的日均云豆消耗差不到10豆,算下来也就是不到 3元/月。

看见自己设计的方案 省了这么多钱 能够按预期运行,还是很让人高兴的😄。

上面写到的几个解决方案也是凭自己的一些经验设计,要是有不足或者漏洞也欢迎一起讨论~


Update 2018.02.02

经 @SeanChense 在评论中提醒,虽然部署是静态的,但是依然可以通过不断更新 Patch 文件来实现简单的灰度,例如通过 ApplyPolicy 类似的方式,在 Patch 中增加新的 Meta 字段,指定用户尾号范围:

1
2
3
4
5
6
// sign: abcd123

// <MPOMeta> ApplyPolicy:1
// <MPOMeta> UserRange:80,89

defineClass(...); // 具体的 Patch 代码