Skip to content

Latest commit

 

History

History
447 lines (380 loc) · 15.6 KB

console-teleprompter.md

File metadata and controls

447 lines (380 loc) · 15.6 KB
title description keywords author manager ms.date ms.topic ms.prod ms.technology ms.devlang ms.assetid
Console Application
Console Application
.NET, .NET Core
BillWagner
wpickett
06/20/2016
article
.net-core
.net-core-technologies
dotnet
883cd93d-50ce-4144-b7c9-2df28d9c11a0

Console Application

Introduction

This tutorial teaches you a number of features in .NET Core and the C# language. You’ll learn:

  • The basics of the .NET Core Command Line Interface (CLI).
  • The structure of a C# Console Application.
  • Console I/O.
  • The basics of File I/O APIS in .NET Core
  • The basics of the Task Asynchronous Programming Model in .NET Core.

You’ll build an application that reads a text file, and echoes the contents of that text file to the console. The output to the console will be paced to match reading it aloud. You can speed up or slow down the pace by pressing the ‘<’ or ‘>’ keys.

There are a lot of features in this tutorial. Let’s build them one by one.

Prerequisites

You’ll need to setup your machine to run .NET core. You can find the installation instructions on the .NET Core page. You can run this application on Windows, Linux, macOS or in a Docker container. You’ll need to install your favorite code editor.

Create the Application

The first step is to create a new application. Open a command prompt and create a new directory for your application. Make that the current directory. Type the command "dotnet new" at the command prompt. This creates the starter files for a basic “Hello World” application.

Before you start making modifications, let’s go through the steps to run the simple Hello World application. After creating the application, type "dotnet restore" at the command prompt. This command runs the NuGet package restore process. NuGet is a .NET package manager. This command downloads any of the missing dependencies for your project. As this is a new project, none of the dependencies are in place, so the first run will download the .NET Core framework. After this initial step, you will only need to run dotnet restore when you add new dependent packages, or update the versions of any of your dependencies. This process also creates the project lock file (project.lock.json) in your project directory. This file helps to manage the project dependencies. It contains the local location of all the project dependencies. You do not need to put the file in source control; it will be generated when you run “dotnet restore”.

After restoring packages, you run “dotnet build”. This executes the build engine and creates your application executable. Finally, you execute “dotnet run” to run your application.

The simple Hello World application code is all in Program.cs. Open that file with your favorite text editor. We’re about to make our first changes. At the top of the file, see a using statement:

using System;

This statement tells the compiler that any types from the System namespace are in scope. Like other Object Oriented languages you may have used, C# uses namespaces to organize types. This hello world program is no different. You can see that the program is enclosed in the ConsoleApplication namespace. That’s not a very descriptive name, so change it to TeleprompterConsole.

namespace TeleprompterConsole

Reading and Echoing the File

The first feature to add is to read a text file, and display all that text to the console. First, let’s add a text file. Copy the sampleQuotes.txt file from the GitHub repository for this sample into your project directory. This will serve as the script for your application.

Next, add the following method in your Program class (right below the Main method):

static IEnumerable<string> ReadFrom(string file)
{
    string line;
    using (var reader = File.OpenText(file))
    {
        while ((line = reader.ReadLine()) != null)
        {
            yield return line;
        }
    }
}

This method uses types from two new namespaces. For this to compile you’ll need to add the following two lines to the top of the file:

using System.Collections.Generic;
using System.IO;

The IEnumerable<T> interface is defined in the System.Collections.Generic namespace. The File class is defined in the System.IO namespace.

This method is a special type of C# method called an Enumerator method. Enumerator methods return sequences that are evaluated lazily. That means each item in the sequence is generated as it is requested by the code consuming the sequence. Enumerator methods are methods that contain one or more yield return statements. The object returned by the ReadFrom() method contains the code to generate each item in the sequence. In this example, that involves reading the next line of text from the source file, and returning that string. Each time the calling code requests the next item from the sequence, the code reads the next line of text from the file and returns it. When the file has been completely read, the sequence indicates that there are no more items.

