File I/O and Object Serialization - Introducing the .NET Base Class Libraries - C# 6.0 and the .NET 4.6 Framework (2015)

C# 6.0 and the .NET 4.6 Framework (2015)

PART VI

image

Introducing the .NET Base Class Libraries

CHAPTER 20

image

File I/O and Object Serialization

When you create desktop applications, the ability to save information between user sessions is commonplace. This chapter examines a number of I/O-related topics as seen through the eyes of the .NET Framework. The first order of business is to explore the core types defined in theSystem.IO namespace and learn how to modify a machine’s directory and file structure programmatically. The next task is to explore various ways to read from and write to character-based, binary-based, string-based, and memory-based data stores.

After you learn how to manipulate files and directories using the core I/O types, you will examine the related topic of object serialization. You can use object serialization to persist and retrieve the state of an object to (or from) any System.IO.Stream-derived type. The ability to serialize objects is critical when you want to copy an object to a remote machine using various remoting technologies such as Windows Communication Foundation. However, serialization is quite useful in its own right and will likely play a role in many of your .NET applications (distributed or not).

Image Note To ensure you can run each of the examples in this chapter, start Visual Studio with administrative rights (just right-click the VS icon and select Run as Administrator). If you do not do so, you may encounter runtime security exceptions when accessing the computer file system.

Exploring the System.IO Namespace

In the framework of .NET, the System.IO namespace is the region of the base class libraries devoted to file-based (and memory-based) input and output (I/O) services. Like any namespace, System.IO defines a set of classes, interfaces, enumerations, structures, and delegates, most of which you can find in mscorlib.dll. In addition to the types contained within mscorlib.dll, the System.dll assembly defines additional members of the System.IO namespace. Note that all Visual Studio projects automatically set a reference to both assemblies.

Many of the types within the System.IO namespace focus on the programmatic manipulation of physical directories and files. However, additional types provide support to read data from and write data to string buffers, as well as raw memory locations. Table 20-1 outlines the core (nonabstract) classes, providing a road map of the functionality in System.IO.

Table 20-1. Key Members of the System.IO Namespace

Nonabstract I/O Class Type

Meaning in Life

BinaryReader BinaryWriter

These classes allow you to store and retrieve primitive data types (integers, Booleans, strings, and whatnot) as a binary value.

BufferedStream

This class provides temporary storage for a stream of bytes that you can commit to storage at a later time.

Directory
DirectoryInfo

You use these classes to manipulate a machine’s directory structure. The Directory type exposes functionality using static members, while the DirectoryInfo type exposes similar functionality from a valid object reference.

DriveInfo

This class provides detailed information regarding the drives that a given machine uses.

File
FileInfo

You use these classes to manipulate a machine’s set of files. The File type exposes functionality using static members, while the FileInfo type exposes similar functionality from a valid object reference.

FileStream

This class gives you random file access (e.g., seeking capabilities) with data represented as a stream of bytes.

FileSystemWatcher

This class allows you to monitor the modification of external files in a specified directory.

MemoryStream

This class provides random access to streamed data stored in memory rather than in a physical file.

Path

This class performs operations on System.String types that contain file or directory path information in a platform-neutral manner.

StreamWriter
StreamReader

You use these classes to store (and retrieve) textual information to (or from) a file. These types do not support random file access.

StringWriter
StringReader

Like the StreamReader/StreamWriter classes, these classes also work with textual information. However, the underlying storage is a string buffer rather than a physical file.

In addition to these concrete class types, System.IO defines a number of enumerations, as well as a set of abstract classes (e.g., Stream, TextReader, and TextWriter), that define a shared polymorphic interface to all descendants. You will read about many of these types in this chapter.

The Directory(Info) and File(Info) Types

System.IO provides four classes that allow you to manipulate individual files, as well as interact with a machine’s directory structure. The first two types, Directory and File, expose creation, deletion, copying, and moving operations using various static members. The closely relatedFileInfo and DirectoryInfo types expose similar functionality as instance-level methods (therefore, you must allocate them with the new keyword). In Figure 20-1, the Directory and File classes directly extend System.Object, while DirectoryInfo and FileInfoderive from the abstract FileSystemInfo type.

image

Figure 20-1. The File- and Directory-centric types

FileInfo and DirectoryInfo typically serve as better choices for obtaining full details of a file or directory (e.g., time created or read/write capabilities) because their members tend to return strongly typed objects. In contrast, the Directory and File class members tend to return simple string values rather than strongly typed objects. This is only a guideline, however; in many cases, you can get the same work done using File/FileInfo or Directory/DirectoryInfo.

The Abstract FileSystemInfo Base Class

The DirectoryInfo and FileInfo types receive many behaviors from the abstract FileSystemInfo base class. For the most part, you use the members of the FileSystemInfo class to discover general characteristics (such as time of creation, various attributes, and so forth) about a given file or directory. Table 20-2 lists some core properties of interest.

Table 20-2. FileSystemInfo Properties

Property

Meaning in Life

Attributes

Gets or sets the attributes associated with the current file that are represented by the FileAttributes enumeration (e.g., is the file or directory read-only, encrypted, hidden, or compressed?).

CreationTime

Gets or sets the time of creation for the current file or directory.

Exists

Determines whether a given file or directory exists.

Extension

Retrieves a file’s extension.

FullName

Gets the full path of the directory or file.

LastAccessTime

Gets or sets the time the current file or directory was last accessed.

LastWriteTime

Gets or sets the time when the current file or directory was last written to.

Name

Obtains the name of the current file or directory.

FileSystemInfo also defines the Delete() method. This is implemented by derived types to delete a given file or directory from the hard drive. Also, you can call Refresh() prior to obtaining attribute information to ensure that the statistics regarding the current file (or directory) are not outdated.

Working with the DirectoryInfo Type

The first creatable I/O-centric type you will examine is the DirectoryInfo class. This class contains a set of members used for creating, moving, deleting, and enumerating over directories and subdirectories. In addition to the functionality provided by its base class (FileSystemInfo),DirectoryInfo offers the key members detailed in Table 20-3.

Table 20-3. Key Members of the DirectoryInfo Type

Member

Meaning in Life

Create()

CreateSubdirectory()

Create a directory (or set of subdirectories) when given a path name

Delete()

Deletes a directory and all its contents

GetDirectories()

Returns an array of DirectoryInfo objects that represent all subdirectories in the current directory

GetFiles()

Retrieves an array of FileInfo objects that represent a set of files in the given directory

MoveTo()

Moves a directory and its contents to a new path

Parent

Retrieves the parent directory of this directory

Root

Gets the root portion of a path

You begin working with the DirectoryInfo type by specifying a particular directory path as a constructor parameter. Use the dot (.) notation if you want to obtain access to the current working directory (the directory of the executing application). Here are some examples:

// Bind to the current working directory.
DirectoryInfo dir1 = new DirectoryInfo(".");
// Bind to C:\Windows,
// using a verbatim string.
DirectoryInfo dir2 = new DirectoryInfo(@"C:\Windows");

In the second example, you assume that the path passed into the constructor (C:\Windows) already exists on the physical machine. However, if you attempt to interact with a nonexistent directory, a System.IO.DirectoryNotFoundException is thrown. Thus, if you specify a directory that is not yet created, you need to call the Create() method before proceeding, like so:

// Bind to a nonexistent directory, then create it.
DirectoryInfo dir3 = new DirectoryInfo(@"C:\MyCode\Testing");
dir3.Create();

After you create a DirectoryInfo object, you can investigate the underlying directory contents using any of the properties inherited from FileSystemInfo. To see this in action, create a new Console Application project named DirectoryApp and update your C# file to importSystem.IO. Update your Program class with the following new static method that creates a new DirectoryInfo object mapped to C:\Windows (adjust your path if need be), which displays a number of interesting statistics:

class Program
{
static void Main(string[] args)
{
Console.WriteLine("***** Fun with Directory(Info) *****\n");
ShowWindowsDirectoryInfo();
Console.ReadLine();
}

static void ShowWindowsDirectoryInfo()
{
// Dump directory information.
DirectoryInfo dir = new DirectoryInfo(@"C:\Windows");
Console.WriteLine("***** Directory Info *****");
Console.WriteLine("FullName: {0}", dir.FullName);
Console.WriteLine("Name: {0}", dir.Name);
Console.WriteLine("Parent: {0}", dir.Parent);
Console.WriteLine("Creation: {0}", dir.CreationTime);
Console.WriteLine("Attributes: {0}", dir.Attributes);
Console.WriteLine("Root: {0}", dir.Root);
Console.WriteLine("**************************\n");
}
}

While your output might differ, you should see something similar to the following:

***** Fun with Directory(Info) *****

***** Directory Info *****
FullName: C:\Windows
Name: Windows
Parent:
Creation: 10/10/2015 10:22:32 PM
Attributes: Directory
Root: C:\
**************************

Enumerating Files with the DirectoryInfo Type

In addition to obtaining basic details of an existing directory, you can extend the current example to use some methods of the DirectoryInfo type. First, you can leverage the GetFiles() method to obtain information about all *.jpg files located in theC:\Windows\Web\Wallpaper directory.

Image Note If your machine does not have a C:\Windows\Web\Wallpaper directory, retrofit this code to read files of a directory on your machine (e.g., to read all *.bmp files from the C:\Windows directory).

The GetFiles() method returns an array of FileInfo objects, each of which exposes details of a particular file (you will learn the full details of the FileInfo type later in this chapter). Assume that you have the following static method of the Program class, which you call fromMain():

static void DisplayImageFiles()
{
DirectoryInfo dir = new DirectoryInfo(@"C:\Windows\Web\Wallpaper");
// Get all files with a *.jpg extension.
FileInfo[] imageFiles = dir.GetFiles("*.jpg", SearchOption.AllDirectories);

// How many were found?
Console.WriteLine("Found {0} *.jpg files\n", imageFiles.Length);

// Now print out info for each file.
foreach (FileInfo f in imageFiles)
{
Console.WriteLine("***************************");
Console.WriteLine("File name: {0}", f.Name);
Console.WriteLine("File size: {0}", f.Length);
Console.WriteLine("Creation: {0}", f.CreationTime);
Console.WriteLine("Attributes: {0}", f.Attributes);
Console.WriteLine("***************************\n");
}
}

Notice that you specify a search option when you call GetFiles(); you do this to look within all subdirectories of the root. After you run the application, you will see a listing of all files that match the search pattern.

Creating Subdirectories with the DirectoryInfo Type

You can programmatically extend a directory structure using the DirectoryInfo.CreateSubdirectory() method. This method can create a single subdirectory, as well as multiple nested subdirectories, in a single function call. This method illustrates how to do so, extending the directory structure of the C: drive with some custom subdirectories:

static void ModifyAppDirectory()
{
DirectoryInfo dir = new DirectoryInfo(@"C:\");

// Create \MyFolder off application directory.
dir.CreateSubdirectory("MyFolder");

// Create \MyFolder2\Data off application directory.
dir.CreateSubdirectory(@"MyFolder2\Data");
}

You are not required to capture the return value of the CreateSubdirectory() method, but you should be aware that a DirectoryInfo object representing the newly created item is passed back on successful execution. Consider the following update to the previous method. Note the dot notation in the constructor of DirectoryInfo, which gives you access to the application’s installation point.

static void ModifyAppDirectory()
{
DirectoryInfo dir = new DirectoryInfo(".");

// Create \MyFolder off initial directory.
dir.CreateSubdirectory("MyFolder");

// Capture returned DirectoryInfo object.
DirectoryInfo myDataFolder = dir.CreateSubdirectory(@"MyFolder2\Data");

// Prints path to ..\MyFolder2\Data.
Console.WriteLine("New Folder is: {0}", myDataFolder);
}

If you call this method from within Main() and examine your Windows directory using Windows Explorer, you will see that the new subdirectories are present and accounted for (see Figure 20-2).

image

Figure 20-2. Creating subdirectories

Working with the Directory Type

You have seen the DirectoryInfo type in action; now you’re ready to learn about the Directory type. For the most part, the static members of Directory mimic the functionality provided by the instance-level members defined by DirectoryInfo. Recall, however, that the members of Directory typically return string data rather than strongly typed FileInfo/DirectoryInfo objects.

Now let’s look at some functionality of the Directory type; this final helper function displays the names of all drives mapped to the current computer (using the Directory.GetLogicalDrives() method) and uses the static Directory.Delete() method to remove the\MyFolder and \MyFolder2\Data subdirectories created previously.

static void FunWithDirectoryType()
{
// List all drives on current computer.
string[] drives = Directory.GetLogicalDrives();
Console.WriteLine("Here are your drives:");
foreach (string s in drives)
Console.WriteLine("--> {0} ", s);

// Delete what was created.
Console.WriteLine("Press Enter to delete directories");
Console.ReadLine();
try
{
Directory.Delete(@"C:\MyFolder");

// The second parameter specifies whether you
// wish to destroy any subdirectories.
Directory.Delete(@"C:\MyFolder2", true);
}
catch (IOException e)
{
Console.WriteLine(e.Message);
}
}

Image Source Code You can find the DirectoryApp project in the Chapter 20 subdirectory.

Working with the DriveInfo Class Type

The System.IO namespace provides a class named DriveInfo. Like Directory.GetLogicalDrives(), the static DriveInfo.GetDrives() method allows you to discover the names of a machine’s drives. Unlike Directory.GetLogicalDrives(), however,DriveInfo provides numerous other details (e.g., the drive type, available free space, and volume label). Consider the following Program class defined within a new Console Application project named DriveInfoApp (don’t forget to import System.IO):

class Program
{
static void Main(string[] args)
{
Console.WriteLine("***** Fun with DriveInfo *****\n");

// Get info regarding all drives.
DriveInfo[] myDrives = DriveInfo.GetDrives();
// Now print drive stats.
foreach(DriveInfo d in myDrives)
{
Console.WriteLine("Name: {0}", d.Name);
Console.WriteLine("Type: {0}", d.DriveType);

// Check to see whether the drive is mounted.
if(d.IsReady)
{
Console.WriteLine("Free space: {0}", d.TotalFreeSpace);
Console.WriteLine("Format: {0}", d.DriveFormat);
Console.WriteLine("Label: {0}", d.VolumeLabel);
}
Console.WriteLine();
}
Console.ReadLine();
}
}

Here is some possible output:

***** Fun with DriveInfo *****

Name: C:\
Type: Fixed
Free space: 791699763200
Format: NTFS
Label: Windows10_OS

Name: D:\
Type: Fixed
Free space: 23804067840
Format: NTFS
Label: LENOVO

Press any key to continue . . .

At this point, you have investigated some core behaviors of the Directory, DirectoryInfo, and DriveInfo classes. Next, you’ll learn how to create, open, close, and destroy the files that populate a given directory.

Image Source Code You can find the DriveInfoApp project in the Chapter 20 subdirectory.

Working with the FileInfo Class

As shown in the previous DirectoryApp example, the FileInfo class allows you to obtain details regarding existing files on your hard drive (e.g., time created, size, and file attributes) and aids in the creation, copying, moving, and destruction of files. In addition to the set of functionality inherited by FileSystemInfo, you can find some core members unique to the FileInfo class, which you can see described in Table 20-4.

Table 20-4. FileInfo Core Members

Member

Meaning in Life

AppendText()

Creates a StreamWriter object (described later) that appends text to a file

CopyTo()

Copies an existing file to a new file

Create()

Creates a new file and returns a FileStream object (described later) to interact with the newly created file

CreateText()

Creates a StreamWriter object that writes a new text file

Delete()

Deletes the file to which a FileInfo instance is bound

Directory

Gets an instance of the parent directory

DirectoryName

Gets the full path to the parent directory

Length

Gets the size of the current file

MoveTo()

Moves a specified file to a new location, providing the option to specify a new file name

Name

Gets the name of the file

Open()

Opens a file with various read/write and sharing privileges

OpenRead()

Creates a read-only FileStream object

OpenText()

Creates a StreamReader object (described later) that reads from an existing text file

OpenWrite()

Creates a write-only FileStream object

Note that a majority of the methods of the FileInfo class return a specific I/O-centric object (e.g., FileStream and StreamWriter) that allows you to begin reading and writing data to (or reading from) the associated file in a variety of formats. You will check out these types in just a moment; however, before you see a working example, you’ll find it helpful to examine various ways to obtain a file handle using the FileInfo class type.

The FileInfo.Create() Method

One way you can create a file handle is to use the FileInfo.Create() method, like so:

static void Main(string[] args)
{
// Make a new file on the C drive.
FileInfo f = new FileInfo(@"C:\Test.dat");
FileStream fs = f.Create();

// Use the FileStream object...

// Close down file stream.
fs.Close();
}

Notice that the FileInfo.Create() method returns a FileStream object, which exposes synchronous and asynchronous write/read operations to/from the underlying file (more details in a moment). Be aware that the FileStream object returned by FileInfo.Create()grants full read/write access to all users.

Also notice that after you finish with the current FileStream object, you must ensure you close down the handle to release the underlying unmanaged stream resources. Given that FileStream implements IDisposable, you can use the C# using scope to allow the compiler to generate the teardown logic (see Chapter 8 for details), like so:

static void Main(string[] args)
{
// Defining a using scope for file I/O
// types is ideal.
FileInfo f = new FileInfo(@"C:\Test.dat");
using (FileStream fs = f.Create())
{
// Use the FileStream object...
}
}

The FileInfo.Open() Method

You can use the FileInfo.Open() method to open existing files, as well as to create new files with far more precision than you can with FileInfo.Create(). This works because Open() typically takes several parameters to qualify exactly how to iterate the file you want to manipulate. Once the call to Open() completes, you are returned a FileStream object. Consider the following logic:

static void Main(string[] args)
{
// Make a new file via FileInfo.Open().
FileInfo f2 = new FileInfo(@"C:\Test2.dat");
using(FileStream fs2 = f2.Open(FileMode.OpenOrCreate,
FileAccess.ReadWrite, FileShare.None))
{
// Use the FileStream object...
}
}

This version of the overloaded Open() method requires three parameters. The first parameter of the Open() method specifies the general flavor of the I/O request (e.g., make a new file, open an existing file, and append to a file), which you specify using the FileMode enumeration (see Table 20-5 for details), like so:

public enum FileMode
{
CreateNew,
Create,
Open,
OpenOrCreate,
Truncate,
Append
}

Table 20-5. Members of the FileMode Enumeration

Member

Meaning in Life

CreateNew

Informs the OS to make a new file. If it already exists, an IOException is thrown.

Create

Informs the OS to make a new file. If it already exists, it will be overwritten.

Open

Opens an existing file. If the file does not exist, a FileNotFoundException is thrown.

OpenOrCreate

Opens the file if it exists; otherwise, a new file is created.

Truncate

Opens an existing file and truncates the file to 0 bytes in size.

Append

Opens a file, moves to the end of the file, and begins write operations (you can use this flag only with a write-only stream). If the file does not exist, a new file is created.

You use the second parameter of the Open() method, a value from the FileAccess enumeration, to determine the read/write behavior of the underlying stream, as follows:

public enum FileAccess
{
Read,
Write,
ReadWrite
}

Finally, the third parameter of the Open() method, FileShare, specifies how to share the file among other file handlers. Here are the core names:

public enum FileShare
{
Delete,
Inheritable,
None,
Read,
ReadWrite,
Write
}

The FileInfo.OpenRead() and FileInfo.OpenWrite() Methods

The FileInfo.Open() method allows you to obtain a file handle in a flexible manner, but the FileInfo class also provides members named OpenRead() and OpenWrite(). As you might imagine, these methods return a properly configured read-only or write-only FileStreamobject, without the need to supply various enumeration values. Like FileInfo.Create() and FileInfo.Open(), OpenRead() and OpenWrite() return a FileStream object (note that the following code assumes you have files named Test3.dat and Test4.dat on yourC: drive):

static void Main(string[] args)
{
// Get a FileStream object with read-only permissions.
FileInfo f3 = new FileInfo(@"C:\Test3.dat");
using(FileStream readOnlyStream = f3.OpenRead())
{
// Use the FileStream object...
}

// Now get a FileStream object with write-only permissions.
FileInfo f4 = new FileInfo(@"C:\Test4.dat");
using(FileStream writeOnlyStream = f4.OpenWrite())
{
// Use the FileStream object...
}
}

The FileInfo.OpenText() Method

Another open-centric member of the FileInfo type is OpenText(). Unlike Create(), Open(), OpenRead(), or OpenWrite(), the OpenText() method returns an instance of the StreamReader type, rather than a FileStream type. Assuming you have a file namedboot.ini on your C: drive, the following snippet gives you access to its contents:

static void Main(string[] args)
{
// Get a StreamReader object.
FileInfo f5 = new FileInfo(@"C:\boot.ini");
using(StreamReader sreader = f5.OpenText())
{
// Use the StreamReader object...
}
}

As you will see shortly, the StreamReader type provides a way to read character data from the underlying file.

The FileInfo.CreateText() and FileInfo.AppendText() Methods

The final two FileInfo methods of interest at this point are CreateText() and AppendText(). Both return a StreamWriter object, as shown here:

static void Main(string[] args)
{
FileInfo f6 = new FileInfo(@"C:\Test6.txt");
using(StreamWriter swriter = f6.CreateText())
{
// Use the StreamWriter object...
}

FileInfo f7 = new FileInfo(@"C:\FinalTest.txt");
using(StreamWriter swriterAppend = f7.AppendText())
{
// Use the StreamWriter object...
}
}

As you might guess, the StreamWriter type provides a way to write character data to the underlying file.

Working with the File Type

The File type uses several static members to provide functionality almost identical to that of the FileInfo type. Like FileInfo, File supplies AppendText(), Create(), CreateText(), Open(), OpenRead(), OpenWrite(), and OpenText() methods. In many cases, you can use the File and FileInfo types interchangeably. To see this in action, you can simplify each of the previous FileStream examples by using the File type instead, like so:

static void Main(string[] args)
{
// Obtain FileStream object via File.Create().
using(FileStream fs = File.Create(@"C:\Test.dat"))
{}

// Obtain FileStream object via File.Open().
using(FileStream fs2 = File.Open(@"C:\Test2.dat",
FileMode.OpenOrCreate,
FileAccess.ReadWrite, FileShare.None))
{}

// Get a FileStream object with read-only permissions.
using(FileStream readOnlyStream = File.OpenRead(@"Test3.dat"))
{}

// Get a FileStream object with write-only permissions.
using(FileStream writeOnlyStream = File.OpenWrite(@"Test4.dat"))
{}

// Get a StreamReader object.
using(StreamReader sreader = File.OpenText(@"C:\boot.ini"))
{}

// Get some StreamWriters.
using(StreamWriter swriter = File.CreateText(@"C:\Test6.txt"))
{}

using(StreamWriter swriterAppend = File.AppendText(@"C:\FinalTest.txt"))
{}
}

Additional File-Centric Members

The File type also supports a few members, shown in Table 20-6, which can greatly simplify the processes of reading and writing textual data.

Table 20-6. Methods of the File Type

Method

Meaning in Life

ReadAllBytes()

Opens the specified file, returns the binary data as an array of bytes, and then closes the file

ReadAllLines()

Opens a specified file, returns the character data as an array of strings, and then closes the file

ReadAllText()

Opens a specified file, returns the character data as a System.String, and then closes the file

WriteAllBytes()

Opens the specified file, writes out the byte array, and then closes the file

WriteAllLines()

Opens a specified file, writes out an array of strings, and then closes the file

WriteAllText()

Opens a specified file, writes the character data from a specified string, and then closes the file

You can use these methods of the File type to read and write batches of data in only a few lines of code. Even better, each of these members automatically closes down the underlying file handle. For example, the following console program (named SimpleFileIO) persists the string data into a new file on the C: drive (and reads it into memory) with minimal fuss (this example assumes you have imported System.IO):

class Program
{
static void Main(string[] args)
{
Console.WriteLine("***** Simple I/O with the File Type *****\n");
string[] myTasks = {
"Fix bathroom sink", "Call Dave",
"Call Mom and Dad", "Play Xbox One"};

// Write out all data to file on C drive.
File.WriteAllLines(@"C:\tasks.txt", myTasks);

// Read it all back and print out.
foreach (string task in File.ReadAllLines(@"C:\tasks.txt"))
{
Console.WriteLine("TODO: {0}", task);
}
Console.ReadLine();
}
}

The lesson here is that when you want to obtain a file handle quickly, the File type will save you some keystrokes. However, one benefit of creating a FileInfo object first is that you can investigate the file using the members of the abstract FileSystemInfo base class.

Image Source Code You can find the SimpleFileIO project in the Chapter 20 subdirectory.

The Abstract Stream Class

At this point, you have seen many ways to obtain FileStream, StreamReader, and StreamWriter objects, but you have yet to read data from or write data to a file using these types. To understand how to do this, you’ll need to familiarize yourself with the concept of a stream. In the world of I/O manipulation, a stream represents a chunk of data flowing between a source and a destination. Streams provide a common way to interact with a sequence of bytes, regardless of what kind of device (e.g., file, network connection, or printer) stores or displays the bytes in question.

The abstract System.IO.Stream class defines several members that provide support for synchronous and asynchronous interactions with the storage medium (e.g., an underlying file or memory location).

Image Note The concept of a stream is not limited to file I/O. To be sure, the .NET libraries provide stream access to networks, memory locations, and other stream-centric abstractions.

Again, Stream descendants represent data as a raw stream of bytes; therefore, working directly with raw streams can be quite cryptic. Some Stream-derived types support seeking, which refers to the process of obtaining and adjusting the current position in the stream. Table 20-7 helps you understand the functionality provided by the Stream class by describing its core members.

Table 20-7. Abstract Stream Members

Member

Meaning in Life

CanRead

CanWrite

CanSeek

Determines whether the current stream supports reading, seeking, and/or writing.

Close()

Closes the current stream and releases any resources (such as sockets and file handles) associated with the current stream. Internally, this method is aliased to the Dispose() method; therefore, closing a stream is functionally equivalent to disposing a stream.

Flush()

Updates the underlying data source or repository with the current state of the buffer and then clears the buffer. If a stream does not implement a buffer, this method does nothing.

Length

Returns the length of the stream in bytes.

Position

Determines the position in the current stream.

Read()
ReadByte()
ReadAsync()

Reads a sequence of bytes (or a single byte) from the current stream and advances the current position in the stream by the number of bytes read.

Seek()

Sets the position in the current stream.

SetLength()

Sets the length of the current stream.

Write()

WriteByte()
WrriteAsync()

Writes a sequence of bytes (or a single byte) to the current stream and advances the current position in this stream by the number of bytes written.

Working with FileStreams

The FileStream class provides an implementation for the abstract Stream members in a manner appropriate for file-based streaming. It is a fairly primitive stream; it can read or write only a single byte or an array of bytes. However, you will not often need to interact directly with the members of the FileStream type. Instead, you will probably use various stream wrappers, which make it easier to work with textual data or .NET types. Nevertheless, you will find it helpful to experiment with the synchronous read/write capabilities of the FileStream type.

Assume you have a new Console Application project named FileStreamApp (and verify that System.IO and System.Text are imported into your initial C# code file). Your goal is to write a simple text message to a new file named myMessage.dat. However, given thatFileStream can operate only on raw bytes, you will be required to encode the System.String type into a corresponding byte array. Fortunately, the System.Text namespace defines a type named Encoding that provides members that encode and decode strings to (or from) an array of bytes (check out the .NET Framework SDK documentation for more details about the Encoding type).

Once encoded, the byte array is persisted to file with the FileStream.Write() method. To read the bytes back into memory, you must reset the internal position of the stream (using the Position property) and call the ReadByte() method. Finally, you display the raw byte array and the decoded string to the console. Here is the complete Main() method:

// Don’t forget to import the System.Text and System.IO namespaces.
static void Main(string[] args)
{
Console.WriteLine("***** Fun with FileStreams *****\n");

// Obtain a FileStream object.
using(FileStream fStream = File.Open(@"C:\myMessage.dat",
FileMode.Create))
{
// Encode a string as an array of bytes.
string msg = "Hello!";
byte[] msgAsByteArray = Encoding.Default.GetBytes(msg);

// Write byte[] to file.
fStream.Write(msgAsByteArray, 0, msgAsByteArray.Length);

// Reset internal position of stream.
fStream.Position = 0;

// Read the types from file and display to console.
Console.Write("Your message as an array of bytes: ");
byte[] bytesFromFile = new byte[msgAsByteArray.Length];
for (int i = 0; i < msgAsByteArray.Length; i++)
{
bytesFromFile[i] = (byte)fStream.ReadByte();
Console.Write(bytesFromFile[i]);
}

// Display decoded messages.
Console.Write("\nDecoded Message: ");
Console.WriteLine(Encoding.Default.GetString(bytesFromFile));
}
Console.ReadLine();
}

This example populates the file with data, but it also punctuates the major downfall of working directly with the FileStream type: it demands to operate on raw bytes. Other Stream-derived types operate in a similar manner. For example, if you want to write a sequence of bytes to a region of memory, you can allocate a MemoryStream. Likewise, if you want to push an array of bytes through a network connection, you can use the NetworkStream class (in the System.Net.Sockets namespace).

As mentioned previously, the System.IO namespace provides several reader and writer types that encapsulate the details of working with Stream-derived types.

Image Source Code You can find the FileStreamApp project is in the Chapter 20 subdirectory.

Working with StreamWriters and StreamReaders

The StreamWriter and StreamReader classes are useful whenever you need to read or write character-based data (e.g., strings). Both of these types work by default with Unicode characters; however, you can change this by supplying a properly configuredSystem.Text.Encoding object reference. To keep things simple, assume that the default Unicode encoding fits the bill.

StreamReader derives from an abstract type named TextReader, as does the related StringReader type (discussed later in this chapter). The TextReader base class provides a limited set of functionality to each of these descendants; specifically, it provides the ability to read and peek into a character stream.

The StreamWriter type (as well as StringWriter, which you will examine later in this chapter) derives from an abstract base class named TextWriter. This class defines members that allow derived types to write textual data to a given character stream.

To aid in your understanding of the core writing capabilities of the StreamWriter and StringWriter classes, Table 20-8 describes the core members of the abstract TextWriter base class.

Table 20-8. Core Members of TextWriter

Member

Meaning in Life

Close()

This method closes the writer and frees any associated resources. In the process, the buffer is automatically flushed (again, this member is functionally equivalent to calling the Dispose() method).

Flush()

This method clears all buffers for the current writer and causes any buffered data to be written to the underlying device; however, it does not close the writer.

NewLine

This property indicates the newline constant for the derived writer class. The default line terminator for the Windows OS is a carriage return, followed by a line feed (\r\n).

Write()
WriteAsync()

This overloaded method writes data to the text stream without a newline constant.

WriteLine()
WriteLineAsync()

This overloaded method writes data to the text stream with a newline constant.

Image Note The last two members of the TextWriter class probably look familiar to you. If you recall, the System.Console type has Write() and WriteLine() members that push textual data to the standard output device. In fact, the Console.In property wraps a TextReader, and the Console.Out property wraps a TextWriter.

The derived StreamWriter class provides an appropriate implementation for the Write(), Close(), and Flush() methods, and it defines the additional AutoFlush property. When set to true, this property forces StreamWriter to flush all data every time you perform a write operation. Be aware that you can gain better performance by setting AutoFlush to false, provided you always call Close() when you finish writing with a StreamWriter.

Writing to a Text File

To see the StreamWriter type in action, create a new Console Application project named StreamWriterReaderApp and import System.IO. The following Main() method creates a new file named reminders.txt in the current execution folder, using the File.CreateText()method. Using the obtained StreamWriter object, you can add some textual data to the new file.

static void Main(string[] args)
{
Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");

// Get a StreamWriter and write string data.
using(StreamWriter writer = File.CreateText("reminders.txt"))
{
writer.WriteLine("Don’t forget Mother’s Day this year...");
writer.WriteLine("Don’t forget Father’s Day this year...");
writer.WriteLine("Don’t forget these numbers:");
for(int i = 0; i < 10; i++)
writer.Write(i + " ");

// Insert a new line.
writer.Write(writer.NewLine);
}

Console.WriteLine("Created file and wrote some thoughts...");
Console.ReadLine();
}

After you run this program, you can examine the contents of this new file (see Figure 20-3). You will find this file under the bin\Debug folder of your current application because you did not specify an absolute path at the time you called CreateText().

image

Figure 20-3. The contents of your *.txt file

Reading from a Text File

Next, you will learn to read data from a file programmatically by using the corresponding StreamReader type. Recall that this class derives from the abstract TextReader, which offers the functionality described in Table 20-9.

Table 20-9. TextReader Core Members

Member

Meaning in Life

Peek()

Returns the next available character (expressed as an integer) without actually changing the position of the reader. A value of -1 indicates you are at the end of the stream.

Read()
ReadAsync()

Reads data from an input stream.

ReadBlock()

ReadBlockAsync()

Reads a specified maximum number of characters from the current stream and writes the data to a buffer, beginning at a specified index.

ReadLine()
ReadLineAsync()

Reads a line of characters from the current stream and returns the data as a string (a null string indicates EOF).

ReadToEnd()
ReadToEndAsync()

Reads all characters from the current position to the end of the stream and returns them as a single string.

If you now extend the current sample application to use a StreamReader, you can read in the textual data from the reminders.txt file, as shown here:

static void Main(string[] args)
{
Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");
...
// Now read data from file.
Console.WriteLine("Here are your thoughts:\n");
using(StreamReader sr = File.OpenText("reminders.txt"))
{
string input = null;
while ((input = sr.ReadLine()) != null)
{
Console.WriteLine (input);
}
}
Console.ReadLine();
}

After you run the program, you will see the character data in reminders.txt displayed to the console.

Directly Creating StreamWriter/StreamReader Types

One of the confusing aspects of working with the types within System.IO is that you can often achieve an identical result using different approaches. For example, you have already seen that you can use the CreateText() method to obtain a StreamWriter with the File orFileInfo type. It so happens that you can work with StreamWriters and StreamReaders another way: by creating them directly. For example, you could retrofit the current application as follows:

static void Main(string[] args)
{
Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");

// Get a StreamWriter and write string data.
using(StreamWriter writer = new StreamWriter("reminders.txt"))
{
...
}

// Now read data from file.
using(StreamReader sr = new StreamReader("reminders.txt"))
{
...
}
}

Although it can be a bit confusing to see so many seemingly identical approaches to file I/O, keep in mind that the end result is greater flexibility. In any case, you are now ready to examine the role of the StringWriter and StringReader classes, given that you have seen how to move character data to and from a given file using the StreamWriter and StreamReader types.

Image Source Code You can find the StreamWriterReaderApp project in the Chapter 20 subdirectory.

Working with StringWriters and StringReaders

You can use the StringWriter and StringReader types to treat textual information as a stream of in- memory characters. This can prove helpful when you would like to append character-based information to an underlying buffer. The following Console Application project (named StringReaderWriterApp) illustrates this by writing a block of string data to a StringWriter object, rather than to a file on the local hard drive (don’t forget to import System.IO):

static void Main(string[] args)
{
Console.WriteLine("***** Fun with StringWriter / StringReader *****\n");

// Create a StringWriter and emit character data to memory.
using(StringWriter strWriter = new StringWriter())
{
strWriter.WriteLine("Don’t forget Mother’s Day this year...");
// Get a copy of the contents (stored in a string) and dump
// to console.
Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);
}
Console.ReadLine();
}

StringWriter and StreamWriter both derive from the same base class (TextWriter), so the writing logic is more or less identical. However, given the nature of StringWriter, you should also be aware that this class allows you to use the followingGetStringBuilder() method to extract a System.Text.StringBuilder object:

using (StringWriter strWriter = new StringWriter())
{
strWriter.WriteLine("Don’t forget Mother’s Day this year...");
Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);

// Get the internal StringBuilder.
StringBuilder sb = strWriter.GetStringBuilder();
sb.Insert(0, "Hey!! ");
Console.WriteLine("-> {0}", sb.ToString());
sb.Remove(0, "Hey!! ".Length);
Console.WriteLine("-> {0}", sb.ToString());
}

