Это очень философская тема, поэтому я не могу предложить ничего более, чем мой субъективный анализ и опыт.
Проблемы, симптомы
Мой начальный опыт программиста был весьма безоблачным – я без лишних проблем клепал вебсайты-визитки. Писал код, как я это сейчас называю “в строчку” или “полотном”. На маленьких объемах и простых задачах все было хорошо.
Но я сменил работу, и пришлось разрабатывать один единственный вебсайт в течение 4-х лет. Естественно, сложность этого кода была несопоставима с визитками из моей прошлой работы. В какой-то момент проблемы просто посыпались на меня – количество регрессии зашкаливало. Было ощущение, что я просто хожу по кругу – пока чинил “здесь”, сломал что-то “там”. И поэтом это “здесь” и “там” банально менялось местами и круг повторялся.
У меня исчезла уверенность в том, что я контролирую ситуацию – при всем моем желании недопустить баги, они проскакивали. Все эти 4 года проект активно разрабатывался – мы улучшали уже существующий функционал, расширяли, достраивали его. Я видел и чувствовал, как удельная стоимость каждого нового рефакторинга/доработки растет – увеличивался общий объем кода, и соответственно увеличивались затраты на любую его правку. Банально, я вышел на порог, через который уже не мог переступить, продолжая писать код “в строчку”, без использования архитектуры. Но в тот момент, я этого еще не понимал.
Другим важным симптомом оказались книги и видео-уроки, которые я в то время читал/смотрел. Код с этих источников выглядел “глянцево” красивым, естественным и интуитивно понятным. Видя такую разницу между учебниками и реальной жизнью, моей первой реакцией была мысль, что это нормально – в жизни всегда сложнее, чем в теории, больше рутины и конкретики.
Тем не менее, продукт на работе нужно было расширять, улучшать, в общем, двигаться дальше. В тот же самый момент я начал активно участвовать в одном open source проекте. И в совокупности эти факторы вытолкнули меня на путь архитектурного мышления.
Что такое архитектура?
Один мой преподаватель в университете употребил фразу “нужно проектировать так, чтобы максимизировать количество объектов и минимизировать количество связей между ними”. Чем дольше я живу, тем больше с ним соглашаюсь. Если присмотреться к этой цитате, то видно, что эти 2 условия в какой-то мере взаимоисключающие – чем больше мы дробим какую-то систему на подсистемы, тем больше связей придется вводить между ними, чтобы “соединить” каждую из подсистем с остальными актерами. Найти оптимальный баланс между первым и вторым – это своего рода искусство, которым, как и прочими искусствами, можно овладеть через практику.
Сложная система дробится на подсистемы за счет интерфейсов. Для того, чтобы выделить из сложной системы какую-то подсистему, нужно определить интерфейс, который будет декларировать границы между первым и вторым. Представьте, у нас была сложная система, и вроде бы внутри нее осязаются некоторые подсистемы, но они “размазаны” по разным местам основной системы и четкий формат (интерфейс) взаимодействия между ними отсутствует:
Посчитаем, де факто, у нас 1 система и 0 связей. С минимизацией связей все отлично :) Но вот количество систем очень маленькое.
А теперь, кто-то проделал анализ кода, и четко выделил 2 подсистемы, определил интерфейсы, по которым ведется коммуникация. Это значит, что границы подсистем определены и схема стала следующей:
Здесь у нас: 3 системы и 2 связи между ними. Обратите внимание, что количество функционала осталось тем же самым – архитектура ни увеличивает, ни уменьшает функционал, это просто способ организовать код.
Какое различие можно разглядеть между этими двумя альтернативами? При рефакторинге в первом случае нам нужно “прочесать” 100% кода (весь зеленый квадрат), чтобы убедиться, что мы не внесли никакой регрессии. При том же самом рефакторинге во втором случае, нам сначала нужно определить, к какой системе он относится. И потом весь наш рефакторинг сведется к прочесыванию лишь одной из 3х систем. Задача упростилась во втором случае! За счет успешного дробления архитектурой, нам достаточно сконцентрироваться лишь на части кода, а не на всех 100% кода.
На этом примере видно, почему дробить на максимальное количество объектов выгодно. Но существует еще и вторая часть цитаты – минимизация связей между ними. А что, если новая доработка, которая пришла к нам от начальства затрагивает сам интерфейс (красненький мост между 2мя системами)? Тогда дела плохи – изменения в интерфейсе подразумевают изменения по оба конца этого моста. И как раз чем меньше у нас связей между системами, тем меньше вероятность того, что наш рефакторинг вообще затронет какой-либо интерфейс. А чем проще каждый из интерфейсов, тем проще будет внести необходимые изменения по обе стороны интерфейса.
Интерфейс в самом широком смысле
Ключом к правильному применению архитектуры я считаю именно интерфейс, ведь он определяет формат взаимодействия и, соответственно, границы каждой из систем. Другими словами, от выбраных интерфейсов зависит и количество подсистем и их связанность (количество связей). Рассмотрим его поближе.
Прежде всего, он должен быть честным. Не должно быть коммуникаций между системами за пределами интерфейса. Иначе мы скатимся к исходному варианту – диффузия (да, она в программировании тоже есть!) объединит 2 системы обратно в одну общую систему.
Интерфейс должен быть полным. Актер по одну из сторон интерфейса не должен иметь никакого понятия о внутреннем устройстве актера по другую сторону моста – не больше чем то, что подразумевает интерфейс, по которому они взаимодействуют, т.е. интерфейс должен полным (достаточным для наших нужд) образом описывать партнера по “ту сторону моста”. Делая интерфейс полным изначально, мы значительно уменьшаем шансы того, что в будущем придется править интерфейс – вспомните, внесение изменений в интерфейс – это самая дорогая операция, т.к. она подразумевает изменения в более, чем одной подсистеме.
Интерфейс не обязательно должен быть задекларированным как интерфейс из ООП. Я считаю, что достаточно честности, полноты интерфейса и вашего ясного понимания этого интерфейса. Более того, интерфейс такой, как я его подразумеваю в рамках этой статьи – это нечто шире чем интерфейс из ООП. Важна не форма, а суть.
Здесь будет уместным упомянуть архитектуру микросервисов. Границы между каждым из сервисов – это ни что иное, как интерфейс, о котором я повествую в этой статье.
В качестве примера я хочу привести счетчик использования файла в inode на *nix (file reference count): есть интерфейс – если ты используешь файл, то увеличь его счетчик на 1. Когда закончил им пользоваться, уменьши его счетчик на 1. Когда счетчик равняется нулю, значит этим файлом уже никто не пользуется, и его нужно удалить. Такой интерфейс неописуемо гибкий, т.к. он не накладывает абсолютно никаких ограничений на внутреннее устройство актера, который им может пользоваться. В этот интерфейс органично вписывается как использование файлов в рамках файловой системы, так и файл декскриптор из исполняемых программ.
Решайте задачу на абстрактном, а не конкретном уровне
Очевидно, что умение выбрать правильный интерфейс – очень важный навык. Мой опыт мне подсказывает, что очень часто удачный интерфейс приходит в голову, когда ты пытаешься решить задачу на абстрактном (общем) уровне, а не текущем (конкретном) ее проявлении. Альберт Эйнштейн однажды сказал, что правильная постановка задачи важнее, чем ее решение. В этом свете я с ним полностью согласен.
Какое решение задачи “открыть входную дверь” вам кажется более правильным?
- Подойти к двери;
- Достать из кармана связку ключей;
- Выбрать нужный ключ;
- Открыть им дверь.
Или:
- Подойти к двери;
- Вызвать подсистему “хранилище ключей” и получить из нее доступную связку ключей;
- Вызвать подсистему “поиск правильного ключа” и запросить у нее наиболее подходящий ключ к текущей двери из связки доступных ключей;
- Открыть дверь ключем, предложенным подсистемой поиска правильного ключа.
Абстрактность второго алгоритма в разы выше, чем первого, и как следствие, его полнота тоже выше. Банально у второго алгоритма куда больше шансов остаться актуальным даже 50 лет спустя, когда понятие “ключей” и “дверей” будет отличаться от сегодняшнего :)
Смотря на проблему с абстрактной точки зрения, нам в голову естественным образом приходят полные интерфейсы. Ведь решая частное проявление проблемы, максимум, что мы можем придумать в плане интерфейса – это всего лишь его частная проекция на нашу частную проблему. Смотря на абстрактную проблему у нас больше шансов увидеть полный интерфейс, а не его проявление под какую-то конкретику.
В какой-то момент вы начинаете видеть эти абстрактные операции за их конкретными проявлениями (реализациями). Это уже отлично! Но не забывайте, что вам нужно минимизировать количество связей – это значит, что существует риск забраться слишком далеко в дебри абстракции. Абсолютно необязательно включать в вашу архитектуру все абстракции, которые вы видите при анализе. Включайте только те, которые оправдывают свое присутствие за счет дополнительной вносимой гибкости либо за счет дробления чрезмерно сложной системы на подсистемы.
Физика
Есть такая наука, и я ее люблю наравне с программированием. В физике многие явления можно рассматривать на разных уровнях абстракции. Столкновение двух объектов можно рассматривать как динамику Ньютона, а можно рассматривать как квантовую механику. Давление воздуха в воздушном шарике можно рассматривать как микро- и макро-термодинамику. Наверное, физики пришли к такой модели не зря.
Дело в том, что использование разных уровней детализации в архитектуре кода тоже очень выгодно. Любую подсистему можно рекурсивно дробить дальше на под-подсистемы. Подсистема станет выступать системой, и мы будем в ней искать подсистемы. Это divide and conquer (разделяй и властвуй) подход. Таким образом программу любой сложности можно объяснить на удобном собеседнику уровне детализации за 5 минут за кружкой пива другу программисту или начальнику нетехнарю на корпоративном собрании.
Как пример, что происходит в нашем ноутбуке, когда мы включаем фильм? Все можно рассматривать на уровне медиапроигрывателя (читаем содержимое фильма, декодируем в видео, показываем на мониторе). Можно рассматривать на уровне операционной системы (читаем с блочного устройства, копируем в нужные страницы памяти, “просыпаем” процесс плеера и запускаем его на одном из ядер), а можно же и на уровне драйвера диска (соптимизировать i/o очередь на устройство, прокрутить до нужного сектора, считать данные). Кстати, в случае SSD диска последний список шагов был бы другим – и в этом вся прелесть, т.к. в операционных системах есть интерфейс блочного устройства хранения данных, мы можем вытыкнуть магнитный диск, втыкнуть флешку и не заметим особой разницы. Более того, интерфейс блочного устройства был придуман задолго до появления CD дисков, флешек и многих других современных носителей информации – что это как не пример успешного абстрактного интерфейса, который прожил и остался актуальным на протяжении ни одного поколения устройств? Конечно, кто-то может возразить, что процесс был обратным – новые устройства вынужденно адаптировались под уже существующий интерфейс. Но если бы интерфейс блочного устройства был откровенно плохим и неудобным, он бы не выстоял на рынке и был бы поглощен какой-то другой альтернативой.
Человеческий мозг не может удерживать в голове много концепций/объектов одновременно, вне зависимости от того, говорим ли мы о физике или о программировании либо еще о чем-либо. Соответственно, старайтесь организовать вертикальную иерархию вашей архитектуры так, чтобы у вас было не больше дюжины актеров на любом уровне абстракции. Сравните два описания одной и той же системы:
Мы здесь обрабатываем входящие заказы. Сначала идет процесс валидации – проверяем наличие заказанных товаров на складе, проверяем правильность адреса доставки, успешность платежа. Потом запускается процесс уведомления – оператору приходит смс-ка с информацией о новом заказе. Начальник отдела получает имейл со сводной информацией.Или:
Мы здесь обрабатываем входящие заказы. Сначала отрабатывает система валидации – проверяем точность и правильность всех данных. Ну в принципе, если тебе интересно, у нас там есть внутренняя валидация (наличие на складе и тп.) и внешняя (правильность информации, указанной в заказе). При успешной отработке валидации запускается система уведомлений – вот по этой ссылке найдешь полную информацию об уведомлениях.Чувствуете, вертикальную ориентацию второго описания по сравнению с первым? Дополнительно, второе описание ярче выделяет абстракции “валидация” и “уведомление”, чем первое.
Как люди обычно летают на Луну?
Правильно! Они сначала проектируют ракету (ракету, как единое целое, и отдельно каждую из ее компонент). Затем они строят завод по производству каждой из компонент и завод по финальной сборке ракеты из произведенных компонент. А потом они летят на луну на собранной ракете. Чувствуется какая-то параллель?
На выходе получается огромное количество компонент, которые можно переиспользовать в других смежных целях. А еще есть заводы, которые эти компоненты массово производят. И успех всего предприятия в наибольшей степени зависит от успешной проектировки (когда в проект забыли внести модуль по регенерации кислорода, а ракета уже стоит на стартовой площадке – дела плохи), чуть меньше от качества построенных заводов (заводы еще можно как-то калибровать и тестировать) и меньше всего от конкретного экземпляра ракеты, которая стоит на пусковой установке – если с ней что-то случится, ее будет легко пересоздать на базе уже имеющейся инфраструктуры. Скоро научимся клонировать людей, и тогда даже при неудачных пусках о человеческих потерях не будет речи :)
В программировании все точно так же. На плечи железа выпадает роль заводов – исполнять наш код. Но вот роль проектировки (создания архитектуры) и конкретных имплементаций (постройка заводов) ложится на плечи программиста. Очень редко эти 2 этапа как-то явно выделяются из общего клубка. А мыслить об этих 2-х этапах раздельно очень полезно, более того, в других областях это даже выглядит нелогично. Ведь кто будет сразу строить завод, предварительно не решив, что же этот завод будет производить?
Преимущества архитектуры
Я здесь только резюмирую концепции, которые пытался описать выше. При успешном использовании архитектуры, мы имеем:
- Простота изолированого тестирования каждой из систем. Так как каждая система общается с внешним миром через строгий интерфейс, ее очень легко протестировать отдельно
- Упрощение поддержки кода: за счет дробления на подсистемы внесение изменений в существующий код упрощается
- Расширяемость системы увеличивается, т.к. благодаря интерфейсам мы во многих местах можем легко подключить какой-либо новый функционал (либо заменить уже существующий на альтернативную реализацию)
- Повышается переиспользование кода: интерфейсы вводят слабое связывание в код. Значит какую-либо систему будет просто применить в какой-нибудь другой задаче. Здесь снова важную роль играет полнота интерфейса. Если интерфейс был действительно полным, его будет достаточно и для новой задачи. Вспомним парадигму Юникса “Делайте что-то одно, но делайте это хорошо” — переиспользовать хорошо написаную программу с полным интерфейсом одно удовольствие!
Признаки успешной архитектуры
Успешность архитектуры невозможно оценить однозначно “да” либо “нет”. Более того, одна и та же архитектура может быть успешной в рамках одного проекта (спецификации) и провальной в рамках другого проекта, даже если оба проекта номинально оперируют в одной и той же предметной области. В момент проектирования от вас требуется максимально глубокое и исчерпывающее понимание процесса, который вы автоматизируете/моделируете кодом.
Тем не менее, некоторые общие черты успешных архитектур я вам осмелюсь предложить:
- Разделение между кодом архитектуры и кодом имплементации. Кто-то решает задачу на абстрактном уровне (это как начальник, который говорит “нужно повысить продажи в следующем квартале”), а кто-то имплементирует конкретные шаги, необходимые для достижения одной из составляющих общего результата (сотрудник PR отдела начинает давать объявления в газету).
- В какой бы точке программы мы не остановились, всегда должно быть абстрактное объяснение того, чем мы тут занимаемся. На уровне начальника это может быть “мы увеличиваем продажи, т.к. прошлый квартал был неприбыльным”, на уровне конкретного сотрудника это может быть “я даю объявления в газету, т.к. это часть моих должностных обязанностей (интерфейса), и мне только что пришел приказ сверху этим заняться”. Такое объяснение должно быть логичным и соответствовать уровню знаний/кругозора анализируемого субъекта/актера.
- Большинство кода выглядит как взаимодействие поставщика и потребителя услуг. Система уведомлений пользователя предоставляет услугу “уведомить пользователя о событии Х” и в свою очередь в рамках реализации этой услуги потребляет услугу “отправить смс сообщение” и “отправить имейл”.
- Все критические компоненты можно легко заменить на альтернативные реализации. Банально отсоединяем старую компоненту и на тот же интерфейс подсоединяем компоненту с альтернативной реализацией. Кстати, ваш начальник-нетехнать будет ужасно рад такой возможности в какой-то критический момент!
- Архитектуры легко объяснить словами (дополнительной документацией), и относительно сложно “понять смысл” смотря в код. Словами проще объяснить смысловую нагрузку интерфейсов, которые составляют архитектуру, т.к. при высоком уровне абстракции интерфейса эта самая смысловая нагрузка не так очевидна из кода. К тому же, некоторые интерфейсы, официально незадекларированные в рамках используемого языка программирования, могут банально прошмыгнуть мимо глаза программиста, когда он просматривает незнакомый код.
- При использовании архитектуры, большая часть функционала становится доступной ближе к концу цикла разработки. На начальных этапах программист пишет код архитектуры и реализует отдельные подсистемы. Лишь в самом конце он их соединяет между собой в правильной последовательности для достижения конечного (бизнес) результата. А когда архитектуры нет либо ее мало, то функционал поставляется более-менее линейно – банально человеку нужно 10 дней, чтобы написать код, и он каждый день пишет 10% от общего полотна кода. Вот графическое объяснение этого пункта — график распределения завершенности задачи от времени разработки:
Советы для построения успешной архитектуры
Попытайтесь задать 3 вопроса, когда анализируете задачу на архитектуру: Что мы делаем? Зачем мы это делаем? Как мы это делаем? За “что?” отвечает интерфейс, к примеру “мы уведомляем пользователя о событии”. За “зачем?” отвечает потребитель – код, который вызывает подсистему, и на вопрос “как” отвечает конкретная реализация интерфейса (поставщик услуги).
Постарайтесь любую самодостаточную операцию оформить в виде подпрограммы (функции, метода либо еще чего-то в зависимости от доступного вам инструментария). Даже если это всего лишь одна строчка кода, и используется она один раз в вашей программе. Так вы отделяете архитектурный код (список абстрактный действий) от имплементаций. В таком контексте эта функция выступает в роли интерфейса, и тут же получаем потребителя (вызывает функцию) и поставщика (реализация функции). Пример:
function process_object($object) {
$object->data[‘special’] = TRUE;
$object->save();
send_notifications($object);
}
или
function process_object($object) {
$object->markAsSpecial();
$object->save();
send_notifications($object);
}
Используйте побольше уровней детализации в вашей архитектуре. При интенсивном “вертикальном” дроблении у вас будет широкий выбор из компонент разного калибра. Когда вы начнете решать очередную задачу в рамках такого проекта, у вас будет выбор либо использовать какую-то высокоуровневую систему (быстро, возможно в ущерб гибкости решения), либо “дособрать” из низкоуровневых компонент решение, которое точнее ложится под бизнес потребности. Естественно, по возможности вы будете предпочитать высокоуровневые компоненты, но у вас всегда будет свобода собрать какой-то критический участок из более низкоуровневых компонент. К примеру, у вас может быть высокоуровневая компонента “уведомить пользователя о событии”. Она, исходя из настроек в профиле пользователя выбирает длинный либо короткий вариант уведомления и отсылает его либо смской либо на почту. Такая высокоуровневая компонента использует 2 более низкоуровневые: “отправить смску на номер X с содержанием Y” и “отправить имейл по адресу X с содержанием Y”. Когда вам в следующий раз нужно уведомить пользователя о каком-либо событии вероятнее всего вы воспользуетесь высокоуровневой компонентой. Но у вас остается опция слать смски и письма в обход высокоуровневой компоненты используя низкоуровневый слой напрямую – допустим, это вам может пригодиться при критически важном уведомлении – такое лучше бы слать прямо на телефон смской в обход настроек пользователя в силу критичности ситуации. Чем больше уровней детализации вы выделите, тем больше у вас будет такой свободы. Это как атомная бомба и точечный авиаудар – иногда удобнее разбомбить к чертям полматерика, а иногда удобнее нанести 10 точечных ударов по стратегическим объектам. С бомбой проще (более высокий уровень абстракции – просто ткнуть пальцем в нужный материк), с авиаударом больше мороки (нужно выделить эти 10 стратегических объектов), однако иметь выбор из двух альтернатив всегда лучше, чем одна единственная опция.
Ваше воображение работает в разы быстрее, чем ваши пальцы – валидируйте и “примеряйте” архитектуру на бумажке, прежде чем начать ее реализовывать в коде. Будет обидно понять через 5 часов кодирования, что придуманные вами интерфейсы не покрывают нужды предметной области, и эту проблему вы могли бы предвидеть, потратив 20 минут на анализ архитектуры и “проверку” архитектуры на бумаге. В некоторые моменты я провожу полный рабочий день сидя и смотря в небо – придумывая и обкатывая архитектуру на бумаге.
Не перегружайте ваши интерфейсы. В погоне за полнотой интерфейса, мы можем включить в него избыточные элементы, но здесь можно ненароком кашу испортить маслом. Чем больше элементов включает интерфейс, тем меньше свободы он оставляет тому, кто его будет реализовывать. Так же не забывайте, что возможно, в какой-то момент вам нужно будет изменить этот интерфейс в свете каких-то новых бизнес задач. Чем интерфейс проще, тем проще изменять его и актеров по обе стороны этого интерфейса.
Может звучать парадоксально, но перегруженный интерфейс будет менее полным чем идеально сбалансированный по нагрузке интерфейс. Излишние подробности сужают интерфейс, а не расширяют его, т.к. некоторые подробности теряют свой физический смысл в каком-то другом контексте. К примеру, мы могли бы “перестараться” и в наш интерфейс системы уведомления пользователя о каком-либо событии ввести понятие часового пояса: “уведомить пользователя о событии с учетом (или без) его часового пояса”. В некотором контексте это будет правильным интерфейсом, а в каком-то неправильным. Допустим пользователи нашей системы начнут жить на луне и там нет понятия “часового пояса” в таком смысле, в котором к нему привыкли земляне. Тогда это дополнительная нагрузка в интерфейсе окажется избыточной и будет действовать в ущерб всей архитектуре.
Не забывайте о вопросах производительности и масштабируемости в момент проектирования архитектуры. В идеале интерфейсы должны быть максимально простыми – допустим пару функций, которые позволяют изменять и удалять какую-то сущность из хранилища. Упаковывая в интерфейс лишь 2 функции мы получаем высокий уровень абстракции – мы можем использовать реляционную БД и NoSQL для физического хранения данных. Но если таких сущностей будут тысячи, то становится очевидным, что ими нужно манипулировать на уровне СУБД, а не приложения. Тогда нужно сознательно включить в интерфейс структуру БД, где эти сущности хранятся. В противном случае интерфейс будет красивым, но неполным, ибо с учетом требований производительности, полный интерфейс должен предоставлять быстрый и эффективный инструментарий для массового взаимодействия с сущностями.
Творение архитектуры
Способность правильно понять предметную область, идентифицировать успешные интерфейсы я отношу к искусству. В моем личном случае, я учился этому ремеслу через практику и созерцание архитектур других авторов, всегда пропуская исследуемую архитектуру через призму собственного критического мышления.
В следующий раз, когда вам нужно решить относительно большую задачу, отойдите от компьютера и посидите с листком бумаги час. В начале, возможно, никакие мысли не будут лезть в голову, но вы честно продолжайте размышлять над проблемой и абстракциями/интерфейсами, которые могут быть спрятаны внутри этой проблемы. Не отвлекайтесь – очень важна глубина погружения и концентрация, чтобы вы смогли максимально детально продумать и скомпоновать всех актеров и их связи у себя в воображении.
Когда вы видите чужую (либо свою, но какое-то время ранее реализованную) архитектуру, и вам нужно внести правки в код, попытайтесь проанализировать, удобно ли вносить эти правки при текщей архитектуре, достаточно ли она гибка. Что в ней можно улучшить?
Комментарии (25)
iit
18.04.2018 09:56+1Добавлю что как и с объектами, лучше больше узко специализированных интерфейсов, чем меньше раздутых. Таким образом мы пишем 4 интерфейса, которые разделены по задачам и после объединяем их в один композицией.
Так-же и с классами — вместо того чтобы городить кучу классов, есть прекрасный механизм трейтов, которые мы примешиваем в класс.
Стараюсь сделать так чтобы на каждый интерфейс его реализация лежала в трейте. Таким образом когда любой класс по той или иной причине наследует этот интерфейс, можно легко и просто добавить ему функционал который реализует интерфейс с помощью трейта, и чтобы заменить его — достаточно просто написать другой трейт и поменять одну строку в самом классе.
samizdam
18.04.2018 19:22Трейты плохи тем, что в них не очевиден контекст, в котором они исполняются. При классическом наследовании, не множественном, эта проблема тоже есть, но не так явно выражена.
В общем, композиция предпочтительнее наследования. Как раз композиция естественным путём подталкивает нас использовать интерфейсы между объектами. Наследование классов — нет, оно толкает к связности на основе реализации.iit
20.04.2018 07:48Есть такое, однако если взаимодействовать с классом не через его свойства напрямую а через геттеры/сеттеры которые описаны в классе, а трейте гетеры/сеттеры делать обязательными и абстрактными причем с тайп-хинтингом это немного упрощает дело.
Осторожно php код!trait SeedTableTrait { /** * @return DataBaseRepositoryInterface */ abstract public function getDataRepository() : DataBaseRepositoryInterface; /** * @param array $dataset */ public function seed(array $dataset) { $repository = $this->getRepository(); $repository->create($dataset); } }
samizdam
20.04.2018 14:48+1Напоминает решение проблемы, которые мы создали себе сами, начав использовать трейты.
Используя композицию не пришлось бы искать компромиссы — абстрактные классы были бы в интерфейсах, имплементация в классах, а трейты — выглядят лишней сущностей здесь.
oxidmod
20.04.2018 15:30Что приводит нас к мысли, что трейт должен опираться только на тот контекст, который сам и содержит. Тогда никаких проблем с ним нет
samizdam
20.04.2018 19:24То что надо держать что-то в уме и есть проблема — повышение когнитивной нагрузки, и вероятность ошибки при несоблюдении соглашений.
У интерфейса нет зависимости от контекста, неявного состояния, модификаторов доступа к методам и магии позднего статического связывания — нет и соответствующих проблем.
Это не значит, что нельзя спроектировать плохую систему используя интерфейсы. Но это разные уровни
— косяки дизайна на уровне программных интерфейсов
— и сложность и побочные эффекты появляющиеся из-за реализации опирающейся на «не рекомендуемые» шаблоны проектирования.
И чем больше логики в коде вынесено на интерфейсный уровень, тем может быть проще такая система в поддержке. Если про сферических коней в вакууме.oxidmod
20.04.2018 21:13В уме всегда нужно что-то держать. Я похоже не совсем понимаю в чем вы видите проблему.
Если трейт вызывает только свои методы и опирается только на свои поля (независим от контекста использования), то какие с ним проблемы?samizdam
20.04.2018 22:18Большой проблемы нет. И сложность, которую приходиться держать в уме есть всегда.
Тот же LoggerAwareTrait из PSR я использую, и лучшего решения не могу придумать.
Проблемы могут быть когда в трейте размещается реализация какого-нибудь бизнесового интерфейса. Трейт не знает какой интерфейс он реализует. И интерфейс не знает о том какие трейты его реализуют.
Вот эта неявность при рефакторинге или ревью кода может доставлять неудобства. Со всеми вытекающими: Когнитивная нагрузка, риски ошибиться, отсутствие подсказок ide, сложность статического анализа и рефлексии, дополнительные условия или соглашения в подводных тестах трейты же тоже надо тестировать, но стоит решить как...
arTk_ev
18.04.2018 21:28Ну это принципы SOLID + иерархическая декомпозиция + кибернетика. Такая архитектура- самая распространненая и устойчивая. Проектировать нужно именно сверху-вниз, наверху самая высокоуровненая абстракция(это очень удобно с точки зрения управления, аналогия иерархия в армии). Большое количество связей дает комбинаторный взрыв, плюс любая «сложная система» генерирует эмерджетные свойства, побочные эффекты. SOLID даст очень прочный каркас, но из-за этого вытекают и свои минусы(вдобавок проблемы с временем жизни объектов, null-проблемы, с асинхронными процесами, колбеками).
Среди положительных побочных эффектов — очень резкое уменьшение кода и сложности. Многомиллионный код сворачивается в код из пару сотен строк. Нет нужды читать весь код. А когда нужно добавить новых функционал, всегда известно в каком именно месте его следует разместить.
Есть еще архитектура снизу-вверх. Удобна когда бизнес логики очень много, и малая иерархия. Например для игр-квестов и поиск предметов.
Есть еще и более совершенная архитектура — самоогранизующая эволюционная. Но управлять ей почти невозможно, но зато дает массу неожиданных положительных системных эффектов.Skerrigan
19.04.2018 06:47Есть еще и более совершенная архитектура — самоогранизующая эволюционная. Но управлять ей почти невозможно, но зато дает массу неожиданных положительных системных эффектов.
Вы заинтриговали, можно несколько более развернуто?arTk_ev
19.04.2018 12:57частично это интернет, всемирная паутина, блокчейн, нейросети, клеточные автоматы.
Сейчас как раз занимаюсь разработкой такой архитектуры для игр. Понятие «баг — это фича» как раз возникает в таких системах.
vin2809
19.04.2018 08:19Спасибо за статью, хотя не хватает описания практического применения…
Тем не менее, благодарю за высказанные мысли. Похожие бродят и в моей голове при анализе собственных творений.bucefal91 Автор
19.04.2018 09:48+1Хехе, я попытался включить некоторые примеры в статью, она и так получилось слишком длинной, поэтому я не сильно налегал на примеры. Но я могу посоветовать прочитать одну книгу о внутреннем устройстве Юникса. Я ее читал 4 года назад и плакал как ребенок, которому купили вкусную и красивую шоколадку))) Вот эта книга www.amazon.com/Design-UNIX-Operating-System/dp/0132017997
Там именно с архитектурной точки зрения рассматривается устройство ядра. И когда ты читаешь и видишь как сложнейшие задачи, которые должна эффективно и правильно решать ОС, оказывается можно описать и логика предложенных решений выглядит тривиальной, элегантной, то начинаешь восхищаться этой архитектурой. Книга объемистая, и некоторые вещи действительно сложны для понимания, но на меня она произвела неизгладимое впечатление в плане архитектурного мышления. Я бы посоветовал именно эту книгу в качестве примера.
Femistoklov
19.04.2018 09:15у вас будет выбор либо использовать какую-то высокоуровневую систему (быстро, возможно в ущерб гибкости решения), либо “дособрать” из низкоуровневых компонент решение, которое точнее ложится под бизнес потребности
К примеру, у вас может быть высокоуровневая компонента “уведомить пользователя о событии”. Она, исходя из настроек в профиле пользователя выбирает длинный либо короткий вариант уведомления и отсылает его либо смской либо на почту. Такая высокоуровневая компонента использует 2 более низкоуровневые: “отправить смску на номер X с содержанием Y” и “отправить имейл по адресу X с содержанием Y”.
Ага, и только автор архитектуры знает, как её использовать. Потом кто-то будет вызывать первый интерфейс, кто-то второй (просто потому что может), добавятся новые параметры, новая логика уведомлений, всё это будет делаться несогласованно между двумя этими интерфейсами и исправляться, в итоге взаимодействие поставщика и потребителя держится на костылях и неявных договорённостях, они становятся связаны, как старые приятели. А потом приходит новый сотрудник, которому дают задачу «сообщить пользователю об отрицательном балансе», он видит всю эту эволюционировавшую «красивую» архитектуру и матерится про себя, думая, что за идиоты это спроектировали.
Архитектуру надо проектировать так, чтобы её нельзя было неправильно использовать. И следить, чтобы её никто всё-таки не заюзал неправильно.bucefal91 Автор
19.04.2018 10:04Мммм, какой вы пессимист) Я с вами не согласен.
Прежде всего вопросы компетентности программистов и коммуникации внутри команды я оставил за пределами этой статьи.
Полные и абстрактные интерфейсы очень легки в своем понимании. Именно для этого я и привел пример про счетчик использования файла в Линуксе — очень простая для понимания, очень эффективная абстракция. Использовать ее не поназначению будет весьма затруднительным. Большинство правильных (с моей точки зрения) интерфейсов именно так и выглядят — их можно описать 1-2 предложениями и сложно перепутать с каким-то другим интерфейсом, используемым в программе.
Если говорить об этом примере с уведомлениями. То интерфейс большой компоненты будет выглядеть «Уведомить пользователя Х о событии У». Ведь его будет очень сложно спутать с интерфейсом «отправить смс с содержанием Х на номер У». Это 2 очень разные вещи. И мне кажется, что в будущем дополнительно расширять интерфейс что первый что второй уже не будет нужно, они уже полные… т.е. они уже полностью описиывают свою функцию.
Дополнительно, компоненту «отправить смску» можно использовать и для других целей… Допустим, ее можно использовать если что-то упало в инфраструктуре и слать смску сисадмину. Сисадмин не является пользователем системы, поэтому более абстрактная компонента «уведомить пользователя Х о событии У» тут неприменима. Компоненту «отправить имейл с содержанием Х на ящик У» можно еще использовать для отправки каких-то ежедневных отчетов сотрудникам компании.
Я искренне считаю, что эти 2 вещи спутать сложно. И да, их будет проще спутать, когда в каждый из этих интерфейсов еще напихают по полдюжины каких-то флагов/параметров сомнительного смысла и полезности. Для этого я и упомянул в статье, что перегружая интерфейс лишними подробностями мы его делаем менее полными, чем идеально сбалансированный интерфейс.iproger
21.04.2018 00:12Я думаю что все решается дополнительным уровнем абстракции: компонент для уведомления админа о неполадках.
Из этого следует что лучше абстрагироваться даже от низкоуровневых компонентов.
stanislavmachel
19.04.2018 09:38Годная статья! От себя хотелось бы добавить, что не правильно спроэктированая система, особенно сложная, дает менимое ощущение контроля ситуации в проэкте. В какой-то момент такая система начинает диктовать правила написания кода, когда програмист просто не в силах сделать какой-либо рефакторинг потому, что одна часть тянет за собой другую и ситуация превращается в карточный домик из середины которого пытаются выдернуть карту так, чтобы он не упал. Таким образом предстоящий рефакторинг может превратиться в нескончаемый ад. В правильных системах рефакторить код приятно и легко. Обычно в таких системах замена функционала не ведет к краху всей системы.
bucefal91 Автор
19.04.2018 09:40Полностью согласен =) Как раз один проект, который я собственными руками довел до состояния «карточного домика», и завставил меня вообще сесть и почухать репу на тему, как же можно писать сложные системы и при этом сохранять контроль над ситуацией. Ответ получится у меня таким, каким я его описал в этой статье :)
evocatus
19.04.2018 17:04Не так давно я увидел замечательное видео с практическим примером применения построения архитектуры снизу вверх.
Видео с конференции по Clojure, я знаю Clojure очень поверхностно, но это не помешало мне насладиться ходом мысли докладчика:
www.youtube.com/watch?v=Tb823aqgX_0bucefal91 Автор
19.04.2018 21:20Спасибо большое за видео! С удовольствием посмотрел.
Я тоже скептически отношусь к многоуровневому наследованию. Интересная логика была описана в видео. Я таким образом еще ни разу не мыслил, но я вижу, что она очень применима, особенно в тех местах, где нет расширяемости за счет дополнительных подключаемых модулей. Ведь с появлением новых дополнительных модулей, они тоже захотят где-то хранить свое состояние. И возможно тогда все состояние уже будет проблематично упаковать в одном единственном месте.
Но идея докладчика мне действительно понравилась :) Спасибо =)
bucefal91 Автор
20.04.2018 10:05Вот только что проснулся, и в голове как щелкнуло — в этом видео мужик показал куда нагляднее, чем у меня получилось в статье, понятие нескольких вертикальных слоев АПИ (я в статье про это говорил «несколько уровней детализации»).
У него в примере Картахены их было 3 и на его примере было четко видно, что:
- каждая из функций, которую он придумал, однозначно относилась к одному из 3х слоев
- каждая функция состоит из нескольких строк и активно использует функции на 1 слой ниже.
begemot_sun
Вы глаголите истины.