1. 引言
上篇我们介绍了一个软件包在服务端的吃包和发布过程,这篇我们介绍差异化升级的主体流程。
客户端的整个升级可以分为三部分:
- 检测升级
- 请求差异包
- 本地文件替换
下面我们着重从服务端的视角来探究客户端的升级过程,重点会放在差量包和升级检测部分。
2. 升级检测
从整个升级的流程来看,升级检测可以划分为两步:
- 检测新版本:向服务器发请求检测是否有新版本
- 差异比对:比较本地版本与新版本间的差异文件
2.1 检测新版本
检测方式: 客户端拿着本地的application
(应用标识)、version
(版本号)来询问服务器是否有新版本可以升级。
如果只看有无新版本,服务端做的事情可以很简单:
- 查出application的最新发布版本,如果大于请求的version则需要升级,反之不需要升级;
- 如果需要升级,则查出最新版本的release notes和release files,连同发布信息一起返回给客户端;
服务器返回的xml示例:
<release releaseId="2602" needupdate="true" version="6.16.23092101" application="10006" site="60000" time="2023-09-21 07:26:56">
<release_notes>
<note version="6.16.23092101">
<![CDATA[ 1. 电脑客户端优化布局管理、布局翻页 2. 解决重要bug及视觉交互优化 ]]>
</note>
</release_notes>
<file path="/6/60000/api-ms-win-core-console-l1-1-0.dll" checksum="893ccbb69c80f31e4113fee262899556" size="18624"/>
<file path="/6/60000/ucrtbase.dll" checksum="65459ca7bcd1a1e6a749a6be6063db34" size="1147712"/>
<file path="/WebSocketPlugin.dll" checksum="c06a14eb86f7ba549ea9295927c0251c" size="74240"/>
<file path="/websockets.dll" checksum="2264a6b2e13c1538c51ef452ac386964" size="219136"/>
<file path="/zlib.dll" checksum="f8917a175390b9886b79356f1615f1fe" size="71168"/>
……
</release>
xml内容解读如下:
- needupdate: true表示需要升级,false不需要升级;
- releaseId: 发布ID,用来标识一个版本的发布,请求差异包时需要携带;
- version: 新版本的版本号
- release_notes: 新版本的升级内容,用于给用户提示
- file: 新版本的文件列表,其中
- path: 标识文件在安装目录下的路径;
- checksum: 文件的MD5值,用于比对文件变化;
- size: 文件大小,可以用于辅助比较文件是否有变化;
不过文件列表file部分可能会比较大,当不需要升级时,file部分可以不返回。
2.2 文件差异化比对
客户端拿到新版本信息后,分为两种情况:
- 如果不需要升级,客户端直接跳过升级流程就行;
- 如果需要升级,并且用户同意升级,客户端需要作文件差异化比对;
差异化比对的主要逻辑如下:
- 以服务器返回的文件列表为基准,查看对应的本地文件是否存在,不存在则加入差异文件;
- 如果本地文件存在,则对本地文件计算MD5值,与服务器返回的checksum比较,不相同则为差异文件;
- 对所有文件重复上述步骤,即可得到服务器相对于本地有差异的文件;
对比完后,客户端将差异化文件提交给服务器,请求生成差量包。
2.3 检测升级还会有哪些可能?
升级检测这个环节看似简单,其实是最容易扩展需求的地方,除了通过有没有新版本来判断是否需要升级外,实际上这一环节还可以做很多业务,例如:
- 区分是需要强制升级,还是用户可选择的普通升级
- 区分是统一升到最新版本,还是允许不同客户升到不同的版本
- 黑名单:整体普升的背景下,有些客户要求不升级,需要支持按客户配置黑名单
- 有些情况不能强升,如sdk、静默升级
- 当版本之间差别太大时,不能再走差异化升级,需要支持引导下载完整安装包;
这些需求是比较碎的,限于篇幅和重心,此部分在这篇文章里暂时就不作展开。
3. 生成差量包
客户端提交过来的请求参数主要包含两个信息,json示例如下:
- r: 新版本的releaseId;
- f: 差异化文件列表,每个文件在安装包下的相对路径;
{
"r": 2392,
"f": ["/6/60000/api-ms-win-core-handle-l1-1-0.dll", "/resources/html/login/script/109_14103ef2f44c08c77550.js", "/resources/pages/userList.html", "/VideoEngineCore.dll", "/VideoMixerEngine.dll"]
}
服务器收到生成差量包的请求后,主要做以下事情:
- 根据releaseId从
us_release_file
表查询请求文件的存储路径; - 读取文件内容并打成压缩包,给客户端返回压缩包的下载地址;
3.1 查询差异化文件
查询文件信息只需要一个基本的SQL查询:
SELECT id, update_path, version, checksum, storage_path, size, release_date FROM us_file_element WHERE release_id = %d
这条SQL查出来的是release_id下的所有文件,差异包只需要其中一部分文件,所以还需要做一层过滤,过滤逻辑示例如下:
func (self *USDownload) filterDiffFiles(allFiles map[string]*models.TReleaseFile) []*models.TReleaseFile {
diffFiles := make([]*models.TReleaseFile, 0, len(allFiles))
for _, v := range self.Path { // self.Path为客户端请求的文件集, 相对路径的集合
if fileInfo, ok := allFiles[v]; ok { // allFiles为从数据库查询出来的全部文件列表,以相对路径为Key
diffFiles = append(diffFiles, fileInfo)
}
}
return diffFiles
}
这样就得到了差异文件的详细信息diffFiles
,下面就可以为这些差异文件生成压缩包。
3.2 写压缩包
首先,创建一个文件用来存储压缩包内容,为避免重复,直接使用uuid作为文件名。
func (self *USDownload) createPatchFile() (*os.File, string, error) {
packageDir := filepath.Join(self.RootPath, "packages")
if !util.CreateDir(packageDir) { // 创建patch目录
return nil, "", fmt.Errorf("create dir %s error", packageDir)
}
filePath := fmt.Sprintf("%s/%s.zip", packageDir, util.UUID()) // patch文件路径
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY, 0666)
return file, filePath, err
}
然后,开始向这个压缩包写入数据,用下面一个函数封装写入逻辑,写入完成后返回压缩包路径供客户端下载;
func (self *USDownload) buildPatch(files []*models.TReleaseFile) (string, error) {
packageFile, path, err := self.createPatchFile() // 创建差异包文件并打开
…… 错误处理省略
w := zip.NewWriter(packageFile) // 构造zip格式的写操作句柄
defer w.Close()
for _, file := range files { // 逐个遍历文件,将文件内容写入压缩包
if err := self.writeFile(w, file); err != nil {
return "", err
}
}
// 返回压缩包路径,RootPath对于客户端是不可见的,所以要去掉
return strings.TrimPrefix(path, self.RootPath), nil
}
具体到每个文件的数据写入,单独封装一个方法:
- 源文件是磁盘上的实际文件,来自吃包时的保存,storagePath为文件在磁盘上的路径;
- 目标文件是zip压缩包中的文件,file.Path是文件在包内的相对路径;
- io.Copy负责将源文件句柄src中的内容写入到目标文件句柄dst中;
func (self *USDownload) writeFile(w *zip.Writer, file *models.TReleaseFile) error {
srcPath := filepath.Join(self.RootPath, file.StoragePath)
src, err := os.Open(srcPath) // 打开源文件句柄
if err != nil {
return err
}
defer src.Close()
dst, err := w.Create(file.Path[1:]) // 打开目标文件句柄
if err != nil {
return err
}
_, err = io.Copy(dst, src) // 从源文件向目标文件拷贝内容
if err != nil {
return err
}
return nil
}
3.3 差量包复用
当差异内容较大时,生成差量包还是挺耗费时间的,自己用SSD做测试,一个100MB的差量包大概需要6s的时间,机械硬盘只会更慢。如果每个用户请求时都临时生成一份差量包,那每个用户都要等待这段时间。
实际上,软件从一个版本升到另一个版本的差异内容基本是固定的,还是举例说明。
- 假如A、B、C三个人现在安装的都是1.0.0版本,我们发布了1.0.1版本;
- 那么正常情况下,从1.0.0到1.1.1版本的差异文件应该是一样的,不区分是A来请求,还B、C来请求;
- 我们没必要为三个人重复生成3份差量包,给A生成的差量包理论上是可以给B、C复用的;
要想复用差量包,我们在第一次生成差量包的时候要做几件事情:
- 需要用差异化文件生成一个MD5值,此MD5值决定了差量包能否复用,代码示例如下:
func (self *USDownload) GetFileMd5(diffFiles []*models.TReleaseFile) string {
pathList := make([]string, len(diffFiles)) // 获取差异文件存储路径列表
for i, v := range diffFiles {
pathList[i] = v.StoragePath
}
sort.StringSlice(pathList).Sort() // 给差异文件的路径排序
md5Ctx := md5.New()
md5Ctx.Write([]byte(strings.Join(pathList, ","))) // 计算MD5值
cipherStr := md5Ctx.Sum(nil)
return hex.EncodeToString(cipherStr)
}
- 需要创建一张表,来缓存差异包信息。
CREATE TABLE `us_diff_patch` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`storage_path` varchar(255) NOT NULL COMMENT '差异包存储路径',
`checksum` varchar(128) NOT NULL COMMENT '差异包文件内容校验和',
`size` bigint(20) NOT NULL COMMENT '差异包字节大小',
`file_hash` varchar(128) NOT NULL COMMENT '差异文件的MD5值',
PRIMARY KEY (`id`),
KEY `idx_file_hash` (`file_hash`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
- 将生成的差异包信息入库,包括存储路径和MD5值。
这样,后续相同差异文件的请求到来时,我们也用同样的方式计算MD5值,并加一个查询复用环节即可,代码示例如下:
// 检查patch文件是否已经存在
fileHash := self.getFileMd5(diffFiles)
patch := models.GetDiffPatch(fileHash)
if patch != nil && self.FileExists(patch.StoragePath) {
log.Printf("patch file already exists, [%s] %s", fileHash, patch.StoragePath)
return patch, nil
}
GetDiffPatch方法内部就是一条SQL查询:
SELECT id, storage_path, checksum, size, file_hash FROM us_diff_patch WHERE file_hash = [fileHash]
3.4 故障案例
实际场景应用时,可能还会遇到一些问题,这里举一个我们生产线出过的一次服务器内存OOM问题。
-
有一次,PC客户端出了一个新包,包吃完发布后,大约不到10分钟服务器就会因为内存占满而宕机;
-
后来排查得知,客户端新包里更新了一个大文件达100多MB;
-
服务器当时拷贝文件用的方法是
ioutil.ReadAll(file)
,会把每个文件都先读到内存里,再执行写入操作,一次差异包请求单单读这个文件就吃掉100MB以上的内存; -
恰巧,客户端之前有一个静默升级机制,新版本发布后,立即就有几百个请求来生成差异包文件,短时间内就吃掉了几十GB的内存,导致服务器宕机;
这个故障案例中反映出两个问题:
- 拷贝文件不能用
ioutil.ReadAll
,用io.Copy可以避免此问题,不论文件大小,内存都只占用一个buffer大小; - 生成差异包这个地方需要做防并发保护,避免多个请求并发生成相同的差量包,做重复工作;
我们使用一个开源组件singleflight来实现防并发保护:
- 首先,全局创建一个singleflight.Group实例:
import (
"golang.org/x/sync/singleflight"
)
var (
sg = &singleflight.Group{}
)
- 然后,封装一个singleBuildPatch方法,来确保同一个差异包只执行一次构建。
func (self *USDownload) singleBuildPatch(patchKey string, diffFiles []*models.TReleaseFile) (string, error) {
result, err, _ := sg.Do(patchKey, func() (interface{}, error) {
return self.buildPatch(diffFiles)
})
if err != nil {
return "", err
}
patchPath := result.(string)
return patchPath, nil
}
除上面两点外,还有其它点可以优化:
- 差异包生成后可以挂到CDN上,既能就近加快下载速度 ,也能降低升级服务器带宽占用;
- 从DB中查询文件列表这步,也可以加下内存级缓存,防止几百请求都去轰DB;
3.5 串流程
上面已经介绍了差量包生成过程中的各个细分环节,这里将各个环节串一下,以便对业务流程有一个整体认识。
- 创建一个业务结构体,供外面访问和设置参数:
type USDownload struct {
ReleaseId int // 客户端请求的发布ID
Path []string // 客户端请求的差异化文件列表,以相对路径形式提供
RootPath string // 升级业务的磁盘存储目录
}
- 公开一个入口方法供外面调用,这个方法所做的事情就是将前面提到的各个业务点串起来,构成一个完整的差量包生成功能。
func (self *USDownload) Download() (*models.TDiffPatch, error) {
// 查询文件列表
allFiles, err := models.GetDownFile(self.ReleaseId)
if err != nil {
return nil, fmt.Errorf("query release file error: %s", err.Error())
}
// 过滤出差异化文件
diffFiles := self.filterDiffFiles(allFiles)
if len(diffFiles) == 0 {
return nil, fmt.Errorf("not match client upgrage file")
}
// 检查patch文件是否已经存在
fileHash := self.getFileMd5(diffFiles)
patch := models.GetDiffPatch(fileHash)
if patch != nil && self.FileExists(patch.StoragePath) {
log.Printf("patch file already exists, [%s] %s", fileHash, patch.StoragePath)
return patch, nil
}
// 如果不存在,则生成一个新的patch
patch = &models.TDiffPatch{FileHash: fileHash}
patch.StoragePath, err = self.singleBuildPatch(fileHash, diffFiles)
if err != nil {
return nil, err
}
// 计算文件大小和内容校验和
patch.CheckSum, patch.Size = self.calculateChecksumAndSize(patch.StoragePath)
if err := models.SaveDiffPatch(patch); err != nil {
return nil, err
}
return patch, nil
}
生成差量包后,服务器将差量包地址返回给客户端,客户端下载差量包,并解压替换掉本地的文件,最后重启客户端就完成了升级。
小结
本文主要介绍了升级检测及差量包的生成过程,也结合一些实际案例描述了生成差量包环节能做的一些技术优化。
目前使用这种差量包所做的差异化升级,主要是基于安装目录下的文件替换,此种方法比较适合PC端。对于移动端则不一定适用,主要是移动端有严格的文件访问权限控制,应用一般没有权限直接操作app安装后的文件。
有一种方法是基于安装包做差量化,来生成针对安装包的补丁文件。例如:Android端基于apk制作补丁文件,app拿到补丁文件后将它打到本地旧的apk文件中,然后再走apk的安装流程,详情见下面的参考阅读链接。
参考阅读
- Android差分升级原理和实现方式: https://blog.csdn.net/robertcpp/article/details/51730021
- singleflight: https://studygolang.com/articles/18835?fr=sidebar
- io.Copy内部实现:https://zhuanlan.zhihu.com/p/642515527
- 本文代码参考:https://github.com/golfxiao/gotool