When you want to read from a stream of character data, you can use the corresponding StringReader type, which (as you would expect) functions identically to the related StreamReader class. In fact, the StringReader class does nothing more than override the inherited members to read from a block of character data, rather than from a file, as shown here:

using (StringWriter strWriter = new StringWriter())
{
strWriter.WriteLine("Don’t forget Mother’s Day this year...");
Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);

// Read data from the StringWriter.
using (StringReader strReader = new StringReader(strWriter.ToString()))
{
string input = null;
while ((input = strReader.ReadLine()) != null)
{
Console.WriteLine(input);
}
}
}

Image Source Code You can find the StringReaderWriterApp in the Chapter 20 subdirectory.

Working with BinaryWriters and BinaryReaders

The final writer/reader sets you will examine in this section are BinaryReader and BinaryWriter. Both derive directly from System.Object. These types allow you to read and write discrete data types to an underlying stream in a compact binary format. The BinaryWriterclass defines a highly overloaded Write() method to place a data type in the underlying stream. In addition to the Write() member, BinaryWriter provides additional members that allow you to get or set the Stream-derived type; it also offers support for random access to the data (see Table 20-10).

Table 20-10. BinaryWriter Core Members

Member

Meaning in Life

BaseStream

This read-only property provides access to the underlying stream used with the BinaryWriter object.

Close()

This method closes the binary stream.

Flush()

This method flushes the binary stream.

Seek()

This method sets the position in the current stream.

Write()

This method writes a value to the current stream.

The BinaryReader class complements the functionality offered by BinaryWriter with the members described in Table 20-11.

Table 20-11. BinaryReader Core Members

Member

Meaning in Life

BaseStream

This read-only property provides access to the underlying stream used with the BinaryReader object.

Close()

This method closes the binary reader.

PeekChar()

This method returns the next available character without advancing the position in the stream.

Read()

This method reads a given set of bytes or characters and stores them in the incoming array.

ReadXXXX()

The BinaryReader class defines numerous read methods that grab the next type from the stream (e.g., ReadBoolean(), ReadByte(), and ReadInt32()).

The following example (a Console Application project named BinaryWriterReader) writes a number of data types to a new *.dat file:

static void Main(string[] args)
{
Console.WriteLine("***** Fun with Binary Writers / Readers *****\n");

// Open a binary writer for a file.
FileInfo f = new FileInfo("BinFile.dat");
using(BinaryWriter bw = new BinaryWriter(f.OpenWrite()))
{
// Print out the type of BaseStream.
// (System.IO.FileStream in this case).
Console.WriteLine("Base stream is: {0}", bw.BaseStream);

// Create some data to save in the file.
double aDouble = 1234.67;
int anInt = 34567;
string aString = "A, B, C";

// Write the data.
bw.Write(aDouble);
bw.Write(anInt);
bw.Write(aString);
}
Console.WriteLine("Done!");
Console.ReadLine();
}

Notice how the FileStream object returned from FileInfo.OpenWrite() is passed to the constructor of the BinaryWriter type. Using this technique makes it easy to layer in a stream before writing out the data. Note that the constructor of BinaryWriter takes anyStream-derived type (e.g., FileStream, MemoryStream, or BufferedStream). Thus, writing binary data to memory instead is as simple as supplying a valid MemoryStream object.

To read the data out of the BinFile.dat file, the BinaryReader type provides a number of options. Here, you call various read-centric members to pluck each chunk of data from the file stream:

static void Main(string[] args)
{
...
FileInfo f = new FileInfo("BinFile.dat");
...
// Read the binary data from the stream.
using(BinaryReader br = new BinaryReader(f.OpenRead()))
{
Console.WriteLine(br.ReadDouble());
Console.WriteLine(br.ReadInt32());
Console.WriteLine(br.ReadString());
}
Console.ReadLine();
}

Image Source Code You can find the BinaryWriterReader application in the Chapter 20 subdirectory.

Watching Files Programmatically

Now that you have a better handle on the use of various readers and writers, you’ll look at the role of the FileSystemWatcher class. This type can be quite helpful when you want to monitor (or “watch”) files on your system programmatically. Specifically, you can instruct theFileSystemWatcher type to monitor files for any of the actions specified by the System.IO.NotifyFilters enumeration (many of these members are self-explanatory, but you should still check the .NET Framework 4.6 SDK documentation for more details).

