C# FluentFTP 文件上传:异步、断点续传与排错指南 1. 为什么传个文件还要专门挑一个 FTP 库做企业内部系统对接的朋友大概率都遇到过这种需求合作方丢过来一个 FTP 账号、一个目录、一句文件放上去就行然后你打开 Visual Studio看着System.Net.FtpWebRequest那个类发呆。这东西能用但用起来像是十年前的工具箱——每上传一个文件就要 new 一个请求对象同步阻塞进度看不见超时不好控报错只给你一个粗糙的WebExceptionStatus字段还得自己猜。我最早接手一个每天凌晨要把本地生成的报表传上去的小工具用原生类写了不到两百行结果跑了两周每天早上都有几封失败告警追查下来全是数据连接超时和编码乱码这两件事。后来换成 FluentFTP 重写同样功能的代码缩到七十多行还顺手把进度条、断点续传、失败重试都加上了。所以这篇东西想聊的就是这件事在 C# 里把本地文件通过 FTP 传到远端服务器用 FluentFTP 这个库怎么落地参数怎么配坑在哪里。适合两类人看——一类是刚开始写上传功能、还没踩过坑的新手另一类是已经在用原生类或者别的老库被各种莫名其妙的中断折磨过、想换个方案的熟手。文中的代码都是可以直接粘进项目里跑的参数取值也给了解释不是那种只告诉你这样写就行的说明书。先说清楚一个前提FTP 这个协议本身很老它的设计年代里没有安全这个概念用户名口令全明文数据连接也是裸的。所以现在的实际场景里要么走内网专线要么上 FTPSFTP over TLS。FluentFTP 对这两种都支持下面会分别说到。但有个特别容易混淆的点得提前讲FluentFTP 不支持 SFTP。SFTP 是基于 SSH 的另一个协议虽然名字里也有 FTP 三个字母但两者毫无血缘关系端口、报文、握手全不一样。想连 SFTP 服务器得换 SSH.NET 那个库。我见过不只一个人在这个点上卡了半天一直以为连不上是配置问题。2. 动手前的环境与依赖准备2.1 版本选择比想象中重要装包本身很简单dotnet add package FluentFTP或者 Visual Studio 的 NuGet 界面搜一下都行。但版本这件事值得单独说一句因为 FluentFTP 在 50 版本做了一次不小的改动把原来一个FtpClient同时提供同步和异步两套方法的做法拆开了同步的还在FtpClient里异步那套搬到了AsyncFtpClient。这意味着你在网上搜到的示例代码可能一半是client.UploadFile(...)另一半是await client.UploadFile(...)形式上不一样不是因为谁写错了而是版本不同。我的建议是新开的项目直接上最新的稳定版用AsyncFtpClient理由很实际上传是要等网络的活儿同步方法会占着一个线程干等在 WinForm 里会让界面卡住在 Web 服务里会吃掉线程池。异步版本没有这些问题。老项目如果已经在用旧版并且跑得好好的没必要专门为了升级而升级除非你需要新版才有的特性。再把框架版本提一句FluentFTP 现在对 .NET Framework 4.6.2 以上、.NET Standard 2.0 以上、以及各代 .NET 都支持工业现场的机器上跑着 .NET Framework 4.7.2 的也完全没问题这一点在选型时是个加分项很多新潮的库已经把老框架砍掉了而工控机、内网服务器这类环境的升级周期长得很。2.2 连之前先把参数摸清楚在写第一行代码之前有几个参数得跟对方要明确不然调试时会浪费大量时间在猜。参数常见取值说明与踩坑点主机地址IP 或域名内网建议直接写 IP省掉 DNS 解析这一层不确定性端口21明文/显式 TLS、990隐式 TLS端口被改过的情况非常多别默认就是 21用户名/口令由服务方提供注意口令里有没有特殊字符配置文件里要正确转义传输模式主动 / 被动客户端在内网、服务端在公网时几乎必须用被动编码UTF-8 / GBK中文文件名乱码的头号原因根目录如/upload/2024有些账号被限制在自己的家目录写绝对路径反而失败权限读/写/删/建目录只有写权限时创建目录的代码会抛异常要有兜底这几个参数里传输模式和编码是最容易出问题的两个后面会各开一节细说。其余的几个属于问清楚能省两小时的范畴尤其是权限很多服务方开账号时只给了写文件权限没给建目录权限代码里一调CreateDirectory就报 550然后你开始怀疑人生。3. 上传核心实现从能跑到跑得住3.1 最小可用版本先确认链路通写任何网络功能我的习惯都是先写一个能跑通的最小版本把链路打通再谈健壮性。下面这段是能直接用的最短路径using FluentFTP; var client new AsyncFtpClient(192.168.10.20, uploader, Passw0rd, 21); // 先设置几个关键参数再连接 client.Config.DataConnectionType FtpDataConnectionType.AutoPassive; client.Config.ConnectTimeout 10000; client.Config.ReadTimeout 15000; await client.Connect(); var status await client.UploadFile( D:\reports\20240612.csv, /upload/20240612.csv, FtpRemoteExists.Overwrite, createRemoteDir: true); Console.WriteLine($上传结果{status}); await client.Disconnect();注意UploadFile的返回值是FtpStatus枚举成功是Success还有Failed、Skipped这些值。很多人不看返回值只要没抛异常就当成功结果遇到同名文件且策略是Skip时文件根本没传上去日志里却写着上传成功。这个坑我踩过一次在一批文件里漏了一个对账时才发现的。createRemoteDir: true这个参数的意思是远端目录不存在时自动创建。听起来很贴心但它内部是逐级尝试创建如果账号没有建目录权限这个参数会让原本能成功的上传变成失败。所以稳妥的做法是要么确认账号有权限要么自己先DirectoryExists判断再决定。3.2 异步上传、进度回调与取消进度回调是我认为 FluentFTP 相比原生类最舒服的地方。UploadFile的最后一个可空参数是IProgressFtpProgress塞进去就能拿到百分比、已传字节数、速度、剩余时间。var progress new ProgressFtpProgress(p { // p.Progress 是 0~100 的百分比p.TransferredBytes / p.TotalBytes 是字节数 Console.WriteLine($[{p.Progress,6:F1}%] {p.TransferredBytes}/{p.TotalBytes} $速度 {p.TransferSpeedBytes / 1024.0:F0} KB/s 剩余 {TimeSpan.FromSeconds(p.ETA):mm\\:ss}); }); var cts new CancellationTokenSource(TimeSpan.FromMinutes(30)); var status await client.UploadFile( localPath, remotePath, FtpRemoteExists.Overwrite, createRemoteDir: true, FtpVerify.None, progress, cts.Token);这里有两个细节值得展开。第一ProgressT的回调是投递到创建它的同步上下文里执行的在 WinForm 或 WPF 里你可以直接在回调里更新界面控件不用自己Invoke在控制台或后台服务里它走线程池也不用担心。第二取消令牌一定要给而且要设一个合理的上限。我见过没设超时的上传任务因为服务端某一刻卡住整个后台服务挂在那里几个小时不返回后面的任务全排队堵死。顺带说下FtpVerify它是传完之后做完整性校验的选项。FtpVerify.None是不校验设成FtpVerify.OnlyChecksum会走服务端的HASH命令比对校验和但不是所有服务端都支持 HASH 命令不支持的会直接失败。所以我一般在内网可信环境用None做重要文件跨网传输时才开校验而且要先用client.HashAlgorithms探一下对方支持什么。3.3 断点续传与失败重试大文件上传最怕传到 90% 断了。FluentFTP 的续传靠的是FtpRemoteExists.Resumeawait client.UploadFile( localPath, remotePath, FtpRemoteExists.Resume, // 远端已存在则从断点续不存在则新建 createRemoteDir: true);它的内部逻辑是先用SIZE命令拿到远端已有文件的大小再用REST命令告诉服务端从哪个偏移开始写。这里有个前提远端文件必须是你上次传了一半的那个文件。如果远端存在一个同名但内容完全不同的旧文件续传会把新文件的尾部接在旧文件头后面得到一个又大又错的文件。所以我的做法是给续传场景配一个中间文件名比如report_20240612.csv.part传完之后再用MoveFile改成正式名字。这样即使中途失败正式目录里也不会出现半成品下游的扫描程序就不会读到不完整的文件。重试方面FluentFTP 自带client.Config.RetryAttempts设成 3 表示命令级别的失败会自动重发几次。但要注意这个重试是命令级的不是整个上传任务级。也就是说如果连接整个断了RetryAttempts救不了你得自己在外面包一层循环。const int maxTry 3; for (int i 1; i maxTry; i) { try { if (!client.IsConnected) await client.Connect(); var st await client.UploadFile(localPath, remoteFinalPath, FtpRemoteExists.Resume, true); if (st FtpStatus.Success) break; } catch (Exception ex) { if (i maxTry) throw; await Task.Delay(TimeSpan.FromSeconds(5 * i)); // 退避别死循环猛冲 } }这个退避很关键。我早期写的重试是失败立刻重试结果服务端因为连接数超限返回 421客户端三秒钟内连发三次直接把自己的 IP 拉进了临时黑名单恢复要等十分钟。改成 5 秒、10 秒、15 秒递增之后同样的限流场景基本都能自个儿缓过来。4. 协议层的开关配置项决定成败4.1 主动模式与被动模式别选错这是 FTP 最容易翻车的地方值得单独讲透。FTP 的连接分两条一条控制连接一般是 21 端口全程都在一条数据连接每次传文件时临时建立。主动模式下数据连接是服务端反过来连客户端被动模式下是客户端去连服务端告诉它的一个随机高端口。问题出在如今的网络环境客户端基本都在防火墙或 NAT 后面服务端主动连过来的那个连接会被直接丢掉现象就是登录成功、列目录卡住、传文件超时。所以客户端侧几乎应该无条件用被动模式。// 推荐先试扩展被动 EPSV不支持再退回 PASV client.Config.DataConnectionType FtpDataConnectionType.AutoPassive;AutoPassive的行为是先尝试 EPSVIPv6 友好失败再退回 PASV。如果对方的服务端明确只支持 PASV、或者处在某些老旧的负载均衡后面可以显式写FtpDataConnectionType.PASV。反过来如果你确实在做服务端到服务端的内网直连并且确认没有防火墙那AutoActive也可以但这种情况少之又少。注意被动模式下服务端每次传文件会开一个随机端口范围比如 50000-50100如果客户端出网有端口白名单一定要让服务方把这个范围告诉你并放行否则你看到的现象就是控制连接好好的一传文件就 425。4.2 编码中文文件名乱码的根源FTP 协议早年只有 ASCII后来扩展了UTF8命令来协商 UTF-8 编码。FluentFTP 默认用的是 UTF-8但很多国内的老服务端尤其是跑在某些老系统上的实际用的是本地字符集。// 明确指定 UTF-8如果对方是老服务端可能需要换成 GBK client.Encoding System.Text.Encoding.UTF8;排查乱码有个快速判断法上传后用client.GetListing()把远端目录列出来看返回的文件名是不是你传的名字。如果列表里是乱码但用别的 FTP 客户端看又是正常的那基本就是编码协商不一致如果列表里正常但下游系统读出来乱码那问题在下游不在你。这个区分很重要不然容易在错误的方向上改半天。我的习惯是不管三七二十一先把client.Encoding Encoding.UTF8显式写上然后跟服务方确认一句你们那边支持 UTF8 命令吗。支不支持和用不用问一句比试十次快。4.3 FTPS链路要不要走 TLS如果传输环境不是纯内网或者对方合规上有要求就得开 FTPS。FluentFTP 里对应的是FtpEncryptionModeclient.Config.EncryptionMode FtpEncryptionMode.Explicit; // 21 端口先明文再 AUTH TLS 升级 // client.Config.EncryptionMode FtpEncryptionMode.Implicit; // 990 端口连上即 TLS client.Config.SslProtocols System.Security.Authentication.SslProtocols.Tls12; client.Config.ValidateAnyCertificate false; // 仅限自签证书的内网环境公网务必设 trueExplicit和Implicit的区别在于握手时机显式是先连 21 端口明文打招呼然后发AUTH TLS升级隐式是一上来就 TLS端口通常是 990。这两种不通用用错的现象是连上就断或者握手超时。问服务方是显式还是隐式是最直接的办法问不出来就两个都试一下通常一分钟内就能确定。ValidateAnyCertificate false这一行我是特意写出来的因为内网自签证书太常见了不开这个开关必然报证书链错误。但要强调一句这个开关等于关掉了证书校验只应该在你能确定目标服务器的内网环境里用。公网环境设成 false等于把中间人攻击的门打开了这个责任不是写代码的人能兜住的。5. 排错实录那些报错信息没说清楚的事5.1 连接阶段就挂掉的几种情况连接阶段的失败看异常类型基本能定位。SocketException说明 TCP 层就没通八成是地址端口错、防火墙拦了、或者服务根本没起TimeoutException说明连上了但对方没在约定时间内回话通常是网络质量差或者服务端负载太高。这两种情况的处理策略完全不同代码里最好分开 catch不要笼统地 catch 一个 Exception 然后打一行连接失败。还有一个很好用但很少有人用的东西client.OnLogEvent。挂上之后能看到完整的命令往返排错效率能翻好几倍。client.OnLogEvent (level, msg) { // 只在调试时开生产环境记得关掉或降级日志量非常大 Console.WriteLine($[{level}] {msg}); };打开之后你会看到类似 USER uploader、 331 Password required、 PASS ***、 230 Logged in这样的对话。哪一步断的、服务端返回了什么码一目了然。我之前遇到一个登录成功但立刻被踢的问题日志里显示登录后服务端返回421 Too many connections才知道对方限制同一 IP 最多两个连接而我的代码里泄漏了一个没关的客户端。5.2 上传阶段的典型故障与速查表真正折磨人的是能连上、能列目录就是传不上去。下面这张表是我这几年攒下来的基本覆盖了九成情况。现象 / 返回码常见原因处理办法530 认证失败口令错、账号被锁、IP 白名单未加先用别的客户端验证账号本身是否可用550 权限不足 / 路径不存在账号没有目标目录写权限或目录未创建改用家目录相对路径或先DirectoryExistsCreateDirectory425 无法打开数据连接被动端口范围未放行、主动模式被防火墙拦统一改成AutoPassive让服务方开放端口段421 连接数超限客户端连接未释放或并发上传过多每个任务用完Disconnect并发数控制在 1~2426 数据连接中断网络抖动、大文件传输中被 QoS 掐断开Resume续传 外层重试552 超出存储配额服务端磁盘满或账号配额到顶加前置检查失败后清理远端历史文件上传成功但文件大小不对传成了 ASCII 模式换行被转换显式设UploadDataType FtpDataType.Binary中文名乱码编码协商不一致显式设client.Encoding与服务端对齐表里最后两条我想多说两句。ASCII 模式那个坑特别隐蔽因为文本文件用 ASCII 传除了 Windows 换行符变成 Unix 换行符之外看起来一切正常直到某天下游系统说 CSV 的所有列都串在一起了。二进制模式是唯一正确的选择FtpDataType.Binary这个设置我已经写进了模板类里新项目直接带上。5.3 一个容易被忽略的资源泄漏问题AsyncFtpClient实现了IDisposable很多人连接用完只调Disconnect()不调Dispose()表面看没问题但底层 socket 和部分缓冲区要等 GC 才回收。在短时间大量上传的场景下比如批量传几千张小图这个差别非常明显——不 Dispose 的话跑一会儿就会遇到端口耗尽或服务端连接数超限。正确写法是用using或者await usingawait using var client new AsyncFtpClient(host, user, pass);另外Disconnect()和Dispose()不是二选一的关系Dispose内部会调用断开逻辑所以只写using就够了。这个细节看起来琐碎但在批量任务里是能实打实减少故障率的。6. 生产环境的几个实践取舍6.1 连接复用还是每次新建这个问题没有标准答案取决于你的调用模式。单次上传一个文件就跑的脚本新建连接用完即弃最省心但如果是一个常驻服务每隔几分钟要传一批文件每次新建连接意味着每次都做一遍 TCP 握手、登录认证开销不小而且有些服务端对频繁登录有速率限制。我的折中方案是保持一个长连接客户端但每次使用前检查状态。private async Task EnsureReadyAsync(CancellationToken ct) { if (!client.IsConnected) await client.Connect(ct); // IsAuthenticated 为 false 时某些服务端会允许连接但拒绝操作 if (!client.IsAuthenticated) await client.Connect(ct); }要特别注意一点同一个客户端实例在同一时刻只能跑一个数据传输。多线程同时拿同一个 client 上传会出现控制连接的命令和应答错位报出各种离谱的错误。如果确实要并发两个办法每个线程自己 new 一个 client连接数上去了注意服务端限制或者用SemaphoreSlim(1)把上传操作串起来。FluentFTP 有个EnableThreadSafeConnections配置它的作用是在一个客户端内部开多个连接做并发但这类自动并发用起来要小心容易在服务端侧触发连接数限制我一般是关掉它自己在业务层串行化。6.2 目录结构、临时文件与清理远端文件越堆越多是个迟早的事。我一般在上传逻辑里顺手做三件事成本很低但省后续很多事。第一件是按日期分目录。路径写成/upload/2024/06/12/report.csv好处是单目录文件数不会爆炸某些服务端在单目录几万文件时列目录会慢到超时。第二件是先传临时名再改名前面提过.part后缀加上MoveFile收尾保证下游扫描到的永远是完整文件。第三件是上传成功后清理本地但要留一个失败保留的逻辑别一刀切全删了出问题时连现场都没了。var remoteTemp ${remoteDir}/{fileName}.part; var remoteFinal ${remoteDir}/{fileName}; await client.UploadFile(localPath, remoteTemp, FtpRemoteExists.Resume, createRemoteDir: true); await client.MoveFile(remoteTemp, remoteFinal, FtpRemoteExists.Overwrite);注意MoveFile在部分服务端上是通过RNFR/RNTO两条命令实现的如果服务端不支持重命名这一步会失败。所以这个方案在正式上之前最好拿测试账号先验证一次重命名是否可用。不可用的话退而求其次就是直接传正式名接受存在半成品的风险改用文件锁或者标记文件来同步下游。6.3 上传前的那几个检查值得花二十行代码线上跑久了会发现大量的失败其实是本可以提前发现的。所以我在真正上传前会做一轮前置检查本地文件是否存在且大小大于零零字节文件有时是上一环节写失败的产物、远端目录是否可达、磁盘配额是否够这个一般查不到只能靠上传后清理兜底、文件名是否符合服务端的命名要求有些服务端不接受某些特殊字符。var info new FileInfo(localPath); if (!info.Exists || info.Length 0) throw new InvalidOperationException($本地文件无效{localPath}); if (!await client.DirectoryExists(remoteDir)) await client.CreateDirectory(remoteDir); var free await client.GetFileSize(remoteFinal); // 远端同名文件大小用于判断是否需要续传这段代码看着平淡但它把失败点前移了——从传到一半才发现本地文件是空的变成一开始就报错日志里清清楚楚写着原因排查成本从半小时降到十秒。这类前置检查是我认为最划算的投入写的时候多花五分钟运维的时候少熬一个通宵。最后分享一个我自己踩过、也见别人踩过的小坑FTP 服务器返回的目录列表里文件名和路径的拼接方式在不同服务端上表现不一致有的返回绝对路径有的返回相对路径用client.GetListing()拿到条目后拼远端路径时最好统一用client.Path.Combine(remoteDir, item.Name)这种库提供的组合方法别自己上手拼字符串。为这个斜杠问题我在一个跨平台项目里查了整整一个下午最后发现根源就是 Windows 上的反斜杠被原样发给了服务端。这类问题不复杂但特别耗时间记下来能省事。