在C#开发中,经常需要把网络响应、内存压缩包或数据库二进制字段等Stream内容持久化到磁盘。真正可靠的写法并不是简单调用几个快捷方法,而是要理解流的顺序读取特性、缓冲区大小对内存的影响,以及文件句柄的生命周期管理。

一、为什么不能盲目使用ToArray或CopyTo
很多初学者拿到一个Stream,第一反应是调用MemoryStream的ToArray方法,或者直接用CopyTo把内容灌进FileStream。这两种方式在小型数据下没问题,但面对几十兆以上的内容时,ToArray会把整个流复制进字节数组,造成内存峰值翻倍;而默认的CopyTo虽然分块,但缓冲区大小和异常处理完全托管给运行时,难以干预。
Stream的本质是顺序字节序列,它不保证可重复读取,也不保证总长度可知。如果源流来自网络且未缓冲,直接一次性读取可能阻塞甚至超时。因此,手动控制读取循环,既能设定合理的缓冲区(如8KB到64KB),也能在写入异常时记录已写位置,便于后续续写或清理。
二、基础实现:用FileStream分块写入
最通用的做法是以只读方式打开源Stream,以创建或覆盖方式打开目标文件,然后声明一个固定大小的字节数组作为缓冲,循环调用Read方法直到返回0。下面的代码展示了同步保存的完整逻辑,包含释放资源和基础异常捕获。
using System;
using System.IO;
public class StreamSaver
{
public static void SaveStreamToFile(Stream source, string filePath, int bufferSize = 8192)
{
// 确保目录存在
string dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
// 使用using保证FileStream被释放
using (FileStream target = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
byte[] buffer = new byte[bufferSize];
int read;
// 循环读取直到流结束
while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
{
target.Write(buffer, 0, read);
}
target.Flush();
}
}
}
上述代码中,bufferSize设为8192字节是比较均衡的选择。若磁盘为机械硬盘且文件很大,可适当提高到32768减少系统调用;若是高并发服务写小文件,保持较小缓冲更利于内存复用。注意source流在方法外应由调用者释放,避免重复关闭。
该写法的优点是内存占用恒定,不依赖源流是否支持Length属性,且对中断可控。缺点是同步IO在UI线程会卡顿,此时应改用异步版本。
三、异步保存与进度上报
在WinForm、WPF或ASP.NET Core中,为避免阻塞主线程,应使用ReadAsync和WriteAsync。同时可借助已写字节数计算进度,方便界面展示。下面示例演示了异步保存并输出进度到控制台。
using System;
using System.IO;
using System.Threading.Tasks;
public class AsyncStreamSaver
{
public static async Task SaveAsync(Stream source, string filePath, IProgress<long> progress = null)
{
long totalWritten = 0;
using (FileStream target = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true))
{
byte[] buffer = new byte[8192];
int read;
while ((read = await source.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await target.WriteAsync(buffer, 0, read);
totalWritten += read;
progress?.Report(totalWritten);
}
}
}
}
异步方法中的FileStream构造函数最后一个参数useAsync设为true,可让底层使用重叠IO,提升吞吐量。progress对象可以是Progress<long>实例,在回调中更新进度条。若源流本身是MemoryStream,异步并不会带来明显性能提升,但能统一代码风格。
需要留意的是,异步写入若中途取消,文件可能只写了一部分。调用方应捕获TaskCanceledException并决定删除半成品文件还是保留断点。
四、常见坑与权限处理
第一个坑是路径权限。Windows服务或以IIS用户运行的应用,对系统目录或他人目录通常无写权限,会抛出UnauthorizedAccessException。建议将文件保存到Environment.GetFolderPath取出的本地应用数据目录,或明确授权目标文件夹。
第二个坑是流位置。有些Stream在读取前需调用Seek(0, SeekOrigin.Begin)复位,例如MemoryStream被别处读取过。若源流不支持Seek(如NetworkStream),则只能顺序消费一次,保存后不可再用。第三个坑是文件被占用,用FileMode.Create会覆盖,若需不覆盖应判断File.Exists并改用FileMode.CreateNew,捕获IOException来提示用户。
| 场景 | 推荐模式 | 注意点 |
|---|---|---|
| 小文件后台写 | 同步分块 | buffer 8KB即可 |
| 大文件UI程序 | 异步+进度 | 处理取消与半成品 |
| 不可重读网络流 | 边收边写 | 不能二次保存 |
五、封装为扩展方法
为了让调用更直观,可以把逻辑写成Stream的扩展方法,这样任何流实例都能直接调用SaveToFile。扩展方法需放在静态类中,且注意null检查。
using System;
using System.IO;
public static class StreamExtensions
{
public static void SaveToFile(this Stream stream, string path, int bufferSize = 8192)
{
if (stream == null) throw new ArgumentNullException(nameof(stream));
if (string.IsNullOrEmpty(path)) throw new ArgumentException("路径不能为空");
StreamSaver.SaveStreamToFile(stream, path, bufferSize);
}
}
通过扩展方法,业务代码只需一行responseStream.SaveToFile("d:\tmp\a.dat");即可完成保存。但要注意扩展方法内部不应关闭传入的stream,除非文档明确说明所有权转移,否则容易造成外部使用已关闭流的错误。
综合来看,C#把Stream写入磁盘的核心在于恒定内存的分块拷贝、正确的释放机制,以及对运行环境的权限和流特性的预判。掌握这些,就能写出既高效又稳健的保存工具方法。