C# 8 and .NET Core 3 Projects Using Azure - Second Edition

By Paul Michaels , Dirk Strauss , Jas Rademeyer
  • Instant online access to over 7,500+ books and videos
  • Constantly updated with 100+ new titles each month
  • Breadth and depth in over 1,000+ technologies
  1. Ebook Manager and Catalogue App - .NET Core for Desktop

About this book

.NET Core is a general-purpose, modular, cross-platform, and opensource implementation of .NET. The latest release of .NET Core 3 comes with improved performance and security features, along with support for desktop applications. .NET Core 3 is not only useful for new developers looking to start learning the framework, but also for legacy developers interested in migrating their apps. Updated with the latest features and enhancements, this updated second edition is a step-by-step, project-based guide.

The book starts with a brief introduction to the key features of C# 8 and .NET Core 3.

You'll learn to work with relational data using Entity Framework Core 3, before understanding how to use ASP.NET Core. As you progress, you’ll discover how you can use .NET Core to create cross-platform applications. Later, the book will show you how to upgrade your old WinForms apps to .NET Core 3. The concluding chapters will then help you use SignalR effectively to add real-time functionality to your applications, before demonstrating how to implement MongoDB in your apps. Finally, you'll delve into serverless computing and how to build microservices using Docker and Kubernetes.

By the end of this book, you'll be proficient in developing applications using .NET Core 3.

Publication date:
December 2019
Publisher
Packt
Pages
528
ISBN
9781789612080

 

Ebook Manager and Catalogue App - .NET Core for Desktop

.NET Core 3 marks a significant release in the reboot of .NET. Now that the fundamental framework is in place, Microsoft has been able to look at technologies that, while no longer en vogue, are running on millions of machines around the world.

WinForms and WPF have been victims of their own success: Microsoft simply dare not change the framework around them and risk breaking applications that may have been running successfully for several years.

C# 8 has a similar theme in that it introduces features such as nullable reference types, and interface implementations that are designed to improve legacy code bases.


A legacy code base is any code that has already been written, whether that was 10 years or 10 minutes ago!

In this, the first chapter, we'll create the Ebook Manager application. Following this, we'll pick up our Ebook Manager built with .NET Core 2 and migrate it over to .NET Core 3.

In .NET Core 2, a number of significant performance enhancements were made, and so there is a real drive to upgrade existing WinForms apps to .NET Core 3. Microsoft has boasted that .NET Core 2.1 had over 30% performance boost for Bing.

The topics that we'll cover are as follows:

  • Creating a new WinForms application in .NET Core 3.0
  • Migrating an existing WinForms application to .NET Core 3.0
  • Nullable reference types
  • XAML Islands, and how they can be used to add functionality to existing WinForms applications
  • Tree shaking and compilation
 

Technical requirements

To follow along with the first part of the chapter, you'll need the WinForms designer. At the time of writing, this is in pre-release and can be downloaded from https://aka.ms/winforms-designer.

For the XAML Islands section, you will need to be running Windows 10 1903 or later. By the time this book is published, it is expected that the 1903 release will have been delivered automatically to all Windows 10 machines; however, if you are running an earlier version, then you can force an update by visiting the following link: https://www.microsoft.com/en-us/software-download/windows10.

At the time of writing, this WinForms designer was nowhere near ready for production. Try it out while following the chapter; however, if you find that it is too glitchy, feel free to copy the designer code from the GitHub project.
 

Creating a new WinForms application

Let's start by creating a new .NET Core 3.0 WinForms application and later we'll also see how to upgrade an old .NET Core WinForms app to 3.0, so that we can show both ways of achieving this.

 

To follow this section, you'll need to install the WinForms designer described in the Technical requirements section. It's worth pointing out that this tool is in preview at the time of writing and therefore has a number of limitations, so the instructions have changed in order to cater to those limitations.

Using Visual Studio 2019, we will create a simple Windows Forms App template project. You can call the application anything you like, but I called mine eBookManager:

The process of creating a new project has changed slightly in Visual Studio 2019, and you are required to select the type of application, followed by where to create it:

The project will be created and will look as follows:

Since this is .NET Core, you can do all of this from the command line. In PowerShell, running the following command will create an identical project: dotnet new winforms -n eBookManager.

Our solution needs a class library project to contain the classes that drive the eBookManager application. In .NET Core 3.0, we have the option of creating either a .NET Core class library or a .NET Standard class library.

.NET Standard is a bit of a strange concept. In and of itself, it is not a technology, but a contract; creating a .NET Standard class library simply prevents you from using anything that is .NET Core—(or framework—) specific, and does not adhere to .NET Standard. The following document illustrates the .NET Standard versions, and what releases of Core and Framework support them: https://github.com/dotnet/standard/blob/master/docs/versions.md.

Add a new Class Library (.NET Standard) project to your solution and call it eBookManager.Engine:

A class library project is added to the solution with the default class name. Change this class to Document:

The Document class will represent a single eBook. When thinking of a book, we can have multiple properties representing a single book, but that would be representative of all books. An example of this would be the author. All books must have an author; otherwise, they would not exist.

The properties I have added to the class are merely my interpretation of what might represent a book. Feel free to add additional code to make this your own. Open the Document.cs file and add the following code to the class:

public class Document
{
public string Title { get; set; }
public string FileName { get; set; }
public string Extension { get; set; }
public DateTime LastAccessed { get; set; }
public DateTime Created { get; set; }
public string FilePath { get; set; }
public string FileSize { get; set; }
public string ISBN { get; set; }
public string Price { get; set; }
public string Publisher { get; set; }
public string Author { get; set; }
public DateTime PublishDate { get; set; }
public DeweyDecimal Classification { get; set; }
public string Category { get; set; }
}

You will notice that I have included a property called Classification of type DeweyDecimal. We have not added this class yet and will do so next. To the eBookManager.Engine project, add a class called DeweyDecimal. If you don't want to go to this level of classification for your eBooks, you can leave this class out. I have included it for the sake of completeness. We're going to introduce a neat little feature that's been in Visual Studio for some time: if you hover over the DeweyDecimal text, you'll see a lightbulb appear (you can bring this menu up manually by holding the Ctrl key and the dot key (Ctrl + .). I will be using this shortcut profusely throughout the rest of the book!):

This allows us to create a new class with a couple of keystrokes. It also means that the name of the class will match the class name in the calling code.

You can use the lightbulb menu to create methods, add using statements, and even import NuGet libraries!

The DeweyDecimal system is quite big. For this reason, I have not catered for every book classification available. I have also assumed that you only want to be working with programming eBooks. In reality, however, you may want to add other classifications, such as literature, the sciences, the arts, and so on. It is up to you:

  1. Open up the DeweyDecimal class and add the following code to the class:
public class DeweyDecimal
{
public string ComputerScience { get; set; } = "000";
public string DataProcessing { get; set; } = "004";
public string ComputerProgramming { get; set; } = "005";
}

Word nerds may disagree with me here, but I would like to remind them that I'm a code nerd. The classifications represented here are just so that I can catalog programming- and computer science-related eBooks. As mentioned earlier, you can change this to suit your needs.

  1. We now need to add in the heart of the eBookManager.Engine solution. This is a class called DocumentEngine and will contain the methods you need in order to work with the documents:

Your eBookManager.Engine solution will now contain the following classes:

  • DeweyDecimal
  • Document
  • DocumentEngine
  1. We now need to add a reference to eBookManager.Engine from the eBookManager project:

The eBookManager.Engine project will be available in the Projects section in the Reference Manager screen:

  1. Once we have added the reference, we need a Windows Form that will be responsible for importing new books. Add a new form called ImportBooks to the eBookManager solution:

  1. We'll create a separate project for extension methods. Add the eBookManager.Helper class library project (again, as a .NET Standard class library project):

  1. We'll reference that from our main project (as before):

We've now set up the basics needed for our eBookManager application. Next, we will venture further into the guts of the application by writing some code.

Virtual storage spaces and extension methods

Let's start by discussing the logic behind virtual storage space. This is a single virtual representation of several physical spaces on your hard drive (or hard drives). A storage space will be seen as a single area where a specific group of eBooks is stored. I use the term "stored" loosely because the storage space doesn't exist. It's more representative of a grouping than a physical space on the hard drive:

  1. To start creating virtual storage spaces, add a new class called StorageSpace to the eBookManager.Engine project. Open the StorageSpace.cs file and add the following code to it:
