C#实现SFTP上传下载:进度条、断点续传与工程实践
2026/9/7 8:29:24 网站建设 项目流程

简介:这是一份面向C#开发者的SFTP文件传输示例工程,基于Renci.SshNet库实现上传与下载,并重点演示通过回调机制完成进度条展示,适合有一定C#基础、希望为内部工具添加安全文件传输能力的开发者,解决实际项目中文件传输缺少进度反馈的痛点。压缩包共26个文件,核心包含C#源码文件、Visual Studio工程配置、可直接运行的exe程序、依赖的Renci.SshNet动态库以及调试符号和资源文件,整体仅533KB,结构轻量适合快速阅读与二次修改。已有1678人学习该资源。通过工程内的窗体代码,可直观看到进度条的界面实现;上传与下载示例结合,覆盖SftpClient连接、带回调的UploadFile/DownloadFile重载、百分比换算及界面刷新等关键环节。对于想要在C#项目中集成SFTP功能并增强交互体验的开发者,这份带完整工程与运行文件的示例包具有直接参考价值。 作为一个常年写C#上位机的开发者,我对“文件从工控机传到服务器”这个需求再熟悉不过了。之前项目里一直用FTP,但客户现场要求走SFTP——基于SSH协议的安全文件传输,端口用22,账号密码和传输内容全程加密。标题里提到的“C#实现SFTP文件上传和下载,有进度条”,我这次就把完整实现方案、进度条的几种写法,以及我在实际项目中踩过的坑一次性说清楚。代码基于.NET 6,NuGet包用Renci.SshNet,如果你用的是.NET Framework 4.7.2,同样适用,细节我会备注。

1. 方案选型与技术思路

1.1 为什么选Renci.SshNet,而不是WinSCP或SharpSSH

先聊库的选型。C#操作SFTP的库不多,常见的有Renci.SshNet、SSH.NET(其实是同一个东西的历史版本)、WinSCP .NET程序集,以及老掉牙的SharpSSH。

WinSCP .NET本质是包装了WinSCP.exe的COM接口,需要目标机器安装或随程序携带WinSCP.exe,部署麻烦不说,多一个外部进程就多一个不稳定因素。SharpSSH早就停更了,连.NET Framework 4.5跑起来都费劲,直接排除。

Renci.SshNet是纯C#实现的SSH协议库,NuGet直接装,不依赖任何外部程序,支持.NET Framework 3.5到.NET 8。最关键的是,它自带异步上传下载接口SftpUploadAsync和SftpDownloadAsync,配合IProgress 就能实现进度回调,省去自己包一层线程的麻烦。我在多个项目里用过,稳定性足够应对生产环境。

有一点需要注意:Renci.SshNet历史上改过几次命名空间,老版本是Renci.SshNet,新版本也是Renci.SshNet,但包名可能带SshNetSSH.NET字样。NuGet搜索时认准Renci.SshNet这个包名,作者是drieseng和Renci。2020年之后更新的版本要求.NET Framework 4.6.2+或.NET 6+,如果你的目标框架偏老,用2020.0.2版本最稳。

1.2 进度条为什么比想象中更关键

很多人觉得进度条就是UI上画个百分比,简单得很。但实际做过大文件传输就明白,没有进度反馈的程序,用户超过10秒就会觉得“卡死了”,然后直接关进程,传输中断,服务器上留下半个文件。这不是用户体验问题,是工程可靠性问题。

进度条本质是给调用方一个可观测的窗口:不仅能展示百分比,还能推算出当前传输速度、剩余时间,甚至可以在传输异常时及时发现(比如进度长时间不变化,基本可以断定连接断开了)。对于上位机场景,这一步尤其重要——操作工不会去看日志,他们只认屏幕上的进度条。

另外一个设计层面的事:下载进度和上传进度实现方式完全不同。上传时客户端主动把数据写进网络流,你可以控制每次Write的大小;下载时数据是从服务器流过来的,你只能被动接收。这两种模式对进度计算的时机有影响,下文会专门讲。

2. 环境准备与最小可运行Demo

2.1 创建项目与安装依赖

先建一个WPF或WinForm项目,.NET 6以上均可。用命令行创建WPF项目:

dotnet new wpf -n SftpDemo cd SftpDemo dotnet add package Renci.SshNet

如果你用Visual Studio,直接在“管理NuGet程序包”里搜Renci.SshNet,安装最新稳定版即可。我这里实测用的是Renci.SshNet 2024.1.0

连接SFTP服务器前,先确认三件事:服务器IP和端口、账号密码(或私钥)、远程目录是否有读写权限。没有测试服务器的,可以在Windows上自己搭一个OpenSSH服务端,Win10/11的“可选功能”里直接添加OpenSSH服务器,配置文件sshd_config里把Subsystem sftp的注释去掉,就能当SFTP服务器用。我测试时就拿本地虚拟机当靶机,方便得很。

2.2 最简连接与上传下载实现

先来一个无进度条的纯净版,让大家对SshNet的基本用法有个底:

using Renci.SshNet; using Renci.SshNet.Sftp; var connectionInfo = new ConnectionInfo( "192.168.1.100", // 服务器IP 22, // 端口 "username", // 用户名 new PasswordAuthenticationMethod("username", "password") ); using var client = new SftpClient(connectionInfo); client.Connect(); // 上传 using var fileStream = File.OpenRead(@"D:\temp\local.zip"); client.UploadFile(fileStream, "/remote/backup/local.zip"); // 下载 using var downloadStream = File.Create(@"D:\temp\remote.zip"); client.DownloadFile("/remote/backup/remote.zip", downloadStream); client.Disconnect();

就这么简单,连上就能传。这段代码的问题在于:

  • 没有进度反馈;
  • 文件全部读进内存?——不是,UploadFile内部是分块读的,但File.OpenRead本身是按需读取,大文件不会爆内存,这一点SshNet做得挺好;
  • 没有异常处理,断线直接抛SshConnectionException
  • 上传和下载都是同步阻塞的,在UI线程调用会卡界面。

所以要引入异步和进度。注意SftpClient的异步方法不是标准async/await从头到尾,而是返回Task,内部基于线程池调用同步方法。对于UI程序,这不影响使用,配合IProgress<T>ConfigureAwait就能平滑集成。

3. 进度条实现的核心细节

3.1 上传进度:利用SftpUploadAsync的IProgress回调

Renci.SshNet从某个版本开始提供了UploadFile的异步版本,签名是:

public Task UploadFile(Stream input, string remotePath, Action<ulong> progressCallback, ...)

不是标准IProgress<T>,而是Action<ulong>。这个ulong参数是已经上传的字节数。这里有个大坑:如果你想在回调里访问UI控件,这个回调是在线程池线程执行的,不能直接在回调里写ProgressBar.Value = xxx,会抛出跨线程异常(WinForm)或直接崩(WPF)。

解决方式有两种:一是自己捕获SynchronizationContext,把更新UI的动作Post回UI线程;二是用IProgress<T>包装。我推荐第二种,因为Progress<T>的构造器内部会捕获当前同步上下文,用起来最省心。

一个可直接落地的上传方法:

public async Task UploadFileWithProgressAsync( string localPath, string remotePath, IProgress<double> progress, CancellationToken ct = default) { var fileInfo = new FileInfo(localPath); long totalBytes = fileInfo.Length; using var fileStream = new FileStream(localPath, FileMode.Open, FileAccess.Read); using var client = CreateSftpClient(); await Task.Run(() => { client.Connect(); ulong uploaded = 0; var buffer = new byte[81920]; // 80KB,SshNet内部建议值 using var remoteStream = client.OpenWrite(remotePath); int bytesRead; while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) > 0) { ct.ThrowIfCancellationRequested(); remoteStream.Write(buffer, 0, bytesRead); uploaded += (ulong)bytesRead; double percent = (double)uploaded / totalBytes * 100.0; progress.Report(percent); } }, ct); }

这里我没用UploadFile的异步重载,而是直接用OpenWrite+ 手动写流。原因有三:

  1. 可以精确控制每次Write的大小和时机,进度计算更平滑;
  2. 中途可以检查CancellationToken,实现取消操作;
  3. 某些版本UploadFileAction<ulong>回调频率不一致,实测会出现“前99%一下子跳完,最后卡1%”的假象,手动写流反而稳定。

OpenWrite返回的SftpFileStream提供了字节级写入能力,内部封装了SFTP协议的数据通道,写入即发送到服务器,不需要担心缓冲溢出的问题。实测80KB缓冲区在千兆内网速度不错,如果是跨公网,可以调小到32KB,减少单次传输超时重传的代价。

3.2 下载进度:基于位置偏移的实时计算

下载比上传的进度计算麻烦一点,因为SftpFileStream.Read方法的返回值是本次实际读取的字节数,但你无法预知文件总大小吗?——可以,SftpFileStream.Length属性直接给了服务器端文件大小。这就够了。

一个稳定的下载实现:

public async Task DownloadFileWithProgressAsync( string remotePath, string localPath, IProgress<double> progress, CancellationToken ct = default) { using var client = CreateSftpClient(); await Task.Run(() => { client.Connect(); var remoteFileInfo = client.GetAttributes(remotePath); long totalBytes = remoteFileInfo.Size; using var remoteStream = client.OpenRead(remotePath); using var fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write); var buffer = new byte[81920]; long totalRead = 0; int bytesRead; while ((bytesRead = remoteStream.Read(buffer, 0, buffer.Length)) > 0) { ct.ThrowIfCancellationRequested(); fileStream.Write(buffer, 0, bytesRead); totalRead += bytesRead; double percent = (double)totalRead / totalBytes * 100.0; progress.Report(percent); } }, ct); }

这里有几个细节值得注意:

  • GetAttributes拿到文件大小,避免边下载边读取Length造成的额外往返;
  • OpenRead返回的流同样支持PositionLength,如果你看到别人代码里直接用stream.Length计算进度,也可以,但性能上多一次SFTP请求;
  • 下载到本地时,如果目标目录不存在,FileStream会直接抛DirectoryNotFoundException,所以要先Directory.CreateDirectory(Path.GetDirectoryName(localPath)),我项目里就因为这个翻过车。

3.3 UI线程与进度条绑定的正确姿势

写WPF时,我见过太多人直接在进度回调里this.Dispatcher.Invoke,然后界面卡成PPT。其实WPF的Progress<T>已经帮你做了同步上下文切换,正确用法是:

// 确保在UI线程创建Progress,构造函数内部捕获SynchronizationContext var progress = new Progress<double>(p => { ProgressBar.Value = p; TextBlockPercent.Text = $"{p:F1}%"; }); await sftpService.UploadFileWithProgressAsync(localPath, remotePath, progress);

Progress<T>内部封装的SynchronizationContext会自动把回调投递到UI线程,你在回调里直接更新控件是安全的。核心原则:创建Progress<T>实例的线程必须是UI线程,否则回调会在线程池线程执行。

如果用的是WinForm,逻辑同理,Progress<T>也能适配WindowsFormsSynchronizationContext。

进度条还有个体验细节:除了百分比,最好把当前速度(MB/s)和剩余时间(秒)一并显示出来。计算方式很简单:记录上一次回调的时间和字节数,用增量算瞬时速度,再用剩余字节数除以速度得到剩余时间。下面这段逻辑可以直接抄:

// 在循环里维护这几个变量 long lastBytes = 0; DateTime lastTime = DateTime.UtcNow; // 每次Write后计算 var now = DateTime.UtcNow; var elapsed = (now - lastTime).TotalSeconds; if (elapsed >= 0.5) // 0.5秒算一次,避免刷新太频繁 { double speed = (uploaded - lastBytes) / elapsed / 1024.0 / 1024.0; // MB/s double remainingSeconds = (totalBytes - uploaded) / (speed * 1024.0 * 1024.0); if (speed > 0) { progress.Report(percent, speed, remainingSeconds); } lastBytes = (long)uploaded; lastTime = now; }

这里0.5秒的节流很重要。80KB缓冲区在千兆内网一秒能传几十次,如果不节流,进度条回调会被轰炸,UI线程压力陡增。0.5秒刷新一次,人眼已经觉得流畅了。

4. 工程化落地:断点续传、重试与日志

4.1 断点续传:OpenWrite + Position追击

SFTP本身不支持服务端断点续传指令,但Renci.SshNet的SftpFileStream是允许设置Position的,所以我们可以自己做断点续传。思路很简单:上传前先检查远程文件已存在的字节数,本地文件流跳转到同一个位置,再以追加模式写入剩余部分。

public async Task UploadResumeAsync(string localPath, string remotePath, IProgress<double> progress, CancellationToken ct = default) { var localInfo = new FileInfo(localPath); long totalBytes = localInfo.Length; using var client = CreateSftpClient(); client.Connect(); long remoteBytes = 0; if (client.Exists(remotePath)) { remoteBytes = client.GetAttributes(remotePath).Size; } // 远程文件比本地还大,说明异常,重新传 if (remoteBytes >= totalBytes) { client.DeleteFile(remotePath); remoteBytes = 0; } using var localStream = new FileStream(localPath, FileMode.Open, FileAccess.Read); localStream.Seek(remoteBytes, SeekOrigin.Begin); using var remoteStream = client.OpenWrite(remotePath); remoteStream.Seek(remoteBytes, SeekOrigin.Begin); var buffer = new byte[81920]; long uploaded = remoteBytes; int bytesRead; while ((bytesRead = localStream.Read(buffer, 0, buffer.Length)) > 0) { ct.ThrowIfCancellationRequested(); remoteStream.Write(buffer, 0, bytesRead); uploaded += bytesRead; progress.Report((double)uploaded / totalBytes * 100.0); } }

断点续传有几个前提必须提前讲清楚:

  • 远程服务器端必须支持OpenWriteSeek,OpenSSH默认支持,但某些商业SFTP服务器可能会限制;
  • 如果源文件在传输过程中被改动,续传会发生数据错乱,所以只对“固定不变的归档文件”做续传才安全;
  • 断点续传失败时,最好删除远程半成品文件重新完整上传,而不是反复续传,否则可能越续越乱。我封装的逻辑里如果remoteBytes >= totalBytes就直接删除重传,也是这个考虑。

4.2 重试机制:指数退避与丢线重连

SFTP传输最头疼的问题就是网络抖动。SocketExceptionSshConnectionException都可能随时抛出来。上层业务不可能因为一次抖动就失败,所以重试机制是必须的。

推荐指数退避策略:第一次失败等1秒,第二次等2秒,第三次等4秒,最多重试3次。代码模板:

private async Task<T> WithRetryAsync<T>(Func<T> action, int maxRetries = 3) { int attempt = 0; while (true) { try { return action(); } catch (Exception ex) when ( ex is IOException || ex is SshConnectionException || ex is SocketException || ex is SshException) { attempt++; if (attempt >= maxRetries) throw; int delaySeconds = (int)Math.Pow(2, attempt); // 1, 2, 4 Log.Warn($"SFTP操作失败,第{attempt}次重试,等待{delaySeconds}秒", ex); await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); } } }

注意两点:

  1. 重试应当只对“连接阶段”和“重新传输整个文件”生效,对于已经写了一半的文件,重试必须走断点续传逻辑,否则服务器上会残留多个半截文件;
  2. 每次重试必须重新Connect(),因为SftpClient实例一旦断线,内部状态不可信,不要试图复用同一个连接。

4.3 日志与异常处理:让问题可追溯

进度条做得再花哨,出问题时没有日志就是瞎子。我习惯把SFTP操作分成“连接日志”和“传输日志”两类。

连接日志:记录服务器地址、端口、用户名、连接耗时、是否认证成功。注意不要记录密码,安全红线。

传输日志:记录文件路径、文件大小、开始时间、结束时间、平均速度、结果、异常堆栈。用结构化日志最好,后续可以统计传输成功率。没有接入Serilog的,直接Console.WriteLineDebug.WriteLine也行,但至少要有。

还有一个冷门但实用的技巧:传输完成后,主动对比本地文件和远程文件的大小,不一致就报错。这一步能兜住很多诡异的SFTP写入问题,比如服务器磁盘满了、配额限制、文件名编码异常等。逻辑不复杂:

// 上传完成后校验 var remoteInfo = client.GetAttributes(remotePath); if (remoteInfo.Size != new FileInfo(localPath).Length) { throw new InvalidOperationException($"上传后文件大小不一致,本地: {localInfo.Length}, 远程: {remoteInfo.Size}"); }

5. 常见问题与排查技巧实录

5.1 连接不上或频繁断线

先解释一个关系:SFTP底层是SSH协议,端口22。很多客户现场把22端口封了,或者只允许特定IP访问,导致连接超时。

遇到连接问题,按这个顺序排查:

  1. 服务器SSH端口是否可达:Test-NetConnection 192.168.1.100 -Port 22
  2. 账号密码是否正确(SshNet对密码错误抛的是SshAuthenticationException);
  3. 服务器是否配置了AllowTcpForwarding之类的限制,虽然这跟SFTP无关,但某些安全策略会连带影响;
  4. 如果连接后频繁断线,多半是服务器设置了ClientAliveIntervalClientAliveCountMax,客户端长时间不发送数据就会被踢。SshNet的ConnectionInfo里可以设置KeepAliveInterval,我项目里设置了TimeSpan.FromSeconds(30),配合服务器端ClientAliveInterval=60,基本不会再被“broken pipe”。

热搜词里有句linux sftp -oport send disconnect: broken pipe,这种错误本质也是连接断开后,TCP层才感知到,发送时发现管道已破。除了保活,还可以在传输时对IOExceptionSshConnectionException做重试,把断线降级为一次可恢复的异常。

5.2 进度条不走或卡在99%

进度条不走,先确认是不是回调线程问题。WinForm直接跨线程更新控件,会抛InvalidOperationException,但WPF有时只是不刷新,很迷惑人。

卡在99%的情况,多半是文件流没有FlushSftpFileStream.Write可能有内部缓冲,要remoteStream.Flush()或者Dispose()之后才会真正写完。进度到99%意味着你的计数器已经统计了最后一块数据,但网络栈还在把缓冲区往外发。解法是在写入循环结束后调用Flush,再Dispose,并把进度条设为100%。

还有一个容易被忽略的点:不要在整个循环结束前就Report(100)。真正的“完成”应该是Dispose成功之后。所以推荐顺序是:写完循环 →Flush()Dispose()Report(100)

5.3 中文文件名乱码或找不到文件

SshNet有一个坑:如果服务器的locale不是UTF-8,中文文件名会乱。OpenSSH默认UTF-8还好,但Windows端的Sftp服务器(比如Core FTP或Xlight)可能默认GBK。

解决办法是创建SftpClient时指定编码:

var client = new SftpClient(connectionInfo) { Encoding = System.Text.Encoding.UTF8 };

如果服务器用GBK,就把Encoding换成Encoding.GetEncoding("GBK")。这个代码在.NET Core上需要先注册编码提供程序:

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

5.4 超大文件内存爆炸

虽然OpenReadOpenWrite都是流式操作,不会一次把整个文件加载进内存,但如果你图省事用的File.ReadAllBytesclient.ReadAllBytes,10GB文件直接OOM。这条适用于任何大文件传输,务必保持流式读写。

如果你想追求更快的速度,可以对文件做并行分块上传,每个块一个独立SFTP连接。但说实话,绝大多数场景不需要,单连接已经能跑满千兆内网,多连接只会增加服务端压力,还容易触发限速策略。建议先测单连接,不够再上并行。

写在项目之后

我做这类SFTP工具的经验是:先有一个能跑通的最小版本,再逐步加进度条、断点续传、重试、日志,不要一上来就搞大而全的框架。进度条看起来是锦上添花,实际上它是传输过程最直观的健康检查,非常值得优先实现。

最后分享一个测试技巧:在你自己的Windows机器上装OpenSSH服务器,局域网内拿另一台机器连过来传文件,这样能复现大多数远程传输问题,又不会把生产服务器搞坏。我也习惯在代码里加一个SimulateSlowNetwork的开关,用Thread.Sleep控制写流的速度,模拟弱网下的进度条表现,排查UI卡顿或进度跳跃的问题。这个办法简单省事,推荐你也试试。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询