ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:面向具备一定C#基础、需要在桌面程序中接入安全文件传输功能的开发者,这份项目示例围绕SFTP上传与下载展开,重点演示借助Renci.SshNet库建立连接、读写远程文件,并通过回调把传输字节数换算为百分比进度,便于在WinForms界面中实时展示任务状态。压缩包共26个文件,约533KB,包含C#源码、可执行程序、Renci.SshNet依赖库、窗体资源文件以及sln解决方案与csproj工程配置,结构完整,可直接打开编译或运行对照。已有1682人学习下载。读者可从中了解上传/下载方法的重载用法、进度回调参数传递与界面联动思路,并借助现成工程快速验证连接参数、文件流处理和进度刷新逻辑,适合作为SFTP功能原型或课程练习的参考实现。

1. 从需求到选型:C# SFTP 上传下载 + 进度条要先想清楚哪几件事

给桌面端加“传文件到服务器”的需求,产品嘴上说的是“上传下载,带个进度条”,落到 C# 里其实是四件事叠一起:SFTP 协议本身、SSH.NET(Renci.SshNet)这套库怎么接、进度提醒回调怎么从后台线程安全地回到 UI,还有大文件传输时的超时和连接保活。第一次写的人往往卡在最后两条,进度条要么一动不动,要么跑到一半卡死,日志里却显示字节数在涨。这篇按我实际落地的顺序拆:先把库和连接参数配好,再分别做上传和下载,进度用IProgress<T>统一收口,最后把踩过的几个坑写清楚,照着改基本能少走一圈弯路。

2. 环境与选型:SSH.NET 库接入、超时参数和三种认证方式

SFTP 和 FTP 不是一回事。FTP 走独立的 21 端口,明文和 TLS 是两个变体;SFTP 是跑在 SSH 之上的子系统,默认复用 22 端口,认证、加密、通道全都由 SSH 层负责。这个区别直接决定了选型:C# 里不要去找FtpWebRequest,那个是 FTP/FTPS 用的,对 SFTP 无效。要做 SFTP,常见做法就两条,一是调用系统sftp.exe或pscp拼命令行,二是直接引用托管库。命令行方案在进度条上基本没法做,输出解析很脆,路径里带空格或中文就要翻车,所以正经项目我都会走 SS.NET(NuGet 包 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 秒,弱网可调到 60
SftpClient.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 格式 // 带口令的私钥要传 passphrase,PuTTY 生成的 .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 新格式或 PEM,PuTTY 的.ppk要先转成 OpenSSH,否则构造时直接抛异常,错误提示往往只说“invalid private key”,不告诉你是格式问题。第二,把多个方法挂在同一个ConnectionInfo上时,库会按传入顺序尝试,密码错一次不一定会立刻失败,而是会继续试下一个方法,所以排查认证问题时别只看第一条报错,要把整个异常链打出来。

3. 上传实现:UploadFile 进度回调、异步包装与 UI 线程

上传的入口是SftpClient.UploadFile,它有好几个重载,做进度条要用的那个是带Action<ulong>回调的版本。这个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一次,因为库的缓冲区是 32KB~64KB 级别,一次上传调用里回调可能触发成百上千次,直接把 UI 队列塞满,界面反而更卡;第三,ct.ThrowIfCancellationRequested()放在回调里,让取消动作能在传输过程中尽快生效,而不是等整个文件传完才响应。reportStep这个阈值不是固定的,本地大文件可以拉到 256KB,公网小文件可以降到 16KB,本质是“进度条刷新频率”和“UI 压力”之间的平衡。

3.3 WinForms 与 WPF 里用 IProgress 安全刷新进度条

IProgress<T>之所以是正确解法,是因为Progress<T>在构造时会捕获当前的SynchronizationContext,Report被调用时会把回调Post回创建它的那个线程。桌面端在 UI 线程里 new 一个Progress<T>,回调就自动回到 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 线程构造,Progress<T> 捕获当前 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上下文,写法几乎一样,只是控件属性名不同,Progress<T>的语义完全一致。真正要小心的是“不要在后台线程直接改progressBar1.Value”,那会抛跨线程异常,或者在某些环境下被静默吞掉、进度条就是不动,这类现象很难从日志里看出来。

3.4 缓冲区大小与回调频率的取舍

BufferSize和回调节流是一对互相影响的参数。库内部每次读写一块缓冲区,回调大致按块粒度触发。把小文件从 64KB 缓冲区改成 1MB,单次传输的协议往返次数减少,速度可能提升,但进度条在视觉上会变成一段一段跳;反过来把缓冲区压到 8KB,进度更平滑,但吞吐会掉。我的经验是:文件小于 10MB 用默认 32KB 就行,进度本来就快到看不清;几十兆到几百兆以上用 64KB 或 128KB,同时把回调节流阈值抬到 128KB 或 256KB,让 UI 每一两百毫秒刷新一次,比每毫秒刷一次观感好得多。

4. 下载实现:DownloadFile、断点续传与大文件超时

下载这边 API 对称,入口是DownloadFile(Stream, path, Action<ulong>),回调同样给已传输字节数。但下载比上传多两个坑:本地文件被占用,以及断点续传要自己实现。

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 = ...,或者Progress<T>是在非 UI 线程构造的。SftpClient的回调跑在线程池线程上,跨线程写 WinForms 控件要么抛InvalidOperationException,要么因为边界情况下被吞掉而不报错,进度条自然不动。

解决:统一改成Progress<T>,确认它在 UI 线程(按钮事件里)构造;或者退回Control.Invoke手动封送。不要用BeginInvoke逐字节调用,消息队列会堆死,仍然要做节流。

5.2 大文件传到 30% 报超时,重试还是断

现象:几十兆的小文件正常,几百兆以上的文件传到中段抛SshOperationTimeoutException,重试几次在同一个位置附近断开。

原因:OperationTimeout用了默认值,服务端响应稍慢就超时;KeepAliveInterval没开,长时间传输中连接被中间网络设备静默回收。

解决:把OperationTimeout放大到 20~30 分钟,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); } } }

把它套在Progress<T>外面,调用方就不用关心节流逻辑。再往上走一层,我会把“上传 + 下载 + 取消 + 重试”统一成一个入口类,内部持有SftpTransferService,对外只暴露UploadAsync、DownloadAsync两个方法,业务层拿到IProgress直接传进去,取消按钮绑一个CancellationTokenSource就完事。批量上传时记得给每个文件都 new 一份ThrottledProgress,共用一个实例会导致不同文件的进度互相干扰;同时按顺序串行执行,SftpClient本身不保证并发安全,客户端侧做并发反而容易触发服务端限流,报出来的错误又很含糊。

还有一个当初吃过亏的地方:Progress<T>实例不要提前建在静态字段里,静态初始化往往发生在没有 UI 同步上下文的时候,回调会落到线程池,界面看不到变化,日志里又一切正常。从那以后我每次接 SFTP 传输需求,都强制先跑一遍这套骨架:连接参数打日志、进度节流、取消按钮接上、失败重试,最后才去写业务界面。把这四件事按顺序过一遍,后面改需求心里就有底了。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进