Menu

Đọc và ghi file trong C#: File, StreamWriter, StreamReader

Cách ghi và đọc tệp trong C#: File.WriteAllText, ReadAllText, AppendAllText, WriteAllLines và ReadAllLines cho cả tệp, StreamWriter và StreamReader cho tệp lớn, đường dẫn với Path.Combine, thư mục, mã hóa ký tự và các ngoại lệ mà code xử lý tệp phải lường trước.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Namespace System.IO có mọi thứ để làm việc với tệp. Với phần lớn tác vụ, chỉ một lời gọi trên lớp static File là đủ: nó mở tệp, đọc hoặc ghi, rồi đóng tệp. Với tệp lớn hoặc khi cần kiểm soát chi tiết, hãy dùng StreamReader và StreamWriter. Thêm using System.IO; vào mọi ví dụ dưới đây.

Ghi và đọc cả tệp

File.WriteAllText tạo một tệp (hoặc ghi đè tệp đã có) với văn bản bạn đưa vào. File.ReadAllText trả về cả tệp dưới dạng một chuỗi. File.AppendAllText ghi thêm vào cuối:

Đầu ra:

True
Buy coffee
Call the dentist
Renew passport
43 characters
Start over
False

Một đường dẫn tương đối như "notes.txt" được tính từ thư mục hiện hành của tiến trình, không phải lúc nào cũng là thư mục chứa chương trình của bạn (một service, một tác vụ theo lịch hay một trình chạy test có thể khởi động ở chỗ khác). Với các tệp đi kèm ứng dụng, hãy dựng đường dẫn từ AppContext.BaseDirectory.

WriteAllText ghi đè mà không hỏi. Nếu mất nội dung cũ là chuyện nghiêm trọng, hãy kiểm tra File.Exists trước, hoặc ghi vào một tệp tạm rồi dùng File.Move để đưa nó vào đúng chỗ.

Theo dòng: WriteAllLines, ReadAllLines và ReadLines

Tệp chứa bản ghi, mỗi bản ghi một dòng, phổ biến đến mức có phương thức riêng. WriteAllLines ghi từng chuỗi kèm một ký tự xuống dòng; ReadAllLines trả về một string[]:

Đầu ra:

4 lines, header: date,product,amount
Total: 45.60
2026-03-03,Stapler,7.00

Khác biệt giữa hai cách đọc quan trọng khi tệp lớn. ReadAllLines đọc cả tệp vào bộ nhớ rồi mới trả về. ReadLines trả về một IEnumerable<string> lười, đọc dần khi bạn duyệt, nên một foreach qua tệp log 5 GB chỉ tốn vài kilobyte. Với LINQ, ReadLines(...).Where(...).Take(10) ngừng đọc ngay khi đã có mười kết quả khớp.

Phân tích CSV bằng Split(',') ổn với những tệp đơn giản do chính bạn tạo ra. CSV ngoài đời thực có các trường đặt trong dấu ngoặc kép chứa dấu phẩy; với chúng, hãy dùng một thư viện như CsvHelper.

Để ý InvariantCulture trong decimal.Parse: không có nó, khi parse "4.50" trên máy đặt ngôn ngữ Đức hoặc Bồ Đào Nha, dấu chấm sẽ bị hiểu là dấu phân cách hàng nghìn.

StreamWriter và StreamReader

Các phương thức của File mở và đóng tệp ở mỗi lần gọi. Để ghi nhiều phần trong một khoảng thời gian, hoặc đọc một tệp quá lớn so với bộ nhớ mà không dùng LINQ, hãy mở stream một lần. Luôn đặt nó trong using để stream được đóng kể cả khi có ngoại lệ; một writer không được đóng có thể không bao giờ xả bộ đệm, và tệp sẽ trống hoặc bị cắt cụt.

Đầu ra:

2: WARN  disk 85% full
4: ERROR connection refused

