Во второй части статей о Синглтоне разберем его плюсы и минусы, поговорим об уходе от классической реализации шаблона используя DI-контейнеры, а также познакомимся с шаблоном Ambient Context.
- Введение
- Реализации Синглтона
- Плюсы и минусы Синглтона
- Синглтон против внедрения зависимостей
- Шаблон Ambient Context
- Синглтон и утечки памяти
- Шаблон Monostate
Плюсы и минусы Синглтона
Главным достоинством шаблона является его основное предназначение — гарантия единственного объекта класса в приложении, что экономит как память так и процессорное время в случае создания дорогостоящего экземпляра. Также существуют классы, для которых наличие нескольких объектов в системе может привести к непредсказуемому поведению программы, либо противоречит требованиям предметной области, либо просто не имеет смысла. Вторым плюсом будет глобальный доступ через статическое свойство, что позволяет получить объект синглтон в любом месте приложения без необходимости постоянно внедрять его через конструкторы классов. Однако такой плюс одновременно является и главным минусом, так как возможность глобального доступа скрывает зависимости класса в которых используется синглтон (рассматривается дальше).
Другой минус синглтона в том, что он нарушает принцип инверсии зависимостей, что в свою очередь усложняет написание юнит-тестов. Синглтон представляет собой глобальное состояние, а значит написанный юнит-тест на метод внутри которого «сидит» синглтон уже будет не юнит, а интеграционным. На выполнение юнит-теста будет влиять внешний объект, состояние которого в добавок может изменяться и результаты выполнения тестов станут непредсказуемы. Но даже если синглтон не имеет состояния, его использование по прежнему мешает написанию тестов.
Элементарный пример:
public class Doer { public void Do() { Logger.Instance.Log("message"); } }
В конкретном примере Logger является синглтоном, который не имеет состояния. Однако в методе Do хардкодится имя класса Logger, что создает жесткую связь между двумя классами на этапе компиляции программы и делает невозможным подмену объекта Logger какой-нибудь заглушкой NullLogger, c целью написания изолированных юнит-тестов, запуск которых не будет приводить к бесполезной записи данных в файлы логирования.
Синглтон нарушает принцип единой ответственности. Согласно этому принципу на класс должна быть возложена только одна задача, однако у синглтона их будет минимум две: логика, гарантирующая единственный объект + основное предназначение класса (логирование, кеширование или для чего еще класс был создан). Такое нарушение делает невозможным повторное использование класса в другом контексте, в котором он уже не должен быть синглтоном. Решить проблему возможно, если делегировать отслеживание того, является ли объект класса единственным в системе другому специализированному на такой задаче классу, например DI-контейнеру.
Синглтон против внедрения зависимостей
Как упоминалось выше, глобальный доступ к синглтону одновременно является его достоинством и большим недостатком.
Классы взаимодействуют друг с другом. Когда нам нужно внутри некоторого объекта Logic использовать объект синглтон, например, Database, то обычно делается так:
public class Logic { public int Calculate() { var data = Database.Instance.GetData(); //... } public int CalculateAgain() { var data = Database.Instance.GetData(); //... } //... и еще раз 30 }
Если мы посмотрим на конструктор класса Logic (в нашем случае он даже не объявлен явно), тот не увидим ровным счетом никакой информации о его зависимостях и не получим полного представления о механике данного класса. Но когда мы как-то случайно узнаем, что Logic не такой уж и изолированный, то погрузимся построчно изучать детали его реализации, которая может насчитывать сотни строк кода. В таком случае говорят, что синглтон скрывает зависимости. Решить проблему скрытых зависимостей, продолжая использовать синглтон можно следующим способом: инициализировать абсолютно все зависимости класса в его конструкторе. В нашем случае будет выглядеть так:
public class Logic { private readonly Database _database = null; public Logic() { _database = Database.Instance; } public int Calculate() { var data = _database.GetData(); //... } public int CalculateAgain() { var data = _database.GetData(); //... } }
Такой подход позволит превратить неявные зависимости класса в более явные, так как уже достаточно одного взгляда на реализацию конструктора класса Logic для получения более точного представления о нем. Однако проблемы с тестируемостью класса Logic никуда не исчезли, ведь он, как и раньше завязан на глобальный синглтон Database. Для решения проблемы тестируемости мы можем выделить интерфейс IDatabase, удалить из класса Database всю логику, которая касается синглтон реализации, сделав его самым обыкновенным классом с публичным конструктором и внедрить его в конструктор класса Logic. Контроль над созданием единственного объекта возложим на DI-контейнер (пусть будет Autofac).
public class Logic { private readonly IDatabase _database = null; public Logic(IDatabase database) { _database = database; } }
Где-то в Composition Root проекта:
var builder = new ContainerBuilder(); builder.RegisterType<Database>() .As<IDatabase>() .SingleInstance(); //...
Одной из причин популярности механизма внедрения зависимостей через конструктор является его явность — посмотрев на конструктор класса Logic, можно быстро сообразить на какие зависимости он полагается, не изучая его реализации.
Теперь класс Logic превратился в тестируемый класс, устранена проблема скрытых зависимостей, класс Database лишился одной из обязанностей (может теперь он даже соблюдает SRP), а гарантия единственного объекта в системе возложена на того, кто должен этим заниматься — DI-контейнер.
Но теперь возникает вопрос: необходимо ли каждый существующий в проекте класс, реализующий классический синглтон рефакторить к DI-контейнеру для получения вышеперечисленных плюсов? Ответ: зависит от ситуации.
Существуют синглтоны, представляющие cross-cutting concerns приложения (логирование, хранение конфигурационных настроек или системного времени и прочие), которые дюжинами используются в различных слоях системы, таких как контролеры, сервисы, бизнес-логика, уровень доступа к данным и в десятках и десятках классов.
public class Product { private readonly ILogger _logger; private readonly ISystemTimeProvider _systemTimeProvider; private readonly IConfigSettings _configSettings; public Product( ILogger logger, ISystemTimeProvider systemTimeProvider, IConfigSettings configSettings) { _logger = logger; _timeProvider = timeProvider; _configSettings = configSettings; } }
Регулярным внедрением подобных зависимостей в конструкторы мы засорим огромное количество классов приложения одинаковыми списками параметров. Мы вернулись к тому с чего начинали: с одной стороны синглтон со своей глобальной точкой доступа хорош тем, что не засоряет параметры конструкторов, с другой — он не позволяет писать юнит-тесты. Для выхода из сложившейся ситуации, при которой нам необходимы как глобальный доступ к объекту, так и возможность юнит-тестирования, мы можем использовать шаблон проектирования под названием Ambient Context.
Шаблон Ambient Context
Шаблон Ambient Context в отличие от Синглтона не дает гарантию единственного объекта некоторого класса в системе. Контроль за тем, какой объект используется в определенный момент времени перекладывается на плечи программиста. Но преимущество Ambient Context’а в том, что в отличие от Синглтона он оперирует абстрактным типом, который инициализируется нужным объектом в Composition Root приложения или при старте веб-запроса, доступен всей системе через статическое свойство и может быть подменен во время выполнения программы.
public interface ILogger { void Log(string message); } public class FileLogger : ILogger { public void Log(string message) { //TODO: Write to a text file } } public class NullLogger : ILogger { public void Log(string message) { } } //Возможная реализация Ambient Context'a public class Logger { public static ILogger Current { get; private set; } public static void Init(ILogger logger) { Current = logger; } }
Осталось проинициализировать Logger конкретным объектом, желательно в Composition Root приложения
Logger.Init(new FileLogger());
После инициализации можно использовать логер в любом месте приложения, как используется классический синглтон
Logger.Current.Log("Message");
Перед запуском юнит-тестов достаточно подменить логер заглушкой
Logger.Init(new NullLogger());
Теперь любое обращение кода к свойству Current будет возвращать NullLogger, что позволит писать изолированные юнит-тесты.
Стандартная библиотека классов .NET обладает несколькими примерами использования шаблона Ambient Context, среди которых Thread.CurrentCulture, HttpContext.Current, Thread.CurrentThread и другие.
Подытожим вышесказанное о Синглтоне и Ambient Context’е:
- Синглтон и Ambient Context предоставляют глобальную точку доступа к некоторому объекту посредством статического свойства.
- Синглтон и Ambient Context удобно использовать когда приложение использует некоторый объект в десятках разных мест, так как нет необходимости постоянно внедрять его в конструкторы, засоряя их дублирующимися параметрами.
- Ambient Context != Единственный объект — Ambient Context не гарантирует наличие только одного объекта в системе до конца жизненного цикла домена приложения. Объект может подменяться программистом на этапе выполнения программы много раз, главное чтобы реализовывал необходимый интерфейс.
- Ambient Context в отличие от Синглтона не затрудняет написания юнит-тестов, так как оперирует абстракцией, а не конкретным типом.