目录

一、服务端

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

       

python 每隔一秒更新 python自动更新客户端_程序设计

1.2 客户端

TempFolder目录用来存放下载下来的文件,

venv是python目录,

client.py是主程序,

start.bat用来双击启动server.py,

VersionInfo.xml是存放文件信息的xml,

VersionInfoTemp.xml是更新时自动生成的,是下载的最新配置文件

       

python 每隔一秒更新 python自动更新客户端_xml_02

2. 服务端运行效果

默认使用本地测试ip 127.0.0.1,默认端口8888

     

python 每隔一秒更新 python自动更新客户端_python 每隔一秒更新_03

3. 客户端运行效果

上面窗口是控制台窗口,显示运行过程的日志,下面是更新界面。

如果不想显示控制台界面,只需要把start.bat里前三行的注释打开即可。

文件太小可能会一闪而过,因为程序默认更新完立即退出。

    

python 每隔一秒更新 python自动更新客户端_版本号_04

   

python 每隔一秒更新 python自动更新客户端_版本号_05

四、改进思路

1.多线程提高效率

因为没有测试过文件数量和大小非常大的情况,现在程序的所有步骤都是单线程执行,可以将文件扫描和下载等耗时间的步骤,改进成多线程或者协程同时运行,提高程序的运行效率。

2.文件扫描方式

当前只根据文件相对路径加文件全名的方式,进行文件区分,然后根据最后修改时间来判断是否需要更新,可以增加MD5校验来保证文件的唯一性。

3.界面完善

当前只有在下载文件时有界面提示,可以改进界面,使整个更新过程可视化。

4.启动方式

当前使用bat脚本调命令行的方式启动程序,会有一个黑色窗口,可以将程序打包成exe文件发布。