主控台應用程式

本教學課程會教導您 .NET 和 C# 語言中的一些功能。 您將了解:

  • .NET CLI 的基本概念
  • 「C# 主控台應用程式」的結構
  • 主控台 I/O
  • .NET 中檔案 I/O API 的基本概念
  • .NET 中以工作為基礎的非同步程式設計的基本概念

您會組建一個應用程式,此應用程式會讀取文字檔,並將該文字檔的內容回應至主控台。 對主控台之輸出的步調會符合可大聲朗讀它的步調。 您可以按 ‘<’ (小於) 或 ‘>’ (大於) 鍵來將該步調調快或調慢。 您可以在 Windows、Linux、macOS 或 Docker 容器中執行此應用程式。

本教學課程中有許多功能。 讓我們逐一組建。

必要條件

建立應用程式

第一個步驟是建立新的應用程式。 請開啟命令提示字元,然後為您的應用程式建立新目錄。 使該目錄成為目前的目錄。 在命令提示字元處輸入命令 dotnet new console。 這會建立基本 "Hello World" 應用程式的起始檔案。

在您開始進行修改之前,讓我們先執行簡單 Hello World 應用程式。 在建立應用程式之後,請在命令提示字元處輸入 dotnet run。 此命令會執行 NuGet 套件還原程式、建立應用程式可執行檔,並執行可執行檔。

簡單的 Hello World 應用程式程式碼盡在 Program.cs 中。 請使用您慣用的文字編輯器來開啟該檔案。 將 Program.cs 中的程式碼取代為下列程式碼:

namespace TeleprompterConsole;

internal class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

在該檔案的開頭,您會看到 namespace 陳述式。 與您可能已使用的其他「物件導向」語言相同,C# 也使用命名空間來組織型別。 這個 Hello World 程式並無不同。 您可以看到此程式位在名稱為 TeleprompterConsole 的命名空間中。

讀取及回應檔案

要新增的第一個功能是能夠讀取文字檔,並對主控台顯示該全部文字。 首先,讓我們新增一個文字檔。 請從 GitHub 儲存機制將此範例sampleQuotes.txt 檔案複製到您的專案目錄中。 這將作為您應用程式的指令碼。 如需如何下載本主題之範例應用程式的資訊,請參閱範例和教學課程中的指示。

接著,在您的 Program 類別 (就在 Main 方法下方) 中新增下列方法:

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

此方法是 C# 方法的特殊型別,稱為「迭代器方法」。 迭代器方法會傳回延遲評估的序列。 這意謂著序列中的每個項目會在取用序列的程式碼要求該項目時產生。 列舉程式方法是包含一或多個 yield return 陳述式的方法。 ReadFrom 方法所傳回的物件包含用來產生序列中每個項目的程式碼。 在此範例中,這牽涉到從原始程式檔讀取下一行文字,並傳回該字串。 每次呼叫程式碼要求序列中的下一個項目時,程式碼都會從檔案讀取下一行文字,並傳回該文字。 完全讀取檔案後,序列會指出已沒有任何其他項目。

有兩個 C# 語法元素可能是您不熟悉的。 此方法中的 using 陳述式會管理資源清除。 在 using 陳述式中初始化的變數 (在此範例中為 reader) 必須實作 IDisposable 介面。 該介面會定義單一方法 Dispose,而在應該釋出資源時應該呼叫此方法。 編譯器會在執行到達 using 陳述式的結尾大括號時產生該呼叫。 編譯器產生的程式碼會確保即使 using 陳述式所定義區塊中的程式碼擲回例外狀況,也會釋出資源。

定義 reader 變數時,是使用 var 關鍵字來定義。 var 會定義一個「隱含型別區域變數」。 這意謂著變數的型別取決於指派給該變數之物件的編譯階段型別。 在這裡,這是來自 OpenText(String) 方法的傳回值,是一個 StreamReader 物件。

現在,讓我們在 Main 方法中填入可讀取檔案的程式碼:

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

請執行程式 (使用 dotnet run),然後您便可以看到每一行顯示在主控台中。

新增延遲及設定輸出格式

您的內容顯示速度太快,無法大聲朗讀。 現在您必須在輸出中新增延遲。 開始時,您會組建能夠進行非同步處理的部分核心程式碼。 不過,這些開頭的步驟會依循一些反模式。 在您新增程式碼時,註解中會指出這些反模式,然後在稍後的步驟中將會更新此程式碼。

這個部分有兩個步驟。 首先,您會更新迭代器方法,傳回單一文字而不是整行。 藉由下列修改來完成。 請以下列程式碼取代 yield return line; 陳述式:

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

接著,您必須修改取用檔案行的方式,然後在寫入每個字之後新增延遲。 請以下列區塊取代 Main 方法中的 Console.WriteLine(line) 陳述式:

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

請執行範例並檢查輸出。 現在會顯示每個單字,後面接著 200 毫秒的延遲。 不過,顯示的輸出出現一些問題,因為來源文字檔有數行超過 80 個字元且沒有分行符號。 這在捲動時會相當難以閱讀。 這個問題很容易修正。 您只需記錄每一行的長度,並在每次一行達到特定臨界值時就進行換行。 請在保存行長度的 ReadFrom 方法中 words 的宣告之後宣告區域變數:

var lineLength = 0;

接著,在 yield return word + " "; 陳述式之後 (在結尾大括號之前) 新增下列程式碼:

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

請執行範例,然後您即可以其預先設定的步調大聲朗讀。

非同步工作

在這最後一個步驟中,您會新增程式碼並在一個工作中以非同步方式寫入輸出,同時也執行另一個工作以讀取來自使用者的輸入 (如果他們想要加速或減緩文字顯示,或完全停止文字顯示)。 這包括幾個步驟,而最後您會擁有您所需的一切更新。 第一個步驟是建立傳回方法的非同步 Task,該方法代表您到目前為止已建立來讀取和顯示檔案的程式碼。

請將下列方法新增到您的 Program 類別 (這是取自您 Main 方法的主體):

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

您將會注意到兩項變更。 首先,在方法的主體中,此版本使用 await 關鍵字,而不是呼叫 Wait() 來同步等候工作完成。 為了這麼做,您必須將 async 修飾詞新增到方法簽章。 此方法會傳回 Task。 請注意,並沒有會傳回 Task 物件的傳回陳述式。 取而代之的是,會由編譯器在您使用 await 運算子時產生的程式碼建立 Task 物件。 您可以想像這個方法會在到達 await 時返回。 傳回的 Task 指出工作尚未完成。 方法會在所等候的工作完成時繼續執行。 當它執行到完成時,傳回的 Task 會指出它已完成。 呼叫程式碼可以監視傳回的 Task 以判斷它何時完成。

呼叫 ShowTeleprompter 之前新增 await 關鍵字:

await ShowTeleprompter();

這需要您將 Main 方法簽章變更為:

static async Task Main(string[] args)

在基本概念一節中深入瞭解async Main 方法

接著,您必須撰寫第二個非同步方法,以從主控台讀取並監視 ‘<’ (小於)、‘>’ (大於) 和 ‘X’ 或 ‘x’ 鍵。 以下是您針對該工作新增的方法:

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;
            }
            else if (key.KeyChar == 'X' || key.KeyChar == 'x')
            {
                break;
            }
        } while (true);
    };
    await Task.Run(work);
}

這會建立 lambda 運算式來代表 Action 委派,此委派會從主控台讀取機碼,並修改使用者按 ‘<’ (小於) 或 ‘>’ (大於) 鍵時,代表延遲的區域變數。 當使用者按下 ‘X’ 或 ‘x’ 鍵時,委派方法即完成,可讓使用者隨時停止文字顯示。 此方法會使用 ReadKey() 來封鎖並等候使用者按下按鍵。

若要完成此功能,您必須建立一個會傳回方法的新 async Task,該方法既會啟動這兩項工作 (GetInputShowTeleprompter),也會管理這兩項工作之間的共用資料。

現在即可建立可處理這兩項工作間之共用資料的類別。 此類別包含兩個公用屬性:延遲和一個指出已完全讀取檔案的 Done 旗標:

namespace TeleprompterConsole;

internal class TelePrompterConfig
{
    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);
        DelayInMilliseconds = newDelay;
    }
    public bool Done { get; private set; }
    public void SetDone()
    {
        Done = true;
    }
}

請將該類別放在新檔案中,然後將該類別包含在 TeleprompterConsole 命名空間中 (如圖所示)。 您也必須將 using static 陳述式新增至檔案的頂端,才能在不使用封入類別或命名空間名稱的情況下參考 MinMax 方法。 using static 陳述式會從一個類別匯入方法。 這與不含 staticusing 陳述式形成對比,後者會從命名空間匯入所有類別。

using static System.Math;

接著,您必須更新 ShowTeleprompterGetInput 方法以使用新的 config 物件。 請撰寫一個最後的 Task 來傳回 async 方法,以啟動兩項工作並在第一個工作完成時結束:

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

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

這裡的新方法是 WhenAny(Task[]) 呼叫。 此方法會建立一個 Task,它會在其引數清單中的任何工作完成時便結束。

接著,您必須更新 ShowTeleprompterGetInput 方法以使用 config 物件來設定延遲:

private static async Task ShowTeleprompter(TelePrompterConfig config)
{
    var words = ReadFrom("sampleQuotes.txt");
    foreach (var word in words)
    {
        Console.Write(word);
        if (!string.IsNullOrWhiteSpace(word))
        {
            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);
            else if (key.KeyChar == 'X' || key.KeyChar == 'x')
                config.SetDone();
        } while (!config.Done);
    };
    await Task.Run(work);
}

這個新版本的 ShowTeleprompter 會呼叫 TeleprompterConfig 類別中的新方法。 現在,您必須更新 Main 以呼叫 RunTeleprompter 而不是 ShowTeleprompter

await RunTeleprompter();

結論

本教學課程示範一些與在主控台應用程式中工作有關的 C# 語言和 .NET Core 程式庫相關功能。 您可以利用這項知識作為基礎,進一步探索這裡介紹的語言和類別。 您已瞭解檔案和主控台 I/O 的基本概念、封鎖和非封鎖使用工作型非同步程式設計、C# 語言的導覽,以及 C# 程式的組織方式,以及 .NET CLI。

如需檔案 I/O 的詳細資訊,請參閱檔案和資料流 I/O。 如需本教學課程中所使用非同步程式設計模型的詳細資訊,請參閱以工作為基礎的非同步程式設計非同步程式設計