Сегодня мы
рассмотрим подход к написанию кода с использованием возможностей библиотеки
параллельных задач (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.
Создадим консольное приложение 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, null, false));
});
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