среда, 21 ноября 2012 г.

Управление зависимостями

Приложите все усилия для того, чтобы вы управляли зависимостями в приложении, а не зависимости управляли вами.
Мнение автора статьи по поводу зависимостей

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

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

Все известные ООП-шные принципы, включая Принцип Единой Ответственности, понятия связности и связанности (cohesion и coupling) и многие другие, призваны бороться с неотъемлемой сложностью (essential complexity) нашей бизнес-области и сводить случайную сложность (accidental complexity) к минимуму. Все наши продуманные абстракции, хитроумные паттерны и высокоуровневые языки программирования призваны акцентировать внимание на естественной сложности задачи, скрывая на «уровень ниже» несущественные подробности без которых можно обойтись.

Стабильные и изменчивые зависимости

У каждого из нас есть интуитивное представление о том, что является зависимостью и что ею не является. Так, например, с параноидальной точки зрения любая сущность, используемая нашим классом, является зависимостью, включая примитивные типы языка программирования и другие типы стандартной библиотеки. С другой стороны, очевидно, что даже в стандартной библиотеке есть классы, использование которых напрямую может «завязать» наш класс слишком тесно с некоторым окружением и сделает невозможным, например, модульное тестирование.

Марк Cиман (Mark Seeman) в своей книге “Dependency Injection in .NET” именно по этой причине выделяет два типа зависимостей: с одной стороны у нас есть стабильные зависимости (stable dependencies), «абстрагироваться» от которых нет особого смысла, поскольку они доступны «из коробки», являются стандартом «де факто» в вашей команде и их поведение не меняется в зависимости от состояния окружения.

Кроме этого, есть изменчивые зависимости (volatile dependencies) реализация (или даже публичный интерфейс) которых может измениться в будущем, либо их поведение может измениться «самостоятельно» без нашего ведома, поэтому их нужно выделить в отдельную абстракцию и ограничить их использование. К такому классу зависимостей относятся третисторонние библиотеки, собственный код, область применения которого хорошо бы ограничить. К этому же типу зависимости относятся зависимости, завязанные на текущее окружение, такие как файлы, сокеты, базы данных и так далее. И хотя интерфейс таких зависимостей едва ли изменится в будущем, их «изменчивость» проявляется в том, что их поведение может измениться без изменения пользовательского кода.

СОВЕТ
На самом деле, ограничение использования любой зависимости является идеальным способом борьбы с изменениями. Ведь чем в меньшем количестве мест в вашем коде используется некоторый класс или интерфейс, тем меньше стоимость его изменения или ошибки в его реализации. Если вы считаете, что нечто может измениться в будущем, то сделайте это деталью реализации, сведя, таким образом, область изменений к минимуму.

И хотя названия этих двух типов зависимостей с первого взгляда может показаться не вполне точным, лично меня эти термины вполне устраивают. Главное, чтобы это разделение было у нас в голове: есть зависимости, от которых нужно абстрагироваться, а есть такие – которые можно использовать напрямую и в этом ничего страшного!

Кроме того, одну и ту же зависимость (например, все тот же FileStream) можно в одном контексте рассматривать как стабильную, а в другом – как изменчивую. С другой стороны, само использование класса FileStream говорит о некотором «персистентном» хранилище или чем-то подобным. Возможно, разумнее будет абстрагироваться не просто от файловых операций, а выделить некоторую бизнес-сущность, типа IConfigurationLoader и «протаскивать» уже ее, а не низкоуровневые сущности, типа IStream или кастомный IFileStream.

Зачем выделять зависимости?

Каждый раз, когда мы выделяем зависимость в самостоятельную абстракцию, мы преследуем определенную цель (или несколько целей).

В-первых, путем выделения зависимости мы можем повысить уровень абстракции и оградить весь остальной код от ненужных подробностей. Для этого, например, мы можем выделить отдельные интерфейсы/ классы для доступа к удаленным сервисам, слою доступа данных и т.п. Мы просто хотим думать не в терминах реализации (WCF, NHibernate), а в терминах абстракции (AbstractService, CustomRepostory). Не нужно быть гуру в современных новомодных принципах разработки, чтобы прийти к выводу, что подобные операции стоит спрятать куда-нибудь поглубже и не размазывать их использование ровным слоем по всему приложению.

