Эта статья будет интересна, в первую очередь, .NET разработчикам, которые используют в своих проектах принцип TDD или просто модульные тесты и хотят, чтобы их код оставался простым и работал эффективно.
Написание модульных тестов, теоретически, должно увеличивать время, затраченное на создание продукта, и, соответственно, его стоимость. На практике это предположение чаще всего не верно в контексте создания и сопровождения продукта в целом вот почему:
- обычно код тестов прост, однотипен и пишется быстро. Современные фреймворки для тестирования (NUnit, XUnit и т.д.) и "мокирования" (Moq, NSubstitute и др.) значительно упрощают создание тестов и даже делают увлекательным
- программисты люди ленивые, но креативные. Написание модульных тестов помогает сконцентрироваться на ответственности класса или структуры и четко обозначить список решаемых задач. Подталкивает соблюдать принцип "Вам это не понадобится" YAGNI и тем самым, несколько, притупляет "креативность" программиста. А это, в свою очередь, позволяет сделать меньше работы, для достижения того же результата, чему "ленивая" сторона программиста будет рада. Очевидно, меньшее количество кода, будет содержать меньшее количество ошибок в нем
- отличным бонусом будет и контроль регрессии. Любое изменение в коде будет автоматически проверено, например, процессом непрерывной интеграции, на непротиворечивость требованиям и корректности выполнения задач. Станет ощутимо проще и быстрее искать и устранять дефекты в коде, так как упавший тест будет точно указывать какая логика сломалась. Новые фичи будут появляться быстрее и будет проще рефакторить код, не боясь что-то разломать. Своевременный рефакторинг кода не допустит его деградации: усложнения, “копи-пастов”, отставания от современных тенденций
- уменьшится количество других видов тестов, в том числе и особо затратных по времени, таких как интеграционные. Это значительно сократить время от внесения каких-либо изменений в код до готовности использовать результаты этих изменений
- дизайн проекта преобразится в лучшую сторону, так как использование TDD и модульных тестов накладывает на проект определенные требования, такие как: слабая связанность между модулями, следование принципам объекто-ориентированного программирования SOLID и д.р.
Главное из требований к модульным тестам заключатся в том, что каждый модуль (в нашем случае класс или структура) должен быть готовым к тестированию в полной изоляции. Другими словами, модульный тест должен проверять работу только одного модуля. Если же тестировать модуль не в полной изоляции, а вместе с его зависимостями, то на результаты тестов будет влиять "чужая" логика из зависимостей. Это сделает невозможным тонкую настройку тестового окружения, значительно увеличит количество тестов, сделает тесты сложными и ломкими. Изменения в модуле будут влиять на результаты тестов для других модулей и в какой-то момент, а это произойдет достаточно быстро, тесты превратятся в тыкву. Что бы избежать этой мрачной картины и протестировать модуль в изоляции, необходимо внедрить все зависимости в модуль из вне. Другими словами, придется отказаться от использования операторов new в логике класса или структуры, заменив их аргументами конструктора, аргументами методов инициализации или свойствами, передаваемыми из вне.
Подход, с непосредственным внедрением зависимостей в экземпляр типа через конструкторы/методы инициализации/поля/свойства, имеет ряд преимуществ и недостатков. Главное преимущество в том, что нет необходимости что-то изобретать - синтаксис языка содержит все необходимое. Напрашивается вопрос: где и когда создавать экземпляры типов и внедрять зависимости, когда типов много и связи между ними сложны? Можно воспользоваться абстрактной фабрикой. Появляется другой вопрос: сколько понадобится таких фабрик и как они будут взаимодействовать друг с другом для композиции объектов, в случае сложного графа зависимостей?
Ответ на последний вопрос лежит на поверхности и это Service Locator, который, предоставляет зависимость каждому желающему экземпляру по мере необходимости, через метод разрешения зависимости, такой как GetService. Это главное достоинство Service Locator, но одновременно его главный недостаток. Каждый экземпляр может потребовать любую зависимость в любое время. Другими словами, потенциально, каждый зависит от всех. Реальность еще сложнее - логика класса или структуры в разных условиях может потребовать разный набор зависимостей. Становится очевидной потеря контроля над зависимостями, что делает непростым создание окружения для тестирования в изоляции. Как итог, тесты станут ломкими и их будет не просто поддерживать и развивать.
Еще один подход это IoC контейнер. Каждый тип имеет только необходимый и достаточный набор внедряемых зависимостей, определенный в конструкторе, методах инициализации или свойствах, открытых для записи. Казалось бы, все хорошо, бери и используй, но, как всегда, и тут есть ложка дёгтя. Проблема в том, что .NET код создает объекты почти мгновенно, используя операторы new. Скорость их создания, как "скорость света в вакууме", это порог, который невозможно преодолеть, используя какой-либо фреймворк. Каждая реализация IoC контейнера использует свой собственный механизм разрешения и внедрения зависимостей. Можно выделить несколько, добавив один главный недостаток для каждого:
- волшебный статический метод Activator.CreateInstance - низкая производительность
- сопоставление типов и лямбда функций для создания экземпляров этих типов - нет авто-связывания, т.е. придется обозначить весь процесс создания экземпляров вручную, передав все зависимости как аргументы в конструктор, в метод инициализации или в свойство, открытое для записи
- генерация кода
- создание текста кода и его компиляция - низкая скорость генерации и компиляции
- использование синтаксического дерева Roslyn/CodeDOM - зависимость от большого набора библиотек
- использование Expression Trees - нет полного контроля генерируемого кода
- генерация IL кода - сложность реализации и отладки
Любой из этих подходов будет создавать экземпляры медленнее “скорости света в вакууме” - набора операторов new. Если вспомнить, что IoC контейнер - всего лишь инфраструктура, которая не делает ни чего полезного в контексте основных задач, то ради неё не хочется жертвовать производительностью. Можно пойти на компромисс и использовать IoC контейнеры выборочно: xасть типов, количество экземпляров которых не велико, создаются с использованием IoC контейнера, остальные - как-то по-другому. Этот компромиссный вариант выглядит не плохо, в целом же, решение будет не идеальным. Так же можно попытаться выбрать IoC контейнер побыстрее, например, воспользовавшись ссылкой. В этих сравнительных тестах есть нюансы: в тестовых сценариях измеряется производительность огромного числа вызовов методов GetService или Resolve, которых в реальных сценариях должно быть совсем не много. "Скорость света" измерена не "в вакууме", так как для работы “без контейнерного” варианта используется некая структура данных, определяющая соответствие типов, использующихся для разрешения зависимостей, что в реальной жизни мало кто будет делать - всегда можно просто создать экземпляр этого типа, используя конструктор. Экземпляры типов возвращаются как Object, что приводит к лишним операциям приведения типа и отрицательно сказывается на производительности.
Практические все более-менее быстрые фреймворки используют какую-то генерацию кода, остановимся на одном из них. Основными целями создания IoC.Container были простота, гибкость, возможности расширения и, главное, скорость - минимизация накладных расходов по созданию множества экземпляров типов. Этот фреймворк использует Expression Trees при генерации IL кода для разрешения и внедрения зависимостей. Вот простой пример его использования:
- перед вами Кот Шрёдингера
- создадим абстракцию
interface IBox<out T> { T Content { get; } }
interface ICat { State State { get; } }
enum State { Alive, Dead }
- и реализацию для этой абстракции
class CardboardBox<T> : IBox<T>
{
public CardboardBox(T content) => Content = content;
public T Content { get; }
public override string ToString() { return "[" + Content + "]"; }
}
class ShroedingersCat : ICat
{
public ShroedingersCat(State state) { State = state; }
public State State { get; private set; }
public override string ToString() { return State + " cat"; }
}
-
- Package Manager
Install-Package IoC.Container
- .NET CLI
dotnet add package IoC.Container
- Package Manager
-
и "клей", который свяжет абстракцию с реализацией
class Glue : IConfiguration
{
public IEnumerable<IDisposable> Apply(IContainer container)
{
yield return container.Bind<IBox<TT>>().To<CardboardBox<TT>>();
yield return container.Bind<ICat>().To<ShroedingersCat>();
var rnd = new Random();
yield return container.Bind<State>().To(_ => (State)rnd.Next(2));
}
}
- пока наш "полезный" код не подозревает о наличии IoC.Container, используем "клей" и создаем экземпляр типа Program, внедряя зависимости через его конструктор
using (var container = Container.Create().Using<Glue>())
{
container.BuildUp<Program>();
}
- настало время открывать "коробки" большие и маленькие, с бантиками и без!!!
public Program(
ICat cat,
IBox<ICat> box,
IBox<IBox<ICat>> bigBox,
Func<IBox<ICat>> func,
Task<IBox<ICat>> task,
Tuple<IBox<ICat>, ICat, IBox<IBox<ICat>>> tuple,
Lazy<IBox<ICat>> lazy,
IEnumerable<IBox<ICat>> enumerable,
IBox<ICat>[] array,
IList<IBox<ICat>> list,
ISet<IBox<ICat>> set,
IObservable<IBox<ICat>> observable,
IBox<Lazy<Func<IEnumerable<IBox<ICat>>>>> complex,
ThreadLocal<IBox<ICat>> threadLocal,
ValueTask<IBox<ICat>> valueTask,
(IBox<ICat> box, ICat cat, IBox<IBox<ICat>> bigBox) valueTuple) { ... }
Важно отметить, что полезный код не знает ни чего о IoC.Container, а IoC.Container не влияет на полезный код. Для создания графа объектов с экземпляром типа Program в корне, IoC.Container создает и компилирует IL код эквивалентный:
new Program(new ShroedingersCat(), new CardboardBox<ShroedingersCat>(new ShroedingersCat()), ...);
который оказывает минимальное влияние на производительность создания графа объектов. Это небольшое влияние оказывается из-за того, что:
- какое-то время уходит на компиляцию кода из Expression Trees
- скомпилированный код находится в теле лямбда функции, на вызов которых тратится время
Нидже приведены результаты тестов для разных фреймворков под .net core 2.1
20 объектов и 1 синглтон 10 миллионов раз
IoC | Время (мс) |
---|---|
операторы new | 89 |
IoC.Container 1.1.10 в реальном сценарии | 175 |
IoC.Container 1.1.10 | 328 |
LightInject 5.1.8 | 392 |
DryIoc 3.0.2 | 412 |
Castle Windsor 4.1.0 | 4070 |
Unity 5.8.6 | 53600 |
Autofac 4.8.1 | 141500 |
Ninject 3.3.4 | 1350000 |
27 объектов 10 миллионов раз
IoC | Время (мс) |
---|---|
операторы new | 63 |
IoC.Container 1.1.10 в реальном сценарии | 106 |
IoC.Container 1.1.10 | 285 |
DryIoc 3.0.2 | 329 |
LightInject 5.1.8 | 441 |
Castle Windsor 4.1.0 | 3890 |
Unity 5.8.6 | 56800 |
Autofac 4.8.1 | 190800 |
Ninject 3.3.4 | 1734000 |
Здесь "реальный сценарий" - это сценарии, наиболее близкий к честному внедрению зависимостей. Результаты сравнительного тестирования и пример использования показывают, что эффективность современных IoC контейнеров позволяет использовать многие из них и в проектах с особыми требованиями к производительности, без усложнения кода, применяя TDD и/или используя модульные тесты.