Xử lý tệp tạm thời chuyên nghiệp trong C#: Trường hợp thực tế với chức năng tải file ASP.NET

Trong phát triển phần mềm, chúng ta thường xuyên phải làm việc với tệp tạm thời, ví dụ như:

  • Thay thế an toàn các tệp lớn: Ghi nội dung vào tệp tạm thời trước, sau đó mới thay thế tệp mục tiêu để tránh hỏng dữ liệu khi quá trình ghi bị ngắt giữa chừng.
  • Truyền dữ liệu giữa các tiến trình: Tệp tạm thời đóng vai trò trung gian, cho phép trao đổi dữ liệu giữa các tiến trình khác nhau.
  • Tải file web: Ghi dữ liệu được tạo động vào tệp tạm thời và cung cấp cho người dùng tải về.

Bài viết này sẽ lấy ví dụ về tính năng tải file trong ASP.NET Core để hướng dẫn bạn từng bước xây dựng giải pháp xử lý tệp tạm thời một cách chuyên nghiệp hơn.

Một, Khái niệm cốt lõi: Stream (Dòng dữ liệu)

Thao tác với tệp không thể tách rời khỏi khái niệm Stream. Bạn có thể hình dung Stream giống như một ống nước, dữ liệu giống như dòng nước chảy từ đầu này sang đầu kia.

Stream là cách mà C# sử dụng để xử lý dữ liệu. Nó giống như một đường ống, dữ liệu có thể di chuyển qua đường ống này. Bạn có thể đọc dữ liệu từ một nơi (ví dụ như tệp trên đĩa cứng) hoặc ghi dữ liệu đến một nơi khác (ví dụ như bộ nhớ hoặc mạng).

Lợi ích của việc sử dụng Stream là nó cung cấp cách tiếp cận thống nhất để xử lý truyền dữ liệu dạng khác nhau. Trong quá trình này, dữ liệu liên tục chảy như nước trong ống nước, dữ liệu đã xử lý có thể loại bỏ, tiếp tục nhận dữ liệu phía sau, như vậy có thể thực hiện truyền thông tin tệp lớn trên đĩa, giải quyết vấn đề chiếm dụng bộ nhớ khi tải một lần.

Cài đặt cơ bản Lv.1 (Tham khảo cho người mới bắt đầu)

Sau khi hiểu khái niệm cơ bản về dòng dữ liệu, hãy xem xét đoạn mã cơ sở trong ASP.NET Core để thực hiện giao diện tải file. Đây là cách thực hiện trực tiếp nhất. Để nhanh chóng đạt được mục tiêu, chúng ta tạo một tệp tạm thời với tên ngẫu nhiên trong thư mục chỉ định, ghi dữ liệu vào đó, sau đó trả về luồng tệp để tải về.

Nếu đoạn mã này chạy thành công, chúc mừng bạn! Bạn đã thực hiện được một giao diện tải file web cơ bản. Người dùng sẽ nhận được một tệp văn bản với nội dung là giá trị của tham số truy vấn.

// Cài đặt controller đơn giản
public class FileDownloadController : Controller
{
    string storagePath = "c:\\temp";
   
    // Triển khai GET đơn giản với tham số
    [HttpGet("FetchFileMemory")]
    public IActionResult FetchFileMemory(string parameter)
    {
        try
        {                
            // Tạo luồng tệp mới, có thể ghi (ghi dữ liệu tệp), có thể đọc (đọc dữ liệu cuối cùng để người dùng tải về)
            FileStream fileStream = new FileStream(storagePath + "\\" + Guid.NewGuid().ToString(), FileMode.CreateNew, FileAccess.ReadWrite);

            // Đối tượng ghi luồng đơn giản, ví dụ ghi dưới dạng văn bản
            using (StreamWriter outputWriter = new StreamWriter(fileStream, leaveOpen: true))
            {
                outputWriter.Write(parameter);
            }
            fileStream.Position = 0;

            // Trả về tệp để tải về và đặt lại tên tệp thành "output.txt"
            return File(fileStream, "application/octet-stream", "output.txt");  // Luồng tự động đóng
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException(ex.Message);
        }
    }
}

