Модульная архитектура — очень популярная тема в разработке программного обеспечения. По мере роста монолитного приложения оно становится все менее и менее удобным в сопровождении, и возникает необходимость разделить его на отдельные модули. На бэкенде это микросервисы, а в вебе — микрофронтенды. В этой статье мы покажем, как это работает в iOS.
В предыдущей статье мы увидели, как создать приложение с использованием чистой архитектуры + MVVM. Здесь мы покажем, как улучшить ваш проект, разделив ваше приложение на изолированные модули (например, NetworkingService, TrackingService, ChatFeature, PaymentsFeature…). Изоляция модулей может помочь командам работать с этими модулями быстро и независимо.
Монолит неплох, когда мы используем хорошую архитектуру, такую как Чистая Архитектура + MVVM. Тем не менее, приложение может стать слишком большим, чтобы быть монолитом. Кроме того, необходимо создавать более быстрые сборки, повторно использовать модули в качестве фреймворков и изолированные модули, чтобы люди могли легко работать в отдельных кросс-функциональных командах.
Приложения крупных компаний обычно разбиты на десятки модулей. В этом году нам удалось разделить многомиллионное монолитное приложение OLX на 12 модулей. И теперь мы всегда создаем новый модуль для новых достаточно больших функций.
Мы называем это модульной архитектурой, и она также применима к другим платформам (например, Android или кроссплатформенным, таким как ReactNative или Flutter). В этой архитектуре приложение разделено на полностью изолированные модули функций, которые зависят от общих и основных модулей.
В этой статье мы покажем, как разделить монолитное приложение на изолированные модули: сетевую службу и функцию поиска фильмов. Модуль сетевой службы настраивается внутри приложения и внедряется в функцию поиска фильмов:
И в конце статьи мы увидим, как его можно масштабировать до больших размеров, где каждый модуль имеет свою Чистую Архитектуру + MVVM, Домен и DIContainer:
Примечание: подробное описание этого графика приведено ниже в разделе: Как масштабируется
модульная архитектура.
Модульная архитектура
Модульное программирование — это метод проектирования программного обеспечения, в котором основное внимание уделяется разделению функциональности программы на независимые взаимозаменяемые модули, каждый из которых содержит все необходимое для выполнения только одного аспекта желаемой функциональности.
Здесь мы используем CocoaPods в качестве менеджера зависимостей для разделения приложения на изолированные модули. CocoaPods — это мощная система управления зависимостями, которая делает интеграцию фреймворков очень простой и удобной. Другими менеджерами зависимостей, которые мы могли бы использовать, являются Swift Package Manager или Carthage.
Мы создаем новый функциональный модуль, когда функция достаточно велика, чтобы ее можно было вызывать или рассматривать как СОБСТВЕННЫЙ продукт. И он может быть разработан изолированно кросс-функциональной командой.
Каждый модуль может иметь свою собственную архитектуру. Каждая команда решает, какая архитектура лучше всего подходит для разработки их модуля (например, чистая архитектура + MVVM, Redux..) и домен. Это означает, что все объекты домена модуля будут извлечены из API внутри этого модуля и отображены здесь. Примечание: когда мы хотим поделиться некоторыми объектами домена, такими как пользователь, мы создаем модуль CommonDomain.
Каждая функция изолированного модуля будет иметь свой собственный контейнер внедрения зависимостей, чтобы иметь одну точку входа, где мы можем видеть все зависимости и внедрения модуля.
Каждый модуль будет иметь пример/демонстрационный проект для его быстрой изолированной разработки без компиляции всего приложения.
Тесты каждого модуля также будут выполняться быстро без основного приложения. Они будут с исходным кодом модуля.
Важно отметить, что модули являются локальными, что означает, что они находятся в том же репозитории (Monorepo), что и основное приложение, внутри папки с именем DevPods. Мы делаем их удаленными в отдельном репозитории только тогда, когда хотим поделиться ими с другим проектом. Это имеет смысл до тех пор, пока новому второму проекту не понадобится использовать этот модуль, тогда его можно будет очень легко переместить в собственное репо.
Как правило, мы стараемся свести к минимуму использование сторонних фреймворков и в этой архитектуре. Если модуль должен зависеть от сторонней платформы, мы пытаемся изолировать это использование с помощью оболочек и ограничиваем использование этой зависимости только одним модулем. Мы объясним это на примере в разделе ниже: Начальное масштабирование приложения — добавление модуля аутентификации со сторонней платформой.
Примечание. В этой статье модуль и фреймворк имеют эквивалентное значение. потому что способ создания нового модуля в Xcode заключается в создании новой структуры. А DIContrainer — это контейнер внедрения зависимостей. Пример приложения функционального модуля — означает, что это демонстрационное приложение этого функционального модуля, и оно используется для разработки функции.
Преимущества модульной архитектуры
Время сборки быстрее. Изменение одного функционального модуля не повлияет на другие модули. И приложение будет компилироваться быстрее, перекомпилируя только измененный модуль. Компиляция большого монолитного приложения намного медленнее, чем компиляция изолированной части (как чистой, так и инкрементной сборки). Например, наше чистое время сборки всего приложения составляет: 4 минуты по сравнению с примером/демонстрационным приложением модуля платежей: 1 минута.
Время разработки быстрее. Улучшение заключается не только в скорости компиляции, но и в доступе к экрану, который мы разрабатываем. Например, во время разработки модуля из примера проекта вы можете легко открыть этот экран в качестве начального экрана при запуске приложения. Вам не нужно проходить все экраны, как это обычно происходит при разработке в основном приложении.
Изоляция изменений. При разработке в модулях существует четкая ответственность за область кода в проекте, и при выполнении мерж-реквестов легко увидеть, какой модуль затронут.
Тесты выполняются в секундах, потому что они все равно будут работать без хост-приложения, они будут в поде модуля.
Правила зависимостей модулей:
Модули могут зависеть друг от друга (без циклических зависимостей) и от сторонних фреймворков. (например, зависимость A<->B не допускается)
A -> B-> C означает, что модуль A, который импортирует B, будет иметь доступ и к C.
При создании нового модуля, и этот модуль зависит от другой функции, которая еще не была выделена в отдельный модуль, мы делегируем эту функциональность основному приложению с помощью делегирования или замыканий. Например, если модулю доставки нужно показать чат для пользователя, мы можем создать функцию делегата openChat(withUserId:itemId:onView:) внутри модуля доставки, реализовать ее внутри основного приложения и внедрить в модуль доставки.
С другой стороны, если функция уже существует в отдельном модуле, мы просто настраиваем ее внутри основного приложения и внедряем в модуль, который мы отделяем.
Применение модульной архитектуры в примере проекта
Здесь мы разделим монолитное приложение на сервис и функцию, полностью изолированные модули. Приложение монолита находится в этом репо.
Прежде чем переместить функцию поиска фильмов в модуль, сначала нам нужно переместить сетевую службу в отдельный модуль, потому что из этой функции нам нужно будет извлекать элементы фильмов с помощью сетевой службы. Он будет настроен с использованием базового URL-адреса и ключа API внутри приложения и внедрен в модуль функции поиска фильмов (с использованием DIContainer).
Перемещение сетевой службы в изолированный модуль
Во-первых, мы настраиваем наш проект с помощью команд pod init и pod install. Затем мы создаем папку с именем DevPods внутри папки проекта. И внутри этой папки мы запускаем команду pod lib create Networking, которая создаст модуль с примером проекта, который используется для разработки этого модуля. После перемещения всего кода модуля из основного приложения и настройки файла Podfile и модуля .podspec (и запуска установки модуля) в результате у нас появилась дополнительная схема Networking-Example внутри основного рабочего пространства приложения, которая используется для разработки этого сетевого модуля.
Теперь Networking можно разрабатывать отдельно, выбрав схему:
Здесь мы можем видеть, как файлы модулей автоматически копируются в проект Pod рабочей области приложения с помощью CocoaPods, когда выполняется установка командного модуля:
Важно: Подробное объяснение создания модуля можно найти здесь: Шаги по созданию модуля с видео. Шаги могут быть связаны из файла readme.md, чтобы каждый разработчик мог легко создать новый модуль.
Примечание. В случае, если мы хотим сейчас поделиться этой сетью с большим количеством людей и между множеством разных проектов, легко поделиться ею в удаленном репозитории: https://github.com/kudoleh/SENetworking.
Перемещение функции поиска фильмов в изолированный модуль
Как и для сетевой службы, мы запускаем pod lib create MoviesSearch внутри папки DevPods, которая создает новый модуль с примером проекта для разработки этого модуля. Мы перемещаем все файлы, связанные с модулем, в этот модуль. После перемещения всего кода модуля из основного приложения и настройки файла Podfile и модуля .podspec (и запуска установки модуля) в результате у нас появилась дополнительная схема MoviesSearch-Example внутри основного рабочего пространства приложения, которая используется для разработки этого модуля (на следующую картинку). Примечание: эта функция уже имеет чистую архитектуру и DIContainer, в противном случае мы бы добавили ее в функцию перед ее перемещением.
Теперь поиск фильмов можно разработать отдельно, выбрав схему:
Здесь мы можем видеть, как файлы модулей автоматически копируются в проект Pod рабочей области приложения с помощью CocoaPods, когда выполняется установка командного модуля:
Внутри App DIContainer мы настраиваем Networking с базовым URL-адресом и ключом API и внедряем его в модуль поиска фильмов:
Важно: Подробное объяснение создания модуля можно найти здесь: Шаги по созданию модуля с видео. Шаги могут быть связаны из файла readme.md, чтобы каждый разработчик мог легко создать новый модуль.
Примечание: модули являются локальными. Они расположены в том же репозитории (Monorepo), что и основное приложение, потому что мы еще не делимся этим модулем с другим проектом.
Localizable.string может находиться внутри основного приложения. Все модули по умолчанию используют основной пакет приложений, в котором находятся эти переводы. Если они понадобятся во время разработки примера проекта модуля, мы можем сослаться на этот файл.
Создание точки входа для модуля поиска фильмов
Приятно видеть в какой-то момент, какие зависимости имеют модуль и какой функциональный метод он предоставляет (чтобы обеспечить фронтальный интерфейс, шаблон Facade). Мы создаем структуру модуля и перемещаем сюда зависимости модуля.
Источник проекта: https://github.com/kudoleh/iOS-Modular-Architecture.
Начальное масштабирование приложения — добавление модуля аутентификации со сторонней платформой
В настоящее время многие приложения нуждаются в аутентификации. Поэтому мы добавляем сюда модуль аутентификации. Здесь мы увидим, как мы можем использовать сторонний фреймворк (Alamofire для аутентификации) из нашего локального модуля (аутентификация) и как мы можем ограничить использование этого стороннего фреймворка только одним модулем.
В файле Authentication.podspec мы добавляем s.dependency ‘Alamofire’
Модуль аутентификации содержит код для выполнения запросов через Alamofire (SessionManager), который обеспечивает функциональность аутентификации. (У него есть RequestAdapter и RequestRetrier, куда мы добавляем токен доступа и обновляем его)
Примечание. Чтобы не подвергать использование сторонней платформы (Alamofire) другим модулям или основному приложению, мы создаем оболочку AuthNetworkRequest. Это оболочка вокруг DataRequest (класс Alamofire). Потому что, когда другой модуль вызывает функцию authRequest(:completion:) -> DataRequest, это зависит от класса DataRequest из Alamofire. Таким образом, мы ограничиваем зависимость от Alamofire только одним модулем (аутентификация). Кроме того, мы опустили здесь реализацию конфигурации аутентификации (с базовым URL-адресом аутентификации) и делегирование авторизации пользователя основному приложению (когда требуется вход в систему).
В основе сетевого модуля мы используем протокол Network Session Manager для запроса данных. Мы сделаем модуль аутентификации (Диспетчер сеансов аутентификации) в соответствии с этим протоколом:
Все запросы адаптируются внутри модуля Authentication, туда добавляется токен, и в случае неудачи он обновляет токен и повторяет запрос. Таким образом, сетевой модуль ничего не знает об аутентификации. Это имеет смысл, потому что добавление токена и его обновление — это проблема модуля аутентификации. И никакой другой модуль в системе не должен об этом знать (ни сетевой сервис, ни функциональный модуль).
Соответствие аутентификации сети и внедрению в сеть:
Примечание. Чтобы не зависеть от модуля аутентификации от сетевого модуля, мы делаем протокол AuthNetworkSessionManager соответствующим NetworkingSessionManager и внедряем его в NetworkingService.
Источник проекта: https://github.com/kudoleh/iOS-Modular-Architecture
Как масштабируется модульная архитектура
Здесь мы опишем пример того, как модульная архитектура может масштабироваться:
На этом графике у нас есть следующие модули:
Модуль конфигурации: содержит конфигурацию для всего приложения. Параметры, такие как базовые URL-адреса и флаги функций для каждой страны и каждой среды (стадии или производства), хранятся здесь plist. Например, основное приложение использует базовый URL-адрес из модуля конфигурации для настройки сетевой службы и внедрения его во все функции и службы. Он также используется в каждом примере функционального модуля для простой настройки служб, используемых функцией.
Сетевая служба: простая оболочка для запроса Network API, ее можно настроить с помощью параметров (например, базового URL-адреса). Он также отображает декодируемые данные.
Служба аутентификации: аутентифицирует все сетевые запросы, добавляя и обновляя маркер доступа. Это зависит от стороннего фреймворка Alamofire. Примечание. Ни один другой функциональный модуль не зависит от этого модуля, только основное приложение для его настройки с помощью URL-адреса аутентификации и внедрения его в сетевую службу в соответствии с протоколами сети. Из всех функциональных модулей нам нужно только использовать Networking Service для запроса данных.
Служба отслеживания: Служба отслеживания, которая упрощает отслеживание из всех модулей. Он предоставляет только одну дорожку func (событие: атрибуты:). Внутри модуля реализованы все трекинги. Как сетевой модуль, он настраивается в основном приложении и внедряется во все модули.
Функция учетной записи: управление учетной записью пользователя (например, регистрация, вход в систему или обновление паролей).
Модуль Utils: для совместного использования общих служебных функций и расширений.
Модуль UIComponents: для совместного использования общих компонентов пользовательского интерфейса и расширений, связанных с пользовательским интерфейсом.
Модуль тем: для обмена общими изображениями, цветами и шрифтами. Примечание. Для упрощения это может быть частью модуля UIComponents, чтобы иметь меньше модулей.
Функциональный модуль: модуль с достаточно большой функциональностью и собственной чистой архитектурой, доменом и DIContainer. Извлечение данных из сети и сопоставление их с объектами домена зависит от сетевой службы. Функция может зависеть от других функций. Например, нам может понадобиться открыть чат с пользователем из модуля доставки. (раздел выше: правила зависимостей модулей)
Модуль общего домена (основной): для совместного использования общих вариантов использования и объектов домена (например, объект пользователя с идентификатором пользователя и его функцией isLogged, объект Money с его операциями). В связи с тем, что этот модуль также содержит реализацию репозиториев, используемых вариантами использования, он должен зависеть от сетевого модуля. Это также может зависеть от Cache (сторонней платформы для кэширования элементов) или от кэша изображений Kingfisher, используемого ImagesRepo. Также это может зависеть от служебного модуля с общей функциональностью.
Примечание. Когда у нас есть два приложения, мы можем делиться между ними общими модулями (например, Networking, Tracking, Utils, UIComponents или общей функцией).
Пример проекта с шагами по созданию модулей
https://github.com/kudoleh/iOS-Modular-Architecture
Компании со многими iOS-инженерами
Модульность приложений успешно используется в финтех-компании Revolut с более чем 70 iOS-инженерами.
Вывод
Он используется для разбиения нашего монолитного приложения на полностью изолированные части. Это делает нашу разработку проще и быстрее, команды могут работать быстро и независимо
Даже если ваше приложение еще невелико, оно может быстро стать очень большим в ближайшем будущем, и уже сейчас гораздо проще начать модульность приложения.
Мы создаем модуль для достаточно больших функций. С собственным доменом и архитектурой (например, чистая архитектура + MVVM или Redux).
Мы максимально изолируем использование сторонних фреймворков, поэтому при изменении фреймворка другие модули не меняются.
Модульная архитектура одинаково применима независимо от инструментов или платформ управления зависимостями (например, Android или кроссплатформенных, таких как ReactNative или Flutter).
Плюсы использования модульной архитектуры:
- Более быстрое время сборки (как чистой, так и инкрементной)
- Ускоренный поиск кода в доменной логике внутри модуля
- Хорошая развязка и разделение проблем
- Легче делать код-ревью. Легко увидеть, что изменилось в изолированном модуле
- Создавать компоненты по отдельности быстрее, а также тестировать их в примере приложения, а затем интегрировать с основным приложением.
- Новым разработчикам проще подключиться и создать новые команды, которые будут работать в изолированном модульном домене.
- Важно, чтобы примеры проектов всех модулей всегда были доступны для сборки. Мы можем использовать такие инструменты, как Travis CI + Fastlane
- Каждая функция изолированного модуля имеет свой собственный контейнер внедрения зависимостей и зависимости модуля, чтобы увидеть все необходимые зависимости в одной точке.
- Модуль можно собрать и использовать как скомпилированную структуру.
- Модульные тесты модуля выполняются очень быстро, потому что они выполняются без хост-приложения (симулятор не требуется) и находятся внутри модуля.