引言
很多软件在使用过程中,都会自动弹出新版本提示,如下图:
当我们点击立即更新
后,就自动下载升级包走升级流程
这个升级过程往往比较快,不需要像第一次下载安装包时等那么久,其中最主要的区别就在于走了差异化升级
,客户端只下载和安装了差量包。
差量包
是一种只包含了新版本与当前版本之间差异的文件集合。相比于完整的软件包,差量包的大小通常更小,它不仅能减少网络带宽,提高升级效率,还能改善用户体验。
接下来,我们就分两篇来探究下整个差异化升级的过程,本篇为上篇,主要介绍吃包,差量包会放在下篇介绍。
2.准备工作
做差异化升级之前,我们首先需要准备一个待发布的新版本软件包和对应的升级配置:
软件包:包含软件所有文件的压缩包
升级配置:待发布的版本信息和文件清单
2.1 软件包
为了方便解析并让软件包格式通用,可以直接使用 zip 将软件所有文件连带目录打成压缩包,就得到我们需要的软件包,如下图:
2.2 升级配置
发布时需要一些配置来描述软件包的基本构成,包括但不限于:
release: 发布版本信息,包括应用、版本号、客户端类型、产品等信息
file:软件包含的文件列表信息,差异化升级以文件为单位,我们需要知道软件包含的所有文件;
notes: 也称为 release notes, 软件更新内容的说明,即用户看到的升级内容提示;
版本信息的格式用 xml 示例如下:
<?xml version="1.0" encoding="UTF-8"?>
<release application="10006" version="6.16.23090504" site="60000" time="2023-09-05 05:51:20">
<file …… /> <!-- 文件,下面再展开 -->
<notes ……/> <!-- release notes, 下面再展开 -->
</release>
复制代码
文件列表格式用 xml 示例如下:
<file path="/6/60000/api-ms-win-core-console-l1-1-0.dll" checksum="3c444dc1b72aeee3e458d2874eb3aec0" size="176048"/>
<file path="/6/60000/TangClient.exe" checksum="ed5a1240ba6aa942722c733d0cf480fb" size="3313"/>
<file path="/resources/html/page/index.html" checksum="e89a61f155ff434773bf780832270c52" size="9778"/>
<file path="/resources/pages/scripts/index.js" checksum="effc8503cf54c7b9da6c7c0715b8cc72" size="151513"/>
复制代码
release notes 格式示例如下:
<notes lang="zh-cn">
<![CDATA[
1. 解决重要bug及视觉交互优化
]]>
</notes>
复制代码
这个升级配置的构造工作是可以集成到 CI 工具中在编译打包时就自动生成的,包括文件列表、每个文件的 MD5 校验和,类似 Jenkins 的工具中都支持添加构建步骤。
3. 吃包
吃包主要做两件事情:
3.1 部署软件包
部署包就是把软件包里的文件解压,以文件为单位转存到磁盘的固定目录中,最好使用共享存储或云存储,以便集群中多台机器都能访问到。
首先,我们要创建一个目录用于存放此软件包的文件,为了方便识别,我们直接用 application 和 version 拼起来作为目录名。
// 构造当前软件包的文件存储目录,并创建该目录
// rootPath: 可以理解为共享存储为升级业务分配的根存储目录,由外面指定
func (self *USDeploy) buildStorageDir() (string, error) {
storageDir := fmt.Sprintf("%s/files/%d_%s", self.rootPath, self.release.Application, self.release.Version)
if ok := createDir(storageDir); !ok {
return "", fmt.Errorf("create storage dir error")
}
return storageDir, nil
}
复制代码
其次,我们打开并遍历压缩包,循环转存每个文件到存储目录下,转存时遵循以下规则:
只转存文件内容,文件在包内的目录信息交由 DB 记录(发布时会描述);
转存时需要给文件重命名,压缩包多层目录中的文件可能会重名,简单起见直接用数组下标来构造;
存储完后需要将文件的相对路径和实际存储路径作个映射,就是下面代码中的 filePathMap,返回给主调程序;
遍历压缩包的代码示例如下:
func (self *USDeploy) unzip(zfile string) (filePathMap map[string]string, err error) {
rc, err := zip.OpenReader(zfile) // 打开压缩包的读取句柄
…… // 错误处理省略
storageDir, err := self.buildStorageDir() // 构造当前软件包的文件存储目录
…… // 错误处理省略
filePathMap = make(map[string]string) // 相对目录与存储目录的映射,发布配置时需要使用
for i, f := range rc.File {
filePath := fmt.Sprintf("%s/f_%d", storageDir, i) // 文件存储路径,文件名直接用f_[index]来命名
if err = self.saveFile(f, filePath); err != nil { // 保存文件
return
}
// 维护相对目录与存储目录的映射, 为便于存储目录迁移,这里只存rootPath以外的部分
filePathMap[f.Name] = strings.TrimPrefix(filePath, self.rootPath)
}
return filePathMap, nil
}
复制代码
最后,具体到一个文件的转存,就是将数据从源文件拷贝到目标文件,3 步即可完成:
打开源文件句柄
打开目标文件句柄
将数据从源文件读出来,写入目标文件
代码示例如下:
func (self *USDeploy) saveFile(file *zip.File, storagePath string) (err error) {
src, err := file.Open() // 源文件句柄
if err != nil {
return fmt.Errorf("open file[%s] error: %s", file.Name, err.Error())
}
defer src.Close()
dst, err := os.Create(storagePath) // 目标文件句柄
if err != nil {
return err
}
defer dst.Close()
_, err := io.CopyN(dst, src, int64(file.UncompressedSize64)) // 从源文件读取数据,并拷贝到目标文件
if err != nil {
return err
}
return nil
}
复制代码
软件包里的文件解压完后,保存到磁盘上的文件如下图所示:
到这里,软件包中文件的部署过程就结束了,剩下的是版本发布。
3.2 发布版本
发布一个软件版本,也简单分为两步:
3.2.1 读取升级配置
还是以前面的 xml 配置示例来说明,首先我们要将版本信息解析出来,用一个函数来完成解析工作:
func (self *USDeploy) readXmlContent(xmlConfig string) (*models.TRelease, error) {
file, err := os.Open(xmlConfig) // 打开xml文件句柄
if err != nil {
return nil, err
}
defer file.Close()
xmlFileBytes, err := ioutil.ReadAll(file) // 读取xml文件内容
if err != nil {
return nil, err
}
releaseInfo := &models.TRelease{}
if err := xml.Unmarshal(xmlFileBytes, releaseInfo); err != nil { // 利用xml在struct上的 tag直接完成反序列化
return nil, err
}
return releaseInfo, nil
}
复制代码
解析后得到一个 TRelease 类型的结构体变量:
// 软件包版本发布信息
type TRelease struct {
Version string `xml:"version,attr"` //客户端升级包版本
Time string `xml:"time,attr"` //客户端升级包时间
Application int64 `xml:"application,attr"` //客户端升级appid(6:mac/pc 202:ios 201:andriod)
Site string `xml:"site,attr"` //升级站点,对应产品标识
ReleaseId int64 `xml:"-"` //软件包发布标识,服务器生成
Notes []*TReleaseNotes `xml:"notes,omitempty"` // 升级内容,可能会有多种语言的notes
File []*TReleaseFile `xml:"file"` // 文件列表
}
// release notes信息
type TReleaseNotes struct {
XMLName xml.Name `xml:"notes,omitempty" orm:"-"`
Id int64 `xml:"-" orm:"column(id);pk;auto"` // 主键,自增
Lang string `xml:"lang,attr" orm:"column(lang)"` // 语言,如zh-cn, en-us
Notes string `xml:",innerxml" orm:"column(release_notes)"` // 升级提示内容
}
// 文件信息
type TReleaseFile struct {
XMLName xml.Name `xml:"file" orm:"-"`
FileId int64 `xml:"-" orm:"column(id);pk;auto"` // 主键,自增
Path string `xml:"path,attr" orm:"column(path)"` // 文件在包内的相对路径
Version string `xml:"version,attr" orm:"column(version)"` // 文件所属包的版本号
Time string `xml:"time,attr" orm:"column(release_date)"`// 文件所属包的发布时间
CheckSum string `xml:"checksum,attr" orm:"column(checksum)"`// 文件的md5
Size int64 `xml:"size,attr" orm:"column(size)"` // 文件的大小
Url string `xml:"url,attr,omitempty" orm:"-"` // 文件的url,适用于http(s)协议的文件
StoragePath string `xml:"-" orm:"column(storage_path)"` // 存储路径
}
复制代码
上面这个结构体定义中,文件信息中的 StoragePath 是需要我们来补充的,还记得前面部署软件包时生成的 filePathMap 吗?它已经维护了相对目录与存储目录的映射,我们只需要写一个函数来查找补充字段即可:
func (r *TRelease) SetFileStoragePath(fileStorageMap map[string]string) {
for i, file := range r.File {
//从压缩包中读出的相对路径,不带前面的"/",需要去掉
if v, ok := fileStorageMap[file.Path[1:]]; ok {
r.File[i].StoragePath = v
}
}
}
复制代码
完成解析工作后,TRelease 中的信息已经基本完整,下面只需要保存配置到 DB 即可。
3.2.2 保存并发布配置
发布版本主要是将软件包信息入库,并设为发布状态。上面提到,xml 升级配置中主要包含三部分信息:
版本发布信息
文件列表信息
release notes
相应的,我们可以用三张表来存储这三块信息,分别为 :
CREATE TABLE `us_site_release` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`release_id` bigint(20) NOT NULL COMMENT '软件包发布标识',
`release_version` varchar(50) NOT NULL COMMENT '发布版本',
`site_id` varchar(50) NOT NULL COMMENT '产品标识',
`application_id` bigint(20) NOT NULL COMMENT '应用标识',
`client_type` varchar(50) NOT NULL COMMENT '终端类型',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '发布状态,0:未发布,1:已发布',
`extend_attr` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin COMMENT '扩展属性',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_release_id` (`release_id`) USING BTREE,
KEY `idx_release_version` (`release_version`) USING BTREE,
KEY `idx_release_appid` (`application_id`) USING BTREE,
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
复制代码
CREATE TABLE `us_file_element` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`update_path` varchar(255) NOT NULL COMMENT '包内相对路径',
`version` varchar(100) NOT NULL COMMENT '所属版本号',
`storage_path` varchar(255) NOT NULL COMMENT '磁盘存储路径',
`release_date` datetime NOT NULL COMMENT '发布时间',
`checksum` varchar(32) NOT NULL COMMENT 'MD5校验值',
`size` bigint(20) NOT NULL COMMENT '文件大小字节数',
`release_id` bigint(20) NOT NULL COMMENT '发布ID',
PRIMARY KEY (`id`),
KEY `idx_file_rid` (`release_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
复制代码
CREATE TABLE `us_release_notes` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`release_id` bigint(20) NOT NULL COMMENT '软件包发布标识',
`lang` varchar(20) NOT NULL COMMENT '语言',
`release_notes` text COMMENT '升级内容说明',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_notes_rid` (`release_id`, `lang`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
复制代码
这三张表的数据需要通过 release_id 字段关联起来的,为此我们需要有一个机制来生成 release_id, 参考前段时间讲过的 ID 生成器,这里可以用两条 SQL 实现一个简易版(鉴于这里基本不存在并发和性能问题,就简单实现):
UPDATE us_ticket set max_id=max_id+1 WHERE `key_name`='release'
SELECT max_id FROM us_ticket WHERE `key_name`='release'
复制代码
与数据库操作的代码这里就省略了,入库后的信息如下:
有一点需要留意:
信息入库后,将 us_site_release 表的 status 置为已发布,客户端就可以检测到发布的新版本。
3.3 命令行工具封装
上面部署软件包和版本发布是分成多步操作的,为方便使用,我们可以将整个吃包过程封装成一个命令行工具。
首先,用一个函数将上面两步串起来:
type USDeploy struct {
rootPath string // 存储根目录
release *models.TRelease
}
// 入参deployFile表示软件包路径,入参deployXml 表示xml配置文件路径
func (self *USDeploy) Deploy(deployFile, deployXml string) error {
releaseInfo, err := self.readXmlContent(deployXml) // 读取升级配置
if err != nil {
return err
}
self.release = releaseInfo
filePathMap, err := self.unzip(deployFile) // 部署软件包
if err != nil {
return err
}
self.release.SetFileStoragePath(filePathMap) // 设置存储路径
return models.SaveRelease(self.release) // 保存并发布新版本信息
}
复制代码
然后,写一个支持命令行参数解析的 main 函数,从命令行参数中将 deployFile 和 deployXml 读出来,传给 Deploy 函数即可。
封装命令行工具可能需要做一些额外的初始化和配置工作,例如:DB 连接池、命令行参数解析等,具体限于篇幅不再展开,封装后的命令行工具功能:
Package Deploy:
Usage:
usadmin deploy <filename> <xml> [--config=<conf>]
usadmin revoke <version> [--config=<conf>]
usadmin -h | --help
Arguments:
<filename> The zip package will be upgraded. e.g: (G-Net_Upgrade_PC20_2.2.428.zip)
<xml> The upgrade configuration will be deployed. e.g: (config.xml)
<version> The version will be revoked. e.g: (2.2.428)
Options:
-c, --config=<conf> config path. [default:/uc/etc/usserver.conf]
-h, --help show details
复制代码
使用示例:
usadmin deploy ../doc/G-Net_MeetNow_Update_Test_6.16.23091301.zip ../doc/Config.xml -c ../doc/us.conf
复制代码
小结
本篇主要介绍了软件包的组成、吃包以及发布版本过程,下篇会基于已经发布的版本来介绍升级检测、差异包生成和使用。
评论