併發程式設計-7.任務並行庫(TPL)和資料流

F(x)_King發表於2024-03-30

介紹 TPL 資料流庫

TPL Dataflow 庫的歷史與 TPL 本身一樣長。 它於 2010 年 .NET Framework 4.0 達到其 RTM 里程碑後釋出。 資料流庫的成員是
System.Threading.Tasks.Dataflow 名稱空間。 資料流庫旨在構建 TPL 中提供的並行程式設計基礎知識,並進行擴充套件以解決資料流場景(該庫的名稱由此而來)。 資料流庫由稱為塊的基礎類組成。 每個資料流塊負責整個流中的特定操作或步驟。

資料流庫由三種基本型別的塊組成:

  • 源塊(Source blocks):這些塊實現 ISourceBlock<TOutput> 介面。 源塊可以從您定義的工作流程中讀取資料。

  • 目標塊(Target blocks):此類塊實現 ITargetBlock<TInput> 介面,並且是資料接收器。

  • 傳播器塊(Propagator blocks:):這些塊既充當源又充當目標。 它們實現 IPropagatorBlock<TInput, TOutput> 介面。 應用程式可以從這些塊中讀取資料並向其中寫入資料。

當您連線多個資料流塊來建立工作流時,生成的系統稱為資料流管道。 您可以使用 ISourceBlock<TOutput>.LinkTo 方法將源塊連線到目標塊。 這是傳播器塊可以安裝在管道中間的地方。 它們可以充當工作流程中連結的源和目標。 如果來自源塊的訊息可以由多個目標處理,您可以新增過濾來檢查源提供的物件的屬性,以確定哪個目標或傳播器塊應接收該物件。

在資料流塊之間傳遞的物件通常稱為訊息。 您可以將資料流管道視為網路或訊息傳遞系統。 流經網路的資料單位是訊息。 每個塊負責以某種方式讀取、寫入或轉換每條訊息。

要將訊息傳送到目標塊,可以使用 Post 方法同步傳送,也可以使用 SendAsync 方法非同步傳送。 在源塊中,可以使用以下命令接收訊息
ReceiveTryReceiveReceiveAsync 方法。 ReceiveTryReceive 方法都是同步的。 Choose 方法將監視多個源塊的資料,並從第一個源返回訊息以提供資料。

要從源塊向目標塊提供訊息,源可以呼叫目標的 OfferData 方法。 OfferData 方法返回一個 DataflowMessageStatus 列舉,它具有多個可能的值:

  • 已接受(Accepted):訊息已被接受並將由目標處理。

  • 拒絕(Declined):該訊息被目標拒絕。 源塊仍然擁有該訊息,並且在當前訊息被另一個目標接受之前無法處理其下一條訊息。

  • 永久拒絕(DecliningPermanently):訊息被拒絕,目標不再可用於處理。 所有後續訊息都將被當前目標拒絕。 源塊將取消與返回此狀態的目標的連結。

  • 已推遲(Postponed):接受訊息已被推遲。 它可能會在稍後被目標接受。 在這種情況下,源可以等待或嘗試將訊息傳遞到備用目標塊。

  • 無法使用(NotAvailable):當目標嘗試接受訊息時,該訊息不再可用。 當目標在訊息被推遲後嘗試接受訊息,但源塊已經將訊息傳遞給不同的目標塊時,可能會發生這種情況。

資料流塊透過提供 Complete 方法和 Completion 屬性來支援完成的概念。 呼叫 Complete 方法來請求塊的完成,而 Completion 屬性返回一個任務,稱為塊的完成任務。 這些完成成員是 IDataflowBlock 介面的一部分,該介面由 ISourceBlockITargetBlock 繼承。

完成任務可用於確定塊是否遇到錯誤或已被取消。 讓我們看看如何:

  1. 處理資料流塊遇到的錯誤的最簡單方法是呼叫塊的 Completion 屬性上的 Wait 並在 try/catch 塊中處理 AggregateException 異常型別:
try
{
    inputBlock.Completion.Wait();
}
catch (AggregateException ae)
{
    ae.Handle(e =>
              {
                  Console.WriteLine($"Error processing input - {e.GetType().Name}: {e.Message}");
              });
}
  1. 如果您想在不使用阻塞 Wait 呼叫的情況下執行相同的操作,您可以等待完成任務並處理 Exception 型別:
try
{
    await inputBlock.Completion;
}
catch (Exception e)
{
    Console.WriteLine($"Error processing input - {e.GetType().Name}: {e.Message}");
}
  1. 另一種選擇是在完成任務上使用ContinueWith方法。在延續塊內,您可以檢查任務的狀態以確定它是Faulted還是Canceled
try
{
    inputBlock.ContinueWith(task =>
                            {
                                Console.WriteLink($"Task completed with a status of {task.Status}");
                            });
    await inputBlock.Completion;
}
catch (Exception e)
{
    Console.WriteLine($"Error processing input - {e.GetType().Name}: {e.Message}");
}

當我們在下一節中使用生產者/消費者模式建立示例專案時,我們將看到更全面的資料流塊使用示例。 在我們研究資料流塊的型別之前,我們先討論一下 Microsoft 建立該庫的原因。

為什麼使用 TPL 資料流庫?

TPL 資料流庫由 Microsoft 建立,作為編排非同步資料處理工作流的一種手段。 資料從資料來源流入管道中的第一個資料流塊。 源可以是資料庫、本地或網路資料夾、相機或 .NET 可以訪問的任何其他型別的輸入裝置。 一個或多個塊可以是管道的一部分,每個塊負責一項操作。 下圖說明了資料流管道的兩個抽象:

image

您可以考慮的一個現實示例是使用網路攝像頭捕獲影像幀。 在兩步流程中,如示例 1 所示,將網路攝像頭視為資料輸入。 資料流塊 1 可以執行一些影像處理以最佳化影像外觀,而資料流塊 2 將呼叫 Azure 認知服務 API 來識別每個影像中的物件。 結果將包含每個輸入影像的新 .NET 類,其中包含影像二進位制資料和屬性,這些屬性包含每個影像中已識別的物件。

資料流塊的型別

資料流庫中有九個預定義塊。 這些可以分為三個不同的類別。 第一類是緩衝塊。

緩衝塊(Buffering blocks)

緩衝塊的目的是緩衝要消耗的輸入資料。 緩衝塊都是傳播器塊,這意味著它們既可以是資料流管道中的資料來源也可以是目標。 緩衝塊分為三種型別:BufferBlock<T>BroadcastBlock<T>WriteOnceBlock<T>

緩衝塊(BufferBlock)

BufferBlock<T> 是一種非同步排隊機制,它實現物件的先進先出 (FIFO) 佇列。 BufferBlock 可以配置多個資料來源和多個目標。 但是,BufferBlock 中的每條訊息只能投遞到一個目標塊。成功投遞後,訊息將從佇列中刪除。

以下程式碼片段將客戶名稱推送到 BufferBlock 中,然後將前五個名稱讀取到控制檯:

BufferBlock<string> customerBlock = new();
foreach (var customer in customers)
{
    await customerBlock.SendAsync(customer.Name);
}
for (int i = 0; i < 5; i++)
{
    Console.WriteLine(await customerBlock.ReceiveAsync());
}
// The code could display the following output:
// Robert Jones
// Jita Smith
// Patty Xu
// Sam Alford
// Melissa Allen

廣播塊(BroadcastBlock)

BroadcastBlock<T> 的使用方式與 BufferBlock 類似,但它的目的是僅向消費者提供最近釋出的訊息。 它還可用於向許多消費者傳送相同的值。 釋出到 BroadcastBlock 的訊息在被消費者接收後不會被刪除。

每次呼叫 Receive 方法時,以下程式碼片段將讀取相同的警報訊息:

var alertBlock = new BroadcastBlock<string>(null);
alertBlock.Post("Network is unavailable!");
for (int i = 0; i < 5; i++)
{
    Console.WriteLine(alertBlock.Receive());
}

一次寫入塊(WriteOnceBlock)

顧名思義,WriteOnceBlock<T> 只能寫入一次。 收到第一條訊息後,塊將忽略對 PostSendAsync 的所有呼叫。 不會丟擲任何異常。 資料被簡單地丟棄。

以下示例與我們的 BufferBlock 程式碼片段類似。 但是,由於我們現在使用的是 WriteOnceBlock,因此該塊僅接受第一個客戶的姓名:

WriteOnceBlock<string> customerBlock = new();
foreach (var customer in customers)
{
    await customerBlock.SendAsync(customer.Name);
}
Console.WriteLine(await customerBlock.ReceiveAsync());

執行塊(Execution blocks)

執行塊是為收到的每條訊息執行委託方法的塊。 資料流庫中有三種型別的執行塊。 ActionBlock<TInput> 是目標塊,而 TransformBlock<TInput, TOuput>TransformManyBlock<TInput, TOutput> 都是傳播器塊。

ActionBlock

ActionBlock 是一個接受 Action<T>Func<TInput, Task> 作為其建構函式的塊。 當操作返回或 Func 的任務完成時,對輸入訊息的操作被視為完成。 您可以使用同步委託的操作或非同步操作的 Func。

在此程式碼片段中,我們將使用 Console.WriteLine(在 Action 中提供)將客戶名稱輸出到控制檯:

var customerBlock = new ActionBlock<string>(name => Console.WriteLine(name));

foreach (var customer in customers)
{
    await customerBlock.SendAsync(customer.Name);
}
customerBlock.Complete();
await customerBlock.Completion;

TransformBlock

TransformBlock<TInput, TOutput>ActionBlock 類似。 但是,作為傳播器塊,它會為收到的每條訊息返回一個輸出值。 可以提供給 TransformBlock 建構函式的兩個可能的委託簽名是用於同步操作的 Func<TInput, TOutput> 和用於非同步操作的 Func<TInput, Task<TOutput>>

以下示例使用 TransformBlock,在檢索前五個輸出值並顯示在控制檯上之前,該 TransformBlock 會將客戶名稱轉換為全部大寫:

var toUpperBlock = new TransformBlock<string, string>(name=> name.ToUpper());

foreach (var customer in customers)
{
    toUpperBlock.Push(customer.Name);
}
for (int i = 0; i < 5; i++)
{
    Console.WriteLine(toUpperBlock.Receive());
}

TransformManyBlock

TransformManyBlock 的簽名分別為用於同步和非同步操作的 Func<TInput, IEnumerable<TOutput>>Func<TInput, Task<IEnumerable<TOutput>>>

在此程式碼片段中,我們將一個客戶名稱傳遞給 TransformManyBlock,這將返回一個包含客戶名稱中各個字元的列舉:

var nameCharactersBlock = new TransformManyBlock<string,char>(name => name.ToCharArray());

nameCharactersBlock.Post(customerName);

for (int i = 0; i < (customerName.Length; i++)
 {
     Console.WriteLine(nameCharactersBlock.Receive());
 }

分組塊(Grouping blocks)

分組塊(Grouping blocks)可以組合來自一個或多個源的物件。 分組塊分為三種型別: BatchBlock<T> 是傳播器塊,而 JoinBlock<T1, T2>BatchedJoinBlock<T1, T2> 都是源塊。

BatchBlock

BatchBlock 接受批次資料並生成輸出資料陣列。 建立 BatchBlock 時,您可以指定輸入批次大小。 BatchBlockdataflowBlockOptions 可選建構函式引數中有一個 Greedy 屬性,用於指定貪婪模式:

  • Greedytrue(這是其預設值)時,該塊會在接收到每個輸入值時繼續處理它,並在達到批次大小時輸出一個陣列。
  • Greedyfalse 時,可以在建立批次大小的陣列時暫停傳入訊息。

貪婪模式通常表現更好,但如果您要協調來自多個源的輸入,則可能需要使用非貪婪模式。

在此示例中,BatchBlock 將學生姓名分為最大大小為 12 的班級:

var studentBlock = new BatchBlock<string>(12);
// Assume studentList contains 20 students.
foreach (var student in studentList)
{
    studentBlock.Post(student.Name);
}
// Signal that we are done adding items.
studentBlock.Complete();
// Print the size of each class.
Console.WriteLine($"The number of students in class 1 is {studentBlock.Receive().Count()}."); // 12 students
Console.WriteLine($"The number of students in class 2 is {studentBlock.Receive().Count()}."); // 8 students

JoinBlock

JoinBlock 有兩個簽名:JoinBlock<T1, T2> 和 JoinBlock<T1, T2, T3>JoinBlock<T1, T2> 具有 Target1Target2 屬性來接受輸入並返回 Tuple<T1, T2>,因為每對目標是 填充。 JoinBlock<T1, T2, T3> 具有 Target1Target2Target3 屬性,並在每組目標完成時返回 Tuple<T1, T2, T3>

JoinBlock 還具有貪婪和非貪婪模式,其中貪婪模式是預設行為。當您切換到非貪婪模式時,所有輸入都會推遲到已經接收到輸入的目標,直到填充完整的輸出集並將其作為輸出傳送。

在此示例中,我們將建立一個 JoinBlock 將一個人的名字、姓氏和年齡組合到輸出元組中:

var joinBlock = new JoinBlock<string, string, int>();
joinBlock.Target1.Post("Sally");
joinBlock.Target1.Post("Raj");
joinBlock.Target2.Post("Jones");
joinBlock.Target2.Post("Gupta");
joinBlock.Target3.Post(7);
joinBlock.Target3.Post(23);
for (int i = 0; i < 2; i++)
{
    var data = joinBlock.Receive();
    if (data.Item3 < 18)
    {
        Console.WriteLine($"{data.Item1} {data.Item2} is a child.");
    }
    else
    {
        Console.WriteLine($"{data.Item1} {data.Item2} is  an adult.");
    }
}

BatchedJoinBlock

BatchedJoinBlock 類似於 JoinBlock,只不過輸出中的元組包含建構函式中指定的批次大小的 IList 項:Tuple(IList(T1), IList(T2))Tuple(IList(T1), IList(T2) ,IList(T3))。 批處理概念與 BatchBlock 相同。

作為練習,嘗試在 JoinBlock 示例的基礎上新增更多人到列表中,將他們分為四批,並輸出每批中年齡最大的人的姓名。

現在我們已經探索了所有可用資料流塊的示例,讓我們進入一些現實世界的資料流示例。 在下一節中,我們將使用一些資料流塊來建立生產者/消費者實現。

實現生產者/消費者模式

TPL 資料流庫中的塊為實現生產者/消費者模式提供了一個絕佳的平臺。 如果您不熟悉這種設計模式,它涉及兩個操作和一個工作佇列。 生產者是第一個操作。 它負責用資料或工作單元填充佇列。 消費者負責從佇列中取出專案並以某種方式對其進行操作。 系統中可以有一個或多個生產者和一個或多個消費者。 您可以更改生產者或消費者的數量,具體取決於流程的哪個部分是瓶頸。

在我們的 .NET 生產者/消費者示例中,我們將構建一個簡單的 WPF 應用程式,該應用程式從多個 RSS 源獲取部落格文章並將其顯示在單個 ListView 控制元件中。 應用程式中生產者的每一行將從 RSS 提要中獲取帖子,並將 SyndicateItem 新增到每個部落格帖子的佇列中。 我們將從三個部落格獲取帖子,併為每個部落格建立一個製作人。

消費者將從佇列中獲取一個 SyndicateItem,並使用 ActionBlock 委託為每個 SyndicateItem 建立一個 BlogPost 物件。 我們將建立三個消費者來跟上三個生產者排隊的專案。 當該過程完成時,BlogPost 物件的列表將被設定為 ListViewItemSource。 讓我們開始吧:

  1. 首先使用 .NET 6 建立一個新的 WPF 專案。將專案命名為 ProducerConsumerRssFeeds

  2. 開啟該解決方案的 NuGet 包管理器,在“安裝”選項卡上搜尋“聚合”,然後將 System.ServiceModel.Synmination 包新增到專案中。 該包將使從任何 RSS 提要獲取資料變得簡單。

  3. 向名為 BlogPost 的專案新增一個新類。 這將是要在 ListView 中顯示的每篇部落格文章的模型物件。 將以下屬性新增到新類中:

public class BlogPost
{
    public string PostDate { get; set; } = "";
    public string? Categories { get; set; }
    public string? PostContent { get; set; }
}
  1. 現在,是時候建立一個服務類來獲取給定 RSS 提要 URL 的部落格文章了。 將名為 RssFeedService 的新類新增到專案中,並向該類新增名為 GetFeedItems 的方法:
using System.Collections.Generic;
using System.ServiceModel.Syndication;
using System.Xml;
//...
public static IEnumerable<SyndicationItem>  
    GetFeedItems(string feedUrl)
{
    using var xmlReader = XmlReader.Create(feedUrl);
    SyndicationFeed rssFeed = SyndicationFeed.Load(xmlReader);
    return rssFeed.Items;
}

靜態 SyndicateFeed.Load 方法使用 XmlReader 從提供的 feedUrl 中獲取 XML,並將其轉換為 IEnumerable<SyndicateItem> 以從該方法返回。

  1. 接下來,建立一個名為 FeedAggregator 的新類。 此類將包含生產者/消費者邏輯,該邏輯為每個部落格呼叫 GetFeedItems 並轉換每個部落格文章的提要資料,以便可以在 UI 中顯示。 我們聚合的三個部落格如下:
  • .NET 部落格

  • Windows 部落格

  • Microsoft 365 部落格

使用 FeedAggregator 的第一步是建立一個名為 ProduceFeedItems 的生產者方法和一個名為 QuseueAllFeeds 的父方法,該方法將啟動該生產者方法的三個例項:

private async Task QueueAllFeeds(BufferBlock
                                 <SyndicationItem> itemQueue)
{
    Task feedTask1 = ProduceFeedItems(itemQueue,"https://devblogs.microsoft.com/dotnet/feed/");
    Task feedTask2 = ProduceFeedItems(itemQueue,"https://blogs.windows.com/feed");
    Task feedTask3 = ProduceFeedItems(itemQueue,"https://www.microsoft.com/microsoft-365/blog/feed/");
    await Task.WhenAll(feedTask1, feedTask2,feedTask3);
    itemQueue.Complete();
}
private async Task ProduceFeedItems(BufferBlock<SyndicationItem> itemQueue, string feedUrl)
{
    IEnumerable<SyndicationItem> items =
        RssFeedService.GetFeedItems(feedUrl);
    foreach (SyndicationItem item in items)
    {
        await itemQueue.SendAsync(item);
    }
}

我們使用 BufferBlock<SyndicateItem> 作為佇列。 每個生產者都會呼叫 GetFeedItems 並將返回到 BufferBlock 的每個 SyndicateItem 新增到一起。 QueueAllFeeds 方法使用 Task.WhenAll 等待所有生產者完成向佇列新增專案。 然後,它向 BufferBlock 發出訊號,表明所有生產者都已透過呼叫 itemQueue.Complete() 完成。

  1. 接下來,我們將建立我們的消費者方法。 該方法名為 ConsumeFeedItem,將負責獲取 BufferBlock 提供的 SyndicateItem 並將其轉換為 BlogPost 物件。 每個 BlogPost 都將新增到 ConcurrentBag<BlogPost> 中。 我們在這裡使用執行緒安全集合,因為會有多個使用者將輸出新增到列表中:
private void ConsumeFeedItem(SyndicationItem nextItem,
                             ConcurrentBag<BlogPost> posts)
{
    if (nextItem != null && nextItem.Summary != null)
    {
        BlogPost newPost = new();
        newPost.PostContent = nextItem.Summary.Text
            .ToString();
        newPost.PostDate = nextItem.PublishDate
            .ToLocalTime().ToString("g");
        if (nextItem.Categories != null)
        {
            newPost.Categories = string.Join(",",
                                             nextItem.Categories.Select(c =>
                                                                        c.Name));
        }
        posts.Add(newPost);
    }
}
  1. 現在,是時候將生產者/消費者邏輯連線在一起了。 建立一個名為 GetAllMicrosoftBlogPosts 的方法:
public async Task<IEnumerable<BlogPost>>
    GetAllMicrosoftBlogPosts()
{
    var posts = new ConcurrentBag<BlogPost>();
    // Create queue of source posts
    BufferBlock<SyndicationItem> itemQueue = new(new
                                                 DataflowBlockOptions { BoundedCapacity =
                                                     10 });
    // Create and link consumers
    var consumerOptions = new ExecutionDataflowBlockOptions { BoundedCapacity = 1 };

    var consumerA = new ActionBlock<SyndicationItem>
        ((i) => ConsumeFeedItem(i, posts), consumerOptions);
    var consumerB = new ActionBlock<SyndicationItem>
        ((i) => ConsumeFeedItem(i, posts), consumerOptions);
    var consumerC = new ActionBlock<SyndicationItem> 
        ((i) => ConsumeFeedItem(i, posts),  consumerOptions);

    var linkOptions = new DataflowLinkOptions { PropagateCompletion = true, };

    itemQueue.LinkTo(consumerA, linkOptions);
    itemQueue.LinkTo(consumerB, linkOptions);
    itemQueue.LinkTo(consumerC, linkOptions);
    // Start producers
    Task producers = QueueAllFeeds(itemQueue);
    // Wait for producers and consumers to complete
    await Task.WhenAll(producers, consumerA.Completion,
                       consumerB.Completion, consumerC.Completion);
    return posts;
}

I. 該方法首先建立一個 ConcurrentBag<BlogPost> 來聚合 UI 的最終帖子列表。 然後,它建立 BoundedCapacity10 的 itemQueue 物件。此有限容量意味著任何時候排隊的專案數不得超過 10 個。 一旦佇列達到 10 個,所有生產者都必須等待消費者將某些專案出隊。 這可能會降低程序的效能,但可以防止生產程式碼中潛在的記憶體不足問題。 我們的示例在處理來自三個部落格的帖子時不會有記憶體不足的危險,但您可以瞭解如何在應用程式中需要時使用 BoundedCapacity。 您可以建立沒有 BoundedCapacity 的佇列,如下所示:

BufferBlock<SyndicationItem> itemQueue = new();

二. 該方法的下一部分建立三個使用 ActionBlock<SyndicateItem> 的使用者,並以 ConsumeFeedItem 作為提供的委託。 每個消費者都透過 LinkTo 方法連結到佇列。將消費者的 BoundedCapacity 設定為 1 告訴生產者如果當前消費者已經忙於處理一項,則繼續處理下一個消費者。

三. 一旦建立了連結,我們就可以透過呼叫 QueueAllFeeds 來啟動生產者。 然後,我們必須等待每個消費者 ActionBlock 的生產者和 Completion 物件。 透過連結生產者和消費者的完成,我們不需要顯式等待消費者的 Completion 物件:

var linkOptions = new DataflowLinkOptions {PropagateCompletion = true, };
  1. 下一步是建立一些 UI 控制元件來向使用者顯示資訊。 開啟 MainWindow.xaml 檔案並使用以下標記替換現有的 Grid:
<Grid>
    <ListView x:Name="mainListView">
        <ListView.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition
                                          Width="150"/>
                        <ColumnDefinition
                                          Width="300"/>
                        <ColumnDefinition
                                          Width="500"/>
                    </Grid.ColumnDefinitions>
                    <TextBlock Grid.Column="0"
                               Text="{Binding PostDate}"
                               Margin="3"/>
                    <TextBox IsReadOnly="True"
                             Grid.Column="1"
                             Text="{Binding Categories}"
                             Margin="3"
                             TextWrapping="Wrap"/>
                    <TextBox IsReadOnly="True"
                             Grid.Column="2"
                             Text="{Binding PostContent}"
                             Margin="3"/>
                </Grid>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</Grid>
  1. 我們必須做的最後一件事是呼叫 GetAllMicrosoftBlogPosts 方法並填充 UI。 開啟MainWindow.xaml.cs並新增以下程式碼:
public MainWindow()
{
    InitializeComponent();
    Loaded += MainWindow_Loaded;
}
private async void MainWindow_Loaded(object sender,
                                     RoutedEventArgs e)
{
    FeedAggregator aggregator = new();
    var items = await aggregator
        .GetAllMicrosoftBlogPosts();
    mainListView.ItemsSource = items;
}

載入 MainWindow 後,從 GetAllMicrosoftBlogPosts 返回的專案將設定為 mainListView.ItemsSource。這將允許資料繫結到我們在 XAML 中定義的 DataTemplate 中的元素。

  1. 現在,執行該專案並檢視效果:

圖 7.2 – 首次執行 ProducerConsumerRssFeeds WPF 應用程式

image

如您所見,該列表顯示每個 Microsoft 部落格的 10 篇部落格文章摘要。 這是 Microsoft 部落格可以返回的預設最大專案數。

您可以嘗試透過增加或減少專案中生產者和消費者的數量來進行試驗。 新增更多消費者是否會加快該過程? 嘗試新增一些你最喜歡的
部落格的提要到生產者列表,看看會發生什麼。

建立具有多個塊的資料管道

使用資料流塊的最大優勢之一是能夠連結它們並建立完整的工作流或資料管道。 在上一節中,我們瞭解了生產者塊和消費者塊之間的這種連結是如何工作的。 在本節中,我們將建立一個控制檯應用程式,其中包含五個資料流塊的管道,所有資料流塊都連結在一起以完成一系列任務。 我們將利用 TransformBlockTransformManyBlockActionBlock 獲取 RSS 提要並輸出提要中所有部落格文章中唯一的類別列表。 按著這些次序:

  1. 首先在 Visual Studio 中建立一個名為 OutputBlogCategories 的新 .NET 6 控制檯應用程式。

  2. 新增我們在上一個示例中使用的 System.ComponentModel.Synmination NuGet 包。

  3. 新增與上一示例相同的 RssFeedService 類。 您可以在解決方案資源管理器中右鍵單擊該專案,然後選擇 新增| 現有專案,或者您可以建立一個名為 RssFeedService 的新類,並複製/貼上我們在上一個示例中使用的相同程式碼。

  4. 將名為 FeedCategoryTransformer 的新類新增到專案中,並建立名為 GetCategoriesForFeed 的方法:

public static async Task GetCategoriesForFeed(string
                                              url)
{
}
  1. 在接下來的幾個步驟中,我們將建立 GetCategoriesForFeed 方法的實現。 首先,建立一個名為 downloadFeedTransformBlock,它接受字串形式的 url 並從 GetFeedItems 方法返回 IEnumerable<SyndicateItem>
// Downloads the requested blog posts.
var downloadFeed = new TransformBlock<string,
IEnumerable<SyndicationItem>>(url =>
                              {
                                  Console.WriteLine("Fetching feed from '{0}'...",
                                                    url);
                                  return RssFeedService.GetFeedItems(url);
                              });
  1. 接下來,建立一個接受 IEnumerable<SyndicateItem> 並返回 List<SyndicateCategory>TransformBlock。 此塊將從每個部落格文章中獲取完整的類別列表,並將它們作為單個列表返回:
// Aggregates the categories from all the posts.
var createCategoryList = new TransformBlock
    <IEnumerable<SyndicationItem>, List
    <SyndicationCategory>>(items =>
                           {
                               Console.WriteLine("Getting category list...");
                               var result = new List<SyndicationCategory>();
                               foreach (var item in items)
                               {
                                   result.AddRange(item.Categories);
                               }
                               return result;
                           });
  1. 現在,建立另一個 TransformBlock。 此塊將接受前一個塊中的 List<SyndicateCategory>,刪除所有重複項,並返回過濾後的 List<SyndicateCategory>
// Removes duplicates.
var deDupList = new TransformBlock<List
    <SyndicationCategory>, List<SyndicationCategory>>
    (categories =>
     {
         Console.WriteLine("De-duplicating category list...");
                           var categoryComparer = new CategoryComparer();
                           return categories.Distinct(categoryComparer)
                           .ToList();
                           });

要在複雜物件(例如 SyndicateCategory)上使用 LINQ Distinct 擴充套件方法,需要一個實現 IEqualityComparer<T> 的自定義比較器。

  1. 接下來,建立一個名為 createCategoryStringTransformManyBlock。 此塊將接受去重複的 List<SyndicateCategory> 併為類別的每個 Name 屬性返回一個字串。 因此,該塊會針對整個列表呼叫一次,但反過來,它也會為列表中的每個專案呼叫流程中的下一個塊:
// Gets the category names from the list of category
objects.
    var createCategoryString = new TransformManyBlock
    <List<SyndicationCategory>, string>(categories =>
                                        {
                                            Console.WriteLine("Extracting category names...");
                                            return categories.Select(c => c.Name);
                                        });
  1. 最後一個塊是名為 printCategoryInCapsActionBlock。 此塊將使用 ToUpper 將每個類別名稱全部大寫輸出到控制檯:
// Prints the upper-cased unique categories to the
console.
    var printCategoryInCaps = new ActionBlock<string>
    (categoryName =>
     {
         Console.WriteLine($"Found CATEGORY {categoryName.ToUpper()}");   
     });
  1. 現在資料流塊已經配置完畢,是時候連結它們了。 建立一個 DataflowLinkOptions 來傳播每個塊的完成情況。 然後,使用 LinkTo 方法將鏈中的每個塊連結到下一個塊:
var linkOptions = new DataflowLinkOptions { PropagateCompletion = true };
downloadFeed.LinkTo(createCategoryList, linkOptions);
createCategoryList.LinkTo(deDupList, linkOptions);
deDupList.LinkTo(createCategoryString, linkOptions);
createCategoryString.LinkTo(printCategoryInCaps, linkOptions);
  1. 建立 GetCategoriesForFeed 方法的最後幾個步驟涉及將 url 傳送到第一個塊,將其標記為 Complete,然後等待鏈中的最後一個塊:
await downloadFeed.SendAsync(url);
downloadFeed.Complete();
await printCategoryInCaps.Completion;
  1. 現在,開啟 Program.cs 並更新程式碼,以便它呼叫 GetCategoriesForFeed,提供 Windows 部落格 RSS 源的 URL:
using OutputBlogCategories;
Console.WriteLine("Hello, World!");
await FeedCategoryTransformer.GetCategoriesForFeed("https://blogs.windows.com/feed");
Console.ReadLine();
  1. 執行程式並檢查輸出中的類別列表:

圖 7.3 – 顯示 Windows 部落格源中的已消除重複的類別列表

image

現在您已經瞭解瞭如何使用一系列資料流塊建立資料管道,我們將看一個使用 JoinBlock 組合來自多個源的資料的示例。

操作來自多個資料來源的資料

JoinBlock 可以配置為從兩個或三個資料來源接收不同的資料型別。 當每組資料型別完成時,該塊就完成了一個包含要操作的所有三種物件型別的元組。 在此示例中,我們將建立一個接受字串和整數對的 JoinBlock,並將 Tuple(string, int) 傳遞給 ActionBlock,後者將它們的值輸出到控制檯。 按著這些次序:

  1. 首先在 Visual Studio 中建立一個新的控制檯應用程式

  2. 在專案中新增一個名為DataJoiner的新類,並在名為JoinData的類中新增一個靜態方法:

    public static void JoinData()
    {
    }
    
  3. 新增以下程式碼以建立兩個 BufferBlock 物件:JoinBlock<string,int>ActionBlock<Tuple<string, int>>

    var stringQueue = new BufferBlock<string>();
    var integerQueue = new BufferBlock<int>();
    var joinStringsAndIntegers = new JoinBlock<string,int>(
        new GroupingDataflowBlockOptions
        {
            Greedy = false
        });
    var stringIntegerAction = new ActionBlock
        <Tuple<string, int>>(data =>
                             {
                                 Console.WriteLine($"String received:{data.Item1}");  
                                 Console.WriteLine($"Integer received:{data.Item2}"); 
                             });
    

    將塊設定為非貪婪模式意味著它將在執行塊之前等待每種型別的專案。

  4. 現在,建立塊之間的連結:

    stringQueue.LinkTo(joinStringsAndIntegers.Target1);
    integerQueue.LinkTo(joinStringsAndIntegers.Target2);
    joinStringsAndIntegers.LinkTo(stringIntegerAction);
    
  5. 接下來,將一些資料推送到兩個 BufferBlock 物件,等待一秒鐘,然後將它們都標記為完成:

    stringQueue.Post("one");
    stringQueue.Post("two");
    stringQueue.Post("three");
    integerQueue.Post(1);
    integerQueue.Post(2);
    integerQueue.Post(3);
    stringQueue.Complete();
    integerQueue.Complete();
    Thread.Sleep(1000);
    Console.WriteLine("Complete");
    
  6. Program.cs中新增以下程式碼來執行示例程式碼:

    using JoinBlockExample;
    DataJoiner.JoinData();
    Console.ReadLine();
    
  7. 最後,執行應用程式並檢查輸出。 您將看到 ActionBlock 為提供的每組值輸出一個字串和整數對:

    圖 7.4 – 執行 JoinBlockExample 控制檯應用程式

    image

這就是使用 JoinBlock 資料流塊的全部內容。 嘗試自己進行一些更改,例如更改 Greedy 選項或將資料新增到每個 BufferBlock 的順序。這對輸出有何影響?

相關文章