☰
带进度条的FTP上传实例:被动模式、断点续传与SIZE校验
2026/11/2 21:30:41 网站建设 项目流程

简介:面向需要为桌面端文件传输增加可视化反馈的开发者,这份 C# WinForms 工程实例围绕 FTP 上传的进度条实现展开,重点解决上传过程不可见、交互体验差的问题,适合具备一定 C# 基础、希望把 FTP 连接与文件上传封装复用的读者参考。压缩包共 35 个文件、约 78KB,以 C# 源码、WinForms 窗体资源与配置文件为主,辅以解决方案、项目文件和少量编译产物,结构紧凑,便于直接打开调试。已有 869 人学习。实例将连接登录、目录切换、STOR 上传及回调进度计算串成完整流程,并借助独立的上传帮助类降低耦合;读者可从中理解已传字节与总字节的比例换算、进度条刷新时机,以及异常处理和重试策略的扩展位置,进而迁移到自己的文件传输模块中。

1. 进度条不是装饰:FTP 上传实例里最容易被低估的三个变量

FTP 文件上传下载这件事,最怕的不是协议老,而是把进度条做成了摆设。很多实例把storbinary的 callback 一挂,控制台刷出一行百分比就收工,真到跨网段、大文件、断点续传的场景,进度条会先冲到 100%,然后卡住,最后报一个 550 或连接重置。带进度条的 FTP 上传实例,核心不在画那根条,而在三个变量:数据连接由谁发起、进度分子分母怎么取、传完之后拿什么校验。这篇按一个可运行的单文件脚本拆开讲,适合正在做内网文件同步、构建产物分发、设备日志回传的从业者,也适合刚接触 ftplib 想少走弯路的新手。

2. 连接与传输模式:为什么被动模式能救回九成卡死

FTP 跟普通 HTTP 上传不一样,它同时开两条连接:一条控制连接传命令,一条数据连接传文件。进度条卡在 0% 或卡在150 Opening data connection,大半是数据连接没建起来,而不是storbinary写错了。先把模式选对,再谈回调。

模式数据连接发起方适用场景常见故障
主动模式 PORT服务器连客户端客户端与服务器同网段、客户端无入站限制客户端防火墙拦截服务器入站连接
被动模式 PASV客户端连服务器跨网段、云主机、NAT 后面服务器被动端口范围未放行

2.1 主动模式与被动模式:先看懂数据连接是谁发起的

主动模式下,客户端告诉服务器「你来连我这个 IP 和端口」,服务器从 20 端口发起数据连接。办公室里同网段测试往往没问题,一旦客户端在 NAT 或防火墙后面,服务器连不进来,控制连接还活着,数据连接一直挂着,表现就是命令发出去了、进度条不动。被动模式反过来,服务器返回一个临时端口,客户端主动去连,只要服务器侧把被动端口范围放行,跨网段成功率会高很多。

我一般默认set_pasv(True),只有在明确知道客户端有公网入站、且服务器侧限制被动端口时才切主动模式。切换不是改一行代码就完事,服务器侧的pasv_min_port、pasv_max_port和防火墙规则要一起看。如果实例跑在内网测试机,被动模式仍然卡住,先抓包看客户端有没有往服务器返回的端口发 SYN,没有就是客户端侧出站被拦,有 SYN 但没回应就是服务器侧被动端口没开。

2.2 用 ftplib 建一条可复用的控制连接

下面这段是后面所有上传动作的地基。注意encoding要早于任何文件名交互设置,TYPE I要显式发一次,长传时给底层 socket 开 keepalive。

import socket from ftplib import FTP def open_ftp(host, port=21, user='anonymous', password='', passive=True, timeout=30): ftp = FTP() ftp.encoding = 'utf-8' # 控制连接的文件名解码,影响 NLST/LIST/STOR ftp.connect(host, port, timeout=timeout) ftp.login(user, password) ftp.set_pasv(passive) ftp.voidcmd('TYPE I') # 二进制模式,避免换行转换 ftp.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) return ftp

