Признак плохого дизайна N1:
Наличие объекта-«бога» с именем, содержащим «Manager», «Processor» или «API»
Ведущий iOS-разработчик Redmadrobot Егор beptep Тафланиди — о том, как добиться стройного архитектурного дизайна мобильного приложения, используя классические шаблоны проектирования и логическое разделение исходного кода на модули.
Все архитекторы, которых я встречал, в разной степени обладали одной и той же профессиональной деформацией характера: в своей речи они старались избегать конкретики. Подобный подход легко понять, ведь суть гибкости любой системы заключается в абстрагировании от конкретных решений. Чем дольше решение может оставаться отсроченным — тем гибче система. Если UI достаточно абстрагирован от модулей уровня сохранения данных — то считывание файла с жёсткого диска с лёгкостью можно будет подменить скачиванием той же информации с серверного API.
И все же. Попробуем разложить по полочкам архитектурный дизайн типичного iOS-приложения. Я опишу глобальный подход к делению логики приложения на уровни и слои.
Привожу конкретных участников тех или иных процессов, каждый несёт свою собственную ответственность и по-особому взаимодействует с окружением.
Установка
TL;DR: ПРОГРАММИСТЫ — ТОЖЕ НАУЧНЫЕ СОТРУДНИКИ
Студенты высших учебных заведений изучают естественные науки, учатся менеджменту, получают знания о прикладной психологии и пр. Каждая наука несёт в себе обязательный багаж накопленных сведений — академические знания, без которых продвижение самой науки вперёд было бы затруднительно. Без этого фундамента от науки не было бы и практической пользы: технологу на производстве гораздо выгоднее использовать проверенные временем подходы, гарантированно дающие результат, чем придумывать что-то своё. Естественно, сам этот фундамент постоянно эволюционирует и достраивается новыми сведениями, практиками.
За спиной программирования стоит точно такая же строгая наука, как и, например, за процессом производства аспирина. Информатика, как типичный представитель формальных наук, обладает тем же набором и структурой формальных методов, использует наработанные, проверенные подходы к изучению нового, и, естественно, к ней прилагается собственный багаж академических знаний. Научный подход гарантированно ведёт к получению качественного результата, а знание и применение базовых принципов проектирования ПО гарантированно облегчит поддержку продукта.
Книга «Язык паттернов» Кристофера Александра, давшая толчок в сторону использования шаблонов в программировании, была выпущена в 1977 году, а паттерн Model-View-Controller описан в 1979-м.
Вооружившись наработками в области разработки ПО и надев белый халат теоретика, попробуем выстроить типичный проект приложения для мобильной платформы.
N.B. Скорее всего, в реальной жизни (с учётом бизнес-требований к системе и принимая во внимание сроки разработки) ваш проект будет выглядеть несколько иначе. Тем не менее, можно будет с уверенностью сказать, что за спиной подобного продукта стоит не просто ваша выдумка, а обоснованный набор решений.
Дано
СФЕРИЧЕСКАЯ СИСТЕМА В ВАКУУМЕ
Итак, у нас есть:
- сервер с документированным API из нескольких web-сервисов;
- приложение-клиент и несколько типов устройств, которые это приложение должно поддерживать: планшеты, телефоны, часы и прочее;
- макеты того, как должен выглядеть интерфейс приложения;
- набор пользовательских историй, декларирующих, как приложение должно себя вести;
- список нефункциональных требований, которым система должна соответствовать.
Модель песочных часов
СИТХОВ ВСЕГДА ДВОЕ: ОДИН СТРОИТ СЕРВИСЫ, ДРУГОЙ ВОЗДВИГАЕТ НА НИХ UI
Первым же шагом отделим модель от контроллера и представления, таким образом выделив два уровня приложения: «нижний» обращён в сторону серверных мощностей, «верхний» смотрит на пользователя.
На проектах мобильных приложений слои контроллера и представления, как правило, довольно органично способны разрабатываться одним человеком, в то время как остальная бизнес-логика, касающаяся персистентности и обработки данных, безболезненно может быть доверена второму разработчику. Тем самым мы сразу распараллеливаем процесс изготовления ПО на два независимых потока.
Будем придерживаться классического принципа, который гласит, что слои приложения общаются между собой посредством модельных объектов — классов с «прозрачной» реализацией, состоящей исключительно из акцессоров и мутаторов (get- и set-методы; фактически, класс будет включать в себя только свойства). Подобные классы не несут с собой никакой логики, и предназначены лишь для хранения и перемещения данных.
Центральной сущностью приложения — «горлышком» песочных часов — будет выступать объект, иначе называемый «инвертор зависимостей». Он же станет единственным singleton-классом, и на нём будет лежать единственная ответственность — предоставлять «верхним» слоям приложения сервисы.
Сервисы
ИСТОРИЯ ШТЕПСЕЛЬНОГО СОЕДИНЕНИЯ
Как делаются нормальные серверные приложения? Очень просто. Всегда есть какая-то база данных (или их может быть несколько), в которой есть таблицы. Таблицы состоят из записей. Каждая запись содержит набор полей, в которых хранится информация.
Записи представляются в виде модельных объектов с полями, тем самым в приложении возникает логическое разграничение по типам данных: одна таблица — один тип данных. Таблица «Пользователи» — тип данных «Пользователь» (поля: ID, ФИО, адрес). Таблица «Сообщения» — тип данных «Сообщение» (поля: ID, Тема сообщения, Тело сообщения, Кому, От кого, Дата). И так далее.
Таким образом, вся логика крутится вокруг таблиц и типов данных:
1. Модельный объект, представляющий тип данных.
2. Сериализация модельного объекта для сохранения его в таблицу.
3. Десериализация модельного объекта из базы данных.
4. Преобразования модельного объекта: подсчёт какой-то статистики,
сортировка данных, поиск данных и т.д.
5. Сериализация модельного объекта для передачи его по сети.
6. Десериализация модельных объектов, приходящих по сети.
7. Выставленный в сеть web-сервис, соответствующий данному типу
данных.
Всё это выстраивается в своего рода стек, а само серверное приложение состоит из нескольких таких независимых стеков, каждый из которых соответствует какому-то типу данных.
Грубо говоря, если по спецификации API у вас имеется web-сервис api.domain.com/1-0/messages с классическим CRUD-интерфейсом — это означает, что за ним стоит вышеупомянутый «стек» для типа данных «сообщение».
Create = POST;
Read = GET;
Update = PUT;
Delete = DELETE.
N.B. Существуют различные интерпретации операторов POST и PUT. В одних реализациях POST отвечает за создание сущностей, в других — за обновление. Канонического трактования, увы, не существует, но в этом нет ничего страшного.
Как правило, сервер поддерживает стандартизованный набор запросов, дифференцируемый посредством URL-суффиксов к соответствующему web- сервису:
{api}/messages/{id} — операции с сущностью по заданному ID;
{api}/messages/{id}/{property} — операции с полем {property} сущности с ID = {id}.
ИТОГО:
Всё, что нам следует сделать — это изготовить вилку к розетке.
А именно:
1. Оформить транспортный уровень приложения в виде сущности, которая будет инкапсулировать под собой HTTP-соединение вместе со всеми его настройками (включая безопасность, таймауты и пр.)
• Интерфейс сущности должен представлять собой точно такой же CRUD, как и выставленный в Интернет web-сервис. Если есть доступ к адресу {api}/messages — должны быть соответствующие четыре метода; если есть доступ к GET {api}/messages/{id}/{property} — сделайте отдельный метод, который будет получать данные этой {property}.
• Сущность обязана быть модульной. Если в один прекрасный момент ваш замечательный web-сервис {api}/messages перестанет функционировать, вам достаточно будет реализовать одну-единственную сущность, повторяющую интерфейс транспортного уровня, но берущую данные с
файловой системы.
2. Оформить парсеры и сериализаторы, которые будут генерировать модельные объекты и, наоборот, преобразовывать их в вид, удобоваримый для вашего транспортного уровня.
•Парсеры и сериализаторы несут в себе знания о том, как преобразовывать модельные объекты в словари а-ля JSON или XML. Не пытайтесь прикрутить эти знания к самим модельным объектам — они никак не относятся к другим слоям приложения.
• Существует русскоязычный термин «топографический преобразователь», который мог бы заменить англоязычные «парсер» и «сериализатор», но только вот сам термин как-то не прижился…
3. Оформить сущность, которая будет отвечать за кеширование.
• Сущность должна просто уметь потокобезопасно предоставлять доступ к полученным данным, переписывать и обновлять эти данные более свежими.
4. Оформить сам «сервис» — сущность, которая будет ответственна за координацию транспортного уровня, парсеров, сериализаторов и кеша.
• Интерфейс сервиса должен полностью соответствовать бизнес-требованиям слоя UI. Если для UI необходимо получить какую-то сущность — должен быть метод для получения этой сущности. Если UI требует сохранить сущность с определёнными параметрами — у сервиса должен быть метод с этими параметрами. В случае, если UI требует массив объектов данного типа, отсортированный по какому-то критерию, — сервис должен предоставлять соответствующий метод, который вернёт отсортированный массив.
• И да, не забываем классический принцип: слои приложения общаются между собой посредством модельных объектов. Использование словарей/map для передачи данных недопустимо — только строгая типизация.
Таким образом, мы фактически делаем свой собственный стек, соответствующий серверному, но действующий в обратную сторону. Вот так будет выглядеть последовательность из шагов, которые необходимо преодолеть данным, чтобы добраться от центрального хранилища до конечного пользователя:
Сколько типов данных — столько и сервисов. Эту цифру довольно просто вычислить из спецификации API.
N.B. «Чистые» сервисы в природе встречаются не так часто. Бывает, тот или иной сервис обслуживает сразу несколько типов данных. Это обусловлено тем, что модельные сущности могут быть вложенными друг в друга: одна сущность может нести в себе массив объектов другого типа, или иметь подобный объект в качестве свойства.
В этом нет ничего страшного, просто ваши парсеры и сериализаторы должны адекватно воспринимать подобного рода вложенность, и генерировать объекты соответствующих типов.
Сервисы обязаны быть максимально автономными, что не мешает вам выстраивать между ними логические зависимости. К примеру, это вполне логично, что в приложении есть сервис, отвечающий за авторизацию и поддержание сессии с сервером, а остальные сервисы зависят от него — используют предоставляемый им token для формирования запросов.
N.B. Каждый из сервисов так или иначе будет обладать CRUD-подобным интерфейсом, построенным вокруг типа данных, обрабатываемых этим сервисом. Это может служить отличным поводом для выделения абстрактного сервиса с абстрактным CRUD-интерфейсом для последующего наследования от него других сервисов, что обеспечит высокую степень переиспользования кода.
Выстраивание уровня model подобным образом суть реализация паттерна слоистой архитектуры с наложением идей сервис-ориентированной архитектуры.
Мы не изобрели ничего нового.
Уровень UI
ВРЕМЯ ЗАМЕЧАТЕЛЬНЫХ ИСТОРИЙ
Итак, вы пришли на проект, скачали с репозитория исходники разрабатываемого приложения. Какой самый простой способ выяснить, что это приложение делает? Правильно! Запустить его! Перед вами знакомые таблицы с ячейками, стилизованные кнопки, барабаны с датами, навигация, названия экранов… Теперь задача: как соотнести это всё с исходным кодом, который разбросан по нескольким сотням файлов, что маячат перед вами в IDE?
Мы уже обособили один из уровней приложения — фактически model теперь может выступать совершенно самостоятельным модулем, который можно переподключить к какому-нибудь другому проекту, использующему тот же back-end. Или даже переиспользовать для другого back-end’a, если вы соблюли все принципы проектирования и сохранили определённый уровень абстракции.
Но вот незадача: у нас ещё целых два (!) слоя, а единственная намётка на то, что и где в исходных кодах находится — это само работающее приложение, запущенное на устройстве или в эмуляторе. Тут-то на помощь и приходят пользовательские истории.
Если ваше приложение хоть сколько-нибудь соответствует здравому смыслу, оно будет следовать определённым пользовательским историям, которые декларируют сценарии, необходимые для реализации того или иного действия. Самый классический пример — это истории регистрации и авторизации. Во время регистрации пользователь вводит какие-то личные данные, эти данные проверяются на адекватность, задаются логины и пароли, всё это может быть сдобрено мерами безопасности в виде SMS и так далее. Соответственно, в приложении существует целая история, касающаяся регистрации. Следуя логике, дабы облегчить собственную жизнь, наиболее очевидным решением будет следование этим пользовательским историям при построении структуры уровня UI.
Более того, SDK приложений под iOS предоставляют весь необходимый для этого инструментарий: достаточно просто завести под каждую историю свой Storyboard — и разделить структуру проекта так, чтобы все классы строились вокруг этих историй. Далее эти Storyboard’ы можно даже динамически соединять друг с другом посредством нехитрых библиотек, но это уже детали реализации.
В конечном итоге у вас получится структура, разделенная на модули по историям, в каждом модуле будут свои представления и контроллеры с классами-утилитами, и в этой структуре можно будет легко ориентироваться, исходя лишь из того, что у вас творится на экране устройства/эмулятора.
N.B. И да, конечно, не забываем классические принципы проектирования. Применяем SOLID, DRY, KISS и YAGNI по полной программе, главное — не лениться, и разделять сущности в соответствии с ответственностью, которую они несут. Остальное подскажет опыт.
Можно набросать приблизительную диаграмму классов:
Естественно, совершенству нет предела, и данный подход не претендует на звание эталонного.
Заключение
ИТОГИ И ОБСУЖДЕНИЕ
Мы кратко рассмотрели, как можно возвести стройный архитектурный дизайн мобильного приложения. Для этого мы применили сплав из классических шаблонов проектирования (MVC, SOA и Multilayered Architecture), прибегнув также к логическому разделению исходного кода на модули, основываясь на пользовательских историях.
А теперь небольшой вопрос к community:
Использовал ли кто-нибудь на боевых проектах архитектуру View-Interactor- Presenter-Entity-Router, и каким образом было достигнуто переиспользование сущности/шаблона Router? Как можно избежать разрастания Router’a?
Автор: redmadrobot