Friday, July 25, 2014

Replace BackgroundWorker into Task

Сегодня мы рассмотрим подход к написанию кода с использованием возможностей библиотеки параллельных задач (TPL), вместо устаревшего BackgroundWorker. Для многих разработчиков переход с класса BackgroundWorker на класс Task является проблематичным. С выходом .NET Framework 4.5 ситуация с тасками улучшилась, благодаря ключевым словам async/await. 
Я считаю, что использование async/await позволяет писать более компактный код, который легче сопровождать, и позволяет избежать запутывания в ContinueWith множества тасков. В чем заключаются плюсы замены Task на BackgroundWorker, мы рассмотрим ниже. Первый серьезный недостаток BackgroundWorker состоит в том, что он сделан в виде компонента, то есть, наследуется от класса Component. Реализацию данного класса можно посмотреть в онлайн-исходниках .NET Framework 4.5 на #q=BackgroundWorker.
Никакой магии в этом классе нет. Чтобы посмотреть, как он работает, достаточно рассмотреть реализацию WorkerThreadStartDelegate и SendOrPostCalback
Второй недостаток использования BackgroundWorker – постоянная упаковка. Дело в том, что события DoWork и RunWorkerCompleted работают с классами, которые в качестве свойств используют свойства типа object. Например, класс DoWorkEventArgs, который используется в событии DoWork, имеет два свойства: Argument и Result типа object. И если вы работаете со значимыми типами (value type), этот подход плохой. Дело даже не в том, что мы постоянно будем упаковывать наши типы, а в том, что будем постоянно писать код упаковки и распаковки.
Если посмотреть в сторону RunWorkerCompletedEventArgs, то можно увидеть, что там ничуть не лучше. У нас есть свойства Result и UserState, которые также типа object. Теперь перейдем к таскам (класс Task). Одной из основной проблем данного класса является то, что этот класс построен вокруг потоков (эта логика скрыта внутри .NET Framework), а создание потока  отнюдь не дешевая операция. Чтобы посмотреть, что собой представляет класс Task, можно перейти по данной ссылке: #mscorlib/system/threading/Tasks/Task.cs. Если мы посмотрим хотя бы вскользь на его реализацию, то увидим, что она намного сложнее, чем та же самая реализация, которая приведена в классе BackgroundWorker.
 
Этому есть простое объяснение. BackgroundWorker появился в .NET Framework 2.0, и после этого он вряд ли подвергся каким-либо особым изменениям. Таски появились только 4-м фреймворке, и, по сути, являются красивой, мощной и функциональной оболочкой над потоками. Если в том же BackgroundWorker можно посмотреть на код и рассказать, что и за чем вызывается, то в тасках в коде надо ломать голову.
  • BackgroundWorker.cs– 268-строк кода
  • Task.cs – 7333-строк кода
Как говорится, ощутите разницу. Если не углубляться в детали, то использовать таски очень легко, к тому же, компания Microsoft прикладывает титанические усилия, чтобы сделать их использование еще проще. Поскольку я не люблю описывать много теории, так как считаю, что теория без практики это мертвый груз, а я больше, наверное, практик и исследователь, чем просто теоретик, поэтому разберем переход с BackgroundWorker на Task на примере.
Создадим консольное приложение ReplaceBackgroundWorkerSample, установим ему целевой фреймворк 4.5.
Напишем небольшой пример, чтобы продемонстрировать, как работает BackgroundWorker.

