目录
一、服务端
1. XML配置文件
2. 服务端代码设计
二、客户端
1. XML配置文件
2. 客户端代码设计
三、运行效果
1. 程序目录结构
2. 服务端运行效果
3. 客户端运行效果
四、改进思路
五、文件下载
软件客户端在发布新版本的时候,有时候只修改了几个文件,没必要让用户重新下载整个客户端再重新安装,同时也不应要求用户每次去手动下载更新的文件,再手动覆盖本地文件。这个时候需要设计一个自动升级机制,在某些条件触发时(比如软件启动的时候)自动查看是否有更新,如果有就将改变的内容下载下来,更新本地旧文件,再根据情况判断是否重启客户端。这个功能现在是桌面程序必备的功能,基本所有的客户端都有这个检查更新的功能。我曾经用Python实现过一个基于http下载的简易自动升级系统,可以独立运行、复用在不同的情景下。
设计思路很简单:当有新版本需要发布时,将文件放在服务端,生成一个记录每个文件变化的配置文件。客户端本地也有一个记录文件信息的配置文件,客户端检查更新时,将服务端的配置文件下载下来,与本地配置文件进行比较,然后下载有变化的文件,覆盖本地文件(如果文件正在使用中,可能无法覆盖,这时候更新前应该先关闭正在运行的客户端),中间有Tkinter做的界面提示更新进度。更新结束后根据策略决定是否重启客户端。
服务端要做的事,首先是选择一个端口号,开启用于响应客户端下载的http服务。然后把指定的目录下的所有文件都扫描一遍,给每个文件记录一个版本号和最后修改日期,再生成一个总版本号,写在XML配置文件里。
比如版本号从0开始,第一次发布程序时,每个文件的版本号都是0,总版本号也是0,第二次发布时,扫描每个文件的最后修改日期,如果日期大于XML文件中记录的日期,将这个文件的记录日期更新,版本号加1。扫描完毕,只要有任意文件的版本号发生变化,总版本号也加1。这样客户端在检查更新时,只需要先比较服务端的总版本号和自己本地的总版本号是否一致。如果不一致,再下载XML文件比较每一个文件版本号变化,如果一致就不用下载XML文件比较了(可以在服务端增加一个接口,客户端请求这个接口时返回一个总版本号字段)。
1.1 XML配置文件结构
ServerInfo节点:记录服务端IP和端口号,可以让客户端知道去哪里下载,当下载地址或端口号变化时,通过更新这个节点,客户端下次更新时就会到新的地址和端口号下载。
ClientVersion节点:要升级的模块的文件信息,包含1个总版本号属性,子节点包括该模块下每个文件的相对路径、文件大小、最后更新时间和版本号。这个节点可以设计多个,用不同的节点名,区分不同的模块,每个模块都有自己的总版本号。这里以1个模块为例。
1.2 XML配置文件示例:
202.169.100.52
8888
client_path
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
源码文件太长,这里只贴出主要的两个方法,具体实现源码文件放在文末下载。
首先是根扫描所有文件,生成一个最新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
为了简便,客户端和服务端处理xml文件的类用同一个。
源码文件太长,这里只贴出主要的两个方法,具体实现源码文件放在文末下载。
下载最新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 服务端
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是更新时自动生成的,是下载的最新配置文件
默认使用本地测试ip 127.0.0.1,默认端口8888
上面窗口是控制台窗口,显示运行过程的日志,下面是更新界面。
如果不想显示控制台界面,只需要把start.bat里前三行的注释打开即可。
文件太小可能会一闪而过,因为程序默认更新完立即退出。
1.多线程提高效率
因为没有测试过文件数量和大小非常大的情况,现在程序的所有步骤都是单线程执行,可以将文件扫描和下载等耗时间的步骤,改进成多线程或者协程同时运行,提高程序的运行效率。
2.文件扫描方式
当前只根据文件相对路径加文件全名的方式,进行文件区分,然后根据最后修改时间来判断是否需要更新,可以增加MD5校验来保证文件的唯一性。
3.界面完善
当前只有在下载文件时有界面提示,可以改进界面,使整个更新过程可视化。
4.启动方式
当前使用bat脚本调命令行的方式启动程序,会有一个黑色窗口,可以将程序打包成exe文件发布。
零积分下载整个程序源码:用Python实现一个软件自动升级系统