Шаблоны проектирования: Singleton, Часть 2

Во второй части статей о Синглтоне разберем его плюсы и минусы, поговорим об уходе от классической реализации шаблона используя DI-контейнеры, а также познакомимся с шаблоном Ambient Context.

Плюсы и минусы Синглтона

Главным достоинством шаблона является его основное предназначение — гарантия единственного объекта класса в приложении, что экономит как память так и процессорное время в случае создания дорогостоящего экземпляра. Также существуют классы, для которых наличие нескольких объектов в системе может привести к непредсказуемому поведению программы, либо противоречит требованиям предметной области, либо просто не имеет смысла. Вторым плюсом будет глобальный доступ через статическое свойство, что позволяет получить объект синглтон в любом месте приложения без необходимости постоянно внедрять его через конструкторы классов. Однако такой плюс одновременно является и главным минусом, так как возможность глобального доступа скрывает зависимости класса в которых используется синглтон (рассматривается дальше).

Другой минус синглтона в том, что он нарушает принцип инверсии зависимостей, что в свою очередь усложняет написание юнит-тестов. Синглтон представляет собой глобальное состояние, а значит написанный юнит-тест на метод внутри которого «сидит» синглтон уже будет не юнит, а интеграционным. На выполнение юнит-теста будет влиять внешний объект, состояние которого в добавок может изменяться и результаты выполнения тестов станут непредсказуемы. Но даже если синглтон не имеет состояния, его использование по прежнему мешает написанию тестов.

Элементарный пример:

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 { getprivate 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 в отличие от Синглтона не затрудняет написания юнит-тестов, так как оперирует абстракцией, а не конкретным типом.

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s