首页 / 资讯中心 / 文章详情

C# SFTP上传下载与进度条:SSH.NET实战避坑

C# SFTP上传下载与进度条:SSH.NET实战避坑 ★ FEATURED ARTICLE
简介面向具备一定C#基础、需要在桌面程序中接入安全文件传输功能的开发者这份项目示例围绕SFTP上传与下载展开重点演示借助Renci.SshNet库建立连接、读写远程文件并通过回调把传输字节数换算为百分比进度便于在WinForms界面中实时展示任务状态。压缩包共26个文件约533KB包含C#源码、可执行程序、Renci.SshNet依赖库、窗体资源文件以及sln解决方案与csproj工程配置结构完整可直接打开编译或运行对照。已有1682人学习下载。读者可从中了解上传/下载方法的重载用法、进度回调参数传递与界面联动思路并借助现成工程快速验证连接参数、文件流处理和进度刷新逻辑适合作为SFTP功能原型或课程练习的参考实现。1. 从需求到选型C# SFTP 上传下载 进度条要先想清楚哪几件事给桌面端加“传文件到服务器”的需求产品嘴上说的是“上传下载带个进度条”落到 C# 里其实是四件事叠一起SFTP 协议本身、SSH.NETRenci.SshNet这套库怎么接、进度提醒回调怎么从后台线程安全地回到 UI还有大文件传输时的超时和连接保活。第一次写的人往往卡在最后两条进度条要么一动不动要么跑到一半卡死日志里却显示字节数在涨。这篇按我实际落地的顺序拆先把库和连接参数配好再分别做上传和下载进度用IProgressT统一收口最后把踩过的几个坑写清楚照着改基本能少走一圈弯路。2. 环境与选型SSH.NET 库接入、超时参数和三种认证方式SFTP 和 FTP 不是一回事。FTP 走独立的 21 端口明文和 TLS 是两个变体SFTP 是跑在 SSH 之上的子系统默认复用 22 端口认证、加密、通道全都由 SSH 层负责。这个区别直接决定了选型C# 里不要去找FtpWebRequest那个是 FTP/FTPS 用的对 SFTP 无效。要做 SFTP常见做法就两条一是调用系统sftp.exe或pscp拼命令行二是直接引用托管库。命令行方案在进度条上基本没法做输出解析很脆路径里带空格或中文就要翻车所以正经项目我都会走 SS.NETNuGet 包 IDSSH.NET命名空间是Renci.SshNet。2.1 NuGet 引用与最小可运行项目骨架先建一个能跑起来的项目.NET Framework 4.7.2、.NET 6、.NET 8 都行库本身跨平台。包管理器里执行dotnet add package SSH.NET如果用 Visual Studio 的包管理器控制台Install-Package SSH.NET装完先别急着写业务建一个SftpTransferService把客户端包起来让它实现IDisposable连接、断开、复用都在这一个类里管。下面是最小骨架using Renci.SshNet; public sealed class SftpTransferService : IDisposable { private readonly SftpClient _client; public SftpTransferService(string host, int port, string user, string password) { // ConnectionInfo 负责认证信息Timeout 是建立连接阶段的超时 var info new ConnectionInfo(host, port, user, new PasswordAuthenticationMethod(user, password)) { Timeout TimeSpan.FromSeconds(30) }; _client new SftpClient(info) { // 传输期间的空闲检测防止 NAT/防火墙把长连接悄悄掐掉 KeepAliveInterval TimeSpan.FromSeconds(30), // 单次操作的超时大文件必须放大默认值往往不够 OperationTimeout TimeSpan.FromMinutes(30), // 单次读写缓冲区默认 32KB调到 64KB 能明显提升吞吐 BufferSize 64 * 1024 }; } public void Connect() { if (!_client.IsConnected) { _client.Connect(); } } public void Dispose() { if (_client.IsConnected) { _client.Disconnect(); } _client.Dispose(); } }这里解释几个参数。Timeout只影响 TCP 建连和 SSH 握手不影响传文件OperationTimeout才是影响上传下载的它管单次 SFTP 请求的最长等待时间默认一般在一分钟左右传几百兆的文件时后段请求容易撞上这个限制表现就是传到一半报超时。KeepAliveInterval会让客户端定期发心跳长时间没有数据交互时连接不会被动断开。BufferSize是库内部的读写块大小32KB 是保守值本地千兆网或云服务器上 64KB、128KB 通常还能再快一点具体要自己压测别盲目拉到 1MB反而会因为单次请求过大拖慢小文件。提示SftpClient不是线程安全的一个实例上不要并发跑多个上传下载。批量传输要么串行排队要么每个任务各自 new 一个客户端。2.2 ConnectionInfo 里三个最容易被默认值坑到的超时参数刚接触这套库的人最容易犯的错是“只填 host、user、password其他全用默认”然后线上传 2GB 的文件出问题。三个参数值得单独拎出来看参数作用范围默认表现我的常用值ConnectionInfo.Timeout建连 SSH 握手约 30 秒30 秒弱网可调到 60SftpClient.OperationTimeout单次 SFTP 协议请求约 1 分钟大文件 30 分钟普通场景 10 分钟SftpClient.KeepAliveInterval空闲保活心跳关闭30 秒OperationTimeout设成Timeout.InfiniteTimeSpan也能跑但这样一旦服务端真的不响应客户端会一直挂着日志里什么都看不到。我的习惯是给一个足够宽松但有限的值再配合业务层的CancellationToken做兜底取消这样既不会误杀长传输也不会永久卡死。还有一个参数不在表里但经常被漏掉ConnectionInfo的Encoding。老服务器如果默认字符集不是 UTF-8远端建中文文件名会乱码需要在构造ConnectionInfo之后手动设Encoding Encoding.UTF8或者按服务端实际配置来。这条不涉及进度条但一旦踩上排查时间比前面的超时还长。2.3 密码、密钥、键盘交互三种认证方式的取舍SFTP 服务端的认证策略差异很大有的只开密码有的禁密码只认密钥还有的走 keyboard-interactive交互式问答密码只是其中一轮。SSH.NET 对应三种AuthenticationMethod选错了就是Permission denied而且报错信息不会告诉你差在哪。// 方式一密码认证最直接适合内网或测试环境 var byPassword new PasswordAuthenticationMethod(user, password); // 方式二私钥认证key 文件需要是 OpenSSH 格式 // 带口令的私钥要传 passphrasePuTTY 生成的 .ppk 需要先转换 var keyFile new PrivateKeyFile(C:\keys\id_rsa, key-passphrase); var byKey new PrivateKeyAuthenticationMethod(user, keyFile); // 方式三键盘交互常见于只开交互认证的服务器 // 把密码作为第一轮回答塞进去其他轮返回空字符串 var byKeyboard new KeyboardInteractiveAuthenticationMethod(user); byKeyboard.AuthenticationPrompt (_, e) { foreach (var prompt in e.Prompts) { // 判断提示语里是否要求 password多轮时按需回答 prompt.Response prompt.Request.IndexOf(password, StringComparison.OrdinalIgnoreCase) 0 ? password : string.Empty; } }; // 多方法可以一起挂上客户端会按顺序尝试 var info new ConnectionInfo(host, port, user, byPassword, byKey, byKeyboard);注意两点。第一PrivateKeyFile只认 OpenSSH 新格式或 PEMPuTTY 的.ppk要先转成 OpenSSH否则构造时直接抛异常错误提示往往只说“invalid private key”不告诉你是格式问题。第二把多个方法挂在同一个ConnectionInfo上时库会按传入顺序尝试密码错一次不一定会立刻失败而是会继续试下一个方法所以排查认证问题时别只看第一条报错要把整个异常链打出来。3. 上传实现UploadFile 进度回调、异步包装与 UI 线程上传的入口是SftpClient.UploadFile它有好几个重载做进度条要用的那个是带Actionulong回调的版本。这个ulong参数是“已传输字节数”不是百分比百分比得自己拿文件总长度算。下面从最原始的同步写法开始一层层往上包。3.1 同步 UploadFile 的进度回调模板最朴素的写法是这样using var input File.OpenRead(localPath); var total input.Length; _client.UploadFile(input, remotePath, sent { // 注意这个回调在后台线程上执行不能直接碰 UI 控件 Console.WriteLine(${sent} / {total}); });UploadFile是同步阻塞的方法返回时文件已经传完。回调里能拿到当前累计字节数但拿不到总长度因为库不知道你本地文件多大得自己从FileInfo或Stream.Length取。这个写法适合控制台或后台服务放到 WinForms 里就会把界面冻住所以桌面端不能直接用。另一个容易忽略的点是流的所有权。UploadFile(Stream, ...)不会帮你关闭传入的流using要自己加。如果上传失败抛异常流没被释放下次再打开同一个文件会报“文件被另一进程占用”这类问题在“重试”逻辑里特别常见。3.2 用 Task.Run 把同步上传包成可 await 的异步SSH.NET 较新版本提供了UploadFileAsync但为了兼容老项目我一般还是用Task.Run包一层同步方法行为最可控public async Task UploadAsync( string localPath, string remotePath, IProgress(long sent, long total) progress, CancellationToken ct default) { var fileInfo new FileInfo(localPath); if (!fileInfo.Exists) { throw new FileNotFoundException(本地文件不存在, localPath); } long total fileInfo.Length; long lastReported 0; // 节流阈值每 64KB 或到达末尾才通知一次避免 UI 消息队列被打爆 const long reportStep 64 * 1024; Connect(); await Task.Run(() { using var input fileInfo.OpenRead(); _client.UploadFile(input, remotePath, sent { ct.ThrowIfCancellationRequested(); var current (long)sent; if (current - lastReported reportStep || current total) { lastReported current; progress.Report((current, total)); } }); }, ct); }这段代码有三个关键点。第一Task.Run把同步阻塞调用挪到线程池UI 线程才能继续响应第二进度上报做了节流不是每收到一个字节就Report一次因为库的缓冲区是 32KB64KB 级别一次上传调用里回调可能触发成百上千次直接把 UI 队列塞满界面反而更卡第三ct.ThrowIfCancellationRequested()放在回调里让取消动作能在传输过程中尽快生效而不是等整个文件传完才响应。reportStep这个阈值不是固定的本地大文件可以拉到 256KB公网小文件可以降到 16KB本质是“进度条刷新频率”和“UI 压力”之间的平衡。3.3 WinForms 与 WPF 里用 IProgress 安全刷新进度条IProgressT之所以是正确解法是因为ProgressT在构造时会捕获当前的SynchronizationContextReport被调用时会把回调Post回创建它的那个线程。桌面端在 UI 线程里 new 一个ProgressT回调就自动回到 UI 线程不需要手写Invoke。WinForms 里的典型写法private async void btnUpload_Click(object sender, EventArgs e) { var localPath txtLocalPath.Text; if (!File.Exists(localPath)) { MessageBox.Show(请先选择本地文件); return; } progressBar1.Minimum 0; progressBar1.Maximum 100; progressBar1.Value 0; btnUpload.Enabled false; // 在 UI 线程构造ProgressT 捕获当前 SynchronizationContext var progress new Progress(long sent, long total)(p { var pct p.total 0 ? 0 : (int)(p.sent * 100 / p.total); if (pct 0) pct 0; if (pct 100) pct 100; progressBar1.Value pct; lblStatus.Text ${p.sent:N0} / {p.total:N0} 字节; }); try { await _sftp.UploadAsync(localPath, txtRemotePath.Text, progress, _cts.Token); lblStatus.Text 上传完成; } catch (OperationCanceledException) { lblStatus.Text 已取消; } catch (Exception ex) { // 这里只弹提示完整异常建议写日志 MessageBox.Show($上传失败{ex.Message}); lblStatus.Text 上传失败; } finally { btnUpload.Enabled true; } }WPF 里换成ProgressBar和Dispatcher上下文写法几乎一样只是控件属性名不同ProgressT的语义完全一致。真正要小心的是“不要在后台线程直接改progressBar1.Value”那会抛跨线程异常或者在某些环境下被静默吞掉、进度条就是不动这类现象很难从日志里看出来。3.4 缓冲区大小与回调频率的取舍BufferSize和回调节流是一对互相影响的参数。库内部每次读写一块缓冲区回调大致按块粒度触发。把小文件从 64KB 缓冲区改成 1MB单次传输的协议往返次数减少速度可能提升但进度条在视觉上会变成一段一段跳反过来把缓冲区压到 8KB进度更平滑但吞吐会掉。我的经验是文件小于 10MB 用默认 32KB 就行进度本来就快到看不清几十兆到几百兆以上用 64KB 或 128KB同时把回调节流阈值抬到 128KB 或 256KB让 UI 每一两百毫秒刷新一次比每毫秒刷一次观感好得多。4. 下载实现DownloadFile、断点续传与大文件超时下载这边 API 对称入口是DownloadFile(Stream, path, Actionulong)回调同样给已传输字节数。但下载比上传多两个坑本地文件被占用以及断点续传要自己实现。4.1 DownloadFile 的进度回调与本地文件占用坑最直接的下载public async Task DownloadAsync( string remotePath, string localPath, IProgress(long received, long total) progress, CancellationToken ct default) { Connect(); // 远端文件大小要先查一次回调里拿不到总长 var attr _client.GetAttributes(remotePath); long total attr.Size; long lastReported 0; const long reportStep 64 * 1024; await Task.Run(() { // 用 Create 会覆盖已有文件用 CreateNew 会在文件已存在时抛异常 using var output new FileStream(localPath, FileMode.Create, FileAccess.Write); _client.DownloadFile(output, remotePath, received { ct.ThrowIfCancellationRequested(); var current (long)received; if (current - lastReported reportStep || current total) { lastReported current; progress.Report((current, total)); } }); }, ct); }FileMode.Create会直接截断同名文件如果那个文件正被别的程序打开比如 Excel 或 PDF 阅读器运行时会抛IOException: 文件正由另一进程使用。这个异常抛出来的时候下载已经进行了一部分SftpClient那侧不会自动断要自己catch之后Disconnect再清理否则下次连接会出现状态异常的问题。稳妥一点的做法是下载前先检查本地文件是否被占用能独占打开再继续。4.2 断点续传用 SftpFileStream.Seek 从断点下载DownloadFile每次都从头下不支持续传。要做“断了接着下”的效果得改成手动读写public async Task DownloadWithResumeAsync( string remotePath, string localPath, IProgress(long received, long total) progress, CancellationToken ct default) { Connect(); var attr _client.GetAttributes(remotePath); long remoteSize attr.Size; long localSize File.Exists(localPath) ? new FileInfo(localPath).Length : 0; if (localSize remoteSize) { // 本地已经比远端大或相等直接认为完成避免重复下载 return; } await Task.Run(() { // OpenRead 返回 SftpFileStream支持 Seek using var input _client.OpenRead(remotePath); if (localSize 0) { input.Seek(localSize, SeekOrigin.Begin); } using var output new FileStream( localPath, localSize 0 ? FileMode.Append : FileMode.Create, FileAccess.Write); var buffer new byte[64 * 1024]; long received localSize; int read; while ((read input.Read(buffer, 0, buffer.Length)) 0) { ct.ThrowIfCancellationRequested(); output.Write(buffer, 0, read); received read; progress.Report((received, remoteSize)); } }, ct); }SftpFileStream.Seek只对可寻址的远端文件有效绝大多数 SFTP 服务端都支持。FileMode.Append只能追加、不能Seek输出流但这正好符合断点续传“只往后写”的语义。如果本地文件在中断时可能写了一半比如断电或进程被杀光靠文件长度判断不够保险更严格的做法是每写完一定量字节校验一次大小或者引入分片标记文件日常使用里长度判断已经能覆盖 90% 的断点场景。4.3 下载速度上不去时先查的三个点下载慢不一定是库的问题。先看OperationTimeout有没有设够撞超时后的重试会拖垮整体速度再看BufferSize默认 32KB 在跨机房链路上收益明显调到 64KB 或 128KB 值得试最后看本地磁盘如果目标目录在机械盘或者网络盘上写文件本身就会成为瓶颈这种情况下降缓冲区反而比调大更稳。经验上本地 SSD 加千兆内网能跑到 100MB/s 以上公网链路受 RTT 影响明显单连接能到 10MB/s 已经算不错不要拿本地经验去推断线上结果。5. 避坑与排查进度条不动、上传断流、认证失败的 5 个血泪坑前面几节讲的都是正常流程真到落地环节出问题的往往是下面这几个地方。按“现象 → 原因 → 解决”记录遇到时对着查就行。5.1 进度条从头到尾不动日志里却有进度现象后台日志在正常打印字节数但界面进度条一直停在 0传完之后直接跳到 100。原因回调里直接写了progressBar1.Value ...或者ProgressT是在非 UI 线程构造的。SftpClient的回调跑在线程池线程上跨线程写 WinForms 控件要么抛InvalidOperationException要么因为边界情况下被吞掉而不报错进度条自然不动。解决统一改成ProgressT确认它在 UI 线程按钮事件里构造或者退回Control.Invoke手动封送。不要用BeginInvoke逐字节调用消息队列会堆死仍然要做节流。5.2 大文件传到 30% 报超时重试还是断现象几十兆的小文件正常几百兆以上的文件传到中段抛SshOperationTimeoutException重试几次在同一个位置附近断开。原因OperationTimeout用了默认值服务端响应稍慢就超时KeepAliveInterval没开长时间传输中连接被中间网络设备静默回收。解决把OperationTimeout放大到 2030 分钟KeepAliveInterval设为 30 秒重试逻辑挂在业务层不要在UploadFile内部重试失败后要先把客户端断开重连再从头或从断点继续。5.3 远端文件大小对但内容是 0 字节现象上传返回成功远端ls -l看到大小正确但下载下来打开是空白或乱码。原因多半是传入的流在UploadFile执行完之前就被关闭或改动了比如用了using但把UploadFile放在了using之后的异步任务里也可能是远端路径没有“文件 vs 目录”区分写到了一个同名目录不同服务端表现不一致。解决确认using的作用域覆盖完整的UploadFile调用路径统一用正斜杠上传前用_client.Exists和_client.GetAttributes确认目标状态上传完成后用GetAttributes(remotePath).Size与本地文件长度比对不一致就当作失败处理。5.4 连接被拒Permission denied密码明明是对的现象用同一个账号密码通过其他客户端能登录但在 C# 里Connect()抛SshAuthenticationException: Permission denied。原因服务端只允许特定认证方式比如禁用了密码只认 keyboard-interactive 或公钥或者库版本对密钥格式支持有限.ppk直接传进去会失败也有服务端限制客户端来源需要绑定特定 IP 或跳板。解决把PasswordAuthenticationMethod、KeyboardInteractiveAuthenticationMethod、PrivateKeyAuthenticationMethod都挂到ConnectionInfo上依次尝试.ppk先转 OpenSSH 格式把完整异常链和_client.ConnectionInfo里配的方法打日志不要只看第一行Permission denied。5.5 上传目录不存在抛 SftpPathNotFoundException现象本地文件存在、权限也正常上传直接抛Renci.SshNet.Common.SftpPathNotFoundException。原因UploadFile不会自动创建远端目录目标路径里只要有一级目录不存在就失败还有路径拼接时用了反斜杠被服务端当成文件名的一部分。解决上传前逐级创建目录private void EnsureRemoteDirectory(string remoteDir) { var parts remoteDir.Split(/, StringSplitOptions.RemoveEmptyEntries); var current string.Empty; foreach (var part in parts) { current / part; if (!_client.Exists(current)) { _client.CreateDirectory(current); } } }注意Exists对大小写敏感的服务端和 Windows 客户端的默认行为不一致远端路径大小写要保持和服务端实际一致不要凭记忆拼。6. 进阶节流进度器、批量取消与可复用的传输封装进度上报做几次之后会发现每个传输方法里都重复写一段“节流 计算百分比”改起来很散。更省事的做法是抽一个通用节流器把“多久上报一次”和“怎么算百分比”都收进去public sealed class ThrottledProgress : IProgress(long current, long total) { private readonly IProgress(long, long) _inner; private readonly long _minStep; private readonly TimeSpan _minInterval; private long _lastValue; private DateTime _lastTime DateTime.MinValue; public ThrottledProgress(IProgress(long, long) inner, long minStep 64 * 1024, int minIntervalMs 100) { _inner inner; _minStep minStep; _minInterval TimeSpan.FromMilliseconds(minIntervalMs); } public void Report((long current, long total) value) { var now DateTime.UtcNow; var elapsed now - _lastTime; var reachedEnd value.current value.total; // 三个条件任一满足才真正上报距离上次够久、数据够多、已经到末尾 if (reachedEnd || value.current - _lastValue _minStep || elapsed _minInterval) { _lastValue value.current; _lastTime now; _inner.Report(value); } } }把它套在ProgressT外面调用方就不用关心节流逻辑。再往上走一层我会把“上传 下载 取消 重试”统一成一个入口类内部持有SftpTransferService对外只暴露UploadAsync、DownloadAsync两个方法业务层拿到IProgress直接传进去取消按钮绑一个CancellationTokenSource就完事。批量上传时记得给每个文件都 new 一份ThrottledProgress共用一个实例会导致不同文件的进度互相干扰同时按顺序串行执行SftpClient本身不保证并发安全客户端侧做并发反而容易触发服务端限流报出来的错误又很含糊。还有一个当初吃过亏的地方ProgressT实例不要提前建在静态字段里静态初始化往往发生在没有 UI 同步上下文的时候回调会落到线程池界面看不到变化日志里又一切正常。从那以后我每次接 SFTP 传输需求都强制先跑一遍这套骨架连接参数打日志、进度节流、取消按钮接上、失败重试最后才去写业务界面。把这四件事按顺序过一遍后面改需求心里就有底了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站