There are two other C# syntax elements that may be new to you. The using statement in this method manages resource cleanup. The variable that is initialized in the using statement (reader, in this example) must implement the IDisposable interface. The IDisposable interface defines a single method, Dispose(), that should be called when the resource should be released. The compiler generates that call when execution reaches the closing brace of the using statement. The compiler-generated code ensures that the resource is released even if an exception is thrown from the code in the block defined by the using statement.

The reader variable is defined using the var keyword. var defines an implicitly typed local variable. That means the type of the variable is determined by the compile time type of the object assigned to the variable. Here, that is the return value from File.OpenText(), which is a StreamReader object.

Now, let’s fill in the code to read the file in the Main method:

var lines = ReadFrom("SampleQuotes.txt");
foreach (var line in lines)
{
    Console.WriteLine(line); 
}

Run the program (using "dotnet run" and you can see every line printed out to the console.

Adding Delays and Formatting output

What you have is being displayed far too fast to read aloud. Now you need to add the delays in the output. As you start, you’ll be building some of the core code that enables asynchronous processing. However, these first steps will follow a few anti-patterns. The anti-patterns are pointed out in comments as you add the code, and the code will be updated in later steps.

There are two steps to this section. First, you’ll update the iterator method to return single words instead of entire lines. That’s done with these modifications. Replace the yield return line; statement with the following code:

var words = line.Split(' ');
foreach (var word in words)
{
    yield return word + " ";
}
yield return Environment.NewLine;

Next, you need to modify how you consume the lines of the file, and add a delay after writing each word. Replace the Console.WriteLine() statement in the Main method with the following block:

{
    Console.Write(line);
    if (!string.IsNullOrWhiteSpace(line))
    {
        var pause = Task.Delay(200);
        // Synchronously waiting on a task is an
        // anti-pattern. This will get fixed in later
        // steps.
        pause.Wait();
    }
}

The Task class is in the System.Threading.Tasks namespace, so you need to add that using statement at the top of file:

using System.Threading.Tasks;

Run the sample, and check the output. Now, each single word is printed, followed by a 200 ms delay. However, the displayed output shows some issues because the source text file has several lines that have more than 80 characters without a line break. That can be hard to read while it's scrolling by. That’s easy to fix. You’ll just keep track of the length of each line, and generate a new line whenever the line length reaches a certain threshold. Declare a local variable after the declaration of words that holds the line length:

var lineLength = 0;

Then, add the following code after the yield return word; statement (before the closing brace):

lineLength += word.Length + 1;
if (lineLength > 70)
{
    yield return Environment.NewLine;
    lineLength = 0;
}

Run the sample, and you’ll be able to read aloud at its pre-configured pace.

Async Tasks

In this final step, you’ll add the code to write the output asynchronously in one task, while also running another task to read input from the user if they want to speed up or slow down the text display. This has a few steps in it and by the end, you’ll have all the updates that you need. The first step is to create an asynchronous Task returning method that represents the code you’ve created so far to read and display the file.

Add this method to your Program class: (It’s taken from the body of your Main method:

private static async Task ShowTeleprompter()
{
    var words = ReadFrom("SampleQuotes.txt");
    foreach (var line in words)
    {
        Console.Write(line);
        if (!string.IsNullOrWhiteSpace(line))
        {
            await Task.Delay(200);
        }
    }
}

You’ll notice two changes. First, in the body of the method, instead of calling Wait() to synchronously wait for a task to finish, this version uses the await keyword. In order to do that, you need to add the async modifier to the method signature. This method returns a Task. Notice that there are no return statements that return a Task object. Instead, that Task object is created by code the compiler generates when you use the await operator. You can imagine that this method returns when it reaches an await. The returned Task indicates that the work has not completed. The method resumes when the awaited task completes. When it has executed to completion, the returned Task indicates that it is complete. Calling code can monitor that returned task to determine when it has completed.

You can call this new method in your Main program:

ShowTeleprompter().Wait();

Here, in Main(), the code does synchronously wait. You should use the await operator instead of synchronously waiting whenever possible. But, in a console application’s Main method, you cannot use the await operator. That would result in the application exiting before all tasks have completed.

Next, you need to write the second asynchronous method to read from the Console and watch for the ‘<’ and ‘>’ keys. Here’s the method you add for that task:

private static async Task GetInput()
{
    var delay = 200;
    Action work = () =>
    {
        do {
            var key = Console.ReadKey(true);
            if (key.KeyChar == '>')
            {
                delay -= 10;
            }
            else if (key.KeyChar == '<')
            {
                delay += 10;
            }
        } while (true);
    };
    await Task.Run(work);
}

This creates a lambda expression to represent an Action that reads a key from the Console and modifies a local variable representing the delay when the user presses the ‘<’ or ‘>’ keys. This method uses Console.ReadKey() to block and wait for the user to press a key.

To finish this feature, you need to create a new async task returning method that starts both of these tasks (GetInput() and ShowTeleprompter(), and also manage the shared data between these two tasks.

It’s time to create a class that can handle the shared data between these two tasks. This class contains two public properties: the delay, and a flag to indicate that the file has been completely read:

namespace TeleprompterConsole
{
    internal class TelePrompterConfig
    {
        private object lockHandle = new object();
        public int DelayInMilliseconds { get; private set; } = 200;

        public void UpdateDelay(int increment) // negative to speed up
        {
            var newDelay = Min(DelayInMilliseconds + increment, 1000);
            newDelay = Max(newDelay, 20);
            lock (lockHandle)
            {
                DelayInMilliseconds = newDelay;
            }
        }
    }
}

Put that class in a new file, and enclose that class in the TeleprompterConsole namespace as shown above. You’ll also need to add a static using statement so that you can reference the Min and Max method without the enclosing class or namespace names. A static using statement imports the methods from one class. This is in contrast with the using statements used up to this point that have imported all classes from a namespace.

using static System.Math;

The other language feature that’s new is the lock statement. This statement ensures that only a single thread can be in that code at any given time. If one thread is in the locked section, other threads must wait for the first thread to exit that section. The lock statement uses an object that guards the lock section. This class follows a standard idiom to lock a private object in the class.

Next, you need to update the ShowTeleprompter and GetInput methods to use the new config object. Write one final Task returning async method to start both tasks and exit when the first task finishes:

private static async Task RunTeleprompter()
{
    var config = new TelePrompterConfig();
    var displayTask = ShowTeleprompter(config);

    var speedTask = GetInput(config);
    await Task.WhenAny(displayTask, speedTask);
}

The one new method here is the Task.WhenAny() call. That creates a Task that finishes as soon as any of the tasks in its argument list completes.

Next, you need to update both the ShowTeleprompter and GetInput methods to use the config object for the delay:

private static async Task ShowTeleprompter(TelePrompterConfig config)
{
    var words = ReadFrom("SampleQuotes.txt");
    foreach (var line in words)
    {
        Console.Write(line);
        if (!string.IsNullOrWhiteSpace(line))
        {
            await Task.Delay(config.DelayInMilliseconds);
        }
    }
    config.SetDone();
}

private static async Task GetInput(TelePrompterConfig config)
{

    Action work = () =>
    {
        do {
            var key = Console.ReadKey(true);
            if (key.KeyChar == '>')
                config.UpdateDelay(-10);
            else if (key.KeyChar == '<')
                config.UpdateDelay(10);
        } while (!config.Done);
    };
    await Task.Run(work);
}

This new version of ShowTeleprompter calls a new method in the TeleprompterConfig class. Now, you need to update Main to call RunTeleprompter instead of ShowTeleprompter:

RunTeleprompter().Wait();

To finish, you'll need to add the SetDone method, and the Done property to the TelePrompterConfig class:

public bool Done => done;

private bool done;

public void SetDone()
{
    done = true;    
}

Conclusion

This tutorial showed you a number of the features around the C# language and the .NET Core libraries related to working in Console applications. You can build on this knowledge to explore more about the language, and the classes introduced here. You’ve seen the basics of File and Console I/O, blocking and non-blocking use of the Task based Asynchronous programming model, a tour of the C# language and how C# programs are organized and the .NET Core Command Line Interface and tools.