Спокойствие спокойствию рознь

в 16:43, , рубрики: dependency injection, framework, ioc контейнеры, iOS, library, open source, swift, разработка под iOS

иконка библиотекиТри года назад, я написал статью о DI библиотеке для языка Swift. С того момента библиотека сильно измененилась и стала лучшей в своем роде достойным конкурентом Swinject, превосходящяя его по многим показателям. Статья посвящена возможностям библиотеки, но и имеет теоретические рассуждения.И так кому интересны темы DI, DIP, IoC или кто делает выбор между Swinject и Swinject прошу подкат:

Что такое DIP, IoC и с чем его едят?

Теория DIP и IoC

Теория одна из важнейших составляющих в программировании. Да, можно писать код и без образования но, несмотря на это программисты постоянно читают статьи, интересуются различными практиками и т.п. То есть так или иначе получаю теоретические знания, чтобы применить их на практике.
Одна из тем, которую любят спрашивать на собеседованиях — SOLID. Нет статья вовсе не о нем, не пугайтесь. Но одна буква нам понадобится, так как тесно связана с моей библиотекой. Это буква `D` — Принцип инверсии зависимостей.

Принцип Инверсии зависимостей гласит:

  • Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
  • Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

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

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

Заметим, что в Swift для абстракций принято использовать протоколы, но это не обязательное требование. Никто не мешает сделать класс, выделить у него набор публичных методов, а детали реализации оставить приватными. С точки зрения абстрагирования ничего не нарушено. Надо запомнить важный тезис — «абстрагирование не имеет привязки к языку» – это процесс, который происходит у нас постоянно в голове, и каким образом это переносится в код, не так важно. Тут можно еще упомянуть инкапсуляцию, как пример того, что имеет привязку к языку. На каждом языке есть свои средства для того чтобы её обеспечить. На Swift это классы, поля доступа и протоколы; на Obj-C интерфейсы, протоколы и разделение на h и m файлы.

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

Предположим, есть класс автомобиль и класс двигатель. Так сложилось, что надо их связать – машина содержит двигатель. Мы как грамотные программисты выделяем протокол двигатель, реализуем протокол и передаем реализацию по протоколу в класс машины. Вроде все хорошо и правильно – теперь можно легко подменить реализацию двигателя и не думать о том, что что-то сломается. Далее в схему добавляется механик двигателей. Его в двигателе интересует совсем другие характеристики, нежели машину. Мы расширяем протокол и теперь он содержит больший набор характеристик, чем изначально. История повторяется для владельца автомобиля, для завода производящего двигатели и т.д.
Инверсии нет
Но где ошибка в рассуждениях? Проблема в том, что описанная связь, несмотря на наличие протоколов, на самом деле «деталь» — «деталь». А точнее в том как назван и где находится протокол двигатель.
Теперь рассмотрим правильный другой вариант.

Как и раньше есть два класса – двигатель и автомобиль. Как и раньше их надо связать. Но теперь объявляем протокол «Двигатель автомобиля» или «Сердце автомобиля». В нем помещаем только те характеристики, которые нужны автомобилю от двигателя. И размещаем протокол не рядом с его реализацией «двигатель», а рядом с машиной. Далее если нам понадобится механик, то надо будет создать еще один протокол и реализовать его в двигателе. Вроде ничего не изменилось, но подход кардинально другой — вопрос не сколько в названиях, а в том кому протоколы принадлежат, и чем являются протокол — «абстракцией» или же «деталью».
Инверсия есть
Теперь проведем аналогию с другим кейсом, так как, эти доводы могут быть не очевидны.

Есть backend и от него нужен какойто функционал. Backend выдает нам большой метод, который содержит кучу данных, и говорит — «вам нужны вот таких 3 поля из 1000»

Небольшая история

Многие могут сказать, что такое не бывает. И будут относительно правы — бывает backend пишется для мобильного приложения отдельный. Так сложилось, что я работал в компании где backend это сервис с 10 летней историей который помимо прочего завязан на государственное API. По многим причинам в компании было не принято писать под мобилу отдельный метод, и приходилось пользоваться тем что есть. А был там один чудесный метод с порядка сотни параметров в корне и часть из них были вложенными словарями. А теперь представьте себе 100 параметров, 20% из которых имеют вложенные параметры, и внутри каждого вложенного еще по 20-30 параметров имеющих все теже вложенности. Я не помню точно, но количество параметров превышала 800 для простых объект, а для сложных могло быть и выше 1000

