现在,处理文件有更强大的选项:流。流的概念已经存在很长时间了。流是一个用于传输数据的对象,数据可以向两个方向传输:
- 如果数据从外部源传输到程序中,这就是读取流。
- 如果数据从程序传输到外部源中,这就是写入流。
外部源常常是一个文件,但也不完全都是文件。它还可能是:
- 使用一些网络协议读写网络上的数据,其目的是选择数据,或从另一个计算机上发送数据。
- 读写到命名管道上
- 把数据读写到一个内存区域上。
一些流只允许写入,一些流只允许读取,一些流允许随机存取。随机存取允许在流中随机定位游标,例如,从流的开头开始读取,以后移动到流的末尾,再从流的一个中间位置继续读取。
在这些示例中,微软公司提供了一个.NET类System.IO.MemoryStream对象来读写内存,而System.Net.Sockets.NetworkStream对象处理网络数据。Stream类对外部数据源不做任何假定,外部数据源可以是文件流、内存流、网络流或任意数据源。
一些流也可以链接起来。例如,可以使用DeflateStream压缩数据。这个流可以写入FileStream、MemoryStream或NetworkStream。CryptoStream可以加密数据。也可以链接DeflateStream和CryptoStream,在写入FileStream。
使用流时,外部源甚至可以是代码中的一个变量。这听起来很荒缪,但使用流在变量之间传输数据的技术是一个非常有用的技巧,可以在数据类型之间转换数据。C语言使用类似的函数sprintf()在整型和字符串之间转换数据类型,或者格式化字符串。
使用一个独立的对象来传输数据,比使用FileInfo或DirectoryInfo类更好,因为把传输数据的概念与特定数据源分离开来,可以更容易交换数据源。流对象本身包含许多通用代码,可以在外部数据源和代码中的变量之间移动数据,把这些代码与特定数据源的概念分离开来,就更容易实现不同环境下代码的重用。
虽然直接读写流不是那么容易,但可以使用阅读器和写入器。这时另一个关于点分离。 阅读器和写入器可以读写流。例如,StringReader和StringWriter类,与本章后面用于读写文本文件的两个类StreamReader和StreamWriter一样,都是同一继承树的一部分,这些类几乎一定在后台共享许多代码。在System.IO名称空间中,与流相关的类的层次结构如下图所示。
对于文件的读写,最常用的类如下:
- FileStream(文件流)——这个类主要用于在二进制文件中读写二进制数据。
- StreamReader(流读取器)和StreamWriter(流写入器)——这两个类专门用于读写文本格式的流产品API。
- BinaryReader和BinaryWriter——这两个类专门用于读写二进制格式的流产品API。
使用这些类和直接使用底层的流对象之间的区别是,基本流是按照字节来工作的。例如,在保存某个文档时,需要把类型为long的变量的内容写入一个二进制文件中,每个long型变量都占用8个字节,如果使用一般的二进制流,就必须显示地写入内存的8个字节中。
在C#代码中,必须执行一些按位操作,从long值中提取这8个字节。使用BinaryWriter实例,可以把整个操作封装在BinaryWriter.Write()方法的一个重载方法中,该方法的参数是long型,它把8个字节写入流中(如果流指向一个文件,就写入该文件)。对象的BinaryReader.Reader()方法则从流中提取8个字节,恢复long的值。
1. 使用文件流
下面对流进行编程,以读写文件。FileStream实例用于读写文件中的数据。要构造FileStream实例,需要以下4条信息:
- 要访问的文件。
- 表示如何打开文件的模式——例如,新建一个文件或打开一个现有的文件。如果打开一个现有的文件,写入操作是覆盖文件原来的内容,还是追加到文件的末尾?
- 表示访问文件的方式——是只读、只写还是读写?
- 共享访问——表示是否独占访问文件。如果允许其他流同时访问文件,则这些流是只读、只写还是读写文件?
第一条信息通常用一个包含文件的完整路径名的字符串来表示,本章只考虑需要该字符串的那些构造函数。除了这些构造函数外,一些其他的构造函数用本地Wndows句柄来处理文件。其余3条信息分别由3个.NET枚举FileMode、FileAccess和FileShare来表示,这些枚举的值很容易理解,如下表所示。
注意,对于FileMode,如果要求的模式与文件的现有状态不一致,就会抛出一个异常。如果文件不存在,Append、Open和Truncate就会抛出一个异常;如果文件存在,CreateNew就会抛出一个异常。Create和OpenOrCreate可以处理这两种情况,但Create会删除任何现有的文件,新建一个空文件。因为FileAccess和FileShare枚举是按位标志,所以这些值可以与C#的按位OR运算符"|"合并使用。
(1). 创建FileStream
StreamSamples的示例代码使用如下名称空间:
System
System.Collectoins.Generic
System.Globalization
System.IO
System.Linq
System.Text
System.Threading.Tasks
FileStream有很多构造函数。下面的示例使用带4个参数的构造函数:
- 文件名
- FileMode枚举值Open,打开一个已存在的文件
- FileAcdess枚举值Read,读取文件
- FileShare枚举值Read,允许其他程序读取文件,但同时不修改文件
static void ReadFileUsingFileStream(string fileName)
{
const int bufferSize = 4096;
using(var stream = new FileStream(fileName,FileMode.Open,FileAccess.Read,FileShare.Read))
{
ShowStreamInformation(stream);
Encoding encoding = GetEncoding(stream);
}
}
除了使用FileStream类的构造函数来创建FileStream对象之外,还可以直接使用File类的OpenRead方法创建FileStream。OpenRead方法打开一个文件(类似 于FileMode.Open),返回一个可以读取的流(FileAccess.Read),也允许其他进程执行读取访问(FileShare.Read):
using (FileStream stream = File.OpenRead(fileName))
{
}
(2). 获取流信息
Stream类定义了属性CanRead、CanWrite、CanSeek和CanTimeout,可以读取这些属性,得到可以通过流处理的信息。为了读写流,超时值ReadTimeout和WriteTimeout指定超时,以毫秒为单位。设置这些值在网络场景中是很重要的,因为这样可以确保当读写流失败时,用户不需要等待太长时间。Position属性返回光标在流中的当前位置。每次从流中读取一些数据,位置就移动到下一个将读取的字节上。示例代码把流的信息写到控制台上:
static void ShowStreamInformation(Stream stream)
{
Console.WriteLine($"stream can read: {stream.CanRead}, " +
$"can write: {stream.CanWrite}, " +
$"can seek: {stream.CanSeek}, " +
$"can tiemout: {stream.CanTimeout}");
Console.WriteLine($"length: {stream.Length}, position: {stream.Position}");
if (stream.CanTimeout)
{
Console.WriteLine($"read tiemout: {stream.ReadTimeout}, " +
$"write timeout: {stream.WriteTimeout}");
}
}
对已打开的文件流运行这个程序,会得到下面的输出。位置目前为0,因为尚未开始读取:
stream can read: True, can write: False, can seek: True, can tiemout: False
length: 14, position: 0
(3). 分析文本文件的编码
对于文本文件,下一步是读取流中的第一个字节——序言。序言提供了文件如何编码的信息(使用的文本格式)。这也称为字节顺序标记(Byte Order Mark,BOM)。
读取一个流时,利用ReadByte可以从流中只读取一个字节,使用Read()方法可以填充一个字节数组。使用GetEncoding()方法创建一个包含5字节的数组,使用Read()方法填充字节数组。第二个和第三个参数指定字节数组中的偏移量和可用于填充的字节数。Read()方法返回读取的字节数;流可能小于缓冲区。如果没有更多的字符可用于读取,Read()方法就返回0。
示例代码分析流的第一个字符,返回检测到的编码,并把流定位在编码字符后的位置:
static Encoding GetEncoding(Stream stream)
{
if (!stream.CanSeek)
{
throw new ArgumentException("require a stream that can seek");
}
Encoding encoding = Encoding.ASCII;
byte[] bom = new byte[5];
int nRead = stream.Read(buffer: bom, offset: 0, count: 5);
if (bom[0] == 0xff && bom[1] == 0xfe && bom[1] == 0 && bom[3] == 0)
{
Console.WriteLine("UTF-32");
stream.Seek(4, SeekOrigin.Begin);
return Encoding.UTF32;
}
else if (bom[0] == 0xff && bom[1] == 0xfe)
{
Console.WriteLine("UTF-16, little endian");
stream.Seek(2, SeekOrigin.Begin);
return Encoding.Unicode;
}
else if (bom[0] == 0xfe && bom[1] == 0xff)
{
Console.WriteLine("UTF-16, big endian");
stream.Seek(2, SeekOrigin.Begin);
return Encoding.BigEndianUnicode;
}
else if (bom[0] == 0xef && bom[1] == 0xbb && bom[2] == 0xbf)
{
Console.WriteLine("UTF-8");
stream.Seek(2, SeekOrigin.Begin);
return Encoding.UTF8;
}
else
{
stream.Seek(0, SeekOrigin.Begin);
return encoding;
}
}
文件以FF和FE字符开头。这些字节的顺序提供了如何存储文档的信息。两字节的Unicode可以用小或大端字节顺序法储存。FF后跟FE,表示使用小端字节序,而FE后跟FF,就表示使用大端字节序。这个字节顺序可以追溯到IBM的大型机,它使用大端字节序排序。Digital Equipment中的PDP11系统使用小端字节序。通过网络与采用不同字节顺序的计算机通信时,要求改变一端的字节顺序。现在,英特尔CPU体系结构使用小端字节序,ARM架构允许在小端和大端字节顺序之间切换。
这些编码的其他区别是什么?在ASCII中,每一个字符有7位就足够了。ASCII最初基于英语字母表,提供了小写字母、大写字母和控制字符。
扩展的ASCII利用8位,允许切换到特定于语言的字符。切换并不容易,因为它需要关注代码地图,也没有为一些亚洲语言提供足够的字符。UTF-16(Unicode文本格式)解决了这个问题,它为每一种字符使用16位。因为对于以前的字形,UTF-16还不够,所以UTF-32为每一个字符使用32位。虽然WIndows NT 3.1为默认文本编码切换为UTF-16(在以前ASCII的微软扩展中),现在最常用的文本格式是UTF-8。在Web上,UTF-8是自2007年以来最常用的文本格式(这个取代了ASCII,是以前最常见的字符编码)。UTF-8使用可变长度的字符定义。一个字符定义为使用1到6个字节。这个字符序列在文件的开头探测UTF-8:0xEF、0xBB、0xBF。
2. 读取流
打开文件并创建流后,使用Read()方法读取文件。重复此过程,直到该方法返回0为止。使用在前面定义的GetEncoding()方法中创建的Encoding,创建一个字符串。不要忘记使用Dispose()方法关闭流。如果可能,使用using语句:
static void ReadFileUsingFileStream(string fileName)
{
const int bufferSize = 256;
using (var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read))
{
ShowStreamInformation(stream);
Encoding encoding = GetEncoding(stream);
byte[] buffer = new byte[bufferSize];
bool completed = false;
do
{
int nRead = stream.Read(array:buffer,offset:0,count:bufferSize);
if (nRead == 0)
{
completed = true;
}
if (nRead < bufferSize)
{
Array.Clear(array: buffer, index:nRead,length:bufferSize-nRead);
}
string s = encoding.GetString(bytes:buffer,index:0,count:nRead);
Console.WriteLine($"read {nRead} bytes");
Console.WriteLine(s);
} while (!completed);
}
}
3. 写入流
把一个简单的字符串写入文本文件,就演示了如何写入流。为了创建一个可以写入的流,可以使用File.OpenWrite()方法。这次通过Path.GetTemFileName创建一个临时文件名。GetTemFileName定义的默认文件扩展名通过Path.ChangeExtension改为txt:
static void WriteTextFile()
{
string temTextFileName = Path.ChangeExtension(path:Path.GetTempFileName(),extension:"txt");
using (FileStream stream = File.OpenWrite(path:temTextFileName))
{
}
}
写入UTF-8文件时,需要吧序言写入文件。为此,可以使用WriteByte()方法,给流发送3个字节的UTF-8序言:
stream.WriteByte(value:0xe4);
stream.WriteByte(0xbd);
stream.WriteByte(0xa0);
这有一个替代方案。不需要记住指定编码的字节。Encoding类已经有这些信息了。GetPreamble()方法返回一个字节数组,其中包含文件的序言。这个字节数组使用Stream类的Write()方法写入:
byte[] preable = Encoding.UTF8.GetPreamble();
stream.Write(array:preable,offset:0,count:preable.Length);
现在可以写入文件的内容。Write()方法需要写入字节数组,所以需要转换字符串。将字符串转换为UTF-8的字节数组,可以使用Encoding.UTF8.GetBytes完成这个工作,之后写入字节数组:
static void WriteTextFile()
{
string temTextFileName = Path.ChangeExtension(path:Path.GetTempFileName(),extension:"txt");
using (FileStream stream = File.OpenWrite(path:temTextFileName))
{
//stream.WriteByte(value:0xe4);
//stream.WriteByte(0xbd);
//stream.WriteByte(0xa0);
byte[] preable = Encoding.UTF8.GetPreamble();
stream.Write(array:preable,offset:0,count:preable.Length);
string hello = "Hello, World! wxg";
byte[] buffer = Encoding.UTF8.GetBytes(s:hello);
stream.Write(array:buffer,offset:0,count:buffer.Length);
Console.WriteLine($"file {stream.Name} written");
}
}
可以使用编辑器(比如Notepad)打开临时文件,它会使用正确的编码。
4. 复制流
现在复制文件内容,把读写流合并起来。在下一个代码片段中,用File.OpenRead打开可读取的流,用File.OpenWrite()打开可写的流。使用Stream.Read()方法读取缓存区,用Stream.Write()方法写入缓存区:
static void CopyUsingStreams(string inputFile,string outputFile)
{
const int bufferSize = 4096;
using (var inputStream = File.OpenRead(inputFile))
using (var outputStream = File.OpenWrite(outputFile))
{
byte[] buffer = new byte[bufferSize];
bool completed = false;
do
{
int nRead = inputStream.Read(array:buffer,offset:0,count:bufferSize);
if (nRead == 0)
{
completed = true;
}
outputStream.Write(array:buffer,offset:0,count:nRead);
} while (!completed);
}
}
为了复制流,不需要编写读写流的代码。而可以使用Stream类的CopyTo方法,如下所示:
static void CopyUsingStreams2(string inputFile,string outputFile)
{
using var inputStream = File.OpenRead(inputFile);
using var outputStream = File.OpenWrite(outputFile);
inputStream.CopyTo(destination: outputStream);
}
5. 随机访问流
随机访问流(甚至可以访问大文件)的一个优势是,可以快速访问文件中的特定位置。
为了了解随机存储动作,下面的代码片段创建了一个大文件。这个代码片段创建的文件sampledata.data包含了长度相同的记录,包括一个数字、一个文本和一个随机的日期。传递给方法的记录数通过Enumerable.Range方法创建。Selete方法创建一个匿名类型,其中包含Number、Text和Date属性。除了这些记录外,还创建了一个带#前缀和后缀的字符串,每个值的长度都固定,每个值之间用;作为分隔符。WriteAsync方法将记录写入流:
static async Task CreateSampleFile(int nRecords)
{
var stream = File.Create(path:SampleFilePath);
using (var writer = new StreamWriter(stream:stream))
{
var random = new Random();
var records = Enumerable.Range(0, nRecords).Select(x => new
{
Number = x,
Text = $"Sample text {random.Next(200)}",
Date = new DateTime(Math.Abs((long)((random.NextDouble()*2 - 1) * DateTime.MinValue.Ticks)))
});
foreach (var rec in records)
{
string date = rec.Date.ToString(format:"d",provider:CultureInfo.InvariantCulture);
string s = $"#{rec.Number,8};{rec.Text,-20};{date}#{Environment.NewLine}";
await writer.WriteAsync(value:s);
}
}
}
注意:
前面提到,每个实现IDisposable的对象都应该销毁。在前面的代码片段中,FileStream似乎并没有销毁。然而事实并非如此。StreamWriter销毁时,StreamWriter会控制所使用的资源,并销毁流。为了使流打开的时间比StreamWriter更长,可以用StreamWriter的构造函数配置它。在这种情况下,需要显示地销毁流。
现在把游标定位到流中的一个随机位置,读取不同的记录。用户需要输入应该访问的记录号。流中牢固应该访问的字节基于记录号和记录的大小。现在Stream类的Seek方法允许定位流中的光标。第二个参数指定位置是流的开头、流的末尾或是当前位置:
static void RandomAccessSample(int recordSize)
{
try
{
using (FileStream stream = File.OpenRead(SampleFilePath))
{
byte[] buffer = new byte[recordSize];
do
{
try
{
Console.WriteLine("record number (or 'bye' to end): ");
string line = Console.ReadLine();
if (line.ToUpper().CompareTo("BYE") == 0)
{
break;
}
if (int.TryParse(line,out int record))
{
stream.Seek((record-1)*recordSize,SeekOrigin.Begin);
stream.Read(array:buffer,offset:0,count:recordSize);
string s = Encoding.UTF8.GetString(bytes:buffer);
Console.WriteLine($"record: {s}");
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
} while (true);
Console.WriteLine("finished");
}
}
catch (FileNotFoundException)
{
Console.WriteLine("Create the sample file using the option -sample first");
}
}
利用这些代码,可以尝试创建一个包含150万条记录或更多的文件。使用记事本打开这个大小的文件会比较慢,但是使用随机存储会比较快。根据系统、CPU和磁盘类型,可以使用更高或更低的值来测试。
注意:
如果应该访问的记录的大小不固定,仍可以为大文件使用随机存储。解决这一问题的方法之一是把写入记录的位置放在文件的开头。另一个选择是读取记录所在的一个更大的块,在其中可以找到记录标识符和内存块中的记录限值条件。
6. 使用缓存的流
从性能原因上看,在读写文件时,输出结果会被缓存。如果程序要求读取文件流中下面的两个字节,该流会把请求传递给Windows,则Windows不会连接文件,再定位文件,并从磁盘中读取文件,仅读取两个字节。而是在一次读取过程中,检索文件中的一个大块,把该块保存在内存区域,即缓冲区域。以后对流中数据的请求就会从该缓冲区中读取,直到读取完该缓冲区为止。此时,Windows会从文件中再获取另一个数据块。
写入文件的方式与此相同。对于文件,操作系统会自动完成读写操作,但需要编写一个流类,从其他没有缓存的设备中读取数据。如果是这样,就应从BufferedStream创建一个类,它实现一个缓冲区,并把应缓存的流传递给构造函数。但注意,BufferedStream并不用于应用程序频繁切换读数据和写数据的情形。