С данного поста начнем разбираться с внедрениями зависимостей в .Net, так как данная тема является одним из обязательных к изучению для написания качественного, гибкого к изменениям и тестируемого кода. Начнем мы с первого
базового паттерна внедрения зависимостей — внедрение через конструктор. Итак, поехали!
Назначение
Разорвать жесткую связь между классом и его обязательными зависимостями.
Описание
Суть паттерна сводится к тому, что все зависимости, требуемые некоторому классу передаются ему в качестве параметров конструктора, представленных в виде интерфейсов или абстрактных классов.
Как можно гарантировать, что требуемая зависимость будет всегда доступна разрабатываемому классу?
Это обеспечивается, если все вызывающие классы будут передавать зависимость как параметр конструктора. Класс, требующий зависимость, должен иметь конструктор с модификатором доступа public (общедоступный), который получает экземпляр требуемой зависимости в качестве аргумента конструктора:
1 2 3 4 5 6 7 | private readonly IFoo _foo; public Foo(IFoo foo) { if (foo == null) throw new ArgumentNullException(nameof(foo)); _foo = foo; } |
Зависимость является обязательным аргументом конструктора. Код любого клиента, который не предоставляет экземпляра зависимости, не может компилироваться. Однако поскольку как интерфейс, так и абстрактный класс являются ссылочными типами, вызывающий код может передать в аргумент специальное значение null, что делает приложение компилируемым. Поэтому в классе делается проверка на null, которая защищает класс от такого некорректного использования. Поскольку совместная работа компилятора и блока защиты(проверка на null) гарантирует, что аргумент конструктора является корректным (если не возникла исключительная ситуация (Exception)), конструктор может просто сохранить зависимость для будущего использования, не выясняя детали реальной реализации .
Хорошей практикой является объявлять поле, хранящее значение зависимости, как «только для чтения» (Read-Only). Так мы гарантируем, что выполняется, причем только однажды, логика инициализации в конструкторе: поле не может быть модифицировано. Это не нужно для реализации внедрения зависимостей, но таким образом код защищается от случайных модификаций поля (например, от установки его значения в null) в каком-то другом месте кода класса.
Когда и как должно использоваться внедрение конструктора
Внедрение конструктора должно по умолчанию использоваться с внедрением зависимостей. Оно реализует наиболее популярный сценарий, когда классу нужны одна или более зависимостей, а в наличии не имеется подходящих локальных умолчаний.
Best practices:
- При возможности нужно ограничивать класс одним конструктором
- Перегруженные конструкторы провоцируют неоднозначности: какой конструктор должно использовать внедрение зависимостей?
- Не добавляйте в конструктор никакую другую логику
- Зависимость более нигде в классе не нужно проверять на null, поскольку конструктор гарантирует ее наличие
Внедрение гарантировано | В некоторых фреймворках сложно задействовать внедрение конструктора |
Простота реализации | Требование немедленной инициализации всего графа зависимости (*) |
Обеспечение четкого контракта между классом и его клиентами (проще думать о текущем классе, не задумываясь о том, откуда берутся зависимости у более высокоуровневого класса) | — |
Сложность класса становится очевидно | — |
(*)Очевидным недостатком внедрения конструктора является требование немедленной инициализации всего графа зависимости — зачастую уже при запуске приложения. Тем не менее, хотя и кажется, что этот недостаток снижает эффективность системы, на практике он редко становится проблемой. Даже для сложных графов объектов создание экземпляра объекта — это действие, которое .NET фреймворк выполняет чрезвычайно быстро. В очень редких случаях эта проблема может оказаться действительно серьезной. Тогда воспользуемся параметром жизненного цикла, называемый Delayed (отложенный), который вполне подходит для решения этой проблемы.
Потенциальной проблемой использования конструктора для передачи зависимостей может быть чрезмерное увеличение параметров конструктора.
Другой причиной большого количества параметров конструктора может быть то, что выделено слишком много абстракций. Такое положение дел может свидетельствовать, что мы начали абстрагироваться даже от того, от чего абстрагироваться совсем не нужно: начали делать интерфейсы для объектов, которые просто хранят данные, или классов, чье поведение стабильно, не зависит от внешнего окружение и явно должно скрываться внутри класса, а не выставляться наружу.
Примеры использования
Внедрение через конструктор (Constructor Injection) является базовым паттерном внедрения зависимостей и он интенсивно применяется большинством программистов, даже если они об этом не задумываются. Одной из главных целей большинства «стандартных» паттернов проектирования (GoF паттернов) является получение слабосвязанного дизайна, поэтому неудивительно, что большинство из них в том или ином виде используют внедрение зависимостей.
Так, декоратор использует внедрение зависимости через конструктор; стратегия передается через конструктор или «внедряется» нужному методу; команда может передаваться в качестве параметра, или же может принимать через конструктор окружающий контекст. Абстрактная фабрика зачастую передается через конструктор и по определению реализуется через интерфейс или абстрактный класс; паттерн Состояние принимает в качестве зависимости необходимый контекст и т.д.
Два примера, демонстрирующих применение внедрения конструктора в BCL, используют классы System.IO.StreamReader и System.IO.StreamWriter.
Оба они получают экземпляр класса System.IO.Stream в конструктор.
1 2 | public StreamWriter(Stream stream); public StreamReader(Stream stream); |
Класс Stream — это абстрактный класс, выступающий в роли той абстракции, с помощью которой выполняют свои задачи StreamWriter и StreamReader. Вы можете передать любую реализацию класса Stream в их конструкторы, и они будут использовать ее. Но если вы попытаетесь передать в конструктор в качестве Stream значение null, будет генерироваться ArgumentNullExceptions.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // Декораторы var ms = new MemoryStream(); var bs = new BufferedStream(ms); // Стратегия сортировки var sortedArray = new SortedList<int, string>( new CustomComparer()); // Класс ResourceReader принимает Stream Stream ms = new MemoryStream(); var resourceReader = new ResourceReader(ms); // BinaryReader/BinaryWriter, StreamReader/StreamWriter // также принимают Stream через конструктор var textReader = new StreamReader(ms); // Icon принимает Stream var icon = new System.Drawing.Icon(ms); |
Вывод
Независимо от того, используете ли вы DI контейнеры или нет, внедрение через конструктор (Constructor Injection) должен быть первым способом управления зависимостями. Его использование не только позволит сделать отношения между классами более явными, но также позволит определить проблемы с дизайном, когда количество параметров конструктора превысит определенную границу. К тому же, все современные контейнеры внедрения зависимостей поддерживают данный паттерн.