从零开始:c#如何优雅的操作临时文件/数据?以ASP文件下载为例

在程序开发中,我们经常需要处理临时文件,例如:

  • 安全替换大文件 :先将内容写入临时文件,成功后再替换目标文件,避免写入过程中断导致数据损坏。
  • 进程间数据传递:临时文件作为中间媒介,实现不同进程之间的数据交换。
  • Web文件下载:将动态生成的数据写入临时文件,并提供给用户下载。

本文将以 ASP.NET Core 中的文件下载 场景为例,带你一步步实现更优雅的临时文件处理方案。

一、理解核心概念:Stream(流)

文件操作离不开Stream)的概念。你可以把Stream想象成一根水管,数据就像水一样,可以从一端流入,从另一端流出。

Stream是C#中用来处理数据的一种方式。它就像是一个管道,数据可以通过这个管道流动。你可以把数据从一个地方(比如硬盘上的文件)读出来,也可以把数据写到另一个地方(比如内存或者网络)。

使用Stream的好处是,它提供了一种统一的方式来处理不同类型的数据传输。在这个过程中,数据就像水管里的水一样源源不断的流动着,处理完的数据咱们可以舍弃,继续接收后面的即可,这样就可以实现传输例如磁盘上的大文件的信息,解决了一次性加载时的内存占用问题。

二 简单基础的实现 lv.1 (入门参考)

了解了流的基本概念后,我们先来看一个在ASP.NET Core中实现文件下载接口的基础代码。这是一个最直接的实现。为了快速达成目标,我们在指定文件夹创建了一个随机名称的临时文件,写入数据后,返回文件流供下载。

如果这段代码能成功运行,恭喜你!你已经实现了一个基础的Web文件下载接口。用户将得到一个文本文件,内容为查询参数的值。

csharp 复制代码
// 一个简单的控制器实现
public class DownloadController:Controller
{
    string path = "d:\\tmp";
   
    // 一个简单的带参数的Get实现
    [HttpGet("DownloadFileMemory")]
    public IResult DownloadFileMemory(string query)
    {
        try
        {                
            //创建一个新的文件流,可写(写入文件数据)、可读(读取数据最终给用户下载)
            FileStream fs = new FileStream(path+"\\"+Guid.NewGuid(), FileMode.CreateNew, FileAccess.ReadWrite);

            // 一个简单的流写入对象,以文本写入为例
            using (StreamWriter writer = new StreamWriter(fs, leaveOpen: true))
            {
                writer.Write(query);
            }
            fs.Position = 0;

            // 返回下载的文件,并将文件名重设为"sample.txt"
            return Results.File(fs, "application/octet-stream", "sample.txt");  // 流回自带关闭
        }
        catch (Exception ex)
        {
            throw new Exception(ex.Message);
        }
    }
}

这是一个最基础的实现,刚接触代码时,为了简单粗暴实现目标,通常直接在指定的文件夹创建一个随机名称的文件,然后写入信息,返回Stream。上面的代码如果您能跑通,那么可以恭喜了!咱们已经实现了一个基础的web文件下载接口,能下载得到一个txt文本文件,里面写着query取值的文本。

