简介:这份C# FTP下载实例源码面向具备一定.NET基础的开发者,用于解决文件传输场景下的网络操作学习问题。资源以完整可运行的工程形式呈现,涵盖FTP连接、登录凭据设置、被动模式与SSL加密、文件列表获取、流式下载及资源释放等核心环节,帮助读者理解FtpWebRequest与FtpWebResponse在实际项目中的配合方式。压缩包共21个文件,约65KB,包含6个cs源码文件、1个sln解决方案与1个csproj工程文件,另有exe可执行程序、resx与resources资源文件、pdb调试符号及settings配置等,结构完整,可直接编译调试。目前已有693人学习下载。通过研读源码,读者可掌握下载流程的完整实现,并在此基础上扩展断点续传、进度显示、多线程并行下载与异常处理等特性,适合作为网络编程入门与功能扩展的参考范例。
1. C# FTP下载实例源码:网络操作里最容易被低估的一环
很多做 C# 上位机、工控数据采集的朋友,第一次碰到 FTP 下载都觉得这活儿简单——不就是连上去把文件拉下来吗?结果真上手才发现,被动模式连不上、中文文件名乱码、大文件下到一半断了、服务器返回 550 权限错误,一个比一个玄学。C# FTP下载实例源码这类需求,在工控上位机、MES 数据回传、设备日志归档场景里出现频率极高,因为大量老设备和第三方系统只认 FTP 这一种文件交换方式。这篇不讲空泛概念,直接围绕 C# 网络操作里 FTP 下载的完整落地路径展开:从 FtpWebRequest 的原生写法,到连接模式选型、断点续传、异常排查,每一步都给可抄的代码和参数说明。适合有 C# 基础、正在做上位机或数据采集、需要把远端文件稳定拉到本地的开发者。读完你能自己搭一个能跑在生产环境里的下载模块,而不是只能跑通 demo。
2. FtpWebRequest 原生下载:从连接建立到文件落盘
2.1 为什么优先用 FtpWebRequest 而不是第三方库
C# 做 FTP 下载,摆在面前的路大概三条:FtpWebRequest(.NET 原生)、FtpWebResponse配合流操作、或者引入 FluentFTP 这类第三方库。我的建议是:如果你的目标框架是 .NET Framework 4.x 或 .NET 6/8,且需求就是标准的下载、上传、列目录,优先用原生FtpWebRequest。原因很实际——上位机项目经常要部署到客户现场的内网机器上,少一个 NuGet 依赖就少一份部署风险,而且原生类不需要额外授权,代码审计也好过。
FtpWebRequest的定位是「基于 RFC 959 的轻量封装」,它把 FTP 协议的命令交互(USER、PASS、PASV、RETR、QUIT)藏在属性后面。你设置Method = WebRequestMethods.Ftp.DownloadFile,它内部就走 RETR 命令。理解这一点很关键,因为后面所有排错,本质都是在看它发出的 FTP 命令和服务器返回的状态码对不对得上。
需要提前说清楚它的边界:FtpWebRequest不支持 SFTP(那是 SSH 协议,得用 SSH.NET),不支持 FTPS 的隐式模式(显式 AUTH TLS 可以),也不支持并发多文件下载的会话复用。所以如果你的场景是「从一台老 PLC 的 FTP 服务拉日志」,它够用;如果是「从云存储网关批量同步上万文件」,那要考虑连接池和第三方库。
2.2 最小可运行下载代码与逐行参数说明
先给一个能直接跑的最小实例。假设 FTP 服务器地址是192.168.1.100,端口 21,账号operator,密码op123456,要下载/log/2024/device_01.csv到本地D:\data\device_01.csv。
using System; using System.IO; using System.Net; public class FtpDownloader { public static bool DownloadFile( string ftpUrl, // 完整 FTP 路径,如 ftp://192.168.1.100/log/2024/device_01.csv string user, // FTP 账号 string password, // FTP 密码 string localPath) // 本地保存路径 { try { // 1. 创建请求对象,指定为下载文件 FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method = WebRequestMethods.Ftp.DownloadFile; request.Credentials = new NetworkCredential(user, password); // 2. 关键参数:超时、二进制模式、连接模式 request.Timeout = 30000; // 命令超时 30 秒 request.ReadWriteTimeout = 60000; // 数据读写超时 60 秒 request.UseBinary = true; // 二进制传输,避免文件损坏 request.UsePassive = true; // 被动模式,穿透 NAT 更稳 request.KeepAlive = false; // 下载完即断,避免连接被服务器回收 // 3. 发起请求并获取响应流 using (FtpWebResponse response = (FtpWebResponse)request.GetResponse()) using (Stream responseStream = response.GetResponseStream()) using (FileStream fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write)) { byte[] buffer = new byte[8192]; // 8KB 缓冲区,兼顾内存和吞吐 int bytesRead; while ((bytesRead = responseStream.Read(buffer, 0, buffer.Length)) > 0) { fileStream.Write(buffer, 0, bytesRead); } } return true; } catch (WebException ex) { // 打印 FTP 状态码,排错时最关键的信息 if (ex.Response is FtpWebResponse ftpResp) { Console.WriteLine($"FTP 状态: {ftpResp.StatusCode} - {ftpResp.StatusDescription}"); } Console.WriteLine($"下载失败: {ex.Message}"); return false; } } }这段代码的逻辑分三层。第一层是请求构造:WebRequest.Create传入的 URL 必须是ftp://开头,路径里的目录层级要和服务器上完全一致,大小写敏感取决于服务器操作系统(Linux 上区分,Windows IIS FTP 不区分)。第二层是参数设置,UseBinary = true是血泪经验——如果不设,默认走 ASCII 模式,下载二进制文件(比如压缩包、图片)时服务器会把\r\n做转换,文件直接损坏,而且这种损坏不会报错,你拿到手才发现打不开。第三层是流式读取,用 8KB 缓冲区循环读,而不是一次性ReadToEnd,因为设备日志动辄几百 MB,一次性读进内存会直接 OOM。
参数怎么调:Timeout是命令通道超时,登录慢的服务器要加大;ReadWriteTimeout是数据通道超时,大文件下载慢就调这个;KeepAlive = false在批量下载场景下反而要设成true并复用连接,否则每次下载都重新登录,服务器可能因为频繁连接把你 IP 拉黑。
2.3 主动模式与被动模式的选型判断
UsePassive这个参数值得单独拎出来讲,因为它是 FTP 下载翻车率最高的地方。FTP 协议有两条连接:命令通道(21 端口)和数据通道。主动模式(PORT)下,是服务器主动连回客户端的某个端口来传数据;被动模式(PASV)下,是客户端去连服务器开的一个临时端口。
现实情况是:客户端几乎都在 NAT 或防火墙后面,服务器主动连回来基本连不上,所以默认用被动模式。但被动模式也有坑——服务器返回的 PASV 响应里带一个 IP 和端口,如果服务器在 NAT 后面且配置不当,返回的是内网 IP,客户端连过去就是超时。判断方法:抓包看 PASV 响应里的 IP 是不是公网可达的。如果服务器返回内网 IP,要么让运维修服务器配置,要么在代码里忽略返回的 IP、强制用控制连接的 IP(这需要更底层的 Socket 操作,FtpWebRequest不直接支持,得换库)。
我一般的做法是:内网环境先试被动模式,连不上再切主动模式试一次,两个都不行就是服务器或网络配置问题,别在代码里死磕。
3. 断点续传与批量下载:让下载模块扛住生产环境
3.1 用 REST 命令实现断点续传的完整逻辑
生产环境里网络抖动是常态,一个 500MB 的日志下到 80% 断了,如果每次都从头来,运维能把你骂死。FTP 协议本身支持断点续传,靠的是REST命令——告诉服务器「从第 N 个字节开始传」。FtpWebRequest没有直接暴露 REST,但可以通过ContentOffset属性设置。
public static bool DownloadWithResume(string ftpUrl, string user, string password, string localPath) { long existingLength = 0; // 检查本地是否已有部分文件 if (File.Exists(localPath)) { existingLength = new FileInfo(localPath).Length; } FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method = WebRequestMethods.Ftp.DownloadFile; request.Credentials = new NetworkCredential(user, password); request.UseBinary = true; request.UsePassive = true; request.ContentOffset = existingLength; // 关键:从已有长度处续传 using (FtpWebResponse response = (FtpWebResponse)request.GetResponse()) using (Stream responseStream = response.GetResponseStream()) // 注意:续传时用 Append 而不是 Create using (FileStream fileStream = new FileStream(localPath, FileMode.Append, FileAccess.Write)) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = responseStream.Read(buffer, 0, buffer.Length)) > 0) { fileStream.Write(buffer, 0, bytesRead); } } return true; }这里有两个必须配对使用的点:ContentOffset设成已有文件长度,同时本地文件必须用FileMode.Append打开。如果用了Create,续传的数据会覆盖掉前面已下载的部分,文件就废了。另外,续传前最好校验一下服务器上文件的总大小和本地已下载大小,如果本地文件比服务器文件还大,说明本地是脏数据,得删掉重下。
ContentOffset的底层就是发REST N命令,服务器返回 350 表示接受,然后 RETR 从 N 开始传。如果服务器不支持 REST(一些老旧的嵌入式 FTP 服务就不支持),会返回 500 或 502,这时候只能老老实实全量下载。
3.2 批量下载时的连接复用与并发控制
上位机场景经常要一次拉一个目录下的所有文件。新手容易写成foreach里每次 new 一个FtpWebRequest,结果就是频繁登录、频繁握手,慢不说,还容易被服务器判定为异常连接。正确做法是复用连接,同时控制并发数。
using System.Collections.Concurrent; using System.Threading.Tasks; public static void BatchDownload(string[] ftpUrls, string user, string password, string localDir) { // 并发度控制在 3~5,太高会触发服务器连接数限制 var options = new ParallelOptions { MaxDegreeOfParallelism = 3 }; var errors = new ConcurrentBag<string>(); Parallel.ForEach(ftpUrls, options, url => { string fileName = Path.GetFileName(new Uri(url).LocalPath); string localPath = Path.Combine(localDir, fileName); try { DownloadFile(url, user, password, localPath); } catch (Exception ex) { errors.Add($"{url} -> {ex.Message}"); } }); foreach (var err in errors) { Console.WriteLine($"失败: {err}"); } }并发度设 3 到 5 是经验值。IIS FTP 默认限制单 IP 最多 10 个并发连接,超过就返回 421(服务不可用,连接过多)。而且并发太高时,多个线程抢带宽,单个文件反而更慢。Parallel.ForEach配合ConcurrentBag收集异常,保证一个文件失败不影响其他文件继续下。
如果文件数量特别多(上千个),更好的做法是先把目录列表拉下来(ListDirectoryDetails),解析出文件名和大小,再按大小排序,小的先下、大的后下,这样能快速拿到大部分文件,避免卡在某个大文件上。
3.3 中文文件名与路径编码的处理
中文文件名乱码是 C# FTP 下载里另一个高频坑。FTP 协议早期用 ASCII,中文文件名靠服务器和客户端各自猜编码。IIS FTP 默认用 UTF-8,但很多国产 FTP 服务(比如某些设备内置的)用 GBK。
FtpWebRequest在 .NET Framework 下默认用Encoding.Default,在中文 Windows 上就是 GBK,所以连 IIS 时反而乱码。解决办法是显式设置编码:
// .NET Framework 下强制 UTF-8 request.Headers["Accept-Encoding"] = "utf-8"; // 或者更彻底的方式,在请求前设置全局编码 System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);在 .NET Core / .NET 5+ 上,默认编码是 UTF-8,连 GBK 的服务器会乱码,这时候需要注册CodePagesEncodingProvider并手动指定。判断服务器用哪种编码的土办法:用浏览器或 FileZilla 连上去看文件名显示是否正常,FileZilla 的站点管理器里能看它自动选的编码。
如果编码实在搞不定,还有个绕过方案:下载时用文件的完整 URL(URL 里对中文做百分号编码),本地保存时用自己生成的文件名(比如时间戳),避开中文。这个方案不优雅,但在现场调试时能快速让流程跑通。
4. FTP 下载避坑清单:五个真实翻车现场
4.1 现象:下载下来的文件大小对但打不开
原因:UseBinary没设成true,走了 ASCII 模式。ASCII 模式会把\n转成\r\n,二进制文件(zip、exe、图片)结构被破坏。这个坑最阴险的地方在于,文本文件看不出问题,只有二进制文件才暴露。
解决:所有下载请求无条件设request.UseBinary = true。不要根据文件扩展名判断,统一设二进制,文本文件在二进制模式下也能正常下载。
4.2 现象:连接超时,但 ping 得通、端口也通
原因:被动模式下服务器返回的 PASV IP 是内网地址,客户端连不上。或者服务器防火墙只开了 21 端口,没开被动模式的数据端口范围。
解决:先用 FTP 客户端工具(FileZilla)连一次,看它用的是主动还是被动模式。如果 FileZilla 能连上而你的代码连不上,对比两者的模式设置。服务器侧需要运维配置被动模式的端口范围(比如 50000-50100)并在防火墙放行。
4.3 现象:下载到 99% 卡住,然后超时
原因:ReadWriteTimeout太短,或者服务器在传完后没有正确关闭数据连接。有些老 FTP 服务在文件传完后不发 226 完成码,客户端一直等。
解决:把ReadWriteTimeout调到 120000(2 分钟)以上。如果服务器确实有 bug,可以在读取循环里加一个「连续 N 秒没有新数据就主动关闭」的逻辑,但更推荐让运维升级服务器 FTP 服务。
4.4 现象:批量下载时部分文件报 550 权限错误
原因:FTP 账号对某些文件没有读权限,或者文件正在被服务器端进程占用。550 是「请求的操作未执行」,具体原因要看StatusDescription。
解决:不要因为一个 550 就中断整个批量任务。在异常处理里区分状态码,550 记录到失败列表继续下一个,最后统一报告。如果是权限问题,让运维检查 FTP 账号的目录权限。
4.5 现象:程序跑一段时间后报「无法将数据写入传输连接」
原因:连接被服务器或中间网络设备(防火墙、负载均衡)空闲超时回收了,但客户端还在用这个连接。这个报错在热词里也常出现,本质是 TCP 连接已断但应用层不知道。
解决:KeepAlive设成false,每次下载用新连接。如果必须复用,加心跳机制(定期发 NOOP 命令)。另外,捕获这个异常后要能自动重试,重试前先关闭旧连接。
5. 用异步与进度回调把下载模块做成可复用的组件
前面讲的都是「能下下来」,这一章讲「下得优雅」。生产环境的下载模块,用户(或者你的上位机界面)需要看到进度,而且不能因为下载卡住整个 UI 线程。FtpWebRequest本身支持异步,配合IProgress<T>回调,可以做成一个干净的可复用组件。
先看异步下载的核心写法:
public static async Task<bool> DownloadFileAsync( string ftpUrl, string user, string password, string localPath, IProgress<double> progress = null) { var request = (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method = WebRequestMethods.Ftp.DownloadFile; request.Credentials = new NetworkCredential(user, password); request.UseBinary = true; request.UsePassive = true; using (var response = (FtpWebResponse)await request.GetResponseAsync()) { long total = response.ContentLength; // 服务器返回的文件总大小 long received = 0; using (var responseStream = response.GetResponseStream()) using (var fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write)) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { await fileStream.WriteAsync(buffer, 0, bytesRead); received += bytesRead; if (total > 0 && progress != null) { progress.Report((double)received / total * 100); } } } } return true; }这里的关键点是response.ContentLength。FTP 的 RETR 响应里,服务器会返回文件大小(在 150 响应里),FtpWebResponse把它解析到ContentLength。但要注意,不是所有服务器都返回准确的大小,有些返回 -1。所以进度计算前要判断total > 0,否则会除零或者算出负数。
进度回调用IProgress<double>而不是直接传Action<double>,是因为IProgress会自动把回调 marshal 回创建它的线程(通常是 UI 线程),避免跨线程更新控件报错。在上位机里,你可以在窗体构造函数里创建Progress<double>,然后在回调里更新进度条。
再给一个带重试的封装思路。网络操作没有重试就是耍流氓,但重试不能无脑重试,要区分异常类型:
| 异常类型 | 是否重试 | 重试策略 |
|---|---|---|
| WebException 超时 | 是 | 最多 3 次,间隔 2 秒递增 |
| WebException 550 权限 | 否 | 直接记录失败 |
| IOException 连接断开 | 是 | 配合断点续传,从断点继续 |
| 认证失败 530 | 否 | 检查账号密码 |
重试的实现用Polly库最省事,但如果你不想引依赖,手写一个for循环加Task.Delay也够用。我一般会写一个RetryAsync辅助方法,把「重试次数、间隔、哪些异常该重试」做成参数。
最后说一个验证下载完整性的技巧。FTP 下载完,怎么确认文件没坏?最可靠的是比对 MD5。如果服务器上能拿到文件的 MD5(有些 FTP 服务支持SITE MD5命令),下载后本地算一遍比对。拿不到 MD5 的话,至少比对文件大小——response.ContentLength和本地文件长度必须一致,不一致就说明传输中断了。这个校验成本极低,但能拦住 90% 的静默损坏。
我自己做这类模块的习惯是:先写一个能跑通的最小版本,然后立刻加上「大小校验 + 失败重试 + 进度回调」三件套,再放到现场跑。现场网络环境比办公室恶劣得多,没有这三样,第一次断线你就得跑一趟客户现场。希望帮到你。
本文还有配套的精品资源,点击获取