timeout是 socket 级超时,不是整个文件传完的总时长,传大文件时不要把它当成总超时来调。set_pasv(True)放在登录之后、传文件之前,顺序不要乱。TYPE I很关键,文本模式下某些服务器会把\n转成\r\n,文件大小和哈希都会变,进度条也会跟真实字节数对不上。SO_KEEPALIVE是给控制连接保活用的,长时间传数据时控制连接空闲,NAT 设备可能先把它清掉,表现是传到一半突然421或连接重置。

2.3 二进制模式、编码与远端目录:三个参数定生死

远端目录不要用cwd一层层切。上传时用绝对路径STOR /upload/release/app.tar.gz,代码里只负责把目录建出来。这样断点续传、重试、并发上传都不会互相踩当前工作目录。

from ftplib import error_perm def ensure_remote_dir(ftp, remote_dir): if not remote_dir or remote_dir in ('.', '/'): return current = '' for part in remote_dir.replace('\\', '/').split('/'): if not part: continue current = f'{current}/{part}' if current else part try: ftp.mkd(current) except error_perm as exc: # 550 通常是目录已存在或无权限,交给后续 STOR 暴露真实错误 if not str(exc).startswith('550'): raise

remote_dir用posixpath.dirname从远端路径切出来,不要用os.path.dirname,否则在 Windows 上会把/upload/release切成反斜杠路径,MKD直接失败。编码方面,ftp.encoding = 'utf-8'能覆盖大多数现代服务器;如果服务器返回的文件名是乱码,先确认服务器侧用的字符集,不要客户端一会儿 utf-8 一会儿 gbk 地试,最后连日志都读不懂。常见做法是统一 utf-8,老服务器确实只认 gbk 时,在配置里留一个encoding参数,而不是写死在代码里。

提示:TYPE I和encoding是两件事,前者管传输内容是否做换行转换,后者管控制连接上的文件名和目录名怎么解码,不要混在一起调。

3. 分块与回调:把上传进度做成可观测的数字

进度条的本质是一个除法:已发送字节数除以文件总字节数。分母好拿,os.path.getsize就行;分子容易出错,因为「读了多少」「发给内核多少」「服务器落盘多少」是三件事。storbinary的 callback 给的是每次发送的块,已经过sendall,比文件指针tell()更接近真实发送进度,但它仍然不代表服务器已经写完。

3.1 进度条的分子分母:total 从哪来、done 该信谁

ftplib的上传入口签名大致是这样:

ftp.storbinary(cmd, fp, blocksize=8192, callback=None, rest=None)

callback每发完一个块调用一次,参数是bytes。所以最小可用的进度统计就是累加len(block)。块大小默认 8192,实际用 64KB 更常见:太小回调太频繁,控制台刷新开销会拖慢上传;太大进度条一跳一大截,看起来像卡住。不要用fp.tell()做分子,storbinary内部读文件和发送不是严格同步的,而且断点续传时文件指针已经从偏移处开始,直接拿tell()会把偏移重复计算。

一个不带界面的最小统计如下,先跑通再谈 UI:

import os total = os.path.getsize(local_path) done = 0 def on_block(block): global done done += len(block) print(f'\r{done}/{total}', end='', flush=True) with open(local_path, 'rb') as fp: ftp.storbinary(f'STOR {remote_path}', fp, blocksize=64 * 1024, callback=on_block)

这段代码能验证回调是否按预期触发,但它没有节流,也没有处理断点偏移。真实场景下每 64KB 刷一次屏,几十万次write会明显拖慢速度,所以下一步要把刷新频率压下来。

3.2 一个带节流和断点偏移的回调实现

下面这个Progress类可以直接抄。它做三件事:按时间间隔节流刷新、把断点偏移算进初始进度、在完成时换行避免日志粘在一起。

import sys import time def fmt_bytes(n): for unit in ('B', 'KB', 'MB', 'GB'): if n < 1024 or unit == 'GB': return f'{n:.1f}{unit}' n /= 1024 class Progress: def __init__(self, total, offset=0, stream=sys.stdout, min_interval=0.1): self.total = max(total, 1) self.offset = offset self.done = offset self.start = time.monotonic() self.stream = stream self.min_interval = min_interval self.last = 0.0 self.width = 30 def __call__(self, block): self.done += len(block) now = time.monotonic() if now - self.last < self.min_interval and self.done < self.total: return self.last = now pct = min(self.done * 100.0 / self.total, 100.0) filled = int(self.width * pct / 100) bar = '#' * filled + '-' * (self.width - filled) moved = max(self.done - self.offset, 0) speed = moved / max(now - self.start, 1e-6) self.stream.write( f'\r[{bar}] {pct:6.2f}% ' f'{fmt_bytes(self.done)}/{fmt_bytes(self.total)} ' f'{fmt_bytes(speed)}/s' ) self.stream.flush() if self.done >= self.total: self.stream.write('\n')

offset是断点续传时远端已有的字节数,done从它开始累加,进度条不会从 0 重新走一遍。速度用done - offset除以耗时,否则续传时会把之前已经传完的字节也算进本次速度,数字会虚高。min_interval默认 0.1 秒,局域网可以调到 0.05,跨公网可以调到 0.2,目的是让回调里的输出不成为瓶颈。fmt_bytes只做展示,不参与计算,别在回调里做字符串拼接之外的耗时操作,比如每块写数据库、每块发 HTTP 请求,都会把上传拖慢。

3.3 把回调接到控制台、日志和上层事件

同一个进度回调可以同时喂给多个消费者。命令行要刷屏,日志要每 5% 记一条,Web 后端要把进度推给前端,这时候用一个小型多播器把回调分开,比在Progress里塞if干净。

class Fanout: def __init__(self, *callbacks): self.callbacks = callbacks def __call__(self, block): for cb in self.callbacks: cb(block) class PercentLogger: def __init__(self, total, step=5): self.total = max(total, 1) self.step = step self.next_mark = step def __call__(self, block): pct = self.done * 100.0 / self.total if hasattr(self, 'done') else 0

上面这个PercentLogger只是示意结构,真实实现要自己累加done并在跨过next_mark时写日志。更简单的做法是把Progress的__call__拆成「累计」和「渲染」两步,日志消费者只读累计值。Web 场景要注意:浏览器不能直接连 FTP,常见做法是后端进程执行上传,把进度通过 SSE 或 WebSocket 推给前端,前端只负责渲染。

const es = new EventSource('/api/upload/progress?task=abc'); es.onmessage = (event) => { const p = JSON.parse(event.data); bar.style.width = `${p.percent}%`; label.textContent = `${p.done}/${p.total}`; };

这段前端代码消费的是后端事件,不是 FTP 连接本身。进度事件里至少带done、total、percent、speed四个字段,前端不要自己算百分比,否则断点续传和重试时两边口径容易打架。

4. 完整实例落地:命令行上传器的参数、目录与验证

把前面的连接、目录创建、进度回调拼起来,就是一个可用的命令行上传器。参数不要贪多,先把 host、user、local、remote、resume、timeout、block 这几个固定下来,剩下的用配置文件或环境变量补。

4.1 命令行参数与配置文件

参数默认值作用调整建议
--host无,必填FTP 服务器地址先用 IP 排除 DNS 干扰
--port21控制连接端口非标准端口才改
--useranonymous登录用户生产环境用独立账号
--password空登录密码优先读环境变量,不写进命令历史
--activeFalse切主动模式仅在确认客户端有入站时使用
--resumeFalse断点续传远端文件可能被改过时不要开
--timeout30socket 超时秒数跨公网可到 60,不要当总时长
--block65536上传块大小高延迟链路可降到 32768
import argparse def build_parser(): p = argparse.ArgumentParser(description='FTP upload with progress') p.add_argument('local') p.add_argument('remote') p.add_argument('--host', required=True) p.add_argument('--port', type=int, default=21) p.add_argument('--user', default='anonymous') p.add_argument('--password', default='') p.add_argument('--active', action='store_true') p.add_argument('--resume', action='store_true') p.add_argument('--timeout', type=float, default=30) p.add_argument('--block', type=int, default=64 * 1024) return p

local和remote用位置参数,复制命令时不容易漏。--active做成开关而不是--passive,因为默认被动更常用,开关只在排错时打开。密码不建议直接写在命令行,bash 历史会留痕,用export FTP_PASSWORD='***'再在代码里读环境变量更稳妥。

4.2 主流程:连接、建目录、上传、校验

主流程里最容易被忽略的是「传完之后校验」。storbinary正常返回只代表数据连接关掉了,不代表服务器上的文件大小一定对。用SIZE对比一次,成本很低,能挡掉大部分半截文件。

import os import posixpath from ftplib import error_perm def upload(args): local = os.path.abspath(args.local) total = os.path.getsize(local) remote = args.remote.replace('\\', '/') remote_dir = posixpath.dirname(remote) ftp = open_ftp( host=args.host, port=args.port, user=args.user, password=args.password, passive=not args.active, timeout=args.timeout ) try: ensure_remote_dir(ftp, remote_dir) offset = 0 if args.resume: try: remote_size = ftp.size(remote) offset = remote_size or 0 except error_perm: offset = 0 if offset > total: offset = 0 # 远端比本地还大,不能续,直接重传 progress = Progress(total, offset=offset) with open(local, 'rb') as fp: if offset: fp.seek(offset) ftp.storbinary( f'STOR {remote}', fp, blocksize=args.block, callback=progress, rest=offset or None ) remote_size = ftp.size(remote) if remote_size is not None and remote_size != total: raise RuntimeError(f'size mismatch local={total} remote={remote_size}') finally: try: ftp.quit() except Exception: ftp.close()

posixpath.dirname处理远端路径,避免 Windows 反斜杠混进来。rest=offset or None是为了在偏移为 0 时不发REST 0,有些服务器对REST 0的处理并不一致。ftp.size(remote)要在TYPE I下调用,文本模式下返回值可能不可靠。最后quit()放在finally里,正常结束发QUIT,异常时退化为close(),不要两个都无条件调用,否则容易在日志里看到无关的421。

4.3 运行验证与失败重跑

先拿一个几十 MB 的构建产物跑通,再换大文件。命令里的地址用文档保留段,换成自己的服务器地址即可。

export FTP_PASSWORD='***' python uploader.py ./build/app.tar.gz /upload/release/app.tar.gz \ --host 192.0.2.10 --port 21 --user deploy --password "$FTP_PASSWORD" \ --resume --block 65536

跑完之后做三件事:看进度条是否在 100% 后换行、看远端SIZE是否等于本地字节数、看日志里有没有size mismatch。如果中途断掉,重跑同一条命令,--resume会先查远端大小,从已有字节处续传。这里有一个边界:远端文件如果被别的任务覆盖过,SIZE只能证明大小,证明不了内容一致,重要产物建议下载回本地算一次哈希,或者至少比对修改时间。失败重跑时不要盲目删远端文件,先看SIZE和本地大小谁大谁小,远端大于本地时续传逻辑会直接重传,这是预期行为,不是 bug。

5. 避坑与排查:五个上传现场的翻车记录

FTP 上传的坑很集中,下面五条基本覆盖了「进度条不动」「进度条骗人」「传完不能用」三类现场。每条按现象、原因、解决写,照着排查比盲改代码快。

5.1 卡在 150 Opening data connection

现象:控制连接登录成功,STOR也发出去了,日志停在150 Opening data connection,进度条一直是 0,最后超时。

原因:数据连接没建起来。被动模式下客户端连不上服务器返回的被动端口,常见于服务器防火墙只放了 21,没放pasv_min_port到pasv_max_port;主动模式下则是服务器连客户端被拦。

解决:先用set_pasv(True),确认服务器被动端口范围,抓包看客户端有没有往那个端口发 SYN。如果客户端侧出站全放行仍然不通,再切主动模式,同时放行客户端入站端口。不要在同一台机器上反复改代码,先看网络层。

5.2 进度 100% 但文件不完整

现象:进度条走到 100%,脚本退出码也是 0,远端文件大小却比本地小一截,或者解压报错。

