Python自带的ftplib模块,说它是标准库里最容易被忽略的宝藏之一,一点都不过分。很多新手学完os、re、requests之后,遇到文件传输需求第一反应是去找第三方库,但如果你只需要跟FTP服务器打交道,ftplib完全够用,而且是零依赖、开箱即用。这篇文章我就拿实际使用的经验,把ftplib的核心功能、常用的坑、以及一些业务场景下的封装技巧一次讲透,适合那些刚入门Python、需要对接FTP服务的开发同学,也适合已经用了一段时间但没系统梳理过的朋友查漏补缺。
1. 动手之前,先把FTP协议的底子补上
很多人直接拿ftplib当普通的HTTP库用,结果遇到各种奇怪问题——明明能连上、登录成功,但一传文件就卡住或者超时。问题大多出在没搞懂FTP协议的工作方式上。
1.1 一个协议,两条连接
FTP和HTTP有个本质区别:HTTP只有一条连接,请求响应都在上面完成;FTP是两条独立的连接,一条叫控制连接,一条叫数据连接。
控制连接就是你执行登录、切换目录、删除文件这些命令时走的通道,它是一条长连接,一直开着。而真正传输文件内容、拉取目录列表的时候,服务器会单独开一条数据连接,传完就关闭。
这两条连接的端口还不一样。控制连接默认是21端口,数据连接的端口则由传输模式决定。主动模式下服务器主动去连客户端的某个端口,被动模式下服务器告诉你一个端口号,由客户端去连接。这就是为什么很多FTP客户端软件里会有“主动模式”和“被动模式”的切换开关,ftplib也一样,默认为主动模式,但实际使用中多数场景要改成被动模式。
1.2 主动模式和被动模式怎么选
主动模式下,客户端在控制连接上告诉服务器“我开放了某个端口,你过来连接”,然后服务器主动向客户端发起数据连接。这个模式的问题很明显——客户端如果在内网或者有防火墙,服务器过不来,数据传输就失败了。
被动模式下,客户端在控制连接上发送PASV命令,服务器返回一个IP和端口,由客户端去连接。这种模式对客户端更友好,因为数据传输连接是客户端主动发起的,不会被防火墙拦截。
我在实际项目中,客户端部署在云服务器上,FTP服务器在另一台机器上,直接用默认的主动模式就经常卡死,改成被动模式后问题立刻消失。所以如果你发现连接正常、登录也正常,但一执行下载或者列目录就hang住,优先检查是不是主动模式惹的祸。
注意:ftplib里设置被动模式很简单,FTP类的构造参数里有个passive,默认是True,也就是被动模式。如果你用的是默认值还出现问题,那就要从服务器配置或者网络安全组层面去排查了。
1.3 ftplib到底封装了什么
ftplib本身是标准库,它做的事情就是按照FTP协议的报文格式,帮你把命令发出去、把响接收回来。你不需要关心底层socket怎么收发数据,也不需要手工构造FTP命令,ftplib里的每个方法基本都对应一个FTP命令。
比如login()方法发送USER和PASS,cwd()方法发送CWD,nlst()发送NLST,retrbinary()发送RETR,storbinary()发送STOR。理解了这层映射关系,你去看ftplib的源码时会觉得非常清晰,出问题的时候也能通过抓包或者查看原始响应来定位。
2. ftplib的核心类与方法心智图
ftplib模块里真正干活的主要是两个类:FTP和FTP_TLS。FTP_TLS是FTP的加强版,增加了对TLS加密传输的支持。其他还有一些辅助类,比如FTP_HOST、FTP_PORT等常量,但日常用到的不多。
2.1 FTP类的核心方法一览
先看一张梳理好的方法表,不用背,用到的时候回来查就行:
| 方法 | 对应FTP命令 | 作用 |
|---|---|---|
| connect(host, port) | 无 | 连接FTP服务器 |
| login(user, passwd) | USER / PASS | 登录认证 |
| cwd(pathname) | CWD | 切换工作目录 |
| pwd() | PWD | 返回当前目录 |
| nlst(dirname) | NLST | 返回目录下的文件名列表 |
| dir(dirname) | LIST | 返回详细目录列表 |
| retrlines(cmd) | LIST / NLST | 下载文本内容 |
| retrbinary(cmd, callback) | RETR | 以二进制方式下载文件 |
| storbinary(cmd, fp) | STOR | 以二进制方式上传文件 |
| storlines(cmd, fp) | STOR | 以文本方式上传文件 |
| delete(filename) | DELE | 删除文件 |
| rename(from, to) | RNFR / RNTO | 重命名文件/目录 |
| mkd(pathname) | MKD | 创建目录 |
| rmd(pathname) | RMD | 删除目录 |
| size(filename) | SIZE | 获取文件大小 |
| sendcmd(cmd) | 任意命令 | 发送原始FTP命令并返回响应 |
| quit() | QUIT | 发送退出命令并关闭连接 |
| close() | 无 | 关闭连接但不发送QUIT |
| set_debuglevel(level) | 无 | 设置调试输出级别 |
| voidresp() | 无 | 读取并返回响应数据 |
这些方法里,retrbinary、storbinary、nlst、dir是最高频的四个,下面逐个展开说。
2.2 FTP_TLS:该用加密的时候不要偷懒
FTP_TLS是FTP的子类,用法基本一致,只是在连接阶段多了一步TLS握手。这里有一个很容易混淆的点:FTP_TLS支持两种工作模式,一种是显式TLS,客户端连接后先发送AUTH TLS命令升级为加密通道,这是RFC 4217标准定义的方式;另一种是隐式TLS,从建立连接开始就默认使用TLS,默认端口通常是990。
ftplib里的FTP_TLS默认走显式TLS,也就是先connect再login就可以了。但如果服务器只支持隐式TLS,你就需要额外指定一个参数。具体做法是直接用FTP_TLS.connect()连接时传不同的端口,并且通过设置ssl_version等参数来控制。不过说实话,隐式TLS的场景相对少,大多数企业内部还是用显式模式,或者干脆直接用SFTP。
用FTP_TLS还有一个很重要的细节:执行TLS握手后,默认情况下后续的数据连接也是加密的,但有些服务器配置了只有在需要时才加密,或者数据传输不加密。如果你发现retrbinary时报错,提示数据连接没有加密或者SSL相关的问题,可以在完成登录后执行一次prot_p()。
2.3 连接对象的生命周期
每次使用完FTP连接,一定要记得关闭。很多人用with语句习惯了上下文管理的便利,但ftplib的FTP类在Python 3.4之前并没有实现__enter__和__exit__,之后版本才补上。所以网上很多老代码是try/finally写法,新代码可以直接用with。
另外,FTP连接是有时效的,服务器通常配置了空闲超时时间。如果连接长时间没有动作,服务器会自动断开。这种情况最常出现在批量上传任务的循环里,处理完一个文件后隔了一段时间再处理下一个,结果连接已经断了,接着报错。解决办法有两个:要么在每次操作前调用voidcmd("NOOP")做一次心跳,要么在任务循环里重新登录。
3. 实操:从连接、上传到下载的完整落地
下面用一个完整的例子来演示ftplib的实战流程。这个例子综合了我平时处理FTP任务时的习惯写法,从连接、登录、切换目录到文件上传和下载都有。
3.1 基础连接与登录
from ftplib import FTP, FTP_TLS ftp = FTP() ftp.connect(host="192.168.1.100", port=21, timeout=10) ftp.login(user="username", passwd="password") print(ftp.pwd())这里connect和login分开写,好处是你可以更清晰地控制连接阶段的超时。如果直接用FTP(host, user, passwd)方式构造,其实内部也是先connect再login,但超时时间只能用默认的。
connect的timeout参数很重要。默认情况下,如果不设置,socket层面的超时时间可能是全局默认,有些环境下会非常长,导致程序卡住很久才报错。我一般都会显式设置一个合理的timeout,比如10秒或者15秒,这样网络不通时能快速失败。
登录成功后,服务器会返回230状态码表示登录成功,如果用户名密码错误,返回530。ftplib会把状态码和消息包装成异常,所以在实际使用中,登录失败会直接抛异常,你需要在外面捕获并处理。
3.2 目录切换与列表获取
登录之后,第一步通常要切换目录:
ftp.cwd("/pub/data/2024") print(ftp.pwd()) files = ftp.nlst() print(files)nlst()返回的是不带权限信息、不带时间戳的文件名列表,适合做自动化处理。但要注意,有些服务器对NLST的支持不完全,返回的格式可能不标准。如果代码里需要判断文件大小、修改时间等详细信息,就要用dir()或者retrlines('LIST')。
我的习惯是,如果是批量下载,先用nlst拿到文件名列表,然后逐个用size()方法查询文件大小,再决定是否下载。但size()方法依赖SIZE命令,有些FTP服务器上的SIZE命令是被禁用的,会报错“SIZE not allowed in ASCII mode”之类。如果碰到这种情况,直接下载即可,别过度依赖size来做过滤。
3.3 上传文件:两种姿势
上传文件最常用的是storbinary,因为它以二进制方式传输,不会在传输过程中破坏文件内容。这里说的“破坏”是真实存在的问题——如果用文本模式传一个二进制文件,换行符会被自动转换,图片、压缩包传过去就损坏了。
import os local_path = "local_archive.zip" remote_path = "/pub/data/2024/archive.zip" with open(local_path, "rb") as fp: ftp.storbinary(f"STOR {remote_path}", fp, blocksize=8192)storbinary的第一个参数是完整的FTP传输命令,通常是STOR 文件名。第二个参数是一个文件对象,第三个参数是缓冲区大小,默认是8192字节。这个blocksize影响传输性能,太小会导致频繁socket读写,太大则占用内存。我试过8KB和64KB,在小文件场景下区别不大,传大文件时64KB会快一点,但8KB更稳一些,不会因为单次写入过大导致对端缓冲区溢出。日常用默认值即可。
如果你想把一个内存中的字符串上传上去,不需要先保存成临时文件,可以借助io.BytesIO把数据包装成类文件对象:
from io import BytesIO data = "hello ftplib".encode("utf-8") bio = BytesIO(data) ftp.storbinary("STOR /tmp/hello.txt", bio)这个技巧在处理动态生成的内容时非常有用,省去了临时文件的创建和清理。
3.4 下载文件:二进制和文本两种读取方式
下载文件和上传类似,用到retrbinary。它的第二个参数是一个回调函数,这个函数接收每次读取到的chunk数据。最常见的写法是把chunk写入本地文件:
remote_path = "/pub/data/2024/archive.zip" local_path = "local_archive.zip" with open(local_path, "wb") as fp: ftp.retrbinary(f"RETR {remote_path}", fp.write, blocksize=8192)注意这里直接传了fp.write作为回调,因为retrbinary每次读到的chunk都是bytes,而文件以二进制模式打开,write方法接收bytes,完美匹配。
如果是下载文本文件并直接在内存中处理内容,不想落盘,可以用retrlines加上自己的处理逻辑:
lines = [] ftp.retrlines(f"RETR /pub/data/2024/readme.txt", callback=lines.append) for line in lines: print(line)retrlines的callback接收到的是解码后的字符串,默认按utf-8解码,你也可以通过encoding参数修改。需要注意retrlines会把行尾的\r\n去掉,所以在拼接内容重新写文件时可能需要自己补回来。
3.5 完整示例:一个简单的FTP备份脚本
把上面这些拼起来,就是一个比较完整的FTP备份脚本。下面这个例子模拟了一个实际的备份场景:从本地目录上传文件到FTP服务器的某个路径下,并进行简单的错误处理。
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, port=21, timeout=15) ftp.login(user, passwd) ftp.set_debuglevel(0) # 确保远程目录存在 try: ftp.cwd(remote_dir) except Exception: # 目录不存在则逐级创建 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(f"STOR {filename}", fp) print(f"uploaded: {filename}") ftp.quit() print("backup finished at", datetime.now().isoformat()) if __name__ == "__main__": upload_backup( local_dir="./data", remote_dir="/backup/2024", host="192.168.1.100", user="ftpuser", passwd="ftppass" )这段代码里有几个实际常用的处理细节:第一是远程目录不存在时逐级创建,因为mkd只能创建一层目录;第二是每次上传后打印进度,方便观察任务是否卡住;第三是在结束时调用quit()而非close(),这样会正常发送QUIT命令,服务器端也能及时释放资源。
3.6 断点续传的实现思路
ftplib自身没有直接提供断点续传的功能,但实现起来也不复杂。核心就是REST命令加RETR命令的组合。REST命令告诉服务器从文件的某个偏移量开始传输,然后RETR重新开始传输文件。
import os from ftplib import FTP def resume_download(ftp: FTP, remote_path: str, local_path: str): # 获取远程文件总大小 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: # 从本地已有的偏移量开始续传 ftp.sendcmd(f"REST {local_size}") with open(local_path, "ab") as fp: ftp.retrbinary(f"RETR {remote_path}", fp.write) print("download resumed and completed") else: print("file already complete")这个示例是续传下载,断点续传上传会麻烦一些,因为需要服务器支持APPE命令,或者用STOR配合REST来实现。很多服务器不支持REST上传,所以上传断点续传的兼容性不如下载。实际场景中,如果网络不稳定导致上传中断,我更推荐的做法是重新传一遍,或者用SFTP/rsync这类自带断点续传与校验功能的工具,而不是强行在ftplib上做上传续传。
4. 常见报错与排查技巧实录
ftplib使用中碰到的报错,类型其实很有限,核心就那么几个异常类。搞明白这几个异常,基本能解决九成的问题。
4.1 异常类型速查
| 异常类 | 触发场景 | 常见原因 |
|---|---|---|
| ftplib.error_perm | 永久性错误 | 530未登录、550文件不存在或权限不足 |
| ftplib.error_temp | 临时性错误 | 421服务不可用、425无法建立数据连接 |
| ftplib.error_proto | 协议错误 | 服务器返回不合法响应 |
| socket.timeout | 超时 | 网络不通、被动模式端口被防火墙拦截 |
| ConnectionRefusedError | 连接被拒绝 | 服务器端口未开放、IP错误 |
error_perm是最常见的。如果你在调用方法时收到550 Failed to change directory.,说明目标目录不存在或者你没有权限进入。这种错误通常是代码逻辑和服务器配置之间不匹配,排查方向就是确认路径和权限。
425 Unable to build data connection: Connection refused是典型的主动/被动模式或防火墙问题。这时我第一件事是切被动模式试试。用ftp.set_pasv(True)设置被动模式后再调用下载方法,大部分场景能直接解决。
4.2 超时相关:卡住不动怎么办
FTP连接卡住不返回,原因往往是网络层的半开连接。比如控制连接已经建立,但数据连接因为防火墙原因无法建立,程序就hang在读取socket响应的地方。
排查办法很简单,开启调试模式:
ftp.set_debuglevel(2)把这行加在连接成功之后,再次执行操作,你会发现ftplib会把每次发送的命令和接收的响应原样打印出来。这时候看最后一条响应是什么,如果是227 Entering Passive Mode,说明服务器已经返回了被动模式的IP和端口,但客户端后续连接数据端口失败。再看一下命令返回的IP是什么,如果是一个内网地址,说明FTP服务器在NAT后面,客户端无法连接它返回的那个IP。这种情况就需要在服务器端配置正确的公网IP。
每次定位FTP问题,先用set_debuglevel(2)打开调试,看报文,比盲猜高效得多。这个方法简单到几乎不占成本,但很多人就是不知道用。
4.3 中文文件名乱码问题
中文文件名的显示乱码,根源在于FTP协议本身没有声明文件名编码。RFC 2640定义了UTF-8作为推荐的字符集,但很多老旧服务器或者客户端默认用的还是GBK或Latin-1。在Python 3里,ftplib处理响应时默认按latin-1解码,中文就变成乱码了。
ftplib从Python 3开始支持通过encoding参数指定编码。你的FTP服务器如果是纯UTF-8,创建FTP对象时加上编码参数:
ftp = FTP(encoding="utf-8")如果服务器用的是GBK:
ftp = FTP(encoding="gbk")但更隐蔽的问题是,目录列表里的中文乱码和文件名列表里的中文乱码可能不是同一种编码导致的。nlst()返回的文件名和dir()返回的详细信息如果编码判断不一致,需要分别处理。我遇到过一种情况是服务器在控制连接返回的文件名是UTF-8,但LIST响应里带的时间格式却是拉丁字符,混在一起如果无脑用同一种编码解,反而会引发出错。
最稳妥的做法是:能设置编码就设置编码,nlst()拿到的文件名还乱码的话,就自己写一个底层命令交互,用sendcmd和retrbinary手工控制编码。
5. 日常使用时的几个进阶技巧
掌握了基础操作和常见排查,ftplib已经能解决大多数问题。但实际业务中总有些零碎的小技巧,能让代码更简洁、更健壮。
5.1 用上下文管理器避免连接泄漏
新版Python的ftplib已经支持with语法。我强烈建议在任何临时连接场景下使用:
from ftplib import FTP with FTP("192.168.1.100") as ftp: ftp.login("user", "pass") ftp.cwd("/data") print(ftp.nlst())这样即使中间抛异常,连接也会被正确关闭,避免程序的socket资源慢慢耗尽。如果你用的是老代码风格,一定记得在finally里手工调用quit。
5.2 批量任务中的连接复用
批量处理大量文件时,频繁建立连接再断开很浪费。更合理的方式是建立一个长连接,循环里复用,同时通过定时发送NOOP保持连接活跃。
ftp = FTP() ftp.connect(host="192.168.1.100", port=21, timeout=30) ftp.login("user", "pass") try: for remote_file in file_list: # 每次操作前发一个NOOP,维持连接 ftp.voidcmd("NOOP") local_file = download_one(ftp, remote_file) print("downloaded", local_file) finally: ftp.quit()这里的NOOP命令本身没有副作用,只用于检测连接是否还活着。如果服务器已经断开连接,发送NOOP会立刻抛异常,你就可以在这个地方统一做重连处理,而不是等到下载文件到一半才发现连接断了。
5.3 从内存中直接上传和下载
前面提过用BytesIO配合storbinary实现内存上传。下载也可以不进磁盘,直接读进内存:
from io import BytesIO buffer = BytesIO() ftp.retrbinary("RETR /data/config.json", buffer.write) content = buffer.getvalue().decode("utf-8") print(content)这个场景特别适合处理配置文件和接口数据传输。我在做自动化巡检脚本时,经常需要拉取远程机器上的配置文件和别的服务器比对,用这种方式完全不需要在本地创建临时文件,逻辑也更干净。
5.4 一键递归下载目录
ftplib没有递归下载目录的方法,但递归本质上是循环加栈或队列:
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_ok=True) 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(f"RETR {rpath}", fp.write)需要额外实现一个is_dir()判断,可以用size()方法是否抛异常来判断是不是目录——很多服务器对目录执行SIZE命令会报错。这种方法在服务器对LIST响应不一致的场景下比解析dir()的输出要可靠得多。
6. 安全与合规提醒
FTP协议本身是明文传输,用户名密码和数据内容在传输过程中都是裸奔的。尤其在内网环境下,很多人觉得无所谓,但我在实际项目里见过不止一次因为内网被渗透导致FTP账号密码被截获的事件。
6.1 FTPS与SFTP的区别
有人会把FTPS和SFTP搞混。FTPS是FTP over TLS,也就是FTP协议本身加了一层SSL/TLS,基础还是FTP那套命令,用ftplib的FTP_TLS就能实现。SFTP是基于SSH协议的文件传输协议,端口通常走22,Python里要用另一个第三方库去实现,比如paramiko。
如果你能控制服务器端的选择,内部文件传输我更推荐SFTP,因为它天然复用SSH的认证和加密体系,密钥管理和权限配置都更成熟。但如果服务器是传统FTP服务,不需要改造成本的情况下,至少要用FTP_TLS把通信加密起来,不能用裸FTP直接跑敏感数据。
6.2 实际使用中的取舍建议
从实践经验来看,ftplib适合以下场景:
- 对接老旧的FTP服务器,服务器端无法改动协议
- 内部局域网、可信网络环境下的简单文件传输
- 自动运维脚本、定时备份任务
- 需要精细控制FTP命令的探活和交互场景
如果项目允许自由选择技术栈,并且对传输安全有较高要求,还是优先考虑SFTP或者对象存储这类现代方案,没必要在FTP上硬扛。
另外还要注意,ftplib的性能天花板不高,它的socket读写是阻塞式的,处理单线程顺序传输还行,大规模并发传输会力不从心。如果业务要求同时传输大量文件,需要引入线程池或者改用异步HTTP/对象存储网关进行中转。
7. 结尾补充一些实际经验
我最初用ftplib的时候,碰到一个特别隐蔽的问题:服务器端的目录列表里有个中文目录名,使用nlst()拿到的是正常字符串,但当我把它拼到下载路径里后,服务器报“550 No such file or directory”。后来打开调试模式才发现,nlst()返回的字符串是已经按照latin-1解码过的,而实际上原始字节是UTF-8编码的中文,拿到Python里再编码回去就变成了另外一串字节,服务器当然找不到路径。最后通过指定encoding="utf-8"解决了。这件事给我最大的教训就是——遇到ftplib和中文相关的问题,先确认服务器端和客户端的编码是不是一致,再谈其他。
还有一次是批量上传任务,跑了几个小时突然卡住,我最初怀疑是网络问题,但后来发现是FTP服务器设置了空闲超时,10分钟没有数据传输就自动断开控制连接。从那以后,我在所有长任务里都加了NOOP心跳,再也没有出现过类似问题。
希望这篇文章能让你少走一些弯路。如果你在项目里也遇到过ftplib相关的疑难问题,不妨从编码、被动模式、超时和心跳这几个方向去排查,大部分问题都逃不出这几点。