.NET
C#
Functional Programming
2 October

Монада «Maybe» через async/await в C# (без Task-oв!)

Original author: Dmitry Tikhonov
Translation


Обобщенные асинхронные типы возвращаемых значений — это новая возможность появившаяся в C# 7, которая позволяет использовать не только Task в качестве возвращаемого типа асинхронных (async/await) методов, но также и любые другие типы (классы или структуры), удовлетворяющие определенным требованиям.


В то же время async/await — это способ последовательно вызвать некий набор функций внутри некоторого контекста, что является сущностью шаблона проектирования Монада. Возникает вопрос, можем ли мы использовать async/await для написания кода, который будет вести себя так же, как если бы мы использовали монады? Оказывается, что да (с некоторыми оговорками). Например, приведенный ниже код компилируется и работает:


async Task Main()
{
  foreach (var s in new[] { "1,2", "3,7,1", null, "1" })
  {
      var res = await Sum(s).GetMaybeResult();
      Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
  }
  // 3, 11, Nothing, Nothing
}

async Maybe<int> Sum(string input)
{
    var args = await Split(input);//Нет проверки результата
    var result = 0;
    foreach (var arg in args)
        result += await Parse(arg);//Нет проверки результата
    return result;
}

Maybe<string[]> Split(string str)
{
  var parts = str?.Split(',').Where(s=>!string.IsNullOrWhiteSpace(s)).ToArray();
  return parts == null || parts.Length < 2 ? Maybe<string[]>.Nothing() : parts;
}

Maybe<int> Parse(string str)
    => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();

Далее я объясняю, как работает этот код ...


Обобщенные асинхронные типы возвращаемых значений


Прежде всего давайте выясним, что требуется для использования нашего собственного типа (например, класс MyAwaitable<T>) в качестве типа результата некоторой асинхронной функции. Документация говорит, что такой тип должен иметь:


  1. GetAwaiter() метод, который возвращает объект типа, который реализует интерфейс INotifyCompletion, а также имеет свойство bool IsCompleted и метод T GetResult() ;


  2. [AsyncMethodBuilder(Type)] — aтрибут указывающий на тип, который будет выступать в роли "Построителя метода" ("Method Builder"), например MyAwaitableTaskMethodBuilder<T>. Этот тип должен содержать в следующие методы:


    • static Create()
    • Start(stateMachine)
    • SetResult(result)
    • SetException(exception)
    • SetStateMachine(stateMachine)
    • AwaitOnCompleted(awaiter, stateMachine)
    • AwaitUnsafeOnCompleted(awaiter, stateMachine)
    • Task


Пример простой реализации MyAwaitable и MyAwaitableTaskMethodBuilder
[AsyncMethodBuilder(typeof(MyAwaitableTaskMethodBuilder<>))]
public class MyAwaitable<T> : INotifyCompletion
{
    private Action _continuation;

    public MyAwaitable()
    { }

    public MyAwaitable(T value)
    {
        this.Value = value;
        this.IsCompleted = true;
    }

    public MyAwaitable<T> GetAwaiter() => this;

    public bool IsCompleted { get; private set; }

    public T Value { get; private set; }

    public Exception Exception { get; private set; }

    public T GetResult()
    {
        if (!this.IsCompleted) throw new Exception("Not completed");
        if (this.Exception != null)
        {
            ExceptionDispatchInfo.Throw(this.Exception);
        }
        return this.Value;
    }

    internal void SetResult(T value)
    {
        if (this.IsCompleted) throw new Exception("Already completed");
        this.Value = value;
        this.IsCompleted = true;
        this._continuation?.Invoke();
    }

    internal void SetException(Exception exception)
    {
        this.IsCompleted = true;
        this.Exception = exception;
    }

    void INotifyCompletion.OnCompleted(Action continuation)
    {
        this._continuation = continuation;
        if (this.IsCompleted)
        {
            continuation();
        }
    }
}

