首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 更多程式語言 >> 更多程式設計知識

Python 實現支援目錄遞歸的 FTP 上傳下載完整解決方案

     編輯:更多程式設計知識

FTP(File Transfer Protocol)雖然古老,但在自動化部署、備份同步、嵌入式設備檔案傳輸等場景依然廣泛使用。Python 標準庫 ftplib 提供了基礎 FTP 客戶端功能,但不支援目錄遞歸上傳下載。本文提供一個完整、可生產環境使用的 FTPSync 類,支援檔案與目錄的雙向遞歸傳輸,相容 Windows 與 Linux 平台。

核心功能特性

  • 檔案上傳/下載:支援指定本地/遠端路徑,自動處理檔案重新命名
  • 目錄遞歸上傳/下載:完整保留目錄結構,自動建立不存在的遠端/本地目錄
  • 智慧路徑判斷:自動識別遠端路徑是檔案還是目錄,避免常見錯誤
  • 被動/主動模式切換:預設主動模式(set_pasv(False)),相容多種防火牆環境
  • 錯誤處理完善:連線失敗、路徑不存在、權限不足等情況均有明確提示

完整程式碼實現

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import ftplib
import os
import sys

class FTPSync(object):
    """支援目錄遞歸的 FTP 同步客戶端"""
    
    def __init__(self, host, port=21):
        self.conn = ftplib.FTP()
        self.conn.connect(host, port)
    
    def login(self, username, password):
        self.conn.login(username, password)
        self.conn.set_pasv(False)  # 使用主動模式,相容性更好
        print(self.conn.welcome)
    
    # ==================== 內部工具方法 ====================
    
    def _is_ftp_file(self, ftp_path):
        """判斷遠端路徑是否為檔案"""
        try:
            ftp_path = ftp_path.rstrip('/')
            parent = os.path.dirname(ftp_path)
            name = os.path.basename(ftp_path)
            return name in self.conn.nlst(parent)
        except ftplib.error_perm:
            return False
    
    def _ftp_list_callback(self, line):
        """LIST 指令回調,用於判斷目錄"""
        parts = line.split()
        if len(parts) >= 1 and parts[0].startswith('d') and parts[-1] == self._ftp_target_name:
            self._is_dir_flag = True
    
    def _is_ftp_dir(self, ftp_path):
        """判斷遠端路徑是否為目錄"""
        ftp_path = ftp_path.rstrip('/')
        if ftp_path in ('.', './', ''):
            return True
        
        self._ftp_target_name = os.path.basename(ftp_path)
        parent = os.path.dirname(ftp_path)
        self._is_dir_flag = False
        
        try:
            self.conn.retrlines(f'LIST {parent}', self._ftp_list_callback)
        except ftplib.error_perm:
            pass
        return self._is_dir_flag
    
    # ==================== 檔案操作 ====================
    
    def get_file(self, ftp_path, local_path='.'):
        """下載單一檔案"""
        ftp_path = ftp_path.rstrip('/')
        if not self._is_ftp_file(ftp_path):
            print(f'ERROR: 遠端檔案不存在: {ftp_path}')
            return
        
        filename = os.path.basename(ftp_path)
        
        # 確定本地儲存路徑
        if os.path.isdir(local_path):
            local_filepath = os.path.join(local_path, filename)
        elif os.path.isdir(os.path.dirname(local_path)):
            local_filepath = local_path
        else:
            print(f'ERROR: 本地目錄不存在: {os.path.dirname(local_path)}')
            return
        
        with open(local_filepath, 'wb') as f:
            self.conn.retrbinary(f'RETR {ftp_path}', f.write)
        print(f'下載完成: {ftp_path} -> {local_filepath}')
    
    def put_file(self, local_path, ftp_path='.'):
        """上傳單一檔案"""
        if not os.path.isfile(local_path):
            print(f'ERROR: 本地檔案不存在: {local_path}')
            return
        
        ftp_path = ftp_path.rstrip('/')
        filename = os.path.basename(local_path)
        
        with open(local_path, 'rb') as f:
            # 遠端是目錄 -> 上傳到該目錄下,檔名不變
            if self._is_ftp_dir(ftp_path):
                remote_path = os.path.join(ftp_path, filename)
                self.conn.storbinary(f'STOR {remote_path}', f)
            # 遠端父目錄存在 -> 指定檔名上傳
            elif self._is_ftp_dir(os.path.dirname(ftp_path)):
                self.conn.storbinary(f'STOR {ftp_path}', f)
            else:
                print(f'ERROR: 遠端路徑錯誤: {ftp_path}')
                return
        print(f'上傳完成: {local_path} -> {ftp_path}')
    
    # ==================== 目錄遞歸操作 ====================
    
    def get_dir(self, ftp_path, local_path='.', is_root=True):
        """遞歸下載整個目錄"""
        ftp_path = ftp_path.rstrip('/')
        if not self._is_ftp_dir(ftp_path):
            print(f'ERROR: 遠端目錄不存在: {ftp_path}')
            return
        
        # 根呼叫時初始化本地目標目錄
        if is_root:
            if not os.path.isdir(local_path):
                os.makedirs(local_path)
            local_path = os.path.join(local_path, os.path.basename(ftp_path))
        
        if not os.path.isdir(local_path):
            os.makedirs(local_path)
        
        # 進入遠端目錄,遞歸處理
        self.conn.cwd(ftp_path)
        try:
            items = self.conn.nlst()
        except ftplib.error_perm:
            items = []
        
        for item in items:
            local_item = os.path.join(local_path, item)
            if self._is_ftp_dir(item):
                self.get_dir(item, local_item, is_root=False)
            else:
                self.get_file(item, local_item)
        
        # 返回上層目錄
        self.conn.cwd('..')
    
    def put_dir(self, local_path, ftp_path='.', is_root=True):
        """遞歸上傳整個目錄"""
        if not os.path.isdir(local_path):
            print(f'ERROR: 本地目錄不存在: {local_path}')
            return
        
        ftp_path = ftp_path.rstrip('/')
        
        # 根呼叫時初始化遠端目標目錄
        if is_root:
            if not self._is_ftp_dir(ftp_path):
                self.conn.mkd(ftp_path)
            ftp_path = os.path.join(ftp_path, os.path.basename(local_path))
        
        if not self._is_ftp_dir(ftp_path):
            self.conn.mkd(ftp_path)
        
        # 進入本地目錄,遞歸處理
        old_cwd = os.getcwd()
        os.chdir(local_path)
        try:
            for item in os.listdir('.'):
                if os.path.isdir(item):
                    self.put_dir(item, os.path.join(ftp_path, item), is_root=False)
                else:
                    self.put_file(item, ftp_path)
        finally:
            os.chdir(old_cwd)


# ==================== 使用範例 ====================
if __name__ == '__main__':
    ftp = FTPSync('192.168.1.110')
    ftp.login('test', 'test')
    
    # 檔案操作範例
    # ftp.put_file('111.txt', 'a/b')           # 上傳到目錄,不重命名
    # ftp.put_file('111.txt', 'a/112.txt')     # 上傳並重命名
    # ftp.get_file('/a/111.txt', r'D:')        # 下載到目錄
    # ftp.get_file('/a/111.txt', r'D:112.txt') # 下載並重命名
    
    # 目錄操作範例
    # ftp.get_dir('a/b/c', r'D:a')   # 下載到現有目錄
    # ftp.get_dir('a/b/c', r'D:aa')  # 下載到新目錄
    ftp.put_dir('b', 'a')            # 上傳到現有目錄
    ftp.put_dir('b', 'aa/B/')        # 上傳到新目錄

關鍵設計細節解析

1. 遠端檔案/目錄判斷邏輯

FTP 協定本身不提供直接判斷路徑類型的指令。本實現採用 NLST 列出父目錄內容,檢查名稱是否存在來判斷檔案;對於目錄,使用 LIST -l 解析權限位(以 d 開頭)確認。這種方式雖需兩次網路往返,但相容性最好,不依賴伺服器擴展指令。

2. 遞歸遍歷的狀態管理

get_dir 和 put_dir 使用 is_root 旗標區分首次呼叫與遞歸呼叫:

  • 首次呼叫:建立目標根目錄,將來源目錄名附加到目標路徑
  • 遞歸呼叫:直接在當前目標路徑下建立子目錄/傳輸檔案

這確保了 put_dir('src', 'dst') 會在遠端建立 dst/src/... 結構,而非扁平化合併。

3. 工作目錄切換的安全性

本地遞歸上傳時,使用 os.chdir() 進入來源目錄,配合 try...finally 確保無論發生何種異常都能恢復原工作目錄,避免影響後續操作或主程式。

4. 連線模式選擇

預設 set_pasv(False) 使用主動模式。許多企業防火牆對主動模式支援較好(只需開放 20/21 埠),而被動模式需要開放大範圍高位埠。若遇到連線成功但列表/傳輸卡住,可嘗試切換至 set_pasv(True)。

擴展建議

  • 進度顯示:在 retrbinary/storbinary 的回調函數中累計位元組數,顯示進度條
  • 斷點續傳:使用 REST 指令配合 APPE 實現斷點續傳
  • 並發傳輸:使用 threading 或 asyncio 並行傳輸多個檔案
  • SSL/TLS 支援:改用 ftplib.FTP_TLS 實現 FTPS 加密傳輸
  • 完整性校驗:傳輸前後計算 MD5/SHA256 驗證檔案完整性

總結

這個 FTPSync 類解決了 ftplib 不支援目錄遞歸的痛點,提供了生產級的錯誤處理與路徑判斷邏輯。無論是自動化部署腳本、定時備份任務,還是嵌入式設備日誌收集,都能直接拿來使用或輕鬆擴展。掌握其核心原理——FTP 指令組合與遞歸狀態管理——也能幫助你在其他網路協定(如 SFTP、WebDAV)上實現類似功能。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved