简介面向需要在桌面客户端中实现FTP上传和实时进度反馈的.NET开发者这份C#实例完整演示了基于FTP协议分块传输文件、按已上传与总字节数比例计算进度的实现方式适合C#入门学习及需要快速集成的项目参考。项目采用WinForms窗体搭建覆盖建立连接、身份认证、工作目录切换、调用STOR命令上传及回调更新进度条等关键环节并在UpLoadFiles模块中集中了上传与进度计算逻辑便于提取复用。资源包共35个文件压缩后仅78KB核心为12个cs源文件辅以resources/resx界面资源、sln/csproj/config配置以及exe等编译产物目录结构清晰可直接按窗体或工具类模块查阅。已有868人学习下载。其中UpLoadFiles模块里的上传进度计算、异常处理与重试思路以及对网络延迟、文件大小限制、权限异常等常见问题的应对策略均可迁移到实际项目中省去底层协议调试时间提升上传交互体验。1. FTP上传带进度条一份能真实反馈传输状态的完整实例如果你在找 FTP 上传带进度条的代码而不是那种靠 Timer 滚动出来的假进度——这份实例可以直接照着做一个能用的工具。它基于 C# WinForm 实现用 FtpWebRequest 做协议层请求BackgroundWorker 做后台任务进度条显示的是真实写入的字节数传多少显示多少失败就停在原地不会出现“进度到 100% 文件却没到”的经典假象。适合三类人需要给公司做文件上报、数据分发工具的开发者刚接触 FtpWebRequest 想直接抄代码的新手以及被假进度条坑过、想彻底改掉这个坏习惯的运维。下文从请求链路讲起逐步落到可运行的完整实现。提示这份资源是一套可运行的 C# WinForm 工程源码核心功能就一个——把本地文件通过 FTP 传到服务器并且全程显示真实进度。2. FtpWebRequest 上传链路先看清请求模型和进度数据从哪来2.1 一次上传请求的完整生命周期控制连接与数据连接的分工FTP 和 HTTP 最大的区别在于FTP 有两条独立的通道。控制连接负责传指令USER、PASS、STOR、QUIT 这些数据连接才是真正搬运文件字节内容的管道。FtpWebRequest 虽然把这两条通道封装成了一个对象但它底层做的工作并没有变——理解这一点能帮你省下大量查日志的时间。一次完整的 FtpWebRequest.UploadFile 请求内部大致按这个顺序执行建立到服务器 21 端口的 TCP 控制连接发送 USER、PASS 完成身份认证对应的是你设置的 Credentials根据 UsePassive 属性决定数据通道的建立方式——被动模式PASV是客户端主动连接服务器开放的数据端口主动模式PORT是服务器回头连你的客户端。公网、内网穿透、带防火墙的环境基本都推荐被动模式发送 STOR 命令向服务器声明“我要上传这个文件”文件名就是 URL 里最后一个路径段数据通道建立后客户端把文件字节流写入 GetRequestStream() 拿到的 Stream写完后关闭数据连接服务器返回 226 Transfer complete 确认收完如果没设 KeepAlivefalse控制连接还会保持一段时间方便你继续发下一条命令。这套流程能解释绝大部分上传问题。你看到请求抛异常先按返回码定位530 是认证失败550 是文件操作不合法425/426 是数据通道建立失败。我见过不少人把这几类错误混为一谈换了半天服务器配置最后发现只是账号密码不对。选被动还是主动在真实环境里经常是个玄学问题。公司出网防火墙一般只放行出站连接主动模式的 PORT 命令要求服务器回连你客户端的随机端口大概率被防火墙拦掉。所以现代 FTP 客户端默认都是被动模式。但被动模式下服务器开放的临时数据端口范围如果被服务端防火墙限制得很窄又会出现控制连接秒开、数据通道超时的怪象。这种问题排查起来最像玄学——换一个端口范围就好了。总之FtpWebRequest 里设 UsePassivetrue 的同时最好让 FTP 服务端把被动端口范围放宽并固定下来日志里看到 Entering Passive Mode 时能对得上端口区间就知道该去哪里查了。2.2 进度数据从哪来Stream 层的字节计数是唯一可靠来源进度条的本质是“已上传字节数”除以“总字节数”。想拿到这个可靠数据必须在文件字节流这一层数数——也就是你自己负责读本地文件再写入请求流每一步记录累加值。很多图省事的人直接调 UploadFile 这类一键方法内部把整个文件吞进去你拿不到中间状态进度条就只能靠猜。所以我在这个工程里刻意把“读”和“写”拆开用循环一块一块地搬// 手动读取本地文件写入FTP请求流每块记录一次进度 using (FileStream fs File.OpenRead(localPath)) using (Stream requestStream request.GetRequestStream()) { byte[] buffer new byte[64 * 1024]; // 缓冲区大小64KB int bytesRead; long total 0; while ((bytesRead fs.Read(buffer, 0, buffer.Length)) 0) { requestStream.Write(buffer, 0, bytesRead); total bytesRead; // 这里的total就是真实进度 progress?.Report(total); } }缓冲区大小是个折中选择64KB 内存占用小进度刷新粒度又足够平滑。要是设成 4MB大文件下进度条是几秒一跳的顿挫感设成 512B网络小包暴增服务器 CPU 也会被无谓消耗。另一个关键点是 total 必须在 Write 之后立即累加。这样即使某次写失败进度条停留的位置也是真实已经推到网络上的位置不会出现“显示到了、数据没到”的偏差。还有一个容易被忽略的细节GetRequestStream() 返回的流绑定着 FTP 数据通道状态不能用 Stream.CopyTo 这类快捷方式。CopyTo 内部替你读了写、写了读但你插不上手数数。要么拆开循环自己累加要么给流套一层带回调的包装流。前者代码直观后者侵入性好但调试麻烦。对于一个小工具来说手动循环最划算。另外手工循环还有一个额外好处天然支持速度统计。循环里记录每次 Write 的耗时用 bytesRead 除以耗时就得到实时速率。假进度方案根本做不了这个功能因为它和网络传输之间没有对应关系。2.3 三种进度实现方案对比假进度、流计数与轮询为了让你选型时不用重新踩坑我把常见的三种做法做了个对比方案原理真实度适用场景缺点计时器假滚动Timer 每隔 100ms 给进度条 Value 加 1低演示 Demo、临时工具和实际传输完全脱节失败也照常滚字节流计数循环读写时累计实际字节数高生产环境、大文件、需要精确反馈必须手动拆读和写代码稍多服务器轮询上传期间循环调 GetFileSize 探测中拿不到中间流的封装 SDK多一次 RTT实时性差有额外开销我只有在两种情况下用轮询一是上传流程被第三方 SDK 封装死了拿不到中间流二是断点续传时探测服务器已有字节数。平时的上传工具字节流计数是毫无疑问的最优解。选型最怕的是过度设计。如果只是内网传几十 KB 的小脚本文件假进度其实无伤大雅用户不会盯着进度条。但只要文件可能超过 50MB或者要跨公网传到别人的服务器就必须用流计数。这个场景下进度条承担的不只是“好看”它还负责让使用者判断当前网络状态、决定要不要中断重传。一个假进度条在这种场景里就是误导。还有关于凭据的一个小坑NetworkCredential 的 domain 参数对标准 FTP 没有意义但在和 Active Directory 集成的 FTP 服务端比如 IIS FTP上用户名要写完整或者账号信息字段不能为空。否则会收到 332 Need account for login 这类不常见的错误码大多数人第一反应是查服务端权限实际上只是少填了账号字段。3. 动手实现C# WinForm 的 FTP 上传进度条完整代码3.1 界面布局与后台任务设计从触发到收尾的完整链路界面只需要三个控件一个 ProgressBar 显示进度一个 Label 显示当前状态一个 Button 触发上传。要支持取消就再加一个取消按钮。控件之间的协作方式点击上传按钮后不能直接在主线程里执行上传逻辑——网络读取的阻塞调用会让整个窗体失去响应表现为白屏和拖动卡顿。正确做法是启动 BackgroundWorker 把上传逻辑丢到后台线程UI 只通过事件接收进度并更新控件。这种做法跟前端里用 Web Worker 上传大文件是一个思路耗时 IO 全部移出主线程主线程只负责渲染。WinForm 的 BackgroundWorker 就是这个角色的封装它内部通过 SynchronizationContext 把进度事件封送到 UI 线程所以你在 ProgressChanged 里更新进度条不需要写任何 Invoke 代码。界面设计上要留意一个原则进度条控件本身只管显示不要在里面塞业务逻辑。很多新手把文件大小计算、网速估算全塞进 ProgressChanged 回调结果 UI 线程被这些杂事拖住进度条反而卡顿。把上传前就确定的值文件总长度提前存字段进度回调里只做两件事——更新 Value、更新文案。3.2 核心上传方法带真实进度计的 FtpWebRequest 封装下面的方法建议直接复制到公共类留出进度回调和取消检查的接口WinForm、控制台、Windows 服务都能复用// 带真实进度回传的FTP上传方法 // onProgress: 已上传字节数回调 // checkCancel: 返回true时终止上传 public void UploadWithProgress( string localFilePath, string ftpUrl, string userName, string password, Actionlong onProgress, Funcbool checkCancel) { using (FileStream fs File.OpenRead(localFilePath)) { long fileLength fs.Length; FtpWebRequest request (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method WebRequestMethods.Ftp.UploadFile; request.Credentials new NetworkCredential(userName, password); request.UseBinary true; // 二进制传输防止字节被改写 request.UsePassive true; // 被动模式绕开防火墙限制 request.KeepAlive false; // 单一文件传完即关闭连接 // 关键告诉服务器文件总长度否则无法判断传输终态 request.ContentLength fileLength; using (Stream requestStream request.GetRequestStream()) { byte[] buffer new byte[64 * 1024]; // 64KB缓冲区 int bytesRead; long totalBytesWritten 0; while ((bytesRead fs.Read(buffer, 0, buffer.Length)) 0) { // 每次写前检查取消标记协作式取消 if (checkCancel ! null checkCancel()) { throw new OperationCanceledException(用户取消上传); } requestStream.Write(buffer, 0, bytesRead); totalBytesWritten bytesRead; // 上报真实字节数给UI层 onProgress?.Invoke(totalBytesWritten); } // 冲刷缓冲避免最后一小块数据滞留本地 requestStream.Flush(); } // 必须拿到服务器确认响应才算真正完成 using (FtpWebResponse response (FtpWebResponse)request.GetResponse()) { if (response.StatusCode ! FtpStatusCode.ClosingData) { throw new Exception($服务器返回异常: {response.StatusCode}); } } } }四个容易忽略的配置这里一并说清。ContentLength 不设置或设错服务器在数据通道关闭时可能用 451 之类的错误码拒绝文件或者一直等数据直到超时。FtpWebRequest 依赖它判断数据流的边界所以务必从 FileStream.Length 取真实字节数。UseBinary 决定传输模式。对压缩包、图片、PDF 这类二进制文件如果误用 ASCII 模式0x0A 单字节换行符会被改写成 0x0D 0x0A 双字节文件当场损坏。文本文件也建议直接用二进制模式服务器不做任何转换两边字节完全一致。KeepAlivefalse 的意义在于单个文件传完就断开控制连接服务器端不会因为连接缓存让文件句柄迟迟不释放。循环传多个文件时反过来KeepAlivetrue 能复用连接节省握手开销。最后是 Flush。requestStream 内部有缓冲不 Flush 直接 Close某些实现会先关闭连接再倒缓存导致最后一批数据还没到服务器就断了。写完循环后、关闭请求流前主动 Flush是避免“进度条 99% 卡住”的关键动作。3.3 跨线程更新进度条与状态栏BackgroundWorker 的标准连接方式核心方法写完接下来挂到 UI 上。这里用 BackgroundWorker 而不是 async/await是为了让没升级到 .NET 4.5 的旧工程也能直接抄// 点击上传按钮后启动后台任务 private void btnUpload_Click(object sender, EventArgs e) { OpenFileDialog dlg new OpenFileDialog(); if (dlg.ShowDialog() ! DialogResult.OK) return; string localPath dlg.FileName; string uploadUrl ftp://192.168.1.100/share/ Path.GetFileName(localPath); long totalLength new FileInfo(localPath).Length; // 提前缓存避免回调里反复IO BackgroundWorker worker new BackgroundWorker(); worker.WorkerReportsProgress true; worker.WorkerSupportsCancellation true; // 后台执行上传逻辑 worker.DoWork (s, e2) { try { UploadWithProgress( localPath, uploadUrl, ftpuser, ftppass, // 每次回调都报告百分比UserState传出字节数 (bytes) { int percent (int)(bytes * 100 / totalLength); worker.ReportProgress(percent, bytes); }, // 取消检查直接读标志位 () worker.CancellationPending ); } catch (OperationCanceledException) { e2.Cancel true; // 用户取消不按失败处理 } catch (Exception ex) { e2.Result ex; // 异常带回UI线程 } }; // 更新进度条与状态栏 worker.ProgressChanged (s, e2) { progressBar.Value e2.ProgressPercentage; long bytes (long)e2.UserState; lblStatus.Text $已上传 {FormatFileSize(bytes)} / {FormatFileSize(totalLength)}; }; // 上传完成后的收尾 worker.RunWorkerCompleted (s, e2) { if (e2.Cancelled) { lblStatus.Text 已取消上传; } else if (e2.Result is Exception ex) { lblStatus.Text 上传失败 ex.Message; } else { lblStatus.Text 上传完成; } progressBar.Value 0; }; worker.RunWorkerAsync(); }进度更新这个环节最常报的错是“跨线程操作无效”。DoWork 跑在后台线程直接操作 progressBar 必然抛 InvalidOperationException。BackgroundWorker 的 ReportProgress 机制会自动封送到 UI 线程所以 ProgressChanged 里不需要任何 Invoke 代码。这条规则不仅适用进度条状态栏、列表控件都是同样的处理方式。取消按钮的配合也很简单取消按钮里调 worker.CancelAsync()然后立刻把取消按钮禁用、上传按钮恢复防止重复点击。CancelAsync 不会中断正在执行的代码它只是把 CancellationPending 置为 true循环必须主动检查这个标志才能退出。这就是协作式取消在 BackgroundWorker 里体现得最典型。3.4 关键参数清单按生产标准一次设对参数推荐值说明UseBinarytrue二进制传输防止换行符被改写UsePassivetrue被动模式减少防火墙影响KeepAlivefalse单文件上传后断开控制连接ContentLength本地文件长度必须设置否则服务器无法确认文件边界Timeout30000ms建立连接和发送命令的超时ReadWriteTimeout60000ms单个读写操作的最大耗时缓冲区64KB内存与网络包数量的折中Timeout 和 ReadWriteTimeout 是两回事这点很多人栽过。Timeout 管连接建立和命令收发ReadWriteTimeout 管数据流的单次读写。弱网下传大文件默认 30 秒的 ReadWriteTimeout 可能不够网络抖动一下读写就中断了。按文件大小和带宽估算留出余量再设。另外把 FTP 当内部文件共享用跨网段比 SMB 省心是常见做法但一定要给共享目录配专用账号别用匿名登录。匿名账号在不少服务端被设置为只读上传时总是神秘失败日志看不出来最后发现是权限位不对。4. 避坑与常见问题进度条翻车的五个真实场景4.1 现象进度卡在 99% 不动最后抛超时异常原因请求流内部的缓冲区没有冲刷干净数据还滞留在本地就关闭了连接服务器等不到最后一个数据块超时断开。解决关流之前显式调用 requestStream.Flush()。同时检查循环里是否把 Close 写在了所有 Write 完成之前。我最初写的版本就是 Write 完直接 Close导致最后一块数据总是丢。从那以后我关闭任何网络流的顺序都固定为写完 → Flush → Close一步不乱。4.2 现象进度条到 100%服务器上却找不到文件原因最常见的有两个。一是 UseBinary 没设 true二进制文件被传输模式改写字节服务端校验失败后丢弃二是请求流写完就关没等 GetResponse() 拿到 226 确认。解决使用二进制模式并保留到 GetResponse() 返回 226 才算真正结束。进度到 100% 只代表本地流写完不等于服务器确认收完。如果你用第 3 章的封装这段逻辑已经包含在内不要手动提前释放 request。4.3 现象大文件一上传界面就假死拖动窗口都费劲原因上传逻辑跑在 UI 线程FtpWebRequest 同步方法读取网络流时阻塞线程窗口消息全部排队界面自然无响应。解决把上传丢到 BackgroundWorker 或 Task.Run 里。如果已经用了后台线程但还是卡检查 ProgressChanged 回调里有没有耗时操作——有人习惯在回调里 new FileInfo(localPath).Length 重新取文件大小文件一大这步磁盘 IO 就能拖慢 UI。文件总长度应在点击上传时就缓存好回调里只做控件赋值。4.4 现象上传中断后服务器残留半截文件下次继续传也不完整原因取消或断网时服务端已经创建了目标文件并写入了部分字节但因为没收到结束标志文件被留在异常状态。解决在取消和异常处理中主动发 DELE 命令删除残留文件或者实现断点续传。第 5 章会给出 DELE 清理的完整代码。这里先记住一个原则取消上传不只是中断本地循环还要向服务器声明“我刚才那个文件不算数”否则你会在服务器上留一堆垃圾文件。4.5 现象返回 530 Authentication failed账号密码看着没错原因很多 FTP 服务器开了账号锁定策略密码连续错几次会锁一段时间或者服务端要求显式 TLS。还有一种情况账号是弱口令被安全扫描器探测并锁定了你在客户端这边看账号密码完全没问题。解决先登录 FTP 服务端管理界面看账号状态再确认是否强制 FTPS。FtpWebRequest 里设置 EnableSsl true 即可走加密通道。生产环境一定单独建低权限专用账号别用 root 或管理员级别的账号连应用顺手把 FTP 弱口令这个隐患也堵上。5. 进阶取消与断点续传——把进度条升级成可中断任务5.1 协作式取消的完整闭环中断循环、清理残留、通知上层第 3 章用的 BackgroundWorker.CancellationPending 是标准的协作式取消。如果你的代码要复用进 Windows Service 或命令行工具更通用的做法是注入 CancellationToken。无论哪种方式核心都在于“循环内部自己检查标记、自己退出”。退出之后的清理动作往往被忽略。中断上传时服务器上已经生成了一个不完整的文件正确顺序是先抛出 OperationCanceledException 退出循环然后在 catch 块里发一条 DELE 命令删除目标文件最后再重新抛出让上层感知// 取消上传时清理服务器残留的半截文件 catch (OperationCanceledException) { try { FtpWebRequest delRequest (FtpWebRequest)WebRequest.Create(ftpUrl); delRequest.Method WebRequestMethods.Ftp.DeleteFile; delRequest.Credentials new NetworkCredential(userName, password); using (FtpWebResponse delResponse (FtpWebResponse)delRequest.GetResponse()) { // 删除成功或返回550文件不存在都视为清理完成 } } catch { // 清理失败不阻断主流程 } throw; }DELE 命令走的是控制连接不涉及数据通道所以即使数据流已经中断这条命令大概率还能成功。如果服务端本身有残留文件自动清理策略这一步可以省略但作为应用层兜底做了永远比不做好。这里的核心思想是取消操作要保证“不留脏数据”这比“快速退出”更重要。5.2 断点续传ContentOffset 与服务器已有文件大小的配合断点续传的底层是 FTP 的 REST 命令客户端先发REST 1024再发 STOR服务端就从第 1024 个字节开始继续写入。FtpWebRequest 里对应的是 ContentOffset 属性设置后框架会自动在 STOR 前发送 REST 命令。实现分两步。第一步探测服务器上已有的文件大小// 探测服务器已存在文件的大小用于断点续传 private long GetRemoteFileSize(string ftpUrl, string userName, string password) { FtpWebRequest req (FtpWebRequest)WebRequest.Create(ftpUrl); req.Method WebRequestMethods.Ftp.GetFileSize; req.Credentials new NetworkCredential(userName, password); req.UsePassive true; using (FtpWebResponse resp (FtpWebResponse)req.GetResponse()) { return resp.ContentLength; } }第二步把本地 FileStream 的位置跳到 offset同时设置 ContentOffset数据就从指定位置继续// 断点续传从offset位置继续上传 long offset GetRemoteFileSize(ftpUrl, userName, password); using (FileStream fs File.OpenRead(localPath)) { fs.Seek(offset, SeekOrigin.Begin); // 本地从对应位置读 FtpWebRequest request (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method WebRequestMethods.Ftp.UploadFile; request.Credentials new NetworkCredential(userName, password); request.ContentOffset offset; // 服务器从对应位置写 request.ContentLength fileLength - offset; // 剩余长度 // ...后续循环与第3章一致 }续传场景最容易踩三个坑。第一ContentOffset 和 ContentLength 必须一起改。只设 offset 不把 ContentLength 改成剩余长度服务器会一直等数据直到超时。第二本地 FileStream 的 Seek 位置必须和 ContentOffset 严格一致一个传的是 10MB 一个偏移却是 0文件中间会有一段空洞或重复整个文件损坏。第三GetFileSize 对不存在的文件会抛 WebException很多服务端返回 550。所以首次上传不能直接走续传分支要捕获异常后转全量上传。判断服务器上是否存在文件除了 GetFileSize 抛异常还有更稳妥的方式用 ListDirectory 列目录看目标文件名是否在返回列表里。但这种方案会增加一次网络往返对于工具类程序捕获 550 异常已经是常见且够用的做法。5.3 场景延伸FTP 监控与定期同步的小工具把断点续传和进度条组合起来能做一个实用的 FTP 监控与定期同步工具定时扫描本地目录把新增文件全量上传、把中断的任务从断点续传同时把每批次的实时进度显示到状态栏。这个场景在数据采集终端、离线数据上报的运维环境里非常常见。一个容易忽略的细节断点续传只能解决“网络中断后重连续传”的问题如果源文件在中途被其他进程修改了续传会产生数据不一致。做定期同步之前必须确认文件处于稳定状态——最简单的方法是两次读取文件大小间隔 200 毫秒两次一致才进入上传流程。这个“先确认再上传”的习惯能避免大量脏数据进入服务器。6. 上传后验证比对大小、MD5 与列目录确认文件真的完整进度条到 100% 只是本地视角服务器到底收了什么还得靠验证。我习惯在上传完成后强制走一遍三连验证先比对大小、再列目录看时间戳、最后抽检 MD5。这一步看起来多花几十毫秒但能拦住 90% 的“假成功”。大小比对最简单用服务器 GetFileSize 返回的值对比本地文件长度。完全一致才能进下一步。这一步能拦住大部分字节被改写、文件被截断的场景。列目录看时间戳适合确认“服务器上的文件确实是刚才那次上传生成的”而不是某个旧的同名文件顶替了它。MD5 校验最严谨但要做对。FTP 协议本身没有标准的 MD5 校验命令有些服务端实现了 XMD5、XSHA1 这类扩展命令但各家行为不一致。通用做法是把文件下载回来本地哈希或者让服务器端脚本算好 MD5 后再写入一个同名的 .md5 文件两边各算各的// 本地计算文件MD5用于上传后与服务器端比对 public string GetFileMd5(string filePath) { using (FileStream fs File.OpenRead(filePath)) using (var md5 System.Security.Cryptography.MD5.Create()) { byte[] hash md5.ComputeHash(fs); return BitConverter.ToString(hash).Replace(-, ).ToLowerInvariant(); } }对大文件算 MD5 有 IO 开销所以我的习惯是分级验证文件小于 100MB 直接算 MD5文件更大就只做大小比对加抽样读——取文件头部、中部、尾部分别读 1MB 算哈希三段一致基本能确认传输无损坏。这个“三段抽样”的办法在带宽有限的场景里比全量 MD5 实用得多。验证通过之后再更新界面状态栏和日志文件。日志里要记录文件路径、服务器返回码、传输耗时和验证结果这些数据在你排查“为什么用户总说传了但服务器没有”的时候是最直接的证据。做 FTP 工具这几年我最大的教训是永远不相信进度条自己只相信校验结果。从那以后我每次上传完都强制走一遍大小比对大文件加抽检这个习惯帮我拦下了无数次“看似成功实则损坏”的传输。这份资源的完整工程代码里已经内置了这三步验证逻辑直接跑起来就能看到效果。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?