Хабрахабр

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

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

Теория DIP и IoC

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

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

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

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

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

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

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

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

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

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

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

И будут относительно правы — бывает 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. Инверсия говорит о том, как классы и протоколы должны взаимодействовать между собой, а внедрение рассказывает откуда их брать. В общем случае внедрять можно различными способами – начиная, куда приходят зависимости: конструктор, свойство, метод; заканчивая тем, кто их создает и насколько этот процесс автоматизирован. Подходы бывают разные но, на мой взгляд, самый удобными являются контейнеры для внедрения зависимостей. Если коротко, то весь их смысл сводится к простому правилу: Сообщаем контейнеру, где и как нужно внедрять и после все внедряется самостоятельно. Этот подход соответствует «настоящему внедрению зависимостей» — это когда классы, в которые внедряются зависимости ничего не знают о том как это происходит, то есть они пассивны.

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

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

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

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

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

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

Именно этих принципов, я стараюсь придерживаться на протяжении развития библиотеки. Для начала ссылка на репозиторий: github.com/ivlevAstef/DITranquillity

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

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

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

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

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

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

public class AppFramework: DIFramework
}

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

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, поэтому будут найдены и созданы все классы, которые наследуются от этого протокола, с одним лишь исключением — сам себя, то есть рекурсивно. Он не создаст себя при инициализации, хотя может спокойно это сделать при внедрении через свойство.
Тег в свою очередь это отдельный любой тип, который надо указать как при использовании, так и при регистрации. То есть тэги это дополнительные критерии, если не хватает основных типов.
Подробней про это можно прочитать: Модификаторы

Например, можно реализовывать паттерн Observer на совершенно другом уровне. Наличие модификаторов, особенно `many`, выделяет библиотеку в лучшую сторону, по сравнению с другими. Понятное дело это не единственное применение, но весомое. В свое время благодаря этим 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.

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

О себе

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

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

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

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

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

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