static void Main(string[] args)
{
    var bw = new BackgroundWorker();
    bw.DoWork += (s, arg) =>
        {
            var sum = 0;
            for (int i = 0; i < 1000000; i++)
            {
                DateTime.Now.ToString();
                sum += i;
            }

            arg.Result = sum;
        };

    bw.RunWorkerCompleted += (s, arg) =>
        {
            Console.WriteLine(arg.Result);
        };

    bw.RunWorkerAsync();
    Console.ReadLine();
}
Выше написан пример, который считает сумму чисел от 0 до 1000000, а затем выводит результат на консоль. Чтобы этот код можно было нормально покрыть тестами, сделаем для него небольшой враппер, чтобы проставлять значения снаружи. Для этого мы добавим новый интерфейс IAsyncManager, в котором пропишем один метод BackgroundTask.
public interface IAsyncManager
{
    void BackgroundTask(Action<DoWorkEventArgs> action, 
        Action<RunWorkerCompletedEventArgs> onCompleted,
        Action<Exception> errorAction);
}
Классы DoWorkEventArgs и RunWorkerCompletedEventArgs уже приводились выше, а также описывались их аргументы. Это классы, которые использует BackgroundWorker для передачи аргументов в события DoWork и RunWorkerCompleted. Ниже приведена реализация класса AsyncManagerкоторый наследуется от интерфейса IAsyncManager.
public class AsyncManager : IAsyncManager
{
    public void BackgroundTask(Action<DoWorkEventArgs> action, 
        Action<RunWorkerCompletedEventArgs> onCompleted,
        Action<Exception> errorAction)
    {
 
        if (action == null)
            return;
 
        var worker = new BackgroundWorker();
        worker.DoWork += (o, args) => action(args);
 
        if (onCompleted != null)
            worker.RunWorkerCompleted += (o, args) =>
            {
                if (args.Error != null)
                    errorAction(args.Error);
                else
                {
                    onCompleted(args);
                }
            };
        worker.RunWorkerAsync();
    }
}

Рассмотрим простой класс Calculator, который использует интерфейс IAsyncManager, используя Dependency Injection  (DI).
public class Calculator
{
    private IAsyncManager _asyncManager;

    public Calculator(IAsyncManager asyncManager)
    {
        _asyncManager = asyncManager;
    }

    public void Calculate()
    {
        _asyncManager.BackgroundTask(
            arg =>
            {
                var sum = 0;
                for (int i = 0; i < 1000000; i++)
                {
                    DateTime.Now.ToString();
                    sum += i;
                }
                arg.Result = sum;
            },
            completed => Console.WriteLine(completed.Result),
            error => Console.WriteLine(error.ToString()));
    }
}

В методе Calculate вы можете увидеть, как использовать функцию BackgroundTask на практике. Первый action позволяет выполнить какое-то действие. В нашем случае это расчет суммы чисел. Второй Action с именем completed просто выводит посчитанный результат на экран консоли. Action с именем error просто выводит текст ошибки в случае ее возникновения. Это избавляет нас от лишней проверки на ошибку в предыдущем action.
Для проверки нашего кода добавим новый проект Unit Test Project и назовем его ReplaceBackgroundWorkerSampleTest.
Затем перейдем в созданный один класс UnitTest1 и переименуем его следующим образом:
[TestClass]
public class UnitCalculatorTest
{
    [TestMethod]
    public void CalculateMethod()
    {
    }
}

После этого нам понадобится изоляционный фреймворк для тестирования, который позволит нам мокать и стабать то, что мы захотим. В качестве такого фреймворка предпочитаю использование Moq. Для этого перейдем в Manage NuGet Packages и установим наш фреймворк.
Давайте теперь посмотрим, как можно замокать наш интерфейс IAsyncManager. Чтобы у нас была ссылка на проект, в тестах ее необходимо добавить через Add Reference.
Теперь перейдем непосредственно к написанию самого теста. Поскольку у нас один метод, который нам нужно замокать (BackgroundTask), и поскольку имитировать асинхронность для нашего фейка не стоит, делаем простую и прямолинейную реализацию нашего mock объекта. Вот как это будет выглядеть:
[TestClass]
public class UnitCalculatorTest
{
    [TestMethod]
    public void CalculateMethod()
    {
        var asyncManagerMock = new Mock<IAsyncManager>();
        asyncManagerMock.Setup(
            pa => pa.BackgroundTask(It.IsAny<Action<DoWorkEventArgs>>(), 
                It.IsAny<Action<RunWorkerCompletedEventArgs>>(), 
                It.IsAny<Action<Exception>>()))
            .Callback<Action<DoWorkEventArgs>, 
                Action<RunWorkerCompletedEventArgs>, 
                Action<Exception>>((action, onCompleted, error) =>
            {
                if (action == null)
                    return;

                var workArgs = new DoWorkEventArgs(null);

                try
                {
                    action(workArgs);
                }
                catch (Exception ex)
                {
                    error(ex);
                }

                if (onCompleted != null)
                    onCompleted(new RunWorkerCompletedEventArgs(workArgs.Result, nullfalse));
            });

        var calculator = new Calculator(asyncManagerMock.Object);
        calculator.Calculate();
    }
}