ReadLine trả về null ở cuối tệp, và đó là thứ kết thúc vòng lặp while. Một StreamWriter đệm đầu ra và ghi xuống đĩa khi bộ đệm đầy, khi bạn gọi Flush() và khi nó bị dispose; khối using đảm bảo trường hợp cuối cùng.

Từ C# 8, một khai báo using sẽ dispose stream ở cuối khối bao quanh, không cần thêm cặp ngoặc nhọn:

using var writer = new StreamWriter(path);   // C# 8: disposed when the method returns
writer.WriteLine("INFO  server started");

Đường dẫn và thư mục

Hãy dựng đường dẫn bằng Path.Combine thay vì nối chuỗi. Nó chèn đúng ký tự phân cách cho hệ điều hành (\ trên Windows, / trên Linux và macOS) và không lặp đôi ký tự đó. Lớp Path cũng tách đường dẫn ra thành từng phần, còn Directory tạo và liệt kê thư mục:

Đầu ra:

february.txt, january.txt, march.txt
march.txt
march
.txt
march.pdf
False

Một cái bẫy: nếu một đối số phía sau của Path.Combine là đường dẫn gốc (bắt đầu bằng / hoặc \, hoặc một ký tự ổ đĩa trên Windows), mọi thứ trước nó đều bị bỏ, nên Path.Combine("reports", "/2026") cho ra /2026. Hãy truyền các phần tương đối, hoặc dùng Path.Join (.NET Core 3.0 trở lên), phương thức này không bao giờ bỏ phần nào.

Directory.GetFiles trả về các tệp theo thứ tự mà hệ thống tệp đưa ra, thứ tự này khác nhau giữa Windows và Linux, nên hãy sắp xếp kết quả khi thứ tự quan trọng. Directory.EnumerateFiles là phiên bản lười, quan hệ giống như ReadLines với ReadAllLines. Truyền SearchOption.AllDirectories để lấy cả thư mục con.

Xử lý lỗi

Thao tác với tệp thất bại vì những lý do nằm ngoài chương trình: tệp không có, thư mục không tồn tại, một tiến trình khác đang mở tệp, ổ đĩa đầy, sai quyền truy cập. Mỗi trường hợp có kiểu ngoại lệ riêng. FileNotFoundException và DirectoryNotFoundException kế thừa từ IOException, nên hãy bắt chúng trước; UnauthorizedAccessException thì không, nên cần một catch riêng:

Đầu ra:

missing file, using defaults
missing folder, using defaults
theme=dark

Gọi File.Exists trước khi đọc trông đơn giản hơn, nhưng không thay được try: tệp có thể biến mất, hoặc bị chương trình khác khóa, ngay giữa lúc kiểm tra và lúc đọc. Hãy dùng Exists để quyết định làm gì, và dùng catch để vượt qua những gì thực sự xảy ra.

Mã hóa ký tự

File.WriteAllText, WriteAllLines và StreamWriter mặc định ghi UTF-8 không có byte order mark, còn các phương thức đọc nhận ra UTF-8, UTF-16 và UTF-32 từ byte order mark nếu có. Hãy truyền một Encoding khi tệp phải ở một định dạng cụ thể:

Đầu ra:

plain.txt   16 bytes, reads back unchanged: True
bom.txt     19 bytes, reads back unchanged: True
utf16.txt   30 bytes, reads back unchanged: True

é và ã mỗi ký tự chiếm hai byte trong UTF-8, vì thế 14 ký tự cần 16 byte, và cả ba tệp đều được đọc lại đúng vì phương thức đọc nhận ra byte order mark. Encoding.UTF8 trông như mặc định nhưng lại thêm byte order mark, thứ mà một số công cụ (trình nhập CSV đời cũ, shell script) hiển thị thành ký tự rác ở đầu dòng đầu tiên; hãy dùng new UTF8Encoding(false) khi cần UTF-8 tường minh không có nó. Đọc tệp sai mã hóa không ném ngoại lệ: kết quả chỉ chứa ký tự thay thế, nên hãy tìm hiểu mã hóa của tệp không do bạn tạo ra.

Đọc ghi tệp bất đồng bộ

Trong ứng dụng web và code giao diện, chặn một luồng để chờ đĩa là lãng phí luồng đó. .NET Core 2.0 trở lên có phiên bản async của các phương thức File:

await File.WriteAllTextAsync("notes.txt", text);
string content = await File.ReadAllTextAsync("notes.txt");
string[] lines = await File.ReadAllLinesAsync("sales.csv");

StreamReader.ReadLineAsync và StreamWriter.WriteLineAsync hoạt động tương tự trên stream.

Lỗi thường gặp

  • Không dispose stream. Không có using, văn bản trong bộ đệm có thể không bao giờ xuống đĩa và tệp bị khóa cho đến khi bộ thu gom rác chạy.
  • Dựng đường dẫn bằng + "\\" +. Cách này hỏng trên Linux và macOS. Hãy dùng Path.Combine.
  • Nạp tệp khổng lồ bằng ReadAllText hoặc ReadAllLines. Hãy đọc dần bằng ReadLines hoặc StreamReader.
  • Dựa vào thư mục hiện hành. Thư mục này phụ thuộc vào cách chương trình được khởi động. Hãy dùng đường dẫn tuyệt đối hoặc AppContext.BaseDirectory.
  • Parse số từ tệp theo culture của máy. Hãy truyền CultureInfo.InvariantCulture cho tệp dữ liệu.
  • Nghĩ rằng File.Exists ngăn được ngoại lệ. Tệp có thể thay đổi giữa lúc kiểm tra và lúc dùng.

Câu hỏi thường gặp

Làm sao ghi văn bản vào tệp trong C#?

File.WriteAllText("notes.txt", text); tạo tệp (hoặc ghi đè nếu tệp đã tồn tại), ghi chuỗi dưới dạng UTF-8 rồi đóng tệp. Dùng File.WriteAllLines(path, lines) cho một tập các dòng và File.AppendAllText(path, text) để ghi thêm vào cuối thay vì thay thế. Cả ba đều nằm trong System.IO.

Làm sao đọc tệp văn bản trong C#?

string text = File.ReadAllText("notes.txt"); đọc cả tệp vào một chuỗi, còn string[] lines = File.ReadAllLines(path); tách nó thành các dòng. Với tệp lớn, hãy dùng File.ReadLines(path) trong một foreach, cách này đọc từng dòng một thay vì nạp toàn bộ, hoặc dùng StreamReader.

Làm sao ghi thêm vào cuối tệp trong C#?

File.AppendAllText(path, text) thêm văn bản vào cuối tệp và tạo tệp nếu chưa có; File.AppendAllLines(path, lines) làm tương tự với các dòng. Với stream, hãy mở ở chế độ ghi thêm: new StreamWriter(path, append: true). Nhớ tự thêm Environment.NewLine hoặc \n khi ghi thêm văn bản.

Làm sao đọc tệp từng dòng một trong C#?

foreach (string line in File.ReadLines(path)) { ... } đọc tệp lần lượt từng dòng, nên bộ nhớ dùng không tăng kể cả với tệp log hàng gigabyte. Cách tương đương kiểu cũ là một StreamReader trong khối using với while ((line = reader.ReadLine()) != null). Tránh ReadAllLines cho tệp lớn: phương thức này nạp mọi dòng vào một mảng trước.

Làm sao kiểm tra tệp có tồn tại trong C#?

File.Exists(path) trả về true nếu tệp tồn tại và chương trình được phép thấy nó, còn Directory.Exists(path) làm tương tự với thư mục. Đừng dựa vào nó để tránh ngoại lệ: tệp có thể bị xóa hoặc bị khóa giữa lúc kiểm tra và lúc đọc, nên vẫn phải xử lý FileNotFoundException và IOException quanh thao tác tệp thực sự.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu