
简介面向需要在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.NETRenci.SshNet纯C#实现的SSH协议库NuGet直接引用普通业务、嵌入式、上位机异步API某些版本不够完善建议用Task.Run包同步方法WinSCP .NET程序集基于WinSCP命令行封装功能完整稳定性强复杂自动化、批量同步依赖WinSCP.exe部署时要多带个文件Posh-SSHPowerShell模块封装的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, Actiondouble 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, Actiondouble 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, Actiondouble 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线程上执行要在同一个线程里既跑传输又刷进度条两个操作全在排队进度条自然一动不动。解决办法是把传输放到后台线程执行然后通过IProgressT把进度消息调度回UI线程让UI线程只负责刷新控件。4.2 IProgress 的用法IProgressT是.NET里专门解决“后台任务向UI线程汇报进度”问题的接口。它通过同步上下文SynchronizationContext把回调调度到创建它的线程上。在WinForms里在UI线程上创建的ProgressT它的Report回调就会自动回到UI线程执行。标准用法是这样的var progress new Progressdouble(percent { progressBar.Value (int)Math.Round(percent); labelPercent.Text ${percent:F1}%; });然后把progress传给后台任务await Task.Run(() { transferService.UploadFile(localPath, remotePath, p progress.Report(p)); });这里有个关键点必须强调Progressdouble实例必须是在UI线程上创建的这样回调才会回到UI线程。如果你在后台线程里new ProgressT那回调就在后台线程跑操作控件照样崩。这是新手最容易搞反的地方。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 Progressdouble(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连接被重置。解决办法有两个一个是开启客户端的KeepAliveclient.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层尽量保持薄这样后面不管换密钥认证、加批量操作还是加断点续传都不至于大改。本文还有配套的精品资源点击获取