简介:面向C# WinForms开发者与需要实现局域网文件传输的学习者,这份代码示例围绕“将本地文件上传至服务器共享文件夹”展开,适合用来理解UNC路径访问、网络凭据、文件流读写与异步进度反馈等关键环节。压缩包共23个文件,约36KB,以cs源码、sln解决方案、csproj工程文件和resx/resources资源文件为主体,并附带编译生成的exe、pdb及缓存文件,便于直接打开解决方案查看项目结构与实现逻辑。已有1429人学习,说明该实例在同类需求中具有一定参考价值。读者可从中获得一套可运行的WinForms上传示例,并据此梳理文件选择、连接共享目录、流式传输、进度显示和异常处理之间的组织方式,也可借鉴其中的工程配置与资源管理做法,用于企业内部通信或数据传输场景的练手与改造。
1. 内网归档需求,为什么最后都落到共享文件夹上
车间扫码枪扫完单子,客户端要把生成的 PDF 和现场照片立刻归集到一台文件服务器的目录里——这个场景听起来平淡,真正落地时却卡在流程上:运维不给开 FTP,也不愿意为这点事单独写一套上传接口。于是 WinForm 直接上传文件到共享文件夹,成了性价比最高的一条路,不用起服务、不用配中间件,目录权限还是原来那套 NTFS 权限。上传文件到服务器这件事,在这里被压缩成一次 UNC 路径拷贝,代码量不大,但凭据、路径、大文件、断线重试一个都躲不掉。这份项目源码把「文件上传到共享文件夹」拆成了连接、传输、配置、异常四块,适合正在做内网桌面工具、又不想引入后端服务的开发者直接拿来改。
2. 项目骨架与技术选型:先把上传链路拆成能看懂的几层
拿到一个桌面端上传 Demo,最先看的不是界面,是它怎么把「连接」和「传输」这两件耦合度不高的事分开。很多同类代码把凭据认证、路径拼接、文件拷贝全塞进按钮的 Click 事件里,两百行写到底,改一个共享目录就得重编译。这份源码在结构上做了分层,虽然规模不大,但该抽的接口、该独立的配置都独立了,后面接二次开发会省不少事。
2.1 目录结构与项目分层
源码的工程结构大致如下,我把实际目录压缩成了便于理解的形式:
ShareUploader/ ├── ShareUploader.sln ├── src/ │ ├── ShareUploader.App/ # WinForm 主程序 │ │ ├── Forms/ │ │ │ ├── MainForm.cs # 主界面,文件列表 + 进度条 │ │ │ └── MainForm.Designer.cs │ │ ├── Program.cs │ │ └── appsettings.json # 共享路径、凭据、分块参数 │ ├── ShareUploader.Core/ # 与 UI 无关的核心逻辑 │ │ ├── Network/ │ │ │ ├── ShareConnection.cs # WNetAddConnection2 封装 │ │ │ └── NetworkPath.cs # UNC 路径拼装与校验 │ │ ├── Transfer/ │ │ │ ├── IFileUploader.cs │ │ │ ├── UncFileUploader.cs # 分块拷贝 + 进度回调 │ │ │ └── TransferProgress.cs │ │ ├── Config/ │ │ │ └── UploadOptions.cs │ │ └── Logging/ │ │ └── FileLogger.cs │ └── ShareUploader.Tests/ # 单元测试 + 本地假共享目录 └── docs/ └── deploy-notes.mdApp层只做两件事:收集用户选中的文件、把IProgress<T>绑到界面上。真正的连接和拷贝都在Core里,Core不引用System.Windows.Forms,这意味着同一套逻辑后面可以直接搬到控制台程序或者 Windows 服务里跑,不用重写。我做内网工具时习惯这么切,因为归档类需求十有八九会被要求「顺便做成定时任务」,早切早省事。
Network/和Transfer/分开是关键。前者解决「有没有权限进这个目录」,后者解决「怎么把字节搬过去」。这两件事的失败原因完全不同:连不上是账号、网络、共享名的问题;拷一半断了是文件锁、磁盘空间、会话超时的问题。混在一起写,出问题时日志里全是「上传失败」,排查起来就是黑匣子。
2.2 技术选型:UNC 直连、映射盘还是临时接口
同样是往共享文件夹写文件,Windows 下有三条常见路线,选错了后期维护成本差别很大:
| 方案 | 实现方式 | 优点 | 主要问题 | 适用场景 |
|---|---|---|---|---|
| UNC 直连 | 代码里直接用\\server\share\path | 不依赖盘符,多进程互不干扰 | 需要显式传凭据 | 推荐,绝大多数内网工具都走这条 |
| 映射网络驱动器 | net use Z: \\server\share | 路径短,老代码兼容好 | 盘符冲突、会话残留、注销后失效 | 遗留系统、用户手工操作 |
| 临时 HTTP 接口 | 后端起一个上传服务 | 可审计、可限流 | 要部署服务,运维成本高 | 跨网段、需要留痕的正式系统 |
源码走的是 UNC 直连。原因很实际:映射盘符是「按用户会话」存在的,同一台机器上开两个账号、或者程序以服务身份运行,盘符根本看不到;而且盘符被占用之后报错信息极其难懂,用户只会说「Z 盘没了」。UNC 路径没这个问题,\\fileserver\archive\2024\在任何进程里含义都一样,只要凭据对。
代价是必须自己处理认证。匿名共享(Everyone 可写)在正规内网基本见不到,绝大多数共享目录都要求域账号或者本地账号,所以ShareConnection.cs里那段 P/Invoke 是整个项目绕不开的核心,第 3 章会展开。
提示:如果目标共享目录和客户端在同一台机器上,UNC 路径同样有效,但要注意本机回环访问在某些安全策略下会被限制,测试时优先用另一台机器验证。
2.3 配置项设计:把共享路径从代码里赶出去
共享路径写死在代码里,是这类工具最常见的返工原因。测试环境叫\\test-nas\up,生产环境叫\\fileserver\archive,每次发版都要改代码重编译,出了事还得回滚。源码把这一组参数全放进了appsettings.json:
{ "Upload": { "ShareRoot": "\\\\fileserver\\archive", "SubFolderPattern": "yyyy\\\\MM\\\\dd", "UserName": "svc_archive", "PasswordCipher": "AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAA...", "ChunkSizeKB": 1024, "MaxConcurrency": 4, "RetryTimes": 3, "TempSuffix": ".uploading", "ConnectTimeoutMs": 15000 } }几个参数值得单独说。SubFolderPattern用日期做二级目录,是归档类需求的常规做法,按天分目录能避免单目录文件数过万之后资源管理器卡死;程序启动时按DateTime.Now.ToString(pattern)生成,不存在的目录用Directory.CreateDirectory补。PasswordCipher不是明文,源码里用 DPAPI 的ProtectedData.Protect加了一层,绑定当前用户或本机,配置文件被拷走也解不开——这比硬编码明文密码体面得多,虽然算不上强安全。
读取侧对应一个强类型对象,绑定时做一次校验:
public sealed class UploadOptions { public string ShareRoot { get; set; } = ""; public string SubFolderPattern { get; set; } = "yyyy\\\\MM\\\\dd"; public string UserName { get; set; } = ""; public string PasswordCipher { get; set; } = ""; public int ChunkSizeKB { get; set; } = 1024; public int MaxConcurrency { get; set; } = 4; public int RetryTimes { get; set; } = 3; public string TempSuffix { get; set; } = ".uploading"; public string ResolvePassword() { if (string.IsNullOrWhiteSpace(PasswordCipher)) return ""; var raw = Convert.FromBase64String(PasswordCipher); var bytes = ProtectedData.Unprotect(raw, null, DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(bytes); } public void Validate() { if (!ShareRoot.StartsWith(@"\\\\")) throw new ArgumentException("ShareRoot 必须是 UNC 路径,形如 \\\\server\\share"); if (ChunkSizeKB < 64 || ChunkSizeKB > 8192) throw new ArgumentException("ChunkSizeKB 建议在 64 到 8192 之间"); } }ResolvePassword在每次建立连接时调用,而不是启动时缓存在内存里——连接可能中途断开重连,缓存一份明文密码在字段里,进程转储时就是裸的。Validate放在配置加载之后、界面初始化之前,路径不合法直接弹窗退出,好过让用户点完上传再看到「找不到网络路径」。
ChunkSizeKB这个值有讲究。默认 1MB 是折中:太小(比如 4KB)会导致循环次数暴涨,网络往返开销压过实际传输;太大(比如 64MB)在弱网或者机械盘上单次写阻塞时间过长,进度条看起来像卡死。内网千兆环境下 1MB 到 4MB 都合适,跨网段或者共享盘本身是机械盘阵列,建议降到 256KB 到 512KB,用平滑的进度换吞吐。
3. 共享目录连接:凭据、会话和连接复用
上传失败的案例里,一多半死在连接阶段,而不是拷贝阶段。原因在于 Windows 的 SMB 会话是「按用户 + 按服务器」维护的,一旦某个会话建立过,后续再用别的账号连同一台服务器,系统会直接拒绝,报 1219「不允许一个用户使用多个用户名与服务器建立多个连接」。这个行为不写在任何显眼的地方,是这类项目最典型的翻车点。
3.1 net use 与 WNetAddConnection2 的取舍
最省事的写法是起一个net use进程:
public static bool ConnectByNetUse(string uncPath, string user, string pwd) { var args = $"use \"{uncPath}\" \"{pwd}\" /user:\"{user}\" /persistent:no"; var psi = new ProcessStartInfo("net.exe", args) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true }; using var p = Process.Start(psi); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(15000); return p.ExitCode == 0; }这段代码能用,但我不推荐作为主方案。三个问题:第一,密码会出现在命令行参数里,任务管理器或者进程审计工具能直接看到;第二,net use的报错文本是中文本地化的,你想按错误码分支处理都没法做,只能靠字符串匹配,换台英文系统就崩;第三,它依赖net.exe存在,某些精简过的系统镜像里这个工具被裁掉了。
源码用的是WNetAddConnection2,通过mpr.dll直接调 Win32 API,拿得到整数错误码,密码不落命令行。代价是要写 P/Invoke,多二十行代码。
3.2 WNetAddConnection2 的完整封装
先看结构体定义和导入,这块必须一字不差,字段顺序错了会得到莫名其妙的ERROR_INVALID_PARAMETER:
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct NETRESOURCE { public int dwScope; // 0 = RESOURCE_CONNECTED public int dwType; // 1 = RESOURCETYPE_DISK public int dwDisplayType; // 0 public int dwUsage; // 0 public string lpLocalName; // 直连 UNC 时传 null,不映射盘符 public string lpRemoteName; // 共享根,如 \\fileserver\archive public string lpComment; public string lpProvider; } [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetAddConnection2( ref NETRESOURCE lpNetResource, string lpPassword, string lpUserName, int dwFlags); [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetCancelConnection2( string lpName, int dwFlags, bool fForce); private const int RESOURCETYPE_DISK = 1; private const int CONNECT_TEMPORARY = 0x00000004; private const int CONNECT_INTERACTIVE = 0x00000008;关键点:lpLocalName传null表示只建立到远程共享的会话,不占用盘符;dwFlags用CONNECT_TEMPORARY,会话只在当前登录会话内有效,程序退出、用户注销后自动清掉,不会给下一台机器留下「盘符被占」的烂摊子。如果传 0(持久连接),系统会尝试把它写进注册表的网络连接列表,反而容易出问题。
调用侧要做错误码翻译,这一步决定了日志有没有价值:
public static ShareConnectResult Connect(string shareRoot, string user, string pwd) { var nr = new NETRESOURCE { dwType = RESOURCETYPE_DISK, lpRemoteName = shareRoot, lpLocalName = null }; int code = WNetAddConnection2(ref nr, pwd, user, CONNECT_TEMPORARY); return code switch { 0 => ShareConnectResult.Ok, 53 => ShareConnectResult.Fail("找不到网络路径,检查共享名和网络连通性"), 67 => ShareConnectResult.Fail("共享名不存在"), 86 => ShareConnectResult.Fail("账号或密码错误(旧版凭据冲突)"), 1219 => ShareConnectResult.Fail("已存在其他账号的会话,需先断开:net use \\\\server /delete"), 1326 => ShareConnectResult.Fail("用户名或密码不正确"), 1385 => ShareConnectResult.Fail("该账号没有从网络访问此服务器的权限"), _ => ShareConnectResult.Fail($"连接失败,Win32 错误码 {code}") }; }错误码 1219 值得单独处理:它不是「密码错了」,是「这台机器已经用另一个身份连过这台服务器」。程序里遇到它,正确做法是先WNetCancelConnection2(shareRoot, 0, true)断开旧会话再重连,而不是提示用户去手敲net use /delete。很多同类工具就卡在这,用户重启电脑就好了,然后以为是自己操作问题。
注意:
WNetCancelConnection2的fForce传true时会强制断开,如果同一个共享上还有别的进程在读写,可能导致对方写失败。只在重连前调用,不要在程序正常退出时无条件强断。
3.3 连接复用、断开时机与路径拼装
一个容易忽略的细节:连接是「按共享根」建立的,不是按目录。\\fileserver\archive\2024\05和\\fileserver\archive\2024\06用的是同一个会话,所以连接只需要在程序启动或者第一次上传前建立一次,不需要每传一个文件调一遍WNetAddConnection2。源码里用一个静态的ShareConnection持有状态:
public sealed class ShareConnection : IDisposable { private readonly string _root; private bool _connected; public ShareConnection(string shareRoot, string user, string pwd) { _root = shareRoot.TrimEnd('\\'); var r = Native.Connect(_root, user, pwd); if (!r.Success) throw new IOException(r.Message); _connected = true; } public string BuildTargetPath(string fileName) { var sub = DateTime.Now.ToString("yyyy\\\\MM\\\\dd"); var dir = $@"{_root}\{sub}"; Directory.CreateDirectory(dir); // 目录不存在时补建,已存在不报错 return Path.Combine(dir, fileName); } public void Dispose() { if (!_connected) return; Native.WNetCancelConnection2(_root, 0, true); _connected = false; } }BuildTargetPath里Path.Combine用的是本地路径语义,UNC 路径也适用,但要注意_root末尾不能带反斜杠,否则拼出来是\\server\share\\2024,大部分场景能容忍,个别老旧的 NAS 固件会判成非法路径。所以构造函数里先TrimEnd('\\')。
断开的时机也有讲究。源码在MainForm.FormClosing里Dispose,也就是程序退出时断开。如果这是个常驻托盘的工具,一天只上传几次,那就该在每次批量上传结束后断开——长时间挂着 SMB 会话,中间网络抖动、服务器重启、账号被改密码,会话都会变成僵尸状态,下次写入报ERROR_BAD_NET_NAME,但看起来像是「文件明明在,就是写不进去」。
4. 文件传输核心:分块读写、进度回调和取消
连接打通之后,真正的传输反而是最直白的部分,但因为要兼顾大文件、进度、取消和失败重试,代码量并不小。这一章按一次完整的上传流程拆:打开源文件、分块读、分块写、报进度、临时文件转正。
4.1 分块拷贝的主循环
不要用File.Copy。它在共享盘上不可中断,没有进度,出错时只给一个笼统的IOException,而且目标文件可能留下半截。源码自己写循环:
public async Task UploadAsync(string sourcePath, string uncTarget, IProgress<TransferProgress> progress, CancellationToken token) { int bufferSize = _options.ChunkSizeKB * 1024; var buffer = new byte[bufferSize]; var tempPath = uncTarget + _options.TempSuffix; // 先写临时文件 await using var src = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); await using var dst = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous); long total = src.Length; long written = 0; var sw = Stopwatch.StartNew(); int read; while ((read = await src.ReadAsync(buffer.AsMemory(0, bufferSize), token)) > 0) { await dst.WriteAsync(buffer.AsMemory(0, read), token); written += read; progress?.Report(new TransferProgress(written, total, sw.Elapsed)); } await dst.FlushAsync(token); dst.Close(); // 原子转正:同一共享目录内 rename,失败概率极低 File.Move(tempPath, uncTarget, overwrite: true); }FileOptions.SequentialScan告诉系统这个文件是顺序读的,Windows 会提前做预读,大文件下能省一截时间。FileShare.Read允许别的程序同时读源文件,避免用户开着 PDF 预览就传不了。目标侧FileShare.None是故意的:如果已经有另一个进程在写同名临时文件,直接失败,比两个进程交叉写出一堆乱码强。
FlushAsync不能省。FileStream内部有缓冲区,不刷盘就Move,理论上数据还在内存里,断电就是空文件。虽然Close也会刷,但显式写出来更稳。
4.2 进度回调与 UI 线程安全
IProgress<T>是 .NET 里做这件事最省心的机制。它在创建时捕获当前的SynchronizationContext,Report时自动Post回 UI 线程,所以窗体代码里不需要写Invoke:
private async void btnUpload_Click(object sender, EventArgs e) { var files = GetSelectedFiles(); var progress = new Progress<TransferProgress>(p => { progressBar.Maximum = 100; progressBar.Value = (int)(p.Percent * 100); lblSpeed.Text = $"{p.BytesPerSecond / 1024 / 1024:F1} MB/s"; lblEta.Text = $"剩余 {p.EstimatedRemaining:mm\\:ss}"; }); _cts = new CancellationTokenSource(); try { await _uploader.UploadBatchAsync(files, _options, progress, _cts.Token); } catch (OperationCanceledException) { lblStatus.Text = "已取消"; } catch (Exception ex) { _logger.Error("批量上传失败", ex); MessageBox.Show(ex.Message, "上传失败"); } }TransferProgress里除了字节数,还派生出了速度和剩余时间。速度不要用瞬时值,用「最近 3 秒的滑动窗口」算,否则机械盘一阵一阵的 IO 会让数字疯狂跳动,用户看着心慌。剩余时间用总大小减已传,除以平均速度,简单够用。
一个血泪经验:不要在Report里做任何耗时操作,包括File.AppendAllText打日志。进度回调的触发频率跟分块大小直接相关,1MB 块传一个 2GB 文件就是两千次回调,每次都写盘,传输速度会被日志拖掉三成。要记录,就在批量结束时汇总写一次。
4.3 临时文件、原子落盘与断点续传
为什么先写.uploading再Move?因为共享文件夹经常被别的系统扫描——定时任务、杀毒软件、索引服务。一个写到一半的 PDF 如果直接叫正式名字,扫描器读到截断的文件,轻则报错,重则把错误结果入库。临时后缀加上「写完再改名」,让下游只会看到完整文件。
File.Move在同目录内是重命名操作,SMB 协议下表现为一次rename请求,不需要真的搬数据,所以对大文件来说这一步几乎瞬间完成。跨目录就会变成「复制 + 删除」,那个开销就大了。所以临时文件和目标文件必须在同一个目录下。
断点续传在这个场景里属于「有更好,没有也能用」的功能。实现起来是在本地维护一个小索引文件,记录源文件路径 + 文件大小 + 最后修改时间 + 已传字节数 + 目标临时文件,重试时如果目标临时文件还存在,就以FileMode.Append打开,把源文件Seek到对应位置继续写:
if (resumeOffset > 0 && File.Exists(tempPath)) { dst = new FileStream(tempPath, FileMode.Append, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous); src.Seek(resumeOffset, SeekOrigin.Begin); written = resumeOffset; }判断能不能续的关键是「源文件没变」,所以要把源文件的大小和LastWriteTimeUtc一起存下来比对。只比大小不够,同名文件覆盖的情况太常见了。
4.4 多文件并发与取消
批量上传时,并发数不是越大越好。共享文件夹那头通常是一块机械盘阵列或者低端 NAS,四个并发写已经接近它的随机写瓶颈,再往上加只会让每个流的吞吐都下降。源码用一个SemaphoreSlim控住:
public async Task UploadBatchAsync(IReadOnlyList<string> files, UploadOptions opt, IProgress<TransferProgress> progress, CancellationToken token) { using var gate = new SemaphoreSlim(opt.MaxConcurrency); var tasks = files.Select(async f => { await gate.WaitAsync(token); try { var target = _connection.BuildTargetPath(Path.GetFileName(f)); await RetryAsync(() => UploadAsync(f, target, null, token), opt.RetryTimes, token); } finally { gate.Release(); } }); await Task.WhenAll(tasks); }每个文件的进度不往总进度条上报,而是算一个聚合值:已传总字节除以总字节。内部仍然走Interlocked.Add累加,避免多线程写同一个long出问题。
取消用CancellationTokenSource,ReadAsync/WriteAsync都接了这个 token,用户点取消之后当前块写完就会抛OperationCanceledException。此时临时文件是残留的,finally里应该删掉——除非开了断点续传模式。这两种策略要明确选一个,不能一半删一半留,否则下次上传时目录里一堆.uploading没人认领。
5. 避坑与排查:共享文件夹上传翻车的五个现场
这一章记录的是这类工具在现场最容易遇到的五类故障,每一条我都按「现象、原因、解决」写,遇到问题可以对照着查。
5.1 现象:开发机跑得好好的,换台机器就提示「找不到网络路径」
原因通常不是网络不通,而是 UNC 路径里的共享名和实际不一致。开发机上可能映射过盘符,代码里写的是Z:\archive,测试时刚好能用;换到没映射的机器上,Z:不存在,Directory.CreateDirectory抛的异常信息又指向了内层。另一种情况是主机名解析:\\fileserver在域内能解析,在没加域的工作组机器上就找不到,得换成 IP。
排查顺序是固定的:先在目标机器上ping主机名,再在资源管理器地址栏里直接敲\\server\share看能不能弹认证框,最后才是看代码。如果资源管理器能进、代码不能进,那问题一定在凭据传递上,跟网络无关。日志里要把完整的 UNC 路径打出来,不要只打文件名——很多工单就卡在「日志里看不出到底往哪写的」。
5.2 现象:登录失败,账号密码在资源管理器里输一遍就能进
这是最经典的一个坑。资源管理器输密码能进,说明账号本身没问题,问题在代码传给WNetAddConnection2的用户名格式。域账号必须写成DOMAIN\user或者user@domain.com,只写user会被当成目标机器的本地账号。本地账号则相反,写成MACHINE\user或者干脆user,带上错误的域名反而连不上。
另一个可能是密码里的特殊字符。配置文件里如果手写了密码,&、%、<这些字符在某些读取方式下会被转义,看起来一模一样,实际传过去就变了。所以密码要么走 DPAPI 加密存 Base64,要么在界面上让用户输入,不要往 JSON 里手抄。
还有一种情况是账号被锁。服务账号为了安全通常配了「密码永不过期」但也配了「多次失败锁定」,调试期间反复连失败,把账号锁了,之后正确的密码也进不去。排查时先找管理员确认锁定状态,别在代码里死磕。
5.3 现象:进度卡在 99% 不动,最后报「文件被占用」
File.Move那一步失败。原因可能是杀毒软件正在扫描刚写完的临时文件,文件句柄还没释放;也可能是下游的采集程序盯着这个目录,发现有新文件就立刻打开读,正好卡在改名的一瞬间。现象是进度条满格但不动,超时之后抛异常。
解决分两层。短期靠重试:把File.Move包在一个带退避的重试里,失败后等 200ms、500ms、1s 再试,多数情况三次之内就过了。长期靠命名约定:临时文件用.uploading后缀,并且让下游系统的扫描规则明确排除这个后缀,从源头上避免争抢。
private static void MoveWithRetry(string from, string to, int times) { for (int i = 0; ; i++) { try { File.Move(from, to, overwrite: true); return; } catch (IOException) when (i < times) { Thread.Sleep(200 * (i + 1)); } } }注意这里的overwrite: true是必要的,同名文件已存在时,默认行为是抛异常而不是覆盖。归档场景下同名通常意味着重传,覆盖才是想要的结果。
5.4 现象:小文件秒传,几百兆的文件必炸
小文件走的是 SMB 的一次性写请求,大文件走的是分块流式写,两者的失败面完全不同。最常见的原因是服务器端的会话超时设置。SMB 会话在空闲一段时间后会被服务端回收,如果一个文件传了十几分钟,中间没有任何其他请求,某些配置下会话就被判为空闲断开了,后续的写请求全部失败。
解决办法不是调大分块,而是在传输期间定期发一个轻量请求保活,比如每隔 30 秒对目标目录做一次Directory.Exists或者读一下文件长度。这些操作代价极小,但能让会话保持活跃。
第二个原因是磁盘配额。共享目录所在卷快到容量上限时,小文件还能写,大文件写到一半就ERROR_DISK_FULL。上传前先查一下目标卷剩余空间:
var drive = new DriveInfo(Path.GetPathRoot(uncTarget)); if (drive.AvailableFreeSpace < sourceLength * 1.1) throw new IOException($"目标空间不足,剩余 {drive.AvailableFreeSpace / 1024 / 1024} MB");DriveInfo对 UNC 路径的支持在 .NET 里不太稳定,某些环境下Path.GetPathRoot返回空。稳妥的做法是写一个 1 字节的探测文件,能写就说明空间还没满到 0,虽然不精确,但至少不会在传了 90% 之后才炸。
5.5 现象:程序退出后共享连接还挂着,下次启动连不上
WNetAddConnection2建立的会话,如果程序异常退出(崩溃、被任务管理器结束),Dispose不会执行,会话就留在系统里。下次启动用同一账号其实没问题,但如果配置里换了账号,就会撞上 3.2 节说的 1219。
处理方式是在程序启动、建立连接之前,先无条件WNetCancelConnection2(shareRoot, 0, true)断一次,忽略返回值,然后重新连。这一步相当于给会话做一次幂等的重置,代价是一次网络往返,换来的是「换账号不用重启电脑」。我在所有这类工具里都会加这一步,属于典型的后悔药。
还有个小细节:如果程序是用「以其他用户身份运行」启动的,SMB 会话属于那个用户,跟当前登录用户是两套。这时候用资源管理器测试是测不出来的,必须在同一个运行身份下验证。
6. 二次开发:把上传能力抽成可替换的传输层
真要把这套代码用起来,改动往往不是「修个 bug」,而是「再加一个去处」。比如归档要同时写内网共享和备份 NAS,或者某些客户端要求走 HTTP 接口、某些只能走共享目录。这时候如果上传逻辑还绑在窗体里,每加一个目标就得复制一遍代码。
我的做法是先定一个接口,把「上传一个文件」这件事收敛成三个参数:
public interface IFileUploader { Task UploadAsync(string localPath, string remoteName, IProgress<TransferProgress> progress, CancellationToken token); Task UploadBatchAsync(IReadOnlyList<string> localPaths, IProgress<TransferProgress> progress, CancellationToken token); }UncFileUploader实现这个接口,构造函数注入ShareConnection和UploadOptions。以后要加HttpFileUploader或者FtpFileUploader,只要实现同一个接口,窗体代码一行不用改,靠配置文件里的"Provider": "Unc"决定实例化哪个。这一步做完之后,「多目标分发」就变成了一个组合器:
public sealed class FanoutUploader : IFileUploader { private readonly IReadOnlyList<IFileUploader> _inners; public FanoutUploader(params IFileUploader[] inners) => _inners = inners; public async Task UploadAsync(string localPath, string remoteName, IProgress<TransferProgress> progress, CancellationToken token) { foreach (var inner in _inners) await inner.UploadAsync(localPath, remoteName, progress, token); } }注意这里的失败策略要提前想清楚:是「任意一个成功就算成功」,还是「全部成功才算成功」。归档场景一般选后者,因为缺一份就漏数据,宁可报错让人工介入。
验证方法上,我建议在测试项目里造一个本地假共享目录——在Temp下建一个文件夹,用net share临时共享出来,或者直接用本机的\\localhost\c$\temp管理共享做冒烟测试。真正要验证的是这几条:断网重连后能否自动恢复、传一半取消后临时文件是否清理、同名文件重传是否覆盖、目标目录不存在时是否自动创建。这四条过了,现场基本不会出大问题。
从那以后我每次接这类归档工具,都会在动 UI 之前先把共享目录的账号权限、UNC 路径、临时文件策略写成一张检查清单,跑通了再写界面。界面是最不容易出问题、也最容易被当成进度的地方,真正的坑全在前面那三步。希望帮到你。
本文还有配套的精品资源,点击获取