C#上位机SFTP文件传输模块设计与实践
简介面向C#开发者的SFTP文件传输示例工程围绕Renci.SshNet库讲解上传与下载的完整实现重点解决操作过程中缺少进度反馈的问题。通过UploadFile/DownloadFile的带回调重载方法实时取得已传输字节数进而计算百分比进度可直接嵌入WinForms或WPF界面。对于刚接触SSH.NET库的初中级开发者是一份兼顾原理与实战的入门参考。资源共26个文件主体为cs源码、exe可执行程序、dll依赖库及项目工程/配置文件并附Renci.SshNet.dll与其原始rar包整体仅533KB。源码工程已给出可运行示例编译生成的exe便于立即验证上传下载及进度显示效果调试所需的pdb等辅助文件也一并保留。工程内目录组织清晰方便按窗体、属性、资源与设置模块拆解学习。目前已有1678人学习下载。对照源码可掌握SftpClient连接管理、进度回调委托的使用方式、本地与远程路径参数处理等关键细节减少自行摸索底层SSH协议的成本适合作为开发SFTP文件管理工具或学习C#网络编程的起步模板。 做过上位机和工业软件的朋友应该都有同感只要和Linux服务器打交道文件传输就是绕不开的活儿。前阵子给一套产线数据采集系统做维护工具要定时把工控机里的CSV报表上传到服务器再把服务器下发的配方文件拉回本地最初图省事直接用命令行调pscp结果部署到客户现场到处踩坑。后来用C#配合SSH.NET重新实现了整套SFTP文件上传和下载模块还加了实时进度条总算把这块彻底安定下来。这篇就聊聊整个实现过程包括为什么选SFTP、进度条到底怎么做才平滑、以及那些文档里不会写但实际特别磨人的坑。1. 为什么偏偏选SFTP又为什么是SSH.NET这个库1.1 和FTP、FTPS对比SFTP才是省心的那个不少老项目还在用FTP原因无非是代码简单、FTPClient到处都有。但FTP有个致命问题控制通道和数据通道分离被动模式下客户端要开一个额外端口连服务器在带防火墙的工业网络里非常难受。要么端口没放通传输卡死要么被安全审计揪出来说账号密码是明文传输。FTPS虽然是加密版但证书分发、数据通道端口范围配置同样折腾人。SFTP不一样它走的是SSH协议默认22端口一条TCP连接搞定认证和传输。整个传输过程全部加密密钥或账号登录都支持。服务器端不管是Linux自带OpenSSH还是Windows上安装OpenSSH Server都天然支持不需要额外装什么FTP软件更不用给数据通道单独开端口。从运维角度说SFTP几乎是零额外成本这也是我最终决定把所有传输都切到SFTP底下的原因。用一句话概括FTP适合完全可控的内网老系统只要涉及跨网段、过防火墙或者有安全要求直接SFTP一了百了。1.2 SSH.NET是C#生态里最稳的SFTP方案C#里做SFTP主流选择是SSH.NET这个开源库NuGet上直接搜SSH.NET安装就行。它最大的好处是纯托管代码实现完全不需要调用外部exe。我最初用pscp命令行那种方案每次传输要启动子进程偶尔会出现进程没退干净、控制台窗口闪一下、路径带空格时转义出错等问题。用SSH.NET后这些全没了直接内嵌在程序里一个SftpClient对象搞定所有操作。对比一下市面上几种方案方案是否需要外部依赖嵌入式集成难度进度回调稳定性命令行调用pscp/openssh需要较高路径转义麻烦解析标准输出不稳定一般WinSCP .NET程序集需要安装WinSCP中等有事件但依赖外部程序较好SSH.NET不需要低NuGet引用即可自建循环计数非常灵活好SSH.NET内部封装了SFTP协议的各种细节连接、认证、目录列举、上传下载、权限设置等调用方式也非常直观。比如建立连接的代码就几行using Renci.SshNet; var connectionInfo new ConnectionInfo( 192.168.1.100, 22, sftpuser, new PasswordAuthenticationMethod(sftpuser, password) ); using var client new SftpClient(connectionInfo); client.Connect(); // 这里执行上传下载操作 client.Disconnect();连接池、超时、密钥认证这些它都支持而且持续维护在GitHub上也很活跃。做上位机项目时不需要额外安装任何运行库部署时把DLL丢过去就好客户现场没有网也能跑这点对工控场景极其重要。2. 进度条到底卡在哪一步字节计数与流复制是核心2.1 SSH.NET自带的进度回调为什么不够用SSH.NET的SftpClient.UploadFile和DownloadFile方法其实有两个重载带Actionulong进度回调很多新手看到有这个参数就以为进度条好做实际用起来就会发现坑很深这个回调触发时机是“每个底层数据块传输完”并不保证均匀触发。上传一个几百KB的小文件可能整个文件一个块就传完了进度条直接从0跳到100%传大文件时它可能又疯狂触发导致UI线程被刷爆。更麻烦的是默认回调只告诉你哪个文件传完了多少字节没有文件名上下文做多文件任务时没法区分当前进度属于哪个文件。基于这些原因我后面干脆没用这个回调而是自己读写Stream做字节计数进度条要快要慢、要均匀要平滑全部自己控制。2.2 自己动手做流复制循环核心思路很简单不要调用现成的UploadFile方法去“一把梭”而是自己打开远程流和本地流用Read/Write循环一块一块地搬数据搬一块就累加一次字节数然后算百分比。上传的核心代码大概长这样public async Task UploadFileWithProgressAsync( SftpClient client, string localPath, string remotePath, IProgressdouble progress, CancellationToken ct) { var fileInfo new FileInfo(localPath); long totalBytes fileInfo.Length; long uploadedBytes 0; byte[] buffer new byte[81920]; // 80KB缓冲区兼顾速度和UI刷新频率 await using var localStream new FileStream(localPath, FileMode.Open, FileAccess.Read); await using var remoteStream client.OpenWrite(remotePath); int bytesRead; while ((bytesRead await localStream.ReadAsync(buffer, 0, buffer.Length, ct)) 0) { await remoteStream.WriteAsync(buffer, 0, bytesRead, ct); uploadedBytes bytesRead; progress.Report((double)uploadedBytes / totalBytes * 100.0); } }这段代码有几个关键细节值得多说一句。缓冲区大小选80KB是我实测比较稳的数值太小时进度回调太频繁UI频繁Invoke容易卡顿太大时内存占用上去而且进度条更新太稀疏看着像卡死了。在实际测试中80KB是个不错的折中点。用IProgressT和ProgressT配合是C#里比较标准的做法它内部会捕获创建时的SynchronizationContext在WinForms里就是UI线程所以进度回调里直接更新ProgressBar控件安全得很不用自己写Invoke。调用方式这样写var progressReporter new Progressdouble(p { progressBar.Value (int)p; labelPercent.Text ${p:F1}%; }); await UploadFileWithProgressAsync(client, localPath, remotePath, progressReporter, ct);下载的时候逻辑完全对称区别只是client.OpenRead(remotePath)和本地FileStream对调循环里是ReadAsync远程流WriteAsync本地流。2.3 总字节能顺手拿到但别把类型写错计算百分比需要总字节数本地文件直接FileInfo.Length就行远程文件要用client.GetAttributes(remotePath).Size。这里有个容易踩的坑Size属性返回的是long类型有些示例代码把它强转成int文件一旦超过2GB进度条直接出负数。传输文件多大就用多长的类型这点务必小心别问我是怎么知道的。GetAttributes这个操作本身有一次服务器往返在上传前提前拿一次没问题但不要在循环里反复调用否则会发现连接慢了很多。3. 上传模块实战从单个文件到整个文件夹3.1 批量目录上传的思路现场的配套项目通常不是一个一个传文件而是要把本地某个目录连同子目录一起同步到服务器。我的做法是递归遍历本地目录把相对路径拼接成远程路径然后逐文件上传。private IEnumerable(string local, string remote) CollectFiles( string localDir, string remoteBaseDir) { var files Directory.EnumerateFiles(localDir); foreach (var file in files) { var fileName Path.GetFileName(file); yield return (file, ${remoteBaseDir}/{fileName}); } foreach (var subDir in Directory.GetDirectories(localDir)) { var dirName Path.GetFileName(subDir); var newRemoteDir ${remoteBaseDir}/{dirName}; // 递归前确保远程目录存在 if (!client.Exists(newRemoteDir)) client.CreateDirectory(newRemoteDir); foreach (var item in CollectFiles(subDir, newRemoteDir)) yield return item; } }有个细节必须提醒SFTP客户端CreateDirectory一次只能创建一级目录不能像Directory.CreateDirectory那样直接创建多级父子目录。递归遍历时每进一层就先确保当前层远程目录存在不然上传时会报目录不存在错误。3.2 多文件任务的整体进度加重逻辑当一次要传很多文件时进度条就不能只看单个文件了得设计两层进度一个是当前文件进度一个是整个任务的总进度。总进度怎么算我用的办法是前期先扫描一遍所有待传文件算出总字节数然后每传完一个文件把该文件的字节数累加到已完成总数里总进度就是已完成总字节 / 所有文件总字节。文件级进度加总进度两层合并到同一个ProgressBar上比较常见的方法是文件级进度作为细节展示总进度作为主进度条。如果产品经理硬要一根进度条走完天下那就把总字节和已完成字节都放大到同一个累计器里当前文件的实时进度也折算进总字节这样大文件传一半时总进度会平缓走动体验好很多。double overallPercent (double)finishedBytes / totalAllBytes * 100.0;其中finishedBytes包含“已传完的文件字节总和 当前文件已传字节”这样算出来就是整个任务的实时进度。3.3 目录存在性检查别漏权限错误要先拦批量上传最常见的报错就是Permission denied。根目录、/root、/var这些系统目录普通SFTP用户根本没有写权限程序能正常连接但一建目录就抛异常。这种错误属于业务层面最好在上传前先做一次目录可写性探测尝试在目标目录下创建一个临时文件然后删除能成功就继续失败就直接弹提示比每个文件都报一遍错体验好得多。远程目录不存在的另一种情况也值得处理客户端Exists返回false于是CreateDirectory但如果上一层也不存在创建依然会失败。所以遍历目录时严格逐级创建不要图省事拼一整条深层路径直接创建。4. 下载模块实战最容易栽的路径参数顺序4.1 DownloadFile的参数顺序有多坑说一个SSH.NET用了一年的老手都可能搞反的坑DownloadFile方法的参数顺序第一个是本地路径第二个才是远程路径。很多人按“从哪来”的直觉写先填远程路径再填本地路径结果要么抛文件不存在要么在本地生成了一堆乱七八糟的目录结构。我自己用流的方式做下载后就没有这个困惑了代码里明确谁是Remote谁是Local不会弄混public async Task DownloadFileWithProgressAsync( SftpClient client, string remotePath, string localPath, IProgressdouble progress, CancellationToken ct) { var remoteFileSize client.GetAttributes(remotePath).Size; long downloadedBytes 0; byte[] buffer new byte[81920]; await using var remoteStream client.OpenRead(remotePath); await using var localStream new FileStream(localPath, FileMode.Create, FileAccess.Write); int bytesRead; while ((bytesRead await remoteStream.ReadAsync(buffer, 0, buffer.Length, ct)) 0) { await localStream.WriteAsync(buffer, 0, bytesRead, ct); downloadedBytes bytesRead; progress.Report((double)downloadedBytes / remoteFileSize * 100.0); } }FileMode.Create会直接覆盖已存在的同名文件如果产品要求不允许覆盖换成FileMode.CreateNew文件存在时抛IOException自己catch后做决策。4.2 二进制流传输千万别用文本模式下载配置文件、XML、TXT这些文本文件有人会图省事用StreamReader一行行读这个习惯在传输二进制文件时会出大事。PDF、图片、可执行文件一旦经过文本Reader中转行尾被改写、字节序列被篡改文件直接损坏。SFTP底层本身就是纯字节流正确姿势永远是FileStream配二进制Read/Write。我习惯对所有文件一视同仁用二进制流文本文件也只是后续解析时再转字符串传输环节绝不掺和编码和换行。4.3 跨平台路径分隔符工业现场Windows和Linux混着用太常见了。本地Windows文件路径用反斜杠\服务端Linux只认正斜杠/上传时拼远程路径一定要统一用正斜杠千万别直接拿本地路径替换盘符就去用。我封装了一个小方法处理路径拼接private static string CombineRemotePath(string basePath, string relativePath) { return ${basePath.TrimEnd(/)}/{relativePath.Replace(\\, /)}; }对了下载到本地时反过来把远程的正斜杠路径转回Windows风格用Path.Combine逐级拼接别自己手动拼字符串否则又掉进分隔符陷阱。5. 与OpenSSH服务端的连接稳定性断线、超时和续传5.1 broken pipe是怎么来的现场经常遇到一个诡异现象批量传文件传到一半日志里冒出send disconnect: broken pipe之类的错误后面所有文件全部失败。根本原因是SSH连接被服务端断开了典型场景是一批任务执行完毕连接空闲了几分钟服务端的ClientAliveInterval一到就主动断开空闲连接而客户端还傻乎乎地拿着旧连接继续发数据自然就broken pipe。解决办法很简单在ConnectionInfo或SftpClient上配置保持连接参数。SSH.NET里可以这样设置client.KeepAliveInterval TimeSpan.FromSeconds(30);这个属性会让客户端每30秒发一个SSH keepalive请求维持连接。实测下来批量任务间歇期再也没掉过线。要注意的是这个参数应该在Connect()之前设置连接建立之后改是无效的。5.2 重试机制的封装不管怎么预防网络抖动、服务端重启、防火墙超时这些意外总是防不胜防所以传输层一定要有重试机制。我的做法是封装一个ExecuteWithRetryAsync把每次文件传输操作包进去捕获典型的SshConnectionException和SocketException先断开重连再从失败的那个文件继续。private async Task ExecuteWithRetryAsync(FuncSftpClient, Task action, int maxRetry 3) { var attempt 0; while (attempt maxRetry) { try { if (!client.IsConnected) client.Connect(); await action(client); return; } catch (SshConnectionException) when (attempt maxRetry) { client.Disconnect(); await Task.Delay(2000); attempt; } catch (SocketException) when (attempt maxRetry) { await Task.Delay(2000); attempt; } } }注意重试的延迟时间别太短服务端刚断开时立刻重连往往还会失败等一两秒再试成功率明显高很多。5.3 大文件续传:临时文件加Seek定位如果文件动不动几个GB断线重试还从头传一遍就很浪费时间。SFTP协议本身没有原生续传标记位但我们可以借助Seek实现“伪续传”。思路是上传前先在远程创建一个upload.tmp临时文件本地维护一个upload.offset记录已传字节数断线重连后先拿远程临时文件的大小作为偏移量本地流Seek到偏移位置远程流也Seek到偏移位置继续传全部传完后把.tmp重命名为正式文件名。// 重连后继续时 long remoteTmpSize client.GetAttributes(remoteTmpPath).Size; localStream.Seek(remoteTmpSize, SeekOrigin.Begin); remoteStream client.OpenWrite(remoteTmpPath); remoteStream.Seek(remoteTmpSize, SeekOrigin.Begin);有个前提条件服务端必须支持Seek写入。OpenSSH 8.x之后基本都支持但某些Windows第三方SFTP服务端可能不支持所以我在代码里加了特性探测Seek远程流时报错就回退到全量重传。这个方法虽然不完美但确实解决了几百MB文件断线重传的痛点。5.4 下载大文件时的续传思路下载端续传更简单因为本地文件是我们自己控制的。断线后看本地临时文件的大小远程流Seek到该位置开始读就行。写完同样用重命名的方式避免半成品文件被业务系统误用。本地写文件时用FileMode.OpenOrCreate打开临时文件写完字节后Flush(true)强制刷盘确保断电也不丢已传数据。这些都是现场被血泪教训逼出来的细节。6. 上位机场景里那些绕不开的坑UI线程、文件锁和权限管理6.1 UI刷新要节流不然进度条比传输还卡WinForms里面直接用ProgressT虽然已经在UI线程执行但频率过高依然会导致界面卡顿。我们80KB一次回调传一个1GB文件大概要触发一万多次每次都更新UI界面会很僵。解决办法是节流用一个Stopwatch判断距上次刷新是否超过50毫秒不到就不更新进度条到了才刷新一次。这样每秒最多刷新20次肉眼看起来很流畅UI也不卡。private readonly Stopwatch _sw Stopwatch.StartNew(); private DateTime _lastUpdate DateTime.MinValue; private void UpdateProgress(double percent) { if ((DateTime.Now - _lastUpdate).TotalMilliseconds 50) return; _lastUpdate DateTime.Now; progressBar.Value Math.Min(100, (int)percent); labelPercent.Text ${percent:F1}%; }6.2 SftpClient不是线程安全的多线程并发上传一定要小心同一个SftpClient实例同时被多个线程调用SSH.NET内部会直接抛异常或数据错乱。我的做法是有两种可选方向一是多个文件串行上传这种简单可靠二是确实要并发就每个线程各建一个独立的SftpClient连接线程结束后释放。实测下来串行批量上传在几百个文件场景下并不慢瓶颈通常在等待服务器响应上而不是本地处理速度。如果没有明确性能要求优先串行少很多烦恼。6.3 杀毒软件和文件锁这两个隐形凶手Windows工控机上大概率装了各种安全软件实时扫描文件时会锁住刚下载到本地的文件导致程序读不了或者删不掉临时文件。遇到这种情况一是把工控机上的业务目录加入杀毒白名单二是程序里对文件操作失败时重试几次规避瞬时锁。还有一类问题上传的文件正在被Excel或者日志组件占用FileStream打开就报IOException。我封装文件上传前都会try一次打开本地流失败就直接提示哪个文件被占用让操作员去关掉程序而不是让整个任务卡在那里不动。6.4 本地搭一套SFTP测试环境最后强烈建议开发机上准备一个永久的SFTP测试环境。不用买服务器Windows开启OpenSSH Server功能就行设置好服务自动启动本地改代码调试非常方便。我现在的开发机上就跑着一个OpenSSH服务专门用来测试各种上传下载边界情况比如目录不存在、权限不足、文件名含中文和空格等。测试时建议专门建一个低权限账号把所有操作系统目录访问权限去掉只给它一个测试目录的读写权这样能提前暴露权限问题的代码路径而不是事到临头到客户现场才发现。另外每次改完代码跑一遍“上传大文件中途拔网线”的灾难测试这个动作帮我提前排查了一堆重试逻辑的问题。工具这种东西往往是在最恶劣的情况下才显出价值。本文还有配套的精品资源点击获取