Đây là triển khai cơ bản nhất, khi mới tiếp xúc với mã, để đơn giản hóa việc đạt được mục tiêu, thường trực tiếp tạo một tệp với tên ngẫu nhiên trong thư mục chỉ định, sau đó ghi thông tin, trả về Stream. Nếu bạn có thể chạy thành công mã ở trên, chúc mừng! Chúng ta đã thực hiện được một giao diện tải file web cơ bản, có thể tải về một tệp txt với nội dung là văn bản của giá trị parameter.

Có vài điểm cần lưu ý:

  • FileStream không thể thêm using, vì using sẽ khiến đối tượng fileStream kích hoạt Dispose() ngay khi phương thức kết thúc khiến luồng bị đóng, nhưng lúc này luồng yêu cầu của máy khách chưa bắt đầu truyền;
  • StreamWriter phải thêm tham số leaveOpen:true, nếu không luồng sẽ bị đóng sớm do đối tượng outputWriter, gây lỗi truyền sau này, nếu không muốn dùng tham số này cũng có thể bỏ cả leaveOpen:trueusing, thêm outputWriter.Flush(), đối tượng được thu hồi tự động;
  • fileStream.Position=0 phải có, vì sau khi outputWriter.Write() thực thi đã ghi vị trí Position của luồng đến cuối, nếu không đặt lại, thì sẽ trả về một Stream trống.
  • Return File(), khung ASP.NET Core sẽ tự động đóng luồng sau khi truyền tệp hoàn tất, không cần xử lý thủ công.

Cài đặt này có vấn đề rõ ràng: Sau khi phương thức thực thi xong, tệp tạm thời sẽ vẫn còn trên đĩa, theo số lần gọi tăng lên, sẽ tạo ra lượng lớn tệp rác, cần dọn dẹp thủ công định kỳ. Vậy làm thế nào để tránh dư thừa tệp tạm thời?

Hai, Ngăn chặn dư thừa tệp tạm thời Lv.2 (Tối ưu nâng cao)

Khi xác nhận tệp tạm thời không còn cần thiết, chúng ta nên xóa ngay sau khi thao tác hoàn tất. Với trường hợp tải tệp, khó khăn nằm ở việc phải đảm bảo nội dung tệp đã truyền thành công cho người dùng trước khi xóa tệp. Nghĩa là: trước khi truyền tệp hoàn tất, luồng dữ liệu không được phá vỡ.

3.1 Hướng tiếp cận A: Sử dụng FileShare.Delete

Phương pháp này tận dụng tính năng của hệ điều hành: cho phép đánh dấu xóa khi tệp vẫn đang mở. Cách cụ thể là: khi tạo luồng tệp, thiết lập chế độ chia sẻ của nó thành "cho phép xóa". Như vậy, chúng ta có thể gọi lệnh xóa ngay lập tức. Lúc này, tệp sẽ không biến mất khỏi đĩa ngay, mà sẽ đợi đến khi chương trình cuối cùng mở nó (tức quá trình tải của chúng ta) đóng luồng tệp, sau đó hệ thống mới dọn sạch thực sự.

Thao tác cụ thể: Thiết lập thuộc tính FileShare của FileStream thành Delete, sau đó trực tiếp dùng File.Delete() để xóa tệp, mã như sau:

      // Các mã khác ở đầu không đổi, bỏ qua
      // mã khác...

            // Vì tệp có thể bị xóa ngay lập tức, nên việc chỉ định đường dẫn không còn quan trọng
            // Có thể tiện lợi dùng phương pháp hệ thống tích hợp trực tiếp tạo một tệp tạm trống và trả về đường dẫn tệp đó
            var temporaryPath = Path.GetTempFileName();

            FileStream fileStream = new FileStream(temporaryPath, 
            FileMode.OpenOrCreate, FileAccess.ReadWrite,
            FileShare.Delete); // Thêm tham số cờ hiệu, cho phép tiến trình khác xóa

            // Mã của writer, giống như trước đây, bỏ qua
            // mã khác...

            System.IO.File.Delete(temporaryPath); // Xóa trực tiếp
            return File(fileStream, "application/octet-stream", "output.txt");

      // Các mã khác ở cuối không đổi, bỏ qua
      // mã khác...