public enum NotifyFilters
{
Attributes, CreationTime,
DirectoryName, FileName,
LastAccess, LastWrite,
Security, Size
}

To begin working with the FileSystemWatcher type, you need to set the Path property to specify the name (and location) of the directory that contains the files you want to monitor, as well as the Filter property that defines the file extensions of the files you want to monitor.

At this point, you may choose to handle the Changed, Created, and Deleted events, all of which work in conjunction with the FileSystemEventHandler delegate. This delegate can call any method matching the following pattern:

// The FileSystemEventHandler delegate must point
// to methods matching the following signature.
void MyNotificationHandler(object source, FileSystemEventArgs e)

You can also handle the Renamed event using the RenamedEventHandler delegate type, which can call methods that match the following signature:

// The RenamedEventHandler delegate must point
// to methods matching the following signature.
void MyRenamedHandler(object source, RenamedEventArgs e)

While you could use the traditional delegate/event syntax to handle each event, you can certainly make use of lambda expression syntax as well (the downloadable code for this project uses lambda syntax, if you are interested).

Next, let’s look at the process of watching a file. Assume you have created a new directory on your C: drive named MyFolder that contains various *.txt files (named whatever you like). The following Console Application project (named MyDirectoryWatcher) monitors the *.txtfiles in the MyFolder directory and prints messages when files are created, deleted, modified, or renamed:

static void Main(string[] args)
{
Console.WriteLine("***** The Amazing File Watcher App *****\n");
// Establish the path to the directory to watch.
FileSystemWatcher watcher = new FileSystemWatcher();
try
{
watcher.Path = @"C:\MyFolder";
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
return;
}
// Set up the things to be on the lookout for.
watcher.NotifyFilter = NotifyFilters.LastAccess
| NotifyFilters.LastWrite
| NotifyFilters.FileName
| NotifyFilters.DirectoryName;

// Only watch text files.
watcher.Filter = "*.txt";

// Add event handlers.
watcher.Changed += new FileSystemEventHandler(OnChanged);
watcher.Created += new FileSystemEventHandler(OnChanged);
watcher.Deleted += new FileSystemEventHandler(OnChanged);
watcher.Renamed += new RenamedEventHandler(OnRenamed);

// Begin watching the directory.
watcher.EnableRaisingEvents = true;

// Wait for the user to quit the program.
Console.WriteLine(@"Press ’q’ to quit app.");
while(Console.Read()!=’q’)
;
}

The following two event handlers simply print the current file modification:

static void OnChanged(object source, FileSystemEventArgs e)
{
// Specify what is done when a file is changed, created, or deleted.
Console.WriteLine("File: {0} {1}!", e.FullPath, e.ChangeType);
}

static void OnRenamed(object source, RenamedEventArgs e)
{
// Specify what is done when a file is renamed.
Console.WriteLine("File: {0} renamed to {1}", e.OldFullPath, e.FullPath);
}

To test this program, run the application and open Windows Explorer. Try renaming your files, creating a *.txt file, deleting a *.txt file, and so forth. You will see various bits of information generated about the state of the text files within your MyFolder, as in this example:

***** The Amazing File Watcher App *****

Press ’q’ to quit app.
File: C:\MyFolder\New Text Document.txt Created!
File: C:\MyFolder\New Text Document.txt renamed to C:\MyFolder\Hello.txt
File: C:\MyFolder\Hello.txt Changed!
File: C:\MyFolder\Hello.txt Changed!
File: C:\MyFolder\Hello.txt Deleted!

Image Source Code You can find the MyDirectoryWatcher application in the Chapter 20 subdirectory.

That wraps up this chapter’s look at fundamental I/O operations within the .NET platform. While you will certainly use these techniques in many of your applications, you might also find that object serialization services can greatly simplify how you persist large amounts of data.

Understanding Object Serialization

The term serialization describes the process of persisting (and possibly transferring) the state of an object into a stream (e.g., file stream and memory stream). The persisted data sequence contains all the necessary information you need to reconstruct (or deserialize) the state of the object for use later. Using this technology makes it trivial to save vast amounts of data (in various formats). In many cases, saving application data using serialization services results in less code than using the readers/writers you find in the System.IO namespace.

For example, assume you want to create a GUI-based desktop application that provides a way for end users to save their preferences (e.g., window color and font size). To do this, you might define a class named UserPrefs that encapsulates 20 or so pieces of field data. Now, if you were to use a System.IO.BinaryWriter type, you would need to save each field of the UserPrefs object manually. Likewise, if you were to load the data from a file back into memory, you would need to use a System.IO.BinaryReader and (once again) manually read in each value to reconfigure a new UserPrefs object.

This is all doable, but you can save yourself a good amount of time by marking the UserPrefs class with the [Serializable] attribute, like so:

[Serializable]
public class UserPrefs
{
public string WindowColor;
public int FontSize;
}

Doing this means that you can persist entire state of the object with only a few lines of code. Without getting hung up on the details for the time being, consider the following Main() method:

static void Main(string[] args)
{
UserPrefs userData= new UserPrefs();
userData.WindowColor = "Yellow";
userData.FontSize = 50;

// The BinaryFormatter persists state data in a binary format.
// You would need to import System.Runtime.Serialization.Formatters.Binary
// to gain access to BinaryFormatter.
BinaryFormatter binFormat = new BinaryFormatter();

// Store object in a local file.
using(Stream fStream = new FileStream("user.dat",
FileMode.Create, FileAccess.Write, FileShare.None))
{
binFormat.Serialize(fStream, userData);
}
Console.ReadLine();
}

.NET object serialization makes it easy to persist objects; however, the processes used behind the scenes are quite sophisticated. For example, when an object is persisted to a stream, all associated data (e.g., base class data and contained objects) are automatically serialized, as well. Therefore, if you attempt to persist a derived class, all data up the chain of inheritance comes along for the ride. As you will see, you use an object graph to represent a set of interrelated objects.

.NET serialization services also allow you to persist an object graph in a variety of formats. The previous code example uses the BinaryFormatter type; therefore, the state of the UserPrefs object is persisted as a compact binary format. You can also persist an object graph into SOAP or XML format using other types. These formats can be quite helpful when you need to ensure that your persisted objects travel well across operating systems, languages, and architectures.

Image Note WCF prefers a slightly different mechanism for serializing objects to/from WCF service operations; it uses the [DataContract] and [DataMember] attributes. You’ll learn more about this in Chapter 25.

Finally, understand that you can persist an object graph into any System.IO.Stream-derived type. In the previous example, you used the FileStream type to persist a UserPrefs object into a local file. However, if you would rather store an object to a specific region of memory, you could use a MemoryStream type instead. All that matters is that the sequence of data correctly represents the state of objects within the graph.

The Role of Object Graphs

As mentioned previously, the CLR will account for all related objects to ensure that data is persisted correctly when an object is serialized. This set of related objects is referred to as an object graph. Object graphs provide a simple way to document how a set of items refer to each other. Be aware that object graphs are not denoting OOP is-a or has-a relationships. Rather, you can read the arrows in an object diagram as “requires” or “depends on.”

Each object in an object graph is assigned a unique numerical value. Keep in mind that the numbers assigned to the members in an object graph are arbitrary and have no real meaning to the outside world.

Once you assign all objects a numerical value, the object graph can record each object’s set of dependencies.

For example, assume you have created a set of classes that model some automobiles (of course). You have a base class named Car, which has-a Radio. Another class named JamesBondCar extends the Car base type. Figure 20-4 shows a possible object graph that models these relationships.

image

Figure 20-4. A simple object graph

When reading object graphs, you can use the phrase depends on or refers to when connecting the arrows. Thus, in Figure 20-4, you can see that the Car refers to the Radio class (given the has-a relationship). JamesBondCar refers to Car (given the is-a relationship), as well as toRadio (it inherits this protected member variable).

Of course, the CLR does not paint pictures in memory to represent a graph of related objects. Rather, the relationship documented in Figure 20-4 is represented by a mathematical formula that looks something like this:

[Car 3, ref 2], [Radio 2], [JamesBondCar 1, ref 3, ref 2]

If you parse this formula, you can see that object 3 (the Car) has a dependency on object 2 (the Radio). Object 2, the Radio, is a lone wolf and requires nobody. Finally, object 1 (the JamesBondCar) has a dependency on object 3, as well as object 2. In any case, when you serialize or deserialize an instance of JamesBondCar, the object graph ensures that the Radio and Car types also participate in the process.

The beautiful thing about the serialization process is that the graph representing the relationships among your objects is established automatically behind the scenes. As you will see later in this chapter, however, you can become more involved in the construction of a given object graph by customizing the serialization process using attributes and interfaces.

Image Note Strictly speaking, the XmlSerializer type (described later in this chapter) does not persist state using object graphs; however, this type still serializes and deserializes related objects in a predictable manner.

Configuring Objects for Serialization

To make an object available to .NET serialization services, all you need to do is decorate each related class (or structure) with the [Serializable] attribute. If you determine that a given type has some member data that should not (or perhaps cannot) participate in the serialization scheme, you can mark such fields with the [NonSerialized] attribute. This can be helpful if you would like to reduce the size of the persisted data and you have member variables in a serializable class that do not need to be remembered (e.g., fixed values, random values, and transient data).

Defining Serializable Types

To get the ball rolling, create a new Console Application project named SimpleSerialize. Insert a new class named Radio, which has been marked [Serializable], excluding a single member variable (radioID) that has been marked [NonSerialized] and will, therefore, not be persisted into the specified data stream.

[Serializable]
public class Radio
{
public bool hasTweeters;
public bool hasSubWoofers;
public double[] stationPresets;

[NonSerialized]
public string radioID = "XF-552RR6";
}

Next, insert two additional class types to represent the JamesBondCar and Car classes, both of which are also marked [Serializable] and define the following pieces of field data:

[Serializable]
public class Car
{
public Radio theRadio = new Radio();
public bool isHatchBack;
}

[Serializable]
public class JamesBondCar : Car
{
public bool canFly;
public bool canSubmerge;
}

