Pull to refresh

Создаём асинхронный Fluent API

Reading time13 min
Views6.1K

Недавно я захотел сделать более удобный способ взаимодействия с кое-каким классом в одном из модулей приложения. Тогда-то я вспомнил про Fluent API, который в моём случае очень хорошо подходил.

Я нашёл кучу объяснений и примеров кода для реализации Fluent API, однако я не мог найти внятных объяснений, как реализовать в этом же Fluent API асинхронные методы, аки делает это какой-нибудь Linq. Библиотеки используют какую-то эльфийскую магию, но я нашёл драйвер MongoDB, на исходном коде которого я и разбирался, как реализовать асинхронный Fluent API.

Содержание

  1. Что такое Fluent API;

  2. Реализуем асинхронный Fluent API:

    1. Готовимся творить;

    2. Делаем скелет приложения:

      1. Первый случай реализации Fluent API;

      2. Второй случай реализации Fluent API;

    3. Добавляем асинхронность;

  3. Optional pattern;

  4. Итого.

Что такое Fluent API

Не буду пытаться сформулировать определение Fluent API, поэтому давайте обратимся к старой-доброй Википедии:

В программной инженерии Fluent API — это объектно-ориентированный API, дизайн которого в значительной степени зависит от цепочки методов. Его цель - повысить разборчивость кода путем создания DSL. Термин был введен в 2005 году Эриком Эвансом и Мартином Фаулером.

Из непонятных слов в этом определении является лишь DSL. DSL - это, если по-житейски, язык программирования, созданный для выполнения какой-то конкретной задачи в определённой предметной области. Для примера, наверное, можно привести такие языки, как LINQ, SQL, GraphQL, Regex, gRPC, Markdown и т.п, которые предназначены для действий с источниками данных, поиска совпадений в тексте по паттернам, описания сетевых интерфейсов, а также форматирования контента, соответственно. Кстати, известные фреймворки по типу React и JQuery также являются внешними DSL и называются eDSL. Исходя из второй ссылки на форум про JQuery можно сделать вывод, что данные фреймворки являются DSL из-за того, что являются более удобной прослойкой, которая потом будет преобразована в стандартный Javascript-код. Ну, или что-то такое. Более подробно про DSL прошу в эту статью, в которой рассказывается про, собственно, DSL, что такое API и др.

В этой статье (статья, кстати, тоже про асинхронный Fluent API, однако там нисколько ничего не понятно) нашёл пример кода в Fluent-стиле и его аналоге в императивном стиле, соответственно, чтобы понять, в чём прелесть Fluent API:

var data = await GetNewsAsync()
         .Where(newsList => newsList.Date >= DateTime.Today)
         .SelectMany(newsList => newsList.NewsItems)
         .ToList();
var newsList = await GetNewsAsync();

if (newsList.Date >= DateTime.Today)
{
   return newsList.Select(news=>news.NewsItems).ToList();
}

return new List<NewsItem>();

Как мы можем наблюдать из примеров выше, Fluent API делает код более компактным и, соответственно, более читаемым, что есть однозначно хорошо.

Реализуем асинхронный Fluent API

Для начала, думаю, стоит начать с простого и идти дальше, поэтому для начала сделаем конструктор SQL-соединения, который также позволит асинхронно получать данные из БД (которой будет являться простой список).

Кстати, для тех, кто мало знаком с асинхронным программированием, рекомендую к прочтению вот эту прекрасную статью.

Готовимся творить

Давайте создадим решение и проект где-то на компьютере и откроем созданное решение в всеми любимом Visual Studio Code с установленным официальным плагином для C# и Github Copilot (мой рекомедосьён), используя следующие команды, если кому интересно:

$ dotnet new sln
$ dotnet new console -o ./src/FluentAPI
$ dotnet sln add ./src/FluentAPI
$ code .

Теперь перейдём к написанию кода...

Енотик, готовый кодить
Енотик, готовый кодить

Делаем скелет приложения

