一种软件包差异化升级方法(下篇)

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的安装流程,详情见下面的参考阅读链接。

参考阅读

  • 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
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沉下心来学鲁班

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值