Lúc đầu có thể thấy kỳ lạ, tại sao lệnh xóa tệp đã thực thi mà vẫn có thể tiếp tục đọc dữ liệu? Đó là vì trong Windows và .NET, xóa tệp là thao tác "xóa trì hoãn". Nghĩa là: khi bạn mở luồng tệp với FileShare.Delete, các tiến trình khác (hoặc cùng tiến trình) có thể "đánh dấu" tệp để xóa. Nhưng thực tế, tệp sẽ không biến mất khỏi đĩa ngay, cho đến khi tay cầm cuối cùng mở tệp được đóng. Vì vậy, miễn là bạn vẫn giữ luồng tệp (FileStream) chưa đóng, bạn vẫn có thể tiếp tục đọc dữ liệu, ngay cả khi tệp đã bị "xóa".

3.2 Hướng tiếp cận B: Sử dụng MemoryStream

Nếu lượng dữ liệu cần tải không lớn, sử dụng luồng bộ nhớ là giải pháp đơn giản và hiệu quả hơn. Luồng bộ nhớ lưu trữ dữ liệu hoàn toàn trong RAM, không liên quan đến thao tác I/O đĩa. Khi tải hoàn tất và luồng bị đóng, bộ nhớ đã chiếm dụng sẽ được trình thu gom rác tự động giải phóng, triệt để loại bỏ vấn đề dư thừa tệp. Đây là giải pháp rất gọn gàng, đặc biệt phù hợp với các trường hợp tạo báo cáo nhỏ, nội dung văn bản hoặc hình ảnh.

[HttpGet("FetchFileMemory")]
public IActionResult FetchFileMemory(string parameter)
{
    try
    {                
        MemoryStream memoryStream = new MemoryStream();
        using (StreamWriter outputWriter = new StreamWriter(memoryStream, leaveOpen: true))
        {
           outputWriter.Write(parameter);
        }
        memoryStream.Position = 0;
        return File(memoryStream, "application/octet-stream", "output.txt");
    }
    catch (Exception ex)
    {
        throw new InvalidOperationException(ex.Message);
    }
}

Ba, Hướng đến sự chuyên nghiệp Lv.3 (Thiết kế giải pháp tổng quát)

Mặc dù hai giải pháp tối ưu nói trên đã có thể giải quyết vấn đề cụ thể, nhưng trong các dự án thực tế phức tạp, chúng ta có thể cần giải pháp thống nhất và mạnh mẽ hơn. Ví dụ:

  • Nguồn luồng đa dạng: Dữ liệu có thể đến từ tệp đĩa, luồng bộ nhớ, thậm chí là bộ nhớ không quản lý, luồng bản thân chứa ít thông tin.
  • Quản lý vòng đời phức tạp: Một số luồng cần cache tái sử dụng, một số cần hủy ngay lập tức.
  • Ngừa rủi ro tiềm ẩn: Chế độ FileShare.Delete có thể khiến tệp bị xóa ngoài dự kiến, tăng độ khó gỡ lỗi.

Vì vậy, thiết kế lý tưởng là tạo một lớp TemporaryDataStream tổng quát. Lớp này nhằm mục đích:

Giao diện thống nhất: Cho dù là luồng tệp hay luồng bộ nhớ, người dùng đều thấy cùng một loại luồng.

Quản lý tự động: Khi luồng đóng, có thể tự động thực hiện công việc dọn dẹp theo chiến lược đã định trước (như xóa tệp tạm, giải phóng bộ nhớ không quản lý).

Linh hoạt kiểm soát: Cho phép chỉ định rõ ràng luồng tạm thời nào cần hủy.