public class MyAwaitableTaskMethodBuilder<T>
{
    public MyAwaitableTaskMethodBuilder() 
        => this.Task = new MyAwaitable<T>();

    public static MyAwaitableTaskMethodBuilder<T> Create() 
    => new MyAwaitableTaskMethodBuilder<T>();

    public void Start<TStateMachine>(ref TStateMachine stateMachine) 
        where TStateMachine : IAsyncStateMachine 
        => stateMachine.MoveNext();

    public void SetStateMachine(IAsyncStateMachine stateMachine) { }

    public void SetException(Exception exception) 
        => this.Task.SetException(exception);

    public void SetResult(T result) 
        => this.Task.SetResult(result);

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine) 
        where TAwaiter : INotifyCompletion 
        where TStateMachine : IAsyncStateMachine
        => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine);

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine) 
        where TAwaiter : ICriticalNotifyCompletion 
        where TStateMachine : IAsyncStateMachine
        => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine);

    public void GenericAwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine         
        => awaiter.OnCompleted(stateMachine.MoveNext);

    public MyAwaitable<T> Task { get; }
}

Теперь мы можем использовать MyAwaitable как тип результата асинхронных методов:


private async MyAwaitable<int> MyAwaitableMethod()
{
    int result = 0;
    int arg1 = await this.GetMyAwaitable(1);
    result += arg1;
    int arg2 = await this.GetMyAwaitable(2);
    result += arg2;
    int arg3 = await this.GetMyAwaitable(3);
    result += arg3;
    return result;
}

private async MyAwaitable<int> GetMyAwaitable(int arg)
{
    await Task.Delay(1);//Эмуляция асинхронного исполнения 
    return await new MyAwaitable<int>(arg);
}

Этот код работает, но для понимания сути требований к классу MyAwaitable давайте посмотрим, что препроцессор C# делает с методом MyAwaitableMethod. Если вы запустите какой-нибудь декомпилятор .NET-сборок (например, dotPeek), вы увидите, что оригинальный метод был изменен следующим образом:


private MyAwaitable<int> MyAwaitableMethod()
{
    var stateMachine = new MyAwaitableMethodStateMachine();
    stateMachine.Owner = this;
    stateMachine.Builder = MyAwaitableTaskMethodBuilder<int>.Create();
    stateMachine.State = 0;
    stateMachine.Builder.Start(ref stateMachine);
    return stateMachine.Builder.Task;
}

MyAwaitableMethodStateMachine

На самом деле это упрощенный код, где я пропускаю множество оптимизаций, чтобы сделать сгенерированный компилятором код читабельным


sealed class MyAwaitableMethodStateMachine : IAsyncStateMachine
{
    public int State;
    public MyAwaitableTaskMethodBuilder<int> Builder;
    public BuilderDemo Owner;
    private int _result;
    private int _arg1;
    private int _arg2;
    private int _arg3;
    private MyAwaitableAwaiter<int> _awaiter1;
    private MyAwaitableAwaiter<int> _awaiter2;
    private MyAwaitableAwaiter<int> _awaiter3;

    private void SetAwaitCompletion(INotifyCompletion awaiter)
    {
        var stateMachine = this;
        this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
    }

