简介:面向需要实现 FTP 文件上传与可视化进度反馈的开发者,这套示例工程以带进度条的 FTP 上传功能为核心,演示建立连接、登录验证、切换目录、文件传输及资源释放等关键环节,适合学习 C#/WinForms 网络编程与文件传输交互设计。压缩包共 35 个文件,约 78KB,主要包含 cs 源码、resx 资源、config 配置、exe 程序及 sln/csproj 工程文件,并附带 pdb、cache 等编译运行产物,便于直接打开工程查看结构与调试。已有 869 人学习下载。工程将 FTP 帮助类与上传进度计算封装在示例项目中,读者可参考其连接管理、分块发送与百分比更新思路,并在此基础上补充重试、异常处理和权限校验,提升上传功能的可用性与复用性。
1. 从「进度条停在 0%」说起:这个 FTP 上传实例能干什么
第一次把 FTP 上传和进度条拼到一起,是在一个每天要往文件服务器推几百兆日志的脚本里:界面一动不动,进度条死死钉在 0%,日志也刷不出来,只能靠ls -l猜到底传了多少。这个实例就是把那次事故里拆出来的东西整理成一份能直接跑的代码——一份只依赖 Python 标准库的 FTP 文件上传下载 实现,传输层用ftplib,进度展示用tkinter的进度条,上传、下载、断点续传、整个目录批量同步都有对应入口,不引入任何第三方包。适合正在写运维工具、客户端自动更新、定时备份脚本的开发者直接照着改;如果你还停留在「storbinary一调,界面就冻住」的阶段,这份代码基本能对上你的问题。
2. 架构与选型:三层拆分让上传和界面互不干扰
这个实例最容易写错的地方不是 FTP 协议本身,而是把网络 IO 和界面刷新塞进同一个线程。storbinary是同步阻塞调用,一个 500MB 的文件在百兆带宽下要跑几十秒,只要它占着主线程,tkinter的窗口就会进入「未响应」,进度条自然也不会动。所以整个项目从第一行代码起就按三层切开:连接层只管建连和参数,传输层只管字节搬运和进度回报,界面层只管把数字画成进度条。三层之间只靠回调和队列通信,谁都不认识谁。
2.1 三个模块的职责边界
按这个划分,仓库里常见的是四个文件:
| 文件 | 职责 | 对外暴露 |
|---|---|---|
ftp_conn.py | 建立连接、设置被动模式/编码/超时 | connect() |
transfer.py | 上传、下载、断点续传、目录同步 | upload_with_progress()等 |
ui_app.py | 窗口、按钮、进度条、事件循环 | UploadApp类 |
main.py | 组装依赖、启动主循环 | 程序入口 |
这么切的直接好处是transfer.py完全不依赖tkinter,你可以把它原封不动搬到命令行工具、Flask 接口或者定时任务里,进度回调换成写日志、写数据库都行。我一般会把传输层设计成「只接收一个on_progress(sent, total)回调」,至于这个回调最后是更新进度条还是打日志,由调用方决定,这样二次开发时改动面最小。
2.2 目录结构与依赖清单
目录按上面的模块平铺,不搞多层包嵌套,示例代码层级越浅越好读:
ftp_progress_demo/ ├── ftp_conn.py # 连接层 ├── transfer.py # 传输层:上传 / 下载 / 续传 / 目录同步 ├── ui_app.py # 界面层:tkinter + 队列 ├── main.py # 入口 └── config.example.ini # 服务器地址、账号、块大小等外置配置依赖是零。Python 3.8 以上自带ftplib、tkinter、queue、concurrent.futures、os、time,不用pip install任何东西。唯一要确认的是tkinter有没有装:部分精简版 Linux 发行版把它拆成了独立包,跑python -c "import tkinter"报ModuleNotFoundError的话补装系统包即可,这不是pip能解决的事。
2.3 连接参数:被动模式、编码、超时怎么定
连接层看着简单,坑几乎全在这十几行里:
# ftp_conn.py 连接层:只负责建连和参数,不做任何数据传输 import ftplib def connect(host, port=21, user='anonymous', password='', timeout=30, encoding='utf-8'): # timeout 必须给,默认是全局 socket 超时,卡死时无法自愈 ftp = ftplib.FTP(timeout=timeout) # encoding 要在 connect 之前设定,它决定路径和响应文本怎么解码 ftp.encoding = encoding ftp.connect(host, port) ftp.login(user, password) # 主动模式在 NAT / 防火墙后基本连不上数据端口,一律走被动 ftp.set_pasv(True) # 显式声明二进制模式,传文件绝对不能落到 TYPE A ftp.voidcmd('TYPE I') return ftpset_pasv(True)是默认值,但我不喜欢依赖默认值——现场排查时你会感谢自己写明了。timeout=30是单次 socket 操作的超时,不是整个传输的总时长,大文件传十分钟也不会被它掐断,但它能保证网络静默 30 秒后抛异常而不是永远挂着。encoding是最容易被忽略的参数:ftplib用它来编解码命令和路径,服务端是 Windows IIS 搭的 FTP 时经常是 GBK,用默认的 UTF-8 去传中文文件名就会变成一堆问号,症状是550 The filename, directory name, or volume label syntax is incorrect。
3. 上传主链路:把字节数和进度条对上
ftplib.FTP.storbinary(cmd, fp, blocksize=8192, callback=None, rest=None)这个签名里有三个东西决定成败:fp必须是二进制文件对象、blocksize决定进度回报的粒度、callback决定你能不能拿到进度。很多人卡在第一步,用open(path, 'r')打开文件,fp.read()返回str,sendall直接抛TypeError;也有人以为callback会给「累计已发送字节数」,实际上它每次收到的是「刚刚发出去的那一块数据」,累计值得自己加。
3.1 storbinary 的 callback 到底给了什么
先看传输层最核心的一段:
# transfer.py 上传:累计 callback 收到的字节块,换算成进度 import os, ftplib def upload_with_progress(ftp, local_path, remote_path, on_progress, blocksize=64 * 1024): total = os.path.getsize(local_path) # 总字节数,进度条的分母 sent = 0 def _cb(buf): # ftplib 每发完一块就回调一次,buf 是这一块的 bytes nonlocal sent sent += len(buf) on_progress(sent, total) # 二进制打开,避免 Windows 下把 \n 转成 \r\n 导致文件损坏 with open(local_path, 'rb') as fp: ftp.storbinary(f'STOR {remote_path}', fp, blocksize=blocksize, callback=_cb) return sentnonlocal sent是这里的关键,闭包里改外层变量忘了声明就会UnboundLocalError。blocksize默认 8192,我调到 64KB 是因为进度回调太密反而拖慢传输——64KB 一块在百兆内网里意味着大约每秒几十次回调,界面完全够顺滑;如果是几十 KB 的小文件,可以降到 8KB,进度条开始动得更早。STOR {remote_path}里的路径必须是服务器上已存在的目录,ftplib不会替你建父目录,路径写错会直接抛error_perm: 550。
回调拿到的sent是「已交给内核 socket 缓冲区的字节数」,不是「服务器已确认落盘的字节数」。这个差别就是后面排查章节里「进度条 100% 但界面不结束」的根源,先记住这一点。
3.2 从工作线程到 tkinter 主线程的进度传递
tkinter不是线程安全的,工作线程里直接碰progress['value']在 Windows 上表现为随机崩溃,在 macOS 上可能只是偶尔不刷新,属于典型的玄学问题。稳妥做法是工作线程只往queue.Queue里塞元组,主线程用after轮询消费:
# ui_app.py 工作线程只写队列,主线程用 after 轮询消费 import queue, threading, tkinter as tk from tkinter import ttk class UploadApp: def __init__(self, root, uploader): self.root = root self.uploader = uploader self.events = queue.Queue() # 唯一的跨线程通道 self.bar = ttk.Progressbar(root, length=360, maximum=100) self.bar.pack(padx=12, pady=12) self.root.after(80, self._drain) # 80ms 一次,肉眼够顺滑 def start(self, local_path, remote_path): def _work(): def _cb(sent, total): # 只放数据,不做任何 UI 操作 self.events.put(('progress', sent, total)) try: self.uploader(local_path, remote_path, _cb) self.events.put(('done', 0, 0)) except Exception as exc: self.events.put(('error', str(exc), 0)) threading.Thread(target=_work, daemon=True).start() def _drain(self): try: while True: kind, a, b = self.events.get_nowait() if kind == 'progress' and b: self.bar['value'] = a * 100 / b elif kind == 'done': self.bar['value'] = 100 elif kind == 'error': self.bar['value'] = 0 print('上传失败:', a) except queue.Empty: pass self.root.after(80, self._drain) # 自己续约,循环轮询轮询间隔 80ms 是权衡后的结果:再快,主线程被无谓唤醒;再慢,进度条会有明显跳跃感。daemon=True保证关窗口时工作线程不会拖住进程退出,代价是传输可能被硬中断,服务端留下半截文件——所以正式场合我会在WM_DELETE_WINDOW里先置一个self.cancelled标志,让工作线程在回调里检查后主动ftp.abort()。
3.3 断点续传:SIZE + REST 的两步走
传 2GB 的镜像传到 1.8GB 断网,从头再来一遍谁都会崩溃。FTP 的续传靠两条命令:先SIZE问服务器上已有多少字节,再在STOR之前发REST offset让服务端把写指针挪过去。storbinary的rest参数就是干这个的:
# transfer.py 续传上传:先问大小,再定位,最后带 rest 发 STOR def resume_upload(ftp, local_path, remote_path, on_progress, blocksize=64 * 1024): total = os.path.getsize(local_path) try: offset = ftp.size(remote_path) or 0 # 服务器不支持 SIZE 会抛异常 except ftplib.error_perm: offset = 0 if offset >= total: on_progress(total, total) # 已经是完整文件,直接收工 return total with open(local_path, 'rb') as fp: fp.seek(offset) # 本地也跳到同一位置 sent = offset def _cb(buf): nonlocal sent sent += len(buf) on_progress(sent, total) ftp.storbinary(f'STOR {remote_path}', fp, blocksize=blocksize, callback=_cb, rest=offset) return sentsent初始化为offset而不是 0,进度条才不会从零重新爬。fp.seek(offset)和rest=offset必须一致,只做一边的后果是文件内容错位,传上去的文件能打开但内容全是乱的,这种问题肉眼极难发现。并非所有服务端都支持对STOR使用REST,有些只允许REST配合RETR,遇到502 REST not implemented就得退化成APPE(追加写),或者干脆把文件切块,用临时文件名传完再RNFR/RNTO重命名——重命名这一步顺带解决了「传一半被别处读到」的问题,我后来做生产工具时都默认这么干。
4. 下载、目录与批量任务:把单文件示例扩成能用的工具
只会传单个文件,示例就永远只是个示例。真实需求往往是「把本地某个目录整个推上去」或者「把服务器上昨天的备份拉下来」,这就涉及下载链路、递归建目录和并发。这三件事各有各的坑,尤其并发,ftplib.FTP实例内部维护着控制连接的状态机,多线程共用一个实例会出现命令和响应错位的诡异现象,表现为莫名拿到上一个命令的返回码。
4.1 retrbinary 与下载进度
下载用retrbinary,回调里拿到的是刚从数据连接读到的字节块,写文件也在回调里做:
# transfer.py 下载:回调里同时落盘和回报进度 def download_with_progress(ftp, remote_path, local_path, on_progress, blocksize=64 * 1024): total = ftp.size(remote_path) or 0 # 拿不到大小时给 0,前端做不确定态 done = 0 with open(local_path, 'wb') as fp: def _cb(buf): nonlocal done fp.write(buf) # 边收边写,避免大文件全塞内存 done += len(buf) on_progress(done, total) ftp.retrbinary(f'RETR {remote_path}', _cb, blocksize=blocksize) return doneftp.size()在 ASCII 模式下返回的是转换后的大小,和实际字节数对不上,所以连接层那句voidcmd('TYPE I')不只是为了上传。total为 0 时(部分服务端对某些文件返回550 SIZE not allowed in ASCII mode或干脆不支持),进度条改走不确定态,别除零。
下载续传同理,retrbinary也有rest参数,本地以'ab'打开,先os.path.getsize得到已有长度:
# transfer.py 续传下载:本地已有多少就从哪里继续 def resume_download(ftp, remote_path, local_path, on_progress, blocksize=64 * 1024): total = ftp.size(remote_path) or 0 offset = os.path.getsize(local_path) if os.path.exists(local_path) else 0 done = offset with open(local_path, 'ab') as fp: # 追加模式,别覆盖已下载部分 def _cb(buf): nonlocal done fp.write(buf) done += len(buf) on_progress(done, total) ftp.retrbinary(f'RETR {remote_path}', _cb, blocksize=blocksize, rest=offset) return done追加模式'ab'一旦写错成'wb',续传就变成了「从断点开始覆盖」,最终文件前面一段全丢。
4.2 目录遍历与递归上传
批量上传要先解决目录:FTP 没有mkdir -p,MKD一次只能建一级。常见做法是遍历时逐级建目录,已存在会抛error_perm: 550,直接吞掉即可:
# transfer.py 递归上传:逐级建目录,550 当已存在处理 import os, ftplib def sync_dir(ftp, local_root, remote_root, on_progress=None): for dirpath, _dirnames, filenames in os.walk(local_root): rel = os.path.relpath(dirpath, local_root) remote_dir = remote_root if rel == '.' else \ remote_root.rstrip('/') + '/' + rel.replace(os.sep, '/') try: ftp.mkd(remote_dir) # 逐级建,不支持递归创建 except ftplib.error_perm: pass # 已存在,正常情况,不中断 for name in filenames: local_file = os.path.join(dirpath, name) remote_file = remote_dir + '/' + name upload_with_progress(ftp, local_file, remote_file, on_progress or (lambda s, t: None))os.sep那一步不能省,Windows 上os.walk给出的是反斜杠路径,直接拼进 FTP 命令会得到一个服务器看不懂的目录名。另外遍历时会被.git、__pycache__、.DS_Store这类目录和文件污染,实际项目里我会传一个exclude集合,在dirnames[:] = [...]上做原地过滤,这样os.walk连进都不会进去。
4.3 多线程并发与连接池的边界
单连接传一百个小文件,握手开销比数据本身还大。加并发是自然想法,但必须是「一线程一连接」:
# transfer.py 并发上传:每个任务独立建连,绝不共用 FTP 实例 from concurrent.futures import ThreadPoolExecutor def upload_many(conn_kwargs, jobs, on_progress, max_workers=4): def _one(job): local_file, remote_file = job ftp = connect(**conn_kwargs) # 每个线程自己的连接 try: return upload_with_progress(ftp, local_file, remote_file, on_progress) finally: try: ftp.quit() # 正常退出,让服务端释放会话 except Exception: ftp.close() # quit 失败也要关 socket with ThreadPoolExecutor(max_workers=max_workers) as pool: return list(pool.map(_one, jobs))max_workers不要贪多,很多服务端有单 IP 会话数上限,开 16 个线程大概率撞上421 Too many connections,而且被动模式下每个连接都要占一个数据端口,服务端没配置端口范围的话会直接被防火墙掐。我一般取 3 到 4,配合每线程独立连接的方案,实测比单线程快四倍左右,再加就只剩失败率上升了。
5. 排查手册:五类让上传卡住或失败的现场
这一节是按「现象 → 原因 → 解决」整理的,全是现场遇到过的。FTP 的错误码本身信息量很低,425、426、550具体指什么要结合上下文,所以排查时先定位是控制连接、数据连接还是应用层的问题,比死背错误码有用得多。
5.1 进度条跑到 100% 但界面一直不结束
现象:进度条已经拉满,按钮还是灰的,过几秒甚至几十秒才提示成功,网络差的时候直接超时。
原因:storbinary的callback统计的是「已经写进内核 socket 缓冲区的字节」,此时数据可能还堆在发送缓冲区里没到对端;函数末尾还有个self.voidresp(),要等服务器回226 Transfer complete才算真正完成。带宽小、延迟高的时候,最后这一段等待非常明显。
解决:把 100% 拆成两个状态。回调里到sent >= total时先把进度条设成 99% 并显示「等待服务器确认」,storbinary正常返回之后再置 100%。这样用户看到的是「传输完成,正在校验」,而不是「卡死在 100%」。
5.2 服务器上的文件大小是 0,或者是原始文件的一半
现象:命令没报错,RETR下来发现文件损坏,文本文件末尾少几行,二进制文件直接打不开。
原因:用open(path, 'r')打开了文件,fp.read()返回str,sendall要bytes,要么直接TypeError,要么在某些封装里被隐式编码,Windows 下\n被转成\r\n,字节数膨胀;另一种情况是自己发了TYPE A命令,服务端按 ASCII 模式做换行转换。
解决:传输层一律open(local_path, 'rb'),连接建立后立刻voidcmd('TYPE I'),并且不要在业务代码里手动发TYPE命令去覆盖它。
5.3 中文文件名变成问号或者 550 找不到路径
现象:本地叫报表2024.xlsx,传上去在客户端里显示成一串乱码,或者STOR直接报550。
原因:ftplib.FTP.encoding默认是 UTF-8,服务端如果是老版本 IIS 或者某些嵌入式 FTP,用的是 GBK/CP936,双方对同一串字节的解释不一致。
解决:连接时把encoding传成'gbk'试一次;新一些的服务端可以发OPTS UTF8 ON显式开启 UTF-8。两边都不确定的话,做一层「先按 UTF-8 试,收到error_perm就切 GBK 重连」的降级,虽然丑但有效。
5.4 小文件正常,大文件传一会儿就 425 或超时断开
现象:几百 KB 的文件随便传,几十 MB 传到一半抛TimeoutError或425 Can't open data connection。
原因:被动模式下服务端返回的是数据端口,如果服务端在 NAT 后面又没配置外网地址,客户端拿到的是内网 IP,根本连不上;另一种是防火墙只放行了 21 端口,被动模式随机端口全被拦。
解决:set_pasv(True)确认已开;让服务端把被动端口范围固定成一小段(比如 50000-50010)并在防火墙上放行;客户端侧把timeout从 30 提到 60 到 120,同时开socket的SO_KEEPALIVE避免中间设备把空闲连接踢掉。
5.5 并发上传时报 421 或者文件内容互相串
现象:单线程一切正常,开了线程池之后偶发421 Too many connections,或者某个文件传上去内容明显是另一个文件的。
原因:多个线程共用了同一个ftplib.FTP实例。FTP 是严格的一问一答协议,控制连接上的状态机被并发写就会错位,STOR的响应可能被另一个线程读走。
解决:一线程一连接,用完quit();连接数控制在服务端允许的会话上限内。如果确实需要复用,就上连接池并保证同一时刻一个连接只被一个任务持有(加锁串行化),别指望ftplib自己处理并发。
6. 二次开发:把 demo 改成能配置的传输组件
真要把这套代码用在项目里,第一件该做的事是把host、user、password、blocksize、timeout从代码里挪出去。我在示例里留了config.example.ini,用configparser读进来打包成一个dataclass,好处是单元测试可以塞假参数、部署时改配置不改代码、密码还能从环境变量覆盖:
# config.py 配置外置,密码优先读环境变量 import os, configparser from dataclasses import dataclass @dataclass class FtpConf: host: str port: int = 21 user: str = 'anonymous' password: str = '' timeout: int = 60 blocksize: int = 64 * 1024 encoding: str = 'utf-8' def load_conf(path='config.ini'): cp = configparser.ConfigParser() cp.read(path, encoding='utf-8') sec = cp['ftp'] return FtpConf( host=sec['host'], port=sec.getint('port', 21), user=sec['user'], password=os.environ.get('FTP_PASSWORD', sec.get('password', '')), timeout=sec.getint('timeout', 60), blocksize=sec.getint('blocksize', 64 * 1024), encoding=sec.get('encoding', 'utf-8'), )第二件事是给传输层留可替换的进度回调。示例里回调签名是(sent, total),二次开发时你可以包一层,把同一个回调复用成「更新进度条 + 写审计日志 + 上报监控」三个下游,传输层代码一行都不用动。第三件事是验证,别拿生产服务器当测试环境。我一般会在本地起一个临时 FTP 服务,把STOR、RETR、REST、MKD四条路径各跑一遍,重点看三个断言:上传后的size和本地一致、续传后的文件 MD5 和源文件一致、550场景(父目录不存在)能正确抛异常而不是静默失败。
还有一个值得提前做的改造是把「传输」包成可取消的任务对象。界面上的取消按钮不能直接ftp.close(),那会让服务端留下半截文件;正确做法是在回调里检查取消标志,抛出自己定义的TransferCancelled,在except里调ftp.abort()再关连接,服务端会把这次传输标记为失败并回收。这个细节我第一次做的时候完全没考虑,结果测试同事点了几次取消,服务器上堆了一堆残缺文件,被运维找上门。
从那以后我每次动传输相关的代码,都强制走一遍「小文件 / 大文件 / 断网重连续传 / 中文名 / 取消」这五条固定用例,哪怕改动只有一行。希望帮到你。
本文还有配套的精品资源,点击获取