Pro C#10 CHAPTER 19 File I/O and Object Serialization

PART VI

File Handling, Object Serialization, and Data Access

CHAPTER 19

File I/O and Object Serialization

When you create desktop applications, the ability to save information between user sessions is commonplace. This chapter examines several 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 the System.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.

■ Note To ensure you can run each of the examples in this chapter, start Visual Studio with administrative rights (just right-click the Visual Studio 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 Core, 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.
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 19-1 outlines the core (nonabstract) classes, providing a road map of the functionality in System.IO.

© Andrew Troelsen, Phil Japikse 2022
A. Troelsen and P. Japikse, Pro C# 10 with .NET 6, https://doi.org/10.1007/978-1-4842-7869-7_19

751

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 later.
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 several 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 related FileInfo and DirectoryInfo types expose similar functionality as instance-level methods (therefore, you must allocate them with the new keyword). The Directory and File classes directly extend System.Object, while DirectoryInfo and FileInfo derive from the abstract FileSystemInfo type.

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, etc.) about a given file or directory. Table 19-2 lists some core properties of interest.

Table 19-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 19-3.

Table 19-3. Key Members of the DirectoryInfo Type

Member Meaning in Life
Create() CreateSubdirectory() Creates 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();

The path syntax used in the previous example is Windows-centric. If you are developing .NET applications for different platforms, you should use the Path.VolumeSeparatorChar and Path.
DirectorySeparatorChar constructs, which will yield the appropriate characters based on the platform.
Update the previous code to the following:

DirectoryInfo dir3 = new DirectoryInfo(
$@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}MyCode{Path.DirectorySeparator Char}Testing");

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 import System and System.IO. Update your Program.cs file with the following new static method that creates a new DirectoryInfo object mapped to C:\Windows (adjust your path if need be), which displays several interesting statistics:

Console.WriteLine(" Fun with Directory(Info) \n"); ShowWindowsDirectoryInfo();
Console.ReadLine();

