简介:这份压缩包面向需要在桌面程序中集成 FTP 文件上传功能的开发者,尤其适合使用 .NET/WinForms 完成课程设计、内部工具或小型客户端项目的学习者。它围绕带进度条的上传场景,提供可运行的工程结构与封装好的 FTP 操作帮助类,覆盖建立连接、登录验证、切换工作目录、分块传输、按已传字节比例刷新进度以及上传完成后释放连接等环节,并对网络延迟、权限限制和失败重试等常见问题留出可扩展思路。包内共 35 个文件,以 C# 源码为主,辅以窗体资源、resx 资源文件、config 配置、解决方案与项目文件,以及少量编译输出和缓存文件,整包约 78KB,体量轻便,便于直接打开阅读和二次改造。目前已有 869 人学习下载。读者可借助现成窗体与帮助类理解进度回调如何驱动界面更新,并将上传逻辑复用到自己的项目中,提升文件传输过程的可见性与交互体验。
1. FTP 上传带进度条:一个被低估的 4GB 传输事故
某公司的运维凌晨跑异地备份,一段 4GB 的镜像用 FTP 往上推,终端只剩一个光标在闪。二十分钟里没人知道是卡死了还是在传,只能 kill 掉重来,第二次还是同样的结局。拆开看,问题不在网络,在脚本把整个文件一次性交给 storbinary,中间没有任何反馈点。FTP 上传带进度条这件事,本质不是给界面加个装饰,而是把数据连接上的每一次读写切片暴露出来,让你能算出瞬时速度、判断是不是停滞,顺便把断点续传的口子留出来。这份实例面向手里已经有 FTP 服务器、需要把文件按时送上去的开发者,也适合第一次写上传脚本、被 425 和 553 磨过一遍的人。
2. 协议层先立住:两条连接、PASV 与 TYPE I 的选择理由
2.1 上传时的两条连接与时序
FTP 跟 HTTP 最大的结构差异,是它天生用两条 TCP 连接干活。一条叫控制连接,默认落在 21 端口,走的是明文行文本命令和三位数字应答,从登录一直挂到 quit;另一条叫数据连接,专门搬文件正文,每次传输临时建、传完就关。很多人第一次写上传脚本失败,就是没意识到自己同时在跟两条连接打交道,看到的报错其实是数据连接那边的事。
一次完整上传,控制连接上走的命令序列大致是这样:
# 用 curl 观察一次完整上传的命令与应答,-v 会把控制连接的内容全部打出来 curl -v -T ./backup_2024.tar.gz \ --ftp-pasv \ ftp://deploy:secret@10.10.20.30:21/upload/执行后往下翻,会看到USER deploy拿到 331、PASS ****拿到 230、TYPE I拿到 200、PASV拿到 227,然后是STOR /upload/backup_2024.tar.gz对应的 150,数据发完再补一个 226。这段输出值得反复看,因为它把「命令走控制连接、内容走数据连接」这件事摆在了明面上。150表示数据连接已经建好、可以开始灌数据,226表示服务端确认收完并落盘。中间那段没有任何应答,进度条要填的就是这段空白。
理解这个时序之后,进度条的位置就很清楚了:它必须挂在数据连接的写循环上,而不是挂在 STOR 命令前后。挂在命令前后只能告诉你「开始了」和「结束了」,中间那几十分钟仍然是黑匣子。
2.2 主动模式与被动模式怎么选
数据连接由谁发起,决定了 FTP 的两种工作模式。主动模式(PORT)是客户端告诉服务端「你来连我某个端口」,被动模式(PASV)是客户端问服务端「你开了哪个端口,我来连你」。前者在今天的网络环境里基本不可用:客户端大多在 NAT 或防火墙后面,服务端拿着客户端上报的私网地址回连,十有八九连不上,表现就是命令发出去了、数据连接迟迟建不起来,最后超时。
所以实际项目里几乎都是 PASV。ftplib 的FTP对象默认set_pasv(True),但显式写一行有好处——排查问题时你能确认自己到底跑在哪种模式下。
PASV 有一个流传很广的坑:227 应答里带的 IP 是服务端自己看到的地址,如果服务端在 NAT 后面,这个 IP 往往是10.x、172.16.x这类内网地址,客户端拿去连当然连不通。正确做法是忽略应答里的 IP,直接用控制连接对端那个地址去连。较新的 Python 在FTP构造函数里提供了trust_server_pasv_ipv4_address参数,默认False,也就是默认就帮你把返回的 IP 换掉了;如果你用的是老版本或者自己手写 socket,就得自己处理这一步。
还有一层是服务端的被动端口范围。PASV 每次协商出来的数据端口是随机的,服务端通常配置一个区间(比如 50000–50100),防火墙必须把这个区间放开。只开 21 端口的结果是:登录成功、列目录成功,一到上传就卡住,然后抛TimeoutError或者425 Can't open data connection。这个现象后面还会细说。
2.3 TYPE I 与文件类型:为什么二进制是默认答案
FTP 有两套传输类型:ASCII(TYPE A)和二进制(TYPE I)。ASCII 模式会做换行符转换,把本地的\n和服务端的\r\n互相改写。对文本文件来说这没问题,对压缩包、镜像、图片、视频来说就是灾难——传上去的文件大小对不上,解压直接报损坏,而且这种损坏是静默的,不报错。
ftplib 的storbinary在发 STOR 之前会自己发一条TYPE I,所以走这个接口基本不会踩到 ASCII 的坑。真正容易出事的是自己拼命令,或者用storlines去传非文本内容。storlines走的是 TYPE A,传二进制必然翻车。
另一个常被忽略的点是size命令。不少服务端在 ASCII 模式下返回的是「行数」而不是字节数,ftp.size(path)拿到的数字会小得离谱。所以调size之前先补一条TYPE I,这一步在做断点续传时是必须的,否则你算出来的偏移量是错的,续传上去的文件尾部会多出一段垃圾数据。
3. 用 ftplib 把进度条接进数据流:回调、分块与断点续传
3.1 storbinary 的 callback 到底喂进来什么
第一次写带进度条的上传,很多人会写成这样:
def cb(sent, total): print(sent, total)然后发现第一个参数根本不是累计字节数。ftplib 的storbinary内部逻辑是循环fp.read(blocksize)、conn.sendall(buf)、callback(buf),也就是说回调拿到的是这一块原始数据(bytes),不是进度值。累计量得自己维护。
这个设计其实更灵活:你既能算累计字节数,也能算这一块的校验和,还能在回调里做限速。代价是回调是同步执行的,跑在发送线程里,你在里面写日志、刷 GUI、查数据库,全部会直接拖慢发送速度。回调里干的事越重,上传越慢,这个因果关系要记住。
还有一个隐藏点:blocksize决定回调的调用频率。默认 8192 字节,传 4GB 要回调五十万次;改成 256KB,回调次数降到一万六。进度条的刷新粒度靠时间戳节流控制,不靠回调频率,所以块大小可以放心调大。
3.2 一份可直接跑的上传脚本
下面这份脚本把连接、上传、进度、续传拆开了,可以直接改常量跑:
import os import sys import time from ftplib import FTP, error_perm class FTPUploader: def __init__(self, host, port=21, user="anonymous", password="", timeout=60): self.ftp = FTP() self.ftp.encoding = "utf-8" # 控制连接命令按 UTF-8 编解码 self.ftp.connect(host, port, timeout=timeout) # timeout 同时作用于控制与数据连接 self.ftp.login(user, password) self.ftp.set_pasv(True) # 显式声明,排查时一眼能确认 try: self.ftp.sendcmd("OPTS UTF8 ON") # 部分服务端需要显式打开 except error_perm: pass # 不支持就直接跳过,不阻断流程 def ensure_dir(self, remote_dir): """逐级创建远端目录,已存在则跳过""" self.ftp.cwd("/") for part in [p for p in remote_dir.strip("/").split("/") if p]: try: self.ftp.cwd(part) except error_perm: self.ftp.mkd(part) self.ftp.cwd(part) def remote_size(self, remote_path): """取远端文件字节数,取不到返回 None""" try: self.ftp.sendcmd("TYPE I") # size 在 ASCII 模式下不可信 return self.ftp.size(remote_path) except error_perm: return None def upload(self, local_path, remote_path, blocksize=256 * 1024, callback=None, rest=None): total = os.path.getsize(local_path) sent = rest or 0 with open(local_path, "rb") as fp: # 必须二进制打开 if rest: fp.seek(rest) # 本地指针跳到断点位置 def _hook(chunk): nonlocal sent sent += len(chunk) # 回调给的是数据块,不是累计值 if callback: callback(sent, total) self.ftp.storbinary( "STOR " + remote_path, fp, blocksize=blocksize, callback=_hook, rest=rest, ) return sent def close(self): try: self.ftp.quit() # 正常发 QUIT,服务端才会释放会话 except Exception: self.ftp.close()几个参数值得单独说一下。blocksize默认给到 256KB,是因为内网千兆环境下小块带来的系统调用开销很明显,而 256KB 在大多数服务端的接收缓冲范围内,不会因为单次写入过大被截断;如果你的服务端比较老,可以退到 64KB。rest是断点偏移量,传 None 就是从头传。timeout设 60 秒,是因为大文件在慢链路上偶尔会出现单块等待较久的情况,设太短会误杀正常传输,设太长又会让真故障拖很久才暴露。
ensure_dir之所以要逐级 cwd,是因为 FTP 的MKD不递归,一次性MKD /a/b/c在父目录不存在时会直接 550。
3.3 进度刷新的节流与 GUI 线程安全
终端里画进度条,用\r回到行首重写就行,关键是别每块都刷。4GB 文件按 256KB 分块,回调一万六千次,每次都sys.stdout.write会把终端拖成幻灯片。做法是加时间戳,200 毫秒内只刷一次:
def make_progress(label, width=36): state = {"last": 0.0, "t0": time.time()} def render(done, total): now = time.time() if now - state["last"] < 0.2 and done < total: return # 节流:200ms 内不重复渲染 state["last"] = now ratio = done / total if total else 0 filled = int(width * ratio) speed = done / max(now - state["t0"], 1e-6) / 1048576 bar = "#" * filled + "-" * (width - filled) sys.stdout.write( f"\r{label} [{bar}] {ratio * 100:6.2f}% " f"{done / 1048576:.1f}/{total / 1048576:.1f} MB {speed:.2f} MB/s" ) sys.stdout.flush() if done >= total: sys.stdout.write("\n") return renderstate["t0"]是起始时间,用来算平均速度;如果你想要瞬时速度,就在每次刷新的同时记一下上次的字节数和时间,用差值算。平均速度在判断「是不是整体偏慢」时更有用,瞬时速度适合看抖动。
GUI 版本要额外注意一件事:tkinter、PyQt 这类框架的控件只能在主线程操作,而回调跑在上传线程里。直接progressbar["value"] = ratio轻则界面不刷新,重则整个进程崩掉。常见做法是回调里往queue.Queue塞一条(done, total),主线程用after(100, poll)定时取。队列要设上限,比如maxsize=8,满了就丢,进度条少刷几帧没人看得出来,但队列无限增长会把内存吃掉。
3.4 断点续传:REST、APPE 与 size 对齐
断点续传这件事,说穿了就三步:问远端现在多大、把本地指针挪到那个位置、告诉服务端从哪儿开始接。
up = FTPUploader("10.10.20.30", user="deploy", password="secret") up.ensure_dir("/upload/2024") remote = "/upload/2024/backup_2024.tar.gz" offset = up.remote_size(remote) local_total = os.path.getsize("./backup_2024.tar.gz") if offset is None or offset == 0: # 远端没有这个文件,或者服务端不支持 SIZE,从头传 up.upload("./backup_2024.tar.gz", remote, callback=make_progress("upload")) elif offset < local_total: # 远端有半截,从断点续 up.upload("./backup_2024.tar.gz", remote, callback=make_progress("resume"), rest=offset) else: # 远端已经不小于本地,跳过 print("remote already complete, skip") up.close()remote_size返回 None 的情况有两种:文件不存在,或者服务端禁用了 SIZE 命令。这两种要分开处理,前者从头传,后者只能靠NLST拿列表再解析,或者干脆放弃续传、整文件重传。判断依据是异常类型:error_perm里带 550 通常是文件不存在,带 500 或 502 是命令不支持。
还有一种退化方案是APPE(追加)。它不需要传偏移量,服务端直接在文件末尾续写。看起来更省事,但风险在于:如果远端文件其实已经完整,APPE 会把整个文件再追加一遍,得到双倍大小。所以用 APPE 的前提是你对远端状态有绝对把握,比如传输中断后立刻重连,中间没有别的进程碰过这个文件。
不管用 REST 还是 APPE,传完都必须比对一次大小。ftp.size()拿到的字节数和os.path.getsize()一致,才算这次传输结束。差了哪怕一个字节,也说明中途有块丢了,得重来。
4. 多文件与并发上传:连接池、文件名编码与限速
4.1 一个线程一条连接,别共享 FTP 对象
单个文件传得动,一到批量就翻车,最常见的根因是把同一个FTP对象丢进了线程池。FTP实例是有状态的:当前工作目录、当前传输类型、数据连接的协商状态,全挂在实例上。两个线程同时发 STOR,控制连接上的命令会交错,应答对不上号,轻则 425,重则把 A 文件的内容写进 B 文件。
正确做法是每个任务建一条连接,用完关掉:
from concurrent.futures import ThreadPoolExecutor, as_completed HOST, USER, PWD = "10.10.20.30", "deploy", "secret" def upload_one(task): up = FTPUploader(HOST, user=USER, password=PWD) try: up.ensure_dir(task["remote_dir"]) remote = f'{task["remote_dir"]}/{task["name"]}' done = up.upload(task["local"], remote, callback=make_progress(task["name"])) return task["name"], done finally: up.close() # 无论成败都要释放会话 tasks = [{"local": p, "name": os.path.basename(p), "remote_dir": "/upload/2024"} for p in file_list] with ThreadPoolExecutor(max_workers=4) as pool: futures = [pool.submit(upload_one, t) for t in tasks] for f in as_completed(futures): name, size = f.result() print(f"{name} done, {size} bytes")max_workers取 3 到 5 比较稳妥。很多 FTP 服务端会限制同一 IP 的并发连接数(常见是 5 到 10),超过就直接拒连或者排队,表现是部分任务莫名其妙超时。并发数不是越大越快,链路带宽和磁盘 IO 才是天花板,4 条连接已经能吃掉大部分家用和办公带宽。
连接复用也有代价:一条连接连续传多个文件,中间要确保上一条传输已经收到 226,否则下一条 STOR 会撞上「上一次传输还没结束」。用storbinary是同步阻塞的,返回即代表收到 226,所以串行复用是安全的;但如果中间插了voidcmd之类的异步命令,就得自己getresp()收尾。
4.2 中文文件名:OPTS UTF8 ON 与服务端协商
英文名一路顺畅,换中文名就报 553,或者传上去了但文件名变成一串问号,这是编码没对齐。
FTP 协议本身最早只认 ASCII,中文名靠扩展。客户端这边要做的是把ftp.encoding设成服务端认的编码。绝大多数现代服务端认 UTF-8,设ftp.encoding = "utf-8"之后再补一条OPTS UTF8 ON,基本就通了。少数老服务端跑在 GBK 环境里,那就得设ftp.encoding = "gbk",而且OPTS UTF8 ON会返回 500,得吞掉这个异常。
判断服务端到底认哪种,最省事的办法是传一个测试文件再列出来看:
def detect_encoding(ftp, remote_dir="/"): """传一个中文名空文件,列目录看能不能原样读回来""" name = "编码测试.txt" ftp.cwd(remote_dir) ftp.storbinary("STOR " + name, __import__("io").BytesIO(b"x")) listed = ftp.nlst() ok = name in listed try: ftp.delete(name) except Exception: pass return ok列出来能对上,说明控制连接的编码是对的;列出来是乱码但对不上,说明服务端存的时候用的另一套编码。这一步别省,血泪经验是:本地测试全绿、上生产环境才发现服务端是 GBK,改一行编码配置比重传一遍数据便宜太多。
4.3 限速、超时与块大小的取值
限速不是为了折腾,是为了别把整条链路占满。上传脚本跑在办公网里,全速推几个 G,其他人的视频会议直接卡成 PPT,这种事发生过一次就够。
限速点在回调里最自然,因为回调本身就是每块的必经之路:
class Throttle: """按字节速率限速,回调里 sleep 会直接阻塞发送""" def __init__(self, bytes_per_sec): self.bps = bytes_per_sec self.t0 = time.time() def __call__(self, done, total): expect = done / self.bps actual = time.time() - self.t0 if actual < expect: time.sleep(expect - actual) throttle = Throttle(2 * 1024 * 1024) # 约 2MB/s progress = make_progress("upload") up.upload("./big.iso", "/upload/big.iso", callback=lambda d, t: (throttle(d, t), progress(d, t)))bytes_per_sec按你的上行带宽留三成余量来设。sleep放在回调里会阻塞发送线程,这正是限速生效的机制,但也意味着这段时间里控制连接完全没有动静。如果服务端的空闲超时设得比较短(有些默认 300 秒),长时间限速传输可能被服务端主动断开,所以限速值别压得太低。
超时统一在FTP(..., timeout=60)里设,它同时作用于控制连接和数据连接。设完之后要留意:大文件在慢链路上的单块等待时间可能超过这个值,表现是传到一半抛socket.timeout,所以限速场景下超时要相应放宽,或者干脆关掉限速再传。
5. 排查与避坑:五条踩过的记录
5.1 连接与登录阶段
第一条:登录成功、列目录成功,一上传就卡到超时。
现象是STOR发出去之后没有任何应答,等 60 秒抛TimeoutError,或者直接425 Can't open data connection。原因几乎都是数据连接建不起来:要么服务端的被动端口段没在防火墙上放开,只开了 21;要么 PASV 返回的 IP 是内网地址,客户端拿去连了一个根本不通的地址。解决分两步,先确认服务端配置的被动端口范围(常见 50000–50100),让网络那边整段放行;再确认客户端有没有忽略 PASV 返回的 IP,Python 新版默认会忽略,老代码要自己处理。
5.2 传输阶段
第二条:226 返回成功,但远端文件大小是 0 或者只有几 KB。
控制连接告诉你传完了,实际数据一个字节没落地。原因通常是文件对象用了文本模式打开,或者走了storlines传二进制内容,TYPE A 下的换行转换把内容搅乱了,某些服务端在遇到不合法的行结束符时直接丢弃缓冲。解决办法就一句:open(path, "rb")配storbinary,别用storlines传非文本。传完立刻用ftp.size()和本地getsize()对一次,对不上就重传,别信 226。
第三条:大文件传到一半断线,重连后从 0 开始,前面白传。
原因是脚本里根本没有续传逻辑,每次都open然后从头STOR,服务端收到 STOR 会把已有文件截断。修法是先TYPE I再size,拿到远端大小,本地seek过去,storbinary(..., rest=offset)。这里有个细节容易漏:rest传了之后,storbinary会在 STOR 之前发一条 REST,如果你的服务端不支持 REST,命令会返回 500,这时要么退回整文件重传,要么改用 APPE,但 APPE 之前必须确认远端确实是不完整状态。
5.3 文件名与并发
第四条:中文文件名报 553,或者传上去变成问号。
控制连接的编码和服务端不一致。FTP 命令是文本协议,文件名要按某种编码编成字节流发过去,客户端按 UTF-8 编、服务端按 GBK 解,落地就是乱码。修法是设ftp.encoding,再试OPTS UTF8 ON,用 4.2 那个检测函数跑一遍确认。别在本地测通了就上线,本地服务端和生产服务端很可能不是同一套配置。
第五条:单线程传得好好的,开到 4 线程就偶发 425 和响应错位。
多个线程共享了一个FTP实例。控制连接上的命令是串行的,两个线程同时发命令,应答就会串台,A 线程收到 B 线程的 226,状态全乱。改成一个线程一条连接,用完quit。另外并发数别贪,超过服务端同 IP 连接上限之后,表现是部分任务连不上,看起来像网络问题,其实是服务端在拒绝。
把这五条整理成一张对照表,排查时先对号入座:
| 现象 | 高概率原因 | 先查什么 |
|---|---|---|
| STOR 后超时、425 | 被动端口未放行 / PASV 返回内网 IP | 服务端被动端口段、客户端 PASV 处理 |
| 226 成功但大小为 0 | 文本模式打开 / 用了 storlines | 文件打开模式、调用的接口 |
| 断线后从头传 | 无 REST 续传逻辑 | 是否先 size 再 rest |
| 中文名 553 或乱码 | 控制连接编码不一致 | ftp.encoding与OPTS UTF8 ON |
| 并发时 425、应答错位 | 共享 FTP 对象 | 是否一线程一连接 |
6. 进阶:把进度条变成可观测的上传任务
6.1 传完必须校验,失败必须能重试
进度条跑到 100% 只代表数据发出去了,不代表服务端收好了。收尾这一步我一般固定做两件事:比对大小、失败重试。
def upload_with_verify(up, local, remote, retries=3): for i in range(retries): try: up.upload(local, remote, callback=make_progress(os.path.basename(local))) remote_sz = up.remote_size(remote) if remote_sz == os.path.getsize(local): return True print(f"size mismatch: {remote_sz} != {os.path.getsize(local)}, retry {i + 1}") except Exception as e: print(f"attempt {i + 1} failed: {e}") time.sleep(2 ** i) # 指数退避,别失败就立刻重连 return Falseretries给 3 次足够,退避用 1、2、4 秒。重试前不用手动清理远端,因为续传逻辑会自己算偏移量;但如果上一次失败在服务端留了个半截文件且大小刚好等于本地大小(极端情况),校验会误判成功,所以生产脚本里我还会补一条 MD5 比对,代价是多传一次校验和,换来的是不用半夜起来查数据。
6.2 一个技巧:把回调改成事件流
进度回调只传(done, total)有个局限:你没法在同一处拿到文件名、速度、剩余时间。改成一个轻量的事件对象,后面接日志、接监控、接告警都不用改上传逻辑。
from dataclasses import dataclass, field @dataclass class UploadEvent: name: str done: int total: int speed: float = 0.0 _t0: float = field(default_factory=time.time) def as_dict(self): return { "file": self.name, "percent": round(self.done / self.total * 100, 2) if self.total else 0, "speed_mb": round(self.speed / 1048576, 2), }回调里只做一件事:算出speed然后往事件总线推一条。终端、GUI、日志文件各自订阅,互不干扰。这么改的好处是上传器本身彻底不知道「展示」这回事,测试的时候塞一个假回调就能跑,不用起界面。
从那以后我每次写上传脚本,都强制先跑一遍几 KB 的小文件、再跑一遍 200MB 的中等文件,最后才放大文件;回调里必定带时间戳,超过 30 秒没有新块就直接断开重连,而不是干等超时。这套流程救过我不止一次,尤其是在服务端配置说不清楚的环境里。希望帮到你。
本文还有配套的精品资源,点击获取