Maipo 的热更新机制
很长一段时间来,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 执行。
客户端缓存
Maipo 利用了 HTTP 的原生缓存机制,实现了在服务端文件未变化时,不再重复下载 Patch 文件:
- 当客户端下载到 Patch 文件,会将文件缓存到磁盘,同时将 Response Header 中的
Last-Modified
字段内容缓存到另一个文件中 - 在下载时,将
Last-Modified
内容作为 Request HeaderIf-Modified-Since
上传 - 如果服务端文件有变化,则会响应
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 | // sign: abcd123 |
- 客户端每次执行 Patch 文件时,用公钥对文件第一行之外的内容进行签名校验,只有校验通过,才执行脚本
1 | // 生成 SHA256 Digest |
由于校验发生在 Patch 实际执行之前,所以无论是新下发的还是缓存的 Patch,均无法被其他人篡改。
覆盖策略
Maipo 的部署策略非常简单,静态部署的方式暂时无法实现 Patch 的灰度下发,所以对于已经下发的 Patch,需要能够及时覆盖或撤销。
但对于已经打上 Patch 1 的客户端, Patch 2 能否直接覆盖,是需要视情况而定的,如果对同一个方法重复进行 Patch,可能导致意外的后果。
因此 Maipo 的 Patch 可以定义自身的覆盖方式,有三种选项:
值 | 枚举 | 定义 | 场景 |
---|---|---|---|
0 | WaitForNextLaunch |
下次启动时才执行 | 对时效要求不高,且不希望打扰用户 |
1 | AskForNextLaunch |
询问用户是否要重启 | 对时效要求高,且不能直接覆盖时 |
2 | Overwrite |
直接执行 | 对时效要求高,且可以直接覆盖时 |
每次投放 Patch 时,我可以根据实际情况进行选择,同样的这种配置也是放在 Patch 的实际内容上方进行下发
1 | // sign: abcd123 |
对于撤回 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 | // sign: abcd123 |