static void ShowWindowsDirectoryInfo()
{
// Dump directory information. If you are not on Windows, plug in another directory
DirectoryInfo dir = new DirectoryInfo($@"C{Path.VolumeSeparatorChar}{Path. DirectorySeparatorChar}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 like the following:

Fun with Directory(Info)
Directory Info FullName: C:\Windows
Name: Windows Parent:
Creation: 3/19/2019 00:37:22 Attributes: Directory
Root: C:\


■Note If you are not on a Windows machine, the output from these samples will show a different directory separator.

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 the C:\Windows\Web\Wallpaper directory (update this directory
if necessary to one that has images on your machine).

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). Create the following static method in the Program.cs file:

static void DisplayImageFiles()
{
DirectoryInfo dir = new DirectoryInfo($@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Windows{Path. DirectorySeparatorChar}Web{Path.DirectorySeparatorChar}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 application execution directory (denoted with the .) with some custom subdirectories:

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

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

// Create \MyFolder2\Data off application directory.
dir.CreateSubdirectory(
$@"MyFolder2{Path.DirectorySeparatorChar}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:

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

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

// Capture returned DirectoryInfo object.
DirectoryInfo myDataFolder = dir.CreateSubdirectory(
$@"MyFolder2{Path.DirectorySeparatorChar}Data");

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

If you call this method from the top-level statements and examine your Windows directory using Windows Explorer, you will see that the new subdirectories are present and accounted for.

Working with the Directory Type
You have seen the DirectoryInfo type in action; now you are 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("MyFolder");

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

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.cs file defined within a new Console Application project named DriveInfoApp:

// 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: 284131119104 Format: NTFS
Label: OS

Name: M:\ Type: Network
Free space: 4711871942656 Format: NTFS
Label: DigitalMedia

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

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 functionalities inherited by FileSystemInfo, you can find some core members unique to the FileInfo class, which are described in Table 19-4.

Table 19-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 filename
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 will find it helpful to examine various ways to obtain a file handle using the FileInfo class type.

The FileInfo.Create() Method
The next set of examples are all in a Console Application named SimpleFileIO. One way you can create a file handle is to use the FileInfo.Create() method, like so:

Console.WriteLine(" Simple IO with the File Type \n");
//Change to a folder on your machine that you have read/write access to, or run as administrator
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}temp{Path. DirectorySeparatorChar}Test.dat";
// Make a new file on the C drive. FileInfo f = new FileInfo(fileName); FileStream fs = f.Create();

// Use the FileStream object…

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

■ Note These examples might require running Visual Studio as an Administrator, depending on your user permissions and system configuration.

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 on that 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 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:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";

//wrap the file stream in a using statement
// Defining a using scope for file I/O FileInfo f1 = new FileInfo(fileName); using (FileStream fs1 = f1.Create())
{
// Use the FileStream object…
}
f1.Delete();

■ Note Almost all of these examples in this chapter include using statements. I could have used the new using declaration syntax but chose not to in this rewrite to keep the examples focused on the System.IO components that we are examining.

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:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";

// Make a new file via FileInfo.Open().
FileInfo f2 = new FileInfo(fileName); using(FileStream fs2 = f2.Open(FileMode.OpenOrCreate,
FileAccess.ReadWrite, FileShare.None))
{
// Use the FileStream object…
}
f2.Delete();

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 19-5 for details), like so:

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

Table 19-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
{
None, Read, Write, ReadWrite, Delete,
Inheritable
}

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 FileStream object, without the need to supply various enumeration values. Like FileInfo.Create() and FileInfo.Open(), OpenRead() and OpenWrite() return a FileStream object.
Note that the OpenRead() method requires the file to already exist. The following code creates the file and then closes the FileStream so it can be used by the OpenRead() method:

f3.Create().Close();

Here are the full examples:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";

// Get a FileStream object with read-only permissions.
FileInfo f3 = new FileInfo(fileName);
//File must exist before using OpenRead f3.Create().Close();
using(FileStream readOnlyStream = f3.OpenRead())
{
// Use the FileStream object…
}
f3.Delete();

// Now get a FileStream object with write-only permissions.
FileInfo f4 = new FileInfo(fileName); using(FileStream writeOnlyStream = f4.OpenWrite())
{
// Use the FileStream object…
}
f4.Delete();

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 named boot.ini on your C: drive, the following snippet gives you access to its contents:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";

// Get a StreamReader object.
//If not on a Windows machine, change the file name accordingly
FileInfo f5 = new FileInfo(fileName);
//File must exist before using OpenText f5.Create().Close();
using(StreamReader sreader = f5.OpenText())
{
// Use the StreamReader object…
}
f5.Delete();

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:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";

FileInfo f6 = new FileInfo(fileName); using(StreamWriter swriter = f6.CreateText())
{
// Use the StreamWriter object…
}
f6.Delete();
FileInfo f7 = new FileInfo(fileName); using(StreamWriter swriterAppend = f7.AppendText())
{
// Use the StreamWriter object…
}
f7.Delete();

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. Note that OpenText() and OpenRead() require the file to already exist. To see this in action, you can simplify each of the previous FileStream examples by using the File type instead, like so:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";

//Using File instead of FileInfo
using (FileStream fs8 = File.Create(fileName))
{
// Use the FileStream object…
}
File.Delete(fileName);
// Make a new file via FileInfo.Open().
using(FileStream fs9 = File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
// Use the FileStream object…
}
// Get a FileStream object with read-only permissions.
using(FileStream readOnlyStream = File.OpenRead(fileName))
{}
File.Delete(fileName);
// Get a FileStream object with write-only permissions.
using(FileStream writeOnlyStream = File.OpenWrite(fileName))
{}
// Get a StreamReader object.
using(StreamReader sreader = File.OpenText(fileName))
{}
File.Delete(fileName);
// Get some StreamWriters.
using(StreamWriter swriter = File.CreateText(fileName))
{}
File.Delete(fileName);

using(StreamWriter swriterAppend = File.AppendText(fileName))
{}
File.Delete(fileName);

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

Table 19-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 the underlying file handle. For example, the following code persists the string data into a new file on the C: drive (and reads it into memory) with minimal fuss:

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(@"tasks.txt", myTasks);

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

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.

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 will 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).

■ 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 19-7 helps you understand the functionality provided by the Stream class by describing its core members.

Table 19-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() WriteAsync() 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 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 the System.
Text namespace is 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 that FileStream 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.
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 code:

using System.Text;

// Don’t forget to import the System.Text namespaces.
Console.WriteLine(" Fun with FileStreams \n");

// Obtain a FileStream object.
using(FileStream fStream = File.Open("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();
}
File.Delete("myMessage.dat");

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.
As mentioned previously, the System.IO namespace provides several reader and writer types that encapsulate the details of working with Stream-derived types.

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 work by default with Unicode characters; however, you can change this by supplying a properly configured System.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 functionalities 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 19-8 describes the core members of the abstract TextWriter base class.

Table 19-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.

■ 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. The following code 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.

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();
//File.Delete("reminders.txt");

After you run this program, you can examine the contents of this new file. You will find this file in the root directory of your project (Visual Studio Code) or under the bin\Debug\net6.0 folder (Visual Studio) because you did not specify an absolute path at the time you called CreateText() and the file location defaults to the current execution directory of the assembly.

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 19-9.

Table 19-9. TextReader Core Members

Member Meaning in Life
Peek() Returns the next available character (expressed as an integer) without 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:

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 or FileInfo 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:

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 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.

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 (do not forget to import System.Text):

using System.Text;

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 similar. However, given the nature of StringWriter, you should also be aware that this class allows you to use the following GetStringBuilder() 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);
}
}
}

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 BinaryWriter class 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 19-10).

Table 19-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 19-11.

Table 19-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 with using System.
IO) writes some data types to a new *.dat file:

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 any Stream-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 several options. Here, you call various read-centric members to pluck each chunk of data from the file stream:


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();

Watching Files Programmatically
Now that you have a better handle on the use of various readers and writers, you will 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 the FileSystemWatcher type to monitor files for any of the actions specified by the System.IO.NotifyFilters enumeration.

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)

Next, let’s look at the process of watching a file. The following Console Application project (named MyDirectoryWatcher and with a using for System.IO) monitors the *.txt files in the bin\debug\net6.0 directory and prints messages when files are created, deleted, modified, or renamed:

Console.WriteLine(" The Amazing File Watcher App \n");
// Establish the path to the directory to watch. FileSystemWatcher watcher = new FileSystemWatcher(); try
{
watcher.Path = @".";
}
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.
// Specify what is done when a file is changed, created, or deleted.
watcher.Changed += (s, e) =>
Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!"); watcher.Created += (s, e) =>
Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!"); watcher.Deleted += (s, e) =>
Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");
// Specify what is done when a file is renamed.
watcher.Renamed += (s, e) =>
Console.WriteLine($"File: {e.OldFullPath} renamed to {e.FullPath}");
// Begin watching the directory.
watcher.EnableRaisingEvents = true;

// Wait for the user to quit the program.
Console.WriteLine(@"Press ‘q’ to quit app.");
// Raise some events.
using (var sw = File.CreateText("Test.txt"))
{
sw.Write("This is some text");
}
File.Move("Test.txt","Test2.txt"); File.Delete("Test2.txt");

while(Console.Read()!=’q’);

When you run this program, the last lines will create, change, rename, and then delete a text file, raising the events along the way. You can also navigate to the project directory (Visual Studio Code) or the bin\debug\net6.0 directory (Visual Studio) and play with files (with the *.txt extension) and raise additional events.

The Amazing File Watcher App Press ‘q’ to quit app.
File: .\Test.txt Created! File: .\Test.txt Changed!
File: .\Test.txt renamed to .\Test2.txt File: .\Test2.txt Deleted!

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 or memory stream). The persisted data sequence contains all the necessary information you need to reconstruct (or deserialize) the public state of the object for use later. Using this technology makes it trivial to save vast amounts of data. 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 time by using either eXtensible Markup Language (XML) or JavaScript Object Notation (JSON) serialization. Each of these formats enables representing the public state of an object in a single block of text that is usable across platforms and programming languages. Doing this means that you can persist the entire public state of the object with only a few lines of code.

■ Note The BinaryFormatter type, covered in previous editions of this book, is a high security risk, and you should stop using it immediately (http://aka.ms/binaryformatter). More secure alternatives include using BinaryReaders/BinaryWriters in conjunction with XMl or jSOn serialization.

.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 public data (e.g., base class data and contained objects) is automatically serialized as well. Therefore, if you attempt to persist a derived class, all public 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.
Finally, understand that you can persist an object graph into any System.IO.Stream-derived type. 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 .NET Runtime will account for all related objects to ensure that public 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. 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 19-1 shows a possible object graph that models these relationships.

Figure 19-1. 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 19-1, 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 to Radio (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 19-1 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.

Creating the Sample Types and Top-Level Statements
Create a new Console Application project named SimpleSerialize. Update the Program.cs file to the following:

global using System.Text.Json;
global using System.Text.Json.Serialization; global using System.Xml;
global using System.Xml.Serialization; using SimpleSerialize;
Console.WriteLine(" Fun with Object Serialization \n");

Next, add a new class named Radio.cs, and update the code to the following:
namespace SimpleSerialize; public class Radio
{
public bool HasTweeters; public bool HasSubWoofers;
public List StationPresets; public string RadioId = "XF-552RR6"; public override string ToString()
{
var presets = string.Join(",", StationPresets.Select(i => i.ToString()).ToList()); return $"HasTweeters:{HasTweeters} HasSubWoofers:{HasSubWoofers} Station Presets:{presets}";
}
}

Next, add a class named Car.cs, and update the code to match this listing:

namespace SimpleSerialize; public class Car
{
public Radio TheRadio = new Radio(); public bool IsHatchBack;
public override string ToString()
=> $"IsHatchback:{IsHatchBack} Radio:{TheRadio.ToString()}";
}

Next, add another class named JamesBondCar.cs and use the following code for this class:

namespace SimpleSerialize; public class JamesBondCar : Car
{
public bool CanFly; public bool CanSubmerge;
public override string ToString()
=> $"CanFly:{CanFly}, CanSubmerge:{CanSubmerge} {base.ToString()}";
}

The final class, Person.cs, is shown here:
namespace SimpleSerialize; 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; }
}
public override string ToString() =>
$"IsAlive:{IsAlive} FirstName:{FirstName} Age:{PersonAge} ";
}

Finally, add the following code to the Program.cs file in the starter code (preserving the using
statements added earlier):

Console.WriteLine(" Fun with Object Serialization \n"); var theRadio = new Radio
{
StationPresets = new() { 89.3, 105.1, 97.1 }, HasTweeters = true
};
// Make a JamesBondCar and set state. JamesBondCar jbc = new()
{
CanFly = true, CanSubmerge = false, TheRadio = new()
{
StationPresets = new() { 89.3, 105.1, 97.1 }, HasTweeters = true
}
};

List myCars = new()
{
new JamesBondCar { CanFly = true, CanSubmerge = true, TheRadio = theRadio }, new JamesBondCar { CanFly = true, CanSubmerge = false, TheRadio = theRadio }, new JamesBondCar { CanFly = false, CanSubmerge = true, TheRadio = theRadio }, new JamesBondCar { CanFly = false, CanSubmerge = false, TheRadio = theRadio },
};

Person p = new Person
{
FirstName = "James", IsAlive = true
};

Now you are all set up to explore XML and JSON serialization.

Extensible Markup Language (XML)
One of the original goals of XML was to represent an object (or set of objects) in human- and machine- readable format. An XML document is a single file that contains the item(s) being serialized. To conform to the standard (and be usable by software systems that support XML), the document opens with an

XML declaration defining the version and optionally the encoding. The next line is the root element and contains the XML namespaces. All the data is contained between the opening and closing tags for the root element.
For example, the Person class can be represented in XML as shown in the following sample. You can see the XML declaration and the root element (Person), as well as additional markup for the properties. Optionally, properties can be represented using attributes.

<?xml version="1.0"?>


true
James

If you have a list of objects, such as a list of JamesBondCar objects, the structure is the same. In the following example, the root element is not a JamesBondCar, but an array of JamesBondCar. Then each JamesBondCar in the array is contained within the root element. The following example shows the attribute syntax for the CanFly and CanSubmerge properties:

<?xml version="1.0"?>




false
false
XF-552RR6

89.3
105.1
97.1


false



false
false
XF-552RR6

89.3
105.1
97.1


false

Serializing and Deserializing with the XmlSerializer
The System.Xml namespace provides the System.Xml.Serialization.XmlSerializer. You can use this formatter to persist the public state of a given object as pure XML. Note that the XmlSerializer requires you to declare the type that will be serialized (or deserialized).

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 establishes 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 the System.Xml.Serialization namespace. Table 19-12 documents some (but not all) of the .NET attributes that influence how XML data is encoded to a stream.

Table 19-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.

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 documentation.

■ 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).

Serializing Objects Using the XmlSerializer
Consider the following local function added to your Program.cs file:

static void SaveAsXmlFormat(T objGraph, string fileName)
{
//Must declare type in the constructor of the XmlSerializer XmlSerializer xmlFormat = new XmlSerializer(typeof(T)); using (Stream fStream = new FileStream(fileName,
FileMode.Create, FileAccess.Write, FileShare.None))
{
xmlFormat.Serialize(fStream, objGraph);
}
}

Add the following code to your top-level statements:

SaveAsXmlFormat(jbc, "CarData.xml"); Console.WriteLine("=> Saved car in XML format!");

SaveAsXmlFormat(p, "PersonData.xml"); Console.WriteLine("=> Saved person in XML format!");

If you were to look within the newly generated CarData.xml file, you would find the XML data shown here:

<?xml version="1.0"?>



true
false

89.3
105.1
97.1

XF-552RR6

false
true
false

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

[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 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">

Next, examine the following PersonData.xml file:

<?xml version="1.0"?>


true
James

Notice how the PersonAge property is not serialized into the XML. This confirms that XML serialization serializes only public properties and fields.

Serializing Collections of Objects
Serializing collections works in the same manner. Add the following to your top-level statements:

SaveAsXmlFormat(myCars,"CarCollection.xml"); Console.WriteLine("=> Saved list of cars!");

The generated XML matches the example shown at the beginning of this section, with the
ArrayOfJamesBondCar as the root element.

Deserializing Objects and Collections of Objects
XML deserialization is literally the opposite of serializing objects (and collections of objects). Consider the following local function to deserialize XML back into an object graph. Notice that, once again, the type to be deserialized must be passed into the constructor for the XmlSerializer:

static T ReadAsXmlFormat(string fileName)
{
// Create a typed instance of the XmlSerializer XmlSerializer xmlFormat = new XmlSerializer(typeof(T));
using (Stream fStream = new FileStream(fileName, FileMode.Open))
{
T obj = default;

obj = (T)xmlFormat.Deserialize(fStream); return obj;
}
}

Add the following code to the top-level statements to reconstitute your XML back into objects (or list of objects):

JamesBondCar savedCar = ReadAsXmlFormat("CarData.xml"); Console.WriteLine("Original Car:\t {0}",jbc.ToString()); Console.WriteLine("Read Car:\t {0}",savedCar.ToString());

List savedCars = ReadAsXmlFormat<List>("CarCollection.xml");

XML serialization is used not only for storing and retrieving data, as shown in these examples, but also for sending data between systems, especially systems developed with differing technology stacks. All modern programming languages (and many database providers) have built-in support for XML.

JavaScript Object Notation (JSON) Serialization
While XML serialization is still widely used, it has largely taken a backseat to systems using JSON to share, persist, and/or load data. JSON, like XML, is a textual representation of an object (or object graph) that is cross-platform compatible and adheres to an open standard. Systems built with a wide range of languages and tooling have built-in support for JSON.
Objects in JSON documents are designated using name-value pairs for the properties enclosed in curly braces ({}). For example, a Person instance when serialized to JSON produces the following document:

{
"firstName": "James", "isAlive": true
}

Notice some of the key differences between the JSON and the XML representation of the same instance from the previous section. There isn’t a declaration or a root name, just the properties of the serialized object. This results in a much smaller amount of text, making it a more efficient format.
The lack of the class name (Person) in the JSON provides additional flexibility. The sender (in this case us) might call the class Person, while the receiver might call the class Human. As long as the properties match, the JSON will be properly applied to the object.
Lists of objects are stored as JavaScript arrays using square brackets ([]). The following is a list containing two JamesBondCar objects:

[
{
"CanFly": true, "CanSubmerge": true, "TheRadio": {
"StationPresets": ["89.3", "105.1", "97.1"],
"HasTweeters": true,

"HasSubWoofers": false, "RadioId": "XF-552RR6"
},
"IsHatchBack": false
},
{
"CanFly": true, "CanSubmerge": false, "TheRadio": {
"StationPresets": ["89.3", "105.1", "97.1"],
"HasTweeters": true, "HasSubWoofers": false, "RadioId": "XF-552RR6"
},
"IsHatchBack": false
}
]

Notice that the entire file is opened and closed with a square bracket, and then each object in the array is opened and closed with a curly brace. The radio presets are also a list, so they are serialized as an array
of values.

Serializing and Deserializing with System.Text.Json
The System.Text.Json namespace provides the System.Text.Json.JsonSerializer. You can use this formatter to persist the public state of a given object as JSON.

Controlling the Generated JSON Data
By default, JsonSerializer serializes all public properties as JSON name-value pairs using the same name (and casing) of the object’s property names. You can control many aspects of the serialization process with attributes; some of the more commonly used attributes are listed in Table 19-13.

Table 19-13. Select Attributes of the System.Text.Json.Serialization Namespace

.NET Attribute Meaning in Life
[JsonIgnore] The property will be ignored.
[JsonInclude] The member will be included.
[JsonPropertyName] This specifies the property name to be used when serializing/deserializing a member. This is commonly used to resolve character casing issues.
[JsonConstructor] This indicates the constructor that should be used when deserializing JSON back into an object graph.

Serializing Objects Using the JsonSerializer
The JsonSerializer contains static Serialize methods used to convert .NET objects (including object graphs) into a string representation of the public properties. The data is represented as name-value pairs in JavaScript Object Notation. Consider the following local function added to your Program.cs file:

static void SaveAsJsonFormat(T objGraph, string fileName)
{
File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph));
}

Add the following code to your top-level statements:

SaveAsJsonFormat(jbc, "CarData.json"); Console.WriteLine("=> Saved car in JSON format!");

SaveAsJsonFormat(p, "PersonData.json"); Console.WriteLine("=> Saved person in JSON format!");

When you examine the created JSON files, you might be surprised to see that the CarData.json file is empty (except for a pair of braces) and the PersonData.json file contains only the Firstname value. This is because the JsonSerializer only writes public properties by default, and not public fields. You will correct this in the next section.