Be aware that you cannot inherit the [Serializable] attribute from a parent class. Therefore, if you derive a class from a type marked [Serializable], the child class must be marked [Serializable] as well, or it cannot be persisted. In fact, all objects in an object graph must be marked with the [Serializable] attribute. If you attempt to serialize a nonserializable object using the BinaryFormatter or SoapFormatter, you will receive a SerializationException at runtime.

Public Fields, Private Fields, and Public Properties

Notice that in each of these classes you define the field data as public; this helps keep the example simple. Of course, private data exposed using public properties would be preferable from an OO point of view. Also, for the sake of simplicity, this example does not define any custom constructors on these types; therefore, all unassigned field data will receive the expected default values.

OO design principles aside, you might wonder how the various formatters expect a type’s field data to be defined in order to be serialized into a stream. The answer is that it depends. If you persist an object’s state using the BinaryFormatter or SoapFormatter, it makes absolutely no difference. These types are programmed to serialize all serializable fields of a type, regardless of whether they are public fields, private fields , or private fields exposed through public properties. Recall, however, that if you have points of data that you do not want to be persisted into the object graph, you can selectively mark public or private fields as [NonSerialized], as you do with the string field of the Radio type.

The situation is quite different if you use the XmlSerializer type, however. This type will only serialize public data fields or private data exposed by public properties. Private data not exposed from properties will be ignored. For example, consider the following serializable Persontype:

[Serializable]
public class Person
{
// A public field.
public bool isAlive = true;

// A private field.
private int personAge = 21;

// Public property/private data.
private string fName = string.Empty;
public string FirstName
{
get { return fName; }
set { fName = value; }
}
}

If you processed the preceding with BinaryFormatter or SoapFormatter, you would find that the isAlive, personAge, and fName fields are saved into the selected stream. However, the XmlSerializer would not save the value of personAge because this piece of private data is not encapsulated by a public type property. If you wanted to persist the age of the person with the XmlSerializer, you would need to define the field publicly or encapsulate the private member using a public property.

Choosing a Serialization Formatter

After you configure your types to participate in the .NET serialization scheme by applying the necessary attributes, your next step is to choose which format (binary, SOAP, or XML) you should use when persisting your object’s state. Each possibility is represented by the following classes:

· BinaryFormatter

· SoapFormatter

· XmlSerializer

The BinaryFormatter type serializes your object’s state to a stream using a compact binary format. This type is defined within the System.Runtime.Serialization.Formatters.Binary namespace that is part of mscorlib.dll. If you want to gain access to this type, you can specify the following C# using directive:

// Gain access to the BinaryFormatter in mscorlib.dll.
using System.Runtime.Serialization.Formatters.Binary;

The SoapFormatter type persists an object’s state as a SOAP message (the standard XML format for passing messages to/from a SOAP-based web service). This type is defined within the System.Runtime.Serialization.Formatters.Soap namespace, which is defined in a separate assembly. Thus, to format your object graph into a SOAP message, you must first set a reference to System.Runtime.Serialization.Formatters.Soap.dll using the Visual Studio Add Reference dialog box and then specify the following C# using directive:

// Must reference System.Runtime.Serialization.Formatters.Soap.dll.
using System.Runtime.Serialization.Formatters.Soap;

Finally, if you want to persist a tree of objects as an XML document, you can use the XmlSerializer type . To use this type, you need to specify that you are using the System.Xml.Serialization namespace and set a reference to the assembly System.Xml.dll. As luck would have it, all Visual Studio project templates automatically reference System.Xml.dll; therefore, all you need to do is use the following namespace:

// Defined within System.Xml.dll.
using System.Xml.Serialization;

The IFormatter and IRemotingFormatter Interfaces

Regardless of which formatter you choose to use, be aware that all of them derive directly from System.Object, so they do not share a common set of members from a serialization-centric base class. However, the BinaryFormatter and SoapFormatter types do support common members through the implementation of the IFormatter and IRemotingFormatter interfaces (strange as it might seem, the XmlSerializer implements neither).

System.Runtime.Serialization.IFormatter defines the core Serialize() and Deserialize() methods , which do the grunt work to move your object graphs into and out of a specific stream. Beyond these members, IFormatter defines the following few properties that the implementing type uses behind the scenes:

public interface IFormatter
{
SerializationBinder Binder { get; set; }
StreamingContext Context { get; set; }
ISurrogateSelector SurrogateSelector { get; set; }
object Deserialize(Stream serializationStream);
void Serialize(Stream serializationStream, object graph);
}

The System.Runtime.Remoting.Messaging.IRemotingFormatter interface (which is leveraged internally by the .NET remoting layer) overloads the Serialize() and Deserialize() members into a manner more appropriate for distributed persistence. Note thatIRemotingFormatter derives from the more general IFormatter interface.

public interface IRemotingFormatter : IFormatter
{
object Deserialize(Stream serializationStream, HeaderHandler handler);
void Serialize(Stream serializationStream, object graph, Header[] headers);
}

Although you might not need to interact directly with these interfaces for most of your serialization endeavors, recall that interface-based polymorphism allows you to hold an instance of BinaryFormatter or SoapFormatter using an IFormatter reference. Therefore, if you want to build a method that can serialize an object graph using either of these classes, you could write the following:

static void SerializeObjectGraph(IFormatter itfFormat,
Stream destStream, object graph)
{
itfFormat.Serialize(destStream, graph);
}

Type Fidelity Among the Formatters

The most obvious difference among the three formatters is how the object graph is persisted to the stream (binary, SOAP, or XML). You should also be aware of a few more subtle points of distinction, specifically, how the formatters contend with type fidelity. When you use theBinaryFormatter type, it will persist not only the field data of the objects in the object graph but also each type’s fully qualified name and the full name of the defining assembly (name, version, public key token, and culture). These extra points of data make the BinaryFormatter an ideal choice when you want to transport objects by value (e.g., as a full copy) across machine boundaries for .NET-centric applications.

The SoapFormatter persists traces of the assembly of origin through the use of an XML namespace. For example, recall the Person type earlier in this chapter. If this type were persisted as a SOAP message, you would find that the opening element of Person is qualified by the generated xmlns. Consider this partial definition, paying special attention to the a1 XML namespace:

<a1:Person id="ref-1" xmlns:a1=
"http://schemas.microsoft.com/clr/nsassem/SimpleSerialize/MyApp%2C%20
Version%3D1.0.0.0%2C%20Culture%3Dneutral%2C%20PublicKeyToken%3Dnull">
<isAlive>true</isAlive>
<personAge>21</personAge>
<fName id="ref-3">Mel</fName>
</a1:Person>

However, the XmlSerializer does not attempt to preserve full type fidelity; therefore, it does not record the type’s fully qualified name or assembly of origin. This might seem like a limitation at first glance, but XML serialization is used by classic .NET web services, which can be called from clients on any platform (not just .NET). This means that there is no point serializing full .NET type metadata. Here is a possible XML representation of the Person type:

<?xml version="1.0"?>
<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<isAlive>true</isAlive>
<PersonAge>21</PersonAge>
<FirstName>Frank</FirstName>
</Person>

If you want to persist an object’s state in a manner that can be used by any operating system (e.g., Windows, Mac OS X, and various Linux distributions), application framework (e.g., .NET, Java Enterprise Edition, and COM), or programming language, you do not want to maintain full type fidelity because you cannot assume all possible recipients can understand .NET-specific data types. Given this, SoapFormatter and XmlSerializer are ideal choices when you need to ensure as broad a reach as possible for the persisted tree of objects.

Serializing Objects Using the BinaryFormatter

You can use the BinaryFormatter type to illustrate how easy it is to persist an instance of the JamesBondCar to a physical file. Again, the two key methods of the BinaryFormatter type to be aware of are Serialize() and Deserialize().

· Serialize(): Persists an object graph to a specified stream as a sequence of bytes

· Deserialize(): Converts a persisted sequence of bytes to an object graph

Assume you have created an instance of JamesBondCar, modified some state data, and want to persist your spy mobile into a *.dat file. Begin by creating the *.dat file itself. You can achieve this by creating an instance of the System.IO.FileStream type. At this point, you can create an instance of the BinaryFormatter and pass in the FileStream and object graph to persist. Consider the following Main() method:

// Be sure to import the System.Runtime.Serialization.Formatters.Binary
// and System.IO namespaces.
static void Main(string[] args)
{
Console.WriteLine("***** Fun with Object Serialization *****\n");

// Make a JamesBondCar and set state.
JamesBondCar jbc = new JamesBondCar();
jbc.canFly = true;
jbc.canSubmerge = false;
jbc.theRadio.stationPresets = new double[]{89.3, 105.1, 97.1};
jbc.theRadio.hasTweeters = true;

// Now save the car to a specific file in a binary format.
SaveAsBinaryFormat(jbc, "CarData.dat");
Console.ReadLine();
}

You implement the SaveAsBinaryFormat() method like this:

static void SaveAsBinaryFormat(object objGraph, string fileName)
{
// Save object to a file named CarData.dat in binary.
BinaryFormatter binFormat = new BinaryFormatter();

using(Stream fStream = new FileStream(fileName,
FileMode.Create, FileAccess.Write, FileShare.None))
{
binFormat.Serialize(fStream, objGraph);
}
Console.WriteLine("=> Saved car in binary format!");
}

The BinaryFormatter.Serialize() method is the member responsible for composing the object graph and moving the byte sequence to some Stream-derived type. In this case, the stream happens to be a physical file. You could also serialize your object types to any Stream-derived type, such as a memory location or network stream.

After you run your program, you can view the contents of the CarData.dat file that represents this instance of the JamesBondCar by navigating to the \bin\Debug folder of the current project. Figure 20-5 shows this file opened within Visual Studio.

image

Figure 20-5. JamesBondCar serialized using a BinaryFormatter

Deserializing Objects Using the BinaryFormatter

Now suppose you want to read the persisted JamesBondCar from the binary file back into an object variable. After you open CarData.dat programmatically (with the File.OpenRead() method), you can call the Deserialize() method of the BinaryFormatter. Be aware that Deserialize() returns a general System.Object type, so you need to impose an explicit cast, as shown here:

static void LoadFromBinaryFile(string fileName)
{
BinaryFormatter binFormat = new BinaryFormatter();

// Read the JamesBondCar from the binary file.
using(Stream fStream = File.OpenRead(fileName))
{
JamesBondCar carFromDisk =
(JamesBondCar)binFormat.Deserialize(fStream);
Console.WriteLine("Can this car fly? : {0}", carFromDisk.canFly);
}
}

Notice that when you call Deserialize(), you pass the Stream-derived type that represents the location of the persisted object graph. Once you cast the object back into the correct type, you will find the state data has been retained from the point at which you saved the object.

Serializing Objects Using the SoapFormatter

Your next choice of formatter is the SoapFormatter type, which serializes data in a proper SOAP envelope. In a nutshell, the Simple Object Access Protocol (SOAP) defines a standard process in which you can invoke methods in a platform- and OS-neutral manner.

Assuming you have added a reference to the System.Runtime.Serialization.Formatters.Soap.dll assembly (and imported the System.Runtime.Serialization.Formatters.Soap namespace), you can persist and retrieve a JamesBondCar as a SOAP message simply by replacing each occurrence of BinaryFormatter with SoapFormatter. Consider the following new method of the Program class, which serializes an object to a local file in a SOAP format:

// Be sure to import System.Runtime.Serialization.Formatters.Soap
// and reference System.Runtime.Serialization.Formatters.Soap.dll.
static void SaveAsSoapFormat (object objGraph, string fileName)
{
// Save object to a file named CarData.soap in SOAP format.
SoapFormatter soapFormat = new SoapFormatter();

using(Stream fStream = new FileStream(fileName,
FileMode.Create, FileAccess.Write, FileShare.None))
{
soapFormat.Serialize(fStream, objGraph);
}
Console.WriteLine("=> Saved car in SOAP format!");
}

As before, you use Serialize() and Deserialize() to move the object graph into and out of the stream. If you call this method from Main() and run the application, you can open the resulting *.soap file. Here you can locate the XML elements that mark the stateful values of the current JamesBondCar, as well as the relationship between the objects in the graph by using the #ref tokens (see Figure 20-6).

image

Figure 20-6. JamesBondCar serialized using a SoapFormatter

Serializing Objects Using the XmlSerializer

In addition to the SOAP and binary formatters, the System.Xml.dll assembly provides a third formatter, System.Xml.Serialization.XmlSerializer. You can use this formatter to persist the public state of a given object as pure XML, as opposed to XML data wrapped within a SOAP message. Working with this type is a bit different from working with the SoapFormatter or BinaryFormatter type. Consider the following code, which assumes you have imported the System.Xml.Serialization namespace:

static void SaveAsXmlFormat(object objGraph, string fileName)
{
// Save object to a file named CarData.xml in XML format.
XmlSerializer xmlFormat = new XmlSerializer(typeof(JamesBondCar));

using(Stream fStream = new FileStream(fileName,
FileMode.Create, FileAccess.Write, FileShare.None))
{
xmlFormat.Serialize(fStream, objGraph);
}
Console.WriteLine("=> Saved car in XML format!");
}

The key difference is that the XmlSerializer type requires you to specify type information that represents the class you want to serialize. If you were to look within the newly generated XML file (assuming you call this new method from within Main()), you would find the XML data shown here:

<?xml version="1.0"?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<theRadio>
<hasTweeters>true</hasTweeters>
<hasSubWoofers>false</hasSubWoofers>
<stationPresets>
<double>89.3</double>
<double>105.1</double>
<double>97.1</double>
</stationPresets>
<radioID>XF-552RR6</radioID>
</theRadio>
<isHatchBack>false</isHatchBack>
<canFly>true</canFly>
<canSubmerge>false</canSubmerge>
</JamesBondCar>

Image Note The XmlSerializer demands that all serialized types in the object graph support a default constructor (so be sure to add it back if you define custom constructors). If this is not the case, you will receive an InvalidOperationException at runtime.

Controlling the Generated XML Data

If you have a background in XML technologies, you know that it is often critical to ensure the data within an XML document conforms to a set of rules that establish the validity of the data. Understand that a valid XML document does not have anything to do with the syntactic well-being of the XML elements (e.g., all opening elements must have a closing element). Rather, valid documents conform to agreed- upon formatting rules (e.g., field X must be expressed as an attribute and not a subelement), which are typically defined by an XML schema or document-type definition (DTD) file.

By default, XmlSerializer serializes all public fields/properties as XML elements, rather than as XML attributes. If you want to control how the XmlSerializer generates the resulting XML document, you can decorate types with any number of additional .NET attributes from theSystem.Xml.Serialization namespace. Table 20-12 documents some (but not all) of the .NET attributes that influence how XML data is encoded to a stream.

Table 20-12. Select Attributes of the System.Xml.Serialization Namespace

.NET Attribute

Meaning in Life

[XmlAttribute]

You can use this .NET attribute on a public field or property in a class to tell XmlSerializer to serialize the data as an XML attribute (rather than as a subelement).

[XmlElement]

The field or property will be serialized as an XML element named as you so choose.

[XmlEnum]

This attribute provides the element name of an enumeration member.

[XmlRoot]

This attribute controls how the root element will be constructed (namespace and element name).

[XmlText]

The property or field will be serialized as XML text (i.e., the content between the start tag and the end tag of the root element).

[XmlType]

This attribute provides the name and namespace of the XML type.

This simple example illustrates how the field data of JamesBondCar is currently persisted as XML:

<?xml version="1.0" encoding="utf-8"?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
...
<canFly>true</canFly>
<canSubmerge>false</canSubmerge>
</JamesBondCar>

If you want to specify a custom XML namespace that qualifies the JamesBondCar and encodes the canFly and canSubmerge values as XML attributes, you can do so by modifying the C# definition of JamesBondCar, like so:

[Serializable, XmlRoot(Namespace = "http://www.MyCompany.com")]
public class JamesBondCar : Car
{
[XmlAttribute]
public bool canFly;
[XmlAttribute]
public bool canSubmerge;
}

This yields the following XML document (note the opening <JamesBondCar> element):

<?xml version="1.0"""?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
canFly="true" canSubmerge="false"
xmlns="http://www.MyCompany.com">
...
</JamesBondCar>

Of course, you can use many other .NET attributes to control how the XmlSerializer generates the resulting XML document. For full details, look up the System.Xml.Serialization namespace in the .NET Framework 4.6 SDK documentation.

Serializing Collections of Objects

Now that you have seen how to persist a single object to a stream, you’re ready to examine how to save a set of objects. As you might have noticed, the Serialize() method of the IFormatter interface does not provide a way to specify an arbitrary number of objects as input (only a single System.Object). On a related note, the return value of Deserialize() is, again, a single System.Object (the same basic limitation holds true for XmlSerializer).

public interface IFormatter
{
...
object Deserialize(Stream serializationStream);
void Serialize(Stream serializationStream, object graph);
}

Recall that the System.Object represents a complete tree of objects. Given this, if you pass in an object that has been marked as [Serializable] and contains other [Serializable] objects, the entire set of objects is persisted in a single method call. As luck would have it, most of the types you find in the System.Collections and System.Collections.Generic namespaces have already been marked as [Serializable]. Therefore, if you would like to persist a set of objects, simply add the desired set to the container (such as a normal array, an ArrayList or a List<T>) and serialize the object to your stream of choice.

Now assume that you want to update the JamesBondCar class with a two-argument constructor so you can set a few pieces of state data (note that you add back the default constructor as required by the XmlSerializer).

[Serializable,
XmlRoot(Namespace = "http://www.MyCompany.com")]
public class JamesBondCar : Car
{
public JamesBondCar(bool skyWorthy, bool seaWorthy)
{
canFly = skyWorthy;
canSubmerge = seaWorthy;
}
// The XmlSerializer demands a default constructor!
public JamesBondCar(){}
...
}

With this, you can now persist any number of JamesBondCars.

static void SaveListOfCars()
{
// Now persist a List<T> of JamesBondCars.
List<JamesBondCar> myCars = new List<JamesBondCar>();
myCars.Add(new JamesBondCar(true, true));
myCars.Add(new JamesBondCar(true, false));
myCars.Add(new JamesBondCar(false, true));
myCars.Add(new JamesBondCar(false, false));

using(Stream fStream = new FileStream("CarCollection.xml",
FileMode.Create, FileAccess.Write, FileShare.None))
{
XmlSerializer xmlFormat = new XmlSerializer(typeof(List<JamesBondCar>));
xmlFormat.Serialize(fStream, myCars);
}
Console.WriteLine("=> Saved list of cars!");
}

You use XmlSerializer here, so you are required to specify type information for each of the subobjects within the root object (List<JamesBondCar>, in this case). However, the logic would be even more straightforward if you were to use the BinaryFormatter orSoapFormatter type instead, as shown here:

static void SaveListOfCarsAsBinary()
{
// Save ArrayList object (myCars) as binary.
List<JamesBondCar> myCars = new List<JamesBondCar>();

BinaryFormatter binFormat = new BinaryFormatter();
using(Stream fStream = new FileStream("AllMyCars.dat",
FileMode.Create, FileAccess.Write, FileShare.None))
{
binFormat.Serialize(fStream, myCars);
}
Console.WriteLine("=> Saved list of cars in binary!");
}

Image Source Code The SimpleSerialize application is included in the Chapter 20 subdirectory.

Customizing the Soap/Binary Serialization Process

In a majority of cases, the default serialization scheme provided by the .NET platform will be exactly what you require. Simply apply the [Serializable] attribute to your related types and pass the tree of objects to your formatter of choice for processing. In some cases, however, you might want to become more involved with how a tree is constructed and handled during the serialization process. For example, perhaps you have a business rule that says all field data must be persisted using a particular format, or perhaps you need to add additional bits of data to the stream that do not map directly to fields in the object being persisted (e.g., timestamps and unique identifiers).

When you want to become more involved with the process of object serialization, the System.Runtime.Serialization namespace provides several types that allow you to do so. Table 20-13 describes some of the core types you should be aware of.

Table 20-13. System.Runtime.Serialization Namespace Core Types

Type

Meaning in Life

ISerializable

You can implement this interface on a [Serializable] type to control its serialization and deserialization.

ObjectIDGenerator

This type generates IDs for members in an object graph.

