C#中的流与IO
流与IO
.NET流的架构
主要包含三个概念:后台存储、装饰器以及流适配器。
后台存储是输入输出的终结点,例如文件或者网络连接。准确地说,它可以是以下的一种或者两种:
- 支持顺序读取字节的源。
- 支持顺序写入字节的目标。
使用后台存储,必须公开相应的接口。而Stream
正是实现这个功能的.NET
标准类。它支持标准的读、写以及定位方法。它与数组不同,流并不会直接将数据存储在内存中,流会以每次一个字节或者每次一块数据的方式按照序列处理数据。
流可以分为两类:
-
后台存储流:它们是与特定的后台存储类型连接的流,例如FileStream或者NetworkStream。
-
装饰器流:这些流会使用其他的流,并以某种方式转换数据。例如DeflateStream或者CryptoStream。
后台存储流和装饰器流仅支持字节处理。虽然这种方式灵活又高效,但应用程序往往需要处理更高层次的数据,例如文本或者XML。适配器正好弥补了这个鸿沟。它在类中创建专门的方法以支持特定的格式。例如,TextReader
有一个ReadLine
方法,而XmlTextWriter
则拥有WriteAttribute
方法。
使用流
抽象的Stream类是所有流的基类。它的方法和属性定义了三种基本的操作:读、写、查找。除此之外,它还定义了一些管理性的任务,例如关闭、刷新(flush)和配置超时时间。
如何对一个文件流进行读写和查找操作:
using System;
using System.IO;
namespace IO和流
{
internal class Program
{
static void Main(string[] args)
{
// 在当前目录创建一个名为test.txt的文件
using(Stream s = new FileStream("test.txt", FileMode.Create))
{
Console.WriteLine(s.CanRead); // True
Console.WriteLine(s.CanWrite); // True
Console.WriteLine(s.CanSeek); // True
s.WriteByte(101); //e
s.WriteByte(102); //f
byte[] block = { 1, 2, 3, 4, 5 };
s.Write(block, 0, block.Length);
Console.WriteLine(s.Length); // 7
Console.WriteLine(s.Position); // 7
s.Position = 0; // 设置流的位置为0
Console.WriteLine(s.ReadByte()); // 101
Console.WriteLine(s.ReadByte()); // 102
Console.WriteLine(s.Read(block, 0, block.Length)); // 5
Console.WriteLine(s.Read(block, 0, block.Length)); // 0
}
}
}
}
要实现异步读写,只需将Read/Write
调用更改为ReadAsync/WriteAsync
,并await相应的表达式即可。
async static void AsyncDemo()
{
using (Stream s = new FileStream("test.txt", FileMode.Create))
{
byte[] block= { 1, 2, 3, 4, 5 };
await s.WriteAsync(block, 0, block.Length);
s.Position = 0;
Console.WriteLine(await s.ReadAsync(block, 0, block.Length)); //5
}
}
读取和写入
流可以支持读操作,写操作或者两者都支持。如果CanWrite为false则这个流就是只读的;如果CanRead为false这个流就是只写的。
Read方法可以将流中的一个数据块读到一个数组中,并返回接收的字节数。这个字节数一定小于等于count参数。如果小于count参数,表明读取位置已经到达了流的末尾,或者流本身是以小块方式提供数据的(通常是网络流)。不论是哪一种情况,数组的剩余字节都不会被写入,仍然会保持先前的值。
查找
如果CanSeek返回true,那么表示当前的流是可以查找的。在一个可以查找的流中(例如文件流),不但可以查询还可以修改它的长度Length(调用SetLength方法)。也可以通过Position属性随时设置读写的位置(Position属性的位置是相对于流的起始位置的)。而Seek方法则可以参照当前位置或者结束位置进行位置的设置。
关闭和刷新
流在使用结束后必须销毁,以释放底层资源,例如文件和套接字句柄。可以在using语句块中创建流的实例来确保结束后销毁流对象。通常,流对象的标准销毁语义为:
-
Dispose和Close方法的功能是一样的。
-
重复销毁或者关闭流对象不会产生任何错误。
关闭一个装饰器流会同时关闭装饰器及其后台存储流。关闭装饰器链的最外层装饰器(链条的头部)就可以关闭链条中的所有对象。
有一些流(例如文件流)会将数据缓冲到后台存储中并从中取回数据,减少回程,从而提高性能。这意味着,写入流的数据并不会直接存储到后台存储中,而是会先将缓冲区填满再写入存储器。Flush方法可以强制将缓冲区中的数据写入后台存储中。当流关闭的时候,也会自动调用Flush方法。
超时
如果流的CanTimeout属性返回true,那么就可以为这个流对象设置读写超时时间。例如,网络流支持超时设置,而文件流和内存流则不支持。若流支持超时时间,则可以使用ReadTimeout和WriteTimeout属性以毫秒为单位设置预期的超时时间。0代表不进行超时设置。在设置完毕后Read和Write方法就会在超时后抛出一个异常。
线程安全
通常情况下,流并不是线程安全的,这意味着当两个线程并发读写同一个流对象的时候有可能会发生错误。而Stream类提供了一个简单的解决方案,即使用静态的Synchronized
方法。这个方法可以接受任何类型的流,并返回一个线程安全的包装器。这个包装器会使用一个排它锁保证每一次读、写或者查找操作只能有一个线程执行。这样,多个线程就可以同时向一个数据流中写入数据了
FileStream类
创建FileStream
实例化FileStream的最简单的方法是使用File类型中的静态方法:
FileStream fs1 = File.OpenRead("reademe.bin");
FileStream fs2 = File.OpenWrite(@"c:\temp\writeme.tmp");
FileStream fs3 = File.Create(@"c:\temp\writeme.tmp");
如果文件已经存在,那么OpenWrite和Create的行为是不同的。Create方法会删除其全部内容,而OpenWrite则会保留流中全部已有内容并将流的起始位置设置为0。如果我们写入的内容比原始文件内容长度还短,则OpenWrite执行之后其文件内容会同时包含新旧内容。
还可以直接实例化一个FileStream。它的构造器支持所有特性,例如允许指定文件名或者底层文件句柄、文件创建和访问模式、共享选项、缓冲选项以及安全性。例如,以下代码会直接打开一个已有文件进行读、写操作,而不会覆盖这个文件:
var fs = new FileStream("readwrite.tmp", FileMode.Open);
以下的静态方法能够将一个文件一次性读到内存中:
- File.ReadAllText(返回字符串)
- File.ReadAllLines(返回一个字符串数组)
- File.ReadAllBytes(返回一个字节数组)
以下的静态方法能够一次性地写入一个完整的文件:
- File.WriteAllText
- File.WriteAllLines· File.WriteAllBytes· File.AppendAllText(适用于向日志文件中追加内容)
指定文件名
文件名可以是绝对路径(例如c:\temp\test.txt)也可以是相对当前目录的路径(例如,test.txt或者temp\test.txt)。可以访问Environment.CurrentDirectory属性来获得或者更改当前目录。
AppDomain.CurrentDomain.BaseDirectory
属性会返回应用程序的基础目录(base directony),正常情况下它就是可执行文件所在的文件夹。结合使用Path.Combine
方法就可以定位该目录下的文件名。
string baseFolder = AppDomain.CurrentDomain.BaseDirectory;
string tmp = Path.Combine(baseFolder, "readwrite.tmp");
Console.WriteLine(File.Exists(tmp));
指定FileMode
FileStream类型每一个接受文件名的构造器都需要提供FileMode枚举参数。
在创建FileStream时若只提供文件名和FileMode将会得到一个可读可写的流(但有一种例外)。而如果传入了FileAccess参数,就可以对读写模式进行取舍了:
[Flags]
public enum FileAccess{Read = 1, Write = 2, ReadWrite = 3}
FileMode.Append
则是一个例外。这个模式只会得到只读的流。相反,如果既要追加内容,又希望支持读写的话,就需要使用FileMode.Open
或者FileMode. OpenOrCreate
,打开文件,并定位到流的结尾处:
using (var fs = new FileStream("myFile.bin", FileMode.Open))
{
fs.Seek(0, SeekOrign.End);
...
}
FileStream的高级特性
创建FileSteam
时可选的其他参数:
-
FileShare枚举:描述了在完成文件处理之前,若其他进程希望访问该文件,则可以给其他进程授予的访问权限(None、Read、ReadWrite或者Write,其中Read为默认权限)。
-
内部缓冲区的大小(字节为单位,默认大小为4KB)。
-
是否由操作系统管理异步I/O的标志。
-
FileSecurity对象,描述给新文件分配的用户角色和权限。
-
FileOptions标志枚举值,其中包括:请求操作系统加密(Encrypted),在文件关闭时自动删除临时文件(DeleteOnClose),以及优化提示(RandomAccess和SequentialScan)。此外还有一个WriteThrough标志可以要求操作系统禁用写后缓存,这适用于事物文件或日志文件的处理。
使用FileShare.ReadWrite打开一个文件可以允许其他进程或用户读写同一个文件。为了避免混乱,我们可以使用以下方法在读或者写之前锁定文件的特定部分。
public virtual void Lock (long postion, long length);
public virtual void Unlock (long postion, long length);
MemoryStream
MemoryStream使用数组作为后台存储。具体以后介绍。
PipeStream
PipeStream是在Framework 3.5中引入的。它可以使用Windows管道协议与另一个进程进行通信。
管道类型有两种:
-
匿名管道(速度快):支持在同一个计算机中的父进程和子进程之间进行单向通信。
-
命名管道(更加灵活):允许同一台计算机的任意两个进程之间,或者不同计算机(使用Windows网络)的两个进程间进行双向通信。
管道很适合在同一台计算机进行进程间通信(IPC):它不依赖于任何网络传输(因此没有网络协议开销),性能更好,也不会有防火墙问题。
管道是基于流实现的,因此一个进程会等待接收字节,而另一个进程则负责发送字节。
PipeStream是一个抽象类,它有4个子类。其中两个用于匿名管道而另外两个用于命名管道。
-
匿名管道:
AnonymousPipeServerStream
和AnonymousPipeClientStream
。 -
命名管道:
NamedPipeServerStream
和NamedPipeClientStream
。
命名管道
名管道可以让通信各方使用名称相同的管道进行通信。其协议定义了两种不同的角色:客户端与服务器。客户端和服务器之间的通信采用以下方式:
-
服务器实例化一个
NamedPipeServerStream
,然后调用WaitForConnection
方法。 -
客户端实例化一个NamedPipeClientStream,然后调用Connect(可提供可选的超时时间)。此后,双方就可以通过读写流进行通信了。
服务端:
// server pipe
using(var ns = new NamedPipeServerStream("pipedream"))
{
ns.WaitForConnection();
ns.WriteByte(100);
Console.WriteLine(ns.ReadByte());
}
客户端:
// client pipe
using (var ns = new NamedPipeClientStream("pipedream"))
{
ns.Connect();
Console.WriteLine(ns.ReadByte());
ns.WriteByte(200);
}
命名管道流默认是双向通信的,因此任何一方都可以读或者写它们的流。这意味着客户端和服务器都必须统一使用一种协议来协调它们的操作,因此双方不能同时发送或者接收消息。
通信双方需要统一每一次传输的数据长度。上面的例子只传输了一个字节,如果要传输更长的数据,管道提供了一种消息传输模式。如果启用了这个模式,调用read
的一方可以检查IsMessageComplete
来确定消息是否传输完毕。
// 读取pipstream中的完整消息
static byte[] ReadMessage(PipeStream s)
{
MemoryStream ms = new MemoryStream();
byte[] buffer = new byte[0x1000];
do
{
ms.Write(buffer, 0, s.Read(buffer, 0, buffer.Length));
} while (!s.IsMessageComplete);
return ms.ToArray();
}
在服务器端,在创建流时指定PipeTransm-issionMode.Message就可以激活消息传输(这里还需要传入最大服务端数量):
// server pipe
using(var ns = new NamedPipeServerStream("pipedream", PipeDirection.InOut, 2, PipeTransmissionMode.Message))
{
ns.WaitForConnection();
byte[] msg = Encoding.UTF8.GetBytes("Hello");
ns.Write(msg, 0, msg.Length);
Console.WriteLine(Encoding.UTF8.GetString(ReadMessage(ns)));
}
在客户端,调用Connect之后设置ReadMode即可激活消息传输模式:
// client pipe
using (var ns = new NamedPipeClientStream("pipedream"))
{
ns.Connect();
ns.ReadMode = PipeTransmissionMode.Message;
Console.WriteLine(Encoding.UTF8.GetString(ReadMessage(ns)));
byte[] msg = Encoding.UTF8.GetBytes("Hello right back!");
ns.Write(msg, 0, msg.Length);
}
匿名管道
匿名管道支持在父子进程之间进行单向通信。匿名管道不会使用系统范围内的名称,而是通过一个私有句柄进行调整。
与命名管道一样,匿名管道也区分客户端和服务器端。然而,其通信系统却不尽相同。它采用了以下的方法:
-
服务器实例化一个
AnonymousPipeServerStream
对象,并提交一个值为In
或者Out
的PipeDirection
。 -
服务器调用
GetClientHandleAsString
方法获得一个管道的标识符,然后传递回客户端(一般作为启动子进程的一个参数)。 -
子进程实例化一个
AnonymousPipeClientStream
对象,指定相反的Pipe-Direction
。 -
服务器调用
DisposeLocalCopyOfClientHandle
方法释放第2步中生成的本地句柄。 -
父子进程通过读/写流进行通信。