目录
一、服务端
1. XML配置文件
2. 服务端代码设计
二、客户端
1. XML配置文件
2. 客户端代码设计
三、运行效果
1. 程序目录结构
2. 服务端运行效果
3. 客户端运行效果
四、改进思路
五、文件下载
软件客户端在发布新版本的时候,有时候只修改了几个文件,没必要让用户重新下载整个客户端再重新安装,同时也不应要求用户每次去手动下载更新的文件,再手动覆盖本地文件。这个时候需要设计一个自动升级机制,在某些条件触发时(比如软件启动的时候)自动查看是否有更新,如果有就将改变的内容下载下来,更新本地旧文件,再根据情况判断是否重启客户端。这个功能现在是桌面程序必备的功能,基本所有的客户端都有这个检查更新的功能。我曾经用Python实现过一个基于http下载的简易自动升级系统,可以独立运行、复用在不同的情景下。
设计思路很简单:当有新版本需要发布时,将文件放在服务端,生成一个记录每个文件变化的配置文件。客户端本地也有一个记录文件信息的配置文件,客户端检查更新时,将服务端的配置文件下载下来,与本地配置文件进行比较,然后下载有变化的文件,覆盖本地文件(如果文件正在使用中,可能无法覆盖,这时候更新前应该先关闭正在运行的客户端),中间有Tkinter做的界面提示更新进度。更新结束后根据策略决定是否重启客户端。
一、服务端
服务端要做的事,首先是选择一个端口号,开启用于响应客户端下载的http服务。然后把指定的目录下的所有文件都扫描一遍,给每个文件记录一个版本号和最后修改日期,再生成一个总版本号,写在XML配置文件里。
比如版本号从0开始,第一次发布程序时,每个文件的版本号都是0,总版本号也是0,第二次发布时,扫描每个文件的最后修改日期,如果日期大于XML文件中记录的日期,将这个文件的记录日期更新,版本号加1。扫描完毕,只要有任意文件的版本号发生变化,总版本号也加1。这样客户端在检查更新时,只需要先比较服务端的总版本号和自己本地的总版本号是否一致。如果不一致,再下载XML文件比较每一个文件版本号变化,如果一致就不用下载XML文件比较了(可以在服务端增加一个接口,客户端请求这个接口时返回一个总版本号字段)。
1. XML配置文件
1.1 XML配置文件结构
ServerInfo节点:记录服务端IP和端口号,可以让客户端知道去哪里下载,当下载地址或端口号变化时,通过更新这个节点,客户端下次更新时就会到新的地址和端口号下载。
ClientVersion节点:要升级的模块的文件信息,包含1个总版本号属性,子节点包括该模块下每个文件的相对路径、文件大小、最后更新时间和版本号。这个节点可以设计多个,用不同的节点名,区分不同的模块,每个模块都有自己的总版本号。这里以1个模块为例。
1.2 XML配置文件示例:
<?xml version="1.0" encoding="utf-8"?>
<versionInfo>
<ServerInfo>
<ServerIp>202.169.100.52</ServerIp><!--服务端ip地址-->
<ServerPort>8888</ServerPort><!--服务端端口号-->
<XmlLocalPath>client_path</XmlLocalPath><!--存放文件的路径-->
</ServerInfo><!--服务端信息-->
<ClientVersion Version="11">
<object>
<FileRelativePath>ClientVersion/cfg.ini</FileRelativePath><!--文件相对路径-->
<FileSize>177</FileSize><!--文件大小B-->
<LastUpdateTime>2019-04-29 16:27:35</LastUpdateTime><!--文件最后修改时间-->
<Version>10</Version><!--文件版本号-->
</object><!--文件节点-->
<object>
<FileRelativePath>ClientVersion/Scripts/config.py</FileRelativePath><!--文件相对路径-->
<FileSize>6567</FileSize><!--文件大小B-->
<LastUpdateTime>2019-04-02 14:37:57</LastUpdateTime><!--文件最后修改时间-->
<Version>1</Version><!--文件版本号-->
</object><!--文件节点-->
</ClientVersion><!--总版本号-->
</versionInfo>
1.3 XML处理代码:
新建一个处理XML文件的类,服务端和客户端通用,主要是一些XML的增删改查功能。
# 处理xml的类
class VersionInfoXml():
def __init__(self, xml_path, server_info=None, module_list=None):
self.xml_path = xml_path
if server_info is not None:
if module_list is None:
module_list = ["ClientVersion"]
self.create_new_xml(server_info, module_list)
self.tree = ET.parse(self.xml_path)
self.root = self.tree.getroot()
def create_new_xml(self, server_info, module_info):
root = ET.Element("versionInfo")
ServerInfo = ET.SubElement(root, "ServerInfo")
ET.SubElement(ServerInfo, "ServerIp").text = server_info[0]
ET.SubElement(ServerInfo, "ServerPort").text = server_info[1]
ET.SubElement(ServerInfo, "XmlLocalPath").text = server_info[2]
for each_module in module_info:
ET.SubElement(root, each_module).set("Version", "0")
self.save_change(root)
print("I created a new temp xml!")
def save_change(self, root=None):
if root is None:
root = self.root
rough_bytes = ET.tostring(root, "utf-8")
rough_string = str(rough_bytes, encoding="utf-8").replace("\n", "").replace("\t", "").replace(" ", "")
content = minidom.parseString(rough_string)
with open(self.xml_path, 'w+') as fs:
content.writexml(fs, indent="", addindent="\t", newl="\n", encoding="utf-8")
return True
def changeServerInfo(self, name, value):
if type(value) is int:
value = str(value)
Xpath = "ServerInfo/%s" % name
element = self.root.find(Xpath)
if element is not None:
element.text = value
# self.save_change()
else:
print("I can't find \"ServerInfo/%s\" in xml!" % name)
def addObject(self, module_name, file_path, file_size, last_update_time, version):
moduleVersion = self.root.find(module_name)
object = ET.SubElement(moduleVersion, "object")
ET.SubElement(object, "FileRelativePath").text = str(file_path)
ET.SubElement(object, "FileSize").text = str(file_size)
ET.SubElement(object, "LastUpdateTime").text = str(last_update_time)
ET.SubElement(object, "Version").text = str(version)
# self.save_change()
def deleteObject(self, module_name, file_name):
Xpath = "%s/object" % module_name
objects = self.root.findall(Xpath)
moudleVersion = self.root.find(module_name)
for element in objects:
if element.find('FileRelativePath').text == file_name:
moudleVersion.remove(element)
# self.save_change()
print("Delete object: %s" % file_name)
break
else:
print("I can't find \"%s\" in xml!" % file_name)
def updateObject(self, module_name, file_name, version):
if type(version) is int:
version = str(version)
Xpath = "%s/object" % module_name
objects = self.root.findall(Xpath)
for element in objects:
if element.find('FileRelativePath').text == file_name:
element.find('Version').text = version
# self.save_change()
# print("Update \"%s\" version: %s" % (file_name, version))
break
else:
print("I can't find \"%s\" in xml!" % file_name)
def updateAttribute(self, module_name, version):
if type(version) is int:
version = str(version)
moduleVersion = self.root.find(module_name)
moduleVersion.set("Version", version)
# self.save_change()
def getObjects(self, module_name):
list_element = []
Xpath = "%s/object" % module_name
objects = self.root.findall(Xpath)
for element in objects:
dict_element = {}
for key, value in enumerate(element):
dict_element[value.tag] = value.text
list_element.append(dict_element)
return list_element
def addModule(self, module):
self.root.append(module)
# self.save_change()
def deleteModule(self, module_name):
module = self.root.find(module_name)
if module is not None:
self.root.remove(module)
# self.save_change()
def getModules(self):
dict_element = {}
objects = self.root.getchildren()
for key, value in enumerate(objects):
dict_element[value.tag] = value.attrib.get("Version")
del dict_element["ServerInfo"]
return dict_element
def getAttribute(self, module_name):
moduleVersion = self.root.find(module_name)
return moduleVersion.get("Version")
def get_node_value(self, path):
'''查找某个路径匹配的第一个节点
tree: xml树
path: 节点路径'''
node = self.tree.find(path)
if node == None:
return None
return node.text
2. 服务端代码设计
源码文件太长,这里只贴出主要的两个方法,具体实现源码文件放在文末下载。
首先是根扫描所有文件,生成一个最新xml配置文件,然后再比较两个xml,分析出增删改。
# -*- coding: utf-8 -*-
# @Time : 2019/4/25 20:16
# @Author : yushuaige
# @File : AutoCheckVersion.py
# @Software: PyCharm
# @Function: 实现客户端自动更新(服务端)
# 处理xml的类
class VersionInfoXml():
pass # 同上面xml类
def AutoCheckVersion(old_xml_path, new_xml_path):
'''
比较两个xml的objects节点,分析出增加,更改,和删除的文件列表,并在新xml里更新版本号
:param old_xml: 旧xml的完整路径
:param new_xml: 新xml的完整路径
:return: len(add_list), len(delete_list), len(change_list),
:return: add_list: [filname1, filname2], delete_list: [filname1, filname2] change_list: [filname1, filname2]
'''
print("Analyze the xml files and update the version number ...")
old_xml = VersionInfoXml(old_xml_path)
new_xml = VersionInfoXml(new_xml_path)
# 先分析模块的增、删、改
old_modules = list(old_xml.getModules().keys())
new_modules = list(new_xml.getModules().keys())
add_modules_list = list(set(new_modules).difference(set(old_modules)))
for module_name in add_modules_list:
ET.SubElement(old_xml.root, module_name).set("Version", 0)
common_modules_list = [item for item in old_modules if item in new_modules]
# 分析每个的模块中的每个文件的增、删、改
total_add_list = []
total_delete_list = []
total_change_list = []
common_modules_list.extend(add_modules_list)
for module_name in common_modules_list:
old_xml_objects = old_xml.getObjects(module_name)
new_xml_objects = new_xml.getObjects(module_name)
old_xml_objects_dict = {file_info["FileRelativePath"]: file_info for file_info in old_xml_objects}
new_xml_objects_dict = {file_info["FileRelativePath"]: file_info for file_info in new_xml_objects}
old_data_list = set(old_xml_objects_dict.keys())
new_data_list = set(new_xml_objects_dict.keys())
add_list = list(new_data_list.difference(old_data_list))
delete_list = list(old_data_list.difference(new_data_list))
common_list = list(old_data_list.intersection(new_data_list))
change_list = []
# 更新每个文件的版本号信息
for file_name in common_list:
new_version = int(old_xml_objects_dict[file_name]["Version"])
update = TimeFormatComp(new_xml_objects_dict[file_name]["LastUpdateTime"],
old_xml_objects_dict[file_name]["LastUpdateTime"])
if update is True:
change_list.append(file_name)
new_version += 1
new_xml.updateObject(module_name, file_name, new_version)
# 更新模块版本信息
new_module_version = int(old_xml.getAttribute(module_name))
if len(add_list) or len(delete_list) or len(change_list):
new_module_version = new_module_version + 1
new_xml.updateAttribute(module_name, new_module_version)
total_add_list.extend(add_list)
total_delete_list.extend(delete_list)
total_change_list.extend(change_list)
# 保存到文件
new_xml.save_change()
print("Analysis update info done. Save the new xml ...")
# 结果提示
if len(total_add_list) or len(total_delete_list) or len(total_change_list):
# 替换旧的xml文件
os.remove(old_xml_path)
os.rename(new_xml_path, old_xml_path)
print("Done. add: %d, delete: %d, update: %d. The new client version: %s." % (
len(total_add_list), len(total_delete_list), len(total_change_list), str(new_xml.getModules())))
else:
os.remove(new_xml_path)
print("No file changed! The current client version: %s." % (str(new_xml.getModules())))
return len(total_add_list), len(total_delete_list), len(total_change_list)
def CreateNewXmlFromFiles(client_dir):
'''
遍历文件夹所有文件,生成标准xml
:param client_dir: 要遍历的文件夹路径
:return: 生成的xml的完整路径
'''
print("Scan the folder and create the temp xml file ...")
config_parser = configparser.ConfigParser()
config_parser.read(os.path.dirname(sys.path[0]) + '\\cfg.ini')
UPDATE_HOST = config_parser.get("mqtt", 'serv')
server_info = [UPDATE_HOST, "8888", "dev_manage_win"]
module_list = os.listdir(client_dir)
new_xml = VersionInfoXml("VersionInfoTemp.xml", server_info, module_list)
for module_name in module_list:
module_dir = os.path.join(client_dir, module_name)
for (dirpath, dirnames, filenames) in os.walk(module_dir):
for file in filenames:
file_dir = os.path.join(dirpath, file)
file_path = file_dir.replace(client_dir, "").strip("\\").replace("\\", "/")
file_size = os.path.getsize(file_dir)
last_update_time = TimeStampFormat(os.path.getmtime(file_dir))
version = 1
new_xml.addObject(module_name, file_path, file_size, last_update_time, version)
new_xml.save_change()
new_xml_path = os.path.join(sys.path[0], "VersionInfoTemp.xml")
return new_xml_path
二、客户端
1. XML配置文件
为了简便,客户端和服务端处理xml文件的类用同一个。
2. 客户端代码设计
源码文件太长,这里只贴出主要的两个方法,具体实现源码文件放在文末下载。
下载最新xml配置文件和本地配置文件进行比较,然后分析出增删改,进行下载和删除。
# -*- coding: utf-8 -*-
# @Time : 2019/4/25 20:16
# @Author : yushuaige
# @File : AutoUpdate.py
# @Software: PyCharm
# @Function: 实现客户端自动更新(客户端)
# 处理xml的类
class VersionInfoXml:
pass # 同上面xml类
# 手动更新时,检查更新
def CheckUpdate(server_ip, server_port, module_name, order):
pass
# 主要函数
def AutoUpdate(server_ip, server_port, module_name, order):
time_start = time.perf_counter()
try:
download_url = "http://{0}:{1}/{2}".format(server_ip, server_port, "VersionInfo.xml")
local_path = os.path.join(sys.path[0], "VersionInfoTemp.xml")
print("download_url: " + download_url)
if not download_file_by_http(download_url, local_path):
raise Exception()
except Exception as e:
# tkinter.messagebox.showerror("更新无法继续", "获取最新版本列表文件出现异常!")
print("Update error: Can't get the latest VersionInfo xml!")
# root.destroy()
return False
root.update()
root.deiconify()
# 比较文件变化
add_dict, delete_list = analyze_update_info(local_xml_path, update_xml_path, module_name)
if add_dict == {} and delete_list == []:
os.remove(update_xml_path)
# tkinter.messagebox.showinfo("更新无法继续", "当前客户端已经是最新版本!")
print("No file changed!")
return False
# 下载需要更新的文件
download_progress(add_dict)
# 文件覆盖到主目录
prompt_info11.set("正在解压...")
prompt_info13.set("总体进度:99.9%")
prompt_info21.set("")
root.update()
source_dir = os.path.join(sys.path[0], "TempFolder")
dest_dir = os.path.dirname(sys.path[0])
# dest_dir = os.path.join(sys.path[0], "test_main")
override_dir(source_dir, dest_dir)
# 删除要删除的文件
for file in delete_list:
delete_dir(os.path.join(dest_dir, file))
# 更新xml文件
if module_name == "all_module":
os.remove(local_xml_path)
os.rename(update_xml_path, local_xml_path)
else:
update_xml(local_xml_path, update_xml_path, module_name)
# 客户端更新结束
time_end = time.perf_counter()
print("更新耗时:%ds" % (time_end - time_start))
prompt_info11.set("更新完毕。")
prompt_info13.set("总体进度:100.0%")
root.update()
# tkinter.messagebox.showinfo("更新完成", "更新完毕,耗时:%ds" % (time_end - time_start))
return True
# 分析两个xml文件
def analyze_update_info(local_xml, update_xml, module_name):
'''
分析本地xml文件和最新xml文件获得增加的文件和要删除的文件
:param local_xml: 本地xml文件路径
:param update_xml: 下载的最新xml文件路径
:return: download_info: {filename1: fizesize1, filename2: fizesize2}, delete_list: [filname1, filname2]
'''
print("Analyze the xml files and check the version number ...")
old_xml = VersionInfoXml(local_xml)
new_xml = VersionInfoXml(update_xml)
module_names = []
if module_name == "all_module":
module_names = new_xml.getModules()
else:
module_names.append(module_name)
download_info_total = {}
delete_list_total = []
for module_name in module_names:
if old_xml.getAttribute(module_name) is None:
ET.SubElement(old_xml.root, module_name).set("Version", "0")
if new_xml.getAttribute(module_name) <= old_xml.getAttribute(module_name):
continue
old_xml_objects = old_xml.getObjects(module_name)
new_xml_objects = new_xml.getObjects(module_name)
old_xml_objects_dict = {file_info["FileRelativePath"]: file_info for file_info in old_xml_objects}
new_xml_objects_dict = {file_info["FileRelativePath"]: file_info for file_info in new_xml_objects}
old_data_list = set(old_xml_objects_dict.keys())
new_data_list = set(new_xml_objects_dict.keys())
add_list = list(new_data_list.difference(old_data_list))
delete_list = list(old_data_list.difference(new_data_list))
common_list = list(old_data_list.intersection(new_data_list))
download_info = {file_name: new_xml_objects_dict[file_name]["FileSize"] for file_name in add_list}
# 根据每个文件的版本号,确定是否需要更新
for file_name in common_list:
if int(new_xml_objects_dict[file_name]["Version"]) > int(old_xml_objects_dict[file_name]["Version"]):
download_info.update({file_name: new_xml_objects_dict[file_name]["FileSize"]})
download_info_total.update(download_info)
delete_list_total.extend(delete_list)
# return download_info, delete_list
return download_info_total, delete_list_total
三、运行效果
1. 程序目录结构
1.1 服务端
ClientFolder目录用来存放要更新的文件夹,
venv是python目录,
cfg.ini文件用来配置ip、端口等信息,
server.py是主程序,
start.bat用来双击启动server.py,
VersionInfo.xml是存放文件信息的xml
1.2 客户端
TempFolder目录用来存放下载下来的文件,
venv是python目录,
client.py是主程序,
start.bat用来双击启动server.py,
VersionInfo.xml是存放文件信息的xml,
VersionInfoTemp.xml是更新时自动生成的,是下载的最新配置文件
2. 服务端运行效果
默认使用本地测试ip 127.0.0.1,默认端口8888
3. 客户端运行效果
上面窗口是控制台窗口,显示运行过程的日志,下面是更新界面。
如果不想显示控制台界面,只需要把start.bat里前三行的注释打开即可。
文件太小可能会一闪而过,因为程序默认更新完立即退出。
四、改进思路
1.多线程提高效率
因为没有测试过文件数量和大小非常大的情况,现在程序的所有步骤都是单线程执行,可以将文件扫描和下载等耗时间的步骤,改进成多线程或者协程同时运行,提高程序的运行效率。
2.文件扫描方式
当前只根据文件相对路径加文件全名的方式,进行文件区分,然后根据最后修改时间来判断是否需要更新,可以增加MD5校验来保证文件的唯一性。
3.界面完善
当前只有在下载文件时有界面提示,可以改进界面,使整个更新过程可视化。
4.启动方式
当前使用bat脚本调命令行的方式启动程序,会有一个黑色窗口,可以将程序打包成exe文件发布。