[OnDeserialized]

This attribute allows you to specify a method that will be called immediately after the object has been deserialized.

[OnDeserializing]

This attribute allows you to specify a method that will be called before the deserialization process.

[OnSerialized]

This attribute allows you to specify a method that will be called immediately after the object has been serialized.

[OnSerializing]

This attribute allows you to specify a method that will be called before the serialization process.

[OptionalField]

This attribute allows you to define a field on a type that can be missing from the specified stream.

[SerializationInfo]

In essence, this class is a property bag that maintains name-value pairs representing the state of an object during the serialization process.

A Deeper Look at Object Serialization

Before you examine various ways that you can customize the serialization process, you will find it helpful to take a deeper look at what takes place behind the scenes. When the BinaryFormatter serializes an object graph, it is in charge of transmitting the following information into the specified stream:

· The fully qualified name of the objects in the graph (e.g., MyApp.JamesBondCar)

· The name of the assembly defining the object graph (e.g., MyApp.exe)

· An instance of the SerializationInfo class that contains all stateful data maintained by the members in the object graph

During the deserialization process, the BinaryFormatter uses this same information to build an identical copy of the object, using the information extracted from the underlying stream. SoapFormatter uses a quite similar process.

Image Note Recall that the XmlSerializer does not persist a type’s fully qualified name or the name of the defining assembly; this behavior helps keep the state of the object as mobile as possible. This type is concerned only with persisting exposed public data.

Beyond moving the required data into and out of a stream, formatters also analyze the members in the object graph for the following pieces of infrastructure:

· A check is made to determine whether the object is marked with the [Serializable] attribute. If the object is not, a SerializationException is thrown.

· If the object is marked [Serializable], a check is made to determine whether the object implements the ISerializable interface. If this is the case, GetObjectData() is called on the object.

· If the object does not implement ISerializable, the default serialization process is used, serializing all fields not marked as [NonSerialized].

In addition to determining whether the type supports ISerializable, formatters are also responsible for discovering whether the types in question support members that have been adorned with the [OnSerializing], [OnSerialized], [OnDeserializing], or[OnDeserialized] attributes. You’ll examine the role of these attributes in momentarily, but first you need to look at the role of ISerializable.

Customizing Serialization Using ISerializable

Objects that are marked [Serializable] have the option of implementing the ISerializable interface. Doing so lets you get “involved” with the serialization process and perform any pre- or post-data formatting.

The ISerializable interface is quite simple, given that it defines only a single method, GetObjectData().

// When you wish to tweak the serialization process,
// implement ISerializable.
public interface ISerializable
{
void GetObjectData(SerializationInfo info,
StreamingContext context);
}

The GetObjectData() method is called automatically by a given formatter during the serialization process. The implementation of this method populates the incoming SerializationInfo parameter with a series of name-value pairs that (typically) map to the field data of the object being persisted. SerializationInfo defines numerous variations on the overloaded AddValue() method, as well as a small set of properties that allow the type to get and set the type’s name, defining assembly, and member count. Here is a partial snapshot:

public sealed class SerializationInfo
{
public SerializationInfo(Type type, IFormatterConverter converter);
public string AssemblyName { get; set; }
public string FullTypeName { get; set; }
public int MemberCount { get; }
public void AddValue(string name, short value);
public void AddValue(string name, ushort value);
public void AddValue(string name, int value);
...

}

Types that implement the ISerializable interface must also define a special constructor that takes the following signature:

// You must supply a custom constructor with this signature
// to allow the runtime engine to set the state of your object.
[Serializable]
class SomeClass : ISerializable
{
protected SomeClass (SerializationInfo si, StreamingContext ctx) {...}
...
}

Notice that the visibility of this constructor is set as protected. This is permissible because the formatter will have access to this member, regardless of its visibility. These special constructors tend to be marked as protected (or private for that matter) to ensure that the casual object user can never create an object in this manner. The first parameter of this constructor is an instance of the SerializationInfo type (which you’ve seen previously).

The second parameter of this special constructor is a StreamingContext type, which contains information regarding the source of the bits. The most informative member of StreamingContext is the State property, which represents a value from theStreamingContextStates enumeration. The values of this enumeration represent the basic composition of the current stream.

Unless you intend to implement some low-level custom remoting services, you will seldom need to deal with this enumeration directly. Nevertheless, here are the possible names of the StreamingContextStates enum (consult the .NET Framework 4.6 SDK documentation for full details):

public enum StreamingContextStates
{
CrossProcess,
CrossMachine,
File,
Persistence,
Remoting,
Other,
Clone,
CrossAppDomain,
All
}

Now let’s look at how to customize the serialization process using ISerializable. Assume you have a new Console Application project (named CustomSerialization) that defines a class type containing two points of string data. Also assume that you must ensure that the stringobjects are serialized to the stream in all uppercase and deserialized from the stream in lowercase. To account for such rules, you could implement ISerializable like this (be sure to import the System.Runtime.Serialization namespace):

[Serializable]
class StringData : ISerializable
{
private string dataItemOne = "First data block";
private string dataItemTwo= "More data";

public StringData(){}
protected StringData(SerializationInfo si, StreamingContext ctx)
{
// Rehydrate member variables from stream.
dataItemOne = si.GetString("First_Item").ToLower();
dataItemTwo = si.GetString("dataItemTwo").ToLower();
}

void ISerializable.GetObjectData(SerializationInfo info, StreamingContext ctx)
{
// Fill up the SerializationInfo object with the formatted data.
info.AddValue("First_Item", dataItemOne.ToUpper());
info.AddValue("dataItemTwo", dataItemTwo.ToUpper());
}
}

Notice that when you fill the SerializationInfo type with the GetObjectData() method, you are not required to name the data points identically to the type’s internal member variables. This can obviously be helpful if you need to further decouple the type’s data from the persisted format. Be aware, however, that you will need to obtain the values from the special, protected constructor using the same names assigned within GetObjectData().

To test your customization, assume that you want to persist an instance of MyStringData using a SoapFormatter (so update your assembly references and imports accordingly), as follows:

static void Main(string[] args)
{
Console.WriteLine("***** Fun with Custom Serialization *****");

// Recall that this type implements ISerializable.
StringData myData = new StringData();

// Save to a local file in SOAP format.
SoapFormatter soapFormat = new SoapFormatter();
using(Stream fStream = new FileStream("MyData.soap",
FileMode.Create, FileAccess.Write, FileShare.None))
{
soapFormat.Serialize(fStream, myData);
}
Console.ReadLine();
}

When you view the resulting *.soap file, you will see that the string fields have been persisted in uppercase, as so:

<SOAP-ENV:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"
xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:clr="http://schemas.microsoft.com/soap/encoding/clr/1.0"
SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<SOAP-ENV:Body>

<a1:StringData id="ref-1" ...>
<First_Item id="ref-3">FIRST DATA BLOCK</First_Item>
<dataItemTwo id="ref-4">MORE DATA</dataItemTwo>
</a1:StringData>
</SOAP-ENV:Body>

</SOAP-ENV:Envelope>

Customizing Serialization Using Attributes

Although implementing the ISerializable interface is one way to customize the serialization process, the preferred way to customize the serialization process is to define methods that are attributed with any of the new serialization-centric attributes: [OnSerializing],[OnSerialized], [OnDeserializing], or [OnDeserialized]. Using these attributes is less cumbersome than implementing ISerializable because you do not need to interact manually with an incoming SerializationInfo parameter. Instead, you can modify your state data directly, while the formatter operates on the type.

Image Note You can find these serialization attributes defined in the System.Runtime.Serialization namespace.

When you define method decorated with these attributes, you must define the methods so they receive a StreamingContext parameter and return nothing (otherwise, you will receive a runtime exception). Note that you are not required to account for each of the serialization-centric attributes, and you can simply contend with the stages of serialization you want to intercept. The following snippet illustrates this. Here, a new [Serializable] type has the same requirements as StringData, but this time you account for using the [OnSerializing] and[OnDeserialized] attributes:

[Serializable]
class MoreData
{
private string dataItemOne = "First data block";
private string dataItemTwo= "More data";

[OnSerializing]
private void OnSerializing(StreamingContext context)
{
// Called during the serialization process.
dataItemOne = dataItemOne.ToUpper();
dataItemTwo = dataItemTwo.ToUpper();
}

[OnDeserialized]
private void OnDeserialized(StreamingContext context)
{
// Called when the deserialization process is complete.
dataItemOne = dataItemOne.ToLower();
dataItemTwo = dataItemTwo.ToLower();
}
}

If you were to serialize this new type, you would again find that the data has been persisted as uppercase and deserialized as lowercase.

Image Source Code You can find the CustomSerialization project in the Chapter 20 subdirectory.

With this example behind you, your exploration of the core details of object serialization services, including various ways to customize the process, is complete. As you have seen, the serialization and deserialization process makes it easy to persist large amounts of data, and it can be less labor-intensive than working with the various reader/writer classes of the System.IO namespace.

Summary

You began this chapter by examining the use of the Directory(Info) and File(Info) types. As you learned, these classes allow you to manipulate a physical file or directory on your hard drive. Next, you examined a number of classes derived from the abstract Stream class. Given that Stream-derived types operate on a raw stream of bytes, the System.IO namespace provides numerous reader/writer types (e.g., StreamWriter, StringWriter, and BinaryWriter) that simplify the process. Along the way, you also checked out the functionality provided byDriveType, learned how to monitor files using the FileSystemWatcher type, and saw how to interact with streams in an asynchronous manner.

This chapter also introduced you to the topic of object serialization services. As you have seen, the .NET platform uses an object graph to account for the full set of related objects that you want to persist to a stream. As long as each member in the object graph has been marked with the[Serializable] attribute, the data is persisted using your format of choice (binary or SOAP).

You also learned that it is possible to customize the out-of-the-box serialization process using two possible approaches. First, you learned how to implement the ISerializable interface (and support a special private constructor), which enables you to become more involved with how formatters persist the supplied data. Second, you learned about a set of .NET attributes that simplify the process of custom serialization. All you need to do is apply the [OnSerializing], [OnSerialized], [OnDeserializing], or [OnDeserialized] attribute on members that take a StreamingContext parameter, and the formatters will invoke them accordingly.