После этого запустим наш тестовый проект, чтобы убедиться в том, что он работает. Небольшое примечание: поскольку в данной статье область тестирования не охватывается как таковая, мы не будем останавливаться на самом тестировании и принципах работы Moq фреймворка, поскольку это целая тема для отдельной статьи. Если вкратце, то мы просто подменили ту реализацию, которая была, на свою. Такой подход называется классическим unit testing.
Поскольку с BackgroundWorker мы разобрались, пора перейти к таскам. Поскольку таски являются типизированными, нам не нужно приводить наши аргументы к object, как это мы делали в BackgroundWorker, а можем использовать наши типы данных, избегая упаковки. Для этого в наш класс IAsyncManager добавим еще один метод BackgroundTask, который будет, по сути, типизированный.
void BackgroundTask<T>(Func<T> action,
                        Action<T> onCompleted,
                        Action<Exception> logErrorAction);
Полная реализация измененного интерфейса предствавлена ниже.
public interface IAsyncManager
{
    void BackgroundTask(Action<DoWorkEventArgs> action, 
        Action<RunWorkerCompletedEventArgs> onCompleted,
        Action<Exception> errorAction);

    void BackgroundTask<T>(Func<T> action,
                            Action<T> onCompleted,
                            Action<Exception> logErrorAction);
}
Реализация нового метода BackgroundTask приведена ниже.
public async void BackgroundTask<T>(Func<T> action, 
    Action<T> onCompleted, 
    Action<Exception> logErrorAction)
{
    var task = Task.Factory.StartNew(action);
    try
    {
        var result = await task;
        onCompleted(result);
    }
    catch (Exception ex)
    {
        logErrorAction(ex);
    }
}
Он получился более компактным и читабельным. Также он типизирован, поэтому, как указано выше, мы не упаковываем значимые типы, если будем их использовать. Полная реализация класса AsyncManager приведена ниже.
public class AsyncManager : IAsyncManager
{
    public void BackgroundTask(Action<DoWorkEventArgs> action, 
        Action<RunWorkerCompletedEventArgs> onCompleted,
        Action<Exception> errorAction)
    {
        if (action == null)
            return;

        var worker = new BackgroundWorker();
        worker.DoWork += (o, args) => action(args);

        if (onCompleted != null)
            worker.RunWorkerCompleted += (o, args) =>
                {
                    if (args.Error != null)
                        errorAction(args.Error);
                    else
                    {
                        onCompleted(args);
                    }
                };

        worker.RunWorkerAsync();
    }

    public async void BackgroundTask<T>(Func<T> action, 
        Action<T> onCompleted, 
        Action<Exception> logErrorAction)
    {
        var task = Task.Factory.StartNew(action);
        try
        {
            var result = await task;
            onCompleted(result);
        }
        catch (Exception ex)
        {
            logErrorAction(ex);
        }
    }
}
Давайте посмотрим, как изменится наш класс Calculator, если мы перепишем его метод Calculate  с использованием нашего нового метода BackgroundTask, который работает на тасках.
public class Calculator
{
    private IAsyncManager _asyncManager;

    public Calculator(IAsyncManager asyncManager)
    {
        _asyncManager = asyncManager;
    }

    public void Calculate()
    {
        _asyncManager.BackgroundTask(
            () =>
            {
                var sum = 0;
                for (int i = 0; i < 1000000; i++)
                {
                    DateTime.Now.ToString();
                    sum += i;
                }
                return sum;
            },
            completed => Console.WriteLine(completed),
            error => Console.WriteLine(error.ToString()));
    }
}
Теперь первый функтор в методе BackgroundTask возвращает результат вычисления метода, а второй action с именем completed выводит этот результат на консоль. Мы можем с функции Main выполнить наш проект, чтобы убедиться в том, что все работает, но поскольку мы покрываем наш код тестами, рекомендую перейти в наш проект ReplaceBackgroundWorkerSampleTest и замокать нашу вторую типизированную функцию BackgroundTask.
[TestClass]
public class UnitCalculatorTest
{
    [TestMethod]
    public void CalculateMethod()
    {
        var asyncManagerMock = new Mock<IAsyncManager>();
        MockObject<int>(asyncManagerMock);

        var calculator = new Calculator(asyncManagerMock.Object);
        calculator.Calculate();
    }

    private void MockObject<T>(Mock<IAsyncManager> baseMock)
    {
        baseMock.Setup(pa => pa.BackgroundTask(It.IsAny<Func<T>>(), 
            It.IsAny<Action<T>>(), 
            It.IsAny<Action<Exception>>()))
            .Callback<Func<T>, 
                Action<T>, 
                Action<Exception>>((action, onCompleted, logErrorAction) =>
            {
                var task = Task<T>.Factory.StartNew(action);
                Task.WaitAll(new[] { task });
                try
                {
                    var result = task;
                    onCompleted(result.Result);
                }
                catch (Exception ex)
                {
                    logErrorAction(ex);
                    throw;
                }

            });
    }
}
Если вы внимательно посмотрите на приватный метод MockObject, то увидите, что для generic типа нам пришлось написать отдельный метод. Это, в принципе, не проблема, так как можно написать отдельный класс, для того чтобы расширить наш текущий mock объект с помощью extension методов (методов расширения). Если мы запустим наш код на выполнение, то увидим, что результат аналогичен предыдущему варианту. Но поскольку у нас типизированный метод BackgroundTask, давайте немного усложним метод Calculate класса Calculator следующим образом:
public void Calculate()
{
    _asyncManager.BackgroundTask(() =>
    {
        var sum = 0;
        for (int i = 0; i < 1000000; i++)
        {
            DateTime.Now.ToString();
            sum += i;
        }
        return sum;
    },
    Console.WriteLine,
    error => Console.WriteLine(error.ToString()));

    _asyncManager.BackgroundTask(
        () =>
        {
            var sb = new StringBuilder();
            for (int i = 0; i < 1000000; i++)
            {
                DateTime.Now.ToString();
                sb.Append(i);
            }
            return sb;
        },
        res => Console.WriteLine(res.Length),
        error => Console.WriteLine(error.ToString()));
}
Первый метод BackgroundTask вычисляет сумму чисел, второй просто складывает числа в StringBuilder, а затем выводит длину этого StringBuilder. Наш тестовый метод изменится лишь слегка.
[TestMethod]
public void CalculateMethod()
{
    var asyncManagerMock = new Mock<IAsyncManager>();
    MockObject<int>(asyncManagerMock);
    MockObject<StringBuilder>(asyncManagerMock);
    var calculator = new Calculator(asyncManagerMock.Object);
    calculator.Calculate();
}
В нем добавилась только строчка

MockObject<StringBuilder>(asyncManagerMock);

После этого мы можем запустить наш пример и посмотреть результат на экране.
Таски очень легко можно проверять отдельно. Например, благодаря использованию ключевых слов async/await мы можем писать такой код для тасков:

[TestMethod]
public async Task TestMethod2()
{
    var task = Task.Run(() =>
    {
        var sum = 0;
        for (int i = 0; i < 1000000; i++)
        {
            DateTime.Now.ToString();
            sum += i;
        }

        return sum;
    });

    var result = await task;

    Assert.AreEqual(result, 1783293664);
}

При этом приведенный выше код успешно выполнится.
 
Мы рассмотрели в статье, как можно достаточно эффективно использовать класс Task, вместо BackgroundWorker, в простых случаях. Если же нам нужно будет добавить ProgressBar для тасков, то это тоже не проблема на данный момент. Для этого можно пойти двумя путями. Можно взять реализацию, которая приводиться в google, либо посмотреть в исходниках .NET Framework 4.5, которые компания Microsoft выложила в публичный доступ, на примере функции ReportProgress. Надеюсь, что я смог хоть чуточку убедить вас в полезности и преимуществах класса Task, по сравнению с устаревшим BackgroundWorker.

No comments:

Post a Comment