原因:回调统计的是交给 socket 的字节,不是服务器落盘字节;数据连接关闭时如果服务器侧异常,客户端可能只看到连接关闭。另一个常见原因是文本模式把换行转换了,字节数对不上。

解决:显式TYPE I,传完后用SIZE对比本地大小,不一致就报错退出而不是静默成功。对压缩包、二进制产物,最好加一步下载回校验或服务端校验。进度条 100% 只代表「发完了」,不代表「传对了」。

5.3 中文文件名乱码

现象:本地文件名正常,远端列表里变成问号或乱码,下载回来名字对不上。

原因:控制连接编码和服务器不一致。客户端用 utf-8,服务器按 gbk 解释,或者反过来。

解决:ftp.encoding统一设 utf-8,确认服务器侧字符集;老服务器只认 gbk 时,把编码做成参数,不要在一个进程里反复切。文件名实在不可控时,远端用 ASCII 别名(比如时间戳加哈希),把原始文件名写进日志或伴随的清单文件,别在 FTP 协议层跟编码较劲。

5.4 大文件传到一半连接重置

现象:小文件正常,几百 MB 以上传到某个百分比后报ConnectionResetError或421,重跑又能传一段。

原因:控制连接长时间空闲被 NAT 或防火墙清掉;或者链路 MTU 有问题,大块数据触发分片;也可能是服务器侧有传输时长限制。

解决:给控制连接开SO_KEEPALIVE,把timeout调到 60 左右,块大小从 64KB 降到 32KB 试一次。仍不稳定就分卷上传,每卷几十 MB,传完一卷校验一卷。不要一味加大块大小,块越大,单次写 socket 的时间越长,弱网下越容易触发中间设备超时。

5.5 REST 断点续传位置错

现象:开了--resume,续传后文件能大不能小,或者内容前后错位,哈希对不上。

原因:远端文件被别的任务改过,SIZE只能说明长度,不能说明内容;或者本地文件在两次上传之间被重新构建,偏移已经失效。

解决:续传前记录本地文件的大小、修改时间或哈希,跟远端SIZE一起判断;远端大小大于本地时直接重传,不要续。构建产物这类每次都会变的文件,默认关闭续传,只有日志、备份这类追加型文件才开。续传是后悔药,不是默认路径。

6. 从能跑到好用:校验、重试与限速的三个进阶动作

上传器能跑之后,下一步不是加界面,而是让它可观测、可重试、可控速。校验放在上传后,SIZE对比是底线,重要文件再加一步远端下载回算哈希,代价是带宽,收益是能发现「大小对但内容坏」的极端情况。重试不要写成无限循环,按错误类型分:连接类错误重试三次,每次退避 2 秒、4 秒、8 秒;权限类和路径类错误直接失败,重试只会刷日志。

限速用一层文件读取包装就能做,不需要改storbinary:

import time class RateLimitedReader: def __init__(self, fp, rate_bps): self.fp = fp self.rate = rate_bps self.last = time.monotonic() def read(self, size): data = self.fp.read(size) if data and self.rate: expect = len(data) / self.rate elapsed = time.monotonic() - self.last if elapsed < expect: time.sleep(expect - elapsed) self.last = time.monotonic() return data

把open(local, 'rb')换成RateLimitedReader(open(local, 'rb'), 2 * 1024 * 1024),就是 2MB/s 的限速。注意包装对象只实现了read,storbinary够用,但如果后面要seek做断点,得把seek、tell一并代理,否则续传会报AttributeError。并发上传时控制连接数要克制,一条连接传一个文件,别在一个FTP对象上开线程同时STOR,FTP 控制连接不是线程安全的。

进阶项建议值说明
校验SIZE必做,哈希选做大文件下载回校验成本高,按产物重要性选
重试次数3 次只对连接超时、重置类错误重试
退避2s / 4s / 8s不要固定间隔猛冲
限速按出口带宽的 70%留余量给控制连接和其他任务
日志粒度每 5% 一条太密会拖慢上传,太疏排错没线索

从那以后我每次给上传器加进度条,都先把 callback 的累计值和服务器SIZE对一遍,再谈界面好不好看。进度条可以骗人,字节数不会。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询