using System;
using System.Collections.Generic;
namespace eBookManager.Engine
{
[Serializable]
public class StorageSpace
{
public int ID { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<Document> BookList { get; set; }
}
}

Note that you need to include the System.Collections.Generic namespace here, because the StorageSpace class contains a property called BookList of type List<Document> that will contain all the books in that particular storage space.

Now we need to focus our attention on the eBookManager.Helper project and add a new class called ExtensionMethods. This will be a static class because extension methods need to be static in nature in order to act on the various objects defined by the extension methods.

  1. The new ExtensionMethods class will initially look as follows:
public static class ExtensionMethods
{
}

Let's add the first extension method to the class called ToInt(). What this extension method does is take a string value and try to parse it to an integer value. I am too lazy to type Convert.ToInt32(stringVariable) whenever I need to convert a string to an integer. It is for this reason that I use an extension method.

  1. Add the following static method to the ExtensionMethods class:
public static int ToInt(this string value, int defaultInteger = 0)
{
try
{
if (int.TryParse(value, out int validInteger))
{
// Out variables
return validInteger;
}
else
{
return defaultInteger;
}
}
catch
{
return defaultInteger;
}
}

The ToInt() extension method acts only on strings. This is defined by this string value in the method signature, where value is the variable name that will contain the string you are trying to convert to an integer. It also has a default parameter called defaultInteger, which is set to 0. Unless the developer calling the extension method wants to return a default integer value of 0, they can pass a different integer to this extension method (-1, for example).

The other methods of the ExtensionMethods class are used to provide the following logic:

  • Read and write to the data source
  • Check whether a storage space exists
  • Convert bytes to megabytes
  • Convert a string to an integer (as discussed previously)

The ToMegabytes method is quite easy. To avoid having to write this calculation all over the place, defining it inside an extension method makes sense:

public static double ToMegabytes(this long bytes) => 
(bytes > 0) ? (bytes / 1024f) / 1024f : bytes;

We also need a way to check whether a particular storage space already exists.


Be sure to add a project reference to eBookManager.Engine from the eBookManager.Helper project.

What this extension method also does is return the next storage space ID to the calling code. If the storage space does not exist, the returned ID will be the next ID that can be used when creating a new storage space:

public static bool StorageSpaceExists(this List<StorageSpace> space, string nameValueToCheck, out int storageSpaceId)
{
bool exists = false;
storageSpaceId = 0;
if (space.Count() != 0)
{
int count = (from r in space
where r.Name.Equals(nameValueToCheck)
select r)
.Count();
if (count > 0) exists = true;
storageSpaceId = (from r in space
select r.ID).Max() + 1;
}
return exists;
}
If you're pasting this code in, remember the Ctrl + . tip from earlier. Wherever you see code that is not recognized, simply place the cursor there and press Ctrl + ., or click the lightbulb, and it should bring in the necessary references.

We also need to create a method that will write the data we have to a file after converting it to JSON:

public async static Task WriteToDataStore(this List<StorageSpace> value,
string storagePath, bool appendToExistingFile = false)
{
using (FileStream fs = File.Create(storagePath))
await JsonSerializer.SerializeAsync(fs, value);
}

Essentially, all we're doing here is creating a stream and serializing the StorageSpace list into that stream.


Note that we're using the new syntactical sugar here from C# 8, allowing us to add a using statement with an implicit scope (that is, until the end of the method).

You'll need to install System.Text.Json from the package manager console:

Install-Package System.Text.Json -ProjectName eBookManager.Helper

This allows you to use the new .NET Core 3 JSON serializer. Apart from being more succinct than its predecessor, or even third-party tools such as Json.NET, Microsoft claims that you'll see a speed improvement, as it makes use of the performance improvements introduced in .NET Core 2.x.

Lastly, we need to be able to read the data back again into a List<StorageSpace> object and return that to the calling code:

public async static Task<List<StorageSpace>> ReadFromDataStore(this List<StorageSpace> value, string storagePath)
{
if (!File.Exists(storagePath))
{
var newFile = File.Create(storagePath);
newFile.Close();
}

using FileStream fs = File.OpenRead(storagePath);
if (fs.Length == 0) return new List<StorageSpace>();

var storageList = await JsonSerializer.DeserializeAsync<List<StorageSpace>>(fs);

return storageList;
}

The method will return an empty list, that is, a <StorageSpace> object, and nothing is contained in the file. The ExtensionMethods class can contain many more extension methods that you might use often. It is a great way to separate often-used code.

As with any other class, you should consider whether your extension method class is getting too large, or becoming a dumping ground for unrelated functionality, or functionality that may be better extracted into a self-contained class.

The DocumentEngine class

The purpose of this class is merely to provide supporting code to a document. In the eBookManager application, I am going to use a single method called GetFileProperties() that will (you guessed it) return the properties of a selected file. This class also only contains this single method. As the application is modified for your specific purposes, you can modify this class and add additional methods that are specific to documents.

Inside the DocumentEngine class, add the following code:

public (DateTime dateCreated, DateTime dateLastAccessed, string fileName, string fileExtension, long fileLength, bool error) GetFileProperties(string filePath)
{
var returnTuple = (created: DateTime.MinValue,
lastDateAccessed: DateTime.MinValue, name: "", ext: "",
fileSize: 0L, error: false);
try
{
FileInfo fi = new FileInfo(filePath);
fi.Refresh();
returnTuple = (fi.CreationTime, fi.LastAccessTime, fi.Name,
fi.Extension, fi.Length, false);
}
catch
{
returnTuple.error = true;
}
return returnTuple;
}

The GetFileProperties() method returns a tuple as (DateTime dateCreated, DateTime dateLastAccessed, string fileName, string fileExtension, long fileLength, bool error) and allows us to inspect the values returned from the calling code easily.

Before getting the properties of the specific file, the tuple is initialized by doing the following:

var returnTuple = (created: DateTime.MinValue, lastDateAccessed: DateTime.MinValue, name: "", ext: "", fileSize: 0L, error: false);

If there is an exception, I can return default values. Reading the file properties is simple enough using the FileInfo class. I can then assign the file properties to the tuple by doing this:

returnTuple = (fi.CreationTime, fi.LastAccessTime, fi.Name, fi.Extension, fi.Length, false);

The tuple is then returned to the calling code, where it will be used as required. We will have a look at the calling code next.

The ImportBooks form

The ImportBooks form does exactly what the name suggests. It allows us to create virtual storage spaces and to import books into those spaces. The form design is as follows:

TreeView controls are prefixed with tv, buttons with btn, combo boxes with dl, textboxes with txt, and date time pickers with dt.

Although this kind of prefixing isn't widely used today, this used to be a common practice for WinForms developers. The reason behind it is that WinForms never really lent itself very well to a separation of business and presentation layers (there have been attempts to rectify this, notably with the MVP pattern), meaning that referencing controls directly from code-behind was a common practice and, as such, it made sense to indicate the type of control you were dealing with.

When this form loads, if any storage spaces have been defined then they will be listed in the dlVirtualStorageSpaces combo box. Clicking on the Select source folder button will allow us to select a source folder in which to look for eBooks.

If a storage space does not exist, we can add a new virtual storage space by clicking the btnAddNewStorageSpace button. This will allow us to add a name and description for the new storage space and click on the btnSaveNewStorageSpace button. Selecting an eBook from the tvFoundBooks TreeView will populate the File details group of controls to the right of the form. You can then add additional Book details and click on the btnAddeBookToStorageSpace button to add the book to our space.

You can access the code-behind of a Windows Form by simply pressing F7, or right-clicking in Solution Explorer and selecting View Code.

The following steps describe changes to be made to the ImportBooks code-behind:

  1. You need to ensure that the following namespaces are added to your class (these should replace any existing namespaces there):
using eBookManager.Engine;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using static eBookManager.Helper.ExtensionMethods;
using static System.Math;
  1. Next, let's start at the most logical place: the ImportBooks() constructor and the class-level variables. Add the following declarations above the constructor:
private string _jsonPath;
private List<StorageSpace> _spaces;
private enum _storageSpaceSelection { New = -9999, NoSelection = -1 }

The usefulness of the enumerator will become evident later on in the code. The _jsonPath variable will contain the path to the file used to store our eBook information.

Some people, including myself, like to prefix private class-level variables with an underscore (as in this example). This is a personal preference; however, there are settings in Visual Studio that will aid in the auto-generation of such variables if you tell it what your preference is.
  1. Modify the constructor as follows:
public ImportBooks()
{
InitializeComponent();
_jsonPath = Path.Combine(Application.StartupPath, "bookData.txt");
}

_jsonPath is initialized in the executing folder for the application and the file is hardcoded to bookData.txt. You could provide a settings screen to configure these settings if you chose to improve this project.

  1. Because we want to load some data when the form loads, we'll attach the Form_Load event. An easy way to create an event handler in WinForms is to select the event in the form designer and simply double-click next to the event that you wish to handle:

The new event should load the following code from the data store asynchronously:

private async void ImportBooks_Load(object sender, EventArgs e)
{
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
}
  1. Next, we need to add another two enumerators that define the file extensions that we will be able to save in our application:
private HashSet<string> AllowedExtensions => new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) 
{ ".doc", ".docx", ".pdf", ".epub", ".lit" };

private enum Extension { doc = 0, docx = 1, pdf = 2, epub = 3, lit = 4 }

We can see the implementation of the AllowedExtensions property when we look at the PopulateBookList() method.

Populating the TreeView control

All that the PopulateBookList() method does is populate the TreeView control with files and folders found at the selected source location. Consider the following code in the ImportBooks code-behind:

public void PopulateBookList(string paramDir, TreeNode paramNode)
{
DirectoryInfo dir = new DirectoryInfo(paramDir);
foreach (DirectoryInfo dirInfo in dir.GetDirectories())
{
TreeNode node = new TreeNode(dirInfo.Name);
node.ImageIndex = 4;
node.SelectedImageIndex = 5;
if (paramNode != null)
paramNode.Nodes.Add(node);
else
tvFoundBooks.Nodes.Add(node);
PopulateBookList(dirInfo.FullName, node);
}
foreach (FileInfo fleInfo in dir.GetFiles()
.Where(x => AllowedExtensions.Contains(x.Extension)).ToList())
{
TreeNode node = new TreeNode(fleInfo.Name);
node.Tag = fleInfo.FullName;
int iconIndex = Enum.Parse(typeof(Extension),
fleInfo.Extension.TrimStart('.'), true).GetHashCode();
node.ImageIndex = iconIndex;
node.SelectedImageIndex = iconIndex;
if (paramNode != null)
paramNode.Nodes.Add(node);
else
tvFoundBooks.Nodes.Add(node);
}
}

The first place we need to call this method is obviously from within itself, as this is a recursive method. The second place we need to call it is from the btnSelectSourceFolder button click event (again, as before, select the click property and double-click):

private void btnSelectSourceFolder_Click(object sender, EventArgs e)
{
try
{
FolderBrowserDialog fbd = new FolderBrowserDialog();
fbd.Description = "Select the location of your eBooks and documents";
DialogResult dlgResult = fbd.ShowDialog();
if (dlgResult == DialogResult.OK)
{
tvFoundBooks.Nodes.Clear();
string path = fbd.SelectedPath;
DirectoryInfo di = new DirectoryInfo(path);
TreeNode root = new TreeNode(di.Name);
root.ImageIndex = 4;
root.SelectedImageIndex = 5;
tvFoundBooks.Nodes.Add(root);
PopulateBookList(di.FullName, root);
tvFoundBooks.Sort();
root.Expand();
}
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}

This is all quite straightforward code. Select the folder to recurse and populate the TreeView control with all the files found that match the file extension contained in our AllowedExtensions property. We also need to look at the code when someone selects a book in the tvFoundBooks TreeView control. When a book is selected, we need to read the properties of the selected file and return those properties to the file details section:

private void tvFoundBooks_AfterSelect(object sender, TreeViewEventArgs e)
{
DocumentEngine engine = new DocumentEngine();
string path = e.Node.Tag?.ToString() ?? "";
if (File.Exists(path))
{
var (dateCreated, dateLastAccessed, fileName, fileExtention, fileLength, hasError) = engine.GetFileProperties(e.Node.Tag.ToString());
if (!hasError)
{
txtFileName.Text = fileName;
txtExtension.Text = fileExtention;
dtCreated.Value = dateCreated;
dtLastAccessed.Value = dateLastAccessed;
txtFilePath.Text = e.Node.Tag.ToString();
txtFileSize.Text = $"{Round(fileLength.ToMegabytes(), 2).ToString()} MB";
}
}
}

You will notice that it is here that we are calling the GetFileProperties() method on the DocumentEngine class that returns the tuple.

Populating the storage space list

The next stage is to populate our list of storage spaces:

private void PopulateStorageSpacesList()
{
List<KeyValuePair<int, string>> lstSpaces =
new List<KeyValuePair<int, string>>();
BindStorageSpaceList((int)_storageSpaceSelection.NoSelection, "Select Storage Space");

void BindStorageSpaceList(int key, string value) =>
lstSpaces.Add(new KeyValuePair<int, string>(key, value));

if (_spaces is null || _spaces.Count() == 0) // Pattern matching
{
BindStorageSpaceList((int)_storageSpaceSelection.New, " <create new> ");
}
else
{
foreach (var space in _spaces)
{
BindStorageSpaceList(space.ID, space.Name);
}
}
dlVirtualStorageSpaces.DataSource = new
BindingSource(lstSpaces, null);
dlVirtualStorageSpaces.DisplayMember = "Value";
dlVirtualStorageSpaces.ValueMember = "Key";
}
The PopulateStorageSpacesList() method is using a local function, essentially allowing us to declare a piece of functionality that is accessible only from within its parent.

Let's add the call to this new method to the ImportBooks_Load method:

private async void ImportBooks_Load(object sender, EventArgs e)
{
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
PopulateStorageSpacesList();
if (dlVirtualStorageSpaces.Items.Count == 0)
{
dlVirtualStorageSpaces.Items.Add("<create new storage space > ");
}
lblEbookCount.Text = "";
}

We now need to add the logic for changing the selected storage space. The SelectedIndexChanged() event of the dlVirtualStorageSpaces control is modified as follows:

private void dlVirtualStorageSpaces_SelectedIndexChanged(object sender, EventArgs e)
{
int selectedValue = dlVirtualStorageSpaces.SelectedValue.ToString().ToInt();
if (selectedValue == (int)_storageSpaceSelection.New) // -9999
{
txtNewStorageSpaceName.Visible = true;
lblStorageSpaceDescription.Visible = true;
txtStorageSpaceDescription.ReadOnly = false;
btnSaveNewStorageSpace.Visible = true;
btnCancelNewStorageSpaceSave.Visible = true;
dlVirtualStorageSpaces.Enabled = false;
btnAddNewStorageSpace.Enabled = false;
lblEbookCount.Text = "";
}
else if (selectedValue != (int)_storageSpaceSelection.NoSelection)
{
// Find the contents of the selected storage space
int contentCount = (from c in _spaces
where c.ID == selectedValue
select c).Count();
if (contentCount > 0)
{
StorageSpace selectedSpace = (from c in _spaces
where c.ID == selectedValue
select c).First();
txtStorageSpaceDescription.Text = selectedSpace.Description;
List<Document> eBooks = (selectedSpace.BookList == null)
? new List<Document> { }
: selectedSpace.BookList;
lblEbookCount.Text = $"Storage Space contains { eBooks.Count()} {(eBooks.Count() == 1 ? "eBook" : "eBooks")}";
}
}
else
{
lblEbookCount.Text = "";
}
}

I will not go into a detailed explanation of the code here as it is relatively obvious what it is doing.

We also need to add code to save a new storage space. Add the following code to the Click event of the btnSaveNewStorageSpace button:

private void btnSaveNewStorageSpace_Click(object sender, EventArgs e)
{
try
{
if (txtNewStorageSpaceName.Text.Length != 0)
{
string newName = txtNewStorageSpaceName.Text;
bool spaceExists =
(!_spaces.StorageSpaceExists(newName, out int nextID))
? false
: throw new Exception("The storage space you are trying to add already exists.");
if (!spaceExists)
{
StorageSpace newSpace = new StorageSpace();
newSpace.Name = newName;
newSpace.ID = nextID;
newSpace.Description =
txtStorageSpaceDescription.Text;
_spaces.Add(newSpace);

PopulateStorageSpacesList();
// Save new Storage Space Name
txtNewStorageSpaceName.Clear();
txtNewStorageSpaceName.Visible = false;
lblStorageSpaceDescription.Visible = false;
txtStorageSpaceDescription.ReadOnly = true;
txtStorageSpaceDescription.Clear();
btnSaveNewStorageSpace.Visible = false;
btnCancelNewStorageSpaceSave.Visible = false;
dlVirtualStorageSpaces.Enabled = true;
btnAddNewStorageSpace.Enabled = true;
}
}
}
catch (Exception ex)
{
txtNewStorageSpaceName.SelectAll();
MessageBox.Show(ex.Message);
}
}

The last few methods deal with saving eBooks in the selected virtual storage space. Modify the Click event of the btnAddBookToStorageSpace button. This code also contains a throw expression. If you haven't selected a storage space from the combo box, a new exception is thrown:

private async void btnAddeBookToStorageSpace_Click(object sender, EventArgs e)
{
try
{
int selectedStorageSpaceID =
dlVirtualStorageSpaces.SelectedValue.ToString().ToInt();
if ((selectedStorageSpaceID != (int)_storageSpaceSelection.NoSelection)
&& (selectedStorageSpaceID != (int)_storageSpaceSelection.New))
{
await UpdateStorageSpaceBooks(selectedStorageSpaceID);
}
else throw new Exception("Please select a Storage Space to add your eBook to"); // throw expressions
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}

If you enter this code, you'll notice that the UpdateStorageSpaceBooks method does not yet exist; let's rectify that.

Saving a selected book to a storage space

The following code basically updates the book list in the selected storage space if it already contains the specific book (after confirming this with the user). Otherwise, it will add the book to the book list as a new book:

private async Task UpdateStorageSpaceBooks(int storageSpaceId)
{
try
{
int iCount = (from s in _spaces
where s.ID == storageSpaceId
select s).Count();
if (iCount > 0) // The space will always exist
{
// Update
StorageSpace existingSpace = (from s in _spaces
where s.ID == storageSpaceId
select s).First();
List<Document> ebooks = existingSpace.BookList;
int iBooksExist = (ebooks != null)
? (from b in ebooks
where $"{b.FileName}".Equals($"{txtFileName.Text.Trim()}")
select b).Count()
: 0;
if (iBooksExist > 0)
{
DialogResult dlgResult = MessageBox.Show($"A book with the same name has been found in Storage Space {existingSpace.Name}. Do you want to replace the existing book entry with this one ?", "Duplicate Title",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (dlgResult == DialogResult.Yes)
{
Document existingBook = (from b in ebooks
where $"{ b.FileName}".Equals($"{txtFileName.Text.Trim()}")
select b).First();
SetBookFields(existingBook);
}
}
else
{
// Insert new book
Document newBook = new Document();
SetBookFields(newBook);

if (ebooks == null)
ebooks = new List<Document>();
ebooks.Add(newBook);
existingSpace.BookList = ebooks;
}
}
await _spaces.WriteToDataStore(_jsonPath);
PopulateStorageSpacesList();
MessageBox.Show("Book added");
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}

We call a helper method in the preceding method, called SetBookFields:

private void SetBookFields(Document book)
{
book.FileName = txtFileName.Text;
book.Extension = txtExtension.Text;
book.LastAccessed = dtLastAccessed.Value;
book.Created = dtCreated.Value;
book.FilePath = txtFilePath.Text;
book.FileSize = txtFileSize.Text;
book.Title = txtTitle.Text;
book.Author = txtAuthor.Text;
book.Publisher = txtPublisher.Text;
book.Price = txtPrice.Text;
book.ISBN = txtISBN.Text;
book.PublishDate = dtDatePublished.Value;
book.Category = txtCategory.Text;
}

Lastly, as a matter of housekeeping, the ImportBooks form contains the following code for displaying and enabling controls based on the button click events of the btnCancelNewStorageSpace and btnAddNewStorageSpace buttons:

private void btnCancelNewStorageSpaceSave_Click(object sender, EventArgs e)
{
txtNewStorageSpaceName.Clear();
txtNewStorageSpaceName.Visible = false;
lblStorageSpaceDescription.Visible = false;
txtStorageSpaceDescription.ReadOnly = true;
txtStorageSpaceDescription.Clear();
btnSaveNewStorageSpace.Visible = false;
btnCancelNewStorageSpaceSave.Visible = false;
dlVirtualStorageSpaces.Enabled = true;
btnAddNewStorageSpace.Enabled = true;
}

private void btnAddNewStorageSpace_Click(object sender, EventArgs e)
{
txtNewStorageSpaceName.Visible = true;
lblStorageSpaceDescription.Visible = true;
txtStorageSpaceDescription.ReadOnly = false;
btnSaveNewStorageSpace.Visible = true;
btnCancelNewStorageSpaceSave.Visible = true;
dlVirtualStorageSpaces.Enabled = false;
btnAddNewStorageSpace.Enabled = false;
}

All that remains now is for us to complete the code in the Form1.cs form, which is the startup form.

Creating the main eBookManager form

Start off by renaming Form1.cs to eBookManager.cs. This is the startup form for the application, and it will list all existing storage spaces that were previously saved:

Design your eBookManager form as follows:

  • A ListView control for existing storage spaces
  • A ListView for eBooks contained in the selected storage space
  • A button that opens the file location of the eBook
  • A menu control to navigate to the ImportBooks.cs form
  • Various read-only fields to display the selected eBook information:

Again, due to the nature of the WinForms designer, you may choose to simply copy and paste the designer code from the repository.

The following using statements will be needed in this section:

using eBookManager.Engine;
using eBookManager.Helper;
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows.Forms;
using System.Linq;
using System.Diagnostics;
As demonstrated earlier, you may choose to omit this and then press Ctrl + . each time a particular method or namespace isn't recognized.

Bear in mind that you won't be able to use this to include libraries with extension methods, so you'll need to include eBookManager.Helper manually.

Let's now start designing our eBookManager form with the help of the following steps:

  1. The constructor and load methods are quite similar to those in the ImportBooks.cs form. They read any available storage spaces and populate the storage spaces list view control with the previously saved storage spaces:
private string _jsonPath;
private List<StorageSpace> _spaces;

public eBookManager()
{
InitializeComponent();
_jsonPath = Path.Combine(Application.StartupPath,
"bookData.txt");
}

private async void eBookManager_Load(object sender, EventArgs e)
{
_spaces = await _spaces.ReadFromDataStore(_jsonPath);

// imageList1
this.imageList1.Images.Add("storage_space_cloud.png", Image.FromFile("img/storage_space_cloud.png"));
this.imageList1.Images.Add("eBook.png", Image.FromFile("img/eBook.png"));
this.imageList1.Images.Add("no_eBook.png", Image.FromFile("img/no_eBook.png"));
this.imageList1.TransparentColor = System.Drawing.Color.Transparent;

// btnReadEbook
this.btnReadEbook.Image = Image.FromFile("img/ReadEbook.png");
this.btnReadEbook.Location = new System.Drawing.Point(103, 227);
this.btnReadEbook.Name = "btnReadEbook";
this.btnReadEbook.Size = new System.Drawing.Size(36, 40);
this.btnReadEbook.TabIndex = 32;
this.toolTip1.SetToolTip(this.btnReadEbook, "Click here to open the eBook file location");
this.btnReadEbook.UseVisualStyleBackColor = true;
this.btnReadEbook.Click += new System.EventHandler(this.btnReadEbook_Click);

// eBookManager Icon
this.Icon = new System.Drawing.Icon("ico/mainForm.ico");

PopulateStorageSpaceList();
}

private void PopulateStorageSpaceList()
{
lstStorageSpaces.Clear();
if (!(_spaces == null))
{
foreach (StorageSpace space in _spaces)
{
ListViewItem lvItem = new ListViewItem(space.Name, 0);
lvItem.Tag = space.BookList;
lvItem.Name = space.ID.ToString();
lstStorageSpaces.Items.Add(lvItem);
}
}
}
  1. If the user clicks on a storage space, we need to be able to read the books contained in that selected space:
private void lstStorageSpaces_MouseClick(object sender, MouseEventArgs e)
{
ListViewItem selectedStorageSpace =
lstStorageSpaces.SelectedItems[0];
int spaceID = selectedStorageSpace.Name.ToInt();
txtStorageSpaceDescription.Text = (from d in _spaces
where d.ID == spaceID
select d.Description).First();
List<Document> ebookList =
(List<Document>)selectedStorageSpace.Tag;
PopulateContainedEbooks(ebookList);
}
  1. We now need to create the method that will populate the lstBooks list view with the books contained in the selected storage space:
private void PopulateContainedEbooks(List<Document> ebookList)
{
lstBooks.Clear();
ClearSelectedBook();
if (ebookList != null)
{
foreach (Document eBook in ebookList)
{
ListViewItem book = new ListViewItem(eBook.Title, 1);
book.Tag = eBook;
lstBooks.Items.Add(book);
}
}
else
{
ListViewItem book = new ListViewItem("This storage space contains no eBooks", 2);
book.Tag = "";
lstBooks.Items.Add(book);
}
}
  1. We also need to clear the selected book's details when the selected storage space is changed. I have created two group controls around the file and book details. This code just loops through all the child controls; if the child control is a textbox, it clears it:
private void ClearSelectedBook()
{
foreach (Control ctrl in gbBookDetails.Controls)
{
if (ctrl is TextBox)
ctrl.Text = "";
}
foreach (Control ctrl in gbFileDetails.Controls)
{
if (ctrl is TextBox)
ctrl.Text = "";
}
dtLastAccessed.Value = DateTime.Now;
dtCreated.Value = DateTime.Now;
dtDatePublished.Value = DateTime.Now;
}
  1. The MenuStrip that was added to the form has a click event on the ImportEbooks menu item. It simply opens up the ImportBooks form:
private async void mnuImportEbooks_Click(object sender, EventArgs e)
{
ImportBooks import = new ImportBooks();
import.ShowDialog();
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
PopulateStorageSpaceList();
}
  1. The following method wraps up the logic to select a specific eBook and populate the file and eBook details on the eBookManager form:
private void lstBooks_MouseClick(object sender, MouseEventArgs e)
{
ListViewItem selectedBook = lstBooks.SelectedItems[0];
if (!String.IsNullOrEmpty(selectedBook.Tag.ToString()))
{
Document ebook = (Document)selectedBook.Tag;
txtFileName.Text = ebook.FileName;
txtExtension.Text = ebook.Extension;
dtLastAccessed.Value = ebook.LastAccessed;
dtCreated.Value = ebook.Created;
txtFilePath.Text = ebook.FilePath;
txtFileSize.Text = ebook.FileSize;
txtTitle.Text = ebook.Title;
txtAuthor.Text = ebook.Author;
txtPublisher.Text = ebook.Publisher;
txtPrice.Text = ebook.Price;
txtISBN.Text = ebook.ISBN;
dtDatePublished.Value = ebook.PublishDate;
txtCategory.Text = ebook.Category;
}
}
  1. Lastly, when the book selected is the one you wish to read, click on the Read eBook button to open the file location of the selected eBook:
private void btnReadEbook_Click(object sender, EventArgs e)
{
string filePath = txtFilePath.Text;
FileInfo fi = new FileInfo(filePath);
if (fi.Exists)
{
Process.Start("explorer.exe", Path.GetDirectoryName(filePath));
}
}

This completes the code logic contained in the eBookManager application.

You can further modify the code to open the required application for the selected eBook instead of just the file location. In other words, if you click on a PDF document, the application can launch a PDF reader with the document loaded. Lastly, note that classification has not been implemented in this version of the application.

It is time to fire up the application and test it out.

Running the eBookManager application

To run the application, please perform the following steps:

  1. When the application starts for the first time, there will be no virtual storage spaces available. To create one, we will need to import some books. Click on the Import eBooks menu item:

  1. The Import eBooks screen opens. You can add a new storage space and select the source folder for eBooks:

  1. Once you have selected an eBook, add the information about the book and save it to the storage space. After you have added all the storage spaces and eBooks, you will see a list of virtual storage spaces. As you click on a storage space, the books it contains will be listed:

  1. Selecting an eBook and clicking on the Read eBook button will open up the file location containing the selected eBook.
  2. Lastly, let's have a look at the JSON file generated for the Ebook Manager application. Initially, this will be stored in the output location of the project:

In the following, I've used VS Code to nicely format the JSON:


The keyboard shortcut to format JSON in VS Code is Shift + Alt + F.

As you can see, the JSON file is quite nicely laid out, and it is easily readable.

Now let's see how to upgrade an existing WinForms app to .NET Core 3.

 

Upgrading to .NET Core 3

In order to follow this section, you won't need the WinForms app from the first edition—any WinForms app will do; however, it is recommended that you use that application, especially for the later section where we will discuss C# 8 features.

You can download the original project from the following location:

https://github.com/PacktPublishing/CSharp7-and-.NET-Core-2.0-Blueprints

If you download and run the application, you should see that it still works fine:

Let's now investigate how we can run this exact same code base under .NET Core 3. We'll start with the project file. Basically, we need to tell Visual Studio that we now have a .NET Core 3 project, and not a Framework one.

If you have PowerTools installed (https://marketplace.visualstudio.com/items?itemName=VisualStudioProductTeam.ProductivityPowerPack2017), you can do this from within Visual Studio; if not, then simply open the .csproj file using your favorite text editor:

Change the contents of the .csproj file to the following:

<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
 
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <LangVersion>8.0</LangVersion>
    
    <AssetTargetFallback>uap10.0.18362</AssetTargetFallback>
    <UseWindowsForms>true</UseWindowsForms>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
  </ItemGroup>
 
  <ItemGroup>
    <ProjectReference Include="..\eBookManager.Controls\eBookManager.Controls.csproj" />
    <ProjectReference Include="..\eBookManager.Engine\eBookManager.Engine.csproj" />
    <ProjectReference Include="..\eBookManager.Helper\eBookManager.Helper.csproj" />
  </ItemGroup>
 
  <ItemGroup>
    <Reference Include="System">
      <HintPath>System</HintPath>
    </Reference>
  </ItemGroup>
  
</Project>    

This is, essentially, all that's needed; however, you will need to decide what to do about the project's resources. You can manually just check that they are all set to copy to the output directory; alternatively, we can add an ItemGroup to the project file, such as the following:

  <ItemGroup>
    <None Update="ico\importBooks.ico">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="ico\mainForm.ico">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\add_ebook_to_storage_space.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\add_new_storage_space.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\docx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\docxx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\eBook.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\epubx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\folder-close-x16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\folder_exp_x16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\image sources.txt">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\no_eBook.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\pdfx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\ReadEbook.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\storage_space_cloud.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

As you can see, the whole thing is a great deal simpler than the previous version of the file.

At the time of writing, the first preview of a WinForms editor was released. The following article details what it is currently capable of: https://devblogs.microsoft.com/dotnet/introducing-net-core-windows-forms-designer-preview-1/.

Unfortunately, Preview 1 was not stable enough to make the changes necessary for this chapter, and so we are bypassing the designer.

The next step is to delete the following files, found under Properties:

  • AssemblyInfo.cs
  • Settings.Designer.cs
  • Settings.settings

In fact, by the end of this chapter, the entire Properties folder will be gone.

Actually, that's it. Simply reload the project and hit F5. The app is now running under .NET Core. However, it's very likely you'll get an error at this point. The reason is that we have two other projects that are still running under .NET Framework:

  • eBookManager.Engine
  • eBookManager.Helper

We'll need to migrate each project in a similar way; let's start with eBookManager.Engine. As before, edit the project file and replace what you find there with the following:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup> 
</Project>

As you can see, this is even simpler than before. In fact, it is not necessary to have this target 3.0; we could target 2.2, or even 2.1 if we so chose. Again, we'll delete AssemblyInfo.cs.

Finally, we come to eBookManager.Helper. Edit the project file again to match the following:

<Project Sdk="Microsoft.NET.Sdk"> 
  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
  </ItemGroup>
 
  <ItemGroup>
    <ProjectReference Include="..\eBookManager.Engine\eBookManager.Engine.csproj" />
  </ItemGroup>
</Project>

Again, we'll delete AssemblyInfo.cs. We'll also need to remove a stray reference to System.Windows.Forms in ExtensionMethods.cs.

Fixing compilation errors

Finally, we'll need to restructure some of the code that depends on embedded image resources. If you run the code as-is, you'll likely get an error similar to the following:

At the time of writing, WinForms on .NET Core 3.0 doesn't support binary serialization. As a result, we need to make a few small changes.

Resource files

The first thing we'll need to do is to read the files from the output directory, so we'll change the Copy to Output Directory setting on the image and icon files; highlight all the files, and then change the Copy to Output Directory action to Copy if newer:

The next step is to go to the eBookManager screen.

The eBookManager screen

In the eBookManager.Designer.cs file, remove the imageList1 section:

Also remove the btnReadEbook section:

And finally, remove the this.Icon assignment in the eBookManager section:

We'll move the code that has been removed into the Form_Load event of eBookManager.cs:

private void Form1_Load(object sender, EventArgs e)
{
    System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(eBookManager));
 
    this.components = new System.ComponentModel.Container();
 
    // imageList1
    //this.imageList1.ImageStream = ((System.Windows.Forms.ImageListStreamer)(resources.GetObject("imageList1.ImageStream")));            
    this.imageList1.Images.Add("storage_space_cloud.png", Image.FromFile("img/storage_space_cloud.png"));
    this.imageList1.Images.Add("eBook.png", Image.FromFile("img/eBook.png"));
    this.imageList1.Images.Add("no_eBook.png", Image.FromFile("img/no_eBook.png"));
    this.imageList1.TransparentColor = System.Drawing.Color.Transparent;
 
    // btnReadEbook            
    this.btnReadEbook.Image = Image.FromFile("img/ReadEbook.png");
    this.btnReadEbook.Location = new System.Drawing.Point(103, 227);
    this.btnReadEbook.Name = "btnReadEbook";
    this.btnReadEbook.Size = new System.Drawing.Size(36, 40);
    this.btnReadEbook.TabIndex = 32;
    this.toolTip1.SetToolTip(this.btnReadEbook, "Click here to open the eBook file location");
    this.btnReadEbook.UseVisualStyleBackColor = true;
    this.btnReadEbook.Click += new System.EventHandler(this.btnReadEbook_Click);
 
    // eBookManager Icon            
    this.Icon = new System.Drawing.Icon("ico/mainForm.ico");
 
    PopulateStorageSpaceList();
}

importBooks screen

A similar change is needed for importBooks.Designer.cs. The following section should be removed:

Remove the setter for the btnAddeBookToStorageSpace image in the same file:

Remove the image for btnAddNewStorageSpace (again, in the same file):

Finally, remove the icon setter for the form:

We'll move this into the Form_Load event of ImportBooks.cs, which should now look as follows:

private void ImportBooks_Load(object sender, EventArgs e)
{
    // tvImages                        
    this.tvImages.Images.Add("docx16.png", Image.FromFile("img/docx16.png"));
    this.tvImages.Images.Add("docxx16.png", Image.FromFile("img/docxx16.png"));
    this.tvImages.Images.Add("pdfx16.png", Image.FromFile("img/pdfx16.png"));
    this.tvImages.Images.Add("epubx16.png", Image.FromFile("img/epubx16.png"));
    this.tvImages.Images.Add("folder-close-x16.png", Image.FromFile("img/folder-close-x16.png"));
    this.tvImages.Images.Add("folder_exp_x16.png", Image.FromFile("img/folder_exp_x16.png"));
    this.tvImages.TransparentColor = System.Drawing.Color.Transparent;
 
    // btnAddeBookToStorageSpace
    this.btnAddeBookToStorageSpace.Image = Image.FromFile("img/add_ebook_to_storage_space.png");
    
    // btnAddNewStorageSpace
    this.btnAddNewStorageSpace.Image = Image.FromFile("img/add_new_storage_space.png");
 
    // ImportBooks            
    this.Icon = new System.Drawing.Icon("ico/importBooks.ico");
 
 
    PopulateStorageSpacesList();
                
    if (dlVirtualStorageSpaces.Items.Count == 0)
    {
        dlVirtualStorageSpaces.Items.Add("<create new storage space>");
    }
 
    lblEbookCount.Text = "";
}

ProcessStartInfo

Finally, the following will need to be changed in eBookManager.cs:

private void btnReadEbook_Click(object sender, EventArgs e)
        {
            string filePath = txtFilePath.Text;
            FileInfo fi = new FileInfo(filePath);
            if (fi.Exists)
            {
                var processStartInfo = new ProcessStartInfo(filePath, Path.GetDirectoryName(filePath))
                {
                    // Change in .NET Core - this defaulted to true in WinForms
                    UseShellExecute = true
                };
                Process.Start(processStartInfo);
            }
        }

The reason is that ProcessStartInfo in .NET Framework used to default to UseShellExecute = true. However, in .NET Core, it now defaults to false, and will therefore fail without this change.

That's it! If you run the app, you're now running it under .NET Core 3. It's the same application (albeit with some minor code changes), but now it's running the .NET Core runtime!

Benefits of upgrading to .NET Core

Let's start with the elephant in the room. You can't now take Ebook Manager and run it on Linux—it is not now cross-platform. WinForms always was, still is, and probably always will be, a Windows-specific technology.

Upgrading essentially gives you three benefits:

  • Speed: .NET Core 2.1 saw some significant speed improvements. Your mileage may vary with this, and it's likely that it will depend on exactly what your application is doing. For example, the Ebook Manager application scans the hard drive to retrieve books: it's unlikely that the memory allocation improvements made in .NET Core 2.1 are going to make a huge difference to the speed of that.

  • Support: Once you've upgraded to .NET Core, your app will now be running on a far more active piece of technology; in the future, Microsoft is less likely to change .NET Framework, except for security bug patches, but .NET Core has an active road-map.

  • Enthusiasm: It's hard to get people excited about (or to get people at all) working on a WinForms application written fifteen years ago.
From the announcement with build 2019, it looks like .NET Framework will shortly be swallowed by .NET Core (to be known as .NET 5 at the time of writing). This means that, if you haven't converted by then, you may be on a train that ends (albeit in a few years) with Microsoft withdrawing support for the framework.

C# 8 brings a host of new features to the table, including the following:

  • Nullable reference types
  • Default implementations of interfaces
  • Records
  • Recursive patterns
  • Async streams
  • Ranges
  • Static local functions
  • Using declarations

Taking the top two headline features from that list, it's clear that there is synergy between being able to run legacy code in .NET Core 3 and being able to apply some of these features to help to update and maintain legacy code.

 

Understanding nullable reference types

In .NET Core 2.1 (or any previous version of .NET), we could legitimately type the following code and run it:

string test = null;
Console.WriteLine(test.Length);

And, of course it would crash. Obviously, no-one familiar with the language would write this; however, they might write something as follows:

string test = FunctionCanReturnNull();
Console.WriteLine(test.Length);

Nullable reference types are an opt-in feature (that is, you have to explicitly turn it on) that simply gives a warning to say that you have a potential for a reference type to be null. Let's try turning this on for our Ebook Manager. It can be turned on a class-by-class basis by adding the following directive to the top of a file:

#nullable enable

However, you can also switch it on for the entire project by adding the following line to the .csproj file:

<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<LangVersion>8.0</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>

At the time of writing, this property will be automatically added to the .csproj file.
There are other options that the Nullable option can be configured for; for further information, see the following URL:

https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references

This is a granular feature, so it can be turned off or on for specific sections of code. There are a few warnings that pop up, so let's focus on StorageSpace.cs:

So, what exactly is this telling us?

To answer that, let's look first at ID. ID is a value type, and therefore cannot be null. If nothing is assigned to ID, it will have the default value: 0. Name, however is a string (which is a reference type), and therefore can be null and, in fact, will be null unless we set it otherwise. If we want one of these fields to be nullable, then we can certainly do that (and in the case of Description, we probably should):

But what about Name? We would probably not want that to be null. There's a couple of options here; one is to add a blank string as a default initializer as follows:

public string Name { get; set; } = string.Empty;

This isn't ideal. In fact, getting a null reference exception might actually be preferable to it being blank and bypassing that.


This is just my opinion, but it is much better to have software crash at runtime and alert you to an error in the logic than to soldier on and potentially corrupt data or, worse, request or update data in a third-party system!

Another option is to add a constructor. The following is an example:

[Serializable]
public class StorageSpace
{
    public StorageSpace(string name)
    {
        Name = name;
    }
    public int ID { get; set; }
    public string Name { get; set; }
    public string? Description { get; set; }
    public List<Document>? BookList { get; set; }
}

This clears up the warnings and ensures that anyone creating the class provides a name, which we are saying can never be null. This is instantiated in ImportBooks.cs, so now we'll have to provide that parameter:

private void btnSaveNewStorageSpace_Click(object sender, EventArgs e)
{
    try
    {
        if (txtNewStorageSpaceName.Text.Length != 0)
        {
            string newName = txtNewStorageSpaceName.Text;
 
 
            // null conditional operator: "spaces?.StorageSpaceExists(newName) ?? false"
            // throw expressions: bool spaceExists = (space exists = false) ? return false : throw exception                    
            // Out variables
            bool spaceExists = (!spaces.StorageSpaceExists(newName, out int nextID)) ? false : throw new Exception("The storage space you are trying to add already exists.");
                                
            if (!spaceExists)
            {
                StorageSpace newSpace = new StorageSpace(newName);                        
                newSpace.ID = nextID;
                newSpace.Description = txtStorageSpaceDescription.Text;
                spaces.Add(newSpace);

Now we know that the Name property can never be null, it's worth remembering that the warnings that you get here are just that, warnings; and, like all warnings, it is your prerogative to ignore them. However, C# 8 does have a feature (which I've heard referred to as the dammit operator) that allows you to insist that, despite what the compiler believes, you know the variable will not be null; it looks as follows:

string test = null;
 
Console.WriteLine(test!.Length);
Console.ReadLine();

Obviously, if you do this, the preceding code will crash, so if you do decide that you know better than the compiler, be sure!

 

Exploring XAML Islands

For this section, you will need to be running Windows 10 1903 or later. By the time this book is published, it is expected that the 1903 release will be delivered automatically to all Windows 10 machines; however, if you are running an earlier version, then you can force an update by visiting the following link: https://www.microsoft.com/en-us/software-download/windows10.

In 2019, when this chapter was written, we noted that the TreeView in the import books section looks a little dated. In fact, you'd think it was a TreeView from 2005 when WinForms was all the rage! Also, we'd like to bind our data to the TreeView, rather than build it up separately. While there are some data binding capabilities in WinForms, we are stuck with the general appearance of TreeView.

Unless, that is, we use one of the nice new UWP controls in WinForms. That's exactly what XAML Islands gives us! We can take an existing UWP control, or even create our own, and use it directly from an existing WinForms application.

Let's try and use the TreeView from the UWP Community Toolkit inside our WinForms application.

UWP TreeView

There are a number of setup requirements for this, which I'll detail later.

By the time this is published, the process for setting this up may have been simplified considerably; please refer to the linked articles for the most recent advice.

The first step is to ensure (as detailed in the Technical requirements section) that you're running Windows 10, version 1903 or later. Please follow the information in that section if you are not. The second step is to install the Windows 10 SDK; for this, you can use the following link: https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk.

We will be performing the following article for the next step: https://docs.microsoft.com/en-us/windows/apps/desktop/modernize/desktop-to-uwp-enhance#set-up-your-project.

Add the following NuGet package to your WinForms project:

Microsoft.Windows.SDK.Contracts

Install the XamlHost NuGet package into the WinForms app:

Install-Package Microsoft.Toolkit.Forms.UI.XamlHost

Now we can replace our existing TreeView with the UWP one.

You'll notice that I've fully qualified all the XAML controls. Since we're dealing with two disparate frameworks, this kind of change makes it very easy to get confused and mix up which control you're dealing with.
In the following code samples, I've included class-level variables with the code samples for clarity. I, personally, would suggest that these actually go at the top of your class file. Of course, it makes no functional difference.

The first thing that we need to consider is XamlHost.

WIndowsXamlHost

Let's create our TreeView; we'll do this in the code-behind for ImportBooks.cs. We're going to add some code to the constructor, which will now look as follows:

private readonly Microsoft.Toolkit.Forms.UI.XamlHost.WindowsXamlHost _windowsXamlHostTreeView;        

public ImportBooks() { InitializeComponent(); _jsonPath = Path.Combine(Application.StartupPath, "bookData.txt"); spaces = spaces.ReadFromDataStore(_jsonPath); var windowsXamlHostTreeView = new WindowsXamlHost(); windowsXamlHostTreeView.InitialTypeName = "Windows.UI.Xaml.Controls.TreeView"; windowsXamlHostTreeView.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowOnly; windowsXamlHostTreeView.Location = new System.Drawing.Point(12, 60); windowsXamlHostTreeView.Name = "tvFoundBooks"; windowsXamlHostTreeView.Size = new System.Drawing.Size(513, 350); windowsXamlHostTreeView.TabIndex = 8; windowsXamlHostTreeView.Dock = System.Windows.Forms.DockStyle.None; windowsXamlHostTreeView.ChildChanged += windowsXamlHostTreeView_ChildChanged; this.Controls.Add(windowsXamlHostTreeView); }

Let's quickly review what we've done here (it's actually not that much). Firstly, we've created a new WIndowsXamlHost object. This is the basis for XAML Islands; it acts as a wrapper around your UWP control, so it will work in a WinForms context.


Although this chapter discusses WinForms, the same is true for WPF and, while the exact syntax may differ slightly, the basic principle is the same.

The things to notice on this code sample are as follows:

  • We're setting the name to tvFoundBooks, which is the same name as our WinForms app had.
  • We're listening to the ChildChanged event: this is so that we can set some specifics on the control itself (we'll come back to this shortly).
  • The InitialTypeName is how XAML Islands knows which UWP control to invoke.
  • We're adding the host control to the current form (we also set the location).

ItemTemplate

Now that we've set up the host control, we can have a look at the ChildChanged event that we mentioned; this is where we set up the UWP control (rather than the host control):

private Windows.UI.Xaml.Controls.TreeView? _tvFoundBooks = null;

private
void windowsXamlHostTreeView_ChildChanged(object? sender, EventArgs e) { if (sender == null) return; var host = (WindowsXamlHost)sender; _tvFoundBooks = (Windows.UI.Xaml.Controls.TreeView)host.Child; _tvFoundBooks.ItemInvoked += _tvFoundBooks_ItemInvoked; _tvFoundBooks.ItemsSource = DataSource; const string Xaml = "<DataTemplate xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\"><TreeViewItem ItemsSource=\"{Binding Children}\" Content=\"{Binding Name}\"/></DataTemplate>"; var xaml = XamlReader.Load(Xaml); _tvFoundBooks.ItemTemplate = xaml as Windows.UI.Xaml.DataTemplate; }

Don't worry so much about why _tvFoundBooks is a class-level variable, we'll come back to that shortly. In the preceding code sample, we have a gated check to ensure that sender is not null, and then we're forcing it to a WindowsXamlHost type. Once we have this type, we can get whatever is inside the host by calling the .Child property.

As before, we're listening to the ItemInvoked event (again, we'll come back to this shortly). The first really new thing here is that we're setting the ItemsSource, and the ItemTemplate. We'll come back to ItemsSource, but the template is worth exploring. Unlike WinForms, UWP uses XAML to define how its controls look. This means that you have control over exactly what goes into the TreeView; for example, each node could have an image, or text, or both. However, if you don't specify ItemTemplate, then the rendering engine doesn't know what to display, or how.

The preceding XAML is probably the simplest one that will display anything. You'll notice there are a few binding statements; they are binding to properties relative to the ItemsSource. Let's have a look at exactly what it is we're binding to.

TreeView Item model and ItemsSource

In order to bind something to a control in UWP, you need something. Essentially, what that means is that we need a model.


A model, in .NET terms, is simply a class that holds data.

We're going to create a new class, and we'll call it Item:

public class Item
{
    public string Name { get; set; }
    public ObservableCollection<Item> Children { get; set; } = new ObservableCollection<Item>();
    public ItemType ItemType { get; set; }
    public string FullName { get; set; }
 
    public override string ToString()
    {
        return Name;
    }
}
I would always recommend that models are held in their own file and sit in a folder called Models, but there's no technical reason why you couldn't add this class to the end of ImportBooks.cs.

Most of this class should be self-explanatory; we're holding the Name and FullName (that is, the name and path) of the file. The ObservableCollection is a special type of Collection that allows the UI framework to be notified when it changes.


For the code that we're writing here, we could get away with this simply being a List; however, ObservableCollection is good practice when dealing with desktop XAML frameworks such as UWP, and this will make extensibility easier.

Finally, we're holding the type of the item, which is a new enumerated type:

public enum ItemType
{
    Docx,
    Docxx,
    Pdfx,
    Epubx,
    Folder
}

Back in ImportBooks.cs, we're going to set up our ItemsSource. The first step is to add a class-level variable called DataSource:

public ObservableCollection<Models.Item> DataSource { get; set; }

Our next change is in the btnSelectSourceFolder_Click event handler:

private void btnSelectSourceFolder_Click(object sender, EventArgs e)
{
    try
    {
        FolderBrowserDialog fbd = new FolderBrowserDialog();
        fbd.Description = "Select the location of your eBooks and documents";
 
        DialogResult dlgResult = fbd.ShowDialog();
        if (dlgResult == DialogResult.OK)
        {
            UpdateBookList(fbd.SelectedPath);
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

As you can see, the new method is hugely simplified compared to the previous version; we've extracted all the real logic into a new method, so let's see that next:

private void UpdateBookList(string path)
{            
    DirectoryInfo di = new DirectoryInfo(path);
    var bookList = new List<Models.Item>();
    var rootItem = new Models.Item()
    {
        Name = di.Name
    };
 
    rootItem.ItemType = Models.ItemType.Folder;
 
    PopulateBookList(di.FullName, rootItem);
    bookList.Add(rootItem);
 
    DataSource = new ObservableCollection<Models.Item>(bookList);
    _tvFoundBooks.ItemsSource = DataSource.OrderBy(a => a.Name);
}

Here, we're setting up the root item of our TreeView; however, you'll notice that the only reference that we actually have to the TreeView is at the end, where we refresh ItemsSource. PopulateBookList is our next port of call. As before, this method is essentially in two parts; let's see the first part:

public void PopulateBookList(string paramDir, Models.Item rootItem)
{
    if (rootItem == null) throw new ArgumentNullException();

rootItem.FullName = paramDir;
rootItem.ItemType = Models.ItemType.Folder; DirectoryInfo dir = new DirectoryInfo(paramDir); foreach (DirectoryInfo dirInfo in dir.GetDirectories()) { var item = new Models.Item(); item.Name = dirInfo.Name; rootItem.Children.Add(item); PopulateBookList(dirInfo.FullName, item); }

Here, we're recursively traversing the directory structure and populating our new model. Notice that we're setting the item type and the FullName (the directory path) at the start, and then we iterate through all the sub-directories, re-calling our method.

Recursion is the practice of calling a method from itself. Is can be very useful in scenarios such as this, where you wish to perform exactly the same operation on nested objects. It is faster than using a loop; however, it does have the potential to fill up the stack very quickly if used incorrectly.

For the second part of the function, we'll process any files that are in the current directory (that is, whichever directory is at the top of the recursion stack at the time):

    foreach (FileInfo fleInfo in dir.GetFiles().Where(x => AllowedExtensions.Contains(x.Extension)).ToList())
    {
        var item = new Models.Item();
        item.Name = fleInfo.Name;
 
        item.FullName = fleInfo.FullName;
        item.ItemType = (Models.ItemType)Enum.Parse(typeof(Extention), fleInfo.Extension.TrimStart('.'), true);
 
        rootItem.Children.Add(item);
    }
}

Our next change is to the ItemInvoked method; the new method should look as follows:

private void _tvFoundBooks_ItemInvoked(Windows.UI.Xaml.Controls.TreeView sender, Windows.UI.Xaml.Controls.TreeViewItemInvokedEventArgs args)
{
    var selectedItem = (Models.Item)args.InvokedItem;
 
    DocumentEngine engine = new DocumentEngine();
    string path = selectedItem.FullName.ToString();
 
    if (File.Exists(path))
    {
        var (dateCreated, dateLastAccessed, fileName, fileExtention, fileLength, hasError) = engine.GetFileProperties(selectedItem.FullName.ToString());
 
        if (!hasError)
        {
            txtFileName.Text = fileName;
            txtExtension.Text = fileExtention;
            dtCreated.Value = dateCreated;
            dtLastAccessed.Value = dateLastAccessed;
            txtFilePath.Text = selectedItem.FullName.ToString();
            txtFileSize.Text = $"{Round(fileLength.ToMegabytes(), 2).ToString()} MB";
        }
    }
}

Again, this is very marginally changed; instead of storing the full filename (with the path) in the node tag property, we're now just referencing the underlying model, so it's much clearer. Our next step is to remove the existing WinForms TreeView control.

Removing the existing TreeView

The following code should be removed from ImportBooks.Designer.cs:

// 
// tvFoundBooks
// 
this.tvFoundBooks.Location = new System.Drawing.Point(12, 41);
this.tvFoundBooks.Name = "tvFoundBooks";
this.tvFoundBooks.Size = new System.Drawing.Size(513, 246);
this.tvFoundBooks.TabIndex = 8;
this.tvFoundBooks.AfterSelect += new System.Windows.Forms.TreeViewEventHandler(this.tvFoundBooks_AfterSelect);

This will remove the control itself. Later, we'll need to remove the following code that adds the TreeView to the controls collection:

this.Controls.Add(this.tvFoundBooks);

That's it. If you now run the project, you'll see a UWP TreeView control right in the middle of a WinForms application.

 

Tree shaking and compiling to a single executable

The main reason why web applications have grown in popularity, eclipsing their desktop cousins, is the problem of deployment. It sounds a trivial issue on the face of it, but it definitely is not. There have been many attempts to solve the problem, from technologies such as ClickOnce to the App Store model (of UWP, Apple, and Google). One of the reasons why this is so difficult in the desktop world and so simple in the web world is that, while both may have a complex tree of dependencies, the web allows those dependencies to mostly live on the server, so they don't need to be directly deployed to the client machine.

One useful feature in .NET Core 3 is the ability to bundle all of your dependencies into a single executable.

This has previously been possible using the concept of IL weavers. This topic is beyond the scope of this book; however, because IL is not compiled, it opens the door to changing it after the project has been deployed.

In .NET Core 3, we can compile our project into a single executable by adding the following line to the .csproj file:

<PublishSingleFile>true</PublishSingleFile>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>

When you publish the application, you'll get a single executable.

You can even reduce the size of this executable by using built-in tree shaking. This is the process of removing dependencies that are not used by the application; this requires the following additional line in the .csproj file:

<PublishTrimmed>true</PublishTrimmed>
At the time of writing, this method did not copy across assets (images), so you will need to do that manually until that issue is fixed. Please see the following link for the current details on this feature: https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0.

 

Summary

In this chapter, we migrated an existing WinForms application over to .NET Core 3. This means that, even though we might have an application that is 10 or 15 years old, you can update it to use the latest framework and take advantage of the performance improvements and new features afforded by .NET Core 3.

Taking advantage of the upgrade, we then investigated one of the key features of C# 8: nullable reference types. This means that, without the use of any third-party software, we were able to expose dozens of potential bugs in our legacy code base.

We didn't stop there; we then used XAML Islands to extend our application by incorporating UWP features into WinForms. This is potentially the most exciting feature, as it essentially means that you can rewrite a legacy WinForms application from the outside in.

In the final section, we took advantage of the new packaging methods available in .NET Core 3. We used the process of tree shaking to reduce the size of our output file, and compiled our code to a single executable.

Like me, you may be seeing a pattern with these features. .NET Core has, in this release, incorporated Windows-only features, meaning you can now take a legacy WinForms app and convert it to use the latest .NET Core, thereby benefiting from all the performance improvements. You can extend it by using component creating in WPF or UWP without needing to rewrite the application. Additionally, deployment is now made easier, as we can now compile into a single executable.

In the next chapter, we'll be looking ASP.NET Core 3 and creating an MVC application that uses Cosmos DB.

About the Authors

  • Paul Michaels

    Paul Michaels is a Lead Developer with over 20 years experience. He likes programming, playing with new technology and solving problems. When he’s not working, you can find him cycling or walking around The Peak District, playing table tennis, or trying to cook for his wife and two children. You can follow him on twitter at @paul_michaels, or find him on LinkedIn by searching for pcmichaels. He also writes a blog for which the link is available on both his LinkedIn and Twitter profiles.

    Browse publications by this author
  • Dirk Strauss

    Dirk Strauss is a full-stack developer with Embrace. He enjoys learning and sharing what he learns with others. Dirk has published books on C# for Packt as well as ebooks for Syncfusion. In his spare time, he relaxes by playing guitar and trying to learn Jimi Hendrix licks. You can find him at @DirkStrauss on Twitter.

    Browse publications by this author
  • Jas Rademeyer

    Jas Rademeyer has been a part of the IT industry for over 15 years, focusing on the software side of things for most of his career. With a degree in information science, specializing in multimedia, he has been involved in all facets of development, ranging from architecture and solution design to user experience and training. He is currently plying his trade as a technical solutions manager, where he manages development teams on various projects in the Microsoft space. A family man and a musician at heart, he spends his free time with his wife and two kids and serves in the worship band at church.

    Browse publications by this author

Recommended For You

Hands-On Software Architecture with C# 8 and .NET Core 3

Design scalable and high-performance enterprise applications using the latest features of C# 8 and .NET Core 3

By Francesco Abbruzzese and 1 more
ASP.NET Core 3 and React

Build modern, scalable, and cloud-ready single-page applications using ASP.NET Core, React, TypeScript, and Azure

By Carl Rippon
Modern App Development with C# 8 and .NET Core 3.0 [Video]

Build amazing cross-device and cross-platform applications—web, mobile, and desktop alike with new C# and .NET Core

By Dimitris Loukas
Learning C# 8 and .NET Core 3.0 [Video]

Get to know the basics of development using the new features in C# 8 along with implementation using .NET Core 3

By Mohammed Ezzat