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