Loading

并发编程-7.任务并行库(TPL)和数据流

介绍 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 的顺序。这对输出有何影响?

posted @ 2024-03-30 03:44  F(x)_King  阅读(119)  评论(0编辑  收藏  举报