Including Fields
To include public fields into the generated JSON, you have two options. The other method is to use the JsonSerializerOptions class to instruct the JsonSerializer to include all fields. The second is to update your classes by adding the [JsonInclude] attribute to each public field that should be included in the JSON output. Note that the first method (using the JsonSerializationOptions) will include all public fields in the object graph. To exclude certain public fields using this technique, you must use the JsonExclude attribute on them to be excluded.
Update the SaveAsJsonFormat method to the following:

static void SaveAsJsonFormat(T objGraph, string fileName)
{
var options = new JsonSerializerOptions
{
IncludeFields = true,
};
File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
}

Instead of using the JsonSerializerOptions, you can achieve the same result by updating all public fields in the sample classes to the following (note that you can leave the Xml attributes in the classes and they will not interfere with the JsonSerializer):

//Radio.cs
public class Radio
{
[JsonInclude]

public bool HasTweeters;
[JsonInclude]
public bool HasSubWoofers;
[JsonInclude]
public List StationPresets;
[JsonInclude]
public string RadioId = "XF-552RR6";

}

//Car.cs
public class Car
{
[JsonInclude]
public Radio TheRadio = new Radio();
[JsonInclude]
public bool IsHatchBack;

}

//JamesBondCar.cs
public class JamesBondCar : Car
{
[XmlAttribute] [JsonInclude] public bool CanFly; [XmlAttribute] [JsonInclude]
public bool CanSubmerge;

}

//Person.cs
public class Person
{
// A public field.
[JsonInclude]
public bool IsAlive = true;

}

Now when you run the code using either method, all public properties and fields are written to the file. However, when you examine the contents, you will see that the JSON is written minified. Minified is a
format where all insignificant whitespace and line breaks are removed. This is the default format largely due to JSON’s wide use of RESTful services and reduces the size of the data packet when sending information between services over HTTP/HTTPS.

■ Note The field handling for serializing jSOn is the same as deserializing jSOn. If you chose to set the option to include fields when serializing jSOn, you must also include that option when deserializing jSOn.

Pretty-Print the JSON
In addition to the option to include public fields, the JsonSerializer can be instructed to write the JSON indented (and human readable). Update your method to the following:

static void SaveAsJsonFormat(T objGraph, string fileName)
{
var options = new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true
};
File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
}

Now examine the CarData.json file; the output is much more readable.

{
"canFly": true, "canSubmerge": false, "theRadio": {
"stationPresets": [ "89.3",
"105.1",
"97.1"
],
"hasTweeters": true, "hasSubWoofers": false, "radioId": "XF-552RR6"
},
"isHatchBack": false
}

PascalCase or camelCase JSON
Pascal casing is a format that uses the first character capitalized and every significant part of a name capitalized as well. Camel casing, on the other hand, sets the first character to lowercase (like the word camelCase in the title of this section), and then every significant part of the name starts with a capital. Take the previous JSON listing. canSubmerge is an example of camel casing. The Pascal case version of the previous example is CanSubmerge.
Why does this matter? It matters because most of the popular languages are case sensitive (like C#). That means CanSubmerge and canSubmerge are two different items. As you have seen throughout this book, the generally accepted standard for naming public things in C# (classes, public properties, functions, etc.) is to use Pascal casing. However, most of the JavaScript frameworks prefer to use camel casing. This can be problematic when using .NET and C# to pass JSON data to/from non-C#/.NET RESTful services.

Fortunately, the JsonSerializer is customizable to handle most situations, including casing differences. If no naming policy is specified, the JsonSerializer will use camel casing when serializing and deserializing JSON. To change the serialization process to use Pascal casing, you need to set PropertyNamingPolicy to null, as follows:

static void SaveAsJsonFormat(T objGraph, string fileName)
{
JsonSerializerOptions options = new()
{
PropertyNamingPolicy = null, IncludeFields = true, WriteIndented = true,
};
File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
}

Now, when you execute the calling code, the JSON produced is all Pascal cased.

{
"CanFly": true, "CanSubmerge": false, "TheRadio": {
"StationPresets": [ "89.3",
"105.1",
"97.1"
],
"HasTweeters": true, "HasSubWoofers": false, "RadioId": "XF-552RR6"
},
"IsHatchBack": false
}

When reading JSON, C# is (by default) case sensitive. The casing setting of the PropertyNamingPolicy is used during Deserialization. If the property is not set, the default (camel casing) is used. By setting the PropertyNamingPolicy to Pascal case, then all incoming JSON is expected to be in Pascal case. If the casing does not match, the deserialization process (covered soon) fails.
There is a third option when deserializing JSON, and that is casing indifference. By setting the PropertyNameCaseInsensitive option to true, then C# will deserialize canSubmerge as well as CanSubmerge. Here is the code to set the option:

JsonSerializerOptions options = new()
{
PropertyNameCaseInsensitive = true, IncludeFields = true
};

Ignoring Circular References with JsonSerializer (New 10)
Introduced in .NET 6/C# 10, the System.Text.Json.JsonSerializer supports ignoring circular references when serializing an object graph. This is done by setting the ReferenceHandler to ReferenceHandler.
IgnoreCycles in the JsonSerializerOptions. Here is the code to set the serializer to ignore the circular references:

JsonSerializerOptions options = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};

Table 19-14 lists the available values in the ReferenceHandler enum.

Table 19-14. ReferenceHandler Enum Values

Enum Value Meaning in Life
IgnoreCycles Circular references are not serialized, and the reference loop is replaced with a null.
Preserve Metadata properties will be honored when deserializing JSON objects and arrays into reference types and written when serializing reference types. This is necessary to create round-trippable JSON from objects that contain cycles or duplicate references.

Number Handling with JsonSerializer
The default handling of numbers is Strict, meaning numbers will be serialized as numbers (without quotes) and deserialized as numbers (without quotes). The JsonSerializerOptions has a NumberHandling property that controls reading and writing numbers. Table 19-15 lists the available values in the JsonNumberHandling enum.

Table 19-15. JsonNumberHandling Enum Values

Enum Value Meaning in Life
Strict (0) Numbers are read from numbers and written as numbers. Quotes are not allowed nor are they generated.
AllowReadingFromString (1) Numbers can be read from number or string tokens.
WriteAsString (2) Numbers are written as JSON strings (with quotes).
AllowNamedFloatingPointLiterals (4) The Nan, Infinity, and -Infinity string tokens can be read, and Single and Double values will be written as their corresponding JSON string representations.

The enum has a flags attribute, which allows a bitwise combination of its values. For example, if you want to read strings (and numbers) and write numbers as strings, you use the following option setting:

JsonSerializerOptions options = new()
{

NumberHandling = JsonNumberHandling.AllowReadingFromString & JsonNumberHandling.WriteAsString

};

With this change, the JSON created for the Car class is as follows:

{
"canFly": true, "canSubmerge": false, "theRadio": {
"hasTweeters": true, "hasSubWoofers": false, "stationPresets": [
"89.3",
"105.1",
"97.1"
],
"radioId": "XF-552RR6"
},
"isHatchBack": false
}

JSON Property Ordering (New 10)
With the release of .NET 6/C# 10, the JsonPropertyOrder attribute controls property ordering during serialization. The smaller the number (including negative values), the earlier the property is in the resulting JSON. Properties without an order are assigned a default order of zero. Update the Person class to the following:
namespace SimpleSerialize; public class Person
{
[JsonPropertyOrder(1)] public bool IsAlive = true;

private int PersonAge = 21;

private string _fName = string.Empty; [JsonPropertyOrder(-1)]
public string FirstName
{
get { return _fName; } set { _fName = value; }
}
public override string ToString() => $"IsAlive:{IsAlive} FirstName:{FirstName} Age:{PersonAge} ";
}

With that change, the properties are serialized in the order of FirstName (-1) and then IsAlive (1).
PersonAge doesn’t get serialized because it’s private. If it were made public, it would get the default order of zero and be placed between the other two properties.

Support for IAsyncEnumerable (New 10)
With the release of .NET 6/C# 10, the System.Text.Json.JsonSerializer now has support for serializing and deserializing async streams.

Streaming Serialization
To demonstrate streaming serialization, start by adding a new method that will return an
IAsyncEnumerable:

static async IAsyncEnumerable PrintNumbers(int n)
{
for (int i = 0; i < n; i++)
{
yield return i;
}
}

Next, create a Stream from the Console and serialize the IAsyncEnumerable returned from the
PrintNumbers() function.

async static void SerializeAsync()
{
Console.WriteLine("Async Serialization");
using Stream stream = Console.OpenStandardOutput(); var data = new { Data = PrintNumbers(3) };
await JsonSerializer.SerializeAsync(stream, data); Console.WriteLine();
}

Streaming Deserialization
There is a new API to support streaming deserialization, DeserializeAsyncEnumerable(). To demonstrate the use of this, add a new method that will create a new MemoryStream and then deserialize from the stream:

async static void DeserializeAsync()
{
Console.WriteLine("Async Deserialization");
var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("[0,1,2,3,4]")); await foreach (int item in JsonSerializer.DeserializeAsyncEnumerable(stream))
{
Console.Write(item);
}
Console.WriteLine();
}