Во-вторых, возможно мы хотим получить «конфигурируемость» поведения путем использования паттернов «Стратегия», «Абстрактная фабрика» и тому подобных. Т.е. мы с самого начала знаем, что у нас будет несколько реализаций и создавать соответствующий дизайн с самого начала.

ПРИМЕЧАНИЕ
Марк Сииман в своей книге приводит очень интересное наблюдение, подтвержденное и моей собственной практикой. Многие разработчики и архитекторы забывают, что у любого решения есть и обратная сторона и гибкость в этом вопросе не исключение. На самом деле, не такое уж много приложений требует гибкой настройки своего поведения путем переконфигурирования приложения. Обычно повторное развертывание приложение с заменой текущей реализации может быть достаточно «гибким» решением, особенно когда это развертывание происходит на собственных серверах. Поэтому прежде чем закладываться на «конфигурябельность» всего и вся подумайте о том, насколько это вам действительно нужно.

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

Интерфейс != слабосвязанный дизайн

В последнее время мода использования интерфейсов стала настолько популярной, что очень часто их начинают выделять «автоматом», считая это залогом «слабосвязанного дизайна» и тестируемости приложения. И хотя правильное выделение зависимостей и использование абстракций (интерфейсов и абстрактных классов), а не конкретных реализаций (конкретных классов) и правда приводит к слабой связанности (low coupling) чрезмерное их использование может привести к обратному результату.

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

Скотт Мейерс (гуру С++) самым важным принципом проектирования считает следующий: ваш класс или модуль должно быть легко использовать правильно и сложно использовать неправильно. Классом же с десятком параметром правильно пользоваться весьма сложно; точнее достаточно просто пользоваться в вашем давно отконфигурированном приложении с использованием DI контейнеров, но создать и «угодить» ему с нуля – дело совсем не простое.

Именно поэтому использование сервис локатора или внедрение обязательных зависимостей через свойства являются антипаттернами. В этом случае невероятно просто использовать класс неправильно и сложно использовать правильно.

Сам факт наличия у класса большого количества зависимостей само по себе говорит о проблемах с дизайном и, либо о нарушении принципа Единственной Ответственности и нечетком контракте класса, либо неправильной группировке операций по интерфейсам, либо о чрезмерной гибкости, которая, возможно, так и не понадобилась.

Даже если ваш класс завязан лишь на абстракции (т.е. на интерфейсы) – это не значит, что связанность отсутствует, просто вместо явной зависимости от конкретных реализаций, вы получили неявную зависимость (implicit coupling) на множество «абстракций», на порядок их создания, на их состояние в момент использования, на определенные «детали» их реализации, на стабильность их интерфейсов, что делает сложным понимание того, «как же вся эта хрень работает».

Выделение зависимости в интерфейс добавляет в приложение «шов» (seam) благодаря которому мы можем «вклиниться» в существующее поведение. Подобные швы дают определенную гибкость, но делают это не бесплатно – если швов слишком много, то мы получаем слишком сложное решение, более сложное в понимании и сопровождении.

Заключение

Зависимости сами по себе не являются чем-то плохим, пока они используются в ограниченном количестве мест, позволяют легко сопровождать код и писать для него юнит тесты, и основной способ борьбы с ними сводится не столько к выделению интерфейсов, сколько к ограничению их области применения. При этом помните, что параноидальное выделение интерфейсов и протаскивание их десятками с помощью контейнеров не делает дизайн слабсвязанным и понятным. Код остается формально тестируемым, но стоимость его сопровождения (и стоимость сопровождения тестов) будет не меньше, чем исходный якобы «монолитный» дизайн с мелкими конкретными классами.

Прагматизм, как всегда рулит, поэтому не стоит доводить ваше приложение до франкен-дизайна, делая десятки швов там, где без этого можно обойтись.

В следующий раз мы перейдем к практическим аспектам управления зависимостями, поговорим о роли контейнеров, о тестируемости без выделения интерфейсов и о многом другом.