Наше приложение будет получать из «базы данных» данные (как я люблю тавтологию), которые будут какими-то числами, затем выводить эти числа в терминал.

С приложением определились, теперь идём в файл Program.cs, находящийся в созданном по пути ./src/FluentAPI проекте, где меняем содержимое файла на следующее:

namespace FluentAPI;

public class Program {
    public static void Main(string[] args) {
        var numbers = FluentSQLConnection<int>
            //  С помощью статического метода CreateConnection создаём и 
            //  настраиваем экземляр класса FluentSQLConnection
            .CreateConnection(builder => {
                builder.ConnectionString = "Data Source=.;Initial Catalog=FluentAPI;Integrated Security=True";
                builder.Data = Enumerable.Range(0, 100).ToList();
            })
            .Query("SELECT * FROM Numbers") //  Выполняем запрос к базе данных
            .Where(n => n % 2 == 0) //  Выбираем только чётные числа
            .ToList(); //  Получаем результат запроса в виде списка

        //  Выводим результат запроса в консоль
        foreach (var number in numbers) {
            System.Console.WriteLine(number);
        }
    }
}

Из кода выше понятно, наверное, что код не будет выполняться асинхронно. Асинхронность введём позже. Кстати, не обращайте внимание на builder.Data = Enumerable.Range(0, 100).ToList();, это нужно исключительно для того, чтобы ввести тестовый источник данных, ведь реальной базы данных нету.

А сейчас давайте создадим класс FluentSQLConnection. Для этого создадим файл FluentSQLConnection.cs. Обычно Fluent API лишь меняет состояние класса, однако, в принципе, никто не запрещает вернуть методом Query(string) тип IEnumerable<TData>.

Первый случай

using Microsoft.Data.SqlClient;

namespace FluentAPI;

public record FluentSQLConnectionBuilder<TData>
{
    public string ConnectionString { get; set; }
    public List<TData> Data { get; set; }
}

public interface IFluentSQLQuery<TData>
{
    IFluentSQLSelection<TData> Query(string sql);
}

public interface IFluentSQLQueryResult<TData>
{
    List<TData> ToList();
}

public interface IFluentSQLSelection<TData> : IFluentSQLQueryResult<TData>
{
    IFluentSQLSelection<TData> Where(Func<TData, bool> predicate);

}

public sealed class FluentSQLConnection<TData>
    : IFluentSQLQuery<TData>, IFluentSQLQueryResult<TData>, IFluentSQLSelection<TData>
{
    IEnumerable<TData> _data;
    IEnumerable<TData> _queredData;
    SqlConnection _connection;

    FluentSQLConnection(string connectionString, IEnumerable<TData> data)
    {
        _connection = new SqlConnection(connectionString);
        _queredData = new List<TData>();
        _data = data;
    }

    public static IFluentSQLQuery<TData> CreateConnection(Action<FluentSQLConnectionBuilder<TData>> configureConnection)
    {
        var builder = new FluentSQLConnectionBuilder<TData>() { 
            ConnectionString = "",
            Data = new List<TData>() 
        };
        configureConnection(builder);
        return new FluentSQLConnection<TData>(builder.ConnectionString, builder.Data);
    }


    IFluentSQLSelection<TData> IFluentSQLQuery<TData>.Query(string sql)
    {
        // _connection.Open();
        // var command = new SqlCommand(sql, _connection);
        // var reader = command.ExecuteReader();
        // var data = new List<TData>();
        // while (reader.Read())
        // {
        //     data.Add((TData)reader[0]);
        // }
        // _queredData = data;
        // _connection.Close();

        Task.Delay(1000).Wait();
        _queredData = _data;

        return this;
    }

    public IFluentSQLSelection<TData> Where(Func<TData, bool> predicate)
    {
        _queredData = _queredData.Where(predicate);
        return this;
    }

    List<TData> IFluentSQLQueryResult<TData>.ToList() => _queredData.ToList();
}
Microsoft.Data.SqlClient