Potential Performance Issues Using JsonSerializerOptions
When using JsonSerializerOption, it is best to create a single instance and reuse it throughout your application. With that in mind, update your top-level statements and JSON methods to the following:

JsonSerializerOptions options = new()
{
PropertyNameCaseInsensitive = true,
//PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = null, //Pascal casing IncludeFields = true,
WriteIndented = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling. WriteAsString
};
SaveAsJsonFormat(options, jbc, "CarData.json"); Console.WriteLine("=> Saved car in JSON format!");

SaveAsJsonFormat(options, p, "PersonData.json"); Console.WriteLine("=> Saved person in JSON format!");

static void SaveAsJsonFormat(JsonSerializerOptions options, T objGraph, string fileName)
=> File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));

Web Defaults for JsonSerializer
When building web applications, you can use a specialized constructor to set the following properties:

PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
NumberHandling = JsonNumberHandling.AllowReadingFromString

You can still set additional properties or override the defaults through object initialization, like this:

JsonSerializerOptions options = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles
};

General Defaults for JsonSerializer
If you want to start off with more general settings, passing JsonSerializerDefaults.General into the constructor will set the following properties:

PropertyNameCaseInsensitive = false, PropertyNamingPolicy = null, //Pascal Casing NumberHandling = JsonNumberHandling.Strict

Like the web version, you can still set additional properties or override the defaults through object initialization, like this:

JsonSerializerOptions options = new(JsonSerializerDefaults.General)
{
WriteIndented = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles, PropertyNameCaseInsensitive = true
};

Serializing Collections of Objects
Serializing a collection of objects into JSON is handled the same way as a single object. Add the following line to the top-level statements:

SaveAsJsonFormat(options, myCars, "CarCollection.json");

Deserializing Objects and Collections of Objects
JSON deserialization is the opposite of serialization. The following function will deserialize JSON into the type specified using the generic version of the method:

static T ReadAsJsonFormat(JsonSerializerOptions options, string fileName) => System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(fileName), options);

Add the following code to the top-level statements to reconstitute your XML back into objects (or list of objects):

JamesBondCar savedJsonCar = ReadAsJsonFormat(options, "CarData.json"); Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());

List savedJsonCars = ReadAsJsonFormat<List>(options, "CarCollection.json");
Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());

Note that the type being created during the deserializing process can be a single object or a generic collection.

JsonConverters
You can take additional control of the serialization/deserialization process by adding in custom converters. Custom converters inherit from JsonConverter (where T is the type that the converter operates on) and override the base Read() and Write() methods. Here are the abstract base methods to overwrite:

public abstract T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options);
public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);

One common scenario is converting null values in your object to an empty string in the JSON and back to a null in your object. To demonstrate this, add a new file named JsonStringNullToEmptyConverter.cs, make the class public, inherit from JsonConverter, and implement the abstract members. Here is the initial code:

namespace SimpleSerialize
{
public class JsonStringNullToEmptyConverter : JsonConverter
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
}

public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
}
}
}

In the Read method, use the Utf8JsonReader instance to read the string value for the node, and if it is
null or an empty string, then return null. Otherwise, return the value read:

public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString(); if (string.IsNullOrEmpty(value))
{
return null;
}
return value;
}

In the Write method, use the Utf8JsonWriter to write an empty string if the value is null:

public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
value ??= string.Empty; writer.WriteStringValue(value);
}

The final step for the custom converter is to force the serializer to process null values. By default, null values are not sent through the conversion process to improve performance. However, in this scenario, we want the null values to be processed, so override the base property HandleNull and set it to true instead of the default false value.

public override bool HandleNull => true;

The final step is to add the custom converter into the serialization options. Create a new method named
HandleNullStrings() and add the following code:

static void HandleNullStrings()
{
Console.WriteLine("Handling Null Strings"); var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true, PropertyNamingPolicy = null, IncludeFields = true,
WriteIndented = true,
Converters = { new JsonStringNullToEmptyConverter() },
};
//Create a new object with a null string var radio = new Radio
{
HasSubWoofers = true, HasTweeters = true, RadioId = null
};
//serialize the object to JSON
var json = JsonSerializer.Serialize(radio, options); Console.WriteLine(json);
}

When you call this method from the top-level statements, you can see that the RadioId property is indeed an empty string in the JSON instead of a null. The StationPresets values are still null in the JSON because the custom converter acts only on string types, not on List types.

{
"StationPresets": null, "HasTweeters": true, "HasSubWoofers": true, "RadioId": null
}

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 several 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 by DriveType, 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. You then worked with XML and JSON serialization and deserialization.

发表评论