用Python實現一個軟件自動升級系統

目錄

一、服務端

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文件發佈。

五、文件下載

零積分下載整個程序源碼:用Python實現一個軟件自動升級系統

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章