ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python标准库ftplib实战:从FTP协议到文件传输全解析

Python标准库ftplib实战:从FTP协议到文件传输全解析 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和PASScwd()方法发送CWDnlst()发送NLSTretrbinary()发送RETRstorbinary()发送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()无关闭连接但不发送QUITset_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(host192.168.1.100, port21, timeout10) ftp.login(userusername, passwdpassword) 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(fSTOR {remote_path}, fp, blocksize8192)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(fRETR {remote_path}, fp.write, blocksize8192)注意这里直接传了fp.write作为回调因为retrbinary每次读到的chunk都是bytes而文件以二进制模式打开write方法接收bytes完美匹配。如果是下载文本文件并直接在内存中处理内容不想落盘可以用retrlines加上自己的处理逻辑lines [] ftp.retrlines(fRETR /pub/data/2024/readme.txt, callbacklines.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, port21, timeout15) 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(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 )这段代码里有几个实际常用的处理细节第一是远程目录不存在时逐级创建因为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(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)这个示例是续传下载断点续传上传会麻烦一些因为需要服务器支持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(encodingutf-8)如果服务器用的是GBKftp FTP(encodinggbk)但更隐蔽的问题是目录列表里的中文乱码和文件名列表里的中文乱码可能不是同一种编码导致的。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(host192.168.1.100, port21, timeout30) 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_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)需要额外实现一个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协议的文件传输协议端口通常走22Python里要用另一个第三方库去实现比如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里再编码回去就变成了另外一串字节服务器当然找不到路径。最后通过指定encodingutf-8解决了。这件事给我最大的教训就是——遇到ftplib和中文相关的问题先确认服务器端和客户端的编码是不是一致再谈其他。还有一次是批量上传任务跑了几个小时突然卡住我最初怀疑是网络问题但后来发现是FTP服务器设置了空闲超时10分钟没有数据传输就自动断开控制连接。从那以后我在所有长任务里都加了NOOP心跳再也没有出现过类似问题。希望这篇文章能让你少走一些弯路。如果你在项目里也遇到过ftplib相关的疑难问题不妨从编码、被动模式、超时和心跳这几个方向去排查大部分问题都逃不出这几点。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表