準(zhǔn)庫(kù)ftplib實(shí)戰(zhàn):從FTP協(xié)議到文件傳輸全解析)
Python自帶的ftplib模塊說(shuō)它是標(biāo)準(zhǔn)庫(kù)里最容易被忽略的寶藏之一一點(diǎn)都不過(guò)分。很多新手學(xué)完os、re、requests之后遇到文件傳輸需求第一反應(yīng)是去找第三方庫(kù)但如果你只需要跟FTP服務(wù)器打交道ftplib完全夠用而且是零依賴(lài)、開(kāi)箱即用。這篇文章我就拿實(shí)際使用的經(jīng)驗(yàn)把ftplib的核心功能、常用的坑、以及一些業(yè)務(wù)場(chǎng)景下的封裝技巧一次講透適合那些剛?cè)腴T(mén)Python、需要對(duì)接FTP服務(wù)的開(kāi)發(fā)同學(xué)也適合已經(jīng)用了一段時(shí)間但沒(méi)系統(tǒng)梳理過(guò)的朋友查漏補(bǔ)缺。1. 動(dòng)手之前先把FTP協(xié)議的底子補(bǔ)上很多人直接拿ftplib當(dāng)普通的HTTP庫(kù)用結(jié)果遇到各種奇怪問(wèn)題——明明能連上、登錄成功但一傳文件就卡住或者超時(shí)。問(wèn)題大多出在沒(méi)搞懂FTP協(xié)議的工作方式上。1.1 一個(gè)協(xié)議兩條連接FTP和HTTP有個(gè)本質(zhì)區(qū)別HTTP只有一條連接請(qǐng)求響應(yīng)都在上面完成FTP是兩條獨(dú)立的連接一條叫控制連接一條叫數(shù)據(jù)連接??刂七B接就是你執(zhí)行登錄、切換目錄、刪除文件這些命令時(shí)走的通道它是一條長(zhǎng)連接一直開(kāi)著。而真正傳輸文件內(nèi)容、拉取目錄列表的時(shí)候服務(wù)器會(huì)單獨(dú)開(kāi)一條數(shù)據(jù)連接傳完就關(guān)閉。這兩條連接的端口還不一樣??刂七B接默認(rèn)是21端口數(shù)據(jù)連接的端口則由傳輸模式?jīng)Q定。主動(dòng)模式下服務(wù)器主動(dòng)去連客戶(hù)端的某個(gè)端口被動(dòng)模式下服務(wù)器告訴你一個(gè)端口號(hào)由客戶(hù)端去連接。這就是為什么很多FTP客戶(hù)端軟件里會(huì)有“主動(dòng)模式”和“被動(dòng)模式”的切換開(kāi)關(guān)ftplib也一樣默認(rèn)為主動(dòng)模式但實(shí)際使用中多數(shù)場(chǎng)景要改成被動(dòng)模式。1.2 主動(dòng)模式和被動(dòng)模式怎么選主動(dòng)模式下客戶(hù)端在控制連接上告訴服務(wù)器“我開(kāi)放了某個(gè)端口你過(guò)來(lái)連接”然后服務(wù)器主動(dòng)向客戶(hù)端發(fā)起數(shù)據(jù)連接。這個(gè)模式的問(wèn)題很明顯——客戶(hù)端如果在內(nèi)網(wǎng)或者有防火墻服務(wù)器過(guò)不來(lái)數(shù)據(jù)傳輸就失敗了。被動(dòng)模式下客戶(hù)端在控制連接上發(fā)送PASV命令服務(wù)器返回一個(gè)IP和端口由客戶(hù)端去連接。這種模式對(duì)客戶(hù)端更友好因?yàn)閿?shù)據(jù)傳輸連接是客戶(hù)端主動(dòng)發(fā)起的不會(huì)被防火墻攔截。我在實(shí)際項(xiàng)目中客戶(hù)端部署在云服務(wù)器上FTP服務(wù)器在另一臺(tái)機(jī)器上直接用默認(rèn)的主動(dòng)模式就經(jīng)??ㄋ栏某杀粍?dòng)模式后問(wèn)題立刻消失。所以如果你發(fā)現(xiàn)連接正常、登錄也正常但一執(zhí)行下載或者列目錄就hang住優(yōu)先檢查是不是主動(dòng)模式惹的禍。注意ftplib里設(shè)置被動(dòng)模式很簡(jiǎn)單FTP類(lèi)的構(gòu)造參數(shù)里有個(gè)passive默認(rèn)是True也就是被動(dòng)模式。如果你用的是默認(rèn)值還出現(xiàn)問(wèn)題那就要從服務(wù)器配置或者網(wǎng)絡(luò)安全組層面去排查了。1.3 ftplib到底封裝了什么ftplib本身是標(biāo)準(zhǔn)庫(kù)它做的事情就是按照FTP協(xié)議的報(bào)文格式幫你把命令發(fā)出去、把響接收回來(lái)。你不需要關(guān)心底層socket怎么收發(fā)數(shù)據(jù)也不需要手工構(gòu)造FTP命令ftplib里的每個(gè)方法基本都對(duì)應(yīng)一個(gè)FTP命令。比如login()方法發(fā)送USER和PASScwd()方法發(fā)送CWDnlst()發(fā)送NLSTretrbinary()發(fā)送RETRstorbinary()發(fā)送STOR。理解了這層映射關(guān)系你去看ftplib的源碼時(shí)會(huì)覺(jué)得非常清晰出問(wèn)題的時(shí)候也能通過(guò)抓包或者查看原始響應(yīng)來(lái)定位。2. ftplib的核心類(lèi)與方法心智圖ftplib模塊里真正干活的主要是兩個(gè)類(lèi)FTP和FTP_TLS。FTP_TLS是FTP的加強(qiáng)版增加了對(duì)TLS加密傳輸?shù)闹С?。其他還有一些輔助類(lèi)比如FTP_HOST、FTP_PORT等常量但日常用到的不多。2.1 FTP類(lèi)的核心方法一覽先看一張梳理好的方法表不用背用到的時(shí)候回來(lái)查就行方法對(duì)應(yīng)FTP命令作用connect(host, port)無(wú)連接FTP服務(wù)器login(user, passwd)USER / PASS登錄認(rèn)證cwd(pathname)CWD切換工作目錄pwd()PWD返回當(dāng)前目錄nlst(dirname)NLST返回目錄下的文件名列表dir(dirname)LIST返回詳細(xì)目錄列表retrlines(cmd)LIST / NLST下載文本內(nèi)容retrbinary(cmd, callback)RETR以二進(jìn)制方式下載文件storbinary(cmd, fp)STOR以二進(jìn)制方式上傳文件storlines(cmd, fp)STOR以文本方式上傳文件delete(filename)DELE刪除文件rename(from, to)RNFR / RNTO重命名文件/目錄mkd(pathname)MKD創(chuàng)建目錄rmd(pathname)RMD刪除目錄size(filename)SIZE獲取文件大小sendcmd(cmd)任意命令發(fā)送原始FTP命令并返回響應(yīng)quit()QUIT發(fā)送退出命令并關(guān)閉連接close()無(wú)關(guān)閉連接但不發(fā)送QUITset_debuglevel(level)無(wú)設(shè)置調(diào)試輸出級(jí)別voidresp()無(wú)讀取并返回響應(yīng)數(shù)據(jù)這些方法里retrbinary、storbinary、nlst、dir是最高頻的四個(gè)下面逐個(gè)展開(kāi)說(shuō)。2.2 FTP_TLS該用加密的時(shí)候不要偷懶FTP_TLS是FTP的子類(lèi)用法基本一致只是在連接階段多了一步TLS握手。這里有一個(gè)很容易混淆的點(diǎn)FTP_TLS支持兩種工作模式一種是顯式TLS客戶(hù)端連接后先發(fā)送AUTH TLS命令升級(jí)為加密通道這是RFC 4217標(biāo)準(zhǔn)定義的方式另一種是隱式TLS從建立連接開(kāi)始就默認(rèn)使用TLS默認(rèn)端口通常是990。ftplib里的FTP_TLS默認(rèn)走顯式TLS也就是先connect再login就可以了。但如果服務(wù)器只支持隱式TLS你就需要額外指定一個(gè)參數(shù)。具體做法是直接用FTP_TLS.connect()連接時(shí)傳不同的端口并且通過(guò)設(shè)置ssl_version等參數(shù)來(lái)控制。不過(guò)說(shuō)實(shí)話(huà)隱式TLS的場(chǎng)景相對(duì)少大多數(shù)企業(yè)內(nèi)部還是用顯式模式或者干脆直接用SFTP。用FTP_TLS還有一個(gè)很重要的細(xì)節(jié)執(zhí)行TLS握手后默認(rèn)情況下后續(xù)的數(shù)據(jù)連接也是加密的但有些服務(wù)器配置了只有在需要時(shí)才加密或者數(shù)據(jù)傳輸不加密。如果你發(fā)現(xiàn)retrbinary時(shí)報(bào)錯(cuò)提示數(shù)據(jù)連接沒(méi)有加密或者SSL相關(guān)的問(wèn)題可以在完成登錄后執(zhí)行一次prot_p()。2.3 連接對(duì)象的生命周期每次使用完FTP連接一定要記得關(guān)閉。很多人用with語(yǔ)句習(xí)慣了上下文管理的便利但ftplib的FTP類(lèi)在Python 3.4之前并沒(méi)有實(shí)現(xiàn)__enter__和__exit__之后版本才補(bǔ)上。所以網(wǎng)上很多老代碼是try/finally寫(xiě)法新代碼可以直接用with。另外FTP連接是有時(shí)效的服務(wù)器通常配置了空閑超時(shí)時(shí)間。如果連接長(zhǎng)時(shí)間沒(méi)有動(dòng)作服務(wù)器會(huì)自動(dòng)斷開(kāi)。這種情況最常出現(xiàn)在批量上傳任務(wù)的循環(huán)里處理完一個(gè)文件后隔了一段時(shí)間再處理下一個(gè)結(jié)果連接已經(jīng)斷了接著報(bào)錯(cuò)。解決辦法有兩個(gè)要么在每次操作前調(diào)用voidcmd(NOOP)做一次心跳要么在任務(wù)循環(huán)里重新登錄。3. 實(shí)操?gòu)倪B接、上傳到下載的完整落地下面用一個(gè)完整的例子來(lái)演示ftplib的實(shí)戰(zhàn)流程。這個(gè)例子綜合了我平時(shí)處理FTP任務(wù)時(shí)的習(xí)慣寫(xiě)法從連接、登錄、切換目錄到文件上傳和下載都有。3.1 基礎(chǔ)連接與登錄from ftplib import FTP, FTP_TLS ftp FTP() ftp.connect(host192.168.1.100, port21, timeout10) ftp.login(userusername, passwdpassword) print(ftp.pwd())這里connect和login分開(kāi)寫(xiě)好處是你可以更清晰地控制連接階段的超時(shí)。如果直接用FTP(host, user, passwd)方式構(gòu)造其實(shí)內(nèi)部也是先connect再login但超時(shí)時(shí)間只能用默認(rèn)的。connect的timeout參數(shù)很重要。默認(rèn)情況下如果不設(shè)置socket層面的超時(shí)時(shí)間可能是全局默認(rèn)有些環(huán)境下會(huì)非常長(zhǎng)導(dǎo)致程序卡住很久才報(bào)錯(cuò)。我一般都會(huì)顯式設(shè)置一個(gè)合理的timeout比如10秒或者15秒這樣網(wǎng)絡(luò)不通時(shí)能快速失敗。登錄成功后服務(wù)器會(huì)返回230狀態(tài)碼表示登錄成功如果用戶(hù)名密碼錯(cuò)誤返回530。ftplib會(huì)把狀態(tài)碼和消息包裝成異常所以在實(shí)際使用中登錄失敗會(huì)直接拋異常你需要在外面捕獲并處理。3.2 目錄切換與列表獲取登錄之后第一步通常要切換目錄ftp.cwd(/pub/data/2024) print(ftp.pwd()) files ftp.nlst() print(files)nlst()返回的是不帶權(quán)限信息、不帶時(shí)間戳的文件名列表適合做自動(dòng)化處理。但要注意有些服務(wù)器對(duì)NLST的支持不完全返回的格式可能不標(biāo)準(zhǔn)。如果代碼里需要判斷文件大小、修改時(shí)間等詳細(xì)信息就要用dir()或者retrlines(LIST)。我的習(xí)慣是如果是批量下載先用nlst拿到文件名列表然后逐個(gè)用size()方法查詢(xún)文件大小再?zèng)Q定是否下載。但size()方法依賴(lài)SIZE命令有些FTP服務(wù)器上的SIZE命令是被禁用的會(huì)報(bào)錯(cuò)“SIZE not allowed in ASCII mode”之類(lèi)。如果碰到這種情況直接下載即可別過(guò)度依賴(lài)size來(lái)做過(guò)濾。3.3 上傳文件兩種姿勢(shì)上傳文件最常用的是storbinary因?yàn)樗远M(jìn)制方式傳輸不會(huì)在傳輸過(guò)程中破壞文件內(nèi)容。這里說(shuō)的“破壞”是真實(shí)存在的問(wèn)題——如果用文本模式傳一個(gè)二進(jìn)制文件換行符會(huì)被自動(dòng)轉(zhuǎn)換圖片、壓縮包傳過(guò)去就損壞了。import os local_path local_archive.zip remote_path /pub/data/2024/archive.zip with open(local_path, rb) as fp: ftp.storbinary(fSTOR {remote_path}, fp, blocksize8192)storbinary的第一個(gè)參數(shù)是完整的FTP傳輸命令通常是STOR 文件名。第二個(gè)參數(shù)是一個(gè)文件對(duì)象第三個(gè)參數(shù)是緩沖區(qū)大小默認(rèn)是8192字節(jié)。這個(gè)blocksize影響傳輸性能太小會(huì)導(dǎo)致頻繁socket讀寫(xiě)太大則占用內(nèi)存。我試過(guò)8KB和64KB在小文件場(chǎng)景下區(qū)別不大傳大文件時(shí)64KB會(huì)快一點(diǎn)但8KB更穩(wěn)一些不會(huì)因?yàn)閱未螌?xiě)入過(guò)大導(dǎo)致對(duì)端緩沖區(qū)溢出。日常用默認(rèn)值即可。如果你想把一個(gè)內(nèi)存中的字符串上傳上去不需要先保存成臨時(shí)文件可以借助io.BytesIO把數(shù)據(jù)包裝成類(lèi)文件對(duì)象from io import BytesIO data hello ftplib.encode(utf-8) bio BytesIO(data) ftp.storbinary(STOR /tmp/hello.txt, bio)這個(gè)技巧在處理動(dòng)態(tài)生成的內(nèi)容時(shí)非常有用省去了臨時(shí)文件的創(chuàng)建和清理。3.4 下載文件二進(jìn)制和文本兩種讀取方式下載文件和上傳類(lèi)似用到retrbinary。它的第二個(gè)參數(shù)是一個(gè)回調(diào)函數(shù)這個(gè)函數(shù)接收每次讀取到的chunk數(shù)據(jù)。最常見(jiàn)的寫(xiě)法是把chunk寫(xiě)入本地文件remote_path /pub/data/2024/archive.zip local_path local_archive.zip with open(local_path, wb) as fp: ftp.retrbinary(fRETR {remote_path}, fp.write, blocksize8192)注意這里直接傳了fp.write作為回調(diào)因?yàn)閞etrbinary每次讀到的chunk都是bytes而文件以二進(jìn)制模式打開(kāi)write方法接收bytes完美匹配。如果是下載文本文件并直接在內(nèi)存中處理內(nèi)容不想落盤(pán)可以用retrlines加上自己的處理邏輯lines [] ftp.retrlines(fRETR /pub/data/2024/readme.txt, callbacklines.append) for line in lines: print(line)retrlines的callback接收到的是解碼后的字符串默認(rèn)按utf-8解碼你也可以通過(guò)encoding參數(shù)修改。需要注意retrlines會(huì)把行尾的\r\n去掉所以在拼接內(nèi)容重新寫(xiě)文件時(shí)可能需要自己補(bǔ)回來(lái)。3.5 完整示例一個(gè)簡(jiǎn)單的FTP備份腳本把上面這些拼起來(lái)就是一個(gè)比較完整的FTP備份腳本。下面這個(gè)例子模擬了一個(gè)實(shí)際的備份場(chǎng)景從本地目錄上傳文件到FTP服務(wù)器的某個(gè)路徑下并進(jìn)行簡(jiǎn)單的錯(cuò)誤處理。import os from ftplib import FTP from datetime import datetime def upload_backup(local_dir: str, remote_dir: str, host: str, user: str, passwd: str): ftp FTP() ftp.connect(host, port21, timeout15) ftp.login(user, passwd) ftp.set_debuglevel(0) # 確保遠(yuǎn)程目錄存在 try: ftp.cwd(remote_dir) except Exception: # 目錄不存在則逐級(jí)創(chuàng)建 current_path for part in remote_dir.strip(/).split(/): current_path f/{part} try: ftp.cwd(current_path) except Exception: ftp.mkd(current_path) ftp.cwd(current_path) for filename in os.listdir(local_dir): local_file os.path.join(local_dir, filename) if not os.path.isfile(local_file): continue with open(local_file, rb) as fp: ftp.storbinary(fSTOR {filename}, fp) print(fuploaded: {filename}) ftp.quit() print(backup finished at, datetime.now().isoformat()) if __name__ __main__: upload_backup( local_dir./data, remote_dir/backup/2024, host192.168.1.100, userftpuser, passwdftppass )這段代碼里有幾個(gè)實(shí)際常用的處理細(xì)節(jié)第一是遠(yuǎn)程目錄不存在時(shí)逐級(jí)創(chuàng)建因?yàn)閙kd只能創(chuàng)建一層目錄第二是每次上傳后打印進(jìn)度方便觀(guān)察任務(wù)是否卡住第三是在結(jié)束時(shí)調(diào)用quit()而非close()這樣會(huì)正常發(fā)送QUIT命令服務(wù)器端也能及時(shí)釋放資源。3.6 斷點(diǎn)續(xù)傳的實(shí)現(xiàn)思路ftplib自身沒(méi)有直接提供斷點(diǎn)續(xù)傳的功能但實(shí)現(xiàn)起來(lái)也不復(fù)雜。核心就是REST命令加RETR命令的組合。REST命令告訴服務(wù)器從文件的某個(gè)偏移量開(kāi)始傳輸然后RETR重新開(kāi)始傳輸文件。import os from ftplib import FTP def resume_download(ftp: FTP, remote_path: str, local_path: str): # 獲取遠(yuǎn)程文件總大小 remote_size ftp.size(remote_path) local_size 0 if os.path.exists(local_path): local_size os.path.getsize(local_path) if local_size remote_size: # 從本地已有的偏移量開(kāi)始續(xù)傳 ftp.sendcmd(fREST {local_size}) with open(local_path, ab) as fp: ftp.retrbinary(fRETR {remote_path}, fp.write) print(download resumed and completed) else: print(file already complete)這個(gè)示例是續(xù)傳下載斷點(diǎn)續(xù)傳上傳會(huì)麻煩一些因?yàn)樾枰?wù)器支持APPE命令或者用STOR配合REST來(lái)實(shí)現(xiàn)。很多服務(wù)器不支持REST上傳所以上傳斷點(diǎn)續(xù)傳的兼容性不如下載。實(shí)際場(chǎng)景中如果網(wǎng)絡(luò)不穩(wěn)定導(dǎo)致上傳中斷我更推薦的做法是重新傳一遍或者用SFTP/rsync這類(lèi)自帶斷點(diǎn)續(xù)傳與校驗(yàn)功能的工具而不是強(qiáng)行在ftplib上做上傳續(xù)傳。4. 常見(jiàn)報(bào)錯(cuò)與排查技巧實(shí)錄ftplib使用中碰到的報(bào)錯(cuò)類(lèi)型其實(shí)很有限核心就那么幾個(gè)異常類(lèi)。搞明白這幾個(gè)異?;灸芙鉀Q九成的問(wèn)題。4.1 異常類(lèi)型速查異常類(lèi)觸發(fā)場(chǎng)景常見(jiàn)原因ftplib.error_perm永久性錯(cuò)誤530未登錄、550文件不存在或權(quán)限不足ftplib.error_temp臨時(shí)性錯(cuò)誤421服務(wù)不可用、425無(wú)法建立數(shù)據(jù)連接ftplib.error_proto協(xié)議錯(cuò)誤服務(wù)器返回不合法響應(yīng)socket.timeout超時(shí)網(wǎng)絡(luò)不通、被動(dòng)模式端口被防火墻攔截ConnectionRefusedError連接被拒絕服務(wù)器端口未開(kāi)放、IP錯(cuò)誤error_perm是最常見(jiàn)的。如果你在調(diào)用方法時(shí)收到550 Failed to change directory.說(shuō)明目標(biāo)目錄不存在或者你沒(méi)有權(quán)限進(jìn)入。這種錯(cuò)誤通常是代碼邏輯和服務(wù)器配置之間不匹配排查方向就是確認(rèn)路徑和權(quán)限。425 Unable to build data connection: Connection refused是典型的主動(dòng)/被動(dòng)模式或防火墻問(wèn)題。這時(shí)我第一件事是切被動(dòng)模式試試。用ftp.set_pasv(True)設(shè)置被動(dòng)模式后再調(diào)用下載方法大部分場(chǎng)景能直接解決。4.2 超時(shí)相關(guān)卡住不動(dòng)怎么辦FTP連接卡住不返回原因往往是網(wǎng)絡(luò)層的半開(kāi)連接。比如控制連接已經(jīng)建立但數(shù)據(jù)連接因?yàn)榉阑饓υ驘o(wú)法建立程序就hang在讀取socket響應(yīng)的地方。排查辦法很簡(jiǎn)單開(kāi)啟調(diào)試模式ftp.set_debuglevel(2)把這行加在連接成功之后再次執(zhí)行操作你會(huì)發(fā)現(xiàn)ftplib會(huì)把每次發(fā)送的命令和接收的響應(yīng)原樣打印出來(lái)。這時(shí)候看最后一條響應(yīng)是什么如果是227 Entering Passive Mode說(shuō)明服務(wù)器已經(jīng)返回了被動(dòng)模式的IP和端口但客戶(hù)端后續(xù)連接數(shù)據(jù)端口失敗。再看一下命令返回的IP是什么如果是一個(gè)內(nèi)網(wǎng)地址說(shuō)明FTP服務(wù)器在NAT后面客戶(hù)端無(wú)法連接它返回的那個(gè)IP。這種情況就需要在服務(wù)器端配置正確的公網(wǎng)IP。每次定位FTP問(wèn)題先用set_debuglevel(2)打開(kāi)調(diào)試看報(bào)文比盲猜高效得多。這個(gè)方法簡(jiǎn)單到幾乎不占成本但很多人就是不知道用。4.3 中文文件名亂碼問(wèn)題中文文件名的顯示亂碼根源在于FTP協(xié)議本身沒(méi)有聲明文件名編碼。RFC 2640定義了UTF-8作為推薦的字符集但很多老舊服務(wù)器或者客戶(hù)端默認(rèn)用的還是GBK或Latin-1。在Python 3里ftplib處理響應(yīng)時(shí)默認(rèn)按latin-1解碼中文就變成亂碼了。ftplib從Python 3開(kāi)始支持通過(guò)encoding參數(shù)指定編碼。你的FTP服務(wù)器如果是純UTF-8創(chuàng)建FTP對(duì)象時(shí)加上編碼參數(shù)ftp FTP(encodingutf-8)如果服務(wù)器用的是GBKftp FTP(encodinggbk)但更隱蔽的問(wèn)題是目錄列表里的中文亂碼和文件名列表里的中文亂碼可能不是同一種編碼導(dǎo)致的。nlst()返回的文件名和dir()返回的詳細(xì)信息如果編碼判斷不一致需要分別處理。我遇到過(guò)一種情況是服務(wù)器在控制連接返回的文件名是UTF-8但LIST響應(yīng)里帶的時(shí)間格式卻是拉丁字符混在一起如果無(wú)腦用同一種編碼解反而會(huì)引發(fā)出錯(cuò)。最穩(wěn)妥的做法是能設(shè)置編碼就設(shè)置編碼nlst()拿到的文件名還亂碼的話(huà)就自己寫(xiě)一個(gè)底層命令交互用sendcmd和retrbinary手工控制編碼。5. 日常使用時(shí)的幾個(gè)進(jìn)階技巧掌握了基礎(chǔ)操作和常見(jiàn)排查ftplib已經(jīng)能解決大多數(shù)問(wèn)題。但實(shí)際業(yè)務(wù)中總有些零碎的小技巧能讓代碼更簡(jiǎn)潔、更健壯。5.1 用上下文管理器避免連接泄漏新版Python的ftplib已經(jīng)支持with語(yǔ)法。我強(qiáng)烈建議在任何臨時(shí)連接場(chǎng)景下使用from ftplib import FTP with FTP(192.168.1.100) as ftp: ftp.login(user, pass) ftp.cwd(/data) print(ftp.nlst())這樣即使中間拋異常連接也會(huì)被正確關(guān)閉避免程序的socket資源慢慢耗盡。如果你用的是老代碼風(fēng)格一定記得在finally里手工調(diào)用quit。5.2 批量任務(wù)中的連接復(fù)用批量處理大量文件時(shí)頻繁建立連接再斷開(kāi)很浪費(fèi)。更合理的方式是建立一個(gè)長(zhǎng)連接循環(huán)里復(fù)用同時(shí)通過(guò)定時(shí)發(fā)送NOOP保持連接活躍。ftp FTP() ftp.connect(host192.168.1.100, port21, timeout30) ftp.login(user, pass) try: for remote_file in file_list: # 每次操作前發(fā)一個(gè)NOOP維持連接 ftp.voidcmd(NOOP) local_file download_one(ftp, remote_file) print(downloaded, local_file) finally: ftp.quit()這里的NOOP命令本身沒(méi)有副作用只用于檢測(cè)連接是否還活著。如果服務(wù)器已經(jīng)斷開(kāi)連接發(fā)送NOOP會(huì)立刻拋異常你就可以在這個(gè)地方統(tǒng)一做重連處理而不是等到下載文件到一半才發(fā)現(xiàn)連接斷了。5.3 從內(nèi)存中直接上傳和下載前面提過(guò)用BytesIO配合storbinary實(shí)現(xiàn)內(nèi)存上傳。下載也可以不進(jìn)磁盤(pán)直接讀進(jìn)內(nèi)存from io import BytesIO buffer BytesIO() ftp.retrbinary(RETR /data/config.json, buffer.write) content buffer.getvalue().decode(utf-8) print(content)這個(gè)場(chǎng)景特別適合處理配置文件和接口數(shù)據(jù)傳輸。我在做自動(dòng)化巡檢腳本時(shí)經(jīng)常需要拉取遠(yuǎn)程機(jī)器上的配置文件和別的服務(wù)器比對(duì)用這種方式完全不需要在本地創(chuàng)建臨時(shí)文件邏輯也更干凈。5.4 一鍵遞歸下載目錄ftplib沒(méi)有遞歸下載目錄的方法但遞歸本質(zhì)上是循環(huán)加?;蜿?duì)列from ftplib import FTP, error_perm import os def download_recursive(ftp: FTP, remote_dir: str, local_dir: str): stack [(remote_dir, local_dir)] while stack: rdir, ldir stack.pop() ftp.cwd(rdir) os.makedirs(ldir, exist_okTrue) for name in ftp.nlst(): rpath f{rdir}/{name} lpath os.path.join(ldir, name) if is_dir(ftp, rpath): stack.append((rpath, lpath)) else: with open(lpath, wb) as fp: ftp.retrbinary(fRETR {rpath}, fp.write)需要額外實(shí)現(xiàn)一個(gè)is_dir()判斷可以用size()方法是否拋異常來(lái)判斷是不是目錄——很多服務(wù)器對(duì)目錄執(zhí)行SIZE命令會(huì)報(bào)錯(cuò)。這種方法在服務(wù)器對(duì)LIST響應(yīng)不一致的場(chǎng)景下比解析dir()的輸出要可靠得多。6. 安全與合規(guī)提醒FTP協(xié)議本身是明文傳輸用戶(hù)名密碼和數(shù)據(jù)內(nèi)容在傳輸過(guò)程中都是裸奔的。尤其在內(nèi)網(wǎng)環(huán)境下很多人覺(jué)得無(wú)所謂但我在實(shí)際項(xiàng)目里見(jiàn)過(guò)不止一次因?yàn)閮?nèi)網(wǎng)被滲透導(dǎo)致FTP賬號(hào)密碼被截獲的事件。6.1 FTPS與SFTP的區(qū)別有人會(huì)把FTPS和SFTP搞混。FTPS是FTP over TLS也就是FTP協(xié)議本身加了一層SSL/TLS基礎(chǔ)還是FTP那套命令用ftplib的FTP_TLS就能實(shí)現(xiàn)。SFTP是基于SSH協(xié)議的文件傳輸協(xié)議端口通常走22Python里要用另一個(gè)第三方庫(kù)去實(shí)現(xiàn)比如paramiko。如果你能控制服務(wù)器端的選擇內(nèi)部文件傳輸我更推薦SFTP因?yàn)樗烊粡?fù)用SSH的認(rèn)證和加密體系密鑰管理和權(quán)限配置都更成熟。但如果服務(wù)器是傳統(tǒng)FTP服務(wù)不需要改造成本的情況下至少要用FTP_TLS把通信加密起來(lái)不能用裸FTP直接跑敏感數(shù)據(jù)。6.2 實(shí)際使用中的取舍建議從實(shí)踐經(jīng)驗(yàn)來(lái)看ftplib適合以下場(chǎng)景對(duì)接老舊的FTP服務(wù)器服務(wù)器端無(wú)法改動(dòng)協(xié)議內(nèi)部局域網(wǎng)、可信網(wǎng)絡(luò)環(huán)境下的簡(jiǎn)單文件傳輸自動(dòng)運(yùn)維腳本、定時(shí)備份任務(wù)需要精細(xì)控制FTP命令的探活和交互場(chǎng)景如果項(xiàng)目允許自由選擇技術(shù)棧并且對(duì)傳輸安全有較高要求還是優(yōu)先考慮SFTP或者對(duì)象存儲(chǔ)這類(lèi)現(xiàn)代方案沒(méi)必要在FTP上硬扛。另外還要注意ftplib的性能天花板不高它的socket讀寫(xiě)是阻塞式的處理單線(xiàn)程順序傳輸還行大規(guī)模并發(fā)傳輸會(huì)力不從心。如果業(yè)務(wù)要求同時(shí)傳輸大量文件需要引入線(xiàn)程池或者改用異步HTTP/對(duì)象存儲(chǔ)網(wǎng)關(guān)進(jìn)行中轉(zhuǎn)。7. 結(jié)尾補(bǔ)充一些實(shí)際經(jīng)驗(yàn)我最初用ftplib的時(shí)候碰到一個(gè)特別隱蔽的問(wèn)題服務(wù)器端的目錄列表里有個(gè)中文目錄名使用nlst()拿到的是正常字符串但當(dāng)我把它拼到下載路徑里后服務(wù)器報(bào)“550 No such file or directory”。后來(lái)打開(kāi)調(diào)試模式才發(fā)現(xiàn)nlst()返回的字符串是已經(jīng)按照l(shuí)atin-1解碼過(guò)的而實(shí)際上原始字節(jié)是UTF-8編碼的中文拿到Python里再編碼回去就變成了另外一串字節(jié)服務(wù)器當(dāng)然找不到路徑。最后通過(guò)指定encodingutf-8解決了。這件事給我最大的教訓(xùn)就是——遇到ftplib和中文相關(guān)的問(wèn)題先確認(rèn)服務(wù)器端和客戶(hù)端的編碼是不是一致再談其他。還有一次是批量上傳任務(wù)跑了幾個(gè)小時(shí)突然卡住我最初懷疑是網(wǎng)絡(luò)問(wèn)題但后來(lái)發(fā)現(xiàn)是FTP服務(wù)器設(shè)置了空閑超時(shí)10分鐘沒(méi)有數(shù)據(jù)傳輸就自動(dòng)斷開(kāi)控制連接。從那以后我在所有長(zhǎng)任務(wù)里都加了NOOP心跳再也沒(méi)有出現(xiàn)過(guò)類(lèi)似問(wèn)題。希望這篇文章能讓你少走一些彎路。如果你在項(xiàng)目里也遇到過(guò)ftplib相關(guān)的疑難問(wèn)題不妨從編碼、被動(dòng)模式、超時(shí)和心跳這幾個(gè)方向去排查大部分問(wèn)題都逃不出這幾點(diǎn)。