1. 引言
上篇我们介绍了一个软件包在服务端的吃包和发布过程,这篇我们介绍差异化升级的主体流程。
客户端的整个升级可以分为三部分:
下面我们着重从服务端的视角来探究客户端的升级过程,重点会放在差量包和升级检测部分。
2. 升级检测
从整个升级的流程来看,升级检测可以划分为两步:
检测新版本:向服务器发请求检测是否有新版本
差异比对:比较本地版本与新版本间的差异文件
2.1 检测新版本
检测方式: 客户端拿着本地的application
(应用标识)、version
(版本号)来询问服务器是否有新版本可以升级。
如果只看有无新版本,服务端做的事情可以很简单:
服务器返回的 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": 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"]
}
复制代码
服务器收到生成差量包的请求后,主要做以下事情:
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来实现防并发保护:
import (
"golang.org/x/sync/singleflight"
)
var (
sg = &singleflight.Group{}
)
复制代码
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
}
复制代码
除上面两点外,还有其它点可以优化:
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 的安装流程,详情见下面的参考阅读链接。
参考阅读
评论