. Звучит както не очень, правда? Обычно backend пишет метод под конкретные задачи для frontend, и frontend является заказчиком/пользователем этих методов. Хм… А ведь если подумать то backend это двигатель, а frontend это машина – машине нужны некоторые характеристики двигателя, а не двигателю нужно отдавать характеристики для машины. Так почему, не смотря на это мы продолжаем писать протокол Двигатель и располагать его ближе к реализации двигателя, а не машины? Все дело в масштабах – в большинстве iOS программ очень редко приходиться расширять функционал на столько, что бы подобное решение стало проблемой.

А что тогда такое DI

Тут есть подмена понятий – DI это не сокращение от DIP, а совершенно другая аббревиатура, несмотря на то что очень тесно пересекается c DIP. DI — это внедрение зависимостей или Dependency Injection, а не Inversion. Инверсия говорит о том, как классы и протоколы должны взаимодействовать между собой, а внедрение рассказывает откуда их брать. В общем случае внедрять можно различными способами – начиная, куда приходят зависимости: конструктор, свойство, метод; заканчивая тем, кто их создает и насколько этот процесс автоматизирован. Подходы бывают разные но, на мой взгляд, самый удобными являются контейнеры для внедрения зависимостей. Если коротко, то весь их смысл сводится к простому правилу: Сообщаем контейнеру, где и как нужно внедрять и после все внедряется самостоятельно. Этот подход соответствует «настоящему внедрению зависимостей» — это когда классы, в которые внедряются зависимости ничего не знают о том как это происходит, то есть они пассивны.

На многих языках для настоящего внедрения используется следующий подход: В отдельных классах/файлах описываются правила внедрения с использованием синтаксиса языка, после чего они компилируются и автоматически внедряются. Магии никакой нет – автоматически ничего не происходит, просто библиотеки тесно интегрируются с базовыми средствами языка, и перегружают методы создания. Так для Swift/Obj-C принято считать, что стартовой точкой является UIViewController, и библиотеки умеют легко сами внедряться в создаваемый ViewController из Storyboard. Правда если не пользоваться Storyboard, то часть работы придется делать ручками.

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

История библиотеки

Рассказ был бы не полный, если бы я не рассказал кратко историю. Если вы следите за библиотекой с бета версии вам будет не так интересно, но для тех, кто видит её в первый раз, думаю стоит понимать, как она появилась и каких целей придерживался автор (то бишь я).
Библиотека была моим вторым проектом, который я решил, в целях самообразования, написать на Swift. До этого успел написать логгер, но не выкладывал его в публичный доступ – есть лучше и качественнее.

А вот с DI история интересней. Когда я начал его делать, удалось найти только одну библиотеку на Swift – Swinject. На тот момент у неё было 500 звезд и баги о том, что циклы нормально не обрабатываются. Посмотрел я на все это и… Мое поведение лучше всего описывает любимая фраза «И тут Остапа понесло» — я прошелся по 5-6 языкам, посмотрел, что есть в этих языках, почитал статьи на эту тему и осознал — можно сделать лучше. И сейчас по пришествию почти трех лет могу с уверенностью сказать – цель достигнута, на текущий момент DITranquillity лучшая в моем мировоззрении.

Давайте поймем, а что есть хорошая DI библиотека:

  • Она должна обеспечивать все базовые внедрения: конструктор, свойства, методы
  • Она не должна влиять на бизнес код
  • Она должна четко описывать, что пошло не так
  • Она должна заранее понимать, где есть ошибки, а не во время исполнения
  • Она должна быть интегрирована с базовыми средствами (Storyboard)
  • У нее должен быть краткий лаконичный синтаксис
  • Она должна все делать быстро и качественно
  • (Опционально) Она должна быть иерархичной

Именно этих принципов, я стараюсь придерживаться на протяжении развития библиотеки.

Возможности и преимущества библиотеки

Для начала ссылка на репозиторий: github.com/ivlevAstef/DITranquillity

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

Для описания проблем используется лог функция, которую я настоятельно рекомендую использовать. У логгирования есть 4 уровня: error, warning, info, verbose. Первых три достаточно важны. Последний не так важен — он пишет все, что происходит – какой объект был зарегистрирован, какой объект начал внедряться, какой объект создался и т.п.

Но это не все, чем может похвастаться библиотека:

  • Полная потоко-безопасность – любую операцию можно делать из любого потока и все будет работать. Большей части людей это не нужно, поэтому в плане потоко-безопасности проводилась работа по оптимизации скорости исполнения. А вот библиотека конкурент, несмотря на обещания падает, если начать одновременно регистрировать и получать объект
  • Быстрая скорость исполнения. На реальном устройстве DITranquillity в два раза быстрее по сравнению с конкурентом. Правда на симуляторе скорость исполнения почти эквивалента. Ссылка на тест
  • Маленький размер – библиотека весит меньше чем Swinject + SwinjectStoryboad + SwinjectAutoregistration, но по возможностям превосходит эту связку
  • Краткая, лаконичная запись, хотя и требует привыкания
  • Иерархичность. Для больших проектов, которые состоят из многих модулей, это очень большой плюс, так как библиотека способна находить нужные классы по расстоянию от для текущего модуля. То есть если у вас есть в каждом модуле своя реализация одного протокола, то в каждом модуле вы получите нужную реализацию, не прилагая никаких усилий

Демонстрация

И так приступим. Как и в прошлый раз будет рассматриваться проект: SampleHabr. Я специально не стал изменять пример – так можно сравнить, как все изменилось. И пример отображает многие особенности библиотеки.
На всякий случай, чтобы не возникло недопонимания, так как проект на показ то в нем используются многие возможности. Но никто не мешает использовать библиотеку упрощенным способом – скачал, создал контейнер, зарегистрировал пару классов, пользуешься контейнером.

Для начала нам надо создать framework (опционально):

public class AppFramework: DIFramework { // центральный фреймворк
  public static func load(container: DIContainer) {
     //позже сюда будем добавлять код
  }
}

И при старте программы создать свой контейнер, с добавлением этого фреймворка:

let container = DIContainer() // создаем контейнер
container.append(framework: AppFramework.self)

// функция проверки валидности графа связей.
// на самом деле эту функцию я рекомендую включать в ifdef DEBUG так как она требует времени исполнения, а граф зависимостей от запуска к запуску не изменяется, при условии не изменения кода.
if !container.validate() { 
   fatalError()
}

Storyboard

Далее нужно создать базовый экран. Обычно для этого используются Storyboard-ы, и в данном примере я буду использовать его, но никто не мешает использовать UIViewController-ы.
Начнем с того что нам надо зарегистрировать Storyboard. Для этого создадим «часть» (опционально — можно весь код написать в framework) c регистрацией в нем Storyboard:

import DITranquillity

class AppPart: DIPart {
  static func load(container: DIContainer) {
    container.registerStoryboard(name: "Main", bundle: nil)
      .lifetime(.single) // время жизни - один на всю программу.
  }
}

И добавим часть в AppFramework:

container.append(part: AppPart.self)

Как видим, у библиотеки есть удобный синтаксис для регистрации Storyboard, и я настоятельно рекомендую им пользоваться. В принципе можно написать эквивалентный код и без этого метода, но он будет более большой, и не сможет поддерживать StoryboardReferences. То есть на этот Storyboard не получится перейти из другого.
Теперь осталось дело за малым — надо создать Storyboard, и показать стартовый экран. Делается это в AppDelegate, после проверки контейнера:

window = UIWindow(frame: UIScreen.main.bounds)

/// создаем Storyboard
let storyboard: UIStoryboard = container.resolve(name: "Main")

window!.rootViewController = storyboard.instantiateInitialViewController()
window!.makeKeyAndVisible()

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

Presenter и ViewController

Переходим к самому экрану. Не будем нагружать проект сложными архитектурами, а применим обычный MVP. Более того я настолько ленив, что не буду для presenter-а создавать протокол. Протокол будет чуть позже для другого класса, тут важно показать, как зарегистрировать и связать Presenter и ViewController.Для этого в AppPart надо добавить следующий код:

container.register(YourPresenter.init)

container.register(YourViewController.self)
  .injection(.presenter) // устанавливаем связь

Эти три строчки, позволят нам зарегистрировать два класса, и установить между ними связь.

У любопытных может возникнуть вопрос — почему тот синтаксис, который у Swinject находится в отдельной библиотеке, сделан в проекте основным? Ответ кроется в целях — благодаря такому синтаксису, библиотека заранее хранит все связи, а не вычисляет их во время исполнения. Такой синтаксис открывает доступ ко многим возможностям, которые не доступны другим библиотекам.

Запускаем приложение, и все работает, все классы созданы.

Данные

Отлично теперь надо добавить класс и протокол для получения данных с сервера:

public protocol Server {
  func get(method: String) -> Data?
}

class ServerImpl: Server {
  
  init(domain: String) {
    ...
  }
  
  func get(method: String) -> Data? {
    ...
  }
}

И для красоты под сервер заведем отдельный DI класс ServerPart, в котором зарегистрируем его. Напомню, что это делать не обязательно и можно регистрировать на прямую в контейнер, но мы не ищем легких путей :)

import DITranquillity

class ServerPart: DIPart {
  static func load(container: DIContainer) {
    container.register{ ServerImpl(domain: "https://github.com/") }
      .as(check: Server.self){$0}
      .lifetime(.single)
  }
}

В этом коде всё уже не так прозрачно как в предыдущих, и требует разъяснений. Во первых внутри функции регистр происходит создание класса с передачей параметра.
Во вторых есть функция `as` — она говорит, что класс будет доступен по еще одному типу — протоколу. Странный конец этой операции в виде `{$0}` являются частью названия `check:`. То есть данный код гарантирует, что ServerImpl является наследником Server. Но есть и другой синтаксис: `as(Server.self)` который сделает тоже самое, но без проверки. Чтобы посмотреть, что выведет компилятор в обоих случаях, можно убрать реализацию протокола.

Функций `as` может быть несколько — это будет означать, что тип доступен по любому из этих имен. Обращаю внимание, что это будет одна регистрация, а значит, если класс является синглетоном, то по любому указанному типу будет доступен один и тот же экземпляр.

В принципе, если вы хотите оградить себя от возможности создавать класс по типу реализации, или вы еще не успели привыкнуть к такому синтаксису, то можно написать:

container.register{ ServerImpl(domain: "https://github.com/") as Server }

Что будет некоторым эквивалентом, но без возможности указать несколько отдельных типов.

Теперь можно внедрить server в Presenter, для этого поправим Presenter чтобы он принимал Server:

    class YourPresenter {
        init(server: Server) {
            ...
        }
    }

Запускаем программу, и она падает на функции `validate` в AppDelegate, с сообщением, что тип `Server` не найден, но он требуется `YourPresenter`. В чем дело? Обращу внимание, что ошибка произошла в начале исполнения программы, а не пост фактум. А причина достаточно простая — забыли добавить `ServerPart` в `AppFramework`:

container.append(part: ServerPart.self)

Запускаем — все работает.

Логгер

До этого было знакомство с возможностями, которые не сильно впечатляют и есть у многих. Теперь будет демонстрация того, что другие библиотеки на Swift не умеют.

Под логгер был создан отдельный Проект

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

public protocol Logger {
  func log(_ msg: String)
}

class ConsoleLogger: Logger {
  func log(_ msg: String) { ... }
}

class FileLogger: Logger {
  init(file: String) { ... }
  func log(_ msg: String) { ... }
}

class ServerLogger: Logger {
  init(server: String) { ... }
  func log(_ msg: String) { ... }
}

class MainLogger: Logger {
  init(loggers: [Logger]) { ... }
  func log(_ msg: String) { ... }
}

Итого, у нас есть:

  • Публичный протокол
  • 3 разных реализации логгера, каждая из которых пишет в разное место
  • Один центральный логгер, который вызывает функцию логгирования для всех остальных

В проекте создан `LoggerFramework` и `LoggerPart`. Я не буду выписывать их код, а выпишу лишь внутренности `LoggerPart`:

container.register{ ConsoleLogger() }
  .as(Logger.self)
  .lifetime(.single)

container.register{ FileLogger(file: "file.log") }
  .as(Logger.self)
  .lifetime(.single)

container.register{ ServerLogger(server: "http://server.com/") }
  .as(Logger.self)
  .lifetime(.single)

container.register{ MainLogger(loggers: many($0)) }
  .as(Logger.self)
  .default()
  .lifetime(.single)

Первых 3 регистрации мы уже видели, а последняя вызывает вопросы.На вход передается параметр. Подобное уже демонстрировалось, когда создавался presenter, правда там была сокращенная запись — просто использовался метод `init`, но никто не мешает писать вот так:

