简介:面向需要在C#项目中实现安全文件传输的.NET开发者,资源围绕Renci.SshNet库的SFTP上传与下载场景,提供带进度回调的完整可运行示例。资源共26个文件,压缩包仅533KB,以C#源码、可执行程序、DLL依赖库为主,同时包含解决方案、工程文件、资源文件、调试符号、Renci.SshNet动态库及源码备份压缩包等;工程名为SFTPtest,内含窗体界面代码与程序入口,目录结构清晰,便于直接打开工程查看实现细节。目前已有1678人学习下载。通过源码中的带进度回调的上传与下载方法,读者可以掌握如何利用回调机制实时获取传输字节数并计算百分比,进而触发进度条刷新或界面提示;在此基础上稍作封装,即可移植到WinForms或WPF程序,快速搭建带进度展示的SFTP文件传输工具。 先说一下背景。最近做一个C#上位机项目,需要把工控机上的检测数据文件、日志和配置包推到远程的Linux服务器上,有时候还要把服务器下发的参数文件拉回来。最直接的需求就是文件的上传和下载,但客户提了个硬要求:界面上必须能看到传输进度,不能让人干等。我一开始在FTP和SFTP之间纠结了一下——FTP简单,但明文传输、被动模式还容易被防火墙卡;SFTP走的是SSH通道,认证和数据都是加密的,权限模型也和系统账户一致,在Linux服务器上基本零配置就能用。所以最终选择了SFTP,用它把上传下载和进度条一起做掉了。整套代码不复杂,核心也就几十行。
如果你现在也在写类似的功能,或者正在调研C#怎么连SFTP、怎么做进度条,这篇文章应该能给你省不少时间。里面所有代码都是我真实验证过的,不是抄文档那种人云亦云的东西。
1. 需求拆解与技术选型
1.1 这个功能主要用在哪里
C#项目里碰到需要SFTP的场景,我归类了一下大概有这几类:
- 上位机/工控软件的数据回传。设备每天生成CSV报表、检测日志、报警记录,程序需要定期推给服务器存档。
- 配置下发。服务器端维护一份参数文件或固件包,客户端启动时主动拉取,更新本地配置。
- 接口对接。比如第三方系统通过SFTP落文件,这边写个服务去监听远程目录,有新文件就下载解析。
- 运维小工具。用C#写个批量更新工具,给几十台设备分发部署包。
这些场景的核心都一样:程序要稳定地连上远程主机,把文件传过去或拉回来,同时让用户看到进度,避免出现“程序看起来像死机了”的体验。
1.2 为什么选SFTP而不是FTP
很多人第一反应是FTP,但实际项目里我越来越不推荐FTP,原因很实在:
- SFTP是建立在SSH传输层协议上的文件协议,所有内容都走SSH加密通道,传输内容不会明文泄露。
- 不需要像FTP那样额外开放21端口和数据端口,服务器只开22端口就行,防火墙规则简单很多,运维也好说话。
- 登录身份和Linux系统用户绑定,权限管控直接复用服务器账户体系,给哪个目录权限、只读还是可写,一套规则管好。
- 不会遇到FTP主动/被动模式切换导致连不上这种经典问题。
这里要特别区分一个概念:SFTP和FTPS不是一回事。FTPS是FTP over SSL/TLS,仍然有独立的数据连接,只是传输层加密,配置起来反而更麻烦。SFTP则是SSH协议内部的一个子系统,OpenSSH默认自带SFTP服务,Linux服务器基本改一下配置就能用,部署成本极低。
1.3 C#生态里的SFTP库怎么选
C#做SFTP,最常见的方案有三个,我列个表直接对比:
| 方案 | 说明 | 适合场景 | 注意点 |
|---|---|---|---|
| SSH.NET(Renci.SshNet) | 纯C#实现的SSH协议库,NuGet直接引用 | 普通业务、嵌入式、上位机 | 异步API某些版本不够完善,建议用Task.Run包同步方法 |
| WinSCP .NET程序集 | 基于WinSCP命令行封装,功能完整,稳定性强 | 复杂自动化、批量同步 | 依赖WinSCP.exe,部署时要多带个文件 |
| Posh-SSH | PowerShell模块封装的SSH库 | 简单脚本自动化 | 不是原生C#集成,不能当库用 |
我最终选了SSH.NET。原因说起来很简单:包体积小、API风格和.NET框架一致,而且上传下载都带了现成的进度回调接口,做进度条很方便。虽然它的异步方法有些历史包袱,但只要把同步调用放到后台线程执行,实际效果完全够用。后面所有代码都基于SSH.NET。
2. 环境准备与连接配置
2.1 引入SSH.NET依赖
打开Visual Studio的NuGet包管理器,搜索SSH.NET,安装最新的稳定版本(我写这篇文章时用的版本是2024.x)。注意包名是SSH.NET,不是SharpSSH,装错了API完全不同。装完之后项目里using下面两个命名空间就够用了:
using Renci.SshNet; using Renci.SshNet.Common;2.2 密码认证连接
先看最常用的密码认证,代码非常短:
var connectionInfo = new ConnectionInfo("192.168.1.100", 22, "username", new PasswordAuthenticationMethod("username", "password")); using var client = new SftpClient(connectionInfo); client.Connect(); // 连接成功,开始传文件... client.Disconnect();这段代码里有几个容易踩的细节:
ConnectionInfo的端口参数是int类型,别传字符串"22"。PasswordAuthenticationMethod里的用户名要和ConnectionInfo里的用户名保持一致,否则认证阶段直接报错。using var的作用域是整个方法,Disconnect()要等所有上传下载操作结束之后再执行。- 如果网络环境比较差,
Connect()默认超时可能要等很久,建议往下看,设置一下连接超时。
2.3 密钥认证与超时设置
生产环境我更喜欢用密钥认证,比密码更安全,也方便批量部署时免密。SSH.NET对RSA、ECDSA、Ed25519都支持,公钥提前放到服务器的authorized_keys里,私钥文件放在本地:
using var privateKey = new PrivateKeyFile(@"C:\keys\id_rsa", "私钥密码"); var connectionInfo = new ConnectionInfo("192.168.1.100", 22, "username", new PrivateKeyAuthenticationMethod("username", privateKey)) { Encoding = Encoding.UTF8, Timeout = TimeSpan.FromSeconds(15) }; using var client = new SftpClient(connectionInfo); client.OperationTimeout = TimeSpan.FromSeconds(30); client.Connect();这里有个经验分享一下:OperationTimeout设置的是单个SFTP命令(比如一次Read/Write操作)的超时时间,而ConnectionInfo.Timeout是SSH握手和认证阶段的超时。两个都设上,可以避免很多异常情况下程序长时间卡死的问题。另外如果私钥没有密码,PrivateKeyFile构造函数第二个参数传null就行。
3. 上传与下载的核心实现
3.1 封装一个连接管理类
动手写功能之前,建议先把连接和传输封装成一个类,UI层用起来会干净很多。我的做法是这样的:
public class SftpTransferService : IDisposable { private readonly SftpClient _client; public SftpTransferService(string host, int port, string username, string password) { var connectionInfo = new ConnectionInfo(host, port, username, new PasswordAuthenticationMethod(username, password)); _client = new SftpClient(connectionInfo); } public void Connect() { if (!_client.IsConnected) { _client.Connect(); } } public void Disconnect() { if (_client.IsConnected) { _client.Disconnect(); } } public void Dispose() { Disconnect(); _client?.Dispose(); } }封装之后,业务层不需要关心连接细节,拿到服务类直接调上传下载方法就行。后面代码都以这个类为基础扩展。
3.2 上传文件:带进度回调
SSH.NET的上传方法是UploadFile,第三个参数就是进度回调。回调的参数是已经上传的字节数(ulong类型),用它除以文件总大小就是百分比:
public void UploadFile(string localPath, string remotePath, Action<double> onProgress) { using var fileStream = File.OpenRead(localPath); long totalBytes = fileStream.Length; long lastReportBytes = 0; _client.UploadFile(fileStream, remotePath, uploadedBytes => { double percent = totalBytes == 0 ? 100 : (double)uploadedBytes / totalBytes * 100.0; onProgress?.Invoke(percent); }); }几个细节说明一下:
- 回调在传输循环内部触发得特别频繁,外层展示时可以限流,比如每隔1%才刷新一次界面,避免UI线程被消息淹没。
totalBytes为0时直接返回100,防止除零错误。FileStream用using确保释放,传大文件时如果不释放,文件会被占用,下一次读写直接报IOException。- 这里用的是同步版本
UploadFile,正是为了配合进度回调。这个回调是同步阻塞的,必须放到后台线程跑,后面讲进度条的时候会详细说。
3.3 下载文件:思路反着来
下载用DownloadFile,第三个参数同样是进度回调,逻辑基本对称:
public void DownloadFile(string remotePath, string localPath, Action<double> onProgress) { long totalBytes = _client.GetFileSize(remotePath) ?? 0; using var fileStream = File.Create(localPath); _client.DownloadFile(remotePath, fileStream, downloadedBytes => { double percent = totalBytes == 0 ? 100 : (double)downloadedBytes / totalBytes * 100.0; onProgress?.Invoke(percent); }); }这里有一个容易忽略的坑:GetFileSize返回的是long?,如果服务器没有正确返回文件大小,这里会拿到null。我给出的兜底方案是直接置0,进度百分比就按0处理。如果你要在界面上显示“文件总大小”,这个边界情况必须提前处理。
3.4 批量目录上传的扩展
实际项目里很少只传一个文件。整个目录上传是刚需,SSH.NET没有提供现成的批量API,需要自己遍历加递归。下面是我写的一个目录上传方法:
public void UploadDirectory(string localDir, string remoteDir, Action<double> onTotalProgress) { var files = Directory.GetFiles(localDir, "*", SearchOption.AllDirectories); long totalSize = files.Sum(f => new FileInfo(f).Length); long uploadedSize = 0; foreach (var file in files) { string relativePath = Path.GetRelativePath(localDir, file); string remoteFilePath = remoteDir.TrimEnd('/') + "/" + relativePath.Replace('\\', '/'); string remoteSubDir = Path.GetDirectoryName(remoteFilePath); if (!_client.Exists(remoteSubDir)) { _client.CreateDirectory(remoteSubDir); } using var fs = File.OpenRead(file); _client.UploadFile(fs, remoteFilePath, uploadedBytes => { double percent = totalSize == 0 ? 100 : (double)(uploadedSize + uploadedBytes) / totalSize * 100.0; onTotalProgress?.Invoke(percent); }); uploadedSize += fs.Length; } }这段代码有两个细节要特别注意:一是远程路径统一用正斜杠/,Windows本地路径是反斜杠\,拼接时必须替换掉,否则Linux服务器找不到文件;二是CreateDirectory在目录已存在时会抛异常,所以要先调Exists判断。整体进度通过闭包变量uploadedSize累计,逻辑直观。
4. 进度条落地的完整方案
4.1 为什么直接在回调里改UI会卡死
进度条这个需求看着简单,实际做的时候很多人会栽跟头。最常见的错误就是在UploadFile的回调里直接写progressBar.Value = (int)percent;,结果界面卡得完全动不了。
原因在于SSH.NET的UploadFile是同步方法,内部的传输循环一直占用调用线程。如果你在UI线程上调用它,那么回调也在UI线程上执行,要在同一个线程里既跑传输又刷进度条,两个操作全在排队,进度条自然一动不动。解决办法是把传输放到后台线程执行,然后通过IProgress<T>把进度消息调度回UI线程,让UI线程只负责刷新控件。
4.2 IProgress 的用法
IProgress<T>是.NET里专门解决“后台任务向UI线程汇报进度”问题的接口。它通过同步上下文(SynchronizationContext)把回调调度到创建它的线程上。在WinForms里,在UI线程上创建的Progress<T>,它的Report回调就会自动回到UI线程执行。
标准用法是这样的:
var progress = new Progress<double>(percent => { progressBar.Value = (int)Math.Round(percent); labelPercent.Text = $"{percent:F1}%"; });然后把progress传给后台任务:
await Task.Run(() => { transferService.UploadFile(localPath, remotePath, p => progress.Report(p)); });这里有个关键点必须强调:Progress<double>实例必须是在UI线程上创建的,这样回调才会回到UI线程。如果你在后台线程里new Progress<T>,那回调就在后台线程跑,操作控件照样崩。这是新手最容易搞反的地方。
4.3 WinForms完整示例
下面给一个可以直接跑起来的WinForms最小示例。窗体上放一个ProgressBar、一个Label、两个按钮(开始上传、开始下载):
public partial class MainForm : Form { private readonly SftpTransferService _transferService; public MainForm() { InitializeComponent(); _transferService = new SftpTransferService( "192.168.1.100", 22, "username", "password"); _transferService.Connect(); } private async void btnUpload_Click(object sender, EventArgs e) { var progress = new Progress<double>(p => { progressBar.Value = (int)Math.Round(p); labelStatus.Text = $"上传进度 {p:F1}%"; }); btnUpload.Enabled = false; try { await Task.Run(() => _transferService.UploadFile("C:\\data\\report.csv", "/data/report.csv", p => progress.Report(p))); labelStatus.Text = "上传完成"; } catch (Exception ex) { labelStatus.Text = "上传失败: " + ex.Message; } finally { btnUpload.Enabled = true; } } }这段代码有几个要点:
async void是事件处理器专用写法,按钮点击事件必须用这种签名,但业务方法不要用async void,一定要返回Task。- 按钮的
Enabled先置false,防止用户重复点击,这是最容易被忽略的体验细节。 - 如果文件很大,可以后续用
CancellationTokenSource加取消功能,放在Task.Run里传给传输层。 ProgressBar的Value范围默认是0到100,所以百分比直接赋值就行。如果改过Minimum和Maximum范围,要自己换算。
5. 常见问题与排查技巧实录
5.1 上传到一半断了:broken pipe
这是实际项目里最常碰到的报错之一。现象是上传大文件中途连接断开,服务器直接把连接踢了。热搜词里也出现了“linux sftp -oport send disconnect: broken pipe”,可见遇到的人不少。
我排查下来,常见原因有三个:
- 服务器端配置了空闲超时。可以在服务器的
sshd_config里查ClientAliveInterval和ClientAliveCountMax,如果设了短空闲时间,没有数据交互的连接会被主动断开。 - 客户端没有开启KeepAlive,导致长时间无数据交互时被防火墙或NAT设备掐断。
- 网络不稳定,尤其是跨地域传输时丢包严重,TCP连接被重置。
解决办法有两个,一个是开启客户端的KeepAlive:
client.KeepAliveInterval = TimeSpan.FromSeconds(30);另一个是把SSH.NET的BufferSize调大,减少网络往返次数,降低超时概率:
client.BufferSize = 64 * 1024;5.2 进度条卡在99%不动
这个问题我遇到过两次,原因都是文件流没有正确关闭,导致最后一次上传的最终刷新没有触发。更隐蔽的情况是:UploadFile全部传完后,文件流还在被占用,最后的Flush耗时太长,界面看起来就像卡住了。解决办法是上传完成后手动调用fileStream.Flush(),或者把FileStream的Dispose放在finally块里确保执行。
还有一种情况是进度计算方式不对。比如只算了数据字节数,没算SSH通道封装的协议开销,最后一点进度走得明显偏慢,给人“卡住”的错觉。这个其实属于体验问题,可以适当在进度计算里加一个“剩余估算”的概念,但没必要太精确。
5.3 中文文件名乱码与特殊字符
SSH.NET默认使用UTF-8编码,但某些Linux服务器的文件系统locale不是UTF-8,导致中文文件名出现乱码或上传失败。这时候需要给ConnectionInfo指定编码:
connectionInfo.Encoding = Encoding.UTF8;如果对接的两台机器都是Windows,建议在业务层统一编码约定,避免服务端和客户端各猜各的。另外远程路径里如果有空格、括号等特殊字符,SSH.NET内部会处理转义,不需要你额外手动加引号。
5.4 断线重试策略
文件传输是网络操作,失败重试一定要做。我个人经验是不能用死循环一轮一轮重试,要带指数退避。简单实现可以用这个思路:
int maxRetries = 3; for (int i = 1; i <= maxRetries; i++) { try { await Task.Run(() => _transferService.UploadFile(localPath, remotePath, p => progress.Report(p))); break; } catch (Exception ex) when (i < maxRetries) { await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); } }还有一个容易忽略的点:连接断开后,下一次重试前要重新执行Connect(),不要直接复用同一个连接状态的客户端实例,否则大概率会碰到“连接已关闭”的异常。我在封装类里写的Connect()方法已经做了IsConnected判断,重试前调用一下就可以。
最后再分享一个我在实际项目里的体会:SFTP传输这块,代码本身并不复杂,复杂度全在边界情况里——断线、编码、目录结构、权限、大文件内存占用,每一个都可能在实际运行中冒出来。如果你正在做的项目也有类似需求,建议先封装好连接管理,把上传下载的进度回调接口定义好,UI层尽量保持薄,这样后面不管换密钥认证、加批量操作还是加断点续传,都不至于大改。
本文还有配套的精品资源,点击获取