1. 从零开始:为什么C#操作TXT文件是基本功
如果你刚开始接触C#,或者从其他语言转过来,可能会觉得操作一个简单的TXT文件没什么技术含量。不就是读点字、写点字吗?但恰恰是这种看似基础的操作,构成了无数复杂应用的基石。无论是读取配置文件、记录程序运行日志、处理用户上传的文本数据,还是作为数据交换的中间格式,TXT文件的身影无处不在。我见过不少项目,前期为了追求“高大上”,把所有配置都塞进数据库,结果部署和维护成本陡增,后来还是老老实实换回了TXT或JSON配置文件。所以,熟练掌握C#对TXT文件的增删改查,不是“会不会”的问题,而是“熟不熟”、“坑踩得够不够”的问题。
今天,我们就抛开那些花哨的框架和库,回归到System.IO这个命名空间,把文件流(Stream)那点事彻底聊透。你会发现,即便是简单的File.ReadAllText,背后也藏着编码、异常处理和性能的考量。我们不仅要会调用API,更要明白在什么场景下该用哪个API,以及为什么。比如,处理一个10GB的日志文件,你还敢用ReadAllText吗?答案显然是否定的。这就是基本功的价值:它让你在面临具体问题时,能做出最合理、最稳健的技术选型。
2. 核心武器库:System.IO 下的几员大将
在C#中,所有文件操作的核心都在System.IO命名空间里。对于TXT文件,我们主要跟几个类打交道:File,FileStream,StreamReader,StreamWriter。别被它们吓到,我们可以把它们想象成不同工种的工具。
File类:这是你的瑞士军刀,提供了大量静态方法,用于一次性完成常见操作。它的特点是“简单粗暴”,适合处理小文件或不需要精细控制的场景。比如File.ReadAllText,一句话就把整个文件内容读成一个字符串;File.WriteAllText,一句话就把一个字符串覆盖写入文件。它帮你封装了打开、读写、关闭流的全过程,你无需关心底层细节。但成也萧何败也萧何,这种便利性是以牺牲灵活性和对大文件的支持为代价的。
FileStream类:这是底层通道,代表一个指向文件的字节流。你可以把它想象成连接程序和硬盘上那个文件的一条水管。FileStream本身主要操作字节数组(byte[]),对于文本文件,直接用它有点费力,因为我们更习惯操作字符串。所以,我们通常会给这根“水管”装上“翻译器”。
StreamReader和StreamWriter类:它们就是装在FileStream上的“翻译器”。StreamReader负责把字节流翻译成我们能看懂的字符串(解码),StreamWriter则负责把字符串翻译成字节流写入文件(编码)。它们提供了按行读取(ReadLine)、按字符读取、写入字符串等非常方便的方法,是我们处理文本文件最得力的助手。
它们之间的关系通常是这样的:你需要先打开一个FileStream(水管),然后用这个FileStream创建一个StreamReader或StreamWriter(装上翻译器),最后通过翻译器来读写文本。当然,StreamReader和StreamWriter的构造函数也可以直接接收文件路径,它们会在内部帮你创建FileStream,这是更常用的方式。
选择哪一套工具,取决于你的需求:
- 需求明确,文件很小(<10MB):直接用
File类的静态方法,代码最简洁。 - 需要逐行处理,尤其是大文件:使用
StreamReader和StreamWriter,可以避免一次性加载全部内容到内存。 - 需要更底层的控制(如文件共享模式、缓冲区大小)或处理二进制数据:使用
FileStream,必要时再套上StreamReader/Writer。
3. 实战演练:增、删、改的经典场景与代码实现
光说不练假把式,我们直接上代码,看看如何用C#实现TXT文件的增、删、改。我会为每个操作提供至少两种实现方式,并解释其中的优劣。
3.1 “增”:如何向文件追加内容
“增”操作最常见的就是在文件末尾追加日志或记录。
方法一:使用File.AppendAllText(最简单)
string logEntry = $"{DateTime.Now}: 用户登录成功。\n"; string filePath = @"C:\Logs\app.log"; // 如果文件不存在,会自动创建;如果存在,则在末尾追加。 File.AppendAllText(filePath, logEntry);注意:
AppendAllText方法会打开文件、写入内容、然后关闭文件。如果你在循环中高频调用此方法(比如每秒写入几百条日志),频繁的打开关闭操作会成为性能瓶颈。此时应考虑方法二。
方法二:使用StreamWriter并指定追加模式(更灵活、高效)
string filePath = @"C:\Logs\app.log"; // using语句确保即使发生异常,文件流也会被正确关闭和释放资源。 using (StreamWriter sw = new StreamWriter(filePath, true)) // 第二个参数为true表示追加 { for (int i = 0; i < 1000; i++) { sw.WriteLine($"这是第{i}条日志,时间:{DateTime.Now:HH:mm:ss.fff}"); // 在高频写入时,可以配合AutoFlush或定时Flush,但需权衡性能和数据安全性。 } // 循环结束后,using块结束时会自动调用sw.Flush()和sw.Close()。 }关键点解析:
new StreamWriter(filePath, true)中的true是精髓,它指示写入器从文件末尾开始写(Append)。- 使用
using语句是必须养成的好习惯。它等价于try-finally块,能确保非托管资源(如文件句柄)被及时释放,避免资源泄漏。 - 对于日志场景,方法二在循环中只打开一次文件,性能远优于方法一。
3.2 “删”:删除文件或删除文件中的特定内容
“删”分为两个层面:删除整个文件,或删除文件中的部分内容。
删除整个文件
string filePath = @"C:\Temp\to_be_deleted.txt"; if (File.Exists(filePath)) { File.Delete(filePath); Console.WriteLine("文件已删除。"); } else { Console.WriteLine("文件不存在。"); }踩坑提醒:直接调用
File.Delete,如果文件不存在,会抛出FileNotFoundException。所以先使用File.Exists判断是一个好习惯。但这里有个“竞态条件”的坑:可能在Exists检查之后、Delete执行之前,文件被其他程序删除或创建。对于高并发场景,更稳健的做法是直接try-catchFile.Delete可能抛出的异常。
删除文件中的特定行(例如,删除包含某个关键词的行)TXT文件本质上是连续的字节流,无法直接“删除中间一段”。标准的做法是:读取原文件,在内存中过滤掉不需要的内容,然后将结果写回一个新文件或覆盖原文件。
string inputFilePath = @"C:\Data\source.txt"; string tempFilePath = Path.GetTempFileName(); // 创建一个临时文件 try { using (StreamReader reader = new StreamReader(inputFilePath)) using (StreamWriter writer = new StreamWriter(tempFilePath)) { string line; while ((line = reader.ReadLine()) != null) { // 如果这一行不包含“DELETE_ME”这个关键词,就把它写入新文件 if (!line.Contains("DELETE_ME")) { writer.WriteLine(line); } } } // 删除原文件,将临时文件重命名为原文件名 File.Delete(inputFilePath); File.Move(tempFilePath, inputFilePath); Console.WriteLine("指定行已删除。"); } catch (Exception ex) { Console.WriteLine($"操作失败: {ex.Message}"); // 清理临时文件 if (File.Exists(tempFilePath)) { File.Delete(tempFilePath); } }关键点解析:
- 使用
Path.GetTempFileName()获取一个唯一的临时文件路径,避免文件名冲突。 - 采用
try-catch包裹核心操作,并在异常时清理临时文件,保证程序的健壮性。 - 这是“读取-处理-写入”模式的典型应用。对于超大文件,此方法会占用较多内存(因为
ReadLine循环本身是流式的,但写入需要另一个文件)。如果内存极其紧张,可以考虑更复杂的“就地修改”方案,但那通常涉及底层字节操作,复杂且易错,非必要不推荐。
3.3 “改”:修改文件中的内容
“改”操作是“删”和“增”的结合,也是最复杂的。同样,我们需要读取全部内容,修改后写回。
场景一:全局替换文本
string filePath = @"C:\Config\settings.txt"; string oldText = "Server=localhost;"; string newText = "Server=prod.db.com;"; string content = File.ReadAllText(filePath); content = content.Replace(oldText, newText); File.WriteAllText(filePath, content);这种方法适用于小配置文件,简单直接。但ReadAllText和WriteAllText都会覆盖整个文件。
场景二:修改特定行的内容假设我们要修改文件第3行的内容(行号从1开始)。
string filePath = @"C:\Data\list.txt"; int lineNumberToEdit = 3; // 要修改的行号 string newLineContent = "这是修改后的第三行内容"; var lines = File.ReadAllLines(filePath); // 读取所有行到数组 if (lineNumberToEdit > 0 && lineNumberToEdit <= lines.Length) { lines[lineNumberToEdit - 1] = newLineContent; // 数组索引从0开始 File.WriteAllLines(filePath, lines); // 将数组写回文件 } else { Console.WriteLine("指定的行号无效。"); }关键点解析:
File.ReadAllLines返回一个字符串数组,每个元素是一行。这非常方便进行按行随机访问和修改。- 修改数组元素后,使用
File.WriteAllLines将整个数组写回文件,覆盖原内容。 - 重要缺陷:
ReadAllLines和WriteAllLines同样会将整个文件内容加载到内存(数组)。对于几百MB以上的文件,这会消耗大量内存。此时又需要回到StreamReader和StreamWriter配合临时文件的流式处理模式。
4. 深入原理:编码、异常与性能,一个都不能少
掌握了基本操作,我们得往深处挖一挖,否则迟早会掉进坑里。下面这几个点,是区分“能用”和“用好”的关键。
4.1 字符编码:乱码的万恶之源
你有没有遇到过打开TXT文件全是“锟斤拷”或者“烫烫烫”?这十有八九是编码问题。计算机底层存储的是字节,字符串和字节之间的转换规则就是编码。
常见的编码:
- UTF-8:Web和跨平台应用的事实标准,兼容ASCII,变长编码。强烈推荐作为默认选择。
- ASCII:仅包含128个英文字符,处理中文会出问题。
- UTF-16 (Unicode):在.NET内部字符串使用的编码,每个字符通常占2字节。
- GB2312/GBK:中文Windows系统的默认编码。
如何在C#中指定编码?
// 读取时指定编码 using (StreamReader reader = new StreamReader(filePath, Encoding.UTF8)) { // ... } // 写入时指定编码 using (StreamWriter writer = new StreamWriter(filePath, false, Encoding.UTF8)) // false表示覆盖 { // ... } // 使用File类的方法时指定编码 string content = File.ReadAllText(filePath, Encoding.GetEncoding("GBK")); File.WriteAllText(filePath, content, Encoding.UTF8);最佳实践:
- 明确指定编码:永远不要依赖系统的默认编码。在创建
StreamReader或StreamWriter,或调用File类的方法时,显式传入Encoding.UTF8。 - 保持一致:读取和写入文件应使用同一种编码。
- 处理未知编码:对于来源不明的文件,可以尝试用
StreamReader的自动检测功能(不传编码参数),但不可靠。更专业的做法是使用第三方库(如Utf8Unknown或chardet)来探测编码。
4.2 异常处理:让你的程序更健壮
文件操作是I/O操作,充满了不确定性:文件可能不存在、路径可能无效、磁盘可能已满、文件可能正被其他程序占用……健壮的程序必须处理这些异常。
必须处理的常见异常:
FileNotFoundException:文件不存在。DirectoryNotFoundException:目录不存在。PathTooLongException:路径超长(Windows系统有最大路径限制)。IOException:这是一个大类,包含很多子情况,如磁盘空间不足、文件正在被使用等。UnauthorizedAccessException:没有访问权限。
标准的异常处理模式:
string filePath = @"C:\SomePath\data.txt"; try { using (StreamWriter sw = new StreamWriter(filePath)) { sw.WriteLine("Hello World"); } Console.WriteLine("写入成功。"); } catch (DirectoryNotFoundException ex) { Console.WriteLine($"错误:目录不存在。请检查路径:{filePath}"); // 这里可以尝试创建目录 // Directory.CreateDirectory(Path.GetDirectoryName(filePath)); } catch (IOException ex) when (ex.Message.Contains("正由另一进程使用")) { Console.WriteLine("错误:文件被其他程序锁定,请关闭相关程序后重试。"); } catch (UnauthorizedAccessException ex) { Console.WriteLine("错误:没有写入该文件的权限。"); } catch (Exception ex) // 捕获其他所有未预料到的异常 { Console.WriteLine($"发生未知错误: {ex.Message}"); // 记录日志,便于排查 // Logger.LogError(ex); } finally { // 如果需要,可以在这里执行一些清理工作 // 但using语句已经帮我们关闭了流,所以通常不需要额外操作 }关键点解析:
- 使用多个特定的
catch块,可以提供更精准的错误提示。 catch (IOException ex) when (...)是异常过滤器(C# 6.0+),允许在满足特定条件时才捕获该异常,非常有用。- 最外层的通用
Exception捕获是最后一道防线,防止程序崩溃。 - 在
catch块中,除了给用户提示,记录详细的异常日志(包括堆栈跟踪)对于后期调试至关重要。
4.3 性能优化:处理大文件的正确姿势
当文件大小从KB级增长到GB级,所有“一次性读入内存”的方法都会失效甚至导致程序崩溃。这时,我们必须采用流式处理。
流式读取大文件(逐行处理)
string largeFilePath = @"D:\HugeLogs\server.log"; string searchTerm = "ERROR"; using (StreamReader reader = new StreamReader(largeFilePath)) { string line; long lineNumber = 0; while ((line = reader.ReadLine()) != null) { lineNumber++; if (line.Contains(searchTerm)) { Console.WriteLine($"在行 {lineNumber} 发现错误: {line.Substring(0, Math.Min(50, line.Length))}..."); // 处理找到的行,例如写入另一个文件或进行统计 } } }这种方式的内存占用是常数级别的(主要是一行字符串的大小),无论文件多大,内存使用都保持稳定。
流式读取并写入(过滤大文件)这就是我们在“删”操作中使用的模式。读取源文件流,同时写入目标文件流,内存中只保留当前处理的行。
using (var sourceStream = new FileStream(inputPath, FileMode.Open, FileAccess.Read)) using (var reader = new StreamReader(sourceStream)) using (var destStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write)) using (var writer = new StreamWriter(destStream)) { string line; while ((line = reader.ReadLine()) != null) { if (/* 满足某些条件 */) { writer.WriteLine(line); } } }性能提升技巧:
- 缓冲区大小:
FileStream和StreamReader/Writer内部都有缓冲区。对于顺序读写的大文件,适当增加缓冲区大小可以减少物理磁盘I/O次数。默认缓冲区是4KB,可以尝试设置为16KB或32KB。int bufferSize = 16384; // 16KB using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize)) using (var reader = new StreamReader(fs, Encoding.UTF8, true, bufferSize)) - 异步操作:对于UI程序,使用
ReadLineAsync和WriteLineAsync等异步方法可以避免界面卡死。对于高性能服务端程序,异步I/O也能更好地利用系统资源。using (StreamReader reader = new StreamReader(filePath)) { while (!reader.EndOfStream) { string line = await reader.ReadLineAsync(); // 异步处理这一行 } }
5. 避坑指南:我踩过的那些“坑”
理论讲得再多,不如实际踩一次坑来得深刻。下面分享几个我亲身经历或常见的问题。
坑一:文件被锁定,无法访问这是最最常见的问题。当你用StreamReader或FileStream打开一个文件后,如果没有正确关闭(比如忘了using,或者在异常发生时没有关闭),这个文件句柄就会一直保持打开状态,导致其他进程(甚至同一进程的其他线程)无法访问该文件,尤其是进行写入或删除操作。
症状:执行写入或删除时,抛出IOException: The process cannot access the file '...' because it is being used by another process.
根因与解决:
- 确保使用
using语句:这是最根本的解决方法。using会确保在代码块结束时调用Dispose方法,从而关闭底层流。 - 检查文件共享模式:在创建
FileStream时,可以通过FileShare参数控制其他线程或进程的访问权限。例如,FileShare.Read允许其他进程读取,但禁止写入。如果你需要在写入时允许其他进程读取,可以这样:using (var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read)) using (var writer = new StreamWriter(fs)) { // 写入操作 } - 使用
try-finally手动关闭:如果因为某些原因不能用using,务必在finally块中手动调用Close()或Dispose()。
坑二:路径中的空格和特殊字符路径字符串如果包含空格,在拼接时很容易出错。
string folder = @"C:\My Documents"; string file = "my file.txt"; string badPath = folder + "\\" + file; // 结果是 C:\My Documents\my file.txt,这本身没问题,但拼接方式不优雅。最佳实践:使用Path.Combine方法,它能自动处理路径分隔符和空格。
string goodPath = Path.Combine(folder, file); // 推荐对于包含特殊字符(如<,>,:,",|,?,*)的文件名,Windows是不允许的,在创建文件前需要进行验证或清理。
坑三:文本文件末尾的换行符不同操作系统对换行符的定义不同:Windows是\r\n,Linux/Unix是\n,Mac OS旧版本是\r。StreamReader的ReadLine方法会自动剥离行尾的换行符。而WriteLine方法在写入时,会使用Environment.NewLine(在Windows上是\r\n)作为换行符写入。
这可能导致一个问题:如果你用ReadLine读出一行,修改后,再用WriteLine写回,文件的换行符风格可能会被统一为当前系统的风格。如果对换行符有严格要求(例如版本控制的配置文件),需要注意。
解决方案:如果需要保持原换行符,就不能用ReadLine/WriteLine组合,而应该用Read/Write方法操作原始字符,或者自己解析行尾。但绝大多数情况下,统一为当前系统的换行符是可以接受的。
坑四:文件内容包含BOM(字节顺序标记)UTF-8编码的文件有时会带一个BOM(EF BB BF),它是一个特殊的字节序列,用来标识文件是UTF-8编码。StreamReader默认能识别并跳过BOM。但如果你用FileStream以二进制方式读取文件开头,可能会看到它。
影响:通常无影响。但在某些极端的文本处理或比较场景下,BOM可能会被当作文件内容的一部分,导致意外结果。StreamWriter在指定Encoding.UTF8时,默认会写入BOM。如果你不想要BOM,可以使用new UTF8Encoding(false)来创建编码器。
// 写入不带BOM的UTF-8文件 using (StreamWriter sw = new StreamWriter(filePath, false, new UTF8Encoding(false))) { sw.Write("内容"); }6. 进阶应用:构建一个简单的文本日志器
把上面的知识融会贯通,我们来动手写一个实用的小工具:一个线程安全的、支持按日期滚动的简单文本日志器。这个例子涵盖了文件操作、并发控制、日期处理和路径操作等多个知识点。
using System; using System.IO; using System.Text; using System.Threading; public class SimpleLogger { private readonly string _logDirectory; private readonly string _logFileBaseName; private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); public SimpleLogger(string logDirectory, string appName = "App") { _logDirectory = logDirectory; _logFileBaseName = appName; // 确保日志目录存在 Directory.CreateDirectory(_logDirectory); } public void Log(string message, LogLevel level = LogLevel.INFO) { string logFilePath = GetCurrentLogFilePath(); string logEntry = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; // 使用写锁,确保多线程环境下不会交叉写入 _lock.EnterWriteLock(); try { // 使用追加模式,并指定UTF-8编码 File.AppendAllText(logFilePath, logEntry, Encoding.UTF8); } catch (Exception ex) { // 日志记录本身失败,这是一个严重问题,可以输出到控制台或事件查看器 Console.Error.WriteLine($"无法写入日志文件: {ex.Message}"); } finally { _lock.ExitWriteLock(); } } private string GetCurrentLogFilePath() { // 按日期滚动日志文件,格式如:MyApp_2024-05-17.log string dateStamp = DateTime.Now.ToString("yyyy-MM-dd"); string fileName = $"{_logFileBaseName}_{dateStamp}.log"; return Path.Combine(_logDirectory, fileName); } // 可选:清理过期日志文件的方法 public void CleanOldLogs(int daysToKeep) { _lock.EnterWriteLock(); try { var cutoffDate = DateTime.Now.AddDays(-daysToKeep); foreach (var file in Directory.GetFiles(_logDirectory, $"{_logFileBaseName}_*.log")) { var fileInfo = new FileInfo(file); // 从文件名中解析日期(简单实现,假设格式固定) if (fileInfo.LastWriteTime < cutoffDate) { File.Delete(file); } } } catch (Exception ex) { Log($"清理旧日志失败: {ex.Message}", LogLevel.ERROR); } finally { _lock.ExitWriteLock(); } } } public enum LogLevel { DEBUG, INFO, WARN, ERROR }使用示例:
class Program { static void Main(string[] args) { var logger = new SimpleLogger(@"C:\AppLogs\", "MyApplication"); logger.Log("应用程序启动。"); try { // ... 你的业务逻辑 ... logger.Log("完成了一项重要操作。", LogLevel.INFO); } catch (Exception ex) { logger.Log($"操作发生异常: {ex.Message}", LogLevel.ERROR); } logger.Log("应用程序关闭。"); // 每周调用一次清理 // logger.CleanOldLogs(7); } }这个简单的日志器实现了几个关键特性:
- 线程安全:通过
ReaderWriterLockSlim确保多线程同时写日志不会导致文件内容错乱。 - 按日期滚动:每天生成一个新的日志文件,便于管理和查看。
- 异常处理:日志操作本身也被
try-catch包裹,防止因日志写入失败导致主程序崩溃。 - 资源管理:使用
File.AppendAllText,它内部会妥善处理流的打开和关闭。
当然,这是一个极简的版本。生产环境更推荐使用成熟的日志库,如NLog、Serilog或log4net,它们提供了更丰富的功能(如日志级别过滤、多种输出目标、结构化日志、异步记录等)。但通过自己动手实现一个,你能更深刻地理解文件操作、并发和资源管理这些核心概念是如何结合在一起的。