
简介本资源是一套面向VB.NET初学者与中级开发者的FTP功能实战源码包聚焦远程文件传输核心场景系统解决创建目录、删除文件、查询列表、上传与下载等五类高频操作问题。压缩包共30个文件含6个核心VB源码文件如Form1.vb、1个Visual Studio解决方案.sln、1个项目配置文件.vbproj及配套资源文件.resx、.png、.exe等结构完整可直接编译运行整体体积仅164KB轻量易用。已有707人学习下载说明其在教学演示与快速集成中具备良好实践价值。读者可直接复用各功能模块代码理解FtpWebRequest类在不同操作模式MakeDirectory、DeleteFile、ListDirectoryDetails等下的请求构造、凭证认证、流处理与异常应对逻辑并通过已封装的ExecuteFtpRequest通用方法掌握FTP通信的标准化调用范式。 最近在帮客户做一套内网文件自动同步工具VB.NET FTP 这套组合看着基础真做起来全是细节。标题里这五个操作——创建、删除、查询、下载、上传——单拎出来每一个都能写一篇但放到一个工程里时你才会发现真正的难点不是某个功能不会写而是路径处理、编码、被动模式、异常响应这些“看不见的边界条件”。这篇文章我把整套案例源码的思路完整梳理一遍从最开始的方案选型到每个操作的实现再到我实际调试中踩过的坑全部记录下来。无论你是刚接触VB.NET的初级开发者还是准备把老项目里的 FTP 模块重新整理一遍这篇内容都能直接参考。1. 方案选型为什么我最终选择了 FtpWebRequest1.1 标题里五个操作背后的真实需求拆解先把需求翻译成 FTP 协议的语言。标题提到的“创建、删除、查询、下载、上传”看起来是五个按钮其实是两类实体、五种动作的组合针对目录的 MKD创建目录、RMD删除目录、LIST/NLST查询列表针对文件的 DELE删除文件、RETR下载、STOR上传还有隐含的 CWD切换工作目录、PWD获取当前路径、SIZE获取文件大小等辅助命令。为什么要单独拆这点因为很多初学者会直接写一个“上传按钮”的代码然后复制一份改成“下载按钮”。这种做法的隐患在于创建目录时服务器返回的状态码和上传文件时完全不同删除目录和删除文件的请求方法名也不一样如果不理解每个操作对应什么 FTP 命令一旦出现异常连日志都看不明白。实际项目里我通常还会把“查询”拆成两个层次查询目录列表和查询文件详情。前者只需要文件名列表后者需要文件大小、修改时间这两者的解析逻辑差别很大后面第 3 节会细说。1.2 FtpWebRequest、WinINet API 和第三方库怎么选VB.NET 里做 FTP 操作主流有三条路.NET 自带的 FtpWebRequest、P/Invoke 调用 WinINet 的 InternetOpenUrl 系列 API、引用第三方 FTP 库如 FluentFTP、edtFTPnet。我最终选了 FtpWebRequest原因很简单它是 .NET Framework 原生实现不需要额外引用 DLL部署时不用考虑依赖而且它的对象模型设计得比较符合 .NET 开发者的直觉。WinINet API 是 VB6 时代老项目迁移时常带的“遗产”。如果你的老项目已经在用 InternetOpenUrl不想重写那可以继续用但新项目不建议碰因为你得自己处理句柄释放、缓冲区和错误码代码写起来非常啰嗦调试成本也高。第三方库功能更全比如 FluentFTP 支持断点续传、目录递归下载、HTTP 代理但引入第三方依赖意味着你要评估开源协议、版本兼容性和服务器环境兼容性。内网工具还是尽量少引依赖出了问题不至于抓瞎。从兼容性角度做一个简单对比方案依赖断点续传代码量调试难度FtpWebRequest无需自己实现中中WinINet API无需自己实现高高FluentFTP有内置低低三个方案我全用过我的结论是如果你的项目是 .NET Framework 4.x 的 WinForms/WPFFtpWebRequest 是性价比最高的选择如果做的是跨平台 .NET Core 服务我反而推荐 FluentFTP因为 .NET Core 下 FtpWebRequest 的一些行为在不同操作系统上有差异排查起来更费劲。这篇文章的案例源码基于 .NET Framework如果你用的是 .NET Core思路完全一样个别属性名要注意一下。2. 动手前必须先做的事环境准备与核心类封装2.1 开发环境与项目引用的最小准备这个案例不需要安装任何 NuGet 包也不需要引用额外的 com 组件。你只需要一个 VB.NET 的 Windows 项目我这边用的是 Visual Studio 2019 .NET Framework 4.7.2WinForms 和 Console 程序都测试过。唯一要注意的是项目目标框架里的“启用 TLS 1.2”问题如果你的 FTP 服务器开启了 TLS/SSL而程序没有在启动时加一行代码握手会直接失败 在程序入口Main 或 Form_Load添加 ServicePointManager.SecurityProtocol SecurityProtocolType.Tls12 ServicePointManager.ServerCertificateValidationCallback Function(s, cert, chain, errors) True第二行回调是“信任所有证书”只推荐在内网调试时使用。一旦上了生产尤其是对接第三方服务器这个回调要么移除要么写上完整的证书校验逻辑否则等于把安全门敞开了。2.2 连接参数设计主机、端口、用户名、密码一个都不能想当然FTP 连接参数看着简单实际每个字段都有坑。主机名不要带协议头比如 ftp://192.168.1.100真正的地址应该是 192.168.1.100。端口默认是 21但有些服务器为了安全会改成别的端口例如 2121所以端口必须设计成可配置项。用户名密码如果允许匿名访问用户名填 anonymous密码填一个 Email 格式的字符串比如 testtest.com。还有一个容易忽略的参数编码。FTP 协议的 RFC 959 默认字符集是 ASCII但中文环境下文件名如果是中文就必须处理编码。FtpWebRequest 默认使用的是 UTF-8如果服务器支持 UTF-8但很多国产服务器或者老旧的 IIS FTP 服务器只认 GBK。这个问题在第 4 节会专门展开这里先埋个伏笔。我把所有连接参数封装成一个简单的配置类Public Class FtpConfig Public Property Host As String Public Property Port As Integer 21 Public Property UserName As String Public Property Password As String Public Property UsePassive As Boolean True Public Property UseBinary As Boolean True Public Property EnableSsl As Boolean False Public Property EncodingName As String UTF-8 Public Property Timeout As Integer 30000 Public Function GetBaseUri() As String Return String.Format(ftp://{0}:{1}, Host, Port) End Function End Class2.3 路径处理的三个坑斜杠、结尾分隔符、相对路径路径处理是 FTP 操作里最容易翻车的地方我总结为三个坑。第一个坑FTP 路径永远用正斜杠/不能用 Windows 的反斜杠\。很多从 VB6 过来的代码直接拿本地文件路径拼接 FTP 路径反斜杠传过去服务器根本不认。第二个坑目录结尾斜杠的问题。创建目录时目标路径末尾不要带斜杠否则某些服务器会返回 550但查询目录内容时末尾必须带斜杠否则可能查询到的是目录本身而不是里面的文件。这个行为在不同服务器上表现不一致最好的办法是写一个归一化函数统一处理。第三个坑相对路径和绝对路径的语义。FtpWebRequest 创建的请求实际上每次都是绝对路径请求即 ftp://host/目录/文件。但如果你用 CWD 切换了工作目录后续请求的路径解析依赖于服务器的当前会话状态。由于 FtpWebRequest 每次请求默认都是一次独立连接KeepAliveFalse 时你切换工作目录的请求不会影响下一次请求。所以最稳妥的做法始终构造完整的绝对路径不要依赖“当前目录”这个状态。我封装了一个路径处理函数Public Shared Function NormalizeFtpPath(ByVal baseUrl As String, ByVal path As String) As String 去掉反斜杠统一为正斜杠 path path.Replace(\, /) 去掉开头的斜杠避免双斜杠 path path.TrimStart(/c) 去掉结尾的斜杠目录列表操作时调用方自行决定是否加回 path path.TrimEnd(/c) Return baseUrl / path End Function有了这个函数后续五个操作的核心代码都可以复用它避免了每次拼接路径时手工处理斜杠。3. 五大 FTP 操作的完整实现与源码解析3.1 连接测试怎么判断服务器通不通、账号对不对在写任何业务逻辑之前我建议先写一个测试连接的函数。它的用途不只是弹个“连接成功”的提示框而是要知道服务器连不上时到底卡在哪一步域名解析失败、端口不通、认证失败还是权限不足。最简单的测试方案是发送一条 PWD 命令它返回当前的 FTP 路径既能验证连接又能验证认证。如果连 PWD 都失败那后面三个操作都不用谈了。实现如下Public Function TestConnection(ByVal config As FtpConfig) As Boolean Try Dim uri As String NormalizeFtpPath(config.GetBaseUri(), ./) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.PrintWorkingDirectory request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Console.WriteLine(连接成功: response.StatusDescription) Return True End Using Catch ex As WebException Dim response As FtpWebResponse TryCast(ex.Response, FtpWebResponse) If response IsNot Nothing Then Console.WriteLine(FTP错误: response.StatusCode - response.StatusDescription) Else Console.WriteLine(网络错误: ex.Message) End If Return False End Try End Function这里有个细节请求的路径传的是 ./目的是告诉服务器“给我当前目录”。如果你传一个不存在的目录PWD 不依赖目标目录存在性所以这个写法最安全。如果服务器返回 530那就是账号密码不对返回 550是权限问题连接超时则是网络隔离或者端口被防火墙拦截。3.2 创建目录MKD 的易踩坑点和多级目录递归创建创建目录用的请求方法是 MakeDirectory。很多人在这一步遇到的问题是直接创建一个多级目录比如 /data/2025/04服务器返回 550。原因在于标准 FTP 协议的 MKD 命令一次只能创建一级目录/data/2025/04 要求 /data/2025 已经存在。解决方法有两种一种是自己先递归判断并创建每一级目录另一种是如果你的服务器支持比如 vsftpd 开启了 mkdir 递归扩展但这个扩展不是标准功能所以不能依赖。我采用递归创建的方案逻辑是从根开始逐级尝试创建每一级创建失败时先判断是否因为目录已存在返回 550 且错误信息里包含 Already exists 或 File exists如果是则继续下一级Public Sub CreateDirectory(ByVal config As FtpConfig, ByVal remotePath As String) 将路径按 / 分割逐级创建 Dim parts() As String remotePath.Replace(\, /).TrimStart(/c).Split(/c) Dim currentPath As String For Each part As String In parts If String.IsNullOrEmpty(part) Then Continue For currentPath / part Dim uri As String config.GetBaseUri() currentPath Try Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.MakeDirectory request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Console.WriteLine(创建目录成功: currentPath) End Using Catch ex As WebException Dim response As FtpWebResponse TryCast(ex.Response, FtpWebResponse) If response IsNot Nothing AndAlso response.StatusCode FtpStatusCode.ActionNotTakenFileUnavailable Then 目录已存在时某些服务器返回 550这里不作异常处理继续下一级 Console.WriteLine(目录已存在跳过: currentPath) Else Throw End If End Try Next End Sub这类代码里我专门捕获了 ActionNotTakenFileUnavailable对应 550并判断为“目录已存在”。但要注意550 也可能代表“没有权限创建目录”比如服务器根目录只读。判断到底是哪一种只能依靠服务器返回的描述文本比如检查 response.StatusDescription 是否包含 exists 或 already如果包含说明存在可以跳过如果不包含直接抛出异常让上层处理。这是没法“完美”避免的服务器差异只能在代码里做兼容然后在日志里打出来方便排查。3.3 删除目录和删除文件两个方法名千万别搞混删除操作有两个相对独立的实现方法名只差一个单词DeleteFile 和 RemoveDirectory。删除文件比较简单直接传入文件的 FTP 路径即可Public Sub DeleteFile(ByVal config As FtpConfig, ByVal remotePath As String) Dim uri As String NormalizeFtpPath(config.GetBaseUri(), remotePath) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.DeleteFile request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Console.WriteLine(删除文件成功: response.StatusDescription) End Using End Sub删除目录就有讲究了FTP 协议规定RMD 命令只能删除空目录。如果目录里还有文件或子目录服务器会返回 550。所以你要实现“删除整个目录”的语义必须自己递归先列出目录下所有条目逐个删除文件再对子目录递归调用删除目录最后才能删掉当前目录。这个递归删除的实现是 FTP 操作里最容易写错的地方主要是两个原因一是你删除子目录时父目录的列表缓存已经过期二是在遍历列表的过程中删除条目会改变后续条目。我处理的办法是先一次性取回完整的文件列表缓存到内存然后先删文件再对子目录递归最后删当前目录Public Sub DeleteDirectory(ByVal config As FtpConfig, ByVal remotePath As String) 1. 获取目录下所有条目 Dim entries As List(Of FtpEntry) ListDirectoryDetail(config, remotePath) 2. 先删除文件 For Each entry As FtpEntry In entries If Not entry.IsDirectory Then DeleteFile(config, remotePath / entry.Name) End If Next 3. 再递归删除子目录 For Each entry As FtpEntry In entries If entry.IsDirectory AndAlso entry.Name . AndAlso entry.Name .. Then DeleteDirectory(config, remotePath / entry.Name) End If Next 4. 最后删除当前目录 Dim uri As String NormalizeFtpPath(config.GetBaseUri(), remotePath) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.RemoveDirectory request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Console.WriteLine(删除目录成功: remotePath) End Using End SubFtpEntry 是自定义的一个简单结构体包含文件名和是否是目录两个属性具体从列表解析而来这个解析逻辑放到下一节里统一讲。3.4 查询目录与文件列表LIST 与 LIST -al 的解析差异查询操作是所有操作的基础因为你做下载前总要知道服务器上有什么文件。FtpWebRequest 提供了两个方法ListDirectory 和 ListDirectoryDetails。前者只返回文件名列表对应 NLST后者返回带权限、大小、时间等详细信息的列表对应 LIST我一般用 ListDirectoryDetails因为只有它才能判断条目是文件还是目录。ListDirectoryDetails 返回的文本格式取决于服务器类型。Unix 风格vsftpd、ProFTPD返回类似-rw-r--r-- 1 ftp ftp 16384 Apr 10 10:30 report.pdf drwxr-xr-x 2 ftp ftp 4096 Apr 10 10:30 backupWindows FTP 服务IIS返回类似04-10-25 10:30AM DIR backup 04-10-25 10:31AM 16384 report.pdf两种格式差异巨大所以我写了一个兼容两种格式的解析函数Public Structure FtpEntry Public Name As String Public IsDirectory As Boolean Public Size As Long Public ModifiedTime As DateTime End Structure Public Function ListDirectoryDetail(ByVal config As FtpConfig, ByVal remotePath As String) As List(Of FtpEntry) Dim uri As String config.GetBaseUri() / remotePath.TrimStart(/c).Replace(\, /) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.ListDirectoryDetails request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Dim lines As New List(Of FtpEntry)() Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Using reader As New StreamReader(response.GetResponseStream(), Encoding.GetEncoding(config.EncodingName)) Dim line As String While (line reader.ReadLine()) IsNot Nothing Dim entry As FtpEntry ParseEntryLine(line) If entry.Name . AndAlso entry.Name .. Then lines.Add(entry) End If End While End Using End Using Return lines End Function Private Function ParseEntryLine(ByVal line As String) As FtpEntry Dim entry As New FtpEntry() line line.Trim() Unix 风格判断第一个字符是 d 表示目录 If line.StartsWith(d) Then entry.IsDirectory True Dim parts() As String line.Split(New Char() { c}, StringSplitOptions.RemoveEmptyEntries) 最后一个字段是文件名倒数第三个是大小Unix格式 entry.Name parts(parts.Length - 1) Long.TryParse(parts(parts.Length - 5), entry.Size) ElseIf line.StartsWith(-) Then entry.IsDirectory False Dim parts() As String line.Split(New Char() { c}, StringSplitOptions.RemoveEmptyEntries) entry.Name parts(parts.Length - 1) Long.TryParse(parts(parts.Length - 5), entry.Size) ElseIf line.Contains(DIR) Then Windows 风格 entry.IsDirectory True Dim markerIndex As Integer line.IndexOf(DIR) entry.Name line.Substring(markerIndex 6).Trim() Else Windows 风格普通文件日期时间 大小 文件名 entry.IsDirectory False Dim parts() As String line.Split(New Char() { c}, StringSplitOptions.RemoveEmptyEntries) If parts.Length 4 Then entry.Name parts(parts.Length - 1) Long.TryParse(parts(parts.Length - 2), entry.Size) End If End If Return entry End Function这段解析代码是我踩了好多轮服务器差异后整理出来的。你可能会遇到的场景还包括微软的 IIS 返回的日期格式受区域设置影响有的显示 04-10-25 10:30AM有的显示 2025-04-10 10:30。这时候你只能手动适配没有一劳永逸的解析器。一个经验做 FTP 工具时把原始返回的每一行都记录到日志里排查列表解析问题时特别有用。查询功能还可以扩展成“FTP 目录监控”的效果。我在一个自动化项目里就是定时调用 ListDirectoryDetail把返回的文件名与上一次的列表做对比发现新文件就自动下载并处理这比用 FileSystemWatcher 监听局域网共享目录更靠谱因为 FTP 是跨平台的不需要把共享目录映射到本地。3.5 上传文件STOR 进度显示 断点续传思路上传是使用最频繁的操作。FtpWebRequest 上传文件的核心思路很直白把本地文件流复制到请求流里。但“复制”这件事可以通过不同方式实现最基础的是 FileStream.CopyTo我看很多教程里就这么干它确实简单Public Sub UploadFile(ByVal config As FtpConfig, ByVal localPath As String, ByVal remotePath As String) Dim uri As String NormalizeFtpPath(config.GetBaseUri(), remotePath) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.UploadFile request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout request.KeepAlive False Using fileStream As FileStream File.OpenRead(localPath) Using requestStream As Stream request.GetRequestStream() fileStream.CopyTo(requestStream) End Using End Using Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Console.WriteLine(上传成功: response.StatusDescription) End Using End Sub这段代码的缺点是没有任何进度反馈也不支持断点续传。CopyTo 方法内部是一次性读取缓冲并写入整个过程你是看不到进度的。UI 层如果需要一个进度条就必须自己动手写读写循环Public Sub UploadFileWithProgress(ByVal config As FtpConfig, ByVal localPath As String, ByVal remotePath As String, ByVal progress As Action(Of Integer)) Dim fileInfo As New FileInfo(localPath) Dim totalBytes As Long fileInfo.Length Dim uploadedBytes As Long 0 Dim uri As String NormalizeFtpPath(config.GetBaseUri(), remotePath) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.UploadFile request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout request.KeepAlive False Dim buffer(8191) As Byte Using fileStream As FileStream File.OpenRead(localPath) Using requestStream As Stream request.GetRequestStream() Dim bytesRead As Integer Do bytesRead fileStream.Read(buffer, 0, buffer.Length) If bytesRead 0 Then Exit Do requestStream.Write(buffer, 0, bytesRead) uploadedBytes bytesRead progress?.Invoke(CInt(uploadedBytes * 100 / totalBytes)) Loop End Using End Using End Sub这个版本用 8KB 的缓冲区手动循环读写每读一块就更新一次进度百分比。8KB 是一个比较稳妥的选择因为 FTP 是流式传输缓冲区太小会导致网络请求次数过多太大则占用内存且对进度条刷新频率没有明显提升。断点续传的思路这里提一下FTP 协议支持 REST 命令指定从文件的某个偏移量继续传输。FtpWebRequest 没有直接把 REST 暴露成属性你需要发送一个 ContentOffset 请求头。具体做法是 request.ContentOffset 已上传字节数这样服务器会从偏移位置继续写入。但要注意FTP 服务器需要支持 REST且上传目标是同一个文件。这种方式适合大文件断点续传实现和调试的复杂度都不低我建议项目初期先不做把基本的上传下载跑通后再考虑。3.6 下载文件RETR 与本地文件写入重名策略下载文件与上传逻辑对称用 DownloadFile 方法获取响应流然后把响应流复制到本地 FileStreamPublic Sub DownloadFile(ByVal config As FtpConfig, ByVal remotePath As String, ByVal localPath As String) Dim uri As String NormalizeFtpPath(config.GetBaseUri(), remotePath) Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method WebRequestMethods.Ftp.DownloadFile request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Using response As FtpWebResponse CType(request.GetResponse(), FtpWebResponse) Using inputStream As Stream response.GetResponseStream() Using outputStream As FileStream File.Create(localPath) inputStream.CopyTo(outputStream) End Using End Using End Using End Sub本地文件重名策略是我在实际项目里被客户“教育”之后补上的。最初的无脑实现是直接 File.Create如果本地已经存在同名文件会直接覆盖。客户的数据报表本地已经有了一份远程又更新了新版本直接覆盖导致旧版本完全丢失客户损失了两天的数据。后来我加了一个参数overwrite 为 False 时如果本地文件已存在自动在文件名后追加时间戳Public Function GenerateLocalPath(ByVal localPath As String, ByVal overwrite As Boolean) As String If overwrite Then Return localPath If Not File.Exists(localPath) Then Return localPath Dim dir As String Path.GetDirectoryName(localPath) Dim name As String Path.GetFileNameWithoutExtension(localPath) Dim ext As String Path.GetExtension(localPath) Return Path.Combine(dir, name _ DateTime.Now.ToString(yyyyMMddHHmmss) ext) End Function这个方案很简单但客户反馈非常好因为它保证了任何情况下都不会丢数据。如果你做的是批量下载更合理的策略是先对比本地文件和远程文件的修改时间、大小如果一致就跳过下载否则下载到临时文件最后再替换本地文件这样避免下载过程中断导致本地文件损坏。下载网络异常时的处理也有讲究。FTP 是长连接上的命令交互如果你在下载到一半时断网响应流可能已经关闭这时本地文件是半截的。最稳妥的做法是先下载到同目录下的临时文件比如 report.pdf.tmp下载成功后用 File.Replace 或 File.Move 替换目标文件失败就把临时文件删掉。这样无论怎么失败都不会破坏本地已有的完整文件。4. 常见问题与排查技巧实录4.1 中文文件名乱码UTF-8 和 GBK 的服务器差异这是我遇到过最多的问题。FtpWebRequest 默认按 UTF-8 编码。如果你的服务器是 Windows IIS FTP中文系统下默认编码是 GBK如果你传 UTF-8 的中文文件名服务器上显示出来就是乱码反过来从服务器拉取列表中文文件名在客户端也是乱码。解决方案就是把请求的编码设置成和服务器一致。FtpWebRequest 本身没有直接提供“编码”属性但响应流的读取可以指定编码上传请求的中文路径则需要你在构造 URI 时手动编码。我封装了一个兼容方案在 FtpConfig 里加 EncodingName 参数默认情况下尝试 UTF-8遇到乱码就切换为 GB2312Private Function CreateRequest(ByVal config As FtpConfig, ByVal uri As String, ByVal method As String) As FtpWebRequest Dim request As FtpWebRequest CType(WebRequest.Create(uri), FtpWebRequest) request.Credentials New NetworkCredential(config.UserName, config.Password) request.Method method request.UsePassive config.UsePassive request.UseBinary config.UseBinary request.EnableSsl config.EnableSsl request.Timeout config.Timeout Return request End FunctionURI 里的中文路径在编码时要注意WebRequest.Create 会自动对非 ASCII 字符进行 URL 编码但有些服务器端对编码后的中文解码有差异导致找不到文件。一个比较土但有效的办法把中文文件名用 Uri.EscapeDataString 手动编码后拼到 FTP URL 里再试试服务器是否正常。这个问题的具体表现和服务器版本强相关我建议做一个“编码探测”的配置项正式运行前用一个小工具测试该服务器使用哪种编码。4.2 判断文件或目录是否存在为什么 FtpWebRequest 没有 Exists 方法很多新手会理所当然地认为 FtpWebRequest 会提供一个 FileExists 或者 DirectoryExists 的方法但标准类库里没有。这是因为 FTP 协议本身没有“查询某个路径是否存在”的命令只能通过其他命令间接判断。我的做法是对一个目标路径发送 GetFileSize 请求如果返回 550大概率是路径不存在。也可以用简单方法对目标路径发送 MakeDirectory如果返回 550 且错误文本包含 already exists则目录存在。但这两个方法都有误判概率最可靠的是对目标路径的父目录发送 ListDirectoryDetails然后检查列表里是否有目标条目Public Function Exists(ByVal config As FtpConfig, ByVal remotePath As String) As Boolean 把目标路径拆成父目录和文件名 remotePath remotePath.Replace(\, /).TrimEnd(/c) Dim idx As Integer remotePath.LastIndexOf(/c) If idx 0 Then Return False Dim parentPath As String remotePath.Substring(0, idx) Dim targetName As String remotePath.Substring(idx 1) Try Dim entries As List(Of FtpEntry) ListDirectoryDetail(config, parentPath) For Each entry As FtpEntry In entries If entry.Name targetName Then Return True End If Next Catch ex As WebException 父目录不存在则直接返回 False Return False End Try Return False End Function这个方法可靠但会多一次网络往返在追求性能的批量操作中你可以考虑用一个“目录缓存”来减少 ListDirectory 的调用次数。4.3 主动模式与被动模式为什么你能连上却传不了文件FTP 有主动Active和被动Passive两种模式区别在于数据连接的建立方式。主动模式是服务器主动连接客户端的数据端口被动模式是客户端去连接服务器的数据端口。FtpWebRequest 的 UsePassive 属性默认是 True也就是被动模式。但在某些内网环境下服务器配置了主动模式特别是老旧的 Linux vsftpd 配置或者服务器的防火墙对被动模式的数据端口范围做了限制这时会出现一种奇怪的现象登录成功了列表也能出来但上传大文件时中途连接被断开。原因是数据连接在被动模式下选择了服务器防火墙没有放行的端口。排查方法很简单把 UsePassive 在 True 和 False 之间切换看哪种模式能正常工作。如果被动模式失败且服务器的数据端口范围有限制你需要联系服务器管理员放行这段端口范围。我在部署工具时一般会把 UsePassive 做成一个可配置选项客户现场连不上时先在配置里切换试试而不是改代码重新编译。4.4 TLS/SSL 加密连接服务器证书不受信任的应对现在越来越多的企业内网要求 FTP 启用了 TLS。FtpWebRequest 开启 EnableSsl True 后最常见的问题就是证书链不受信任因为很多内部 FTP 服务器用的是自签名证书。测试阶段可以临时挂一个 ServerCertificateValidationCallback 返回 True但正式使用时要换成校验证书指纹否则中间人攻击的风险很大。证书校验的实现方式不复杂把服务器的证书指纹SHA256写死在配置里回调函数对比指纹一致才返回 TrueServicePointManager.ServerCertificateValidationCallback Function(sender As Object, certificate As X509Certificate, chain As X509Chain, sslPolicyErrors As SslPolicyErrors) Dim cert2 As X509Certificate2 TryCast(certificate, X509Certificate2) If cert2 Is Nothing Then Return False Return cert2.GetCertHashString(Security.Cryptography.HashAlgorithmName.SHA256).ToLower() 你的服务器证书指纹 End Function这个做法的好处是即使有人伪造证书只要他不持有目标服务器的私钥就无法构造出相同的证书指纹。4.5 FileZilla Server、Windows IIS FTP、vsftpd 的兼容差异我测试过的服务器类型有 FileZilla Server、Windows 自带 IIS FTP、Linux 的 vsftpd它们的行为差异比较大这里列一个速查表服务器类型默认编码目录列表格式常见问题FileZilla ServerUTF-8Unix风格中文目录可能需要客户端手动适配IIS FTP系统区域编码中文系统为GBKWindows风格中文文件名乱码、日期格式随区域变化vsftpdUTF-8Unix风格主动/被动模式配置易错、匿名访问限制严格如果你开发的是一个通用工具需要同时兼容这三种服务器我建议把编码、UsePassive、目录列表解析全部做成可配置项并且在界面上提供“服务器类型”下拉框不同选项对应不同的默认配置。这是减少售后问题的关键因为客户现场的服务器你根本无法控制。4.6 大文件上传超时与半途中断上传 2GB 以上的文件时FtpWebRequest 的 Timeout 属性对请求流写入的影响并不像小文件那么明显但连接空闲超时KeepAlive 期间会导致传输中断。一个常见场景客户端通过网络向服务器传大文件中间遇到了 1 分钟的网络波动FTP 数据连接断开FileStream.CopyTo 抛出 IOException整个上传失败。应对策略分三层第一层把 Timeout 调大比如大文件上传时设置到 5 分钟这只是防止空闲断开并不能真正解决网络问题第二层写入时用带进度的循环实时显示进度让操作者知道程序还活着第三层实现重试逻辑比如失败后自动重试 3 次每次间隔 5 秒。对于断点续传的需求FtpWebRequest 的 ContentOffset 属性可以帮你实现但这个功能我在生产环境里用得不多因为大多数 FTP 服务器对 REST 命令的支持不太一致测试和联调成本高。4.7 连接 FTP 的另一个视角Windows 资源管理器里的“记住密码”怎么清理这个问题虽然不是编程问题但我被问过好多次。Windows 资源管理器在访问 FTP 服务器时如果你勾选了“保存密码”凭据会被存到 Windows 的凭据管理器里。下次代码里用相同主机名连接时有时候会感觉“为什么我传的密码根本没生效”这是因为资源管理器和浏览器用的是 WinINet 缓存。清理方法是打开控制面板 - 凭据管理器 - Windows 凭据找到 Windows 凭据列表里对应 FTP 服务器地址的记录点击删除。如果你做的是自动化工具建议明确告诉使用者不要和资源管理器使用相同的凭据登录避免混淆。4.8 匿名访问的权限边界与安全提醒很多内网环境开了匿名访问FTP 服务器允许用户以 anonymous 身份登录。作为程序端匿名账号不需要输入密码用户名填 anonymous 即可。但匿名访问同时也带来安全隐患尤其是当 FTP 目录的写入权限开放给匿名用户时任何人都能上传文件这可能被用来传播恶意文件。我写工具时有个原则生产环境的 FTP 操作都使用独立账号权限尽量最小化匿名访问只用于临时文件交换。如果你的工具在运行时会暴露密码比如配置文件里明文存储建议至少做一层加密。可以用 DPAPIProtectedData加密后保存在本地配置文件里这样即使别人拿到了配置文件没有当前用户的 Windows 账户也解不开。这个方案对桌面工具来说足够安全代码也不复杂网上有大量现成例子引入成本很低。最后聊两句项目之外的经验这套 FTP 操作代码我从 VB6 时代一路做到 VB.NET中间踩过最多的坑不是代码本身而是环境的不可控。FTP 协议是 1985 年就定下来的老协议各家服务器实现差异很大所以代码里最重要的不是“写得漂亮”而是“能配置、可排查”。我现在的习惯是把服务器类型、编码、被动模式、超时这些全部做成配置项遇到问题先看配置再看日志大部分情况下不用改代码就能解决客户现场的问题。另外一个建议是日志一定要打全。FTP 操作失败时把请求的 URL排除密码、响应状态码、状态描述、原始返回行全部记到日志里。很多看起来玄学的问题比如解析列表差一位、中文乱码、目录判断出错只要你把原始返回打出来基本一眼就能定位。这个习惯帮我省了无数沟通成本强烈推荐你也这么做。本文还有配套的精品资源点击获取