Каждый iOS разработчик в своей жизни уходил с собеседования в расстроенных чувствах и мыслью «это что еще за новая аббревиатура?». Архитектурами пугают и джунов, и миддлов, и синьоров (и наверное даже синьорит). Важно не просто знать что стоит за названием, но еще и в каком случае какую использовать. Литературы по этому вопросу преступно мало, редкие обсуждения в интернете ограничиваются собственным опытом и какими-то поделками на гитхабе.
В этом цикле из трёх статей я кратко разберу все популярные архитектурные паттерны, использующиеся в iOS разработке: устройство, плюсы и минусы, а также когда и где их лучше применять. Собеседующим — хитрые вопросы, собеседуемым — клёвые ответы!
Первая часть посвящена MV(X) паттернам: самым известным и распространенным практикам в индустрии.
Это первая статья из цикла, посвящённого архитектурным паттернам в iOS разработке. Расскажу про плюсы и минусы, а также когда и где их лучше применять. В этой статье начну с основных и самых популярных MV(X) практик. Во второй части речь пойдёт о реализациях концепции Чистой Архитектуры (Clean Architecture), а в третьей — об FRP практиках и паттернах в iOS разработке.
Я Маша, ведущий инженер-разработчик iOS в КРОК, а также аспирант-препод в МЭИ.
Быть iOS-разработчиком непросто. Сначала Xcode не хочет подключаться к устройству, потом ты возишься неделю с сертификатами, потом полгода изучаешь Swift и пытаешься понять когда использовать `guard`, а когда — `if let`. Или когда ты уже написал не один калькулятор и приходишь на собеседование в крутой проект, тебя с порога начинают закидывать какими-то страшными аббревиатурами.
Сначала тебе кажется все кроме MVC — это не тру. Потом от друга ты узнаешь про MVVM, а через месяц — уже рвёшь на голове волосы, пытаясь переложить принципы чистой архитектуры на свой проект.
Короче, психануть может каждый. Разбираемся, что почём и как не ударить в грязь лицом на собеседовании или чтобы хорошо себя показать в интересном проекте.
Что такое MV(X)?
Основные части MV(X) архитектур — это, так или иначе, модели, виды и контроллеры.
Модель – отвечает за использование предметных (бизнес, domain) данных. Это так же может быть data access layer. Модели бывают двух видов: активные и пассивные. Активные модели умеют уведомлять окружающих об изменениях в себе, а пассивные — нет.
Вид (Представление, View) – отвечает за слой представления (GUI). В каком виде вам это представить? 🙂 Вид не обязательно должен быть связан с UI отрисовкой: если вы хотите представлять данные в виде сменяющих друг друга диодов на arduino-плате — этим все равно будет заниматься Вид. Помимо представления пользователю данных, у Вида есть ещё одна важная задача: принять событие пользователя (нажатие на кнопку, например) и пнуть кого-то, кто знает что с этим делать.
Контроллер/Презентер/ViewModel – так или иначе отвечают за связь модели с контроллером. В основном занимаются тем, что пробрасывают события модели в вид, а события вида – в модель, соответствующим образом их преобразуя и обрабатывая. Например, изменяя модель в ответ на действия пользователя на экране (виде), или изменяют вид в ответ на изменения модели. Как правило являются пассивными участниками процесса и реагируют только на внешние стимулы (события от Вида или Модели).
Любой нормальный человек, только что закончивший изучать ООП, задаст себе вопрос: а нафига плодить эти сущности? Зачем мне так много классов/структур/файлов/сообщений? Какие такие офигенные бенефиты дает вся эта возня? Вон я писал все в одном файле на Паскале/Питоне/Си/Свифт-плейграуде — и работало же!
Хорошая архитектура нужна чтобы:
-
Код становится читабельнее (как минимум для человека, знакомого с архитектурой, как максимум — для любого человека)
-
Переиспользовать код было намного легче
-
(проще и быстрее) покрыть код юнит-тестами
-
Расширять (масштабировать) код без мигрени и убийств
Хотя скорее всего если вы нашли эту статью, то вы уже знаете зачем вам нужна архитектура 🙂
Если же нет, и вы все еще задаетесь вопросом из предыдущего абзаца про «зачем плодить сущности» (а это, кстати говоря, очень важный и правильный вопрос!), то предлагаю сначала изучить классную статью на эту тему:
Создание архитектуры программы или как проектировать табуретку (обязательно походите по ссылкам внутри)
Примечание: далее по тексту к каждому рассматриваемому паттерну я буду оставлять ссылки, помечая их как источники. По смыслу это, конечно, далеко не источники цитирования, а скорее ссылки для дальнейшего изучения обсуждаемой темы — эдакое первое приближение для тех, кому хочется настоящего deep dive.
MVC
CocoaMVC — это iOS архитектура, которую предложили Apple, создавая iOS Developer SDK. Но стоит оговориться, что, вообще говоря, видение Apple немного отличается от традиционного понимания MVC как архитектуры.
Традиционная MVC выглядит как-то так:
Сплошная стрелочка по заветам UML тут означает ассоциацию. Переводя на программерский: Контроллер владеет Моделью (например у класса Контроллера есть поле типа Модели). Я дальше вместо совершенно нетолерантного слова «владеть» буду говорить «Контроллер ЗНАЕТ О Модели», ведь знание — сила.
Пунктирная стрелочка означает зависимость. То есть когда Контроллер владеет Видом, то Вид оказывается у него в зависимости (то есть у Контроллера есть поле типа Вид). У нас в свифте мы зависимость часто видим на примере `weak` свойств на родительские объекты и/или делегаты.
То есть:
-
Вид: рисует кнопочки
-
зависит от Контроллера
-
сообщает Контроллеру о событиях ввода
-
-
знает о Модели
-
запрашивает данные у Модели
-
получает изменения в данных от Модели
-
-
-
Контроллер: знает где взять текст для кнопочки и какой кнопочке его дать
-
знает о Виде
-
может менять конфигурацию Вида или заменять один Вид другим
-
-
знает о Модели
-
запрашивает изменения в данных Модели
-
-
-
Модель: знает как выглядят данные (типы, структуры), что делать с данными (бизнес-логика), где лежит БД и как в нее ходить (инкапсулирует доступ к данным)
-
зависит от Контроллера
-
меняет данные по запросу Контроллера
-
-
зависит от Вида
-
сообщает Виду об изменении в данных
-
-
Тут важный момент в том, что Вид не имеет состояний, то есть является stateless. Он просто отрисовывается заново, как только в Модели что-то меняется. Хороший пример: веб-страница в рунете нулевых, которая полностью перезагружается, если вы нажали на какую-то кнопку.
А ещё обратите внимание, что у View нет доступа на запись в Модель. Все изменения Модели производятся только через Контроллер, при этом права на чтение Модели у Вида не просто есть — он ими активно пользуется (чтобы обновлять себя и показывать актуальные данные на экране).
Вкратце достоинства классической MVC:
-
К одной Модели можно присоединить несколько Видов так, чтобы не менять код Модели. Например, табличные данные хочется показать и таблицей, и гистограммой, и пай-чартом.
-
Можно изменить реакцию на действия пользователя, не затрагивая реализацию Видов — просто подставив другой Контроллер. Например, если я хочу чтобы по кнопке данные не отправлялись в БД, а сохранялись локально — мне достаточно заменить Контроллер, не трогая при этом ни Вид, ни Модель.
-
Разработка бизнес-логики не зависит от разработки представлений и vice versa. А значит, можно посадить несколько человек одновременно долбить код одной фичи.
-
Однонаправленный поток данных (Вид -> Контроллер -> Модель -> Вид) делает дебаг проще и приятнее
Все это звучит очень классно, но немного запутанно и архаично. Apple предлагает вот такую реализацию Cocoa MVC:
Видим знакомую картину: Контроллер — это промежуточный слой между Видом и Моделью. Он принимает события из Вида и Модели, и посылает запросы на модификации туда и туда. В Контроллер можно положить всю логику, которая не помещается в Модель: например преобразует данные для «красивого» отображения — это не часть бизнес-логики, так что в Модели ей не место.
Любой iOS-ник скажет, что эта картинка вообще не вяжется с реальностью и будет по-своему прав. На самом деле в большинстве случаев Cocoa MVC будет выглядеть вот так:
Что кажется хоть и до боли знакомой, но довольно примитивной картиной. Так что неудивительно, что она создает столько проблем.
ViewController и View настолько плотно завязаны друг на друга, что сложно вообще отличить где заканчивается одно и начинается другое. С одной стороны: ну вот же UIView — его можно отдельным классом написать, там экстеншены-категории все вот это вот, вы меня за дурака держите?
С другой: UIView не умеет отрабатывать IBAction (тк он не посылает никаких actions — это делают UIControl), а .xib-ы легче подключаются к контроллерам (наверняка же сталкивались с историей, что если xib не заканчивается на -Controller то он не подключается “из коробки”? :)). В итоге чтобы отделить UIView от UIViewController в терминах целого экрана, а не отдельного компонента — нужно пройти сквозь огонь, воду и LLVM.
Так как ходить через LLVM никому не нравится, все пихают логику Вида в UIViewController, что приводит к тому, что MVC элегантным жестом превращается… в Massive View Controller. Разобраться в этой мешанине делегатов, датасорсов, экшенов и просто вспомогательных функций — задача на любителя.
Происходит это из-за того, что у Вида может быть достаточно большое количество различных состояний: представьте себе форму регистрации из серии “введите емейл, пароль, подтвердите пароль”. Логично, чтобы все поля имели валидацию ввода и цветовую индикацию: хорошо/плохо. Получается, что это три текстфилда, каждый с как минимум двумя состояниями. Записать это состояние в Вид нельзя — он stateless, в Контроллер тоже нельзя — он не должен управлять состояниями Вида, лишь передавать ему данные. В итоге, состояние Вида приходится хранить в Модели. Так у нас Модель получается перегружена: она хранит в себе и Domain Model (модель предметной области: данные, бизнес логика и тп) и View Model (модель вида: его состояния, хранимые данные, логика переключений состояний и тп).
Пример несколько утрированный, и опытные разработчики скорее всего не увидят проблему в реализации подобной фичи (ну сделай ты кастомный контрол и делов-то!). Но если говорить о формальном следовании архитектуре MVC/CocoaMVC, то видно как всё быстро становится достаточно запутанно.
Ну а протестировать взаимодействие между View и ViewController — задача настолько нетривиальная, что в MVC приложениях как правило тестируют только модель и нетворк слой (что во многих случаях бывает так же сложно разделить как и UIView с UIViewController: представьте модель данных и интерфейс доступа к данным (к БД)).
В итоге, чтобы сделать действительно MVC-шный MVC на iOS придется исхитряться ломая либо Cocoa MVC (например давая UIView возможность самостоятельно ходить в модель), либо логику UIKit и делегирования (чтобы сообщения в контроллер приходили действительно от условных UIView, а не от других контролов и контроллеров).
MVP
Прозорливый читатель заметит, что всех проблем c Cocoa MVC можно было бы избежать, если пару UView+UIViewController воспринимать как View, а под слой Controller выделить отдельную сущность, которая не знает о UIKit и занимается только тасканием данных и событий между View и Model, как и было задумано.
То есть:
-
Презентер:
-
зависит от Вида
-
сообщает Виду о необходимости обновить отображение измененных данных
-
-
знает о Модели
-
запрашивает изменить данные
-
получает сообщения об изменившихся данных
-
-
-
Вид:
-
знает о Презентере
-
запрашивает у Презентера реакцию на действия пользователя («пользователь нажал на красную кнопку, куда его послать, сэр?»)
-
-
-
Модель:
-
зависит от Презентера
-
получает запросы Презентера на изменения данных
-
посылает Презентеру сообщения об изменившихся данных
-
-
Выглядит классно и вполне адекватно! Только вот…
Нам очень хочется, чтобы Вид ничего не знал о Модели, так? Если собирать эту диаграмму в код напрямую, то получится, что в условном Виде содержится полем объект Контроллера, Контроллер содержит внутри себя Модель, ну и при инициализации Вида у нас по цепочке через Контроллер будет инициализироваться управляющий объект Модели. Так знает Вид о Модели или нет? Как-то некрасиво получается, да и с концепцией не вяжется.
Эта проблема сборки (assembly problem) корнями растет напрямую из Cocoa MVC. Ведь именно Cocoa MVC заставляет нас в первую очередь ориентироваться на UIViewController как на единицу сборки и навигации.
В идеальном мире мы бы сделали как:
«`
let newModel = Model() // а возможно она уже где-то существует и мы на нее ссылаемся
let newPresenter = Presenter(model: newModel)
let newView = MyViewController(presenter: newPresenter)
«`
Получается красивая инъекция зависимостей через конструктор, и все бы хорошо, если бы было понятно а где собственно она должна происходить. По идее (согласно Cocoa MVC) мы вызываем новые виды (UIViewController) из других видов (других UIViewController). В итоге получается что-то вроде `self.present(newViewController)`. Так значит какой-то другой Вид будет знать про модель нового Вида? Или этим должен заниматься Презентер?
Если отдать эту честь Презентеру, то получится что мы создаем как-то шибко много зависимостей и таскаем данные туда-сюда:
Вид: эй, Презентер, юзер хочет перейти на новый экран, что мне делать?
Презентер: ну нужно НовыйВид показать. Вот ему модель, вот ему презентер, вот тебе собранный НовыйВид. Заменяй себя на него, а мы с тобой деинитмся… так, падажжи…
В большинстве примеров в интернете и в жизни вы найдете код, где ViewController спокойно себе инитит модель, а потом кладет её в параметры инициализатора Презентера. У такого подхода нет ничего плохого, только надо следить за ссылками и понимать, что это не чистый MVP «как на диаграмме», да и заменить модель при случае будет не так просто, как хотелось бы.
Решается проблема сборки как правило тем, что создается ещё один управляющий слой: Роутер (Router), который собирает MVP-кусочки, управляет их жизненным циклом и передает их друг другу при необходимости. По этой же причине так часто встречается связка MVP+Coordinator (на эту архитектуру часто ссылаются как на MVP+C).
MVP+C
Выглядит это дело вот так:
Задача координатора — создавать MVP блоки и управлять тем какой UIViewController показывается на экране прямо сейчас (например через `navigationController.pushViewController`).
В этой схеме оказывается, что Координатор знает, какому Презентеру какую Модель подсунуть. Что в общем случае не ломает нашу MVP концепцию и вообще кажется довольно-таки логичным и удобным:
Вид: эй, Презентер! Юзер Иванов хочет посмотреть на красную кнопку.
Презентер: *потея* ох, но у нас её нету, надо искать где-то в другом месте… Координатор, нужно показать красную кнопку Иванову!
Координатор: ага, ну понятно. Собираем, значит: ЭкранСКраснойКнопкой — есть, ПрезентерЭкранаСКраснойКнопкой ему вот, модель… модель для Иванова, ага… нашел, кладу в ПрезентерЭкранаСКраснойКнопкой. Собрано. Показываю ЭкранСКраснойКнопкой. Вас деиничу. Спасибо за содействие.
Презентер: *деинитится вместе с Видом и, возможно, Моделью*
Это не единственный способ решить проблему сборки в MVP, но остальные методы не слишком-то элегантны:
Так что подведем итоги:
-
При грамотном подходе все три компоненты (Вид, Презентер и Модель) растут примерно в одинаковых масштабах, что здорово помогает справиться с проблемой Massive View Controller.
-
Все разделено настолько естественным образом, что масштабирование не представляет никаких угроз: все обязанности гарантированно разделены — масштабируем что хотим и где хотим.
-
Покрыть тестами логику ответа на действия пользователя теперь тоже намного проще: Презентер хорошо отделён от Вида
-
Правда, как это ни парадоксально, переиспользовать Презентер вряд ли получится: всё же, хоть он и отделен, но заточен на работу с конкретным представлением [2] Впрочем, тут все действительно зависит от вас и ваших навыков в SOLID
MVP оказывается хорошей альтернативой MVC, если выбран осознанно для решения конкретной проблемы. В противных случаях он ощущается как «слишком много лишнего кода» (намёк на роутер) и какой-то громоздкий костыль к MVC («нафига ещё презентер, если ViewController это всё умеет?»)
MVP хорошо подходит небольшим командам, однако в крупном проекте с большим количеством разработчиков может стать настоящим яблоком раздора в GIT: разработчики могут постоянно править одни и те же переиспользуемые классы (например, роутеры или модели), работая над разными фичами, что неизменно приводит к большому количеству конфликтов при мержах, разгребать которые никому не нравится 🙂
Источники:
[1] iOS Architecture Patterns and Best Practices for Advanced Programming — 2021
[2] MVP vs. MVVM in iOS Development. Let’s discuss those architectural… | by Ahmed Ragab Issa
MVVM
MVVM очень похож на MVP, но совсем другой 🙂 Он был создан в Microsoft для работы с WPF и Silverlight, но благодаря своей элегантной модуляризации приобрел популярность и далеко за пределами WPF приложений.
Давайте начнём с картинки:
Как видно, картинка в точности повторяет MVP, только стрелочки почему-то двунаправленные стали, а Презентер превратился в Модель Вида (ViewModel). Ну и как читать это безобразие?
Начнём с того, что тут View — это, как и в MVP, связка UIView+UIViewController. Мы вроде уже достаточно взрослые на данном этапе чтобы понять почему. Дальше, кстати, будет так же.
Второе: двунаправленная стрелочка. Она означает биндинг (binding), или, по-русски, связывание данных. Вкратце это работает как в реакте: если значение связанной с лейблом переменной изменилось — на экране оно тоже тут же изменится без дополнительных телодвижений с нашей стороны. Стрелочка в обратную сторону означает, что если данные изменились в UI (например, юзер что-то ввел), то и в связанной переменной они тоже изменятся без дополнительных чтений из текстфилда.
Всё это звучит очень круто и удобно, но потом приходит iOS-ник и такой:
И действительно, штатных (предоставленных Apple) средств организовать биндинг в iOS SDK (вне SwiftUI) нет, но зато есть другие инструменты, с помощью которых можно симулировать такое поведение.
Так как делать биндинги вообще? Есть четыре способа:
-
KVO (Key-Value Programming) — очень крутая и мощная штука прямиком из Cocoa, которая позволяет подписаться и наблюдать (observe) за переменной по её имени (keyPath) и получать уведомления об изменении её значения (value). Кажется, то что надо!
-
FRP (Functional Reactive Programming) — парадигма программирования (заметьте: не язык и не фреимворк, а целая пАрАдИгМа) для обработки событий и данных как потоков. Ну, например представьте, что у вас есть какой-то поток данных и вы хотите для каждого объекта в этом потоке что-то предпринять. Эдакий forEach, только вы не знаете когда появится новый объект.«`let lowercaseNames = cast.map { $0.lowercased() }«` видали? Вот вам и FRP, где поток — это статичный массив (cast).Реализация FRP в Swift обычно отдается на откуп RxSwift и ReactiveSwift, но есть ещё и Combine от Apple — сейчас можно говорить, что он лучше всех, но это конечно не точно.
-
Delegate — делегирование можно использовать чтобы передавать сообщения об изменении данных.
-
Boxing — если вы первым делом подумали про `didSet` — то это для вас! Похоже на KVO, кстати, но придется каждое свойство оборачивать в кастомный дженерик класс (Box). Подробнее детали реализации можно посмотреть в крутом туторе вот тут.Ну а если вы пришли из Objective-C, то с паттерном «коробки» у вас и подавно проблем возникнуть не должно! Это тот же самый паттерн, что используется в Obj-C (и не только) для оборачивания скалярных типов в ссылочные (например float в NSNumber), только теперь мы ещё и листенер туда навесить пытаемся. По сути — обычная обертка, совсем не страшно.
Пятый, бонусный способ: SwiftUI и его @State и @BindingУдивительное дело, но в SwiftUI Apple решила отказаться от концепции ViewController как такового! Вместо этого роль передатчика данных между View и Model на себя берет FRP фреймворк Combine (а вы думали отвертитесь от его изучения?). Самая простая иллюстрация для тех кто совсем в танке:- в описании View (читайте его как ViewModel) переменная помечается аннотацией @State- в описании Компонента (CounterButton, читайте это уже View) эта же переменная, привязываемая к UI контролу, помечается аннотацией @Binding — вуа-ля! Очень реактивно и роскошно 🙂
Тут есть конечно свои нюансы (например то, что SwiftUI больше подходит для Redux-like паттернов), но да ладно.
Что же мы видим в итоге?
-
Модель Вида:
-
зависит от Вида
-
биндится с данными в Виде, которые могут обновляться в Модели или в GUI
-
-
знает о Модели
-
запрашивает изменить данные
-
получает сообщения об изменившихся данных
-
-
-
Вид:
-
знает о Модели Вида
-
биндится с данными в Модели Вида, которые могут обновляться в Модели или в GUI
-
-
-
Модель:
-
зависит от Модели Вида
-
получает запросы Модели Вида на изменения данных
-
посылает Модели Вида сообщения об изменившихся данных
-
-
При этом функции компонент очень строго разделены и немного отличаются от оных в MVC/MVP:
Модель:
-
Содержит описание данных
-
Реализует CRUD [2]
-
Оповещает Модель Вида о произошедших изменениях в данных
-
Содержит бизнес-логику [1]
-
Содержит логику валидации данных [1]
-
Вообще говоря MVVM никак не описывает реализацию Модели, и она может быть реализована любым способом, хоть VIPER модулем [3]
Вид:
-
Определяет структуру, расположение и внешний вид элементов интерфейса (как в MVC и MVP)
-
Содержит логику Вида: анимации, переходы между Видами и манипуляции с дочерними Видами
-
Передаёт действия пользователя дальше (в Модель Вида)
Модель Вида:
-
Хранит состояние Вида
-
Знает о Модели и может менять её состояние (вызывая соответствующие методы Модели) [1]
-
Как правило образует связь «один ко многим» с несколькими Моделями [3]
-
Преобразует данные, полученные от Модели в формат, удобный для отображения Видом и обратно
-
Ничего не знает о Виде и может с ним коммуницировать только благодаря биндингам
-
Может содержать логику валидации данных [2]
-
Может включать в себя бизнес-правила работы с действиями пользователя [2,3]
Как видим, Модель Вида — какой-то монстр, который всё обо всех знает и всеми погоняет. Это и достоинство, и недостаток MVVM:
-
С одной стороны, Модель Вида — удобный медиатор, оторванный от мира UI и Data Access, а значит её удобно тестировать и можно переиспользовать на других платформах, если вдруг понадобилось сделать еще и приложение для tvOS, например
-
с другой стороны, Модель Вида занимается и управлением состояниями своего Вида и зависимостей, и различного рода логикой. А значит её код разрастается и мы возвращаемся к проблеме Massive View Controller. Чтобы этого избежать, необходимо крайне осторожно подходить к проектированию Моделей Вида
И, хоть Модель Вида и легко протестировать, то дебажить приложение на MVVM совсем непросто: ведь благодаря биндингам данные меняются мгновенно. И, что бы это не значило со стороны пользователя, для разработчика это означает огроменный call stack подписок и ивентов, в котором чёрт ногу сломит:
Впрочем, если у команды есть опыт реактивного программирования, то всё это не должно стать проблемой.
MVVM хорошо подходит опытным командам среднего размера, которые работают над проектом с большим количеством представлений (экранов). Особенно хорошо MVVM себя показывает в проектах, где надо сильно отделить бизнес-логику от логики отрисовки представлений — такое часто встречается в кросс-платформенных приложениях, или приложениях, которым в будущем может понадобиться клиент-сателлит на другую платформу (tvOS, watchOS, …).
Из-за сильного разделения MVVM позволяет нескольким разработчикам работать над одной фичей одновременно, практически не пересекаясь в коде — это самая «простая» для понимания и внедрения архитектура, которая обладает таким замечательным свойством.
Источники:
[1] iOS Architecture Patterns and Best Practices for Advanced Programming — 2021
[2] Advanced iOS App Architecture | raywenderlich.com
[3] Modern MVVM iOS App Architecture with Combine and SwiftUI
[5] Architectural Patterns by Pethuru Raj, Anupama Raman, Harihara Subramanian
[6] MVP vs. MVVM in iOS Development. Let’s discuss those architectural… | by Ahmed Ragab Issa
В большинстве случаев этих трёх паттернов достаточно, чтобы написать классное приложение и поддерживать его на протяжении нескольких лет. Конечно, это не все MV(X) паттерны, встречающиеся в реальной жизни, литературе и собеседованиях. Но, зная это, уже не ударишь в грязь лицом на своём первом собеседовании или проекте 🙂
На больших проектах со сложной бизнес-логикой требуются более основательные подходы к проектированию кодовой базы приложения. Один из таких подходов — концепция Чистой Архитектуры, о которой мы поговорим в следующей статье. Обсудим VIPER, CleanSwift, RIBs и относительно малоизвестную штуковину под названием Elements.
Не переключайтесь! 🙂
ссылка на оригинал статьи https://habr.com/ru/company/croc/blog/549590/
Добавить комментарий