简介:这份C# FTP下载实例源码面向具备一定.NET基础的开发者,用于解决文件传输场景中FTP下载功能的实现问题,帮助理解网络操作在实际项目中的应用。资源包共21个文件,以cs源码文件为主,配合sln解决方案、csproj项目文件、resx资源文件及exe可执行程序等,整体约65KB,结构完整可直接编译运行。源码围绕FtpWebRequest与FtpWebResponse类展开,涵盖连接服务器、凭据登录、SSL/TLS安全设置、获取文件列表、流式读取与本地写入、资源释放等关键环节,并涉及异常处理、下载进度显示与多线程并行下载等扩展思路。已有693人学习下载,适合作为FTP网络编程的入门参考,读者可在此基础上扩展断点续传、文件大小校验等功能,满足更复杂的实际项目需求。
1. C# FTP下载实例源码:网络操作里最容易被低估的一环
很多做 C# 上位机、工控采集、MES 对接的朋友,第一次接到“从 FTP 服务器定时拉文件”的需求时,都会觉得这事简单——FtpWebRequest几行代码就完事。真到产线上跑起来才发现:大文件下载到一半连接被重置、中文文件名变成乱码、被动模式下卡死不动、下载完的文件大小对不上。这些坑我在不同项目里几乎踩了个遍,所以这篇笔记不打算讲“FTP 是什么”,而是把 C# 做 FTP 下载这件事,从选型、最小可跑实例、参数配置到排错,按能直接抄作业的方式写清楚。
标题里的“实例源码”和“网络操作”是两个关键词。实例源码意味着我会给出能编译、能改、能直接嵌进 WinForm 或控制台项目的完整代码;网络操作意味着重点不在语法,而在连接模式、超时、编码、断点这些和网络行为强相关的参数。适合谁看:正在用 C# 写上位机、做设备日志回传、做数据采集落地的工程师,尤其是那些被FtpWebRequest的默认行为坑过一次的人。下面从最朴素的实现开始,一层层把可靠性补上。
2. 用 FtpWebRequest 跑通最小下载实例:从连接模式到落盘
2.1 为什么先选 FtpWebRequest 而不是第三方库
C# 里做 FTP 下载,主流有三条路:FtpWebRequest(.NET 自带)、FtpWebResponse配合流操作、以及第三方库如 FluentFTP。选型上我的习惯是:如果只是下载、上传、列目录这类基础操作,且不想引入额外依赖,FtpWebRequest足够;如果要做断点续传、并发、SSL 显式加密、代理,第三方库会省很多事。但很多工控现场的内网环境不允许随便引 NuGet 包,或者项目本身就是 .NET Framework 4.x 老工程,这时候FtpWebRequest是唯一稳妥选择。
需要提前说清楚一个边界:FtpWebRequest在 .NET 6 之后被标记为过时(obsolete),官方建议用第三方库。但在 .NET Framework 4.8 及更早的工程里它依然是主力。所以下面的实例我按 .NET Framework 4.x 的写法给,.NET Core/5+ 项目请自行评估是否换库。
2.2 最小可运行下载代码
先给一个能直接跑的最小实例,控制台程序即可。核心是构造FtpWebRequest、设置方法为DownloadFile、拿响应流写到本地文件。
using System; using System.IO; using System.Net; class FtpDownloader { // 下载单个文件的最小实现 public static bool DownloadFile(string ftpUrl, string userName, string password, string localPath) { FtpWebRequest request = null; FtpWebResponse response = null; FileStream fileStream = null; Stream responseStream = null; try { // 1. 创建请求,ftpUrl 形如 ftp://192.168.1.10/data/log_20240101.txt request = (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method = WebRequestMethods.Ftp.DownloadFile; request.Credentials = new NetworkCredential(userName, password); // 2. 关键:默认是主动模式,内网穿透场景常需改被动 request.UsePassive = true; request.UseBinary = true; // 二进制模式,避免文件被当文本处理 request.KeepAlive = false; // 下载完就断,避免连接池残留 request.Timeout = 30000; // 控制命令超时 30 秒 request.ReadWriteTimeout = 60000; // 数据读写超时 60 秒 // 3. 发起请求并拿响应 response = (FtpWebResponse)request.GetResponse(); responseStream = response.GetResponseStream(); // 4. 落盘,用 FileMode.Create 覆盖同名文件 fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write); byte[] buffer = new byte[8192]; int read; while ((read = responseStream.Read(buffer, 0, buffer.Length)) > 0) { fileStream.Write(buffer, 0, read); } return true; } catch (WebException ex) { // FTP 错误会带 FtpWebResponse,能拿到状态码 if (ex.Response is FtpWebResponse ftpResp) { Console.WriteLine($"FTP 错误:{ftpResp.StatusCode} - {ftpResp.StatusDescription}"); } else { Console.WriteLine($"网络错误:{ex.Message}"); } return false; } finally { // 5. 逆序释放,先关流再关响应 if (fileStream != null) fileStream.Dispose(); if (responseStream != null) responseStream.Dispose(); if (response != null) response.Close(); } } }逻辑说明:整个流程是“建请求 → 设参数 → 拿响应 → 读流写盘 → 释放”。WebRequest.Create传入的 URL 必须带ftp://前缀,否则会走 HTTP 处理器直接抛异常。GetResponse()这一步才是真正建立连接,前面都只是配置。
参数说明几个容易忽略的:
UsePassive:默认值在 .NET Framework 里是true,但很多老代码显式设成false走主动模式。主动模式下服务器要反向连客户端端口,客户端在 NAT 或防火墙后面时基本连不通,所以内网跨网段场景一律设true。UseBinary:默认就是true,但如果你下载的是文本日志且服务器端做过换行转换,设false反而能拿到原始字节。工控场景建议保持true。KeepAlive:默认true,会复用连接。批量下载几百个文件时复用能提速,但一旦某个文件出错,连接状态可能污染后续请求。我一般批量下载时设false,单文件下载无所谓。Timeout和ReadWriteTimeout:前者管控制命令(登录、列目录),后者管数据流读写。大文件下载卡死往往是ReadWriteTimeout太短或没设。
2.3 批量下载与目录遍历
单文件下载跑通后,实际需求通常是“把服务器某个目录下所有当天日志拉下来”。这就需要先列目录再逐个下载。
using System; using System.Collections.Generic; using System.Net; class FtpBatch { // 列出目录下所有文件名 public static List<string> ListFiles(string ftpDirUrl, string user, string pwd) { var files = new List<string>(); FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpDirUrl); request.Method = WebRequestMethods.Ftp.ListDirectory; request.Credentials = new NetworkCredential(user, pwd); request.UsePassive = true; request.UseBinary = true; request.Timeout = 30000; using (FtpWebResponse response = (FtpWebResponse)request.GetResponse()) using (var reader = new System.IO.StreamReader(response.GetResponseStream())) { string line; while ((line = reader.ReadLine()) != null) { // 过滤掉 . 和 .. 这类目录项 if (!string.IsNullOrWhiteSpace(line) && line != "." && line != "..") { files.Add(line.Trim()); } } } return files; } }逻辑说明:ListDirectory返回的是纯文件名列表,每行一个。注意返回的可能是相对路径,拼完整 URL 时要用ftpDirUrl.TrimEnd('/') + "/" + fileName。参数上ListDirectory和ListDirectoryDetails的区别是后者返回类似 Unixls -l的详细信息,包含权限、大小、日期,需要自己解析。
批量下载时建议加个间隔,别一口气几百个请求打过去,服务器可能直接拒绝。我一般每个文件之间Thread.Sleep(50),或者用信号量控制并发数不超过 3。
3. 中文文件名、编码与断点续传:三个必须处理的现实问题
3.1 中文文件名乱码的根因与修法
FTP 协议本身对文件名编码没有强制规定,服务器用 GBK 还是 UTF-8 取决于服务端配置。FtpWebRequest默认按 ASCII 处理,遇到中文就乱码或直接报 550 找不到文件。
修法是给请求设置Encoding属性。但这里有个坑:FtpWebRequest没有公开的Encoding属性可以直接设,需要通过反射或者改用WebRequestMethods之外的方式。实际可行的做法是设置全局的WebRequest.DefaultWebProxy无关,真正管用的是在 .NET Framework 里通过request.Headers不行,得用下面这个方式:
// 设置 FTP 命令和响应的编码为 GBK(适配多数国产 FTP 服务端) System.Text.Encoding gbk = System.Text.Encoding.GetEncoding("GBK"); request.Headers["Content-Encoding"] = "GBK"; // 部分场景无效,仅作示意说实话,FtpWebRequest对编码的支持很弱,反射改私有字段m_Encoding是网上流传的做法,但版本升级后字段名可能变,属于玄学操作。更稳的方案是:如果服务端支持 UTF-8,在连接后先发OPTS UTF8 ON命令。FtpWebRequest不直接暴露发原始命令的接口,所以真要处理中文文件名,我一般直接换 FluentFTP,它构造时能指定Encoding。
如果必须用FtpWebRequest,退而求其次的办法是:先用ListDirectoryDetails拿到服务器返回的原始字节,自己按 GBK 解码,再拼 URL 时用Uri.EscapeUriString转义。这条路能走通但代码丑,属于血泪经验。
3.2 断点续传的实现要点
大文件下载中断后重头再来,在产线上是灾难。FTP 支持REST命令指定偏移量,FtpWebRequest通过ContentOffset属性暴露。
// 断点续传:先看本地已下载多少字节,再从该位置继续 long localLength = 0; if (File.Exists(localPath)) { localLength = new FileInfo(localPath).Length; } request = (FtpWebRequest)WebRequest.Create(ftpUrl); request.Method = WebRequestMethods.Ftp.DownloadFile; request.Credentials = new NetworkCredential(user, pwd); request.UsePassive = true; request.UseBinary = true; request.ContentOffset = localLength; // 关键:从已下载位置继续 using (var response = (FtpWebResponse)request.GetResponse()) using (var responseStream = response.GetResponseStream()) using (var fileStream = new FileStream(localPath, FileMode.Append, FileAccess.Write)) { byte[] buffer = new byte[8192]; int read; while ((read = responseStream.Read(buffer, 0, buffer.Length)) > 0) { fileStream.Write(buffer, 0, read); } }逻辑说明:ContentOffset告诉服务器从第 N 字节开始传,本地用FileMode.Append追加写入。参数上要注意:如果本地文件长度已经等于服务器文件大小,ContentOffset设成文件大小会返回 0 字节,需要先比对大小再决定是否下载。服务器文件大小可以通过GetFileSize方法拿:
request.Method = WebRequestMethods.Ftp.GetFileSize; using (var resp = (FtpWebResponse)request.GetResponse()) { long remoteSize = resp.ContentLength; }3.3 超时与重试的工程化封装
网络操作没有重试就是耍流氓。但重试不能无脑循环,要区分错误类型:连接超时可以重试,认证失败重试多少次都没用。
public static bool DownloadWithRetry(string url, string user, string pwd, string localPath, int maxRetry = 3) { for (int i = 0; i < maxRetry; i++) { try { if (DownloadFile(url, user, pwd, localPath)) return true; } catch (WebException ex) { // 530 是未登录,550 是文件不存在,这类不重试 if (ex.Response is FtpWebResponse ftpResp) { var code = (int)ftpResp.StatusCode; if (code == 530 || code == 550) return false; } // 其他错误等 2 秒再试 System.Threading.Thread.Sleep(2000); } } return false; }参数说明:maxRetry建议 3 次,间隔 2 秒起步。如果是跨公网或网络抖动大的环境,间隔可以指数退避。注意DownloadFile内部要保证失败时清理掉半截文件,否则下次续传会基于错误长度。
4. 避坑与排查:FTP 下载在工控现场的 5 个真实翻车记录
4.1 现象:下载小文件正常,大文件到 99% 卡死
原因:ReadWriteTimeout默认值和实际网络带宽不匹配,或者服务器端在传输末尾做了校验导致延迟。另一个常见原因是KeepAlive=true时连接被中间设备提前回收。
解决:显式设ReadWriteTimeout为 60000 以上,大文件场景设KeepAlive=false,并在读流循环里加超时判断。如果还卡,用responseStream.ReadTimeout单独设。
4.2 现象:中文文件名报 550 找不到文件
原因:服务端用 GBK,客户端按 UTF-8 发命令,字节序列对不上。
解决:优先让服务端开 UTF-8 支持;不行就换 FluentFTP 指定编码;再不行用ListDirectoryDetails拿原始字节自己解码后拼 URL。
4.3 现象:被动模式下连接超时,主动模式正常
原因:客户端在 NAT 后,被动模式服务器返回的 IP 是内网地址,客户端连不上。这是 FTP 协议设计的历史遗留问题。
解决:让服务端配置被动模式返回公网 IP,或者客户端侧改用主动模式并开放端口。内网环境一般没这问题,跨网段必踩。
4.4 现象:批量下载几百个文件后程序内存暴涨
原因:FtpWebResponse和Stream没及时释放,连接池堆积。
解决:每个文件下载完立即Dispose,用using包住。批量场景设KeepAlive=false,并控制并发数。我一般用SemaphoreSlim限流到 3 个并发。
4.5 现象:下载完的文件比服务器上小几 KB
原因:UseBinary=false时文本模式会做换行转换,或者流没读完就关了。
解决:确认UseBinary=true,读流循环用while ((read = stream.Read(...)) > 0)直到返回 0,不要用固定次数读。下载完比对ContentLength和本地文件大小。
5. 把下载封装成可复用类库:一个具体技巧
前面都是散装代码,实际项目里我会把 FTP 下载封装成一个类,对外只暴露Download和DownloadBatch两个方法。这里给一个精简版的封装思路,重点在异常分类和日志埋点。
public class FtpClientWrapper : IDisposable { private readonly string _host; private readonly string _user; private readonly string _pwd; private readonly int _timeout; public FtpClientWrapper(string host, string user, string pwd, int timeout = 30000) { _host = host.TrimEnd('/'); _user = user; _pwd = pwd; _timeout = timeout; } // 对外统一入口,返回下载结果和错误信息 public (bool Success, string Message) Download(string remotePath, string localPath) { try { string url = $"{_host}/{remotePath.TrimStart('/')}"; var request = (FtpWebRequest)WebRequest.Create(url); request.Method = WebRequestMethods.Ftp.DownloadFile; request.Credentials = new NetworkCredential(_user, _pwd); request.UsePassive = true; request.UseBinary = true; request.KeepAlive = false; request.Timeout = _timeout; request.ReadWriteTimeout = _timeout * 2; using (var response = (FtpWebResponse)request.GetResponse()) using (var stream = response.GetResponseStream()) using (var fs = new FileStream(localPath, FileMode.Create, FileAccess.Write)) { stream.CopyTo(fs, 8192); } return (true, "OK"); } catch (WebException ex) { string msg = ex.Response is FtpWebResponse r ? $"{(int)r.StatusCode} {r.StatusDescription}" : ex.Message; return (false, msg); } } public void Dispose() { } }这个封装的价值在于:调用方不用关心 FTP 细节,拿到(bool, string)就能决定是重试、告警还是跳过。日志埋点建议在Download里加Stopwatch记录耗时,超过阈值就记警告——产线上经常是某个文件特别大或者网络突然变慢,有耗时日志才能定位。
一个具体技巧:如果下载目录里文件很多,不要每次全量拉,用文件名里的日期做过滤。比如日志文件命名是log_20240101.txt,就只下载当天日期匹配的。这比下载完再删要省带宽,也避免磁盘被历史文件塞满。
最后说个我自己的习惯:任何 FTP 下载代码上线前,一定先拿一个 0 字节文件、一个 1GB 大文件、一个中文名文件各跑一遍。这三个用例能覆盖 90% 的边界问题。希望帮到你。
本文还有配套的精品资源,点击获取