简介:这是一份基于C# WinForm与FluentFTP组件实现的FTP客户端完整项目实例,核心解决桌面应用中的文件上传、下载需求,适合需要快速集成FTP功能的.NET开发者,也适合刚接触FTP编程的学生作为参考。项目采用Visual Studio 2022开发,目标框架为.NET Framework 4.8及以上,代码中包含了WinForm窗体界面、FTP工具类、自定义控件以及相关配置文件,可以直接运行或移植。整个压缩包共有62个文件,主要文件类型包括C#源代码文件(cs)、FluentFTP库及依赖的dll、xml配置与说明、resx/resources资源以及图标等,压缩后仅2.7MB,结构十分紧凑。目前已有613人学习,通过该项目可以理解FluentFTP的调用方式、WinForm界面如何与后台交互,以及上传下载流程中的常见错误处理思路;这些代码稍加修改即可用于上位机、文件管理工具等真实场景,节省从零开发的时间。
1. 为什么 WinForm 的 FTP 上传下载该用 FluentFTP
FTP 上传下载看起来是老掉牙的功能,但真要在 WinForm 里做得顺手,大多数人都会在半路换上 FluentFTP 这类库。直接用FtpWebRequest能发命令,可目录解析、断点续传、中文路径、进度条、被动模式切换这些全得自己补,一个小工具写完,代码量和出错率都上来了。这套 DemoFtp 实例是 VS2022 建的 WinForm 项目,基于 .NET Framework 4.8,通过 FluentFTP 45.1.0 实现上传、下载、远程目录浏览和进度显示,适合 C# 入门者跟着敲,也适合上位机、内网数据回传、自动发布工具直接复用。它把 FTP 协议细节收敛成几个方法,让开发者把精力放在业务按钮和流程上,这是它被 WinForm 项目频繁选中的原因。
2. 搭建 FluentFTP 连接会话:从 FtpClient 到目录清单
2.1 为什么弃用 FtpWebRequest 直连
FTP 客户端开发第一坑是底层 API 选型。.NET 自带的FtpWebRequest不是不能干活,但它的定位更像“协议接入点”,不是为交互式客户端设计的。每上传一个文件,你得创建FtpWebRequest、设置Method、写流、读响应;想拿到目录结构,还得自己解析ListDirectory返回的 Unix 或 Windows 风格列表,而且不同 FTP 服务器返回格式差异很大,解析代码很容易写成一团乱麻。
FluentFTP 把这层细节包掉了。它内部维护控制连接和数据连接,对外提供UploadFile、DownloadFile、GetListing这类高层方法,同时对 FTPS 和被动、主动模式做了封装。在 WinForm 里做工具型客户端,选 FluentFTP 的收益远大于自己封装FtpWebRequest。尤其是这个项目里还涉及FtpElementControl这种控件封装,底层 API 不整理清楚,界面层根本没法维护。
2.2 NuGet 依赖与 FtpClient 基础配置
项目源码里能看到packages.config声明了FluentFTP 45.1.0,这是一个较新的稳定版本,API 命名比旧版更统一。安装有两种方式:项目是 packages.config 管理模式时,在 VS2022 的包管理控制台执行:
Install-Package FluentFTP -Version 45.1.0如果项目改用 PackageReference,则直接在 csproj 中添加<PackageReference Include="FluentFTP" Version="45.1.0" />,然后 Restore。DemoFtp.sln 打开后 VS 会自动恢复 NuGet 包,编译时不需要手动拷贝 DLL。
连接服务器的基础逻辑在FtpHelper.cs里,核心类是FluentFTP.FtpClient。一个可用的连接方法长这样:
using FluentFTP; using System; using System.Text; public class FtpHelper { private FtpClient _client; public bool Connect(string host, int port, string user, string password) { _client = new FtpClient(host, user, password) { Port = port, Encoding = Encoding.UTF8, ConnectTimeout = 5000, ReadTimeout = 10000, DataConnectionType = FtpDataConnectionType.AutoActive, RetryAttempts = 3 }; _client.Connect(); return _client.IsConnected; } }这里几个参数值得注意:Encoding设置为 UTF-8,是为了兼容中文文件夹名和文件名,很多 FTP 服务器默认用系统 ANSI 编码,不设置会出现乱码路径,GetListing返回的路径也会跟着乱。DataConnectionType = AutoActive让 FluentFTP 自动协商数据连接模式,内网跨网段时优先尝试主动模式更容易穿过防火墙。ConnectTimeout和ReadTimeout对 WinForm 界面非常重要,服务器地址写错时,没有超时限制会让 UI 卡死十几秒。
Connect()调用后IsConnected只是本地 TCP 状态,不代表登录成功。更准确的验证方式是连接后调用GetWorkingDirectory(),如果返回字符串说明会话已建立。实际开发中我一般把Connect放进Task.Run,因为连接过程包含 DNS 解析和网络超时等待,在主线程调用会直接卡住窗口。
2.3 获取远程目录列表与文件类型识别
拿到目录列表是文件管理界面的第一步。FtpClient.GetListing返回FtpListItem集合,每一项已经解析出名称、大小、修改时间和类型,省去了自己切字符串的麻烦。用法如下:
var items = _client.GetListing(remotePath); foreach (var item in items) { if (item.Type == FtpObjectType.Directory) { // 目录节点,用于双击进入下一级 } else if (item.Type == FtpObjectType.File) { long size = item.Size; DateTime modified = item.Modified; // 显示文件大小和修改时间 } }FtpObjectType有三种值:File、Directory、Link。链接类型在 Unix 服务器上常见,处理时要递归解析链接指向的真实对象,否则会误判为文件。GetListing的第二个参数可传FtpListOption.Recursive一次拿全递归列表,但目录层级深时耗时明显增加,不建议在控件初始化时直接调用,用户点哪层加载哪层更合理。
下面列出FtpClient高频属性和推荐值,这套参数在多数内网 FTP 服务器上能直接工作:
| 属性 | 推荐值 | 说明 |
|---|---|---|
| Encoding | UTF-8 | 影响远程路径的中文解析 |
| DataConnectionType | AutoActive | 或 AutoPassive,取决于服务端配置 |
| ConnectTimeout | 5000 | 防止 UI 卡死 |
| ReadTimeout | 10000 | 数据读取异常时快速失败 |
| RetryAttempts | 3 | 网络抖动时自动重试 |
| SocketKeepAlive | true | 长时间保持控制连接 |
如果Connect阶段抛TimeoutException,先检查端口能不能通,再检查服务端是否限制客户端 IP。如果抛FtpException且错误码是 530,说明用户名或密码有问题,和网络无关,不要反复去调超时参数。
3. 上传与下载核心实现:进度回调、断点续传与 UI 刷新
3.1 上传下载 API 的返回状态与文件存在策略
FluentFTP 的上传下载方法不像FtpWebRequest那样只返回成功失败,而是返回FtpStatus枚举。FtpStatus.Success表示传输成功,FtpStatus.Failed表示失败,FtpStatus.Skipped表示文件未处理。最常见的误判是把Skipped当成失败,其实它经常是“文件已存在且满足跳过策略”的省略处理。
上传时建议明确指定FtpRemoteExists枚举,避免依赖默认行为:
FtpStatus status = _client.UploadFile( localPath: @"D:\data\report.txt", remotePath: "/upload/2025/report.txt", createRemoteDir: true, existsMode: FtpRemoteExists.Overwrite, verifyOptions: FtpVerify.OnlyChecksum, progress: null);这段代码有四个关键点。createRemoteDir = true会在远程路径缺失时自动创建目录,上位机按日期归档数据时特别有用,不用先CreateDirectory再UploadFile。FtpRemoteExists.Overwrite表示覆盖同名文件,适合每次生成新报表的场景。FtpVerify.OnlyChecksum表示传输完成后校验远程文件哈希,服务器不支持哈希计算时,FluentFTP 会自动降级为大小校验,不额外报错。progress先传null,后面单独讲进度回调用法。
下载方法参数结构类似,但多了本地目录的处理规则:
FtpStatus downloadStatus = _client.DownloadFile( localPath: @"D:\downloads\report.txt", remotePath: "/upload/2025/report.txt", existsMode: FtpLocalExists.Overwrite, verifyOptions: FtpVerify.OnlyChecksum, progress: null);这里要特别注意:FtpLocalExists.Overwrite只决定文件重名时的行为,不会校验本地文件是否被其他进程占用。如果目标文件正被 Excel 打开,下载会抛IOException,封装层需要同时捕获FtpException和IOException,否则 WinForm 会弹一个带堆栈的崩溃框。
3.2 进度回调与 WinForm 界面刷新
WinForm 的进度条不能直接在 FluentFTP 的回调里更新,因为进度事件可能来自后台线程。稳妥的做法是用Progress<T>把进度值封送到 UI 线程:
var progress = new Progress<FtpProgress>(p => { progressBar.Value = p.Progress; labelStatus.Text = $"{p.FileName} - {p.Progress}%"; }); await Task.Run(() => { using (var client = new FtpClient(host, user, pass)) { client.Connect(); client.UploadFile( localPath, remotePath, createRemoteDir: true, existsMode: FtpRemoteExists.Overwrite, progress: new Progress<FtpProgress>(p => { ((IProgress<FtpProgress>)progress).Report(p); })); } });注意Progress<T>实例化时会捕捉当前同步上下文,这里是在 async 方法里创建,所以Report回调会回到 WinForm 的 UI 线程,不需要额外写Control.Invoke。而UploadFile是在线程池里执行的,回调本身并不在 UI 线程,借用Progress<T>是更简洁的写法。不要直接在回调里写progressBar.Value = ...,跨线程访问控件会偶发异常,复现率不高,但一旦出现会让用户以为程序不稳定。上面的代码用Task.Run包住整个客户端操作,上传过程不会阻塞主窗体,用户还能点取消按钮。
3.3 大文件传输与断点续传策略
FTP 断点续传有两种理解:一种是从本地已下载的部分继续传输,另一种是上传中断后从远程已存在的位置继续写。FluentFTP 通过FtpLocalExists.Resume和FtpRemoteExists.Resume分别控制下载和上传的续传行为。典型的下载续传写法:
FtpStatus resumeStatus = _client.DownloadFile( localPath: localPath, remotePath: remotePath, existsMode: FtpLocalExists.Resume, verifyOptions: FtpVerify.OnlyChecksum, progress: progress);FtpLocalExists.Resume会先比较本地文件和远程文件的大小,本地小于远程时从断点继续下载,本地大于远程时则覆盖重下。这个方法要求服务器支持 REST 命令,主流 FileZilla Server、vsftpd 都支持,Windows 自带的 FTP 服务也支持。如果服务器不支持,FluentFTP 返回FtpStatus.Failed,封装层需要提示用户关闭“断点续传”选项,改用Overwrite。
文件存在策略的差异可以通过一张表理解:
| 枚举值 | 上传行为 | 下载行为 | 使用建议 |
|---|---|---|---|
| Overwrite | 覆盖远程同名文件 | 覆盖本地同名文件 | 固定文件名、内容总是最新的场景 |
| Append | 在远程文件末尾追加 | 追加到本地文件末尾 | 日志文件同步 |
| Resume | 从断点继续上传 | 从断点继续下载 | 大文件传输、网络不稳定 |
| NoCheck | 不检查同名文件 | 不检查本地同名文件 | 外部已有锁机制时使用 |
选择Resume时要额外注意:如果远程文件被另一个进程修改过,大小可能刚好匹配但内容不一致,此时续传会直接跳过差异部分。生产环境我一般会配合FtpVerify.OnlyChecksum,让 FluentFTP 在传输完成后做哈希比对,一旦发现不一致就删除本地文件重新下载。
4. 封装 FtpElementControl:把 FTP 能力变成 WinForm 可复用控件
4.1 为什么单独抽一个控件而不是直接写在 Form 里
从 DemoFtp 的项目结构能看到,除了MainForm之外还有一个FtpElementControl.cs和对应的 Designer 文件,说明作者把文件列表、上传下载入口做成了 UserControl。这样做的直接好处是复用:同一个系统里如果既要管理工程文件,又要回传数据文件,两个窗体可以直接引用同一个控件,只需要设置不同的RemotePath。另外,把 FTP 逻辑从窗体事件里剥出来,也让后续换 FTP 服务器、加界面美化的成本更低。做 WinForm 界面美化时,控件级封装比窗体级封装更容易统一风格和字体。
4.2 控件的属性与事件设计
FtpElementControl的职责应该包括远程路径显示、文件列表加载、上传按钮、下载按钮和刷新按钮。连接参数不写死在控件内部,而是暴露成公开属性,这样设计器里可以直接配置。简化后的逻辑骨架如下:
public partial class FtpElementControl : UserControl { private FtpHelper _helper; private CancellationTokenSource _cts; public string Server { get; set; } public int Port { get; set; } = 21; public string UserName { get; set; } public string Password { get; set; } public string RemotePath { get; set; } = "/"; public event EventHandler<FtpEventArgs> FileDownloaded; public FtpElementControl() { InitializeComponent(); } private async void btnRefresh_Click(object sender, EventArgs e) { await LoadRemoteDirectory(RemotePath); } private async Task LoadRemoteDirectory(string path) { _cts?.Cancel(); _cts = new CancellationTokenSource(); listView1.Items.Clear(); var items = await Task.Run(() => _helper.GetFileList(path), _cts.Token); foreach (var item in items) { var listItem = new ListViewItem(item.Name); listItem.SubItems.Add(item.Size.ToString()); listItem.SubItems.Add(item.Modified.ToString("yyyy-MM-dd HH:mm")); listView1.Items.Add(listItem); } } public void Connect() { _helper = new FtpHelper(); _helper.Connect(Server, Port, UserName, Password); } }这里有一个 WinForm 控件开发里很容易踩的坑:设计器会在设计模式加载构造函数。如果构造函数里去连 FTP 服务器,打开窗体设计器就会卡住甚至抛异常。所以构造函数里只做InitializeComponent,连接放到Connect方法或Load事件里,这样设计模式不会触发网络操作。
异步刷新时还要处理用户快速点击按钮的情况。上面代码用CancellationTokenSource取消上一次加载,如果上一次GetList已经发出去,取消 token 只能让等待不再继续,但无法中止服务端响应。实际运行中列表先显示旧结果又覆盖新结果的情况,通过每次刷新前清空listView1.Items已经能掩盖大部分问题。
4.3 把控件挂到 MainForm 上
主窗体中使用这个控件很直接,按钮或窗体Load事件里赋值属性并调用连接方法:
ftpElementControl1.Server = "192.168.1.100"; ftpElementControl1.Port = 21; ftpElementControl1.UserName = "ftpuser"; ftpElementControl1.Password = "ftppass"; ftpElementControl1.RemotePath = "/数据归档"; ftpElementControl1.Connect();注意中文路径在resx资源文件里的编码问题。在设计器里写中文没问题,但如果你用 Git 管理代码,且换过.editorconfig,要检查.resx文件是否被转成 ANSI。一旦资源文件变成 ANSI,运行时拿到的路径可能是乱码,GetListing会直接返回空列表或抛路径错误。VS 2022 默认会把.resx保存为 UTF-8 with BOM,一般不用改,但团队协作时值得检查.
控件对外暴露的属性,总结成一张表方便调用方查阅:
| 成员 | 类型 | 说明 |
|---|---|---|
| Server | string | FTP 主机地址 |
| Port | int | 端口,默认 21 |
| UserName | string | 登录账号 |
| Password | string | 登录密码,控件内部不打印明文 |
| RemotePath | string | 当前浏览的远程目录 |
| Connect() | Method | 显式连接并验证登录 |
| FileDownloaded | Event | 文件下载完成时触发 |
不要直接把FtpHelper暴露为公共属性,否则窗体和控件耦合太紧。主窗体只需要订阅FileDownloaded,下载完成后决定文件放到哪个目录、是否继续处理,传输细节交给控件内部。
5. 传输校验与排错:证书、中文文件名和被动模式
5.1 用 FtpHash 验证传输完整性
FtpVerify.OnlyChecksum已经能在传输后自动校验,但校验失败时的提示不够具体。更可控的做法是下载完成后手动比较哈希:
FtpHash remoteHash = client.GetChecksum(remotePath); string localHash = GetLocalMd5(localPath); if (remoteHash.Value != localHash) { // 这里抛异常或提示用户重新下载 }FtpHash对象包含Algorithm和Value,FluentFTP 已经对不同服务器的返回格式做了归一化,不需要自己处理空格或大小写。要注意的是,如果服务器不支持 HASH 命令,GetChecksum会抛FtpException,此时要降级为比较文件大小,而不是让程序崩溃。
5.2 高频排错与参数修正
实际接入不同 FTP 服务器时,最容易遇到下面这几类问题:
| 异常或现象 | 原因 | 处理方式 |
|---|---|---|
| Certificate verification failed | 服务器使用自签名证书 | 内网环境设置ValidateAnyCertificate = true |
| 中文文件名乱码 | 服务器编码不是 UTF-8 | 改为Encoding.GetEncoding("GBK") |
| 上传后文件大小为 0 | Passive 模式被防火墙拦截 | 改成DataConnectionType.AutoActive |
| 断点续传失败 | 服务器不支持 REST 命令 | 捕获异常后改用Overwrite |
| 连接超时 | 端口被防火墙封禁 | 先用 Telnet 检查目标端口 |
针对 FTPS 服务器的通用配置可以这样写:
_client.EncryptionMode = FtpEncryptionMode.Explicit; _client.ValidateAnyCertificate = true; _client.DataConnectionType = FtpDataConnectionType.AutoPassive;Explicit表示在 21 端口显式升级 TLS,适合大多数 FTPS 服务器。ValidateAnyCertificate = true会跳过证书链校验,只建议在内网调试时开启,如果服务器证书是可信 CA 签发的,不需要设置这项。
排查连接问题时,打开诊断追踪是最高效的手段。FluentFTP 内置了FtpTrace输出:
FtpTrace.EnableTracing = true;开启后控制连接的每条命令和响应都会输出到调试器,遇到 530 登录失败、550 路径不存在这类返回码,一眼就能定位是账号问题还是目录问题。这个开关只建议在调试配置里打开,生产环境一直开着会导致日志文件快速膨胀,磁盘占用不可控。
本文还有配套的精品资源,点击获取