Хабрахабр

Архитектура кода

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

Проблемы, симптомы

Мой начальный опыт программиста был весьма безоблачным – я без лишних проблем клепал вебсайты-визитки. Писал код, как я это сейчас называю “в строчку” или “полотном”. На маленьких объемах и простых задачах все было хорошо.

Естественно, сложность этого кода была несопоставима с визитками из моей прошлой работы. Но я сменил работу, и пришлось разрабатывать один единственный вебсайт в течение 4-х лет. Было ощущение, что я просто хожу по кругу – пока чинил “здесь”, сломал что-то “там”. В какой-то момент проблемы просто посыпались на меня – количество регрессии зашкаливало. И поэтом это “здесь” и “там” банально менялось местами и круг повторялся.

Все эти 4 года проект активно разрабатывался – мы улучшали уже существующий функционал, расширяли, достраивали его. У меня исчезла уверенность в том, что я контролирую ситуацию – при всем моем желании недопустить баги, они проскакивали. Банально, я вышел на порог, через который уже не мог переступить, продолжая писать код “в строчку”, без использования архитектуры. Я видел и чувствовал, как удельная стоимость каждого нового рефакторинга/доработки растет – увеличивался общий объем кода, и соответственно увеличивались затраты на любую его правку. Код с этих источников выглядел “глянцево” красивым, естественным и интуитивно понятным. Но в тот момент, я этого еще не понимал.
Другим важным симптомом оказались книги и видео-уроки, которые я в то время читал/смотрел. Видя такую разницу между учебниками и реальной жизнью, моей первой реакцией была мысль, что это нормально – в жизни всегда сложнее, чем в теории, больше рутины и конкретики.

В тот же самый момент я начал активно участвовать в одном open source проекте. Тем не менее, продукт на работе нужно было расширять, улучшать, в общем, двигаться дальше. И в совокупности эти факторы вытолкнули меня на путь архитектурного мышления.

Что такое архитектура?

Один мой преподаватель в университете употребил фразу “нужно проектировать так, чтобы максимизировать количество объектов и минимизировать количество связей между ними”. Чем дольше я живу, тем больше с ним соглашаюсь. Если присмотреться к этой цитате, то видно, что эти 2 условия в какой-то мере взаимоисключающие – чем больше мы дробим какую-то систему на подсистемы, тем больше связей придется вводить между ними, чтобы “соединить” каждую из подсистем с остальными актерами. Найти оптимальный баланс между первым и вторым – это своего рода искусство, которым, как и прочими искусствами, можно овладеть через практику.

Для того, чтобы выделить из сложной системы какую-то подсистему, нужно определить интерфейс, который будет декларировать границы между первым и вторым. Сложная система дробится на подсистемы за счет интерфейсов. Представьте, у нас была сложная система, и вроде бы внутри нее осязаются некоторые подсистемы, но они “размазаны” по разным местам основной системы и четкий формат (интерфейс) взаимодействия между ними отсутствует:

image

С минимизацией связей все отлично 🙂 Но вот количество систем очень маленькое. Посчитаем, де факто, у нас 1 система и 0 связей.

Это значит, что границы подсистем определены и схема стала следующей: А теперь, кто-то проделал анализ кода, и четко выделил 2 подсистемы, определил интерфейсы, по которым ведется коммуникация.

Обратите внимание, что количество функционала осталось тем же самым – архитектура ни увеличивает, ни уменьшает функционал, это просто способ организовать код. Здесь у нас: 3 системы и 2 связи между ними.

При рефакторинге в первом случае нам нужно “прочесать” 100% кода (весь зеленый квадрат), чтобы убедиться, что мы не внесли никакой регрессии. Какое различие можно разглядеть между этими двумя альтернативами? И потом весь наш рефакторинг сведется к прочесыванию лишь одной из 3х систем. При том же самом рефакторинге во втором случае, нам сначала нужно определить, к какой системе он относится. За счет успешного дробления архитектурой, нам достаточно сконцентрироваться лишь на части кода, а не на всех 100% кода. Задача упростилась во втором случае!

Но существует еще и вторая часть цитаты – минимизация связей между ними. На этом примере видно, почему дробить на максимальное количество объектов выгодно. Тогда дела плохи – изменения в интерфейсе подразумевают изменения по оба конца этого моста. А что, если новая доработка, которая пришла к нам от начальства затрагивает сам интерфейс (красненький мост между 2мя системами)? А чем проще каждый из интерфейсов, тем проще будет внести необходимые изменения по обе стороны интерфейса. И как раз чем меньше у нас связей между системами, тем меньше вероятность того, что наш рефакторинг вообще затронет какой-либо интерфейс.

Интерфейс в самом широком смысле

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

Не должно быть коммуникаций между системами за пределами интерфейса. Прежде всего, он должен быть честным. Актер по одну из сторон интерфейса не должен иметь никакого понятия о внутреннем устройстве актера по другую сторону моста – не больше чем то, что подразумевает интерфейс, по которому они взаимодействуют, т.е. Иначе мы скатимся к исходному варианту – диффузия (да, она в программировании тоже есть!) объединит 2 системы обратно в одну общую систему.
Интерфейс должен быть полным. Делая интерфейс полным изначально, мы значительно уменьшаем шансы того, что в будущем придется править интерфейс – вспомните, внесение изменений в интерфейс – это самая дорогая операция, т.к. интерфейс должен полным (достаточным для наших нужд) образом описывать партнера по “ту сторону моста”. она подразумевает изменения в более, чем одной подсистеме.

Я считаю, что достаточно честности, полноты интерфейса и вашего ясного понимания этого интерфейса. Интерфейс не обязательно должен быть задекларированным как интерфейс из ООП. Важна не форма, а суть. Более того, интерфейс такой, как я его подразумеваю в рамках этой статьи – это нечто шире чем интерфейс из ООП.

Границы между каждым из сервисов – это ни что иное, как интерфейс, о котором я повествую в этой статье.
В качестве примера я хочу привести счетчик использования файла в inode на *nix (file reference count): есть интерфейс – если ты используешь файл, то увеличь его счетчик на 1. Здесь будет уместным упомянуть архитектуру микросервисов. Когда счетчик равняется нулю, значит этим файлом уже никто не пользуется, и его нужно удалить. Когда закончил им пользоваться, уменьши его счетчик на 1. он не накладывает абсолютно никаких ограничений на внутреннее устройство актера, который им может пользоваться. Такой интерфейс неописуемо гибкий, т.к. В этот интерфейс органично вписывается как использование файлов в рамках файловой системы, так и файл декскриптор из исполняемых программ.

Решайте задачу на абстрактном, а не конкретном уровне

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

Какое решение задачи “открыть входную дверь” вам кажется более правильным?

  1. Подойти к двери;
  2. Достать из кармана связку ключей;
  3. Выбрать нужный ключ;
  4. Открыть им дверь.

Или:

  1. Подойти к двери;
  2. Вызвать подсистему “хранилище ключей” и получить из нее доступную связку ключей;
  3. Вызвать подсистему “поиск правильного ключа” и запросить у нее наиболее подходящий ключ к текущей двери из связки доступных ключей;
  4. Открыть дверь ключем, предложенным подсистемой поиска правильного ключа.

Абстрактность второго алгоритма в разы выше, чем первого, и как следствие, его полнота тоже выше. Банально у второго алгоритма куда больше шансов остаться актуальным даже 50 лет спустя, когда понятие “ключей” и “дверей” будет отличаться от сегодняшнего 🙂

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

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

Физика

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

Любую подсистему можно рекурсивно дробить дальше на под-подсистемы. Дело в том, что использование разных уровней детализации в архитектуре кода тоже очень выгодно. Это divide and conquer (разделяй и властвуй) подход. Подсистема станет выступать системой, и мы будем в ней искать подсистемы. Таким образом программу любой сложности можно объяснить на удобном собеседнику уровне детализации за 5 минут за кружкой пива другу программисту или начальнику нетехнарю на корпоративном собрании.

Все можно рассматривать на уровне медиапроигрывателя (читаем содержимое фильма, декодируем в видео, показываем на мониторе). Как пример, что происходит в нашем ноутбуке, когда мы включаем фильм? Кстати, в случае SSD диска последний список шагов был бы другим – и в этом вся прелесть, т.к. Можно рассматривать на уровне операционной системы (читаем с блочного устройства, копируем в нужные страницы памяти, “просыпаем” процесс плеера и запускаем его на одном из ядер), а можно же и на уровне драйвера диска (соптимизировать i/o очередь на устройство, прокрутить до нужного сектора, считать данные). Более того, интерфейс блочного устройства был придуман задолго до появления 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 для физического хранения данных. В противном случае интерфейс будет красивым, но неполным, ибо с учетом требований производительности, полный интерфейс должен предоставлять быстрый и эффективный инструментарий для массового взаимодействия с сущностями. Тогда нужно сознательно включить в интерфейс структуру БД, где эти сущности хранятся.

Творение архитектуры

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

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

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

Теги
Показать больше

Похожие статьи

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

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть