简介:这份资源面向需要实现FTP文件上传功能的开发者,尤其适合正在学习C#网络编程、希望为上传过程加入可视化进度反馈的初中级程序员。资源以FTP协议的文件传输为核心,围绕连接建立、身份验证、目录切换、分块上传与进度计算等环节给出可运行的实例,帮助解决上传过程不可见、用户体验差的问题。压缩包共35个文件,约78KB,以cs源码文件为主,辅以resx资源、config配置、sln解决方案与csproj项目文件,并包含编译后的exe与pdb,结构完整,可直接在Visual Studio中打开调试。其中封装好的FTP帮助类便于在其他项目中复用,读者可据此理解进度回调的实现思路,并在此基础上扩展重试机制、错误处理与分块上传策略。目前已有868人学习下载,适合作为FTP上传功能的入门参考与二次开发起点。
1. FTP上传实例带进度条:一个能直接跑的上传模块长什么样
很多人第一次写 FTP 上传,代码跑通了,文件也传上去了,但界面从头到尾只有一个转圈的鼠标指针——用户不知道传到哪了、还要多久、是不是卡死了。这个实例要解决的就是这件事:在标准 FTP 上传流程里插入可观测的进度反馈,让上传从“黑匣子”变成“有刻度的仪表盘”。它适合正在做桌面工具、内部运维面板、批量文件同步脚本的开发者,尤其是需要把上传能力嵌进已有 GUI 或命令行界面的场景。核心思路不复杂:FTP 协议本身不推送进度,进度必须由客户端自己算——把待传字节按块切开,每写一块就累计一次,再换算成百分比回调出去。难点在于块大小怎么定、回调怎么不拖慢传输、断线怎么续,这些后面逐条拆。
2. 进度条的数据从哪来:FTP 传输模型与分块计数原理
2.1 为什么 FTP 没有原生进度事件
FTP 是上世纪七十年代定型的协议,控制连接和数据连接分离:控制连接发命令收响应码,数据连接负责搬字节。整个协议里没有任何“已传输 N 字节”的中间状态回报,服务器只会在传输结束时回一个 226 或 250。也就是说,进度条的数据源只能来自客户端本地——你往数据连接里写了多少字节,你自己最清楚。常见做法是把文件对象按固定大小分块读取,每读一块写一块,写成功后累加计数。这个计数是“已提交给 socket 的字节数”,不是“服务器已落盘的字节数”,两者有细微差别,后面避坑章节会专门说。
理解这一点很关键,因为它决定了进度条的性质:它是客户端侧的估算,不是服务端确认。对绝大多数内网上传场景够用,但如果业务要求“服务器确认收到才算数”,就得在传输结束后补一次 SIZE 或 MDTM 校验,用返回的文件大小做最终对账。
2.2 分块大小与回调频率的取舍
块太小,回调太频繁,GUI 线程被刷爆;块太大,进度条一跳一大格,观感差。血泪经验是:块大小取 8KB 到 64KB 之间,回调做节流——不是每块都刷界面,而是累计变化超过 1% 或距上次刷新超过 100ms 才触发一次。这样既保证平滑,又不让 UI 线程成为瓶颈。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 分块大小 chunk_size | 16KB~64KB | 内网可偏大,跨公网偏小 |
| 回调节流阈值 | 1% 或 100ms | 二者取先到者 |
| 缓冲区 | 与 chunk_size 一致 | 避免二次拷贝 |
| 超时 timeout | 30s~60s | 数据连接单独设 |
2.3 一个最小可用的上传函数骨架
下面这段是核心逻辑,去掉了界面部分,只保留“分块读、分块写、累计回调”三件事。用标准库 ftplib 就够,不需要额外依赖。
import ftplib import os def upload_with_progress(host, user, pwd, local_path, remote_path, chunk_size=32 * 1024, progress_cb=None): total = os.path.getsize(local_path) sent = 0 last_pct = -1 ftp = ftplib.FTP(host, timeout=60) ftp.login(user, pwd) ftp.set_pasv(True) # 被动模式,穿透常见 NAT 更稳 with open(local_path, "rb") as f: def _reader(): nonlocal sent, last_pct while True: buf = f.read(chunk_size) if not buf: break sent += len(buf) pct = int(sent * 100 / total) if total else 100 # 节流:百分比变化才回调,避免刷爆 UI if progress_cb and pct != last_pct: last_pct = pct progress_cb(sent, total, pct) yield buf ftp.storbinary(f"STOR {remote_path}", _reader(), blocksize=chunk_size) ftp.quit() return sent逻辑说明:storbinary接受一个可迭代对象作为数据源,ftplib 内部会按blocksize从迭代器取数据写入数据连接。我们把文件读取包成生成器_reader,每 yield 一块之前先累加sent并触发回调,这样进度计算和实际发送是同一份数据流,不会出现“进度跑得比传输快”的错位。参数上,chunk_size同时传给f.read和blocksize,保证读多少写多少;set_pasv(True)是绝大多数企业网络下的默认选择,主动模式在客户端有防火墙时经常连不上数据端口。progress_cb签名是(已传字节, 总字节, 百分比),界面层拿这三个值去更新进度条和剩余时间估算即可。
3. 把进度接到界面上:回调、线程与剩余时间估算
3.1 回调不能直接碰 UI 控件
不管用 Tkinter、PyQt 还是 Web 前端,上传都必须放在工作线程里跑,回调里只做“投递消息”,不直接改控件。Tkinter 的经典翻车是:在工作线程里直接progressbar['value'] = pct,轻则界面卡顿,重则抛RuntimeError: main thread is not in main loop。正确做法是用队列或after轮询把进度值转回主线程。
import queue import threading progress_q = queue.Queue() def on_progress(sent, total, pct): # 工作线程只投递,不碰控件 progress_q.put((sent, total, pct)) def start_upload(...): t = threading.Thread( target=upload_with_progress, args=(host, user, pwd, local, remote), kwargs={"progress_cb": on_progress}, daemon=True, ) t.start() poll_progress() def poll_progress(): try: while True: sent, total, pct = progress_q.get_nowait() # 这里才在主线程更新进度条 bar["value"] = pct label["text"] = f"{sent}/{total} 字节" except queue.Empty: pass root.after(100, poll_progress) # 每 100ms 拉一次这段的关键是职责分离:上传线程只管算和投递,主线程每 100ms 拉一次队列并刷新控件。daemon=True保证窗口关闭时线程不会把进程吊住。after的 100ms 与前面回调节流阈值呼应,两层节流叠加后界面既平滑又不抢 CPU。
3.2 剩余时间怎么估才不跳
剩余时间直接用“剩余字节 / 瞬时速度”会剧烈抖动,因为瞬时速度受网络波动影响大。稳妥做法是维护一个滑动窗口,取最近 5~10 次回调的平均速度,再算 ETA。窗口太小抖动,太大反应迟钝,5 次左右是常见折中。
from collections import deque import time class EtaEstimator: def __init__(self, window=5): self.samples = deque(maxlen=window) self.last_t = time.time() self.last_sent = 0 def update(self, sent, total): now = time.time() dt = now - self.last_t if dt > 0: speed = (sent - self.last_sent) / dt # 字节/秒 self.samples.append(speed) self.last_t, self.last_sent = now, sent if not self.samples or total <= sent: return 0 avg = sum(self.samples) / len(self.samples) return int((total - sent) / avg) if avg > 0 else -1update每次回调调一次,返回剩余秒数,-1表示速度还没测出来(刚开始那一两秒)。界面层把秒数格式化成mm:ss即可。注意last_sent初值为 0,第一次调用时sent - 0就是已传量,配合dt得到首段速度,逻辑自洽。
3.3 命令行场景的进度输出
不是所有上传都跑在 GUI 里。批量脚本里更常见的是往终端打一行不断刷新的进度。用\r回车覆盖同一行即可,注意别用print换行,否则日志会被刷屏。
import sys def cli_progress(sent, total, pct): bar_len = 30 filled = int(bar_len * pct / 100) bar = "=" * filled + "-" * (bar_len - filled) sys.stdout.write(f"\r[{bar}] {pct:3d}% {sent}/{total}") sys.stdout.flush() if sent >= total: sys.stdout.write("\n")\r把光标拉回行首,下一次写覆盖旧内容;flush强制刷新缓冲区,否则重定向到文件时看不到实时进度。传输完成补一个换行,避免后续日志粘在进度条后面。
4. 避坑与排查:上传进度条最容易翻车的五个地方
4.1 进度到 100% 但文件不完整
现象:进度条走满,服务器上的文件却比本地小一截。原因多半是storbinary的blocksize和生成器每次 yield 的块大小不一致,或者生成器提前break。ftplib 会按blocksize反复调用迭代器,如果某次返回的块小于blocksize,它仍会照发,但如果你在生成器里自己做了截断逻辑就会丢数据。解决:生成器只负责“读到多少 yield 多少”,不做任何长度裁剪,blocksize与chunk_size保持一致,传输结束后用ftp.size(remote_path)和本地大小对账。
4.2 被动模式连不上数据端口
现象:登录成功、命令也发了,卡在数据传输阶段超时。原因:服务器返回的被动端口被中间防火墙拦了,或者服务器配置的被动端口范围没放开。解决:先确认set_pasv(True);若仍失败,让服务端把被动端口范围固定在一个已放行的区间,客户端侧不要自作主张改主动模式——主动模式要求服务器回连客户端,在客户端有 NAT 时几乎必挂。
4.3 中文文件名乱码
现象:上传成功,服务器上文件名变成问号或乱码。原因:FTP 协议历史上用 Latin-1 传路径,而现代系统是 UTF-8。解决:连接后发OPTS UTF8 ON(部分服务器支持),或在storbinary前对远端路径做编码处理。更稳的做法是统一用英文或拼音命名远端文件,中文名只保留在本地,从源头绕开编码差异。
ftp = ftplib.FTP(host, timeout=60) ftp.login(user, pwd) try: ftp.sendcmd("OPTS UTF8 ON") # 服务器不支持会抛错,捕获即可 except ftplib.error_perm: pass ftp.encoding = "utf-8"ftp.encoding设为 utf-8 后,ftplib 内部编解码路径都走 UTF-8;OPTS UTF8 ON是锦上添花,不支持就跳过,不影响主流程。
4.4 大文件传到一半连接被重置
现象:几 GB 的文件传到某个百分比就断,重试又从零开始。原因:服务器或中间设备有空闲超时,长时间单连接被掐;也可能是客户端 socket 没设超时,卡死后一直挂着。解决:给FTP构造传timeout,并在业务层实现断点续传——用REST命令告诉服务器从第 N 字节开始,本地文件seek到对应位置再读。续传前先用ftp.size拿到远端已有大小作为起点,别盲目从 0 重传。
4.5 回调里做重活拖慢传输
现象:加了进度条之后上传速度明显下降。原因:回调里做了字符串拼接、日志落盘、甚至同步网络请求,每块都执行一次,累积开销可观。解决:回调只做“存值 + 投递”,格式化、写日志、算 ETA 全部挪到主线程的轮询里做。前面 3.1 的队列方案就是为此设计的,工作线程的回调体应该短到只有一次put。
5. 进阶:断点续传与校验闭环,让进度条说真话
进度条走到 100% 只是“客户端发完了”,不等于“服务器收对了”。我一般会在传输结束后补两步:先ftp.size比对字节数,再对关键文件做一次哈希校验(小文件用 MD5,大文件用分块 SHA-256,避免一次性读进内存)。这两步做完,进度条才有资格显示“完成”而不是“已发送”。
断点续传的实现要点在于REST与本地seek的配合。下面是一个可复用的续传片段:
import hashlib def resume_upload(ftp, local_path, remote_path, chunk_size=32 * 1024): local_size = os.path.getsize(local_path) try: remote_size = ftp.size(remote_path) or 0 except ftplib.error_perm: remote_size = 0 # 远端不存在,从 0 开始 if remote_size >= local_size: return remote_size # 已传完或远端更大,跳过 with open(local_path, "rb") as f: f.seek(remote_size) # 本地跳到断点 ftp.storbinary( f"STOR {remote_path}", f, blocksize=chunk_size, rest=remote_size, # 告诉服务器从断点追加 ) return ftp.size(remote_path) def sha256_of(path, chunk=1024 * 1024): h = hashlib.sha256() with open(path, "rb") as f: for block in iter(lambda: f.read(chunk), b""): h.update(block) return h.hexdigest()rest参数是 ftplib 对REST命令的封装,服务器收到后会把写入位置移到指定偏移;本地seek保证读出的数据正好接在远端已有内容之后,两边偏移必须严格相等,差一个字节文件就废了。sha256_of分块读,1MB 一块,内存占用恒定,适合大文件。校验时远端文件得先下载回来算,或者让服务端提供校验接口——前者费流量,后者看服务器能力,内网场景直接下载回算最省心。
| 校验方式 | 适用场景 | 代价 |
|---|---|---|
| 仅比对 size | 内网、可信链路 | 最低,但抓不出内容损坏 |
| MD5 | 中小文件、快速对账 | 低,有碰撞风险但工程够用 |
| 分块 SHA-256 | 大文件、跨公网 | 中,CPU 稍高 |
从那以后我每次写上传模块,都强制走一遍“传完必对 size、关键文件必算哈希”的流程,进度条只负责过程可视,结果可信靠校验兜底。希望这套拆法帮到你,少在“看起来传完了”这件事上栽跟头。
本文还有配套的精品资源,点击获取