Для тех, кто не знает, то библиотеку Microsoft.Data.SqlClient, которая, по-идее, является частью ADO.NET, нужно устанавливать в проект ручками. Для того, чтобы установить библиотеку, необходимо открыть терминал в VS Code и набрать следующую команду:

$ dotnet add ./src/FluentAPI package Microsoft.Data.SqlClient

Путь после оператора add позволяет указать проект, в который необходимо добавить библиотеку, название которой идёт после оператора package, указывающий тип того, что нужно добавить в проект: другой проект или пакет NuGet.

По логике наш метод Query(string) может выбирать лишь раз, поэтому наш класс использует интерфейсный подход, что выражается в том, что ранее упомянутый метод возвращает тип, наследующий интерфейс IFluentSQLQueryResult<TData>, то есть наш класс FluentSQLConnection<TData>. Данный подход также используется тогда, когда в нашем конечном автомате имеется множество свойств. Где-то на этом времени в данном видео объясняется наглядно то, о чём я говорю сейчас:

Классный видос, как и многие другие видео на канале Ника. Если ничего не понятно в настоящей статье, то рекомендую посмотреть данное видео. Кстати, именно пример Ника я сейчас использую.

Как ранее упоминалось, не обращаем внимание на всё, что связано с Data и _data в классе. Кусок работы с SQL-запросами, предложенный Github Copilot в методе IFluentSQLQuery.Query(string sql), решил оставить в качестве примера того, как может выглядеть реальный случай использования данного подхода. Права, кто будет делать подобный велосипед, когда есть прекрасный Entity Framework и менее прекрасный, но шустрый Dapper? Но всё же.

Второй случай

using Microsoft.Data.SqlClient;

namespace FluentAPI;

public record FluentSQLConnectionBuilder<TData>
{
    public string ConnectionString { get; set; }
    public List<TData> Data { get; set; }
}

public sealed class FluentSQLConnection<TData>
{
    IEnumerable<TData> _data;
    SqlConnection _connection;

    FluentSQLConnection(string connectionString, IEnumerable<TData> data)
    {
        _connection = new SqlConnection(connectionString);
        _data = data;
    }

    public static FluentSQLConnection<TData> CreateConnection(Action<FluentSQLConnectionBuilder<TData>> configureConnection)
    {
        var builder = new FluentSQLConnectionBuilder<TData>() { 
            ConnectionString = "",
            Data = new List<TData>() 
        };
        configureConnection(builder);
        return new FluentSQLConnection<TData>(builder.ConnectionString, builder.Data);
    }


    public IEnumerable<TData> Query(string sql)
    {
        // _connection.Open();
        // var command = new SqlCommand(sql, _connection);
        // var reader = command.ExecuteReader();
        // var data = new List<TData>();
        // while (reader.Read())
        // {
        //     data.Add((TData)reader[0]);
        // }
        // _queredData = data;
        // _connection.Close();

        Task.Delay(1000).Wait();
        return _data;
    }
}

Как мы можем наблюдать в коде, второй случай куда компактнее и проще в исполнении, так как уже готовые решения Linq позволяют реализовать ровно то же самое без излишнего кода: что там, что там будет вывод следующий:

Вывод программы
Вывод программы

Поэтому считаю, что можно обходиться вторым подходом, если контекст того позволяет. Под контекстом я подразумеваю задачу и уже готовые решения для её решения.

Добавляем асинхронность

Судя по тем исходникам, что я мог наблюдать, а точнее ранее упомянутые исходники MongoDB, реализуется всё это через такую структуру данных, как очередь. Для тех, кто не знаком с этим типом данных, могу посоветовать просмотреть исчерпывающую статью на Википедии.

Вообще, если говорить про MongoDB, там всё слишком запутано на мой взгляд, так как там используется куча всяких абстракций и вообще вместо очереди используется словарь. Точно не знаю, но предполагаю, что всё это используется для дальнейшего маппинга из сущностей БД в объекты C#.

Но в нашем случае всё будет куда проще, то есть реализовано через простую очередь. Чтобы это сделать, сначала скопируем файл с первым случаем реализации Fluent API и переименуем его в FluentSQLConnectionAsync.cs. Со вторым случаем я не представляю, как можно это реализовать. Хотя нет, догадываюсь, но пока разберём лишь проверенный способ.

В скопированном файле FluentSQLConnectionAsync.cs также переименуем класс соответствующим образом, а к названиям интерфейсам добавим ключевое слово Async. Проделаем ещё пару вещей по типу того, что добавим ещё одно поле Queue<Func<Task>> _pipeline. Очередь будет содержать лямба-функции (как необычно слышать слово «функция» в C#, бррр, аж мурашки по коже), так как мы не хотим создавать шаманить, а хотим красивый, лаконичный код. В общем, вот весь код для асинхронной реализации:

using Microsoft.Data.SqlClient;

namespace FluentAPI;

public record FluentSQLConnectionAsyncBuilder<TData>
{
    public string ConnectionString { get; set; }
    public List<TData> Data { get; set; }
}

public interface IFluentSQLAsyncQuery<TData>
{
    IFluentSQLAsyncSelection<TData> Query(string sql);
}

public interface IFluentSQLAsyncQueryResult<TData>
{
    Task<List<TData>> ToListAsync();
}

public interface IFluentSQLAsyncSelection<TData> : IFluentSQLAsyncQueryResult<TData>
{
    IFluentSQLAsyncSelection<TData> Where(Func<TData, bool> predicate);

}

public sealed class FluentSQLConnectionAsync<TData>
    : IFluentSQLAsyncQuery<TData>, IFluentSQLAsyncQueryResult<TData>, IFluentSQLAsyncSelection<TData>
{
    IEnumerable<TData> _data;
    IEnumerable<TData> _queredData;
    SqlConnection _connection;
    Queue<Func<Task>> _pipeline;

    FluentSQLConnectionAsync(string connectionString, IEnumerable<TData> data)
    {
        _queredData = new List<TData>();
        _pipeline = new();
        _connection = new(connectionString);
        _data = data;
    }

    public static IFluentSQLAsyncQuery<TData> CreateConnection(Action<FluentSQLConnectionAsyncBuilder<TData>> configureConnection)
    {
        var builder = new FluentSQLConnectionAsyncBuilder<TData>() { 
            ConnectionString = String.Empty,
            Data = new List<TData>() 
        };
        configureConnection(builder);
        return new FluentSQLConnectionAsync<TData>(builder.ConnectionString, builder.Data);
    }

    public IFluentSQLAsyncSelection<TData> Query(string sql)
    {
        _pipeline.Enqueue(async () => {
            await Task.Delay(1000);
            await Task.Run(() => _queredData = _data);
        });

        return this;
    }

    public IFluentSQLAsyncSelection<TData> Where(Func<TData, bool> predicate)
    {
        _pipeline.Enqueue(async () => await Task.Run(() => _queredData = _queredData.Where(predicate)));
        return this;
    }

    public Task<List<TData>> ToListAsync() => Task.Run(async () => {
            for (var i = 0; i < _pipeline.Count; ++i)
                await _pipeline.Dequeue().Invoke();

            return _queredData.ToList();
        });
}

Для того, чтобы понять, для чего вообще необходимо асинхронное выполнение кода, запилил некоторые бенчмарки, код которых оставил ниже под спойлером. В таблице и графике ниже видно разницу между асинхронным и синхронным выполнением кода. Но это при том условии, что таски собраны в массив и для их ожидания используется метод Task.WaitAll.

Синхронный код

Синхронный код с Task.Run

Асинхронный код

Кол-во итераций

Время выполнения (ms)

Кол-во итераций

Время выполнения (ms)

Кол-во итераций

Время выполнения (ms)

1

1093

1

1001

1

1010

10

10009

10

1024

10

1005

50

50050

50

2704

50

1003

100

100099

100

5247

100

1015

График сравнения производительности синхронного и асинхронного кода
График сравнения производительности синхронного и асинхронного кода

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

Код бенчмарков

Сразу скажу, что не стал париться на счёт того, чтобы завести бенчмарки под BenchmarkDotNet, так как библиотека без костылей не хочет работать со всем, что относится к многопоточности и асинхронности. Хотя я видел какой-то форк, который позволяет из коробки запускать бенчмарки с многопоточностью и асинхронностью. Но зачем нам он, когда есть старый-добрый Stopwatch?

using System.Diagnostics;

namespace FluentAPI;

public class Program {
    public static void Main(string[] args) {
        foreach (var count in new int[] { 1, 10, 50, 100 })
        {
            Console.WriteLine($"Count ops: {count} {{");

            var nonAsyncStopwatch = Stopwatch.StartNew();

            Enumerable.Range(0, count)
                .Select(_ => FluentSQLQuery())
                .ToArray();
            
            Console.WriteLine($"\tNon-async: {nonAsyncStopwatch.ElapsedMilliseconds}ms");

            var syncInAsyncStopwatch = Stopwatch.StartNew();

            var tasks = Enumerable.Range(0, count)
                .Select(_ => Task.Run(() => FluentSQLQuery()))
                .ToArray();
            Task.WaitAll(tasks);

            Console.WriteLine($"\tSync in async: {syncInAsyncStopwatch.ElapsedMilliseconds}ms");

            var asyncStopwatch = Stopwatch.StartNew();

            var asyncTasks = Enumerable.Range(0, count)
                .Select(_ => FluentSQLQueryAsync())
                .ToArray();
            Task.WaitAll(asyncTasks);

            Console.WriteLine($"\tAsync: {asyncStopwatch.ElapsedMilliseconds}ms\n}}");
        }
    }
    static List<int> FluentSQLQuery() => FluentSQLConnection<int>
        .CreateConnection(builder => {
            builder.ConnectionString = "Data Source=.;Initial Catalog=FluentAPI;Integrated Security=True";
            builder.Data = Enumerable.Range(0, 10_000).ToList();
        })
        .Query("SELECT * FROM Numbers")
        .Where(n => n % 5 == 0)
        .ToList();

    static async Task<List<int>> FluentSQLQueryAsync() => await FluentSQLConnectionAsync<int>
        .CreateConnection(builder => {
            builder.ConnectionString = "Data Source=.;Initial Catalog=FluentAPI;Integrated Security=True";
            builder.Data = Enumerable.Range(0, 10_000).ToList();
        })
        .Query("SELECT * FROM Numbers")
        .Where(n => n % 5 == 0)
        .ToListAsync();
}

Optional pattern

Optional pattern — паттерн, согласно которому результат функций упаковывается в структуру Some<T>; в случае ошибки используется вариант Some<T, Error>, а при отсутствии значения вовсе используется структура None<T>.

Данный паттерн позволяет заменить try\catch или, в нашем случае, писать без остановок на проверки код в Fluent-стиле. Нет, проверки, конечно, будут, но они будут лаконично встроены где-то посередине вызовов других методов.

Библиотека Optional позволяет в C# реализовать Optional pattern. В таких языках, как Rust и Swift, данный паттерн используется по-умолчанию. На счёт Swift не уверен, но в Rust из-за этого просто-напросто отсутствуют стейтменты try\catch.

Ниже представлен простой пример использования Optional pattern с Fluent API:

using Microsoft.Data.SqlClient;
using Optional;

namespace FluentAPI.Optional;

public record FluentSQLConnectionBuilder<TData>
{
    public string ConnectionString { get; set; }
    public List<TData> Data { get; set; }
}

public sealed class FluentSQLConnection<TData>
{
    IEnumerable<TData> _data;
    SqlConnection _connection;

    FluentSQLConnection(string connectionString, IEnumerable<TData> data)
    {
        _connection = new SqlConnection(connectionString);
        _data = data;
    }

    public static FluentSQLConnection<TData> CreateConnection(Action<FluentSQLConnectionBuilder<TData>> configureConnection = null!)
    {
        var builder = new FluentSQLConnectionBuilder<TData>() { 
            ConnectionString = String.Empty,
            Data = new List<TData>() 
        };
        configureConnection?.Invoke(builder);
        return new FluentSQLConnection<TData>(builder.ConnectionString, builder.Data);
    }

    public Option<IEnumerable<TData>,QueryError> Query(string sql)
    {
        Task.Delay(1000).Wait();

        return Option.Some<IEnumerable<TData>,QueryError>(_data);
    }

    public Option<IEnumerable<TData>,QueryError> QueryWithoutResult(string sql)
    {
        Task.Delay(1000).Wait();

        return Option.None<IEnumerable<TData>,QueryError>(new QueryError());
    }

    public record struct QueryError(string Message = "Query Error");
}
using Optional.Unsafe;

namespace FluentAPI;

public class Program {
    public static void Main(string[] args) {
        var data = FluentAPI.Optional.FluentSQLConnection<int>
            .CreateConnection()
            .Query("SELECT * FROM Table")
            .ValueOr(new int[] { 1, 2, 3, 4, 5 })
            .Where(n => n % 5 == 0)
            .ToList();

        PrintList(data);

        var data1 = FluentAPI.Optional.FluentSQLConnection<int>
            .CreateConnection()
            .QueryWithoutResult("SELECT * FROM Table")
            .ValueOrFailure()
            .Where(n => n % 2 == 0)
            .ToList();

        PrintList(data1);
    }
    static void PrintList<T>(IEnumerable<T> data)
    {
        Console.WriteLine("Data: [");
        foreach (var item in data)
        {
            Console.WriteLine($"\t{item}");
        }
        Console.WriteLine("]");
    }
}

Обратите внимание на методы ValueOr и ValueOrFailure, расположенные на 10 и 19 строках второго листинга. Первый метод, если обнаруживает, что получен None, то возвращает объект, который был ему передан в качестве аргумента, иначе возвращает объект из Option. Второй же метод позволяет выкинуть исключение, если получил None.

Минус второго метода в том, что нельзя настроить под себя исключения, поэтому не получится с помощью этого метода получать какую-либо информацию об ошибках. Однако это можно исправить, используя метод Match. С помощью данного метода можно определить действия при том или ином результате. Вот немного переписанный пример:

var data1 = FluentAPI.Optional.FluentSQLConnection<int>
            .CreateConnection()
            .QueryWithoutResult("SELECT * FROM Table")
            .Match(
                some: d => d,
                none: e => throw new Exception(e.Message)
            )
            .Where(n => n % 2 == 0)
            .ToList();

  PrintList(data1);

Как видно из кода, метод Match получает в качестве аргументов делегаты. Делегат some вызывается в том случае, если Option является Some, и ему передаётся в качестве аргумента упакованный в вышеупомянутую структуру объект. Над ним можно произвести какие-то действия, но, чаще всего, его просто возвращают. Второй делегат вызывается в случае отсутствия значения, обозначенное структурой None. Там можно вернуть какое-то значение или выкинуть собственное исключение, как это и сделано в примере выше.

Также можно сказать про метод ValueOrDefault, однако он находится в неймспейсе Optional.Unsafe, название которого говорит само за себя. Использовать этот или другие методы из данного пространства имён не желательно. Лучше ограничится теми, что находятся в основном пространстве имён библиотеки.

Итого

Из всего вышенаписанного можно сделать выводы, что Fluent API достаточно полезная дизайн-система интерфейсов, которая позволяет с лёгкостью руководить последовательность инициализации свойств конечного автомата и работы методов, а также как добавить поддержку асинхронного выполнения кода. Также с помощью вышеобозначенного Optional pattern реализовать простую обработку исключений и ошибок, позволяя обрабатывать их, не выходя из контекста Fluent API.

Стоит также сказать, что данный API-дизайн не универсальный, и нужно подумать перед тем, как его сувать везде, куда только можно. Ведь другой подход может быть наиболее эффективным или более легко реализуемым.

Tags:
Hubs:
-1
Comments6

Articles