Опровергаю пять архитектурных заблуждений
Привет! Я Алексей, iOS-разработчик в Тинькофф. Участвую в развитии архитектуры приложения, провожу собеседования и занимаюсь менторингом разработчиков.
За мой рабочий опыт у меня сложилось ощущение, что в среде мобильной разработки есть недопонимание ключевых принципов архитектуры. Хочется десакрализировать тему архитектуры, раскрыть некоторые принципы, по которым она строится, и разобрать популярные заблуждения, с которыми я столкнулся. Расскажу, почему бизнес-логика — это не все, кроме UI, с чего начинается архитектура и в чем разница между архитектурными шаблонами и архитектурой.
Правила и требования в архитектуре
Для простоты давайте считать архитектурой приложения набор правил, которыми мы руководствуемся при написании кода, не считая ТЗ. Сюда можно отнести не только System Design, паттерны и Code Style, но и внутренние договоренности команды не задействовать сторонние библиотеки, избегать хаков рантайма и использовать современное API нативных фреймворков
Главное, чтобы наши правила были четко зафиксированы в текстовом виде и не хранились только в голове у главного архитектора. Кроме текстовой формы важна и мотивация внедрения, чтобы не создавать карго-культ. Иначе, когда причина внедрения правил устареет или отпадет, они продолжат передаваться из уст в уста от код-ревью к код-ревью на автомате, как «сакральные знания предков». Через несколько лет сотрется из памяти, какую проблему решало каждое из правил.
Например, мы не используем популярный фреймворк X, потому что в нем находят редкие баги, которые долго правятся. А для нас критична скорость исправления багов. Значит, если владельцы фреймворка X изменят подход и будут гарантированно фиксить баги в течение пары дней, правило можно отменить, а фреймворк — использовать.
Правила, в том числе архитектурные, — это просто инструмент, такой же, как языковые конструкции или любимая IDE. У них тоже есть область применимости и спектр решаемых задач, в которых они эффективны или не очень.
Любую архитектурную задачу можно сформулировать в виде вопросов. Как мы создаем модуль? Как передаем данные из одного модуля в другой? Где храним общие постоянные данные? Когда и как кэшируем сетевые запросы?
Совокупность всех вопросов, проблем и задач, на которые должна отвечать наша архитектура, — это требования.
Требования бывают функциональными и нефункциональными.
Функциональные требования говорят, что именно должен делать наш продукт: | Нефункциональные требования описывают, как именно продукт будет что-то делать: |
|
|
Держать в голове все требования и приоритеты между ними — непосильная задача, особенно когда их много. Чаще всего требования сформулированы продуктово-бытовым языком, который разработчику нужно переводить в технические постановки. Поэтому нам, как разработчикам, гораздо удобнее скомпилировать все повторяющиеся задачи и хотелки сразу в архитектурные требования.
Как понять, что правила приведут нас к победе? В чем отличие хорошей архитектуры от плохой? Если текущий набор правил позволяет писать и поддерживать фичи в вашей программе быстро, безболезненно и тестируемо, это хорошие правила и архитектура. Определение вроде бы корректное, но слишком абстрактное и неосязаемое.
Мне ближе бизнесовый подход. С точки зрения коммерческой разработки наилучшая архитектура — та, что максимально снижает затраты на разработку в долгосрочной перспективе.
Определенные фундаментальные ценности и признаки хорошего кода есть в каждой архитектуре. Любой более-менее опытный разработчик перечислит читаемость, тестируемость, расширяемость, производительность, а для мобильных устройств еще и энергоэффективность. Но приоритеты между ними расставляются уже с оглядкой на конкретный продукт.
То, что чрезвычайно важно для ядра операционной системы, — производительность — не так критично для фронтенда. И наоборот. Допустим, на проекте фронтенда во главу угла поставлены простота и читаемость, чтобы новые разработчики быстро онбордились и начинали пилить фичи. А для разработчиков ОС простота и читаемость пусть и важны, но не на первом месте. Им слишком упрощать не нужно, потому что и алгоритмы сложнее сами по себе, и в команду набирают уже опытных сеньоров.
В коммерческой разработке приоритеты направлены на то, чтобы как можно быстрее доставлять полезность до клиента, снижать Lead Time и Time to Market. Возможность добавить новые фичи или прокачивать старые — один из краеугольных камней такой разработки.
Суровая оптимизация необходима в редких случаях и только после профилирования. Преждевременная оптимизация — корень всех зол, или, в переводе на русский, лучшее — враг хорошего. Поэтому в приложениях разработчики в первую очередь налегают на простоту, читаемость и модуляризацию, а также уделяют большое внимание софтверным интерфейсам.
Архитектура начинается с команды
Для архитектуры важно все: сложность проекта, срок его жизни, объемы легаси и техдолга, а также количество разработчиков и их уровень.
Проектирование начинается со сбора функциональных и нефункциональных требований. Особенно хочется выделить одно нефункциональное требование, которое существенно влияет на архитектуру и разработку. Архитектура нужна именно разработчикам, потому ключевой аспект в ее выборе — команда.
От опыта и уровня разработчиков по большей части зависит то, насколько подробной и детальной должна быть архитектура. Чем выше уровень, тем меньше разработчику надо объяснять, где макаронный код, где чрезмерный оверинжиниринг, а где золотая середина. Если средний уровень невысок, лучше сразу «запечь» в архитектуру даже очевидные решения популярных задач, чтобы потом не ловить на ревью костыли. То есть правил нужно побольше и желательно более подробных.
Если удариться в формализм, то ключевое нефункциональное требование любой архитектуры такое: она должна соответствовать команде и закрывать все технические вопросы для основной части разработчиков. Например, для всех разработчиков уровня Senior и Middle.
Остальные функциональные и нефункциональные требования тоже сильно влияют на архитектуру. Их всегда много, и они различаются от проекта к проекту. Главный нюанс: они всегда противоречивы. Например, одновременно может быть два требования: иметь максимально тонкий клиент, но давать комфортный offline mode, или поддерживать современные плюшки с ИИ, но запускаться на деревянных счетах дедушки.
Чьи требования более приоритетны, зависит от облика нашей архитектуры. У каждого проекта свои цели и приоритеты. Если у вас простое приложение а-ля «Список дел 3000», требований немного и заморачиваться нет резона. Поэтому незамысловатую архитектуру можно скомпоновать из популярных паттернов (MVC + StateMachine + Repository).
Для крупных проектов с повышенными требованиями к оптимизации будут важны сотни мелочей, поэтому их архитектура будет содержать гораздо больше ограничений, в том числе довольно специфических. Например, шифрование экзотическим алгоритмом или исполнение юридических предписаний. Тут нет простого пути и популярных подходов либо недостаточно, либо они вообще не подойдут.
Если подытожить, архитектура приложения как хороший костюм: чтобы подходить идеально, она должна быть скроена на заказ по индивидуальным размерам, как наряд для красной ковровой дорожки. Если же требования к приложению невысоки, сгодится любая комбинация архитектурных массмаркет-шаблонов из туториалов. А чтобы архитектура подходила вашему проекту как влитая, следует избегать популярных ошибок.
Топ-5 заблуждений в вопросах архитектуры
MVC — это архитектура приложения. На самом деле нет. Так же как MVP/MVVM/VIPER. Это все архитектурные шаблоны, но не полноценная архитектура. Этот факт хорошо разобран в докладе Роберта Мартина. К тому же архитектурные задачи, которые решал оригинальный MVC, устарели.
Выбранный шаблон становится частью архитектуры всего приложения, но его одного недостаточно. Часто в статьях про мобильные архитектуры обсуждают только архитектуру UI-слоя: как передавать данные во View, получать фидбек от пользователя, как делать роутинг экранов и так далее. При этом остальная часть приложения словно не заслуживает внимания и остается за кадром. А это сбивает с толку, ведь MVC-подобные шаблоны говорят, только как отделить представление от модели, то есть рендеринг от бизнес-логики. Правда, с пониманием термина «бизнес-логика» тоже встречается путаница, которую разберем отдельно.
MVC-подобные шаблоны ничего не говорят о том, как организовать части приложения помимо View.Например, как спроектировать бизнес-логику, выстроить работу с сетевым слоем, кэшированием и базой данных, где и как обрабатывать диплинки и Push-нотификации, как взаимодействовать с системными API: CoreBluetooth, CoreAudio, Contacts. В MVC, MVVM, VIPER попросту нет буковок для этих фреймворков. А в крупном приложении работа с системными API будет занимать много кода, над организацией которого надо подумать. В итоге все оказывается в сервисах.
Ох уже эти всемогущие и безликие сервисы!
А ведь еще есть непаханое поле по модуляризации приложения, когда оперировать приходится не отдельными классами, а целыми группами и модулями. Вопросы, где проводить границы модулей, как организовать их зависимости друг от друга, как выпускать версии модулей, не менее сложные, чем те, на которые отвечает SOLID. Останавливаться не будем, за подробностями лучше обратиться к тому же Роберту Мартину и его «Чистой архитектуре».
В идеале архитектура должна отвечать на любые вопросы, особенно если они часто возникают. Поэтому она и существенно выходит за рамки паттерна MVC. Так же как работа разработчика — нечто большее, чем написание строк кода.
Разработка — это только написание кода. Тут не возразить. Разработчики, конечно же, пишут код. Но это не единственное их занятие, особенно в крупных проектах, где много разработчиков. Там они гораздо больше времени проводят, читая код, пытаясь его понять и улучшить.
Например, на нашем крупном проекте порядка 80 разработчиков. За месяц они добавляют или редактируют около 90 000 строк. На работу с кодом они в среднем тратят половину своего времени. Остальное — на созвоны, перемещение задачек в таск-менеджере, обсуждение ТЗ, налаживание процессов и прочие сторонние активности.
Тогда при общем количестве кодо-человеко-часов 80 × 20 × 4 = 6400 ч⋅ч в месяц выходит, что один разработчик задевает в среднем 14 строк в час. Причем среди этих строк есть и строки форматирования, и шаблонный код тестов, и другие несмысловые изменения вроде правки линтера, а также общий рефакторинг от платформенной команды. Осмысленных строк наберется от силы 5—7.
К аналогичным выводам приходят и другие разработчики на Stackoverflow.com, полагая, что в среднем 10—12 строк кода в день — нормальная практика. То есть и вовсе пара осмысленных строк в час. Такой показатель далек от скорости набора на клавиатуре любого разработчика. Даже если увеличить его раз в 5, получится только 20—30 смысловых строк, и это все равно довольно скромно. Выходит, что основное время разработчик тратит не на нажимание на клавиш. Но на что же тогда? По субъективному опыту, на первом месте будут:
Чтение и понимание кода. Причем не понимание «что он делает?», а «почему делает именно так?».
Поиск лучшего способа изменить этот код: добавить функциональность или исправить баг.
Подбор короткого, понятного имени для методов и свойств.
Вывод: при планировании архитектуры больше внимания стоит уделять не простоте написания, а простоте чтения кода и внесения изменений. Ваш кэп.
Каким бы очевидным вывод ни казался, он вызывает возражения, поэтому хочу остановится на нем отдельно. Если мы, для примера, ввели соглашение, которое увеличивает количество строк в четыре раза, но читаемость повышается хотя бы раза в два, это соглашение крайне выгодно. Ведь на чтение тратится раз в десять больше времени, чем на написание.
Бросаться в крайности и пытаться сэкономить лишнюю строчку — нездоровый фанатизм. Так же как переусердствовать с читаемостью или расширяемостью, создавая абстракции ради абстракций. В итоге нет большого смысла фанатично оптимизировать количество строк кода, сокращать абстракции, экономить классы и всячески пытаться запилить фичу за пару строк, если от вас этого не требуют. Особенно если это не идет на пользу читаемости и расширяемости. Получится экономия на спичках. Все равно большая часть времени разработчика тратится на другую работу.
В первую очередь нужно оптимизировать когнитивную нагрузку при работе с кодом, учитывая не только написание нового кода, но и его поддержку. Бойлерплейт же проще оптимизировать кодогенерацией и макросами, не ударяясь в аскетизм с отказом от всех абстракций.
Стоит делать упор не на легкость написания кода, а на легкость его восприятия. И когда возникают противоречия в требованиях, стоит разрешать их в пользу снижения когнитивной нагрузки. Чем меньше когнитивная нагрузка сервисной логики, тем больше у разработчика остается сил и внимания на ключевую логику.
Бизнес-логика — это все, кроме UI. При общении с подопечными или собеседуемыми часто встречаюсь с непониманием, что считать бизнес-логикой, а что — сервисной, которая просто обслуживает бизнес-логику. Разберем на примере игр.
Практически любую настолку можно перевести в пошаговую стратегию. Примеров масса, и многие серии берут свое начало как раз от настолок. Иногда можно даже портировать в обратную сторону — из компьютерной игры в настольную. Такие серии, как Heroes III, Hearthstone, Warhammer 40 000, имеют по две реализации, и у обеих реализаций есть что-то общее, кроме лора.
Настолка — та же программа, только алгоритмы написаны на естественном языке и предназначены для выполнения человеком, а не компьютером. В обеих версиях — компьютерной и настольной — используются одни и те же доменные модели: герои, юниты, строения, характеристики персонажа, заклинания и так далее.
Именно с помощью доменных высокоуровневых моделей описывается бизнес-логика, в нашем примере это игровые правила. Неважно, в каком виде представлены доменные сущности: как картонка с текстом, json-объект или ООП-класс. Все они служат одной цели — описанию игрового процесса. И этот процесс должен правильно работать.
Так, в обеих версиях игры правила должны обеспечивать рабочие игровые механики, баланс фракций, эмоциональный баланс и другие аспекты, которые делают игру увлекательной и захватывающей. Можно обойтись без плавных анимаций и фотореалистичной графики, если ключевая логика с игровыми механиками работает. А если она не работает, сломан баланс, нет экшена или играть попросту неинтересно, не поможет ни графика, ни сверхоптимизация, ни маркетинг.
Другими словами, бизнес-логика — это такая логика, без которой ни игра, ни ваше приложение не имеют смысла. Бизнес-логика занимает центральную роль в вашем приложении.
Программы могут существовать без базы данных, сетевого слоя и, как ни странно, даже без UI. Но они не могут существовать без своей сути, своих сущностей. Список дел не может обойтись без объектов Task и List, а онлайн-магазин — без объектов Product, Cart и Order.
Эти сущности и логика, непосредственно с ними связанная, и есть бизнес-логика. А все остальное — мапперы, сервисы, UI и базы данных — только помогает ей, обслуживает ее потребности и считаются сервисной логикой.
Самый верхний слой — это UI. При обсуждении архитектуры часто речь заходит о слоях и их зависимости друг от друга. Все согласны с принципом инверсии зависимости (буква D из SOLID), но не все его понимают в полной мере. Что считать модулем верхнего уровня? Что нижнего? И где вообще это направление?
Проверим нашу ориентацию в архитектуре (где верх, где низ) и попробуем сгруппировать в 4 слоя, от верхнего к нижнему, такие сущности: UI, Presenter, ViewModel, Interactor, Reducer, UseCase, Repository, NetworkingService, BluetoothService, DataBaseFrameworkService, Entity, DomainModel.
Получим примерно следующее:
Entity и DomainModel.
Interactor, Reducer, UseCase.
Repository, Presenter, ViewModel.
UI, NetworkingService, DataBaseService, BluetoothService.
Если у вас иначе, давайте разберемся.
Первый вопрос, который мог возникнуть: что это UI делает в самом низу и что у нас тогда на самом верхнем уровне? Нередко встречается мнение, что UI выше всех, потому что он ближе к пользователю. Но давайте взглянем на принцип инверсии зависимости — DIP:
A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Если UI будет выше всех, он не должен зависеть от ViewModel и Presenter? Понятно, что они будут зависеть от общих абстракций (протоколов), но к какому слою должны относится эти абстракции — UI или Presenter? Чтобы соблюсти принцип, нам нужно разместить абстракции (интерфейсы) в верхнем слое, иначе нарушим правило A.
Но если верхним будет UI, значит, все модули должны зависеть от него? Даже слой бизнес-логики, хотя и опосредованно? Надеюсь, понятно, что это ошибка и так лучше не делать. Поэтому при соблюдении DIP в шаблонах MVP и MVVM UI будет зависеть от слоя Presenter или ViewModel.
Напоминалка по Чистой архитектуре
Чтобы легче ориентироваться в диаграмме чистой архитектуры, можно представить ее как вид сверху на детскую пирамидку. На самом верху — доменные сущности. От слоя сущностей зависит слой Use Case, где сосредоточена бизнес-логика. От слоя Use Case через прослойку интерфейсных адаптеров (Presenter, ViewModel) уже зависит слой фреймворков, где данные покидают наше приложение или, наоборот, приходят извне.
С бэкендом наше приложение общается через сетевой фреймворк (Foundation, Alamofire), с пользователем — через UI (UIKit, SwiftUI), с соседним телефоном — через Bluetooth (CoreBluetooth)
В UI логику не держим. Часто встречается утверждение, что UI должен быть максимально глупым и всю логику из него следует выносить. Это справедливо, только если имеется в виду бизнесовая логика. В любом другом случае слой может быть очень умным и сложным, если этого требует задача. Задача UI — получить данные от пользователя и рендерить картинки. И ничто не мешает ему инкапсулировать в себе всю сопутствующую логику.
Целые горы логики спрятаны от наших глаз и инкапсулированы в UIKit
С выходом iOS 15 Apple на примере AsyncImage показала, что можно доверить загрузку картинок и UI-слою. Многие разработчики обрадовались, некоторые засомневались. Были и те, кто задумался: корректное ли это решение? И если да, то только для SwiftUI или и для UIKit тоже?
Дело не только в желании упростить API для начинающих разработчиков. Такое решение имеет весомые причины и с точки зрения архитектуры. Рассмотрим эволюцию работы с картинками поэтапно:
Первая версия приложения. Картинки для простоты хранятся в самом приложении.
В какой-то момент приходят аналитики с недоумением: почему наше приложение с тремя экранами весит 200 МБ? Это сильно влияет на процент загрузок. Надо оптимизировать.Не вопрос. Переносим все картинки на сервер. Приложение становится легким.
Но на этот раз приходят из поддержки с проблемой от пользователей. Оказывается, постоянные загрузки картинок забивают трафик и нагревают телефон, сажая батарею.Известная проблема — известное решение. Настраиваем кэширование картинок.
Каждый раз менялся только способ хранения ресурсов, бизнес-логика никак не затрагивалась. Более того, сетевая часть приложения по получению бизнесовых данных в виде JSON не изменилась. Да и вообще, в идеале наш бэкенд и CDN с хранилищем картинок — это буквально разные эндпоинты и даже разные ЦОДы.
То есть перенос картинок в облако — это оптимизация UI-слоя. И раз оптимизируем только его, то все связанное с этой оптимизацией логично хранить в UI-слое.
Как в таком случае работать с инвалидацией кэша, можете спросить вы. Отвечу так: это зависит от выбранного контракта. Все уже придумано за нас, и нам осталось выбрать. Есть и HTTP-коды (304 Not Modified), и специальные HTTP-заголовки (cache-control, tag). А если картинка не критичная, можно просто положиться на время жизни кэша 20 минут и не усложнять. Кэширование — решенная задача, изобретать свое решение не стоит.
В случае с AsyncImage настроить кэширование можно через URLSession.shared. DI на синглтонах не то чтобы идеал для подражания, но прост в использовании и не навязывает сторонние правила по инжекции зависимостей.
Для нас важнее то, что здесь мы можем провести воображаемую черту между нашим приложением и модулем UI-фреймворка со своими доменными сущностями, приоритетами и бизнес-логикой. Нижним слоем у которого выступает железо графической подсистемы.
А если в этом модуле есть задача загружать ресурсы по сети для собственных нужд, то почему бы и нет.
Контракт для UI-слоя действительно должен быть максимально простым, и UI не должен делать то, что не относится непосредственно к рендеренгу пользовательского интерфейса. То есть он не должен заниматься конвертированием данных и ни в коем случае не должен содержать ни капли бизнес-логики. Но что касается отрисовки, тут полная свобода и логики может быть сколько угодно, если это соразмерно задаче. То же самое можно сказать и про другие фреймворки.
На прощание
Я хотел поделиться взглядом на заблуждения, с которыми довелось столкнуться на собственном опыте. Некоторые из них приводили не только к проблемам в коде, но и к холиварам в команде. Поэтому важно не только разобраться самим, но и синхронизировать понимание базовых понятий в команде.
Надеюсь, у меня получилось немного снизить порог в понимании базовых принципов архитектуры. Чтобы вместо абстрактных и умных слов удавалось различать в ней конкретные инструменты, которые полезны в работе. Будет здорово, если этот опыт поможет лучше понять общепринятые архитектурные подходы и сократить количество ошибок при проектировании.
А если у вас есть вопросы или желание поделиться своими наблюдениями — жду в комментариях!