
简介这是一份面向C#初学者的FTP文件上传示例工程聚焦在WinForms界面中实现带实时进度反馈的上传流程。示例围绕连接登录、目录切换、分块发送与进度计算展开并将FTP操作封装为可复用的帮助类便于读者理解上传回调、已传字节与总字节的比例换算以及异常与连接释放等处理思路。压缩包共35个文件以cs源代码、resx资源、config配置和sln/csproj工程文件为主另含exe、pdb等编译产物整体约78KB结构紧凑适合直接打开解决方案对照学习。目前已有869人学习下载可用于课堂示例、课程设计或小型工具开发中的上传模块参考帮助开发者把无进度提示的FTP上传改造成可视化交互并在此基础上扩展重试、错误提示和分块上传等策略。1. 带进度条的 FTP 上传能传完与看得见是两件事去年帮一家做生产报表的小团队收拾过一个上传脚本需求只有一句“每天把生成的 xlsx 推到文件服务器”代码十几行就写完了跑起来却天天有人问“传完没有”。原因很简单——卡在 90% 和卡在 9%在运维眼里长得一模一样。FTP 文件上传下载这套协议几十年没大改麻烦的从来不是STOR这条命令而是“进度”这个数字到底代表什么是本地读完了、内核缓冲区收下了还是对端真的落盘了这份 FTP 上传实例带进度条拆开看就三件事控制连接怎么建、进度怎么算、断了怎么接着传。适合手上有台 FTP 服务器、要写上传脚本、又不想为这点事上重型调度框架的后端和运维同学。2. FTP 上传的两条连接控制通道、数据通道与模式选择2.1 控制连接和数据连接一次上传要开两次门FTP 跟绝大多数应用层协议不一样的地方在于它用两条 TCP 连接干活。控制连接常年挂在 21 端口上从头到尾只跑文本命令和三位数响应码USER、PASS、CWD、TYPE、PASV、STOR都走这条线真正搬字节的数据连接是每次传输临时开一条传完就关。所以你在日志里看到“登录成功但一上传就超时”八成不是账号问题而是第二条连接根本没建起来——控制通道通了只证明 21 端口通不代表数据端口通。这个结构带来的第一个实操后果是任何一次上传的耗时都包含“建数据连接”的开销。如果你按文件循环上传几千个小文件每个文件都新开一条数据连接握手成本会比传数据本身还大。常见做法是把小文件打成 zip 再传或者在服务端支持的情况下用一条连接连续发多个STOR——后者省的是控制连接的登录开销数据连接照样一次一开别指望能复用。第二个后果是超时参数得设两层。connect(timeout)管的是控制连接建立数据连接的 socket 超时继承自同一个值但当数据连接卡在“已建立、对端不读”的状态时表现是storbinary长时间不返回而不是抛异常。这一点在第 5 章会具体讲。2.2 主动模式与被动模式看谁在 NAT 后面数据连接由谁发起就是主动与被动的全部区别。维度主动模式PORT被动模式PASV谁发起数据连接服务端回连客户端客户端连服务端客户端要求必须有服务端可达的地址和放行端口无特殊要求能出网即可服务端要求能出站到客户端高位端口开放一段固定的被动端口典型翻车点客户端在 NAT 后服务端连的是内网地址服务端防火墙没放行被动端口段判断方法很朴素客户端在办公网、家里、容器里一律用被动模式只有服务端和客户端在同一段可信内网、且客户端防火墙全关的调试场景主动模式才可能更省事。ftplib默认就是被动模式set_pasv(True)这个默认值是对的别去改。被动模式真正要盯的是服务端的端口段配置。服务端每接受一次PASV会在某个范围内随机开一个端口并在响应里告诉客户端“来连我这个 IP 和端口”。如果中间有防火墙只放行了 21那么控制连接一切正常、LIST也可能偶然成功有的服务端会把目录列表走控制连接缓存但一上传就报425 Cant open data connection。这时候要做的不是改代码是让服务端管理员把被动端口段固定下来并放行同时把对外通告的地址配成公网/映射后的地址否则客户端会拿到一个内网 IP 去连白等。2.3 建连的最小可用骨架先把手动建连这段跑通后面所有代码都挂在这上面。这段不长但每一行都有存在的理由。import ftplib HOST 192.0.2.10 # 文件服务器地址 PORT 21 USER uploader PWD change-me ftp ftplib.FTP() ftp.encoding utf-8 # 老版 Windows FTP 服务端常见需要改成 gbk ftp.connect(HOST, PORT, timeout15) # 不给 timeout卡死时会一直挂着 ftp.set_pasv(True) # 被动模式NAT/容器环境基本只能用这个 ftp.login(USER, PWD) ftp.voidcmd(TYPE I) # 显式声明二进制避免行尾被改写 print(ftp.getwelcome()) # 220 欢迎语排错第一现场逻辑说明connect只建立控制连接并读走 220 欢迎语login走USER/PASS正常返回 230voidcmd(TYPE I)把传输类型钉死在二进制。这一行的作用被严重低估——FTP 默认是 ASCII 模式服务端在 ASCII 模式下会做行尾转换传二进制文件直接损坏而且SIZE命令在很多服务端上会直接返回550 SIZE not allowed in ASCII mode。参数说明timeout15是控制连接的 socket 超时建议 10 到 30 秒之间太短会在网络抖动时误杀太长会让卡死场景排查变成盲等。encoding决定命令和响应里中文的编解码方式Python 3.9 之后默认是 UTF-8但服务端如果没开UTF8特性FEAT命令能查到中文文件名会往返乱码这时候要么改成gbk试探要么干脆用 ASCII 文件名加一张映射表后者更省心。提示排错时先把ftp.getwelcome()和每一步的响应码打出来。220是欢迎、331是等口令、230是登录成功、425是数据连接开不了、550是权限或路径问题这四个码能覆盖九成的连不上场景。3. 进度条的实现原理把 socket 的 send 变成可计数事件3.1 storbinary 的回调在什么时候被触发ftplib.FTP.storbinary(cmd, fp, blocksize8192, callbackNone, restNone)的内部循环大致是这样先发TYPE I然后发PASV拿地址、连数据 socket、发STOR 文件名接着进入一个while从文件对象fp里读blocksize字节sendall到数据 socket然后立刻调用callback(buf)直到读空关闭写方向最后读响应拿 226。关键点有三个。第一callback收到的是刚发出去的那一块原始字节不是累计值也不是百分比累计要你自己加。第二回调发生在sendall返回之后而sendall返回只代表数据进了本地内核发送缓冲区不代表对端应用已经落盘——所以进度条走到 100% 只是“本地交出去了”真正的完成信号是最后的 226 响应。第三blocksize默认 8192 太小同样传 1 GB8 KB 要循环十几万次每次一次系统调用加一次 Python 回调开销肉眼可见。blocksize的经验值局域网 64 KB 到 256 KB跨公网 32 KB 到 64 KB。调到 1 MB 以上在丢包环境里反而会拖慢重传窗口撑大了恢复代价。3.2 手写一个限频进度条自己写的好处是零依赖、显示内容可控坏处是所有边界都得自己兜。import os, sys, time import ftplib def upload_with_progress(ftp, local_path, remote_path, blocksize256 * 1024): total os.path.getsize(local_path) # 总大小必须取本地文件别信远端 sent 0 start time.monotonic() last_paint [0.0] def cb(chunk): nonlocal sent sent len(chunk) now time.monotonic() # 限频到 10Hz回调里做 IO 会直接拖慢传输 if now - last_paint[0] 0.1 and sent total: return last_paint[0] now ratio sent / total if total else 1.0 speed sent / max(now - start, 1e-6) / 1024 / 1024 bar # * int(ratio * 28) sys.stdout.write( f\r[{bar:28}] {ratio * 100:6.2f}% f{sent / 1048576:8.2f}/{total / 1048576:.2f} MB {speed:5.2f} MB/s ) sys.stdout.flush() with open(local_path, rb) as fp: ftp.storbinary(fSTOR {remote_path}, fp, blocksizeblocksize, callbackcb) sys.stdout.write(\n) return sent逻辑说明闭包里用nonlocal sent累加last_paint用列表是为了在闭包里可变限频判断里带and sent total保证最后一块一定会重绘到 100%否则进度条会停在 99.6% 那种尴尬位置。速度用单调时钟算别用time.time()系统时间被校准时会算出负速度。参数说明blocksize256*1024是局域网起步值公网建议降到 65536remote_path是相对当前工作目录的路径含子目录的话要么先ftp.cwd()要么直接写绝对路径注意部分服务端对绝对路径解释成“账号根目录下的路径”不是系统根total来自本地os.path.getsize文件在传输过程中被别的进程改写会导致比例失真生产脚本里应该先读文件头算一遍哈希再传传完比对。3.3 tqdm 版本与显示口径的取舍项目里已经有 tqdm 的话没必要重造轮子from tqdm import tqdm def upload_tqdm(ftp, local_path, remote_path, blocksize256 * 1024): total os.path.getsize(local_path) with open(local_path, rb) as fp, \ tqdm(totaltotal, unitB, unit_scaleTrue, desclocal_path.split(/)[-1], ncols90) as bar: def cb(chunk): bar.update(len(chunk)) ftp.storbinary(fSTOR {remote_path}, fp, blocksizeblocksize, callbackcb)total必须精确等于文件字节数否则 tqdm 会一直显示“未完成”或者提前跳满unit_scaleTrue让它自动换算 KB/MBncols固定宽度避免在 CI 日志里被拉成几千列。tqdm 自带 ETA但它的 ETA 是按最近速率外推的全量估计长传期间会来回跳不要把它当 SLA 承诺。两种实现的显示口径其实不一样这点容易忽略tqdm.update(len(chunk))累加的是回调次数乘以块大小跟手写版本等价都表示“已提交到本地 socket 的字节数”。想显示真正的“对端已确认字节”FTP 协议里没有这个粒度——只能传完看 226或者事后用SIZE对账。写监控的时候把这条口径写进注释能省掉后面一轮扯皮。4. 断点续传与失败重试REST/APPE 的边界条件4.1 SIZE 与 MDTM先问清楚远端有什么断点续传的前提是能读出远端文件已有的字节数。SIZE返回字节数MDTM返回 UTC 修改时间两个都是 RFC 3659 扩展命令主流服务端都支持但不是所有。返回值/异常含义处理方式整数远端存在大小已知与本地大小比较决定跳过、续传还是覆盖550且提示 not a regular file远端是同名目录中止人工确认550且提示 SIZE not allowed当前是 ASCII 模式先voidcmd(TYPE I)再问error_perm其他文本服务端不支持或路径不存在当作“远端不存在”从 0 开始网络异常控制连接抖动重连后重问不要直接重传SIZE有个很坑的前置条件多数服务端在 ASCII 模式下会直接拒绝因为 ASCII 模式下字节数没有意义。所以任何续传逻辑的第一行都应该是voidcmd(TYPE I)而不是直接size()。4.2 REST 续传的代码骨架与降级路径storbinary带rest参数时会先发REST offset再发STOR告诉服务端“从第 offset 字节开始写”。前提是本地文件对象也已经seek到同一位置两边偏移必须严格相等差一个字节就是文件损坏。import os, ftplib def remote_size(ftp, path): ftp.voidcmd(TYPE I) # 必须先进二进制模式否则部分服务端拒绝 SIZE try: return ftp.size(path) # 不存在时返回 None 或抛 error_perm except ftplib.error_perm: return None def resume_upload(ftp, local_path, remote_path, blocksize256 * 1024): lsize os.path.getsize(local_path) rsize remote_size(ftp, remote_path) if rsize is None: rsize 0 # 远端没有从头传 if rsize lsize: raise RuntimeError(f远端更大({rsize}{lsize})先确认再覆盖) if rsize lsize: return skipped with open(local_path, rb) as fp: fp.seek(rsize) # 本地偏移与 REST 偏移必须一致 if rsize 0: ftp.storbinary(fSTOR {remote_path}, fp, blocksizeblocksize) else: try: ftp.storbinary(fSTOR {remote_path}, fp, blocksizeblocksize, restrsize) except ftplib.error_perm as e: # 服务端不支持 REST STOR退化为整传 print(fREST 不被支持({e})改为覆盖上传) fp.seek(0) ftp.storbinary(fSTOR {remote_path}, fp, blocksizeblocksize) return resumed if rsize else uploaded逻辑说明先问远端大小再比本地大小三种分支各自处理。restrsize触发的REST是紧贴下一个传输命令生效的一次性指令执行完就失效所以每次续传都得重新给。降级分支很关键REST用于STOR并不是所有服务端都实现有的会返回502或504有的干脆忽略REST从头写——忽略的那种最危险因为不报错你以为续上了实际文件被从头覆盖最后大小对不上才被发现。参数说明blocksize同前rest单位是字节不能超过远端文件大小也不建议设在文件末尾等于追加空内容如果服务端支持APPE而不支持REST可以改成每次追加一个分片、自己维护偏移代价是逻辑复杂度翻倍收益只在超大文件上才明显。4.3 传完之后的核对传完必须核对别信进度条。最轻的核对是重新SIZE一次比对是否等于本地文件大小上一档是比MDTM与本地 mtime能抓到“同名但内容是旧版本”的情况最狠的是下载回来算哈希一般只在合规场景做因为等于把流量翻倍。def verify(ftp, local_path, remote_path): lsize os.path.getsize(local_path) rsize remote_size(ftp, remote_path) if rsize ! lsize: raise RuntimeError(f大小不一致 local{lsize} remote{rsize}) return TrueMDTM返回的是 UTC 时间格式YYYYMMDDHHMMSS跟本地 mtime 比对前要统一时区否则会稳定地差 8 小时——这种偏差不会报错只会让你误判“文件没更新”。5. 避坑与排查五类高频翻车现场5.1 登录成功但一上传就 425现象控制连接秒连、LIST偶尔能出结果一执行STOR就停十几秒然后抛425 Cant open data connection或者干脆超时无响应。原因被动模式的数据端口被中间防火墙拦了或者服务端在 NAT 后面把内网地址当 PASV 响应发回来客户端拿着一个不可达的 IP 去连。也有一种情况是服务端被动端口段被并发连接占满。解决先让服务端把被动端口段固定成一个区间并在防火墙上放行同时配置对外通告地址客户端侧确认set_pasv(True)。临时验证可以用ftp.sendcmd(PASV)看返回的 IP:port 是不是可达地址一眼就能判断是“地址错”还是“端口不通”。5.2 550 权限拒绝与路径歧义现象本地调试全通过换到生产账号就报550 Permission denied或553 Could not create file。原因FTP 账号有“根目录”概念登录后PWD显示的/往往是映射出来的虚拟根不是系统根。用绝对路径/data/upload/x.csv时服务端可能把它解释成账号根/data/upload/x.csv而那个目录不存在。另一种是 Windows 服务端上目录存在但账号没有写权限。解决登录后先ftp.pwd()打印当前目录再用ftp.cwd()逐级进入目标目录最后用相对文件名执行STOR。这条路径最不容易出歧义。文件名里带空格、#、时也要留神命令是纯文本拼接的服务端解析规则各不相同统一重命名成[A-Za-z0-9._-]之外的字符全部替换能省掉大量玄学问题。5.3 中文文件名变成问号现象上传成功但远端列目录一看是????.xlsx或者乱码再上传同名文件时报550 No such file因为客户端拼出来的名字和服务端存的名字编码不一致。原因控制连接上的文件名编码没有协商。客户端按 UTF-8 发服务端按 GBK 解往返一次就废了。FEAT里有UTF8说明服务端支持没有就只能猜。解决先ftp.sendcmd(FEAT)看特性列表。支持就保持ftp.encoding utf-8不支持就试gbk两边都试不通就别硬扛改用纯 ASCII 文件名加一张本地映射表把原始名写在同目录的manifest.json里。文件名一旦开始出现乱码别在编码上继续试错成本远高于换命名规则。5.4 进度条瞬间 100% 却等了十分钟现象进度条一秒跳满之后程序卡住不动最后报超时或者进度条长时间停在某个百分比日志里没有任何输出。原因callback统计的是“已提交到本地内核缓冲区”的字节网络慢的时候内核缓冲区会先吃下一大截进度条自然虚高。反过来卡住则通常是对端不读导致sendall阻塞而timeout设成了None。解决把“进度 100%”和“传输完成”分成两个状态打印完成信号只认storbinary正常返回内部已读走 226。connect时给一个明确超时并在上传前给数据 socket 开 keepaliveimport socket ftp.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)再配合blocksize从 8 KB 提到 256 KB回调频率降下来虚高的观感也会好很多。5.5 续传完了文件却打不开现象断点续传显示成功远端大小也和本地一致但文件解压报错、视频播到一半花屏。原因三种可能。一是REST之前没进二进制模式偏移量按 ASCII 规则算的差了几个字节二是服务端不支持RESTSTOR但静默从头写中间那段时间你以为是追加三是本地文件在两次上传之间被重新生成过前后根本不是同一个文件。解决REST前强制TYPE I续传前比对SIZE和MDTM时间戳变了就不续传直接整传续传完成后除了比大小对关键文件再算一次本地哈希写进日志下次出问题能立刻定位是不是内容层面的损坏。血泪经验是续传功能上线前拿一个 200 MB 以上的文件手工掐三次网每次都比对哈希别只测“能续上”。6. 进阶把上传封装成一个能重试的任务对象散着写的上传脚本最后都会长成一坨我的做法是先落一个UploadTask数据类把local、remote、blocksize、retries全塞进去再配一个带退避的执行器。重试只对“可重试异常”生效——socket.timeout、ftplib.error_temp4xx 响应可以重试error_perm5xx重试多少次都是白搭直接抛出去让人看。import time, socket, ftplib RETRYABLE (socket.timeout, ConnectionResetError, ftplib.error_temp) def run_task(ftp_factory, task, retries3): for attempt in range(1, retries 1): ftp ftp_factory() # 每次重试都新建连接不复用坏掉的会话 try: return resume_upload(ftp, task[local], task[remote]) except RETRYABLE as e: if attempt retries: raise time.sleep(2 ** attempt) # 指数退避避开服务端瞬时限流 finally: try: ftp.quit() except Exception: ftp.close()几处细节值得单独说。重试必须新建连接因为失败的会话里控制连接状态是未知的继续用可能读到上一条命令的残留响应出现“命令与响应错位”这种最难查的故障。quit()发QUIT等 221close()只关 socketfinally里先quit再兜底close是为了让服务端及时回收会话——很多服务端的最大连接数很小漏关几次就把自己锁在门外。需要加密的话把FTP换成FTP_TLS并且一定要在登录后调prot_p()from ftplib import FTP_TLS ftps FTP_TLS(timeout30) ftps.connect(host, 21) ftps.login(user, pwd) ftps.prot_p() # 不加这行只有口令是加密的文件内容仍是明文 ftps.set_pasv(True)prot_p()之后数据连接也走 TLS代价是吞吐下降一到三成内网大文件传输要算这笔账。自签证书环境还得传一个宽松的ssl_context否则握手直接失败报错信息藏在ssl.SSLError里很容易被误判成密码错误。从那以后我每次写上传脚本都强制把三件事写成文件头的常量BLOCKSIZE、TIMEOUT、NEED_VERIFY谁改谁在提交信息里写理由。进度条只是给人看的SIZE对账才是给系统看的——这条规矩救过我两次一次是服务端磁盘满导致写入截断另一次是REST被静默忽略。希望帮到你。本文还有配套的精品资源点击获取