Перевод статьи подготовлен в преддверии старта курса «Разработчик C#».





Одной из наиболее важных функций .NET Core 3.0 и C# 8.0 стал новый IAsyncEnumerable<T> (он же асинхронный поток). Но что в нем такого особенного? Что же мы можем сделать теперь, что было невозможно раньше?

В этой статье мы рассмотрим, какие задачи IAsyncEnumerable<T> предназначен решать, как реализовать его в наших собственных приложениях и почему IAsyncEnumerable<T> заменит Task<IEnumerable<T>> во многих ситуациях.

Ознакомьтесь со всеми новыми функциями .NET Core 3

Жизнь до IAsyncEnumerable<T>


Возможно, лучший способ объяснить, почему IAsyncEnumerable<T> так полезен — это рассмотреть проблемы, с которыми мы сталкивались до него.

Представьте, что мы создаем библиотеку для взаимодействия с данным, и нам нужен метод, который запрашивает некоторые данные из хранилища или API. Обычно этот метод возвращает Task<IEnumerable<T>>, как здесь:

public async Task<IEnumerable<Product>> GetAllProducts()

Чтобы реализовать этот метод, мы обычно запрашиваем данные асинхронно и возвращаем их, когда он завершается. Проблема с этим становится более очевидной, когда для получения данных нам нужно сделать несколько асинхронных вызовов. Например, наша база данных или API могут возвращать данные целыми страницами, как, например, эта реализация, использующая Azure Cosmos DB:

public async Task<IEnumerable<Product>> GetAllProducts()
{
    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);
    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");
    var products = new List<Product>();
    while (iterator.HasMoreResults)
    {
        foreach (var product in await iterator.ReadNextAsync())
        {
            products.Add(product);
        }
    }
    return products;
}

Обратите внимание, что мы пролистываем все результаты в цикле while, создаем экземпляры объектов product, помещаем их в List, и, наконец, возвращаем все целиком. Это довольно неэффективно, особенно на больших наборах данных.

Возможно, мы сможем создать более эффективную реализацию, изменив наш метод так, чтобы он возвращал результаты по целой странице за раз:

public IEnumerable<Task<IEnumerable<Product>>> GetAllProducts()
{
    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);
    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");
    while (iterator.HasMoreResults)
    {
        yield return iterator.ReadNextAsync().ContinueWith(t => 
        {
            return (IEnumerable<Product>)t.Result;
        });
    }
}

Вызывающий объект будет использовать метод следующим образом:

foreach (var productsTask in productsRepository.GetAllProducts())
{
    foreach (var product in await productsTask)
    {
        Console.WriteLine(product.Name);
    }
}

Эта реализация более эффективна, но метод теперь возвращает IEnumerable<Task<IEnumerable<Product>>>. Как мы видим из вызывающего кода, вызов метода и обработка данных не интуитивны. Что еще более важно, подкачка страниц — это деталь реализации метода доступа к данным, о которой вызывающая сторона не должна ничего знать.

IAsyncEnumerable<T> спешит на помощь


Что бы мы действительно хотели сделать, так это асинхронно извлекать данные из нашей базы данных и передавать результаты обратно вызывающей стороне по мере их получения.

В синхронном коде метод, который возвращает IEnumerable, может использовать оператор yield return для возврата каждой части данных вызывающей стороне, когда она приходит из базы данных.

public IEnumerable<Product> GetAllProducts()
{
    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);
    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");
    while (iterator.HasMoreResults)
    {
        foreach (var product in iterator.ReadNextAsync().Result)
        {
            yield return product;
        }
    }
}

Однако, НИКОГДА ТАК НЕ ДЕЛАЙТЕ! Приведенный выше код превращает асинхронный вызов базы данных в блокирующий и не масштабируется.

Если только мы могли бы использовать yield return с асинхронными методами! Это было невозможно… до сих пор.

IAsyncEnumerable<T> был представлен в .NET Core 3 (.NET Standard 2.1). Он предоставляет энумератор, у которого есть метод MoveNextAsync(), который может быть ожидаемым. Это означает, что инициатор может совершать асинхронные вызовы во время (посреди) получения результатов.

Вместо возврата Task<IEnumerable<T>> наш метод теперь может возвращать IAsyncEnumerable<T> и использовать yield return для передачи данных.

public async IAsyncEnumerable<Product> GetAllProducts()
{
    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);
    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");
    while (iterator.HasMoreResults)
    {
        foreach (var product in await iterator.ReadNextAsync())
        {
            yield return product;
        }
    }
}

Чтобы использовать результаты, нам нужно использовать новый синтаксис await foreach(), доступный в C# 8:

await foreach (var product in productsRepository.GetAllProducts())
{
    Console.WriteLine(product);
}

Это намного приятнее. Метод производит данные по мере их поступления. Код вызова использует данные в своем темпе.

IAsyncEnumerable<T> и ASP.NET Core


Начиная с .NET Core 3 Preview 7, ASP.NET может возвращать IAsyncEnumerable из экшена контроллера API. Это означает, что мы можем возвращать результаты нашего метода напрямую — эффективно передавая данные из базы данных в HTTP ответ.

[HttpGet]
public IAsyncEnumerable<Product> Get()
    => productsRepository.GetAllProducts();

Замена Task<IEnumerable<T>> на IAsyncEnumerable<T>


С течением времени по ходу освоения .NET Core 3 и .NET Standard 2.1, ожидается, что IAsyncEnumerable<T> будет использоваться в местах, где мы обычно использовали Task<IEnumerable>.

Я с нетерпением жду возможности увидеть поддержку IAsyncEnumerable<T> в библиотеках. В этой статье мы видели подобный код для запроса данных с помощью SDK Azure Cosmos DB 3.0:

var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");
while (iterator.HasMoreResults)
{
    foreach (var product in await iterator.ReadNextAsync())
    {
        Console.WriteLine(product.Name);
    }
}

Как и в наших предыдущих примерах, собственный SDK Cosmos DB также нагружает нас деталями реализации подкачки страниц, что затрудняет обработку результатов запроса.

Чтобы посмотреть, как это могло бы выглядеть, если бы GetItemQueryIterator<Product>() вместо этого возвращал IAsyncEnumerable<T>, мы можем создать метод-расширение в FeedIterator:

public static class FeedIteratorExtensions
{
    public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this FeedIterator<T> iterator)
    {
        while (iterator.HasMoreResults)
        {
            foreach(var item in await iterator.ReadNextAsync())
            {
                yield return item;
            }
        }
    }
}

Теперь мы можем обрабатывать результаты наших запросов намного более приятным способом:

var products = container
    .GetItemQueryIterator<Product>("SELECT * FROM c")
    .ToAsyncEnumerable();
await foreach (var product in products)
{
    Console.WriteLine(product.Name);
}

Резюме


IAsyncEnumerable<T> — является долгожданным дополнением к .NET и во многих случаях сделает код более приятным и эффективным. Узнать об этом больше вы можете на этих ресурсах:




Шаблон проектирования «Состояние (state)»



Читать ещё: