ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# FTP下载实例源码:网络操作中的可靠性实践与避坑指南

C# FTP下载实例源码:网络操作中的可靠性实践与避坑指南 简介这份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 Liststring ListFiles(string ftpDirUrl, string user, string pwd) { var files new Liststring(); 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默认值和实际网络带宽不匹配或者服务器端在传输末尾做了校验导致延迟。另一个常见原因是KeepAlivetrue时连接被中间设备提前回收。解决显式设ReadWriteTimeout为 60000 以上大文件场景设KeepAlivefalse并在读流循环里加超时判断。如果还卡用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包住。批量场景设KeepAlivefalse并控制并发数。我一般用SemaphoreSlim限流到 3 个并发。4.5 现象下载完的文件比服务器上小几 KB原因UseBinaryfalse时文本模式会做换行转换或者流没读完就关了。解决确认UseBinarytrue读流循环用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% 的边界问题。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进