    void IAsyncStateMachine.MoveNext()
    {
        int finalResult;
        try
        {
            label_begin:
            switch (this.State)
            {
                case 0:
                    this._result = 0;
                    this._awaiter1 = this.Owner.GetMyAwaitable(1).GetAwaiter();
                    this.State = 1;

                    if (!this._awaiter1.IsCompleted)
                    {
                        this.SetAwaitCompletion(this._awaiter1);
                        return;
                    }
                    goto label_begin;

                case 1:// awaiter1 должен быть завершен
                    this._arg1 = this._awaiter1.GetResult();
                    this._result += this._arg1;

                    this.State = 2;
                    this._awaiter2 = this.Owner.GetMyAwaitable(2).GetAwaiter();

                    if (!this._awaiter2.IsCompleted)
                    {
                        this.SetAwaitCompletion(this._awaiter2);
                        return;
                    }
                    goto label_begin;

                case 2:// awaiter2 должен быть завершен

                    this._arg2 = this._awaiter2.GetResult();
                    this._result += this._arg2;

                    this.State = 3;
                    this._awaiter3 = this.Owner.GetMyAwaitable(3).GetAwaiter();

                    if (!this._awaiter3.IsCompleted)
                    {
                        this.SetAwaitCompletion(this._awaiter3);
                        return;
                    }
                    goto label_begin;

                case 3:// awaiter3 должен быть завершен

                    this._arg3 = this._awaiter3.GetResult();
                    this._result += this._arg3;

                    finalResult = this._result;
                    break;
                default:
                    throw new Exception();
            }
        }
        catch (Exception ex)
        {
            this.State = -1;
            this.Builder.SetException(ex);
            return;
        }

        this.State = -1;
        this.Builder.SetResult(finalResult);
    }
}

Изучив сгенерированный код, мы видим, что Method Builder имеет следующие обязанности:


  1. Организация вызова метода MoveNext() который переводит сгенерированный конечный автомат в следующее состояние.
  2. Создание объекта, который будет представлять контекст асинхронной операции (public MyAwaitable<T> Task { get; })
  3. Реагирование на перевод сгенерированного конечного автомата в финальные состояния: SetResult или SetException.

Другими словами, с помощью Method Builder мы можем получить контроль над тем, как выполняются асинхронные методы, и это выглядит как та возможность, которая поможет нам достичь нашей цели — реализации поведения монады Maybe.


Но что же такого хорошего в этой монаде?… На самом деле, вы можете найти много статей об этой монаде в Интернете, поэтому здесь я опишу только основы.


Монада Maybe


Вкратце, монада Maybe — это шаблон проектирования, который позволяет прерывать цепочку вызовов функций, если какая-то функция из цепочки не может вернуть осмысленный результат (например, недопустимые входные параметры).


Исторически императивные языки программирования решали эту проблему двумя способами:


  1. Болшое количество условной логики
  2. Исключения

Оба способа имеют очевидные недостатки, поэтому был предложен альтернативный подход:


  1. Создать тип, который может находиться в двух состояниях: "Некое значение" и "Нет значения" ("Nothing") — назовем его Maybe
  2. Создать функцию (назовем ее SelectMany) которая принимает 2 аргумента:
    2.1. Объект типа Maybe
    2.2. Следующую функцию из списка вызовов. Эта функция так же должна вернуть объект типа Maybe, который может содержать какое-то результирующее значение или находиться в состоянии Nothing, если результат не может быть получен (например, в функцию были переданы некорректные параметры)
  3. Функция SelectMany проверяет объект типа Maybe и если он содержит результирующее значение, то этот результат извлекается и передается в качестве аргумента следующей функции из цепочки вызовов (переданной в качестве второго аргумента). Если же объект типа Maybe находится в состоянии Nothing, то SelectMany сразу же вернет Nothing.


В C# это может быть реализовано следующим образом:


public struct Maybe<T>
{
    public static implicit operator Maybe<T>(T value) => Value(value);

    public static Maybe<T>  Value(T value) => new Maybe<T>(false, value);

    public static readonly Maybe<T> Nothing = new Maybe<T>(true, default);

    private Maybe(bool isNothing, T value)
    {
        this.IsNothing = isNothing;
        this._value = value;
    }

    public readonly bool IsNothing;

    private readonly T _value;

    public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value;
}

public static class MaybeExtensions
{
    public static Maybe<TRes> SelectMany<TIn, TRes>(
        this Maybe<TIn> source, 
        Func<TIn, Maybe<TRes>> func)

        => source.IsNothing ? 
            Maybe<TRes>.Nothing : 
            func(source.GetValue());
}

и пример использования:


static void Main()
{
    for (int i = 0; i < 10; i++)
    {
        var res = Function1(i).SelectMany(Function2).SelectMany(Function3);
        Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
    }

    Maybe<int> Function1(int acc) => acc < 10 ? acc + 1 : Maybe<int>.Nothing;

    Maybe<int> Function2(int acc) => acc < 10 ? acc + 2 : Maybe<int>.Nothing;

    Maybe<int> Function3(int acc) => acc < 10 ? acc + 3 : Maybe<int>.Nothing;
}

Почему "SelectMany"?

Думаю, что некоторые из вас могут задаться вопросом: "Почему автор назвал эту функцию "SelectMany"? На самом деле, для этого есть причина — в C# препроцессор вставляет вызов Select Many при обработке выражений записанных в Query Notation, которая, по сути, является “Синтаксическим сахаром” для сложных цепочек вызовов. (Вы можете найти более подробную информацию об этом в моей предыдущей статье).


На самом деле, мы можем переписать предыдущий код следующим образом:


var res = Function1(i)
    .SelectMany(x2 => 
        Function2(x2).SelectMany(x3 => 
            Function3(x3.SelectMany<int, int>(x4 => 
                x2 + x3 + x4)));

получив таким образом доступ к промежуточному состоянию (x2, x3), что в некоторых случаях может быть весьма удобно. К сожалению, читать такой код весьма затруднительно, но к счастью, в C# есть Query Notation с помощью которой подобный код будет выглядеть гораздо проще:


var res = from x2 in Function1(i)
    from x3 in Function2(x2)
    from x4 in Function3(x3)
    select x2 + x3 + x4;

Для того, чтобы сделать этот код компилируемым, нам понадобится слегка расширить функцию Select Many:


public static Maybe<TJ> SelectMany<TIn, TRes, TJ>(
    this Maybe<TIn> source, 
    Func<TIn, Maybe<TRes>> func, 
    Func<TIn, TRes, TJ> joinFunc)
{
    if (source.IsNothing)
        return Maybe<TJ>.Nothing;

    var res = func(source.GetValue());
    return res.IsNothing 
        ? Maybe<TJ>.Nothing 
        : joinFunc(source.GetValue(), res.GetValue());
}

Вот так будет выглядеть код из заголовка статьи, если его переписать с использованием "классической" реализации "Maybe"
static void Main()
{
    foreach (var s in new[] {"1,2", "3,7,1", null, "1"})
    {
        var res = Sum(s);
        Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
    }

    Console.ReadKey();
}

static Maybe<int> Sum(string input)
    => Split(input).SelectMany(items => Acc(0, 0, items));

//Рекурсия используется для обработки списка объектов типа "Maybe"
static Maybe<int> Acc(int res, int index, IReadOnlyList<string> array) 
    => index < array.Count 
        ? Add(res, array[index])
            .SelectMany(newRes => Acc(newRes, index + 1, array)) 
        : res;

static Maybe<int> Add(int acc, string nextStr) 
    => Parse(nextStr).SelectMany<int, int>(nextNum => acc + nextNum);

static Maybe<string[]> Split(string str)
{
    var parts = str?.Split(',')
        .Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
    return parts == null || parts.Length < 2 ? Maybe<string[]>.Nothing : parts;
}

static Maybe<int> Parse(string value)
    => int.TryParse(value, out var result) ? result : Maybe<int>.Nothing;

Это код выглядит не очень элегантно, поскольку C# не был изначально спроектирован как функциональный язык, но в "настоящих" функциональных языка подобный подход весьма распространён.


Async Maybe


Суть монады Maybe заключается в управлении цепочкой вызовов функций, но это именно то, что делает async/await. Так что, давайте попробуем объединить их вместе. Во-первых, нам нужно сделать тип Maybe совместимым с асинхронными функциями, и мы уже знаем, как этого достичь:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : INotifyCompletion
{
    ...
    public Maybe<T> GetAwaiter() => this;

    public bool IsCompleted { get; private set; }

    public void OnCompleted(Action continuation){...}

    public T GetResult() =>...
}

Теперь давайте посмотрим, как "классическую" реализацию Maybe можно переписать в виде конечного автомата, чтобы можно было найти какие-либо сходства:


static void Main()
{
    for (int i = 0; i < 10; i++)
    {
        var stateMachine = new StateMachine();
        stateMachine.state = 0;
        stateMachine.i = i;
        stateMachine.MoveNext();

        var res = stateMachine.Result;

        Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
    }

    Console.ReadKey();
}

class StateMachine
{
    public int state = 0;

    public int i;
    public Maybe<int> Result;

    private Maybe<int> _f1;
    private Maybe<int> _f2;
    private Maybe<int> _f3;

    public void MoveNext()
    {
        label_begin:
        switch (this.state)
        {
            case 0:
                this._f1 = Function1(this.i);
                this.state = Match ? -1 : 1;
                goto label_begin;
            case 1:
                this._f2 = Function2(this._f1.GetValue());
                this.state = this._f2.IsNothing ? -1 : 2;
                goto label_begin;
            case 2:
                this._f3 = Function3(this._f2.GetValue());
                this.state = this._f3.IsNothing ? -1 : 3;
                goto label_begin;
            case 3:
                this.Result = this._f3.GetValue();
                break;
            case -1:
                this.Result = Maybe<int>.Nothing;
                break;
        }
    }
}

Если мы сопоставим этот конечный автомат с сгенерированным препроцессором C# (см. выше «MyAwaitableMethodStateMachine»), мы можем заметить, что проверка состояния Maybe может быть реализована внутри:


this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);

где ref awaiter — это объект типа Maybe. Проблема здесь в том, что мы не можем установить автомат в "финальное" (-1) состояние, но значит ли это, что мы не можем контролировать поток выполнения? На самом деле это не так. Дело в том, что для каждого асинхронного действия C# устанавливает функцию обратного вызова продолжения асинхронного действия через интерфейс INotifyCompletion, поэтому, если мы хотим разорвать поток выполнения, мы можем просто вызвать функцию обратного вызова в том случае, когда мы не можем продолжить цепочку асинхронных операций.
Другая проблема здесь состоит в том, что сгенерированный конечный автомат передает следующий шаг (как функцию обратного вызова) текущей последовательности асинхронных операций, но нам нужна функция обратного вызова для исходной последовательности, которая позволил бы обойти все оставшиеся цепочки асинхронных операций (с любого уровня вложенности):



Итак, нам нужно как-то связать текущее вложенное асинхронное действие со его создателем. Мы можем сделать это, используя наш Method Builder, который имеет ссылку на текущую асинхронную операцию — Task. Ссылки на все дочерние асинхронные операции будут передаваться в AwaitOnCompleted (ref awaiter) как awaiter, поэтому нам просто нужно проверить, является ли параметр экземпляром Maybe, и затем установить текущий Maybe как родительский для текущего дочернего действия:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private IMaybe _parent;

    void IMaybe.SetParent(IMaybe parent) => this._parent = parent;
    ...
}

public class MaybeTaskMethodBuilder<T>
{
    ...
    private void GenericAwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter,
        ref TStateMachine stateMachine) 
        where TAwaiter : INotifyCompletion 
        where TStateMachine : IAsyncStateMachine
    {
        if (awaiter is IMaybe maybe)
        {
            maybe.SetParent(this.Task);
        }
        awaiter.OnCompleted(stateMachine.MoveNext);
    }  
    ...  
}

Теперь все объекты типа Maybe могут быть объединены в иерархию, в результате чего, мы получим доступ к завершающему вызову всей иерархии (метод Exit) из любого узла:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private Action _continuation;

    private IMaybe _parent;
    ...
    public void OnCompleted(Action continuation)
    {
        ...
        this._continuation = continuation;
        ...
    }
    ...
    void IMaybe.Exit()
    {
        this.IsCompleted = true;

        if (this._parent != null)
        {
            this._parent.Exit();
        }
        else
        {
            this._continuation();
        }
    }
    ...
}

Метод Exit должен вызываться, когда во время перемещения по иерархии мы нашли уже вычисленный объект Maybe в состоянии Nothing. Такие объекты Maybe могут быть возвращены методами подобными этому:


Maybe<int> Parse(string str)
    => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();

Чтобы хранить состояние Maybe, создадим новую отдельную структуру:


public struct MaybeResult
{
    ...
    private readonly T _value;

    public readonly bool IsNothing;

    public T GetValue() 
        => this.IsNothing ? throw new Exception("Nothing") : this._value;
}

[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private MaybeResult? _result;
    ...
    internal Maybe() { }//Used in async method

    private Maybe(MaybeResult result) => this._result = result;// "Вычисленный" экземпляр
    ...
}

В момент когда асинхронный конечный автомат вызывает (через Method Builder) метод OnCompleted уже вычисленного экземпляра Maybe и он находится в состоянии Nothing, мы сможем разорвать весь поток:


public void OnCompleted(Action continuation)
{
    this._continuation = continuation;
    if(this._result.HasValue)
    {
        this.NotifyResult(this._result.Value.IsNothing);
    }
}

internal void SetResult(T result)
//Вызывается из "method builder" после завершения асинхронного метода
{
    this._result = MaybeResult.Value(result);
    this.IsCompleted = true;
    this.NotifyResult(this._result.Value.IsNothing);
}

private void NotifyResult(bool isNothing)
{
    this.IsCompleted = true;
    if (isNothing)
    {
        this._parent.Exit();//Разрываем весь поток выполнения
    }
    else
    {
        this._continuation?.Invoke();
    }
}

Теперь остается только один вопрос — как получить результат асинхронного Maybe вне его области действия (любой асинхронный метод, тип возвращаемого значения которого не Maybe). Если вы попытаетесь использовать только ключевое слово await с экземпляром Maybe, то тогда возникнет исключение, вызываемое этим кодом:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private MaybeResult? _result;

    public T GetResult() => this._result.Value.GetValue();
}
...
public struct MaybeResult
{
    ...
    public T GetValue() 
        => this.IsNothing ? throw new Exception("Nothing") : this._value;
}

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


var res = await GetResult().GetMaybeResult();

if(res.IsNothing){
    ...
}
else{
    res.GetValue();
    ...
};

Пока это все. В примерах кода я опустил некоторые детали, чтобы сосредоточиться только на самых важных частях. Вы можете найти полную версию на github.


На самом деле, я бы не рекомендовал использовать вышеописанный подход в любом рабочем коде, поскольку он имеет одну существенную проблему — когда мы разрываем поток выполнения, вызывая продолжение корневой асинхронной операции (с типом Maybe), мы перерывам вообще ВСЕ! включая все блоки finally (это ответ на вопрос «Всегда ли вызываются блоки finally?»), поэтому все операторы using не будут работать должным образом, что может привести к утечке ресурсов. Эта проблема может быть решена, если вместо прямого вызова продолжения мы сгенерируем специальное исключение, которое будет неявным образом обрабатываться (здесь вы можете найти эту версию), но это решение, очевидно, имеет ограничение по производительности (что может быть приемлемо в некоторых сценариях). В текущей версии компилятора C# я не вижу другого решения, но возможно это когда-нибудь измениться в будущем.


Однако, эти ограничения не означают, что все приемы, описанные в этой статье, абсолютно бесполезны, их можно использовать для реализации других монад, которые не требуют изменений в потоках выполнения, например, "Reader". Как реализовать эту монаду "Reader" через async / await я покажу в следующей статье.


+19
7.6k 93
Comments 29