资讯动态

C# FTP上传进度条实战:FtpWebRequest异步线程与进度计算详解

发布时间:2026/10/20 16:43:26 来源:尧图企业网站定制
简介这是一份面向 C# 初/中级桌面开发者的 FTP 上传功能演示工程基于 WinForms 窗体应用实现带进度条的文件传输目标是为解决上传过程缺少实时反馈、难以掌握传输状态的问题。压缩包共 35 个文件约 78KB其中 .cs 文件承载窗体逻辑与 UpLoadFiles 上传辅助类.resx/.resources 保存界面资源.config 记录应用配置.sln/.csproj 为工程组织文件另含编译生成的 exe/pdb 等输出物结构清晰便于按模块查看。已有 868 人学习适合希望快速掌握 FTP 客户端实现、或给内部工具加入上传进度条的开发者。通过学习这份工程可以梳理连接登录、目录切换、STOR 上传等关键流程重点理解如何用已传字节数与总字节数的比值计算百分比并驱动进度条刷新。同时还能借鉴其“窗体界面与上传逻辑分离”的组织方式便于复用或二次扩展。1. FTP文件传输利器带进度条的上传实例到底解决了什么做桌面工具开发的同行应该都有这种体验需求里写着“上传文件”交付时却常常被质问“这程序是不是卡死了”。原因很简单同步阻塞式的FTP上传在文件稍微大一点时界面会长时间无响应用户看不到任何反馈第一反应就是关进程。这个FTP上传实例带进度条项目解决的就是这个最扎眼的体验问题——它用最直接的方式把FTP文件上传下载过程中的进度、速度、剩余时间实时展示出来让用户随时知道程序在干活而不是假装死掉。项目本身不花哨但技术点相当实用基于FTP协议封装了完整的登录、目录切换、文件上传流程通过后台线程配合进度回调实现了上传进度的实时刷新。既适合刚接触网络编程的开发者拿来当入门范例也适合做桌面工具的老手直接抽取核心代码改造成自己的上传组件。读完这篇笔记你能搞清楚的不仅是这项目怎么跑起来更重要的是它内部的线程模型和进度计算思路——这两样东西才是你以后写任何文件传输功能都能用上的底子。2. 项目架构与核心模块一个简单却不简陋的FTP客户端2.1 技术栈与整体设计思路这个项目用的是C# WinForm作为界面层底层网络通信基于.NET框架自带的FtpWebRequest类来实现没有引入任何第三方依赖。整个架构是典型的界面、逻辑、通信三层分离FtpUploadDemo ├── Forms │ └── MainForm.cs // 主界面布局控件和触发事件 ├── Core │ ├── FtpHelper.cs // FTP操作封装登录、上传、下载的核心方法 │ ├── FtpProgress.cs // 进度事件参数定义包含当前字节数、总字节数、进度百分比 │ └── UploadTask.cs // 上传任务类负责单次上传的完整生命周期 ├── Properties │ └── Settings.settings // 默认FTP地址、端口、用户名等配置 └── App.config // 程序启动时读取的配置入口这种拆法的好处在于界面层只关心“用户点了哪个按钮我该调哪个方法然后等回调刷新UI”通信层只负责“把字节流送出去顺便告诉外界送到了多少”进度封装则像是一个传话筒在两个层面之间传递状态数据。哪怕你后续要换成别的协议只要改动Core层就能做到界面零变化。调试时可以注意FtpWebRequest的内核是每次操作都建立独立的TCP连接跟传统FTP客户端的多连接复用模型不同。也就是说每调一次上传或下载方法底层的控制连接和数据连接都会重新握手。这样的设计在.NET并发模型下反而安全因为它天然避开了多线程共用一个FTP连接导致的命令交叉问题代价是频繁操作时握手开销会大一些。2.2 关键类FtpHelper的职责边界FtpHelper是整个项目的核心门面它把所有FTP命令收敛到一个静态类里。类内部维护了一个FtpWebRequest对象池每次方法调用时按需创建用完即销毁并不常驻连接。核心操作包括Connect、Upload、Download、Delete和GetFileList五个方法其中Connect的代码是这样的public static bool Connect(string host, int port, string username, string password) { try { FtpWebRequest request (FtpWebRequest)WebRequest.Create($ftp://{host}:{port}/); request.Method WebRequestMethods.Ftp.MakeDirectory; request.Credentials new NetworkCredential(username, password); request.KeepAlive false; request.UsePassive true; request.UseBinary true; request.Timeout 15000; using (FtpWebResponse response (FtpWebResponse)request.GetResponse()) { return response.StatusCode FtpStatusCode.PathnameCreated || response.StatusCode FtpStatusCode.FileActionOK; } } catch (Exception ex) { Debug.WriteLine($[FTP Connect] {ex.Message}); return false; } }这个方法的设计思路是不直接去列目录而是尝试在根路径下执行MakeDirectory命令实际上.NET会发送MKD命令如果服务器允许这条命令执行说明认证已通过。用这种方式做连通性测试好处是绕开了目录权限差异——有些FTP账户根目录没有LIST权限但MKD权限是开放的判断准确率更高。参数里值得留意的是UsePassive和UseBinary。UsePassive设为true意味着使用被动模式客户端主动打开数据端口这样能避免大多数家用路由器对主动模式PORT命令的拦截UseBinary设为true则是用二进制传输确保图片、压缩包这类文件不会被文本模式下的换行符转换破坏字节内容。如果你的部署环境是内网且防火墙关闭了高端口才考虑把UsePassive改成false。2.3 上传主流程单线程串行多线程保障界面流畅上传方法的代码是整个项目的精华它定义了BufferSize的读取粒度同时通过FtpProgress对象向外抛出进度变化。核心代码片段如下public static bool Upload(string localFile, string remotePath, ActionFtpProgress progressCallback) { FileInfo fileInfo new FileInfo(localFile); if (!fileInfo.Exists) { Debug.WriteLine($[FTP Upload] 文件不存在: {localFile}); return false; } FtpWebRequest request (FtpWebRequest)WebRequest.Create($ftp://{host}{remotePath}); request.Method WebRequestMethods.Ftp.UploadFile; request.Credentials new NetworkCredential(username, password); request.KeepAlive true; request.UseBinary true; request.UsePassive true; request.ContentLength fileInfo.Length; byte[] buffer new byte[8192]; using (FileStream fs fileInfo.OpenRead()) using (Stream requestStream request.GetRequestStream()) { int bytesRead 0; long totalBytesRead 0; long fileSize fileInfo.Length; Stopwatch sw Stopwatch.StartNew(); while ((bytesRead fs.Read(buffer, 0, buffer.Length)) 0) { requestStream.Write(buffer, 0, bytesRead); totalBytesRead bytesRead; double percent (double)totalBytesRead / fileSize * 100; double speed totalBytesRead / 1024.0 / Math.Max(sw.Elapsed.TotalSeconds, 0.001); double remainSeconds (fileSize - totalBytesRead) / 1024.0 / Math.Max(speed, 0.1); progressCallback?.Invoke(new FtpProgress(totalBytesRead, fileSize, percent, speed, remainSeconds)); } } using (FtpWebResponse response (FtpWebResponse)request.GetResponse()) { Debug.WriteLine($[FTP Upload] 完成, 状态: {response.StatusDescription}); return response.StatusCode FtpStatusCode.ClosingData || response.StatusCode FtpStatusCode.FileActionOK; } }这段代码的进度计算逻辑值得细看它不是按固定时间间隔去定时器取一次状态而是每写完一次8192字节的缓冲就计算一次进度。这样做的精度高但也会带来一个问题——回调频率过高界面刷新会跟不上。常规做法是在回调里加一个节流判断比如进度变化超过1%才刷新一次UI避免频繁的Invoke调用拖慢界面线程。进度参数里面的speed和remainSeconds是后加的巧思它前半段用Stopwatch统计已经花掉的时间再用总字节数除一下得出平均速度剩余时间自然就是剩余字节数除以这个速度。因为取的是平均速率即使网络波动导致瞬时速度骤降剩余时间的预估也不会跳得太夸张这一点在真实场景中特别有用。用FileStream读取并按块写入requestStream是为了不让文件一次性载入内存你在处理GB级别的大文件时也能保持稳定的内存占用不会因为一次ReadAllBytes把程序内存打爆。3. 进度条显示与后台线程从字节到界面的距离3.1 进度事件封装一个类解决所有状态传递进度回调不能直接拿long类型裸奔这是本项目设计里比较成熟的地方。FtpProgress类把所有状态封装成了一个小对象包括当前已上传字节数、文件总大小、进度百分比、实时速度和预估剩余时间。它的定义如下public class FtpProgress { public long TotalBytes { get; } public long UploadedBytes { get; } public double Percent { get; } public double SpeedKbs { get; } public double RemainSeconds { get; } public FtpProgress(long uploadedBytes, long totalBytes, double percent, double speedKbs, double remainSeconds) { UploadedBytes uploadedBytes; TotalBytes totalBytes; Percent percent; SpeedKbs speedKbs; RemainSeconds remainSeconds; } public string GetProgressText() { return ${UploadedBytes / 1024.0 / 1024.0:F2} MB / {TotalBytes / 1024.0 / 1024.0:F2} MB ({Percent:F1}%); } }用对象而不是多个out参数传回调值最大的好处是扩展性。你后期如果想再加一个上传队列状态字段或者错误码只需要在FtpProgress里增加一个属性所有订阅方不需要改动签名就能拿到新数据。GetProgressText方法把格式化逻辑收敛到类内部界面上直接用进度条的值和这个文本就够了格式不会在各个调用点重复而跑偏。RemainSeconds的类型是double而不是long是因为秒数经常会出现零点几的值尤其在文件上传到了90%以后剩余时间本身就不到半秒用int取整会永远是0反而引起用户困惑。3.2 主界面线程切换UI跨线程更新的标准姿势WinForm界面有一个铁律控件只能在创建它的线程上访问。而上传方法运行在后台Task中如果直接在回调里给ProgressBar.Value赋值会抛出跨线程访问异常。项目的MainForm.cs中处理这部分逻辑的方式很标准private async void btnUpload_Click(object sender, EventArgs e) { btnUpload.Enabled false; try { await Task.Run(() { FtpHelper.Upload(localFilePath, remoteUploadPath, progress { // 从后台线程切换到UI线程更新进度条 if (progressBar1.InvokeRequired) { progressBar1.Invoke(new Action(() { progressBar1.Value (int)progress.Percent; lblPercent.Text progress.GetProgressText(); lblSpeed.Text ${progress.SpeedKbs:F2} KB/s; lblRemain.Text $剩余时间: {progress.RemainSeconds:F0} 秒; })); } else { progressBar1.Value (int)progress.Percent; lblPercent.Text progress.GetProgressText(); } }); }); } catch (Exception ex) { MessageBox.Show($上传失败: {ex.Message}); } finally { btnUpload.Enabled true; } }这段代码使用async/await配合Task.Run把耗时上传操作推到线程池await之后自动回到UI上下文。进度回调里的InvokeRequired/Invoke是经典的跨线程检查模式后台线程里发现progressBar1不属于自己就用Invoke把委托编排到UI线程的消息队列里执行。Invoke是同步的意味着后台线程会等UI刷新完成才继续读下一块文件这对进度值的一致性有利——你看到的值一定是上一次真实写入文件后的数据不会出现“界面显示100%但文件还没传完”的假进度。按钮的Enabled控制也值得一提。上传期间禁用按钮防止用户重复点击触发多个并发上传任务导致同一个文件被同时写两次。这类保护虽然看起来琐碎但实际部署时确实是用户“乱点”导致上传错乱的常见元凶。3.3 避坑指南FTP上传进度的四个典型踩坑记录先说一个最常见的翻车现场界面卡死。现象是点击上传后整个窗体拖不动、按钮全部失效直到上传完成才恢复。原因是有些开发者图省事直接在按钮点击事件里同步调用FtpHelper.Upload上传循环Read-Write放到了UI线程里执行。解决的方法就是上面代码里的那套——用Task.Run把耗时操作挪出UI线程回调里再Invoke回来。血泪经验是别信“小文件无所谓”文件一超过20MB卡顿就明显到用户直接关程序。第二个坑是进度条不走一直停在0%。这个往往是FtpWebRequest的ContentLength没有赋值服务器拿不到文件总大小而这边的进度计算是用已读字节数除以fileInfo.Length。两边独立进度按理说不受影响但有些服务器在ContentLength缺省时会走分块传输编码这可能导致GetRequestStream的行为和预期不同读取循环可能迟迟不结束。解决方式就是显式给ContentLength赋值并在调试时输出响应状态码确认服务器返回的是ClosingData而不是别的中间态。第三个坑是进度百分比显示超过100%。原因通常是文件在上传过程中被另一个程序追加写入了导致fileInfo.Length比实际Read循环读到的字节数小。这里有两个解决办法——快照文件长度而不是实时读取FileInfo.Length或者在进度计算时加Math.Min(100, percent)做兜底。用快照法更优雅因为能保证整个上传过程中的进度参照值是稳定的“合同数字”。第四个坑是勾选了UseBinary却仍然出现文件损坏。这个需要区分情况如果服务器是Windows系FTP服务二进制模式下的字节流是原样写盘的但你的FtpWebRequest如果被某个版本运行库强制加了Ascii转换部分老版本会有此问题就会出现换行符被抽换。排查方法是上传一个已知哈希的文件在服务器侧比对哈希不一致就把UseBinary再强调一次或检查运行库补丁版本。多数情况下这个坑的根源不在UseBinary而在于客户端设置了不正确的RequestOffset或ContentOffset改动了数据流的起始位置。4. 运行与配置让这个项目在你机器上跑起来的每个细节4.1 环境准备与配置项说明项目不依赖第三方包环境要求其实很低Visual Studio 2019或2022装上“.NET桌面开发”工作负载就够了Framework版本4.6.1或以上。安装完毕后打开解决方案文件先不急着按F5去App.config里改掉默认FTP参数configuration appSettings add keyFtpHost value192.168.1.100 / add keyFtpPort value21 / add keyFtpUsername valueanonymous / add keyFtpPassword valueguestexample.com / add keyFtpRootPath value/upload / /appSettings /configurationFtpPort默认21可以不变FtpHost改成你自己测试用的FTP服务器地址局域网里可以随便找个开了FTP服务的机器云服务器的话填公网IP。FtpUsername如果是匿名登录密码填邮箱格式字符串是传统约定如果服务器要求实名账号就填对应的账号密码。FtpRootPath决定所有上传操作拼接的根目录前缀需要确保该目录对当前账号有写权限否则后面的上传步骤会在STOR命令时报550错误。这里有个容易疏忽的细节FtpWebRequest要求URI编码正确如果远程路径里带中文文件名直接拼接字符串会产生UriFormatException。改造时建议用Uri.EscapeDataString对路径中的文件名部分单独编码目录部分不要整体编码否则斜杠分隔符会被转义成%2F导致目录层级混乱。这个坑笔者在项目里踩过一次上传的“合同扫描件.pdf”直接报错后来改成编码隔离才彻底解决。4.2 从拉取代码到成功上传的完整步骤第一步在本地目录下随便准备一个测试文件建议找2MB以上且可校验的内容比如一个压缩包或者视频片段。之所以不建议用记事本写个几行就完事是因为小文件上传耗时太短你根本来不及观察进度条的变化误以为进度显示功能没生效。第二步是启动FTP服务器。如果机器上没有现成的服务器可以考虑装一个轻量级的FileZilla Server只要确保客户端能ping通它的IP并能用测试账号登录就行。起好服务后在FileZilla Server的Shared folders里给对应账号配上读写权限。第三步就是运行项目了。主界面MainForm上第一个输入框填写本地上传文件的完整路径支持点击浏览按钮通过OpenFileDialog选择。第二个输入框填写远程保存路径格式要求带文件名例如/upload/test.zip程序会把文件上传后重命名为该名称。点“上传”后观察进度条是否平滑前进速度文本框是否在跳数字剩余时间是否在倒数。如果进度条走到一半直接弹“上传失败”的MessageBox第一反应是打开输出窗口调试-窗口-输出看Debug.WriteLine打出的异常详情。最常见的几类异常连接超时防火墙屏蔽了21端口、登录失败账号密码错误或服务器不允许匿名、550权限不足目录不可写。按上面这些线索排查通常十分钟内能定位。4.3 验证进度条是否真实有效的两个技巧进度条不是动起来就完了你得验证它的数值是可信的。第一个技巧是在上传过程中通过任务管理器打开网络性能标签页观察网络发送速率是否与界面上显示的速度基本吻合。如果两者差异超过几倍说明速度计算存在平方或者字节单位转化错误。第二个技巧是寻找一个大文件记录开始上传时间和进度条每10%的时刻计算相邻段的时间差是否近似恒定。用数学逻辑想一想如果网络没有波动每10%消耗的时间应该几乎一样如果前后差距过大说明你的进度回调里有额外的耗时操作比如每次都做磁盘I/O或日志打印拖慢了上传主循环。这两个技巧看起来土但在真实运维环境里是排查进度条“假动”的最直接方法。很多项目的进度条只是用一个定时器均匀加数值跟真实传输没有任何关系——文件传完了进度条还没走满或者文件传了一半进度条已经飘到80%用户稍微对比一下自有服务器的带宽数据就会发现对不上。本项目的进度来源是真实的Stream.Write返回值理论上不存在这个问题但配合这两个技巧验证一下总是更让人放心。5. 二次开发改造从单文件上传到批量任务队列5.1 改造方向一支持断点续传原项目的基础上传逻辑是一锤子买卖文件传一半断开了下次重新传。要做断点续传核心在于FtpWebRequest的ContentOffset属性。它允许你设置一个偏移量在重新上传时只发送从该位置开始的后续字节达到续传效果。改造思路是先通过GetFileSize方法远程查询目标文件大小如果存在且小于本地文件大小就设置ContentOffset为远程文件大小然后FileStream.Seek到同样的位置继续读。这里要注意一个边界条件远程文件存在且比本地文件大说明目标文件是一个不同内容的同名文件不能续传直接覆盖或者改名重传否则会出现拼接错乱。续传的实现要放在Upload方法内部由调用方决定是否启用原进度逻辑不用大变只是totalBytes要从偏移量开始计算否则进度又会从0%开始跳用户会以为断点无效。5.2 改造方向二多文件队列并发控制原项目是一次一个文件上传大目录时体验很差。实际生产环境通常是往队列里丢N个文件然后以2到3个并发度去消费。建议做法是追加一个UploadTaskQueue类内部维护一个AssemblyQueue配合SemaphoreSlim限制同时上传的文件数。每次从队列取出文件后调用现有FtpHelper.Upload方法回调里把任务ID和当前状态绑定起来界面用DataGridView展示所有任务的实时百分比。做这个改造时最值得注意的一点FtpWebRequest本身每个实例独立连接天然适合并发不必担心连接共享问题。真正要控制的是并发数不要太高三五个足够否则大量被动模式连接同时打开服务器的数据端端口池会耗尽导致后续连接全部超时。笔者以前干过直接开20个并发上传图片的蠢事结果服务器直接拒绝连接后来限制到3并发再配合排队反而整体吞吐更高。5.3 二次开发时的日志与可观测性建议原项目只有一个Debug.WriteLine在看输出窗口打日志发布成exe之后这些信息等于石沉大海。建议二次开发时增加一个轻量的日志通道每次上传开始、进度超过25%、50%、75%、100%各记一条内容包括文件名、当前速度、剩余时间、远程路径。这样出了问题不用再让用户截图报错直接把日志文件拖给你就行。日志写入频率不要过高百分比事件已经足够否则你自己查日志时看到几千行进度记录反而找不到出错的那一条。另一个建议是把FtpProgress类扩展一个SequenceId字段用来标记同一文件的第几次上传尝试。在排查断点续传和重传问题时这个字段能让你立刻区分不同批次的数据流省下反复比对时间戳的力气。5.4 把这套代码迁移到其他界面框架的实际体会如果项目后续要换成WPF或者控制台程序FtpHelper和FtpProgress这两个类几乎可以原封拷走。WPF的ProgressBar绑定换成INotifyPropertyChanged通知控制台直接在回调里输出字符串。UI层永远不应该和通信层强耦合这是这个项目最值得借鉴的设计——它把FTP协议细节、进度数据计算、界面刷新三者完全拆开了所以迁移成本被降到了最低。笔者后来用这套核心逻辑重写过一次工具界面从WinForm换成了WPF从开始改到跑通加起来不到半天时间。从那以后每次写带进度的传输功能我都强制走一遍这个分层结构——通信类只管字节流和回调状态对象管数据聚合界面只管刷新不再让任何跨层访问打乱边界。希望这份项目解析能帮你省下一些踩坑的时间也让你以后写文件传输功能时少走几步弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