22 комментария:

  1. Спасибо за статью. И раньше задумывалась, что пробрасывание интерефейсов пачками в конструктор не есть хорошо. Вы подтвердили мои сомнения.

    ОтветитьУдалить
  2. Если коротко , то сложность это плохо! С этим никто не спорит. Но франкен-дизайн это меньшее зло. Преобразуемое зло. Хуже когда зависимости добавляются с помощью наследования - отражая эволюцию мысли программиста. Франкен-дизайн с помощью решарпера и какой-то матери преобразуется без охвата всей картины - те без свопа. Охватить 20 - 50 зависимостей несложно если они достаточно самостоятельные. По сложности это как переосмыслить чужое косноязычное предложение во что то более удобоваримое.
    Такой дизайн дает шанс программисту переосмыслить идею с меньшими потерями, выделить сущности для переиспользования, увидеть как стыкуются требования задачи с требованиями к коду. Это просто дизайн для не ленивых, дизайн который в конечном итоге, с достаточной долей вероятности, приведет к предложениям составленным из слов, а не предлогов,междометий, и мата как часто бывает

    ОтветитьУдалить
  3. Я не говорю, что интерфейсы - это плохо, я призываю к тому, что это далеко не единственный способ получения слабосвязного (а значит и сопровождаемого дизайна).

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

    Я все к тому, что не нужно рассматривать этот подход, как панацею. Да и франкен-дизайн - это же по сути спагетти код, только в контексте дизайна, а не кода.

    Здравый смысл, как всегда, рулит. И если можно вместо протаскивания четырех интерфейсов воспользоваться наблюдателем, передачей call-back-а или выделением дополнительной "абстракции", то я бы поступил именно так.

    Мой призыв - думать, а не следовать определенным шаблонам бесдумно, только потому, что кто-то говорит, что это правильно.

    ОтветитьУдалить
  4. Не флейма ради, но вот callbackи и наблюдатели зачастую делают код довольно загадочным для исследователя.

    Наверное наболевшее у нас разное :)
    Я пытаюсь своих программистов научить генерить чистенький франкен-дизайн т.к убедился в том что с первого раза и красиво получается только случайно или в очень простых случаях.

    ОтветитьУдалить
  5. В-первых, путем выделения зависимости мы можем повысить уровень абстракции и оградить весь остальной код от ненужных подробностей. Для этого, например, мы можем выделить отдельные интерфейсы/ классы для доступа к удаленным сервисам, слою доступа данных и т.п. Мы просто хотим думать не в терминах реализации (WCF, NHibernate), а в терминах абстракции (AbstractService, CustomRepostory). Не нужно быть гуру в современных новомодных принципах разработки, чтобы прийти к выводу, что подобные операции стоит спрятать куда-нибудь поглубже и не размазывать их использование ровным слоем по всему приложению.

    С NHibernate мне больше подход когда в бизнес логике мы можем использовать все возможности NHibernate через QueryOver/Linq, повторяющиеся запросы выносим в Extension методы. В этом случае не нужно плодить методы (в интерфейсах и реализации) для запросов отличающихся незначительно, также можем определить какие связные сущности загружать, и пр.
    (Ayende писал об этом: http://ayende.com/blog/4784/architecting-in-the-pit-of-doom-the-evils-of-the-repository-abstraction-layer).

    Чтобы в полной мере использовать возможности какой-либо технологии (NHibernate/WCF) через промежуточный слой (AbstractService, CustomRepostory) приходится практически полностью повторять ее интерфейсы. Второй вариант: не вытаскивать подбробности и возможности технологии в интерфейсы, аргументом в пользу этого приводят то, что технологию можно будет заменить безболезненно, но на практике это оказывается только теорией.

    Самая сильная мотивация для абстрагирования от технологий, как мне кажется, это возможность юнит тестирования.

    Опять таки все сводится: нужно думать в каждом конкретном случае, а не слепо следовать патернам.

    Сергей, спасибо за интересные темы.

    ОтветитьУдалить
  6. Раиль, сорри если не в тему. Однако, мне кажется здесь имеется в виду, что предположим класс, который использует МНОГО зависимостей, использует их возможности на x%, где x много меньше 100. Т.е. лучше выделить несколько зависимость в отдельный класс, который реализует интерфейс, который нужен в исходном классе. И в этот исходный класс уже протаскивать этот выделенный интерфейс. В этом случае мы получаем "правильную" зависимость при использовании внутри класса. Класс становится намного понятнее в понимании, чем при исходном случае. Есть правда и минус при таком подходе - растет иерархия классов. Но это каждый сам принимает решение, что ему важнее.

    ОтветитьУдалить
  7. Извиняюсь что комент не по теме заметки, но не смог найти твой мейл и поэтому пишу сюда.
    Сейчас много сказано о юнит тестировании и в твоих публикациях тоже. Но вот такой вопрос не вижу нигде.
    Хочу протестить бизнес-логику. Она многослойная. Верхний уровень слой Command, который полностью
    инкапсулирует вызов конкретного обращения с визуальной формы к бизнес-логике. Из Command идёт обращение к слою Model,
    который содержит методы бизнес-логики. Из Model идёт обращение к слою DAO, который работает с данными в бд.
    Как построить юнит-тест, который через обращение к Command сразу бы мог протестировать и нижележащие слои?
    Думаю это интересная тема для широкого обсуждения.

    ОтветитьУдалить
  8. Так то, все верно изложили. Прагматизм - рулит. Но удержаться на грани между прагматизмом и фанатизмом бывает не то, чтобы крайне сложно, а невозможно. Да только из-за того, что совершенно не знаешь, что будет с продуктом дальше. Какую еще свистоперделку выдумают прожект-лиды. Каким хитрым способом придется решать поставленную задачу. Поэтому как крайность проектируешь на 100 вариантов развития проекта вперед. Как другая крайность забиваешь и делаешь в лоб, топорно как в первом классе, зато быстро. И так если дизайнерский-спагетти код оборачивается сложностью понимания системы целиком в случае изменений, то второй сложностью изменения впринципе.

    Не угодаешь в общем

    ОтветитьУдалить
  9. Kain> вот callbackи и наблюдатели зачастую делают код довольно загадочным для исследователя.

    Kain> Наверное наболевшее у нас разное :)

    Да, обилие делегатов - это страх и ужас! Так что с этим я согласен. Под нормальным наблюдателем я понимаю следующее: очень часто удобно рассматривать класс, как нечно с определенным набором входов и выходов. При этом под входами понимаются открытые методы класса, а под выходом - все, что нужно дернуть ему для завершения его работы. Под "выходной" интерфейс попадают все зависимости, callback-и и т.п.

    class Foo
    -> DoSomething
    -> DoSomethingElse
    <- ReadFromExternalStorage
    <- AskConfirmationFromUser

    При этом то, как мы выразим "выходной" интерфейс сильно зависит от того, какие зависимости у нас есть в наличии и можем ли мы их использовать (например, классы/интерфейсы зависимостей уже есть, но находятся в другой сборке).

    Поэтому для реализации выходного интерфейса мы можем воспользоваться интерфейсом IFooObserver, набором событий, набором делегатов или прямым использования зависимостей.

    В результате мы получаем более четкое отделение слоев приложения и их независимость, что, прежде всего, делает код простым (вмеру), и гибким (ибо он простой).

    В общем, я стремлюсь к разумной гранулярности и автономности классов; в результате получается набор кирпичиков из которых сложить домик довольно просто. И, что более важно, его легко переделать в случае изменения требований.

    Думаю, что это совпадает с вашей идеей франкен-дизайна, только я далеко не для каждого случая выделяю интерфейсы, ибо часть зависимостей нативно становятся стабильными (от них просто нет смысла отвязываться).

    ОтветитьУдалить
  10. @Раиль

    > Самая сильная мотивация для абстрагирования от технологий, как мне кажется, это возможность юнит тестирования.

    Это одна из мотиваций. Другая - использование фасадов. Просто с ними проще, чем со всеми низкоуровневым подробностями. Хотя и доля истины есть в том, что это не всегда нужно.

    > нужно думать в каждом конкретном случае, а не слепо следовать патернам.

    Это точно. Все зависит от типа приложения. Для data-centric приложения полное абстрагирование от DAL-а себя наверняка не оправдает, а вот там, где его немного, это может быть оправдано.

    ОтветитьУдалить
  11. @Alex:

    > Как построить юнит-тест, который через обращение к Command сразу бы мог протестировать и нижележащие слои?

    Если у тебя не просто многослойная архитектура, но она еще и удовлетворяет принципу инверсии зависимостей (Dependency Inversion Principle), то с этим проблем быть недолжно, поскольку каждый уровень ничего не знает о реализации нижнего уровня, и зависит лишь от интерфейсов, которые легко можно замокать.

    Но у меня чуйка, что судя по фразе "сразу бы мог протестировать и нижележащие слои" есть некоторое непонимание того, что такое модульный тест. Модульный тест тестирует лишь один слой, и не должен тестировать нижележащие слои, ибо в этом случае мы получить уже не модульный тест, а интеграционный (что тоже неплохо, просто нужно различать эти понятия).

    ОтветитьУдалить
  12. @Павел:

    > Да только из-за того, что совершенно не знаешь, что будет с продуктом дальше. Какую еще свистоперделку выдумают прожект-лиды. Каким хитрым способом придется решать поставленную задачу.

    Об этом я довольно много, кажись, писал. Мой подход - мелкая гранулярность сущностей и риск менеджмент в виде спрятывания самых стремных моментов в деталях реаилизации. Ну и слабая связанность - всемирный залог успеха тестабильности и сопровождаемости.

    ОтветитьУдалить
  13. Мысль про выходные интерфейсы класса не совсем понятна. Про входной интерфейс всё понятно - это открытые методы класса. Выходной интерфейс - это все обращения к методам и свойствам других классов.
    Не совсем понимаю как реализации выходного интерфейса могут помочь обозреватель, набор событий или набор делегатов. Плз дай какой-нибудь простой примеру в тему.

    ОтветитьУдалить
  14. >спрятывания самых стремных моментов в деталях реаилизации

    WUT? Стремные моменты должны торчать наружу, если я правильно понял фразу, чтобы в случае ошибки не пришлось продираться сквозь слои в поисках проблемы

    ОтветитьУдалить
  15. Константин, я имел ввиду как раз то, что стремные моменты не страшны, если это лишь деталь реализации.

    Если интерфейс модуля четко определен, то плохое решение внутри этого модуля ограничено лишь его границами. Мы сможем его улучшить не трогая наших клиентов.

    Тут вопрос в "оценке ущерба" от го#%&-кода: если это захардкодженные константы в одном методе класса - это одно дело, а если у нас плохо продуманный интерфейс между модулей - то это совсем другое дело. В первом случае мы можем легко улучшить решение, а во втором случае у нас уже может быть куча внешних клиентов у модуля, без ведома которых мы не можем вносить такие изменения.

    ОтветитьУдалить
  16. @Alex: Давайте рассмотрим пример с вью-моделькой, которая по кнопке ОК (или команде OkCommand) должна провалидировать состояние и сохранить в постоянное хранилище.

    Входным интерфейсом это вью-модели мы можем считать EmployeeModel, например, а "выходным" - пару "методов" - ValidateCurrentState и SaveToStorage.

    При этом мы можем реализовать это разными способами.

    1. Выделяем две зависимости: IValidator и IStorage, и протаскиваем их через конструктор.

    2. Используем EmployeeViewModelObserver с двумя методами: ValidateCurrentState и SaveToStorage.

    3. Протащить 2 делегата через конструктор: validateCurrentState и saveToStorage.

    (если нужен пример кода, то я его тоже приведу).

    При этом первое решение завязано на некоторые известные зависимости, а два последних решений являются вариацией паттерна наблюдатель (хотя это и не вполне классический пример).

    ОтветитьУдалить
  17. Этот комментарий был удален автором.

    ОтветитьУдалить
  18. Сергей, спасибо за статью. По поводу "ограничения области применения", задача из реального проекта:
    В проекте порядка 30 web-приложений (Java), и есть сервис (Java класс) загрузки файлов. Этот сервис должен вызываться из каждого приложения (вызов 1-2 методов). Сервис, в данном случае, не является стабильной зависимостью (вполне возможно изменение его интерфейса). Посоветуйте, как ограничить его область применения, чтобы не пришлось переделывать все 30 приложений при изменении этого сервиса?

    ОтветитьУдалить
  19. Я бы предложил придумать новый сервис с более стабильным контрактом (интерфейсом из методов, свойств и т.д.). А далее старый изменчивый сервис спрятать в новый стабильный. Т.о. если что-то и поменяется в старом его использование будет располагаться в одном месте и может быть легко скорректировано.

    ОтветитьУдалить