写点什么

软件差异化升级——差量包篇

作者:golf
  • 2023-09-29
    陕西
  • 本文字数:6156 字

    阅读完需:约 20 分钟

1. 引言

上篇我们介绍了一个软件包在服务端的吃包和发布过程,这篇我们介绍差异化升级的主体流程。


客户端的整个升级可以分为三部分:


  • 检测升级

  • 请求差异包

  • 本地文件替换


下面我们着重从服务端的视角来探究客户端的升级过程,重点会放在差量包和升级检测部分。

2. 升级检测

从整个升级的流程来看,升级检测可以划分为两步:


  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 文件差异化比对

客户端拿到新版本信息后,分为两种情况:


  1. 如果不需要升级,客户端直接跳过升级流程就行;

  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 复用的;


要想复用差量包,我们在第一次生成差量包的时候要做几件事情:


  1. 需要用差异化文件生成一个 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)}
复制代码


  1. 需要创建一张表,来缓存差异包信息。


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;
复制代码


  1. 将生成的差异包信息入库,包括存储路径和 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 的内存,导致服务器宕机;


这个故障案例中反映出两个问题:


  1. 拷贝文件不能用ioutil.ReadAll,用io.Copy可以避免此问题,不论文件大小,内存都只占用一个 buffer 大小;

  2. 生成差异包这个地方需要做防并发保护,避免多个请求并发生成相同的差量包,做重复工作;


我们使用一个开源组件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 的安装流程,详情见下面的参考阅读链接。

参考阅读


发布于: 刚刚阅读数: 3
用户头像

golf

关注

IT行业也需要能沉下心来把软件做好的工匠。 2018-09-21 加入

10年以上行业经验和技术积累,擅长将复杂的业务转换为清晰简化的方案设计,并致力于打造高性能、可扩展、结构优良的软件。

评论

发布
暂无评论
软件差异化升级——差量包篇_golang_golf_InfoQ写作社区