О том и о сём
В битве за хорошую архитектуру и правильное проектирование сломано немало копий. Каждый (или почти каждый) фанатик разработчик рано или поздно начинает задумываться над вечными философскими вопросами. Разделен ли мир код на дух и материю на классы и интерфейсы, а если да, то что такое дух и что такое материя хорошо ли и правильно ли разделен? Что первично: дух или материя абстракция или детали? И что такое архитектура и зачем она нужна (кому нужна и вообще нужна ли)? А может и нет ее, этой правильной uber-архитектуры? Как с зелеными человечками: вроде и попадаются люди, которые утверждают, что видели её и даже прикасались к ней, но все-таки чаще встречаются разработчики, которые (в разной мере) не удовлетворены кодом, с которым они работают или который пишут.
Собственно, попробуем поразмыслить над этими и некоторыми другими вопросами. В данной статье не будет громких поучений и заявлений о том, как правильно (“Я Д’Артаньян!”), или критики (“А они – невежественные гвардейцы кардинала!”). Просто несколько мыслей о разном по теме объектно-ориентированного дизайна, которые кому-нибудь, возможно, покажутся очевидными и прописными истинами, известными с детского сада, а кому-нибудь чем-то да и помогут.
О костылях и хорошей архитектуре
Мы строили, строили и наконец построили!
Существо с огромными ушами,
большими глазами и коричневой шерстью,
ходящее на задних лапах
(утром перед дедлайном)
Пожалуй, любой программный продукт можно выпустить и без наличия хорошей архитектуры. Серьезно. Продукт, состоящий из спагетти да хаков, теоретически может вполне эффективно выполнять свою функцию, а с учетом хорошего тестирования – и практически. Его даже можно будет поддерживать и развивать: пусть и с трудностями, но внесение изменений возможно. Ну а пользователей не интересуют внутренности продукта – им нужен набор функций, желательно стабильный.
По всей вероятности, понятие хорошая архитектура, следует определять именно из реальной пользы, которую она приносит, а не из абстрактных красивостей и надуманных проблем. Что же было бы, если бы продукт изначально обладал хорошей архитектурой? Его было бы легче сопровождать. Изменения вносились бы быстрее, легче и безопаснее. Это выгодно и разработчикам, и компании, и пользователям. Итак, что, по идее, должна давать хорошая архитектура? Список очевидностей:
-
Эффективность выполнения поставленных задач. Если спроектированная супер-красивая архитектура плохо выполняет поставленные задачи, не укладывается в требования по функционалу, стабильности, производительности и срокам разработки, то её вряд ли можно назвать хорошей.
-
Хорошо читаемый и понятный код. В первую очередь, код должен быть понятен. Пусть в вашей архитектуре нету дробления на 100500+ классов, но если код хорошо читается, не содержит дублирования, позволяет повторно использовать компоненты и вполне структурирован, – это главное.
-
Масштабируемость системы. Если ваша архитектура позволяет безболезненно добавлять новые сущности и функции – это хорошая архитектура.
-
Гибкость системы. Если ваша архитектура позволяет безболезненно вносить изменения в существующий функционал – это хорошая архитектура.
Лучшей/хорошей/приемлемой архитектурой будет та, которая будет удовлетворять перечисленным требованиям. Даже если она написана не по канонам, даже если она специфична, и содержит костыли, и ничего не знает об общепринятых практиках. Кстати, очевидно, что любая созданная архитектура не является “бесконечно резиновой”: если в один прекрасный момент требования изменятся чересчур кардинально, то придётся переделывать безупречный до этого дизайн и вставлять хакы. Поддержание хорошего кода и хорошей архитектуры позволяет быстро, успешно и эффективно справляться с наиболее вероятными изменениями, которые укладываются в существующую концепцию. Проекту легче оставаться “на плаву”.
Плохая архитектура, какой бы “блестящей” она не была, не позволит быстро и легко вносить изменения. Если невозможно без существенных изменений поменять что-то в проекте – тупик. И тут есть две яркие крайности.
Во-первых, когда код целиком состоит из запутанного спагетти. Тут все очевидно: код непонятен, его сложно менять, нельзя хорошо протестировать из-за огромного количества связей и состояний системы – очевидно, насколько это все плохо.
Во-вторых, когда вследствие слепого следования подходам код написан с использованием огромного количества шаблонов, паттернов, абстракций, он опять-таки становится чересчур сложным для понимания и поддержки. Куда эффективнее вставить один костыль в 5 строчек кода, чем городить 5 уровней абстракций и 5 новых классов, что с большей вероятностью ухудшит сопровождаемость кода. Ситуация отлично проиллюстрирована, например, здесь:
“Вам покажут просто гигантский список множественного наследования кучи классов и, кхм, многопоточных подразделений COM. И ваше сознание уплывает, и вы перестаёте понимать, о чём, чёрт возьми, болтает этот хмырь. ... И когда вся конструкция с треском рухнет, именно вас посреди ночи попросят прийти и разобраться, потому что он уже будет на какой-нибудь долбанной конференции по паттернам проектирования”.
Что касается рефакторинга, то это, безусловно, отдельная и сложная тема. Проводить рефакторинг постепенно, маленькими кусочками – тяжело, провести его одним махом – затратно, долго и чревато новыми и старыми багами.
В общем, если есть возможность, лучше сразу на старте немного подумать об архитектуре и не ухудшать её впоследствии, касаясь кода. Это будет хорошим подспорьём в работе, можно сказать – полезным инструментом, здорово облегчающим жизнь.
О многообразии видов
Их всего лишь раз в пятьдесят больше, чем нас!
Царь Леонид
Чтобы достичь вышеописанной благодати, на сегодняшний день придумали целый сонм идей, принципов, подходов, методов, приемов, паттернов и правил проектирования объектно-ориентированного кода. Это и всем известные паттерны GoF, и менее известные паттерны GRASP, и принципы SOLID, схемы MVC/MVP/MVVM, подходы контрактного программирования, аспектно-ориентированного программирования и много чего другого. Этот зоопарк усугубляется влиянием каких-то общих принципов (KISS, DRY, YAGNI, Worse is better, etc), влиянием других парадигм программирования, отличных от объектной (функциональной, параллельной, событийной, реактивной, etc), различных методологий и процессов разработки (например, TDD/BDD), алгоритмики и ещё бог знает чего.
Данный heap достаточно тесно взаимосвязан, в немалой мере перекликается между собой, и, пожалуй, не имеет совсем уж четкой уровневой структуры. Зачастую идеи оперируют схожими постулатами, а методологии, парадигмы и их реализации оказывают влияние друг на друга, хотя при этом некоторые подходы хороши только для решения каких-то одних конкретных и очевидных задач, но затрудняют решение других, другие же являются общими и абстрактными.
Только от разработчика зависит, какие подходы и принципы он знает и насколько эффективно он умеет их применять. На мой взгляд, самое важное – это понимание основ и умение ими оперировать. Слишком общие принципы могут трактоваться достаточно вольно (а иногда и противопоставляться друг друг, как, например, MIT и Worse is Better). Напротив, конкретные паттерны прекрасно служат в качестве эффективных примеров для иллюстрации понятий “хорошо”&“плохо” и способов применения общих принципов на рафинированных типовых задачах, но они не должны применяться “в лоб”: в реальности дела обстоят чуть сложнее.
Слепое следование и использование вышеперечисленного умозрительного инструментария без осознавания, понимания причин появления и следствий, его места и роли приводит к коду, который будет очень тяжело поддерживать, менять и понимать.
Приводит, например,
к Singleton-ориентированной архитектуре (зато архитектура, что не так-то?),
или к "впихновению невпихуемого" – попыткам решения всех задач методами одного выученного подхода, который ещё может быть понят или принят не до конца,
или к вколачиванию в проект громоздких специфических супер-библиотек-фреймворков, которые убьют всех воробьёв ракетно-лазерным ударом с орбиты, а впоследствии попросят прислать космонавта на орбиту для устранения неполадки,
или к вольной трактовке KISS,
или к dependency injection через god-uber-locator,
или к появлению длинных цепочек наследования (fragile base class),
или к “наслоению слоёв” классов и интерфейсов, которые впоследствии становятся весьма “луковыми”,
или к гигантским контроллерам в MVC,
или к разным другим злым вещам, которые не позволяют архитектуре удовлетворять и радовать разработчика при внесении изменений (см. О костылях и хорошей архитектуре).
Таким образом, для разработки эффективного решения неплохо было бы опираться на критерии хорошей архитектуры ( выгоду конкретной реализации для нас) и на основы объектно-ориентированного дизайна, которые дадут ключ ко всем остальным подходам. Это позволит увеличить вероятность (но ни в коем случае не является ни гарантией, ни обязательным условием) получения архитектуры, которая будет помогать в процессе разработки, а не вставлять палки в колёса. Про критерии пару слов уже было сказано, перейдём к основам.
Об основах
Меньше знаешь – крепче спишь.
Осьминог Пауль
Что же лежит в основах?
Глядя на “три кита” ООП, на подходы GoF/SOLID/GRASP/etc, очевидно, что главное, что можно выделить – это сущности (классы и интерфейсы) и связи между ними (неожиданно для ООП, правда?). Собственно, если это главное, значит, при проектировании мы и должны опираться на сущности и связи. Правильный набор сущностей и связей должен в идеале давать весьма удобный для разработки проекта конструктор.
Сущности. Выделенные сущности должны быть цельными, сфокусированными, иметь и выполнять одну обязанность (или небольшое семейство обязанностей), между своими составляющими иметь сильную связность (иметь сильное сцепление, т.е. не содержать инородных элементов для выполнения чужих обязанностей). Если не доводить до абсурда, определённый набор классов и интерфейсов даст понятный и выразительный код.
Итак, сущности (классы) выделены, все красиво растусованно по компонентам. Но вот беда: пришел change-request и вместо одной локальной правки нам приходится менять добрый десяток файлов (привет, жесткость, хрупкость и неподвижность!). Почему так? Причина в сильной связанности многих классов, в их связях и зависимостях. Отдельные классы должны быть максимально изолированы друг от друга, быть по возможности независимыми друг от друга.
Что на практике позволяет снизить связанность отдельных классов, ослабить связи и зависимости между ними? Если обращаться к существующим подходам, то, на мой взгляд, лучше всего на этот вопрос отвечает осмысленная связка из принципов SOLID и Закона Деметры. По сути, опираясь именно на основы, они пропагандируют идею “минимального знания” класса о других сущностях.
Компиляция основных (по мнению автора) пунктов:
-
LOD в контексте ООП. Класс должен знать только о тех классах, которые имеют непосредственное отношение к нему. При этом класс должен обладать ограниченным знанием о своих “друзьях” и обращаться (взаимодействовать) только непосредственно к ним (только с ними).
Что это значит? Пример: если Вы хотите, чтобы собака побежала, глупо командовать её ногами, лучше отдать команду собаке, а она уже разберётся со своими ногами сама. В конце концов, если она захромает или получит колесики, вместо лап, то вас это не коснется – следование закону Деметры “погасит” изменения и сделает их более локальными.
На практике нарушения LOD обычно хорошо заметны в коде:
someObject.property1.property2.property3.doSomething(); |
-
ISP. Клиент не должен вынужденно зависеть от элементов интерфейса, которые он не использует. Иными словами, зависимость между классами должна быть ограничена как можно более узким интерфейсом.
- Возвращаясь к нашей собаке: это значит, что если нам нужно что-то гавкающее, нам не интересно, какой длины хвост этот объект реализует и реализует ли он его вообще.
-
DIP.
1) Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракции.
2) Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Этот принцип тоже кажется достаточно простым при прочтении, но, на мой взгляд, является более сложным, чем остальные. В самом деле, по нему написаны отдельные книги и реализовано множество техник внедрения зависимостей.
В качестве его иллюстрации можно привести следующий пример: допустим, нам нужно куда-то отвести некоторый груз на повозке. При этом нам все равно, какое конкретно абстрактное четвероногое копытное животное будет запряжено в упряжь (мул, лошадь или осёл), а животному глубоко фиолетово, какую оно тянет повозку: двух- или четырёхколесную, крытую карету или открытый воз. Точно так же грузу все равно, на чём его везут. Когда нам нужно отвезти что-то куда-то, мы просто берём этот груз, берём доступное животное и повозку и формируем упряжку (модуль верхнего уровня), которая будет двигаться и вернёт результат, когда доедет.
Таким образом, класс ничего не знает о деталях своих зависимостей, о том, как они создаются, об их подзависимостях, но имеет возможность использовать необходимые ему сервисы. Кстати, такой подход очень способствует Unit-тестированию: запрягая в упряжку лошадей разных пород, мы можем протестировать этот класс.
О практике
- Что значит: "У меня есть для тебя небольшая работенка"?..
Геракл
Теоретизировать можно до бесконечности, но лучше рассмотреть небольшой практический пример, пусть и идеализированный и сильно утрированный. Так как я работаю в мобильной сфере, то позволю себе взять для примера типовую задачу именной из этой области.
Допустим, у нам нужно разработать приложение, которое представляет собой галерею, скачивает картинки с изображением природы с некоторого сервиса и показывает их пользователю. Приложение состоит из двух экранов: на одном расположена кнопка Open, на другом мы сеткой отображаем скачанные картинки.
Итак, засучив рукава и включив фантазию, программист начал писать на некотором псевдоязыке (обойдемся без UML). В первую очередь он, конечно, вспомнил об инкапсуляции, уменьшении связей, модульно-структурном программировании и подумал:
- Входной экран (EntryScreen) должен только вызывать экран галереи (GalleryScreen).
- Класс экрана с галереей инкапсулируют эту самую галерею в себя. И выполняют эту обязанность – он галерея. При этом круто, что классы входного экрана ничего об этом не знают!
- Но я не хочу “быдлокодить” и сделаю еще два класса: один будет общаться с сервером (GalleryAgent) и получать список картинок, а второй будет скачивать эти картинки из интернета (ImageProvider). При этом я предусмотрю возможность использования таких же сервисов по другим адресам: GalleryAgent будет использовать данный ему базовый URL, мало ли, придётся масштабировать.
class EntryScreen : Screen
const GALLERY_SERVICE_URL = "http://beautiful-nature.com/api";
|
Всё заработало с первой компиляции, разработчик доволен и спокойно почитывает dev.by: ведь до релиза ещё целая неделя. Внезапно (ну кто бы мог подумать?) прибегает PM и говорит: “Заказчик тестировал наше приложение и был доволен, но вдруг у него пропал интернет, и он увидел только пустую галерею. Заказчик хочет, чтобы мы кэшировали картинки и смогли предъявить пользователю на экран хоть что-то.” На что разработчик слабо отбивается: “Это не было изначально предусмотрено нашей архитектурой. Ладно, ладно, ладно, я посмотрю, что можно сделать”. Тогда код приобретает приблизительно такой вид:
class GalleryScreen : Screen const GALLERY_SERVICE_URL = "http://beautiful-nature.com/api"; Grid grid; Cache cache; void onShow() |
Окинув орлиным взором результат, разработчик одобрительно кивает: а что, не так уж и плохо – инкапсуляция функциональности галереи вон даже не нарушена, а про её URL никто извне и не знает. Закоммитив изменения и собрав билд, разработчик с чистой совестью открывает dev.by… как дверь открывается ударом ноги и в комнату залетает взмыленный PM с круглыми глазами, начиная сбивчиво объяснять: “Кто-то сказал заказчику, что картинки с природой – сейчас не модно, что сейчас в тренде картинки с девушками в бикини. Поэтому сделай галерею с девушками. Но заказчик не хочет убирать природу: нам нужно, чтобы в приложении были и пейзажи, и бикини. На начальный экран добавляется еще одна кнопка, по которой мы должны открыть экран с девушками. Девушки лежат на другом сервере, но вот только у них совершенно другой API – не XML, а JSON, да и вообще… Ещё нужна возможность выбранную пользователем картинку ставить как фоновую на входной экран (EntryScreen)”. Уговорившись с заказчиком на новые сроки (но меньшие – мы же не с нуля пишем!), начинается процесс созидания. Вспомнив про наследование, разработчик создаёт базовый класс для работы с разными галерейными сервисами для красоты и избежания дублирования кода:
class BaseGalleryAgent : Object
|
Кроме того, разработчик внёс изменения в GalleryScreen и EntryScreen. Теперь конструктор принимает type и это хорошо – ведь интерфейс взаимодействия минимален, из связей только строка (можно даже enum сделать!):
class EntryScreen : Screen { // чтобы следовать lazy evaluation принципу |
Тут уже даже самому разработчику становится очевидно, что с его кодом что-то не так, видимо, из-за постоянных change requests. Впоследствии поддержка такого кода становится сущим наказанием и борьбой с желанием все переписать. О чем и будет сказано PM’у, когда он придёт с идеей сделать, например, раздельные политики кэширования для природы и девчонок.
Полученный выше код обладает уймой недостатков:
- GalleryScreen очень много знает о создании объектов ImageProvider, Cache, GalleryAgent. То есть он ещё выполняет процесс их подготовки, чем по идее заниматься не должен, т.к. это не его обязанность. Нарушение принципа SRP, ухудшающее код.
- В связи с этим, GalleryScreen зависит от прямых реализаций GirlsAgent и NatureAgent, а также всего остального. Нарушение принципа DIP. Малейшее изменение – и нам нужно перекраивать всё множество классов-клиентов.
- EntryScreen “лезет в душу” к GalleryAgent за своим фоном. Если в GalleryAgent мы заменим сетку картинок (grid) на другой UI-объект (например, на таблицу), то нам придётся менять реализацию EntryScreen. Нарушение принципа LOD.
- Список можно продолжить.
О том, как можно было сделать по-другому
Сначала нужно было отпереть сейф,
а уж потом всплывать на поверхность.
Г. Гуддини
Как можно было бы спроектировать данный пример по-другому? Я не буду говорить “правильно” или “как нужно делать”. Просто по-другому, несколько лучше, чем было сделано.
Обратимся к принципам SOLID. Обязанность класса GalleryScreen – только отображение сетки картинок некоторым определённым образом. Для этого ему нужен только некий источник картинок, который, в свою очередь, будет работать с галереей и инструментом для загрузки картинок. Для этого подобный ImageSource должен пользоваться предоставленными зависимостями: абстрактным инструментом для загрузки картинок и интерфейсом для получения списка из галереи. Инструмент для получения картинок может брать картинки из интернета по http, из локальной папки, неважно откуда – это его дело, нам же нужны только картинки. То же самое с агентом галереи – мы просто его используем нужным для достижения цели образом, не задумываясь о конкретной его реализации (конкретном классе), о его проблемах и внутренней логике. Чтобы реализовать подобное поведение, перенесём задание зависимостей на уровень выше – в EntryScreen:
class EntryScreen |
Итак, теперь класс GalleryScreen не зависит от конкретных реализаций классов нижнего уровня: ImageSource, ImageProvider, GalleryAgent – он зависит только от абстрактного ImageSource, а не от деталей. Сейчас мы можем “подсунуть” ему как какие-то специфические экземпляры, так и общие (singleton, например, или разделяемый с другим модулем экземпляр). А ещё мы можем провести unit-тесты с большей эффективностью, так как лучше контролируем входные данные класса – просто отдавая ему нужные экземпляры.
Конечно, класс EntryScreen теперь стал зависеть от конкретных реализаций GalleryAgent, но это приемлемо – ведь он знает о выборе пользователя и может принять решение, из каких деталей конструктора ему собирать “автомобиль”, на котором пользователь поедет дальше. Создание конкретных экземпляров и их сборку можно было бы (и следовало бы для строгости) вынести в отдельные(-ый) классы-конструкторы, но особой необходимости в этом пока нету.
Кроме того, теперь класс EntryScreen ничего не знает о логике отображения картинок. Он спрашивает выбранную картинку у GalleryScreen, не создавая жёстких связей и не нарушая закон Деметры.
Таким образом, мы получили более гибкую архитектуру. Добавление новых типов галерей – не проблема. А если вдруг понадобится сделать раздельные политики кэширования – мы просто создадим и отправим в бой конкретные экземпляры, классы которых реализуют интерфейс Cache.
Безусловно, предусмотреть все возможные изменения и заложить необходимый запас гибкости на старте очень тяжело, но, изначально опираясь на основы, можно здорово облегчить себе жизнь впоследствии.
О чём хотел сказать автор
Архитектура, которая приносит пользу, – это хорошая архитектура. Приносит пользу: дает понятный и чистый код, позволяет легко вносить изменения, отлично решает поставленные задачи в сроки. При этом не важно, насколько она “блестит” и на сколько слоёв абстракции она отличается от канонов. Главное, что она помогает разработчику устоять перед нашествием орд изменений.
Слепое и бездумное следование различным подходам к проектированию не способствует построению подобного эффективного решения. Осмысленное же использование основ объектно-ориентированного дизайна позволит увеличить вероятность получения архитектуры, которая в дальнейшем поможет существенно облегчить разработку и предусмотрит страховку от большей части изменений, не выходящих за рамки концепции проекта.
P.S. На лавры за инновации или истину в последней инстанции статья не претендует, свои мысли и вопросы в комментариях – приветствуются.
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.