news 2026/11/2 3:37:56

带进度条的FTP上传下载实例:断点续传与失败重试封装

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
带进度条的FTP上传下载实例:断点续传与失败重试封装

简介:面向需要为文件传输加入可视化反馈的开发者,这份 FTP上传实例(带进度条)以 C# 窗体项目形式提供可运行的参考实现,重点解决连接服务器、登录验证、切换目录、分块上传以及按已传字节数实时刷新进度条等常见需求。压缩包共35个文件,以12个 cs 源码为主体,搭配 resx 界面资源、config 配置、sln 解决方案、csproj 项目文件及编译输出,整体约78KB,体量轻便,打开即可对照项目结构。代码中可见上传帮助类、主窗体与子窗体、程序入口等模块,适合拆解 FTP 连接、传输回调与进度显示之间的协作方式,也可将封装后的操作类迁移到自有工具或管理系统中复用。已有869人学习,适合具备一定 C# 基础、希望补齐 FTP 上传进度反馈与异常处理思路的开发者参考。

1. 从一张卡死的上传界面说起:带进度条的FTP实例定位

上个月同事丢过来一个需求:把本地一个 1.8GB 的日志备份包传到远端 FTP 服务器,界面点完“开始上传”就只剩转圈,半小时后用户以为程序死了,直接强杀进程,结果远端留下一个半截文件。问题不在 FTP 本身,而在缺少可视反馈——这就是 FTP 上传实例(带进度条)要解决的核心问题。它把文件上传下载的字节流切成可观测的块,每传完一块回调一次进度,让用户看得见“还在动”。这套东西适合三类人:需要给运维工具加传输模块的后端开发、要做桌面端文件同步的客户端开发,以及刚接触网络编程、想搞明白“文件是怎么一块一块发出去”的新手。技术栈上,这个实例绕不开 FTP 协议本身,常见做法是用标准库自带的 FTP 客户端能力,配合一个进度回调包装器,不依赖重型框架,几十行核心代码就能跑通上传、下载、断点续传三件事。

2. FTP 传输的底层机制:控制连接、数据连接与进度回调

2.1 控制连接与数据连接为什么是两条

FTP 和普通 HTTP 最大的区别,是它把“发指令”和“搬数据”拆成了两条独立的 TCP 连接。控制连接负责登录、切换目录、发送 STOR/RETR 这类命令,服务端回 220、331、150、226 这些状态码;数据连接专门用来传输文件内容,传完就关。这个设计在带进度条的场景里其实是个好事——控制连接上的状态码能告诉我们“传输已开始”“传输已结束”,数据连接上的字节流则用来数进度。如果只盯着控制连接等 226,中间过程是黑匣子,用户看不到任何进展;反过来,如果把进度挂在数据连接上,每读一个块就回调一次,界面就能平滑地动起来。

理解这一点很关键,因为后面所有的进度计算都发生在数据连接这一侧。控制连接只负责“开始”和“结束”两个锚点,中间过程全靠数据连接的块回调来填补。我见过有人把进度条挂在控制连接的回调上,结果发现它只会在 0 和 100 之间跳变,原因就是控制连接的响应粒度太粗,根本拿不到字节级的进展。

2.2 进度回调的本质:每次读完一个块就报一次数

文件传输在底层就是一个循环:从本地文件读固定大小的块,写进数据连接;或者从数据连接读块,写进本地文件。进度条要做的,是在每次循环结束时把“已传字节数”和“总字节数”算成百分比推给 UI。这件事听起来简单,但有两个参数会直接决定体验:块大小和回调频率。

块大小太小,比如 1KB,一次 1GB 文件要循环一百万次,回调频率过高,UI 线程会被刷屏拖慢;块大小太大,比如 8MB,进度条就会一格一格地跳,用户觉得卡顿。常见做法是 64KB 到 256KB 之间,既能让进度条每秒更新几十次,又不至于让回调成为瓶颈。回调函数本身也别做重活,只负责把数字丢给 UI 层,真正的进度条绘制交给主线程去合并刷新。

2.3 ftplib、ftputil 还是 SFTP:选型上的取舍

在 Python 生态里,标准库的 ftplib 是最轻的选择,没有额外依赖,storbinary 和 retrbinary 都提供了 callback 参数,虽然这个 callback 拿到的是数据块而不是进度数字,但足够我们包装出想要的回调。ftputil 在 ftplib 上做了一层文件对象抽象,用起来更像操作本地文件,但它的进度回调需要自己魔改,反而多一层不确定性。至于 SFTP,那是 SSH 协议族的东西,和 FTP 不是一回事,如果服务端只开了 21 端口,选型上就没得挑。

我一般会先确认服务端的协议和端口,再决定用哪套库。如果只是内网文件搬运,ftplib 足够;如果要做目录递归同步、权限保留,才考虑上 ftputil 或者自己封装一层。这个实例选择 ftplib,是因为它的行为最透明——你能清楚看到每一次 socket 读写发生在哪里,出问题也容易定位,不会被上层抽象遮住。

3. 上传实现:分块传输、进度计算与断点续传

3.1 用包装器给 storbinary 加上进度

ftplib 的 storbinary 签名是storbinary(cmd, fp, blocksize=8192, callback=None, rest=None),其中 fp 只需要提供read(blocksize)方法。它的内部循环是:读一块、发一块、调一次 callback。注意这个 callback 收到的是原始数据块,不是累计进度,所以直接拿它更新进度条会显示成“当前块大小”,不是百分比。解决办法是包一层文件对象,在 read 返回时累计字节数,再调用我们自己的进度回调。

# progress_ftp.py import os from ftplib import FTP class ProgressFile: """包装本地文件对象,在每次 read 时累计已传字节并回调""" def __init__(self, fp, total_size, callback=None): self.fp = fp self.total_size = total_size self.callback = callback self.uploaded = 0 def read(self, blocksize): buf = self.fp.read(blocksize) self.uploaded += len(buf) if self.callback: # 回调只传数字,UI 层自己去决定怎么画 self.callback(self.uploaded, self.total_size) return buf class FtpUploader: def __init__(self, host, user, passwd, port=21, timeout=30): self.host = host self.port = port self.user = user self.passwd = passwd self.timeout = timeout self.ftp = None def connect(self): self.ftp = FTP() self.ftp.connect(self.host, self.port, timeout=self.timeout) self.ftp.login(self.user, self.passwd) self.ftp.encoding = "utf-8" # 服务端如果是 latin-1,需要按实际情况改 self.ftp.set_pasv(True) # 绝大多数场景走被动模式 def upload(self, local_path, remote_path, callback=None, blocksize=64 * 1024): total_size = os.path.getsize(local_path) with open(local_path, "rb") as f: wrapped = ProgressFile(f, total_size, callback) # storbinary 内部会循环调用 wrapped.read(blocksize) self.ftp.storbinary( f"STOR {remote_path}", wrapped, blocksize=blocksize )

这段代码的关键在ProgressFile.read:每次 storbinary 向它要一块数据,它就去真实文件里读,读多少累计多少,然后回调(uploaded, total_size)。blocksize 传 64KB 是一个折中值,进度条大约每 16 次回调走完 1MB,视觉上足够连贯。self.ftp.encoding = "utf-8"这行要留意,很多老 FTP 服务端默认用 latin-1 处理文件名,传中文路径时如果不匹配,远端会出现乱码目录,后面避坑章会细说。

3.2 断点续传:SIZE、REST 与本地 seek 的三方对齐

断点续传的流程比完整上传多三步:先问服务端“这个文件现在多大”,再让本地文件指针跳到同样位置,最后在 STOR 命令前发一个 REST 命令告诉服务端“从第 N 字节开始接收”。ftplib 的 storbinary 支持 rest 参数,它会在传输前自动发 REST。注意,包装器的起始累计数要从远端已有大小开始算,否则进度条会从 0 跳到 40% 之类的位置。

def resume_upload(self, local_path, remote_path, callback=None, blocksize=64 * 1024): total_size = os.path.getsize(local_path) remote_size = 0 try: size = self.ftp.size(remote_path) remote_size = size if size else 0 except Exception: remote_size = 0 # 远端文件不存在,size 返回 None 或抛错 if remote_size >= total_size: # 远端已经完整,直接返回,避免重复传 if callback: callback(total_size, total_size) return with open(local_path, "rb") as f: f.seek(remote_size) # 本地跳到远端已有位置 wrapped = ProgressFile(f, total_size, callback) wrapped.uploaded = remote_size # 进度起点对齐 self.ftp.storbinary( f"STOR {remote_path}", wrapped, blocksize=blocksize, rest=remote_size, # 告诉服务端从偏移量开始 )

这里有个容易翻车的细节:ftp.size()在部分服务端上对不存在的文件会抛异常而不是返回 None,所以要用 try/except 兜住。另外rest=remote_size传的是字节偏移,不是块序号,别搞混。如果服务端不支持 REST 命令,storbinary 会直接报错,这时候只能退回完整上传,判断依据是捕获异常后清空远端文件重传。

3.3 缓冲区大小与回调频率的平衡

blocksize 的选择会直接影响进度条观感和 CPU 占用。太小,比如 8KB,一次 1GB 文件要回调十几万次,如果回调里还带 UI 刷新,主线程会被拖慢;太大,比如 1MB,进度条一次跳 0.1%,用户会觉得“怎么半天不动”。我一般会把默认值设成 64KB,再留一个参数让调用方按文件大小调整:小文件用 16KB 让进度更细腻,大文件用 256KB 减少回调次数。

还有一点,回调里不要做文件写盘或网络请求,只更新内存里的计数器。真正画进度条的地方用一个定时器每 100ms 拉一次最新值,这样即使回调很密集,UI 也不会被刷爆。这个模式在桌面端和 Web 后端都适用,本质上就是把“高频计数”和“低频渲染”解耦。

4. 下载实现:retrbinary、进度条与断点恢复

4.1 retrbinary 的块回调如何驱动进度条

下载侧的 API 是retrbinary(cmd, callback, blocksize=8192, rest=None),它的 callback 每收到一块数据就调用一次,拿到的是 bytes 块。和上传不同,这里不需要包装文件对象,直接在回调里写盘并累计字节数就行。总大小通过ftp.size(remote_path)提前问服务端要,拿不到就传 0,进度条退化成“已下载多少 MB”的计数模式。

def download(self, remote_path, local_path, callback=None, blocksize=64 * 1024): total_size = self.ftp.size(remote_path) or 0 downloaded = 0 with open(local_path, "wb") as f: def _write_block(block): nonlocal downloaded f.write(block) downloaded += len(block) if callback: callback(downloaded, total_size) self.ftp.retrbinary( f"RETR {remote_path}", _write_block, blocksize=blocksize )

nonlocal downloaded是 Python 闭包里修改外层变量的写法,别忘了。total_size用or 0兜底,是因为有些服务端对 SIZE 命令返回 None,直接参与除法会抛异常。回调里同步写盘是安全的,因为 retrbinary 本身就在当前线程顺序读 socket,写盘耗时会被算进块间隔里,只要磁盘不是特别慢,不会明显拖慢下载速度。

4.2 下载断点续传的两处偏移

下载的断点续传需要对齐两个偏移:本地文件已有大小、服务端 REST 的起始位置。逻辑是:先看本地文件多大,如果比远端总大小还大,说明本地文件是坏的,直接清空重下;否则以追加模式打开,把本地大小作为 rest 传给 retrbinary,同时在回调累计时从本地大小起算。

def resume_download(self, remote_path, local_path, callback=None, blocksize=64 * 1024): total_size = self.ftp.size(remote_path) or 0 local_size = os.path.getsize(local_path) if os.path.exists(local_path) else 0 if total_size and local_size > total_size: local_size = 0 open(local_path, "wb").close() # 本地比远端大,判定为脏文件 if total_size and local_size == total_size: if callback: callback(total_size, total_size) return downloaded = local_size with open(local_path, "ab") as f: def _write_block(block): nonlocal downloaded f.write(block) downloaded += len(block) if callback: callback(downloaded, total_size) self.ftp.retrbinary( f"RETR {remote_path}", _write_block, blocksize=blocksize, rest=local_size, )

注意文件打开模式是"ab"而不是"wb",否则会把已下载的部分清掉。rest=local_size只在 local_size 大于 0 时才真正生效,传 0 等价于从头下载,不会报错。有一个边界情况:如果服务端不支持 REST,retrbinary 会抛异常,这时候捕获后把 local_size 重置为 0 再调一次完整下载,别让用户卡在半截文件上。

4.3 大文件场景的流式落盘

所谓流式落盘,就是不要等整个文件下载完再写,而是每收到一块就写一块。retrbinary 的回调模式天然支持这一点,上面的代码已经是流式的。但有一个坑:如果用"wb"打开文件后又因为网络中断抛异常,文件会留下一个不完整的状态,下次续传时 local_size 就是实际收到的字节数,正好可以用来续传。所以不要在异常处理里删文件,保留半截文件反而是断点恢复的基础。

代价是磁盘上会短暂存在不完整文件,如果业务上不能接受,可以下载到.part临时文件,完成后再重命名。这个细节在文件同步工具里很常见,我在实际项目里也会默认用.part后缀,避免下游程序误读半成品。

5. 避坑与排查:FTP 上传下载最容易翻车的几个点

5.1 现象:进度条瞬间到 100% 但远端文件为空

这是最典型的一种翻车。现象是上传回调一路涨到 100%,但去服务端一看文件大小是 0,或者只有几 KB。原因通常有两个:一是控制连接上的 STOR 命令返回了 550 权限拒绝,但代码没有检查ftp.storbinary的返回值,回调仍然在本地读文件,看起来像传完了;二是数据连接实际上没建立成功,块被写进了错误的通道。

解决方式是每次传输后调用ftp.voidresp()或者检查 storbinary 的返回字符串,确认服务端回了 226。更稳妥的做法是在上传前先ftp.cwd(remote_dir)确认目录存在,再用ftp.size(remote_path)验证文件是否真的落地。如果返回 None 或异常,说明传输没生效,别被本地回调骗了。

5.2 现象:中文文件名乱码或无法创建

远端出现???.txt或者文件.txt这种名字,基本是编码不匹配。ftplib 默认 encoding 是 utf-8,但很多老服务端用 latin-1 或者 GBK 处理文件名。现象是本地叫“测试文件.txt”,传上去变成乱码,下载回来也对不上。

解决方式是先确认服务端的字符集,把ftp.encoding设成对应值。如果服务端混用,可以在连接后发OPTS UTF8 ON尝试开启 UTF-8 模式,再用一个中文名文件做小流量测试。测试通过再传正式文件,别拿生产数据去试编码。

5.3 现象:大文件传到一半断连

传几百 MB 以上文件时,连接在几分钟后断开,报EOFError或ConnectionResetError。原因可能是服务端有空闲超时,也可能是 NAT 或防火墙掐掉了长连接。被动模式下数据连接是服务端发起的,如果客户端在 NAT 后面,端口映射不稳定也会导致中断。

解决方式分两层:应用层加心跳,传输大文件时定期发NOOP命令保持控制连接活跃;传输层设置合理的 socket 超时,并在断连后从已传字节数继续续传,而不是从头再来。这就是前面断点续传代码的价值——中断不可怕,能接着传就行。

5.4 现象:断点续传后文件校验失败

续传完成后文件大小对得上,但 MD5 不一致。常见原因是本地文件在续传前被其他进程改过,或者远端文件在两次传输之间被替换,导致 REST 的偏移量和实际内容错位。还有一种情况是上传时用了文本模式而不是二进制模式,换行符被转换,字节数对不上。

解决方式是传输前记录文件大小和修改时间,续传时先比对远端 SIZE 和本地偏移是否一致;上传下载一律走二进制模式(storbinary 和 retrbinary 本身就是二进制),不要用 storlines。传完后做一次 MD5 或 SHA256 校验,校验失败就删掉远端文件重传,别在错位上继续追加。

6. 二次开发:把示例封装成可复用的 FtpClient

把上面的代码收进一个类之后,二次开发的空间主要在三个方向。第一是连接池化:FTP 控制连接是有状态的,不能像 HTTP 那样随便并发,但可以维护一个小池子,每个上传任务独占一条连接,用完归还。第二是进度回调的适配层:把callback(loaded, total)这种原始数字转成 WebSocket 消息、Qt 信号或者命令行进度条,让上层不关心底层是 FTP 还是别的协议。

第三是失败重试策略。我一般会把上传下载包在一个带指数退避的重试循环里,重试前先走一遍resume_upload或resume_download,利用已有的断点信息接着传,而不是无脑重来。下面这个骨架把连接、重试和进度串在一起:

import time class FtpClient: def __init__(self, host, user, passwd, port=21, retries=3): self.uploader = FtpUploader(host, user, passwd, port) self.retries = retries def upload_with_retry(self, local_path, remote_path, callback=None): last_err = None for attempt in range(self.retries): try: if self.uploader.ftp is None: self.uploader.connect() self.uploader.resume_upload(local_path, remote_path, callback) return True except Exception as e: last_err = e self.uploader.ftp = None # 连接已坏,下次重建 time.sleep(2 ** attempt) # 指数退避:1s, 2s, 4s raise last_err

这段骨架里,self.uploader.ftp = None是血泪经验——FTP 连接一旦抛异常基本就不可用了,必须重建,不能在上面继续发命令。time.sleep(2 ** attempt)做指数退避,避免服务端刚重启就被重试打满。回调函数透传给resume_upload,续传时进度会从已有偏移量接着走,用户看到的是“重连后从 60% 继续”,而不是“又回到 0%”。

验证方法我一般分三组:小文件(几 KB)验证路径和权限,大文件(几百 MB)验证进度和断点,断网场景验证重试和续传。断网测试不用真拔网线,在传输中途手动把self.uploader.ftp.sock.close()模拟异常即可。三组都过了,这套封装才敢放进生产工具里。从那以后我每次接文件传输需求,都会先把断点续传和失败重试跑一遍再谈 UI,因为进度条再好看,传断了接不上也是白搭。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 3:36:22

DBeaver连接达梦数据库实战:驱动配置、中文编码与Schema详解

1. 为什么是 DBeaver 达梦?这个组合在实际项目里真不是“凑合用”最近帮某高校实验室做数据平台迁移,他们原来用的是一套老旧的 Oracle 客户端工具,但新采购的国产化服务器预装了达梦数据库 DM8,要求所有开发、DBA、数据分析人员…

作者头像 李华
网站建设 2026/11/2 3:36:22

AnythingLLM踩坑实录:本地部署RAG知识库与智能体问答实践

做企业内部知识库问答工具选型的时候,我前后试过 LangChain 手搓、FastGPT、Dify,最后把 AnythingLLM 当成了主力方案。这个项目之所以打动我,核心在于它把“本地优先的 AI 智能体工具”这件事做得很完整:不需要把文档传到第三方平…

作者头像 李华
网站建设 2026/11/2 3:36:19

国产AI音乐生成工具实测:Kimi K3专业,Qwen 3.8 Max惊艳

上个月做个小项目,需要给两段风格截然不同的内容配背景音乐,一段走《星露谷物语》那种田园治愈风,一段走《指环王》那种史诗宏大风。平时都用国外那几家AI音乐工具,这次想试试国产AI的成色到底怎么样,结果一口气测了7家…

作者头像 李华
网站建设 2026/11/2 3:35:56

IndexTTS 2.0零样本声音克隆实战:3秒复刻任意音色

群里有人丢了一段音频过来,说Indextts2.0声音克隆优化版出来了,号称3秒复刻任意声音。我当时第一反应是不太信,毕竟这类“一句话克隆”的项目我这些年试过不少,多数是宣传大于实用。但实际跑了一遍之后,我承认这个版本…

作者头像 李华
网站建设 2026/11/2 3:35:56

Qt封装DLL供C#调用:纯C接口、P/Invoke与避坑指南

简介:围绕 Qt 动态库封装与 C# 跨语言调用,这套示例面向已具备 C/Qt 基础、需要在 .NET 项目中复用 Qt 界面或核心能力的开发者,适用于界面组件复用、算法模块封装等场景,重点解决 C 接口设计、DLL 导出、P/Invoke 声明与调用约定…

作者头像 李华
网站建设 2026/11/2 3:34:55

智能数据洞察平台v2.0:跨源融合、LOD与嵌入式BI落地实践

简介:《火山引擎智能数据洞察平台v2.0》PPT面向企业数据平台建设者、数据分析师与产品运营人员,系统梳理火山引擎智能数据洞察(eABI)的产品定位、能力矩阵与落地场景,帮助读者理解从数据接入、建模到报表与AI洞察的完整…

作者头像 李华