container.register { YourPresenter(server: $0) }

Если бы параметров было несколько, то можно было бы использовать `$1`, `$2`, `$3`, и т.д. до 16.

Но этот параметр вызывает функцию `many`. И тут начинается самое интересное. В библиотеке есть два модификатора `many` и `tag`.

Скрытый текст

Есть третий модификатор `arg`, но он не безопасный

Модификатор `many` говорит, что надо получить все объекты соответствующие нужному типу. В данном случае ожидается протокол Logger, поэтому будут найдены и созданы все классы, которые наследуются от этого протокола, с одним лишь исключением — сам себя, то есть рекурсивно. Он не создаст себя при инициализации, хотя может спокойно это сделать при внедрении через свойство.
Тег в свою очередь это отдельный любой тип, который надо указать как при использовании, так и при регистрации. То есть тэги это дополнительные критерии, если не хватает основных типов.
Подробней про это можно прочитать: Модификаторы

Наличие модификаторов, особенно `many`, выделяет библиотеку в лучшую сторону, по сравнению с другими. Например, можно реализовывать паттерн Observer на совершенно другом уровне. В свое время благодаря этим 4 буквам, в проекте с каждого Observer-а удалось удалить по 30-50 строчек кода, и решить проблему с вопросом — где и когда должны добавляться объекты в Observable. Понятное дело это не единственное применение, но весомое.

Ну и закончим представление возможностей, внедрением логгера в YourPresenter:

container.register(YourPresenter.init)
      .injection { $0.logger = $1 }

Тут, для примера, написано немного по другому, чем раньше — это сделано для примера другого синтаксиса.
Обращу внимание, что свойство logger опционально:

internal var logger: Logger?

И это не проявляется в синтаксисе библиотеки. В отличие от первой версии, теперь все операции для обычного типа, опционала и принудительного опционала выглядят одинаково. При этом логика внутри отличается — если тип является опционалом, и его не зарегистрировано в контейнере, то программа не упадет, а продолжит исполнение.

Итоги

Итоги похожи на прошлый раз, только синтаксис стал короче и функциональнее.

Что было рассмотрено:

Что еще умеет библиотека:

Планы

В первую очередь планируется переход к проверке графа на стадии компиляции — то есть более тесная интеграция с компилятором. Существует предварительная реализация с помощью SourceKitten, но у такой реализации серьезные сложности с выводом типов, поэтому планируется переход на ast-dump — в swift5 он стал рабочим на больших проектах. Тут хочу сказать спасибо Nekitosss за огромный вклад в этом направлении.

Во вторых хотелось бы интегрироваться с сервисами визуализации. Это будет слегка другой проект, но тесно связанный с библиотекой. В чем смысл? Сейчас библиотека хранит весь граф связей, то есть в теории все, что зарегистрировано в библиотеке можно показать в виде UML класс/компонент диаграммы. И как бы было не плохо иногда посмотреть эту диаграмму.Этот функционал планируется в две части — первая часть позволит добавить API для получения всей информации, а вторая уже интеграция с различными сервисами.Самой простой вариант — вывод графа связей в виде текста, но я не видел читабельных вариантов — если есть, то предлагайте варианты в комментариях.

WatchOS — я сам не пишу проекты под них. За свою жизнь писал только раз, и то мелкий. Но хотелось бы сделать тесную интеграцию, как и со Storyboard.

На этом все спасибо за внимание. Очень надеюсь на комментарии и ответы на опрос.

О себе

Ивлев Александр Евгеньевич — senior/team lead в iOS команде. Работаю в коммерции 7 лет, под iOS 4.5 года – до этого был С++ разработчиком. Но общий стаж программирования более 15 лет – еще в школе познакомился с этим удивительным миром и так им увлекся, что был период, когда променял игры, еду, туалет, сон на написания кода. По одной из моих статей можно догадаться, что я бывший олимпиадник – соответственно написать грамотную работу с графами мне не составляло сложности. Специальность – Информационно-измерительные системы, и в свое время я был помешан на многопоточности и параллелизме – да я пишу код, в котором делаю допущения и баги на подобные темы, но я осознаю проблемные места и прекрасно понимаю, где можно пренебречь мьютексом, а где не стоит.

Автор: Ивлев Александр

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js