ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#实现SFTP上传下载带进度条:基于Renci.SshNet与Stream包装类

C#实现SFTP上传下载带进度条:基于Renci.SshNet与Stream包装类 简介面向需要在C#项目中实现SFTP安全文件传输的开发者这份示例工程围绕Renci.SshNet库封装了上传与下载操作并通过回调机制呈现实时进度解决默认接口无进度反馈的问题。工程共26个文件包含6个C#源码文件、编译生成的exe可执行程序、WinForms界面资源、Renci.SshNet.dll依赖库及pdb调试符号等压缩包仅533KB结构清晰便于直接运行与二次开发。已有1680人学习下载适合初学者对照理解SFTP连接、文件流读写与进度回调触发流程也适合有经验的开发者快速复用代码。工程内附完整源码与可运行程序覆盖Form1界面设计、Program入口、项目配置及依赖组件演示了带回调的UploadFile/DownloadFile调用方式可在此基础上扩展为WinForms或WPF进度条控件。1. C#实现SFTP文件上传下载带进度条先解决UI线程的堵车问题一个WinForms工具要往远程Linux服务器传文件还得让用户看着进度条从0跑到100%这需求听着不难但第一版直接在按钮点击事件里调用Renci.SshNet的SftpClient.UploadFile窗体立刻白屏进度条纹丝不动转圈光标转了好几分钟。问题不在SFTP协议本身而是C#的文件传输与进度条刷新天然不落在同一条时间线上。这篇笔记记录我拆完这个需求后的完整落地方案用Renci.SshNet做C# SFTP文件上传下载自建Stream包装类实现字节级进度回调覆盖UI线程隔离、超时参数调优与五个高频踩坑点。适合做WinForms上位机、桌面运维工具、以及想在.NET项目里接SFTP但不想被同步阻塞坑住的人。2. 进度回调的底层逻辑为什么Renci.SshNet没有内置进度事件2.1 UI线程被文件传输堵死的根因WinForms控件更新依赖UI线程的消息循环。消息循环不仅要处理按钮点击、鼠标移动还要处理WM_PAINT重绘消息和进度条刷新消息。当你直接在按钮事件里调用UploadFile时UI线程被一个长时间运行的同步方法占住消息队列里的重绘消息根本没有机会执行。于是窗体标题栏出现“未响应”进度条卡在初始位置操作系统甚至会弹窗问你要不要强制结束进程。这里得想明白一件事任何文件传输框架只要它的API是同步阻塞的直接放在UI线程里都会导致界面假死。SftpClient.UploadFile就是这种方法——从本地流读取数据、加密、通过SSH通道发送整个过程不交还控制权给消息循环。这不是库的缺陷而是桌面程序与网络IO的经典矛盾。常见的解法是丢到后台线程。但丢到后台线程之后紧接着就是第二个问题怎么把传输进度实时反馈给UI线程。Renci.SshNet这个库在文件级没有提供现成的ProgressChanged事件所以进度反馈只能自己造。造的过程中还得先搞清楚它底层是怎么读流的不然回调触发的频率和位置都找不准。2.2 选型理由纯托管实现与可控的Stream入口我为什么选Renci.SshNet而不是WinSCP命令行或者SharpSSHWinSCP有命令行模式和.NET COM接口但它依赖外部安装输出的文本进度需要解析适合运维脚本而不适合嵌入WinForms界面。SharpSSH是个老库底层依赖Granados和Tamir.SharpSsh多年不更新.NET Core下基本不可用。Renci.SshNetNuGet包名SSH.NET命名空间Renci.SshNet是纯托管实现NuGet一键安装兼容.NET Framework 4.5到.NET 6/8桌面工具、控制台、Windows服务都能用。关键点在SftpClient的两个重载方法上。上传时调用UploadFile(Stream input, string path)下载时调用DownloadFile(string path, Stream output)。这两个重载都接收Stream参数意味着可以传入一个自定义Stream子类拦截底层对Read和Write的调用在拦截处统计字节数、触发进度回调。Renci内部根本感知不到这层包装它只看到自己在操作一个普通流。方案进度反馈方式依赖适合场景Renci.SshNet 自定义Stream精确到字节数的回调NuGet包WinForms/WPF桌面工具WinSCP命令行解析控制台输出文本外部软件运维脚本SharpSSH无进度接口老第三方库新项目不推荐Renci的SftpClient构造参数支持host、port、username、password这套基础组合也支持私钥登录用PrivateKeyFile类加载OpenSSH格式的私钥文件。私钥文件在Linux服务器上会被严格检查权限这个细节放到后面避坑章节展开。2.3 BufferSize决定进度条是平滑还是跳格子SftpClient有一个BufferSize属性它控制着每次从我们传入的Stream中读取多少字节。这个值直接决定回调频率100MB文件、BufferSize设64KB时Read大约被调用1600次如果BufferSize设1MB回调就只有100次左右。回调次数越多进度条越平滑但每次回调背后都跟着一次加密和网络写入过小的BufferSize会明显拖慢吞吐。我一般会先把BufferSize设成256KB局域网环境下传百兆文件进度条刷新手感比较舒服跨公网传输时降到64KB因为公网延迟大分块太碎容易触发对端的拥塞控制整块太大又让单次TCP窗口填不满。这个参数属于跑起来之后再看效果调整的选项不用一开始就追求最优值。另外千万别用轮询远程文件大小的方式来算进度——每GetAttributes一次就是一次完整的SFTP请求给服务器增加无谓负担而且服务器端在写入过程中远程文件大小不一定按本地方向递增算出来的进度根本不准。3. 上传下载落地包装Stream实现进度回调的完整代码3.1 自定义ProgressStream把Read和Write变成进度计数器先写一个ProgressStream类继承System.IO.Stream内部包一个真实流。重写Read和Write两个方法每次调用后累加已处理字节数再把进度百分比抛给回调函数。其他抽象成员直接转发给内部流保证SftpClient能像操作普通流一样操作它。public class ProgressStream : Stream { private readonly Stream _inner; private readonly long _totalBytes; private long _processedBytes; private readonly Actiondouble _progressCallback; public ProgressStream(Stream inner, long totalBytes, Actiondouble progressCallback) { _inner inner; _totalBytes totalBytes; _progressCallback progressCallback; } public override int Read(byte[] buffer, int offset, int count) { int bytesRead _inner.Read(buffer, offset, count); _processedBytes bytesRead; _progressCallback?.Invoke(GetPercent()); return bytesRead; } public override void Write(byte[] buffer, int offset, int count) { _inner.Write(buffer, offset, count); _processedBytes count; _progressCallback?.Invoke(GetPercent()); } private double GetPercent() _totalBytes 0 ? 100 : (double)_processedBytes / _totalBytes * 100.0; public override bool CanRead _inner.CanRead; public override bool CanSeek _inner.CanSeek; public override bool CanWrite _inner.CanWrite; public override long Length _inner.Length; public override long Position { get _inner.Position; set _inner.Position value; } public override void Flush() _inner.Flush(); public override long Seek(long offset, SeekOrigin origin) _inner.Seek(offset, origin); public override void SetLength(long value) _inner.SetLength(value); }这段代码是整个进度方案的核心。Renci上传文件时会调用ProgressStream的Read方法读取本地文件内容每读一次_processedBytes就累加一次除以_totalBytes就能算出实时进度。下载时Renci把ProgressStream当作目标流往里走Write分支。所以这一个类两头通用上传和下载共用。构造函数里的totalBytes上传时从FileInfo.Length拿下载时从GetAttributes(remotePath).Size拿。回调参数用double而不是int因为小数位的百分比在UI上显示起来更细腻。3.2 上传文件把回调接到WinForms控件上private void UploadFileWithProgress(string localFile, string remoteFile) { using (var client new SftpClient(192.168.1.100, 22, sftpuser, password)) { client.Connect(); var localInfo new FileInfo(localFile); using (var stream File.OpenRead(localFile)) using (var progress new ProgressStream(stream, localInfo.Length, OnUploadProgress)) { client.UploadFile(progress, remoteFile); } client.Disconnect(); } } private void OnUploadProgress(double percent) { progressBar.BeginInvoke(new Action(() { progressBar.Maximum 100; progressBar.Value (int)Math.Min(percent, 100); lblStatus.Text $上传中{percent:F1}%; })); }File.OpenRead返回的FileStream是真实数据源ProgressStream包一层之后再传给UploadFile。Renci在内部循环里不断调用progress.Read统计逻辑就在这个循环里触发。OnUploadProgress被回调时仍然在后台线程所以必须用BeginInvoke丢回UI线程更新控件。Math.Min(percent, 100)是防呆写法因为浮点计算可能得到100.0001这种值。3.3 下载文件远程文件大小决定进度上限private void DownloadFileWithProgress(string remoteFile, string localFile) { using (var client new SftpClient(192.168.1.100, 22, sftpuser, password)) { client.Connect(); var attrs client.GetAttributes(remoteFile); long totalBytes attrs.Size; using (var stream File.Create(localFile)) using (var progress new ProgressStream(stream, totalBytes, OnDownloadProgress)) { client.DownloadFile(remoteFile, progress); } client.Disconnect(); } }下载时Renci把progress当作目标流频繁往里Write。远程文件大小通过GetAttributes拿这是SFTP协议属性里的文件长度字段。这里有个隐含前提传输期间远程文件大小不能变化。如果源文件正在被服务器上的另一个进程持续写入totalBytes会偏小进度会提前走到100%。稳妥做法是传输前再读一次大小如果拿到的Size为0直接给出提示而不是傻等。3.4 异步执行与按钮状态管理上面的传输方法在按钮事件里直接调用UI还是会被挡只是挡的时间从传输全程变成了连接等待阶段。正确做法是用Task.Run把传输丢到线程池private async void BtnUpload_Click(object sender, EventArgs e) { btnUpload.Enabled false; progressBar.Value 0; try { await Task.Run(() UploadFileWithProgress(localFile, remoteFile)); lblStatus.Text 上传完成; } catch (Exception ex) { MessageBox.Show($传输失败{ex.Message}); } finally { btnUpload.Enabled true; progressBar.Value 0; } }事件处理器写成async void是WinForms里的合法用法因为事件委托签名要求void返回。await Task.Run(...)会把传输任务丢到线程池执行UI线程利用await里的调度器继续处理消息循环。这样进度条回调里的BeginInvoke才能真正起作用。按钮先禁用再恢复防止用户在传输过程中重复点击造成两个连接并发操作同一个SftpClient实例——Renci的SftpClient不是线程安全的并发调用会得到不可预期的行为。4. 参数调优超时、BufferSize与临时文件策略4.1 ConnectionTimeout与OperationTimeout两个容易搞混的超时client.ConnectionTimeout TimeSpan.FromSeconds(15); client.OperationTimeout TimeSpan.FromSeconds(60); client.KeepAliveInterval TimeSpan.FromSeconds(30);ConnectionTimeout覆盖从TCP建连到SSH密钥交换完成的阶段OperationTimeout覆盖单个SFTP请求的等待比如打开远程文件句柄、读取目录、重命名文件。KeepAliveInterval是维护SSH会话的心跳包间隔适合连接长时间挂着的场景。这两个超时的区分对排错的意义很大。局域网里连接秒开但服务器SSH服务端没响应时ConnectionTimeout会先触发传输中途物理断网时TCP连接已经建立过ConnectionTimeout不再生效OperationTimeout才管用。我遇到过上传到一半卡死二十分钟的情况最后发现OperationTimeout默认值太长断网后线程在等待远程确认消息界面就挂在那里。经验是把OperationTimeout控制在60秒以内配合重试逻辑比让用户干等要好得多。4.2 BufferSize与网络环境的匹配经验BufferSize影响的是Renci从我们传入Stream里读取的块大小也直接控制加密和网络发送的频率。数值设置不是越大越快也不是越小越平滑要看实际网络环境。网络环境BufferSize建议现象反馈局域网千兆256KB进度条刷新平滑传输速度可跑满带宽跨公网10M~50M64KB~128KB回调频率适中丢包重传代价可控弱网Wi-Fi/高延迟32KB~64KB每块传输时间短UI响应快但吞吐略降BufferSize过大时回调次数会变少100MB文件如果设成4MB整个过程只回调25次左右进度条显示就会出现明显跳格。设太小时每个块都经历一次加密和网络往返整体吞吐掉得很明显。建议先用默认值跑一版抓到实际网络环境后再调。4.3 临时文件与重试让传输失败不产生脏数据直接往最终路径上传有风险。传了一半断网远程路径上留下一坨不完整文件其他程序如果扫描到了就会当完整文件处理。我一般先传到xxx.part完成后再重命名string tempRemote remotePath .part; client.UploadFile(progress, tempRemote); if (client.Exists(tempRemote)) { client.RenameFile(tempRemote, remotePath); }重试逻辑放在调用层只对网络类异常重试int maxRetry 3; for (int i 0; i maxRetry; i) { try { UploadFileCore(localPath, remotePath); break; } catch (SshException ex) when (i maxRetry - 1) { Thread.Sleep(1000 * (i 1)); } catch (SocketException ex) when (i maxRetry - 1) { Thread.Sleep(1000 * (i 1)); } }SshException和SocketException这类网络层异常值得重试但“远程路径不存在”“权限拒绝”这种确定性错误重试一百次也是同样结果。重试之前必须把.part临时文件清掉否则第二次上传会基于残留文件的状态远程文件名冲突或者大小对不上。这些参数组合起来才能让进度条下的文件传输在恶劣网络里站得住。5. 避坑SFTP文件传输最常见的五个坑与排查路径5.1 第一次连接就报主机密钥未知现象SftpClient.Connect()抛异常提示remote host key is unknown或者连接直接被拒。原因Renci默认不信任任何主机公钥这是安全设计不像某些FTP客户端会把未知密钥弹窗让用户确认。程序里不做处理它就拒绝建立连接。解决处理HostKeyReceived事件代码里显式信任client.HostKeyReceived (sender, e) e.CanTrust true;生产环境别这么裸奔。正确做法是把服务器公钥指纹固定下来在回调里比对client.HostKeyReceived (sender, e) { e.CanTrust e.FingerPrint.SequenceEqual(expectedFingerprint); };FingerPrint属性是SHA256哈希的字节数组第一次连接时用ssh-keyscan工具把服务器指纹抓下来存到配置里。这是SFTP连接层的第一个坑几乎每个新项目都会撞一次。5.2 进度条卡在99%或者100%后假死现象进度显示已经到100%但程序还停在那里转圈图标一直转状态栏文案不变。原因UploadFile在读完流、发完数据之后还要做flush、关闭远程句柄、发送SSH通道结束消息。这个阶段没有任何数据流动我们的进度统计已经到100%但方法还没返回。如果这时候在UI上弹“完成”提示用户看到的就是完成弹窗弹出来之后界面还在卡。解决回调里最多刷到99%剩余那1%交给UploadFile返回后的代码client.UploadFile(progress, remotePath); client.Disconnect(); lblStatus.Text 上传完成;这个习惯我沿用到现在所有带进度条的文件传输都没让进度先于业务逻辑到达100%。而且不要在进度回调里做任何耗时操作回调频率高的时候在UI线程里更新一下进度条就够了写日志都放到节流之外。5.3 上传成功后远程文件是0字节现象进度跑完远程文件也存在但大小是0。原因File.OpenRead打开的流没有内容可读。常见于本地文件路径写错、或者另一个程序正在往这个文件里写数据还没flush、或者ProgressStream包装类里忘了转发Position属性导致Renci内部Seek出错。实际排查中Renci的UploadFile不会随意Seek输入流最可能的原因就是路径错了或者流确实为空。解决上传前检查FileInfo.Length是否大于0上传后用GetAttributes对比远程大小和本地大小if (localInfo.Length ! client.GetAttributes(remotePath).Size) { // 传输不完整删除远程文件重新传 }如果远程文件不存在GetAttributes会抛FileNotFoundException这个检查本身就能暴露路径问题。5.4 远程路径权限与绝对路径问题现象Connect成功但UploadFile抛异常异常信息里带Permission denied或者No such file。原因SFTP用户对目标目录没有写权限或者路径用了相对路径。Renci会把相对路径解析到用户home目录下结果和预期位置完全对不上。解决远程路径统一用/开头的全路径上传前先探测目录存在性再验证写权限if (!client.Exists(/var/data/uploads)) { throw new DirectoryNotFoundException(远程目录不存在); }权限问题需要用服务器侧配置解决chown调整目录归属或者在SFTP服务器配置里限定可写目录。注意SFTP是SSH子系统目录权限检查逻辑与shell登录一致能登录不一定能写目标目录。5.5 跨线程更新进度条直接抛异常现象后台线程里执行progressBar.Value 80抛出InvalidOperationException提示线程间操作无效。原因WinForms控件和创建它的线程绑定后台线程直接改属性属于违规访问这是WinForms更新状态栏与进度条时最经典的一个报错。解决用BeginInvoke包装更新操作private void SafeUpdateProgress(int value) { if (progressBar.IsHandleCreated) { progressBar.BeginInvoke(new Action(() progressBar.Value value)); } }加IsHandleCreated判断是因为窗体关闭后BeginInvoke会抛ObjectDisposedException。这个判断是WinForms里更新任何控件的标准保险写法不只是进度条。6. 进阶取消传输、大小校验与断点续传的取舍给ProgressStream加一个CancellationToken参数在Read和Write方法里检查取消状态public override int Read(byte[] buffer, int offset, int count) { _token.ThrowIfCancellationRequested(); int bytesRead _inner.Read(buffer, offset, count); _processedBytes bytesRead; _progressCallback?.Invoke(GetPercent()); return bytesRead; }用户点击取消按钮后触发_cts.Cancel()下一次Read或Write立刻抛OperationCanceledExceptionRenci原样把这个异常抛给调用方。捕获到取消异常后要做的第一件事是清理远程.part临时文件避免磁盘上堆积垃圾文件。关于断点续传Renci.SshNet没有直接的AppendFile接口SftpClient的Open(remotePath, FileMode.Append, FileAccess.Write)可以用但SFTP协议对Append模式的支持依赖服务器端实现很多服务器用的是OpenSSH的SFTP子系统对Append支持并不总是如预期。与其赌服务器行为不如走“失败重传整个文件”的路线配合临时文件和大小校验实际体验比半吊子的续传要可靠得多。完整性校验方面SFTP协议没有原生的远程哈希接口Renci也没提供计算远程MD5的能力。如果同时能拿到SSH shell访问权限可以调用md5sum远程计算再和本地比对但那已经超出SFTP协议范围了。纯SFTP场景里最实用的校验就是大小对比上传完成后GetAttributes拿远程大小和本地FileInfo.Length比对。百兆以上的大文件字节数都能对上基本可以排除截断风险对不上就删除重传。这个校验逻辑配合.part临时文件策略就能组成一条完整的传输闭环。从那次“进度条100%但远程文件0字节”的翻车之后我给自己定了个硬习惯任何一次SFTP传输必须走完“临时文件名、进度回调、大小校验、改名”四步才算完。代码的异常分支里先把远程残留文件清掉再谈重试。这套流程在几个项目里跑了很长时间没有一次因为传输中断背锅。希望帮到你。本文还有配套的精品资源点击获取
返回列表