Chúng ta có thể thực hiện thông qua bao bọc (Decorator Pattern). Cho lớp TemporaryDataStream kế thừa lớp cơ sở Stream, và bên trong giữ một thể hiện luồng thực sự (như FileStream hoặc MemoryStream). TemporaryDataStream ghi đè tất cả phương pháp thao tác luồng (như Read, Write, Seek), chuyển tiếp đến thể hiện luồng nội bộ. Quan trọng nhất là trong phương pháp Dispose của nó, ngoài đóng luồng nội bộ, còn thực hiện logic dọn dẹp tùy chỉnh của chúng ta.

    public class FileDownloadController : Controller
    {
        [HttpGet("DownloadFile")]
        public IActionResult DownloadFile()
        {
            TemporaryDataStream tempStream = new TemporaryDataStream(destroyOnDispose: true);
            tempStream.Write(new byte[] { 4, 5, 6, 7 });
            return File(tempStream, "application/octet-stream", "output.txt");                   
        }
    }

Còn hy vọng nó có thể tương thích với các loại luồng khác, làm đối tượng dữ liệu tạm thời thống nhất:

public Stream CreateStream()
{
    Stream stream;  // Định nghĩa Stream nội bộ được bao bọc
    if(dataSize < 1024){
      // Sử dụng MemoryStream lưu trữ tạm thời vào bộ nhớ
      // stream=...
    }else{
      // stream Sử dụng FileStream lưu trữ tạm thời vào tệp
      // stream=...
    }
    //else{
    // Stream bộ nhớ không quản lý
    //  stream=...    
    //}
    return new TemporaryDataStream(stream, destroyOnDispose: true);
}

Nếu quên Dispose, nó cũng nên tự động đóng hoặc hủy xử lý luồng tạm thời theo thiết lập.

Bốn, Triển khai TemporaryDataStream

Triển khai chính thực ra rất đơn giản, trực tiếp ghi đè lớp TemporaryDataStream kế thừa Stream, sau đó lồng ghép một Stream từ bên ngoài, thông qua kiểm soát hành vi vòng đời lớp TemporaryDataStream để thực hiện xử lý Stream bên ngoài.

Mã triển khai ban đầu như sau:

public class TemporaryDataStream : Stream
{
    private Stream _innerStream;  // Giới thiệu Stream bên ngoài
    public string FilePath { get; }  // Lưu trữ đường dẫn thuận tiện thực hiện xóa tệp
    private readonly bool destroyOnDispose = true; // Xác định có hủy khi đóng hay không

   // Bao bọc giới thiệu Stream bên ngoài
    public TemporaryDataStream(Stream stream, string filePath, bool destroyOnDispose = true)
    {
        this.destroyOnDispose = destroyOnDispose;
        FilePath = filePath;
        this._innerStream = stream;
    }

   // Tự động tạo tệp tạm mới
    public TemporaryDataStream(bool destroyOnDispose = true)
    {
        this.destroyOnDispose = destroyOnDispose;
        FilePath = Path.GetTempFileName();
        _innerStream = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);  // Có thể chọn thêm | FileShare.Delete
    }

   // Chỉ định một tệp trên đĩa làm luồng đọc vào
    public TemporaryDataStream(string filePath, bool destroyOnDispose = true)
    {
        this.destroyOnDispose = destroyOnDispose;
        FilePath = filePath;
        _innerStream = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); // Có thể chọn thêm | FileShare.Delete
    }

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

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

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

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

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

    public override bool CanRead => _innerStream.CanRead;

    public override bool CanSeek => _innerStream.CanSeek;

    public override bool CanWrite => _innerStream.CanWrite;

    public override long Length => _innerStream.Length;

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


    private bool disposedValue;

    public override void Close()
    {
        Console.WriteLine("Đã đóng");
        Dispose(true);
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    protected override void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
            }

            // Tất cả được giải phóng như đối tượng không quản lý, thực hiện lại cũng không sao
            _innerStream?.Dispose();

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

            disposedValue = true;
        }
    }

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

Thẻ: csharp aspnet-core file-handling streams temporary-files

Đăng vào ngày 17 tháng 7 lúc 21:08