这里有几个需要注意的点:

  • FileStream 不能加using,因为using将导致fs对象在方法结束时立刻触发Dispose()导致流关闭,但此时用户端请求的流还未开始执行传输;
  • StreamWriter 必须加参数leveOpen:true, 否则流会随着writer对象提前关闭,导致后续传输出错,如果不想用这个参数也可以把leveOpen:trueusing都去掉,加上writer.Flush(),托管对象自动回收;
  • fs.Position=0必须有,因为writer.Write()执行完已将流的位置Position写到了末尾,如果不重置,那么返回的将是一个空的Stream
  • `Return Results.File(),ASP.NET Core 框架在文件传输完成后,会自动关闭流,无需我们手动处理。

这个实现有个明显问题:方法执行后,临时文件会一直留在磁盘上,随着调用次数增加,会造成大量垃圾文件, 得定期手动清理。那么,如何避免临时文件残留呢?

三 避免临时文件残留 lv.2 (优化进阶)

当确认临时文件不再需要时,我们应在操作完成后立即删除它。对于文件下载场景,难点在于必须保证文件内容已成功传输给用户后,才能删除文件。即:在文件传输完成之前,数据流不能被破坏。

3.1 思路A 使用FileShare.Delete

这种方法利用了操作系统的一个特性:允许在文件仍被打开时将其标记为删除。具体做法是,在创建文件流时,设置其共享模式为"允许删除"。这样,我们就可以立即调用删除命令。此时,文件并不会立刻从磁盘上消失,而是会等到最后一个打开它的程序(即我们的下载进程)关闭文件流后,才被系统真正清理。

具体操作:将FileStream的FileShare属性设为Delete,然后在后续操作中直接用File.Delete()删除掉文件即可,代码如下:

csharp 复制代码
      // 前面的其他代码不变,省略
      // codes ...

            // 由于文件可被即时删除了,所以路径的指定不再重要
            // 可以方便的用系统自带方法直接生成一个空白的临时文件,并返回该文件路径
            var tmppath = Path.GetTempFileName()

            FileStream fs = new FileStream(tmpPath, 
            FileMode.OpenOrCreate, FileAccess.ReadWrite,
            FileShare.Delete); // 增加标志位参数,可供其他进程删除

            // writer 的代码,和之前一致, 省略
            // codes ...

            System.IO.File.Delete(tmppath); // 直接删除
            return Results.File(fs, "application/octet-stream", "sample.txt");

      // 后面面的其他代码不变,省略
      // codes ...

可能一开始会觉得奇怪,为什么文件删除都执行了,还能继续读取数据? 这是因为 Windows 和 .NET 中,文件删除是一个"延迟删除"操作。也就是说:当你用 FileShare.Delete 打开一个文件流时,其他进程(或同一进程)可以"标记"该文件为删除。但实际上,文件并不会立即从磁盘上消失,直到最后一个打开该文件的句柄被关闭。所以,只要你还持有文件流(FileStream)未关闭,你就可以继续读取数据,即使文件已经被"删除"。

3.2 思路B 使用MemoryStream

如果待下载的数据量不大,使用内存流是更简单、更高效的方案。内存流将数据完全保存在内存中,不再涉及磁盘I/O操作。当下载完成、流被关闭后,所占用的内存会被垃圾回收器自动释放,从根本上杜绝了文件残留的问题。这是一种非常干净利落的解决方案,特别适合生成小型报表、文本内容或图片等场景。

csharp 复制代码
 [HttpGet("DownloadFileMemory")]
 public IResult DownloadFileMemory(string query)
 {
     try
     {                
         MemoryStream ms = new MemoryStream();
         using (StreamWriter writer = new StreamWriter(ms, leaveOpen: true))
         {
            writer.Write(query);
         }
         ms.Position = 0;
         return Results.File(ms, "application/octet-stream", "sample.txt");

     }
     catch (Exception ex)
     {
         throw new Exception(ex.Message);
     }
 }

四 走向优雅 lv.3 (设计一个通用方案)

虽然上述两种优化方案已经能解决特定问题,但在复杂的实际项目中,我们可能需要一个更统一、更强大的解决方案。例如:

  • 流来源多样:数据可能来自磁盘文件、内存流,甚至是非托管内存,流本身自带的信息甚少。
  • 生命周期管理复杂:某些流需要缓存复用,某些则需要立即销毁。
  • 规避潜在风险FileShare.Delete 模式可能使文件在预期之外被删除,增加调试难度。

因此,一个理想的设计是创建一个通用的 TempDataStream 类。这个类旨在:
统一接口:无论底层是文件流还是内存流,对使用者来说都是同一个流类型。
自动化管理:在流关闭时,能根据预设策略自动执行清理工作(如删除临时文件、释放非托管内存等)。
灵活可控:允许明确指定某个临时流是否需要被销毁。

我们可以通过封装(Decorator Pattern)来实现它。让 TempDataStream 类继承 Stream 基类,并在内部持有一个真正的流实例(如 FileStreamMemoryStream)。TempDataStream 重写所有流操作方法(如 Read, Write, Seek 等),将其转发给内部持有的流实例。最关键的是,在其 Dispose 方法中,除了关闭内部流,还执行我们自定义的清理逻辑。

csharp 复制代码
    public class DownloadController:Controller
    {
        [HttpGet("DownloadFile")]
        public IResult DownloadFile()
        {
            TempDataStream tempStream = new TempDataStream(destoryOnDispose:true);
            tempStream.Write([1, 2, 3, 3]);
            return Results.File(tempStream, "application/octet-stream", "sample.txt");                   
        }
    }

还希望他能兼容其他类型的流,作为统一的临时数据对象:

csharp 复制代码
public Stream GetStream()
{
    Stream stream;  // 定义包装的内部Stream
    if(data.lengthM<1024){
      // 使用 MemoryStream 暂存到内存
      // stream=...
    }else{
      // stream 使用 FileStream 暂存到文件
      // stream=...
    }
    //else{
    // 非托管内存的stream
    //  stream=...    
    //}
    return new TempDataStream(stream,destoryOnDispose:true);
}

假如忘记Dispose,他应该也可以自动按设定来关闭或销毁处理这个临时的流。

五 TempDataStream的实现

主要实现其实很简单,直接重写一个TempDataStream类继承Stream,然后内部嵌套一个来自外部的Stream,通过控制TempDataStream类生命周期的行为来实现对外部Stream的处理即可。

相关初步的代码实现如下:

csharp 复制代码
 public class TempDataStream : Stream
 {
     private Stream _stream;  // 引入外部的Stream
     public string FilePath { get; }  // 存储一个路径方便执行文件删除
     private readonly bool destoryOnDispose = true; // 判定要不要在关闭时进行摧毁

    // 包装引入外部`Stream`
     public TempDataStream(Stream stream, string filePath,bool destoryOnDispose = true)
     {
         this.destoryOnDispose = destoryOnDispose;
         FilePath = filePath;
         this._stream = stream;
     }

    // 自动创建新的临时文件
     public TempDataStream(bool destoryOnDispose = true)
     {
         this.destoryOnDispose = destoryOnDispose;
         FilePath = Path.GetTempFileName();
         _stream = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read );  //可选配 | FileShare.Delete
     }

    // 指定磁盘上的某一个文件作为流读入
     public TempDataStream(string filePath, bool destoryOnDispose = true)
     {
         this.destoryOnDispose = destoryOnDispose;
         FilePath = filePath;
         _stream = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); //可选配 | FileShare.Delete
     }

     public override void Flush()
     {
         _stream.Flush();
     }

     public override int Read(byte[] buffer, int offset, int count)
     {
         return _stream.Read(buffer, offset, count);
     }

     public override long Seek(long offset, SeekOrigin origin)
     {
         return _stream.Seek(offset, origin);
     }

     public override void SetLength(long value)
     {
         _stream.SetLength(value);
     }

     public override void Write(byte[] buffer, int offset, int count)
     {
         _stream.Write(buffer, offset, count);
     }

     public override bool CanRead => _stream.CanRead;

     public override bool CanSeek => _stream.CanSeek;

     public override bool CanWrite => _stream.CanWrite;

     public override long Length => _stream.Length;

     public override long Position { get => _stream.Position; set => _stream.Position = value; }


     private bool disposedValue;

     public override void Close()
     {
         Console.WriteLine("Closedl ");
         Dispose(true);
     }

     [MethodImpl(MethodImplOptions.Synchronized)]
     protected override void Dispose(bool disposing)
     {

         if (!disposedValue)
         {
             if (disposing)
             {
             }

             // 全部作为非托管对象释放,重复执行也无所谓
             _stream?.Dispose();

             if (!string.IsNullOrEmpty(FilePath))
             {
                 try
                 {
                     if (destoryOnDispose && File.Exists(FilePath))
                     {
                         File.Delete(FilePath);
                     }
                 }
                 finally
                 {
                     _stream = null;
                 }
             }

             disposedValue = true;
         }
     }

     ~TempDataStream()
     {
         Dispose(false);
     }
 }

六、最后

感谢您的耐心阅读,希望各位从零开始的新朋友和老朋友有所收获!如果你对这篇文章的内容有任何建议或想法,欢迎随时交流!所有实现的代码以在上述章节完整提供。如果你觉得有用,欢迎去浏览一些本公众号的其他其他项目,点个 Star ⭐️支持一下! https://github.com/LdotJdot

P.S. 虽然现在AI是强大的工具,但那种为一个方案苦思冥想、最终灵光一现的顿悟感,以及亲手将代码调试成功的巨大成就感,是任何提示词都无法直接给予的。这恰恰是编程中最迷人的部分,是真正属于我们自己的成长。希望大家能享受不断实践、深入原理的过程,那才是通往前方之路的坚实阶梯。

欢迎关注公众号"萤火初芒",更多分享等你来看: