简介:FTP协议(File Transfer Protocol)是应用层的重要协议,用于控制文件在Internet上的双向传输,这份基于Python的压缩包完整实现了FTP服务器与客户端功能,特别适合网络编程初学者、计算机专业学生以及需要快速搭建FTP服务的开发者。压缩包内共包含2个文件,均为.py脚本,分别为服务器端与客户端代码,整体大小仅7KB,代码精简、结构清晰,便于逐行阅读和二次修改。资源支持Windows环境下运行,实现了文件和文件夹的上传与下载、多用户多线程并发下载,以及带有图形界面的运行状态显示等核心特性,能够帮助读者理解FTP协议的实际工作流程。当前已有552人学习下载,是一个具有较高参考价值的入门示例。通过阅读源码,读者可以掌握Python中socket编程、多线程处理、GUI界面设计等关键技能,并可在现有代码基础上扩展断点续传、权限控制、日志记录等更多实用功能。
1. 用Python操作FTP协议:把老服务器和自动化脚本连起来的落地路径
客户把一个只开放21端口的老FTP服务器丢给我,让我每天凌晨把生产库导出的CSV送过去。这活儿看着不难,但第一次联调就被中文文件名乱码和被动模式卡了两个小时,下午大文件传到一半又断线。用Python操作FTP协议的核心,就是通过标准库ftplib把文件在本地与服务器之间稳定地传上去、拉下来,再把连接管理、断点续传和编码处理揉成一个能跑的脚本。适合做运维自动化、数据交换和后端对接的工程师,也适合刚入门Python想拿真实协议练手的人。我的结论是:基础连接十分钟能通,真正耗时间的是编码、模式和超时这三件事。
2. 为什么用Python标准库ftplib而不是另装依赖:协议选型与前置准备
2.1 FTP/FTPS/SFTP怎么选:服务器只开放21端口时的唯一答案
FTP协议和常见的SFTP是两回事。FTP的控制连接走21端口,但实际传文件时会另开一条数据连接,这条数据连接的端口和连接方向由模式决定。主动模式(PORT)下,服务器用20端口主动连回客户端随机端口;被动模式(PASV)下,客户端主动去连服务器返回的高位端口。Python的ftplib默认开启被动模式,这也是绝大多数跨网络场景的正确选择。
先确认对方给的是哪种服务,再决定技术方案:
| 协议 | 默认端口 | 加密 | Python标准库支持 | 典型场景 |
|---|---|---|---|---|
| FTP | 21 | 明文 | ftplib | 内网文件交换、老系统对接 |
| FTPS | 21或990 | TLS加密 | ftplib.FTP_TLS | 公网传输但不换协议 |
| SFTP | 22 | SSH加密 | 不支持,常用paramiko | 云服务器、Linux主机间传输 |
如果服务器只开放21端口,而且对方明确说不跑SSH,那SFTP这条路直接堵死,只能在FTP与FTPS之间选。数据要过公网,优先FTPS;纯内网或数据不敏感,普通FTP够用。很多新手拿着FTP的代码去连SFTP的22端口,卡在握手阶段,就是没分清这两个协议。注意,FTP的传输内容是明文,账号密码也暴露在网络上,公网环境要谨慎。
我一般这样判断:拿到服务器信息先看端口,21多半是FTP/FTPS,22就是SFTP。再看对方是否要求隐式TLS或显式TLS。如果只是内网数据交换,普通FTP直接跑;如果涉及客户数据过公网,坚持用FTPS。这里有个容易被忽略的点:FTPS虽然加密了数据通道,但协商过程比普通FTP复杂,老服务器的兼容性问题不少,需要额外测试。
提示:标准库ftplib是Python自带的模块,不需要pip install,这本身就是选它的最大理由。
2.2 Python环境准备:安装版本、编码行为与连通性自检
环境准备其实很简单,Python 3.8以上即可,核心链路完全依赖标准库。如果你还没装Python,官方安装包下载时记得勾选Add to PATH,Windows上装完直接打开命令行输python进入交互环境。后续如果要补paramiko处理SFTP、用schedule做定时调度,再用pip装这些第三方包,FTP协议本身用不到。
ftplib对编码的处理随版本有变化。Python 3.9之前,ftplib内部默认用latin-1解码响应,遇到中文文件名会乱码或直接报错;3.9之后可以在实例上设置encoding属性。也就是说,3.9是一个重要分水岭,建议优先装3.10或3.11版本。为了确认当前环境能不能用,我会先跑一个自检脚本:
import sys import ftplib from ftplib import FTP print("Python版本:", sys.version) print("ftplib模块路径:", ftplib.__file__) ftp = FTP() ftp.connect("192.168.1.100", 21, timeout=10) ftp.login("username", "password") ftp.set_pasv(True) # 默认就是被动模式 print("欢迎信息:", ftp.getwelcome()) print("当前目录:", ftp.pwd()) ftp.quit()这段代码的作用是验证三件事:本机Python环境、目标FTP服务器是否可达、账号能否登录。FTP()不传参数时只是创建对象,必须手动调connect才会发起TCP连接;connect的第二个参数是端口,第三个是socket超时秒数。login失败会抛error_perm异常,常见原因是账号密码错误或该IP被服务器拒绝。getwelcome返回服务器登录前的欢迎信息,能用来判断服务器类型和是否支持UTF8。pwd返回的是当前工作目录的绝对路径,用字符串形式打印出来。
跑通这个自检,才值得继续写业务逻辑。很多看起来像代码bug的问题,其实是服务器拒绝连接或网络策略拦截。connect阶段抛socket.gaierror说明域名解析失败,抛ConnectionRefusedError说明IP通但端口没开,这些都要在第5章的排错里对照排查。
3. 用ftplib跑通第一次FTP会话:连接、登录与目录遍历
3.1 最小连接代码:FTP()、connect与login的边界行为
把自检脚本整理成更稳妥的写法,加上异常捕获和上下文管理。ftplib.FTP实现了上下文管理协议,用with能保证退出时自动发送QUIT,避免连接泄漏:
from ftplib import FTP, error_perm try: with FTP() as ftp: ftp.connect("10.0.0.15", 21, timeout=10) ftp.login("ftp_user", "ftp_pass") ftp.set_pasv(True) print("登录成功,当前目录:", ftp.pwd()) # 在这里执行列目录或传输业务 except error_perm as e: print("FTP协议返回错误,状态码和说明:", e) except TimeoutError: print("连接或操作超时,检查网络或防火墙")connect的host可以是域名或IP;port默认21,多数情况下不用写;timeout是socket层面的超时。login有两个参数,不传时默认匿名登录,但企业FTP基本都要账号密码。FTP协议返回码是三位数字,ftplib把非预期的返回码封装为异常,打印时能看到220、331、230这类状态码。220表示服务就绪,230表示登录成功,331表示需要密码。如果账号错误或密码不对,服务器会返回530,ftplib会把它抛成error_perm异常。
有个边界行为需要留意:pwd返回的字符串不带末尾斜杠,根目录是空字符串而不是“/”。另外,连接成功后建议立刻调set_pasv(True),虽然这也是默认值,但把模式选择显式写出来,读代码的人一眼就知道当前跑在哪种模式。登录失败时with块会正常退出并关闭连接,不需要手动调quit。
3.2 目录遍历:cwd、nlst、mlsd、retrlines的使用差异
拿到会话之后,第一件事通常是看远端目录下有什么。我常用的命令有三个:nlst返回纯文件名列表,只适合路径简单、不需要额外信息的场景;mlsd是RFC 3659标准命令,能返回name、type、size、modify等结构化元数据,但老服务器不一定实现;retrlines("LIST", callback)返回类似ls -l的文本行,兼容性最好,但需要自己解析。
下面这个函数把三种方式串联起来,优先用mlsd,拿不到再降级到LIST:
from ftplib import FTP, error_perm def list_dir(ftp, path=""): if path: ftp.cwd(path) # 优先用 MLSD 获取结构化信息 try: result = [] for name, facts in ftp.mlsd(): row = { "name": name, "type": facts.get("type"), "size": facts.get("size"), "modify": facts.get("modify"), } result.append(row) return result except error_perm: # 老服务器不支持 MLSD,用 LIST 降级 lines = [] ftp.retrlines("LIST", lines.append) return linesmlsd返回的facts是一个字典,type字段的取值有file、dir、cdir、pdir几种。cdir表示当前目录,pdir表示父目录,遍历时要把这两个过滤掉。size只有在type为file时才可靠,目录的size在不同操作系统上含义完全不同。LIST降级后返回的是原始文本行,比如这样一行:
-rw-r--r-- 1 ftp ftp 1024 Jan 10 09:30 report.csv用split分割时,如果文件名里带空格就会错位,后面第5章会专门讲解析。这个兼容性设计在对接第三方FTP时非常必要,因为对方的服务器可能跑着vsftpd、IIS或Windows内置FTP,输出格式差别很大。
目录切换有个小习惯:每次调用ftp.cwd("目录名")之后,用ftp.pwd()确认一下结果;返回上一级就用ftp.cwd(".."),不要省略。支持一次传入相对路径或绝对路径,比如ftp.cwd("/pub/data/2025")。如果需要递归遍历整个目录树,ftplib没有现成的walk,按业务自己实现:先用mlsd区分文件和目录,目录就cwd进去再读,读完整理完用cwd("..")回来。注意深度太深时,路径拼接用绝对路径比相对路径更不容易出错。
4. 文件传输与断点续传:STOR/RETR、回调机制与rest参数
4.1 上传与下载基础实现:storbinary和retrbinary的参数细节
上传用storbinary,下载用retrbinary,这是ftplib里最核心的两个方法。先看基础版本:
import os from ftplib import FTP def upload_file(ftp, local_path, remote_path): with open(local_path, "rb") as f: ftp.storbinary(f"STOR {remote_path}", f, blocksize=8192) print("上传完成:", remote_path) def download_file(ftp, remote_path, local_path): with open(local_path, "wb") as f: ftp.retrbinary(f"RETR {remote_path}", f.write, blocksize=8192) return os.path.getsize(local_path)storbinary的第一个参数是命令字符串,格式是"STOR 目标文件名",ftplib会把它发给服务器;第二个参数是本地文件对象,必须是以二进制模式打开的,文本模式会触发编码报错;blocksize每次发送的字节数,8192是常用值。retrbinary的第一个参数是"RETR 远端文件名",第二个参数是回调函数,每个从网络收到的数据块都会交给它处理。回调直接传f.write是文件下载最简洁的写法。
blocksize的取值有讲究。设成1024,数据包数量成倍上涨,弱网环境下吞吐量明显下降;设成65536,单个TCP分片会被链路层拆开,丢包重传的概率和代价都会变大。内网传大文件用8192或16384都行,公网建议保持在8192附近。另外注意,ftplib会自动把传输模式设置为二进制TYPE I,不需要手动发TYPE命令;只有误用STOR/RETR这类裸命令时才会掉进文本模式的换行转换陷阱。
4.2 断点续传:rest参数的正确用法
断点续传的核心是FTP的REST命令。REST指定从远端文件的哪个偏移量开始接续上传或下载,ftplib把能力包装成storbinary与retrbinary的rest参数。上传断点续传时,先通过ftp.size(remote_path)拿到服务器上已有文件大小,再在STOR时把本地文件指针seek到那个位置,同时把rest设为相同值:
from ftplib import FTP, error_perm def upload_resume(ftp, local_path, remote_path): offset = 0 try: offset = ftp.size(remote_path) except error_perm: offset = 0 with open(local_path, "rb") as f: f.seek(offset) try: ftp.storbinary(f"STOR {remote_path}", f, rest=offset) except error_perm: print("服务器不支持REST,降级为全量覆盖上传") f.seek(0) ftp.storbinary(f"STOR {remote_path}", f)ftp.size返回远端文件字节数。如果远端文件不存在,服务器会返回错误码,我们捕获后按offset=0处理。f.seek(offset)把本地文件指针跳到断点位置,rest=offset告诉服务器从远端偏移offset处开始写入。REST命令需要服务器支持,老服务器如果不支持会直接抛error_perm,这时必须降级为完整重传,不要强行停在原地。降级逻辑的存在很关键,它让脚本在旧环境上也能完成任务,而不是直接失败。
下载断点续传则相反,先把本地文件大小作为offset,采用追加模式打开:
import os from ftplib import FTP, error_perm def download_resume(ftp, remote_path, local_path): offset = os.path.getsize(local_path) if os.path.exists(local_path) else 0 with open(local_path, "ab") as f: try: ftp.retrbinary(f"RETR {remote_path}", f.write, rest=offset) except error_perm: print("服务器不支持REST,全量重新下载") with open(local_path, "wb") as f2: ftp.retrbinary(f"RETR {remote_path}", f2.write)下载断点续传最容易被忽视的坑是:本地文件的大小和远端文件对不上。如果服务器上的文件在断点期间被替换成更小的文件,offset超过了远端文件末尾,服务器会报错或直接返回空数据。所以恢复时应该先对比本地文件大小和ftp.size返回的远端文件大小,一致才走断点续传,不一致就全量重下。这个对比逻辑我建议写进所有同步脚本,能省掉大量莫名其妙的文件损坏问题。
4.3 进度显示与超时控制:不要裸等大文件
传大文件最怕的不是慢,而是一动不动。进度反馈能帮你判断是卡住了还是在传。简单办法是用callback统计累计字节:
received = 0 total = ftp.size(remote_path) def progress(data): global received received += len(data) if total: pct = received / total * 100 print(f"\r{received}/{total} bytes ({pct:.1f}%)", end="") with open(local_path, "wb") as f: ftp.retrbinary(f"RETR {remote_path}", progress, blocksize=8192)这个写法有个问题:progress只统计字节,不把数据写进文件。下载数据的回调函数只能有一个,所以更通用的做法是把进度统计和数据写入合并到一个函数里:
def progress_and_write(data): global received received += len(data) if total: pct = received / total * 100 print(f"\r{received}/{total} bytes ({pct:.1f}%)", end="") f.write(data)回调收到什么就做什么,这是ftplib的典型模式。上传侧的storbinary回调与下载不同,它收到的是已发送字节数,不是数据块内容,做进度时要区分开。如果ftp.size返回-1或直接抛错,说明服务器不支持SIZE命令,进度显示就退化为只显示已传字节数,这是可以接受的降级。
5. 避坑:用Python操作FTP协议时的四个典型问题
5.1 中文文件名乱码:先定encoding,再login
现象:登录正常、列目录正常,但看到的中文文件名全是乱码,上传带中文名的文件到了服务器上也变成乱码。原因在于FTP协议对文件名编码没有任何统一规定,Windows的IIS默认用GBK,Linux的vsftpd默认按UTF-8,而Python 3.9之前的ftplib用latin-1解码所有响应。解决方法是确认服务器编码后,在login之前设置ftp.encoding:
ftp = FTP() ftp.connect("192.168.1.100", 21, timeout=10) ftp.encoding = "utf-8" # 或 "gbk",取决于服务器 ftp.login("user", "pass")一定要先设置encoding再login,因为login过程中返回的欢迎信息里也可能含中文,设置晚了照样会解析失败。判断服务器用什么编码,一个实用技巧是看getwelcome的返回内容:包含UTF8字样或vsFTPd字样,大概率是UTF-8;IIS服务器多为GBK。不过新版IIS也支持UTF-8,最稳妥的做法是拿一个已知中文文件名测试,乱码就切到另一种编码重新登录。这个坑在对接国内老系统时出现频率极高,我现在的所有FTP脚本都把encoding作为配置参数暴露出来,不写死在代码里。
5.2 主动/被动模式互连失败:set_pasv不是摆设
现象:能登录成功,但执行LIST或下载文件时卡住,直到超时。原因是被动模式下,服务器返回一个内网IP和端口让客户端连接,如果服务器在NAT后面,客户端根本连不上;主动模式则是服务器主动连回客户端,客户端防火墙禁止入站就会失败。解决思路是让两种模式都可切换,先试默认被动模式,卡住时切到主动模式:
ftp.set_pasv(True) # 提交代码前确认当前是哪种排查主动被动问题时,先用set_debuglevel(2)打开ftplib的调试输出:
ftp.set_debuglevel(2)调试输出会把客户端和服务器之间交换的每一条命令和响应打到屏幕上。被动模式下发送的是PASV命令,耐心看响应里的227 Entering Passive Mode那一行,括号里的数字前四个是IP、后两个是端口。IP如果是10、172、192开头的内网地址,而你的客户端在公网,那就必然连不上,解决方案是让服务器管理员配置FTP网关的PASV地址,或者改用主动模式并开放客户端防火墙。主动模式则发送PORT命令,如果服务器返回501或550,说明服务器不支持主动模式,客户端防火墙再次确认是否放行了出站到服务器20端口的流量。这两种模式没有绝对好坏,只有哪个模式能在你的网络环境中把数据通道打通。
5.3 长任务易断线:NOOP心跳与断点兜底
现象:传一个大文件,传到一半socket直接断开,报错信息是timed out或Connection reset by peer。原因是NAT设备和服务器防火墙通常对空闲连接有生命周期限制,长时间没有数据交互就会掐掉连接。上传和下载过程中,大文件如果网络带宽跑不满,TCP连接也会进入空闲状态。解决方法是双管齐下:socket层面把timeout调大,逻辑层面定期发送NOOP命令保持活跃:
import time with FTP() as ftp: ftp.connect("192.168.1.100", 21, timeout=30) ftp.login("user", "pass") # 长任务期间每隔 30 秒发一次 NOOP last_noop = time.time() for chunk in iter(lambda: ftp.retrbinary("RETR bigfile.iso", f.write, blocksize=8192), None): now = time.time() if now - last_noop > 30: ftp.voidcmd("NOOP") last_noop = now这里NOOP命令的作用是维持控制连接活跃,让NAT和防火墙认为连接仍在工作。需要注意的是,REST、RETR这类命令在执行数据传输期间,控制连接是空闲的,很多防火墙恰恰盯上这段空闲时间。即使发了NOOP,如果传输中断在几百兆字节之后,断点续传仍然是最终兜底方案:重新连接、获取远端文件大小、用rest参数续传。第4章的download_resume函数可以直接复用到这种情况。我还习惯给定时任务加一个“传输中断后自动重试整个文件”的开关,连续重试三次仍失败才报警,避免半夜被一条断线日志叫醒。
5.4 mlsd和LIST返回格式不一致:解析脚本的兼容写法
现象:同一个解析代码,对A服务器正常,对B服务器却拿不到文件名和大小。原因是LIST的输出格式在不同FTP服务器实现上差异很大,甚至同一台服务器在不同locale环境下也不一样。vsftpd输出的是Unix风格权限加三列时间,IIS输出的是“10-10-25 09:30AM”这样的格式,Windows自带的FTP还有“
import re parse_list = re.compile( r'^(?P<perm>\S+)\s+\S+\s+\S+\s+\S+\s+(?P<size>\d+)\s+' r'(?P<date>\S+\s+\S+\s+\S+)\s+(?P<name>.*)$' ) lines = [] ftp.retrlines("LIST", lines.append) for line in lines: m = parse_list.match(line) if m: print("文件:", m.group("name"), "大小:", m.group("size"))这个正则按Unix风格LIST输出设计,文件名在最后,用group("name")提取,允许文件名带空格。但碰上IIS风格,字段布局完全不一样,正则就会失效。更通用的思路是:如果只需要文件名,直接用nlst,它返回的就是纯文件名列表,避开所有解析问题;如果还需要大小和时间,再用mlsd;两者都不支持的老古董服务器,就只能写多套正则按服务器类型切换。我在脚本里习惯把解析函数做成可替换策略,对接新服务器时先打印原始LIST行,确认格式后选对应的解析器,而不是一上来就写死一套正则在所有环境上碰运气。
6. 进阶:把FTP传输封装成带重试与日志的定时任务脚本
与其每次都在命令行里手工敲ftp,不如把整个流程写成一个可重复执行的脚本骨架,配合系统cron或Windows计划任务跑定时同步。下面这个脚本只依赖标准库,完成连接、重试、下载、断点续传和日志记录:
import logging import os import time from ftplib import FTP, error_perm logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("ftp-sync") RETRY_LIMIT = 3 def sync_from_ftp(host, port, user, pwd, remote_dir, local_dir): os.makedirs(local_dir, exist_ok=True) last_error = None for attempt in range(1, RETRY_LIMIT + 1): try: with FTP() as ftp: ftp.connect(host, port, timeout=15) ftp.encoding = "utf-8" ftp.login(user, pwd) ftp.set_pasv(True) ftp.cwd(remote_dir) names = ftp.nlst() for name in names: remote_path = f"{remote_dir}/{name}" local_path = os.path.join(local_dir, name) offset = os.path.getsize(local_path) if os.path.exists(local_path) else 0 try: remote_size = ftp.size(remote_path) except error_perm: remote_size = -1 if offset > 0 and offset == remote_size: log.info("文件已存在,跳过: %s", name) continue with open(local_path, "ab") as f: if offset and offset < remote_size: ftp.retrbinary(f"RETR {remote_path}", f.write, rest=offset) else: ftp.retrbinary(f"RETR {remote_path}", f.write) log.info("已同步: %s", name) break except (error_perm, TimeoutError, OSError) as e: last_error = e log.warning("第%s次尝试失败: %s", attempt, e) time.sleep(5 * attempt) else: log.error("重试 %s 次仍失败,最后错误: %s", RETRY_LIMIT, last_error) raise SystemExit(1) if __name__ == "__main__": sync_from_ftp("192.168.1.100", 21, "user", "pass", "/remote/data", "/local/data")脚本的核心是把第3、4章的连接和传输逻辑整合进一层重试循环。with FTP()保证异常后连接一定会关闭,重试退避时间按5秒乘以尝试次数递增,最多试3次。本地文件如果已经存在且大小和远端一致就跳过,不一致时用rest参数断点续传,远端文件变小则全量重下。日志使用标准库logging输出到控制台,生产环境再加一个FileHandler就能落盘。
这个脚本骨架里没有处理远端文件更新检测,我后来吃过一次亏:某个报表文件大小没变但内容被覆盖,断点续传直接跳过了,导致业务拿到旧数据。从那以后,我在download逻辑里额外对比mlsd返回的modify时间字段,不一致就重下。如果服务器支持HASH命令,也可以用文件哈希做更严格的校验。这是我在FTP协议对接里最深的教训——协议本身不难,难的永远是边界条件。希望帮到你。
本文还有配套的精品资源,点击获取