Русский
Русский
English
Статистика
Реклама

Блог компании tinkoff.ru

Используем DI в Angular по максимуму концепция частных провайдеров

23.06.2020 16:19:11 | Автор: admin
В Angular очень мощный механизм Dependency Injection. Он позволяет передавать по вашему приложению любые данные, преобразовывать и переопределять их в нужных частях.

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

Тем не менее DI в приложениях используется достаточно скромно. Как правило, это внедрение сервисов или передача каких-то глобальных данных сверху вниз по дереву внедрения зависимостей.

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




Как обычно используется DI в Angular


Я ежедневно провожу ревью Angular-кода на работе и в опенсорсе. Как правило, в большинстве приложений DI сводится к следующей функциональности:

  1. Получить сущности Angular из дерева зависимостей: ChangeDetectorRef, ElementRef и проч.
  2. Получить сервис, чтобы использовать его в компоненте.
  3. Получить какой-нибудь глобальный конфиг по токену, который объявлен где-то наверху. Например, задать токен API_URL в рутовом модуле и получать его из DI в любом месте приложения при необходимости.

Реже встречаются случаи, когда разработчики идут дальше и преобразуют уже существующий глобальный токен в более удобную форму. Хороший пример такого преобразования токен на получение WINDOW из пакета @ng-web-apis/common.

Angular предоставляет токен DOCUMENT, чтобы можно было получить объект страницы из любого места приложения: ваши компоненты не зависят от глобальных объектов, легко тестировать, ничего не сломается при SSR.

Если вам регулярно нужен доступ до объекта WINDOW, можно написать такой токен:

import {DOCUMENT} from '@angular/common';import {inject, InjectionToken} from '@angular/core';export const WINDOW = new InjectionToken<Window>(    'An abstraction over global window object',    {        factory: () => {            const {defaultView} = inject(DOCUMENT);            if (!defaultView) {                throw new Error('Window is not available');            }            return defaultView;        },    },);


Когда кто-то запросит токен WINDOW в первый раз из дерева DI, выполнится фабрика токена он получит объект DOCUMENT у Angular и получит из него ссылку на объект window.

Далее я предлагаю рассмотреть иной подход к таким преобразованиям когда они выполняются непосредственно в providers компонента или директивы, который и инжектит результат.

Частные провайдеры


В нашей команде мы активно используем DI при работе с Angular и стали замечать, что зачастую нам нужно совершить какие-то преобразования полученных из DI данных перед их использованием. Фактически наш компонент нуждается в одних данных, а мы внедряем в него другие и выполняем логику преобразования внутри него.



Давайте посмотрим сразу на солидном примере. Эрин Коглар в своем докладе The Architecture of Components на большой международной конференции Angular Connect показала такой пример:


Если вам неудобно открывать и смотреть видео, то давайте распишу кейс здесь.

Имеем:
Компонент, который отвечает за показ информации по некой сущности организации.
Query-параметр роута, который указывает id организации, с которой мы работаем в текущий момент.
Сервис, который по id возвращает Observable с информацией об организации.

Что хотим сделать
Взять из query-параметров id организации, передать его в метод сервиса, а в ответ получить стрим с информацией об организации. Эту информацию вывести в компоненте.

Рассмотрим три способа добиться желаемого и разберем их.

Как делать не нужно
Иногда я встречаю вот такой стиль работы с данными в компонентах. Пожалуйста, не делайте так:
@Component({   selector: 'organization',   templateUrl: 'organization.template.html',   styleUrls: ['organization.style.less'],   changeDetection: ChangeDetectionStrategy.OnPush,})export class OrganizationComponent implements OnInit {   organization: Organization;   constructor(       private readonly activatedRoute: ActivatedRoute,       private readonly organizationService: OrganizationService,   ) {}   ngOnInit() {       this.activatedRoute.params           .pipe(               switchMap(params => {                   const id = params.get('orgId');                   return this.organizationService.getOrganizationById$(id);               }),           )           .subscribe(organization => {               this.organization = organization;           });   }}

Чтобы использовать полученные данные в шаблоне:
<p *ngIf="organization">   {{organization.name}} from {{organization.city}}</p>


Этот код будет работать, но у него есть ряд проблем:
Неопределенность поля organization: между моментом объявления поля при создании класса и присвоения ему значения пройдет некоторое время. Все это время в данном примере поле будет undefined. Мы либо нарушаем типизацию (такое возможно при отключенном strict у TypeScript), либо предусматриваем это в типе (organization?: Organization) и обрекаем себя на ряд дополнительных проверок.
Такой код тяжелее поддерживать. Завтра нам понадобится вытащить еще один параметр, мы продолжим заполнять ngOnInit, и код начнет постепенно превращаться в кашу с кучей неявных переменных и тяжелым для понимания потоком данных.
При подобном обновлении полей можно столкнуться с проблемами проверки изменений при использовании стратегии OnPush.

Сделаем хорошо
В докладе Эрин из видео, что я прикладывал выше, сделано хорошо. С ее вариантом получается примерно так:

@Component({   selector: 'organization',   templateUrl: 'organization.template.html',   styleUrls: ['organization.style.less'],   changeDetection: ChangeDetectionStrategy.OnPush,})export class OrganizationComponent {   readonly organization$: Observable<Organization> = this.activatedRoute.params.pipe(       switchMap(params => {           const id = params.get('orgId');           return this.organizationService.getOrganizationById$(id);       }),   );   constructor(       private readonly activatedRoute: ActivatedRoute,       private readonly organizationService: OrganizationService,   ) {}}

Чтобы использовать полученные данные в шаблоне:
<p *ngIf="organization$ | async as organization">   {{organization.name}} from {{organization.city}}</p>


Этот код отлично работает и лишен недостатков прошлого подхода: смотрится достаточно аккуратно, в нем нет лишних полей. Если мы захотим расширить компонент аналогичным стримом, мы просто добавим еще один сделать это проще, потому что для добавления нового стрима нам никак не нужно затрагивать код предыдущего.

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

Сделаем еще круче: частные провайдеры


Давайте присмотримся внимательнее к прошлому решению.

На самом деле компонент не зависит от роутера и даже от OrganizationService. Он зависит от organization$. Но такой сущности в нашем дереве внедрения зависимостей нет, поэтому мы вынуждены выполнять преобразования в компоненте.

Но что если выполнить это преобразование перед тем, как данные попадут в компонент? Давайте напишем Provider специально для компонента, в котором и будут происходить необходимые преобразования.

Для удобства мы выносим провайдеры в отдельный файл рядом с компонентом, получая такую структуру файлов:



В файле organization.providers.ts будут находиться Provider для преобразования данных и токен для их получения в компоненте:

export const ORGANIZATION_INFO = new InjectionToken<Observable<Organization>>(   'A stream with current organization information',);По этому токену будет идти стрим с необходимой компоненту информацией:export const ORGANIZATION_PROVIDERS: Provider[] = [   {       provide: ORGANIZATION_INFO,       deps: [ActivatedRoute, OrganizationService],       useFactory: organizationFactory,   },];export function organizationFactory(   {params}: ActivatedRoute,   organizationService: OrganizationService,): Observable<Organization> {   return params.pipe(       switchMap(params => {           const id = params.get('orgId');           return organizationService.getOrganizationById$(id);       }),   );}


Определим массив провайдеров для компонента. Значение для токена ORGANIZATION_INFO получим из фабрики, в которой сделаем необходимое преобразование данных.

Примечание по работе DI deps позволяет взять из дерева DI необходимые сущности и передать их как аргументы в фабрику значения токена. В них можно получить любую сущность из DI, в том числе и с использованием DI-декораторов, например:

{       provide: ACTIVE_TAB,       deps: [           [new Optional(), new Self(), RouterLinkActive],       ],       useFactory: activeTabFactory,}


Объявим providers в компоненте:

@Component({   ..   providers: [ORGANIZATION_PROVIDERS],})


И мы готовы к использованию данных в компоненте:

@Component({   selector: 'organization',   templateUrl: 'organization.template.html',   styleUrls: ['organization.style.less'],   changeDetection: ChangeDetectionStrategy.OnPush,   providers: [ORGANIZATION_PROVIDERS],})export class OrganizationComponent {   constructor(       @Inject(ORGANIZATION_INFO) readonly organization$: Observable<Organization>,   ) {}}

Класс компонента сводится к одной строчке с получением данных.

Шаблон остается прежним:
<p *ngIf="organization$ | async as organization">   {{organization.name}} from {{organization.city}}</p>


Что нам дает этот подход?

  1. Чистые зависимости: компонент не внедряет в себя и не хранит лишних сущностей. Он работает только с теми данными, которые ему нужны, при этом сам остается чистым и содержит только логику для отображения данных.
  2. Простота тестирования: мы можем легко протестировать сам провайдер, потому что его фабрика обычная функция. Нам легче тестировать компонент: в тестах нам не нужно будет собирать дерево зависимостей и подменять много сущностей мы просто передадим по токену ORGANIZATION_INFO стрим с мокаными данными.
  3. Готовность к изменению и расширению: если компонент будет работать с другим типом данных, мы поменяем лишь одну строчку. Если нужно будет изменить преобразование поменяем фабрику. Если потребуется добавить новых данных, то добавим еще один токен мы можем сложить сколько угодно токенов в наш массив провайдеров.


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

Заключение


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

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

Пишем интеграционные тесты на фронтэнд и ускоряем релизы

29.06.2020 16:21:22 | Автор: admin
Всем привет! Меня зовут Вова, я фронтэндер в Тинькофф. Наша команда отвечает за два продукта для юридических лиц. О размерах продукта я могу сказать цифрами: полный регресс каждого из продуктов двумя тестировщиками проходит от трех дней (без влияния внешних факторов).

Сроки значительные и напрашиваются на борьбу с ними. Способов борьбы несколько, основные из них:
  • Распил приложения на более мелкие продукты со своими релизными циклами.
  • Покрытие продукта тестами в соответствии с тестовой пирамидой.

Последний пункт и стал темой моей статьи.

image

Пирамида тестирования


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

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

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

  1. Очень подробная документация.
  2. Легкий debugging тестов (у Cypress для этого сделан специальный GUI с time-travel по шагам в тесте).

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

Начало пути


В самом начале для организации кода использовался Angular Workspace с одним приложением. После установки пакета Cypress в корне приложения появилась папка cypress с конфигурацией и тестами, на данном варианте мы остановились. При попытке подготовить в package.json скрипт, необходимый для запуска приложения и прогона поверх него тестов, мы столкнулись со следующими проблемами:

  1. В index.html были зашиты некоторые скрипты, которые не нужны в интеграционных тестах.
  2. Для запуска интеграционных тестов необходимо было убедиться, что сервер с приложением запущен.

Проблему с index.html решили через отдельную конфигурацию сборки назовем ее сypress, в которой указали кастомный index.html. Как это реализовать? Находим в angular.json конфигурацию вашего приложения, открываем секцию build, добавляем там отдельную конфигурацию для Cypress и не забываем указывать эту конфигурацию для serve-режима.

Пример конфигурации для build:

"build": {..."configurations": { // Другие конфигурации"cypress": {"aot": true,"index": "projects/main-app-integrations/src/fixtures/index.html","fileReplacements": [{"replace": "projects/main-app/src/environments/environment.ts","with": "projects/main-app/src/environments/environment.prod.ts"}]}}}

Интеграция с serve:

"serve": {..."configurations": { // Другие конфигурации"cypress": {"browserTarget": "main-app:build:cypress"}}}

Из основного: для cypress конфигурации мы указываем aot сборку и подменяем файлы с environment это необходимо для создания prod-like сборки при тестировании.

Итак, с index.html разобрались, осталось поднять приложения, дождаться окончания сборки и прогнать поверх него тесты. Для этого используем библиотеку start-server-and-test и на ее основе напишем скрипты:

"main-app:cy:run": "cypress run","main-app:cy:open": "cypress open","main-app:integrations": "start-server-and-test main-app:serve:cypress http://localhost:8808/app/user/ main-app:cy:run","main-app:integrations:open": "start-server-and-test main-app:serve:cypress http://localhost:8808/app/user/ main-app:cy:open"

Как можно заметить, тут два типа скриптов: open и run. Режим open открывает GUI самого Cypress, где можно переключаться между тестами и использовать time-travel. Режим run это просто прогон тестов и получение конечного результата этого прогона, отлично подходит для запуска в CI.

По результатам проделанной работы мы смогли получить стартовый каркас для написания первого теста и прогона его в CI.

Монорепозиторий


У описанного подхода есть очень заметная проблема: если в репозитории два и более приложений, то подход с одной папкой нежизнеспособен. Так и произошло у нас. Но случилось это довольно интересным способом. В момент внедрения Cypress мы переезжали на NX, а этот красавец из коробки дает возможность работать с Cypress. Каков принцип работы в нем:

  1. У вас есть приложение, например main-app, рядом с ним создается приложение main-app-e2e.
  2. Переименуйте main-app-e2e в main-app-integrations вы восхитительны.

Теперь вы можете прогонять интеграционные тесты одной командой ng e2e main-app-integrations. NX автоматически поднимет main-app, дождется ответа и прогонит тесты.

К сожалению, остались в стороне те, кто сейчас использует Angular Workspace, но ничего страшного, у меня есть рецепт и для вас. Файловую структуру будем использовать как и у NX:

  1. Создаем рядом с вашим приложением папку main-app-integrations.
  2. Создаем в ней папку src и заносим в нее содержимое папки cypress.
  3. Не забываем перенести cypress.json (изначально он появится в корне) в папку main-app-integrations.
  4. Правим cypress.json, указывая пути до новых папок с тестами, плагинами и вспомогательными командами (параметры integrationFolder, pluginsFile и supportFile).
  5. Cypress умеет работать с тестами в любых папках, для указания папки используется параметр
    project, поэтому меняем команду с cypress run/open на cypress run/open -project ./projects/main-app-integrations/src.

Решение под Angular Workspace максимально похоже на решение для NX, за исключением того, что папку создаем руками и она не является одним из проектов в вашем монорепозитории. В качестве альтернативы можно напрямую использовать билдер от NX для Cypress (пример репозитория на NX с Cypress, там можно подсмотреть итоговое использование nx-cypress билдера внимание на angular.json и проект
cart-e2e и products-e2e).

Visual Regressing


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

В качестве решения была взята библиотека cypress-image-snapshot. Внедрение не отняло много времени, и вот спустя 20 минут мы получили первый скриншот нашего приложения размером 1000 600 px. Радости было много, ведь интеграция и использование были слишком простыми, а полученная польза могла быть огромной.

После генерации пяти эталонных скриншотов мы запустили проверку в CI, как итог билд развалился. Оказалось, что скриншоты, созданные с помощью команд open и run, отличаются. Решение было довольно простым: снимать скриншоты только в CI-режиме, для этого убрали снятие скриншотов в local-режиме, например так:

Cypress.Commands.overwrite('matchImageSnapshot',(originalFn, subject, fileName, options) => {if (Cypress.env('ci')) {return originalFn(subject, fileName, options);}return subject;},);

В данном решении мы смотрим на параметр env в Cypress, установить его можно разными путями.

Шрифты


Локально тесты начали проходить при повторном запуске, пытаемся снова запустить их в CI. Результат можно увидеть ниже:



Довольно просто заметить разницу в шрифтах на diff-скриншоте. Эталонный скриншот был сгенерирован на macOS, а в CI на агентах установлен Linux.

Неправильное решение


Подобрали один из стандартных шрифтов (вроде это был Ubuntu Font), который давал минимальный попиксельный diff, и применили этот шрифт для текстовых блоков (сделано в
index.html, который предназначался только для cypress-тестов). Затем повысили общий diff до 0,05% и попиксельный diff до 20%. С такими параметрами мы прожили неделю до первого случая, когда потребовалось изменить текст в компоненте. В итоге билд остался зеленым, хотя скриншот мы не обновили. Текущее решение оказалось бесполезным.

Правильное решение


Исходная проблема была в разных окружениях, решение в принципе напрашивается само собой Docker. Для Cypress уже есть готовые docker-образы. Там есть разные вариации образов, нас интересует included, так как Cypress в нем уже включен в образ и не будет каждый раз заново происходить скачивание и распаковка Cypress binary (GUI Cypress запускается через бинарный файл, и скачивание вместе с распаковкой занимает больше времени, чем скачивание docker-образа).
На основе included docker-образа делаем свой docker-контейнер, для этого у себя мы сделали файл integration-tests.Dockerfile с подобным содержимым:

FROM cypress:included:4.3.0COPY package.json /app/COPY package-lock.json app/WORKDIR /appRUN npm ciCOPY / /app/ENTRYPOINT []

Хочется отметить обнуление ENTRYPOINT, это связано с тем, что он задан по умолчанию в образе cypress/included и указывает на команду cypress run, что не дает нам использовать другие команды. Также разбиваем наш dockerfile на слои, чтобы при каждом перезапуске тестов не выполнять повторно npm ci.

Добавляем .dockerignore файл (если его нет) в корень репозитория и в нем обязательно указываем node-modules/ и */node-modules/.

Для запуска в Docker наших тестов напишем bash-скрипт integration-tests.sh со следующим содержимым:

docker build -t integrations -f integration-tests.Dockerfile .docker run --rm -v $PWD/projects/main-app-integrations/src:/app/projects/main-app-integrations/src integrations:latest npm run main-app:integrations

Краткое описание: билдим наш docker-контейнер integration-tests.Dockerfile и указываем volume на папку с тестами, чтобы была возможность получить созданные скриншоты из Docker.

Снова шрифты


После решения проблемы, описанной в предыдущей главе, в билдах наступило затишье, но примерно через день мы столкнулись со следующей проблемой (слева и справа скриншоты одного компонента, сделанные в разное время):



Думаю, самые внимательные заметили, что не хватает заголовка в попапе. Причина очень простая не успел загрузиться шрифт, так как не был подключен через assets, а находился на CDN.

Неправильное решение


Скачиваем шрифты с CDN, закидываем их в assets для cypress-конфигурации и в нашем кастомном
index.html для интеграционных тестов подключаем их. С таким решением мы прожили приличное время, пока у нас не изменился корпоративный шрифт. Второй раз проворачивать такую же историю желания не было.

Правильное решение


Было решено начать предзагружать все необходимые шрифты для теста в
index.html для cypress-конфигурации, выглядело это примерно так:

<linkrel="preload"href="...."as="font"type="font/woff2"crossorigin="anonymous"/>

Число падений тестов из-за не успевших загрузиться шрифтов снизилось до минимума, но не до нуля: все равно иногда шрифт не успевал загрузиться. На помощь пришло решение из KitchenSink самого Cypress waitForResource.
В нашем случае, так как уже была подключена предзагрузка шрифтов, мы просто переопределили команду visit в Cypress, в итоге она не просто навигируется на страничку, но и ждет загрузку указанных шрифтов. Также хотелось бы дополнить, что waitForResource решает проблему не только шрифтов, но и любой загружаемой статики, например картинок (из-за них у нас также ломались скриншоты, и waitForResource отлично помогло). После применения данного решения проблем со шрифтами и любой загружаемой статикой не было.

Анимации


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

Первое решение


Самое простое, что нам пришло в голову на начальном этапе: перед созданием скриншота останавливать браузер на определенное время, чтобы анимации успели завершиться. Шли по цепочке 100ms, 200ms, 500ms и в итоге 1000ms. Оглядываясь назад, я понимаю, что это решение изначально было ужасным, но хотелось именно предостеречь вас от такого же решения. Почему ужасным? Время анимаций разное, агенты в CI тоже могут подтупливать иногда, из-за чего любое время ожидания стабилизации страницы от раза к разу было разным.

Второе решение


Даже с ожиданием в 1 секунду страница не всегда успевала стать стабильной. После небольшого ресерча нашли инструмент у Angular Testability. Принцип основан на отслеживании стабильности ZoneJS:

Cypress.Commands.add('waitStableState', () => {return cy.window().then(window => {const [testability]: [Testability] = window.getAllAngularTestabilities();return new Cypress.Promise(resolve => {testability.whenStable(() => {resolve();}, 3000);});});});

Таким образом при создании скриншотов у нас вызывались две команды: cy.wait(1000) и cy.waitStableState().

С тех пор не было ни одного рандомно упавшего скриншота, но давайте вместе посчитаем, сколько времени тратилось на простаивание браузера. Предположим, у вас в тесте делается 5 скриншотов, для каждого есть стабильное время ожидания в 1 секунду и какое-то рандомное время, предположим 1,5 секунды в среднем (я не замерял среднее значение в реальности, поэтому взял из головы по собственным ощущениям). В итоге для создания скриншотов в тесте мы тратим дополнительно 12,5 секунды. Представим, что вы уже написали 20 тестовых сценариев, где в каждом тесте не менее 5 скриншотов. Получаем, что переплата за стабильность ~4 минуты при имеющихся 20 тестах.

Но даже не это самая большая проблема. Как уже обсуждалось выше, при локальном запуске тестов скриншоты не гоняются, а в CI гоняются, и из-за ожиданий на каждый скриншот срабатывали колбеки в коде, например на debounce Time, что уже создавало рандомизацию в тестах, ведь в CI и локально они проходили по-разному.

Текущее решение


Начнем с Angular-анимаций. Наш любимый фреймворк во время анимации на элементе DOM навешивает класс ng-animating. Это и стало ключом нашего решения, ведь теперь надо убедиться, что на элементе сейчас нет класса анимации. В итоге это вылилось в такую функцию:

export function waitAnimation(element: Chainable<JQuery>): Chainable<JQuery> { return element.should('be.visible').should('not.have.class', 'ng-animating');}

Кажется, ничего сложного, но именно это легло в основу наших решений. На что хочется обратить внимание в таком подходе: делая скриншот, вы должны понимать, анимация какого элемента может сделать ваш скриншот нестабильным, и перед созданием скриншота добавить assertion, который проверит, что элемент не анимируется. Но анимации также могут быть и на CSS. Как говорит сам Cypress, любые assertion на элементе ждут окончания анимации на нем подробнее тут и тут. То есть суть подхода в следующем: у нас есть анимируемый элемент, добавляем на него assertion should(be.visible)/should(not.be.visible) и Cypress сам дождется окончания анимации на элементе (возможно, кстати, решение с ng-animating не нужно и достаточно только проверок Cypress, но мы пока что используем утилиту waitAnimation).

Как сказано в самой документации, Cypress проверяет изменение позиции элемента на странице, но не все анимации про изменение позиции, есть также fadeIn/fadeOut-анимации. В этих случаях принцип решения тот же: проверяем, что элемент виден / не виден на странице.

При переезде с решения cy.wait(1000) + cy.waitStableState() на waitAnimation и Cypress Assertion пришлось потратить ~2 часа времени на стабилизацию старых скриншотов, но как итог мы получили +2030 секунд вместо +4 минут на время выполнения тестов.На данный момент мы тщательно подходим к ревью скриншотов: проверяем, что они выполнены не во время анимации элементов DOM и добавлены проверки в тесте на ожидание анимации. Например, мы часто добавляем отображение скелетонов на странице, пока данные не загрузились. Соответственно, на ревью сразу же прилетает требование, что при создании скриншотов в DOM не должен присутствовать скелетон, так как на нем находится анимация плавного исчезновения.

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

Размер скриншотов


Возможно, вы заметили интересную особенность: разрешение скриншотов по умолчанию 1000 600 px. К сожалению, есть проблема с размером окна браузера при запуске в Docker: даже если вы через Cypress поменяете размер viewportа, это не поможет. Мы нашли решение для браузера Chrome (для Electron не удалось быстро найти работающее решение, а предложенное в данном issue у нас не завелось). Для начала надо сменить браузер для запуска тестов на Chrome:

  1. Не для NX делаем с помощью аргумента --browser chrome при запуске команды cypress open/run и для run-команды указываем параметр --headless.
  2. Для NX в конфигурации проекта в angular.json с тестами указываем параметр browser: chrome, и для конфигурации, которая будет запускаться в CI, указываем headless: true.

Теперь делаем правки в plugins и получаем скриншоты размером 1440 900 px:

module.exports = (on, config) => {on('before:browser:launch', (browser, launchOptions) => {if (browser.name === 'chrome' && browser.isHeadless) {launchOptions.args.push('--disable-dev-shm-usage');launchOptions.args.push('--window-size=1440,1200');return launchOptions;}return launchOptions;});};

Даты


Тут все просто: если где-то отображается дата, связанная с текущей, снятый скриншот сегодня завтра упадет. Фиксим просто:

cy.clock(new Date(2025, 11, 22, 0).getTime(), ['Date']);

Теперь таймеры. Мы не стали заморачиваться и используем опцию blackout при создании скриншотов, например:

cy.matchImageSnapshot('salary_signing-several-payments', {blackout: ['.timer'],});

Flaky-тесты


Используя приведенные выше рекомендации, вы сможете добиться максимальной стабильности тестов, но не 100%, ведь на тесты влияет не только ваш код, но и окружение, в котором они запускаются.

В итоге какой-то процент тестов будет изредка падать, например из-за проседаний производительности агентов в CI. В первую очередь стабилизируем тест с нашей стороны: добавляем необходимые assertion перед снятием скриншотов, но на период починки таких тестов можно использовать retry упавших тестов с помощью cypress-plugin-retries.

Прокачиваем CI


В прошлых главах мы научились запускать тесты одной командой и узнали про работу со скриншот-тестированием. Теперь можно взглянуть в сторону оптимизации CI. В нашем билде будет обязательно выполняться:

  1. Команда npm ci.
  2. Поднятие приложения в aot-режиме.
  3. Запуск интеграционных тестов.

Взглянем на первый и второй пункт и поймем, что похожие шаги выполняются в другом вашем билде в CI билд со сборкой приложения.
Основное отличие это запуск не ng serve, а ng build. Таким образом, если сможем получить уже собранное приложение в билде с интеграционные тестами и поднять сервер с ним, то мы сможем сократить время выполнения билда с тестами.

Почему нам это потребовалось? Просто приложение у нас большое и выполнение
npm ci + npm start в aot-режиме на агенте в CI занимало ~15 минут, что в принципе требовало больших усилий от агента, и еще поверх этого запускались интеграционные тесты. Предположим, у вас уже написано 20+ тестов и на 19-м тесте у вас падает браузер, в котором прогоняются тесты, из-за большой нагрузки на агент. Как вы понимаете, перезапуск билда это снова ожидание установки зависимостей и запуска приложения.

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

Сервер со статикой


Нам нужна замена ng serve по поднятию сервера с нашим приложением. Вариантов много, начну с нашего первого angular-http-server. В его настройке нет ничего сложного: устанавливаем зависимость, указываем, в какой папке лежит наша статика, указываем, на каком порту поднять приложение, и радуемся.

Этого решения нам хватило на целых 20 минут, а потом мы поняли, что какие-то запросы хотим проксировать на тестовый контур. Подключить проксирование для angular-http-server не получилось. Конечным решением стало поднятие сервера на Express. Для решения задачи использовался сам express и express-http-proxy. Раздавать нашу статику будем с помощью
express.static, в итоге получается скрипт, похожий на этот:

const express = require('express');const appStaticPathFolder = './dist';const appBaseHref = './my/app';const port = 4200;const app = express();app.use((req, res, next) => {const accept = req.accepts().join().replace('*/*', '');if (accept.includes('text/html')) {req.url = baseHref;}next();});app.use(appBaseHref, express.static(appStaticPathFolder));app.listen(port);

Интересным моментом здесь является то, что перед прослушиванием роута по baseHref приложения мы также обрабатываем все запросы и ищем запрос на index.html. Это сделано для случаев, когда в тестах осуществляется переход на страницу приложения, путь которой отличается от baseHref. Если не сделать данный трюк, то при переходе на любую страницу вашего приложения, кроме главной, будет прилетать 404 ошибка. Теперь добавим щепотку проксирования:

const proxy = require('express-http-proxy');app.use('/common',proxy('https://qa-stand.ru', {proxyReqPathResolver: req => '/common' + req.url,}),);

Чуть подробнее рассмотрим происходящее. Есть константы:

  1. appStaticForlderPath папка, где находится статика вашего приложения.
  2. appBaseHref возможно, у вашего приложения есть baseHref, если нет можно указать /.

Проксируем же мы все запросы, начинающиеся на /common, причем при проксировании сохраняем тот же путь, который был у запроса, с помощью настройки proxyReqPathResolver. Если ее не использовать, то все запросы будут просто идти наhttps://qa-stand.ru.

Кастомизация index.html


Нам нужно было решить проблему с кастомным index.html, который мы использовали при ng serve приложения в режиме Cypress. Напишем простой скрипт на node.js. Исходными параметрами у нас был index.modern.html, требовалось превратить его в index.html и удалить оттуда ненужные скрипты:

const fs = require('fs');const appStaticPathFolder = './dist';fs.copyFileSync(appStaticPathFolder + '/index.modern.html', appStaticPathFolder + '/index.html');fs.readFile(appStaticPathFolder + '/index.html', 'utf-8', (err, data) => {const newValue = data.replace('<script type="text/javascript" src="http://personeltest.ru/aways/habr.com/auth.js"></script>','',).replace('<script type="text/javascript" src="http://personeltest.ru/aways/habr.com/analytics.js"></script>','',);fs.writeFileSync(appStaticPathFolder + '/index.html', newValue, 'utf-8');});

Скрипты


Очень не хотелось для запуска тестов в CI делать еще раз npm ci всех зависимостей (ведь это уже делалось в таске с билдом приложения), поэтому появилась идея создать отдельную папку под все эти скрипты со своим package.json. Назовем папку, например, integration-tests-scripts и закинем туда три файла: server.js, create-index.js, package.json. Первые два файла были описаны выше, разберем теперь содержимое package.json:

{"name": "cypress-tests","version": "0.0.0","private": true,"scripts": {"create-index": "node ./create-index.js","main-app:serve": "node ./server.js","main-app:cy:run": "cypress run --project ./projects/main-app-integrations ","main-app:integrations": "npm run create-index && start-server-and-test main-app:serve http://localhost:4200/my/app/ main-app:cy:run"},"devDependencies": {"@cypress/webpack-preprocessor": "4.1.0","@types/express": "4.17.2","@types/mocha": "5.2.7","@types/node": "8.9.5","cypress": "4.1.0","cypress-image-snapshot": "3.1.1","express": "4.17.1","express-http-proxy": "^1.6.0","start-server-and-test": "1.10.8","ts-loader": "6.2.1","typescript": "3.8.3","webpack": "4.41.6"}}

В package.json присутствуют только зависимости, необходимые для прогона интеграционных тестов (с поддержкой typescript и скриншот-тестирования) и скрипты по запуску сервера, созданию index.html и известный из главы по запуску интеграционных тестов в Angular Workspace start-server-and-test.

Запуск


Оборачиваем выполнение интеграционных тестов в новый Dockerfile integration-tests-ci.Dockerfile:

FROM cypress/included:4.3.0COPY integration-tests-scripts /app/WORKDIR /appRUN npm ciCOPY projects/main-app-integrations /app/projects/main-app-integrationsCOPY dist /app/distCOPY tsconfig.json /app/ENTRYPOINT []

Суть проста: копируем и разворачиваем папку integration-tests-scripts в корень приложения и копируем все, что необходимо для запуска тестов (у вас этот набор может отличаться). Основные отличия от предыдущего файла в том, что мы не копируем все приложение внутрь docker-контейнера, просто минимальная оптимизация времени выполнения тестов в CI.

Создаем файл integration-tests-ci.sh со следующим содержимым:

docker build -t integrations -f integration-tests-ci.Dockerfile .docker run --rm -v $PWD/projects/main-app-integrations/src:/app/projects/main-app-integrations/src integrations:latest npm run main-app:integrations

Во время запуска команды с тестами корневым станет package.json из папки integration-tests-scripts и в нем запустится команда main-app:integrations. Соответственно, так как эта папка развернется в корень, то и пути до папки со статикой вашего приложения нужно указывать с мыслью, что запускаться все будет из корня, а не из папки integration-tests-scripts.

Хочу также сделать небольшую ремарку: конечный bash-скрипт для запуска интеграционных тестов по мере его эволюции я называл по-разному. Так делать не нужно, это было сделано только для удобства чтения этой статьи. У вас должен всегда оставаться один файл, например integration-tests.sh, который вы уже развиваете. Если у вас несколько приложений в репозитории и их способы подготовки различаются, можно разруливать либо переменными в bash, либо разными файлами под каждое приложение зависит от ваших потребностей.

Итог


Информации было много думаю, теперь стоит подвести итог на основе написанного выше.
Подготовка средств для локального написания и прогона тестов с щепоткой скриншот-тестирования:

  1. Ставим зависимость для Cypress.
  2. Подготавливаем папку с тестами:
    1. Angular Single Application оставляем все в папке cypress.
    2. Angular Workspace создаем папку имя приложения-integrations рядом с приложением, на которое будут гоняться тесты, и переносим в нее все из папки cypress.
    3. NX переименовываем проект из имя приложения-e2e в имя-приложения-integrations.

  3. Кастомная cypress-сборка для поднятия приложения делаем в build-разделе конфигурацию Cypress, указываем там aot, подмену на свой отдельный index.html, подмену файла environment на prod-файл и указываем в serve-разделе сборку Cypress (данный пункт необходим, если вам нужны какие-то отличия от prod-сборки, в противном случае можно пропустить).
  4. Скрипты по запуску и прогону тестов:
    1. Angular Single Application готовим скрипт с serve-ом cypress-сборки и запуску тестов, комбинируем все это с помощью start-server-and-test.
    2. Angular Workspace аналогично с Angular Single Application, только указываем путь до тестов при запуске cypress run/open.
    3. NX запускаем тесты с помощью команд ng e2e.

  5. Подмешиваем скриншот-тестирование:
    1. Добавляем зависимость cypress-image-snapshot.
    2. Переопределяем стандартную команду по сравнению скриншотов для запуска ее только в CI.
    3. В тестах не делаем скриншотов на рандоме. Если скриншоту предшествует анимация, обязательно ждем ее например, добавляем Cypress Assertion на анимируемый элемент.
    4. Дату мокируем через cy.clock либо используем опцию blackout при снятии скриншота.
    5. Любую подгружаемую статику в runtime ожидаем через кастомную команду cy.waitForResource (картинки, шрифты и т. д.).

  6. Оборачиваем все это в Docker:
    1. Готовим Dockerfile.
    2. Создаем bash-файл.


Прогоняем тесты поверх собранного приложения:

  1. В CI учимся прокидывать между билдами артефакты собранного приложения (остается на вас).
  2. Готовим папку integration-tests-scripts:
    1. Скрипт по поднятию сервера вашего приложения.
    2. Скрипт по изменению вашего index.html (если вас устраивает изначальный index.html можно скипать).
    3. Добавляем в папку package.json с необходимыми скриптами и зависимостями.
    4. Готовим новый Dockerfile.
    5. Создаем bash-файл.


Полезные ссылки


  1. Angular Workspace + Cypress + CI в данном примере я создал базу для Angular Workspace приложения с накрученным CI на основе скриптов, описанных в статье (без поддержки typescript).
  2. Cypress обратите внимание на раздел trade-offs.
  3. Start-server-and-test запуск сервера, ожидание ответа и запуск тестов.
  4. Cypress-image-snapshot библиотека для скриншот-тестирования.
  5. Cypress recipes готовые наработки по Cypress, чтобы не изобретать свои велосипеды.
  6. Flaky Tests статья про нестабильные тесты, в конце есть ссылка на статью от Google.
  7. Github Action Cypress дает возможность использовать предустановленные конфиги для GitHub Action, в README куча примеров, из интересного встроенная поддержка wait-on. Также есть пример с docker.
Подробнее..

Лабаем на MIDI клавиатуре в Angular

30.06.2020 14:13:12 | Автор: admin

Web MIDI API интересный зверь. Хоть он и существует уже почти пять лет, его все еще поддерживает только Chromium. Но это не помешает нам создать полноценный синтезатор в Angular. Пора поднять Web Audio API на новый уровень!



Ранее я рассказывал про декларативную работу с Web Audio API в Angular.


Программировать музыку, конечно, весело, но что если мы хотим ее играть? В 80-е годы появился стандарт обмена сообщениями между электронными инструментами MIDI. Он активно используется и по сей день, и Chrome поддерживает его на нативном уровне. Это значит, что, если у вас есть синтезатор или MIDI-клавиатура, вы можете подключить их к компьютеру и считывать то, что вы играете. Можно даже управлять устройствами с компьютера, посылая исходящие сообщения. Давайте разберемся, как это сделать по-хорошему в Angular.


Web MIDI API


В интернете не так много документации на тему этого API, не считая спецификации. Вы запрашиваете доступ к MIDI-устройствам через navigator и получаете Promise со всеми входами и выходами. Эти входы и выходы еще их называют портами являются нативными EventTargetами. Обмен данными осуществляется через MIDIMessageEventы, которые содержат Uint8Array сообщения. В каждом сообщении не более 3 байт. Первый элемент массива называется status byte. Каждое число означает конкретную роль сообщения, например нажатие клавиши или движение ползунка параметра. В случае нажатой клавиши второй байт отвечает за то, какая клавиша нажата, а третий как громко нота была сыграна. Полное описание сообщений можно подсмотреть на официальном сайте MIDI. В Angular мы работаем с событиями через Observable, так что первым шагом станет приведение Web MIDI API к RxJs.


Dependency Injection


Чтобы подписаться на события, мы сначала должны получить MIDIAccess-объект, чтобы добраться до портов. navigator вернет нам Promise, а RxJs превратит его для нас в Observable. Мы можем создать для этого InjectionToken, используя NAVIGATOR из @ng-web-apis/common. Так мы не обращается к глобальному объекту напрямую:


export const MIDI_ACCESS = new InjectionToken<Promise<MIDIAccess>>(   'Promise for MIDIAccess object',   {       factory: () => {           const navigatorRef = inject(NAVIGATOR);           return navigatorRef.requestMIDIAccess               ? navigatorRef.requestMIDIAccess()               : Promise.reject(new Error('Web MIDI API is not supported'));       },   },);

Теперь мы можем подписаться на все MIDI-события. Можно создать Observable одним из двух способов:


  1. Создать сервис, который наследуется от Observable, как мы делали в Geolocation API
  2. Создать токен с фабрикой, который будет транслировать этот Promise в Observable событий

Поскольку в этот раз нам понадобится совсем немного преобразований, токен вполне подойдет. С обработкой отказа код подписки на все события выглядит так:


export const MIDI_MESSAGES = new InjectionToken<Observable<MIDIMessageEvent>>(   'All incoming MIDI messages stream',   {       factory: () =>           from(inject(MIDI_ACCESS).catch((e: Error) => e)).pipe(               switchMap(access =>                   access instanceof Error                       ? throwError(access)                       : merge(                             ...Array.from(access.inputs).map(([_, input]) =>                                 fromEvent(                                     input as FromEventTarget<MIDIMessageEvent>,                                     'midimessage',                                 ),                             ),                         ),               ),               share(),           ),   },);

Если нам нужен какой-то конкретный порт, например если мы хотим отправить исходящее сообщение, достанем его из MIDIAccess. Для этого добавим еще один токен и подготовим фабрику для удобного использования:


export function outputById(id: string): Provider[] {   return [       {           provide: MIDI_OUTPUT_QUERY,           useValue: id,       },       {           provide: MIDI_OUTPUT,           deps: [MIDI_ACCESS, MIDI_OUTPUT_QUERY],           useFactory: outputByIdFactory,       },   ];}export function outputByIdFactory(   midiAccess: Promise<MIDIAccess>,   id: string,): Promise<MIDIOutput | undefined> {   return midiAccess.then(access => access.outputs.get(id));}

Кстати, вы знали, что нет необходимости спрэдить массив Provider[], когда добавляете его в метаданные? Поле providers декоратора @Directive поддерживает многомерные массивы, так что можно писать просто:

providers: [  outputById(someId),  ANOTHER_TOKEN,  SomeService,]

Если вам интересны подобные практичные мелочи про Angular приглашаю почитать нашу серию твитов с полезными советами.

Аналогичным образом можно добывать и входные порты, а также запрашивать их по имени.


Операторы


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


  • Фильтрующие. Они отсеивает события, которые нас не интересуют. Например, если мы хотим слушать только сыгранные клавиши или ползунок громкости.
  • Преобразующие. Они будут преобразовывать значения для нас. Например, оставлять только массив данных сообщения, отбрасывая остальные поля события.

Вот так мы можем слушать события с определенного канала:


export function filterByChannel(   channel: MidiChannel,): MonoTypeOperatorFunction<MIDIMessageEvent> {   return source => source.pipe(filter(({data}) => data[0] % 16 === channel));}

Status byte организован группами по 16: 128143 отвечают за нажатые клавиши (noteOn) на каждом из 16 каналов. 144159 за отпускание зажатых клавиш (noteOff). Таким образом, если мы возьмем остаток от деления этого байта на 16 получим номер канала.


Если нас интересуют только сыгранные ноты, поможет такой оператор:


export function notes(): MonoTypeOperatorFunction<MIDIMessageEvent> {   return source =>       source.pipe(           filter(({data}) => between(data[0], 128, 159)),           map(event => {               if (between(event.data[0], 128, 143)) {                   event.data[0] += 16;                   event.data[2] = 0;               }               return event;           }),       );}

Некоторые MIDI-устройства отправляют явные noteOff-сообщения, когда вы отпускаете клавишу. Но некоторые вместо этого отправляют noteOn сообщение с нулевой громкостью. Этот оператор нормализует такое поведение, приводя все сообщения к noteOn. Мы просто смещаем status byte на 16, чтобы noteOff-сообщения перешли на территорию noteOn, и задаем нулевую громкость.

Теперь можно строить цепочки операторов, чтобы получить стрим, который нам нужен:


readonly notes$ = this.messages$.pipe(  catchError(() => EMPTY),  notes(),  toData(),);constructor(  @Inject(MIDI_MESSAGES)  private readonly messages$: Observable<MIDIMessageEvent>,) {}

Пора применить все это на практике!


Создаем синтезатор


С небольшой помощью библиотеки для Web Audio API, которую мы обсуждали ранеесоздадим приятно звучащий синтезатор всего за пару директив. Затем мы скормим ему ноты, которые играем через описанный выше стрим.


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


readonly notes$ = this.messages$.pipe(  catchError(() => EMPTY),  notes(),  toData(),  scan(    (map, [_, note, volume]) => map.set(note, volume), new Map()  ),);

Чтобы звук не прерывался резко и не звучал всегда на одной громкости, создадим полноценный ADSR-пайп. В прошлой статье была его упрощенная версия. Напомню, идея ADSR менять громкость звука следующим образом:



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


@Pipe({    name: 'adsr',})export class AdsrPipe implements PipeTransform {    transform(        value: number,        attack: number,        decay: number,        sustain: number,        release: number,    ): AudioParamInput {        return value            ? [                  {                      value: 0,                      duration: 0,                      mode: 'instant',                  },                  {                      value,                      duration: attack,                      mode: 'linear',                  },                  {                      value: sustain,                      duration: decay,                      mode: 'linear',                  },              ]            : {                  value: 0,                  duration: release,                  mode: 'linear',              };    }}

Теперь, когда мы нажимаем клавишу, громкость будет линейно нарастать за время attack. Затем она убавится до уровня sustain за время decay. А когда мы отпустим клавишу, громкость упадет до нуля за время release.

С таким пайпом мы можем набросать синтезатор в шаблоне:


<ng-container  *ngFor="let note of notes | keyvalue; trackBy: noteKey">  <ng-container    waOscillatorNode    detune="5"    autoplay    [frequency]="toFrequency(note.key)"   >    <ng-container       waGainNode       gain="0"      [gain]="note.value | adsr: 0:0.1:0.02:1"    >      <ng-container waAudioDestinationNode></ng-container>    </ng-container>  </ng-container>   <ng-container    waOscillatorNode    type="sawtooth"    autoplay     [frequency]="toFrequency(note.key)"  >    <ng-container       waGainNode      gain="0"      [gain]="note.value | adsr: 0:0.1:0.02:1"    >      <ng-container waAudioDestinationNode></ng-container>      <ng-container [waOutput]="convolver"></ng-container>    </ng-container>  </ng-container></ng-container><ng-container  #convolver="AudioNode"  waConvolverNode  buffer="assets/audio/response.wav">  <ng-container waAudioDestinationNode></ng-container></ng-container>

Мы перебираем собранные ноты с помощью встроенного keyvalue пайпа, отслеживая их по номеру сыгранной клавиши. Затем у нас есть два осциллятора, играющих нужные частоты. А в конце эффект реверберации с помощью ConvolverNode. Довольно нехитрая конструкция и совсем немного кода, но мы получим хорошо звучащий, готовый к использованию инструмент. Попробуйте демо в Chrome:


https://ng-web-apis.github.io/midi


Если у вас нет MIDI клавиатуры можете понажимать на ноты мышкой.


Живое демо доступно тут, однако браузер не позволит получить доступ к MIDI в iframe: https://stackblitz.com/edit/angular-midi

Заключение


В Angular мы привыкли работать с событиями с помощью RxJs. И Web MIDI API не сильно отличается от привычных DOM событий. С помощью пары токенов и архитектурных решений мы смогли с легкостью добавить поддержку MIDI в наше Angular приложение. Описанное решение доступно в виде open-source библиотеки @ng-web-apis/midi. Она является частью большого проекта, под названием Web APIs for Angular. Наша цель создание легковесных качественных оберток для использования нативного API в Angular приложениях. Так что если вам нужен, к примеру, Payment Request API или Intersection Observer посмотрите все наши релизы.


Если вам любопытно, что же такого интересного можно сделать на Angular при помощи Web MIDI API приглашаю вас научиться играть на клавишах в личном проекте Jamigo.app

Подробнее..

Еще раз про Angular CLI Builders

06.07.2020 16:22:04 | Автор: admin


Всем привет! Меня зовут Игорь, я фронтэндер в Tinkoff.ru. И, как ни странно, я давно и безнадежно прикипел к Angular и ко всему, что с ним связано.


Очень хорошо помню свои первые проблемы со сборкой приложения на Angular: как передать в приложение переменные окружения или изменить алгоритмы сборки стилей? Я так к этому привык при работе с React. И сначала это решали с помощью ng eject: конфигурация webpack просто извлекалась из недр Angular CLI и изменялась как душе разработчика угодно. Выглядело это как костыль webpack.config.js был раздутым и сложным. Но, когда Angular CLI v8.0.0 принес нам стабильный CLI Builders API, который позволяет кастомизировать, заменять или даже создавать новые CLI команды, все стало проще.


Сейчас самый популярный билдер для кастомизации конфигурации webpack @angular-builders/custom-webpack. Если заглянуть в исходники всех билдеров, поставляемых пакетом, можно увидеть очень компактные решения, не превышающие и 30 строк кода.
Го тогда запилим свой? Challenge Accepted!


Данный материал подразумевает, что вы уже знакомы с Angular и Angular CLI, знаете, что такое rxjs и как с ним жить, а также готовы прочитать 50 строк кода.


Так что же такое эти билдеры?


Билдер это всего лишь функция, которая может быть вызвана с помощью Angular CLI. Она вызывается с двумя параметрами:


  1. JSON-подобный объект-конфигурация
  2. BuilderContext объект, обеспечивающий доступ, например, к логгеру.

Функция может быть как синхронной, так и асинхронной. Ну и бонусом можно вернуть Observable. Но в любом случае и Promise, и Observable должны испускать BuilderOutput.


Упакованная в npm-пакет определенным образом эта функция может использоваться при настройке таких CLI команд как build, test, lint, deploy и любых других из секции architect конфигурационного файла angular.json.


Опять будет просто пример из документации?


Нет. Конечно же я сначала сделал пример, максимально похожий на пример из документации. Такой билдер я использовал, например, при работе с NX и деплоем только измененных приложений. Но мне внезапно понадобился билдер, который умеет запускать команды из angular.json в определенном порядке и в зависимости друг от друга.


Более реальный пример: вам вдруг понадобился запущенный dev-server вашего приложения в тестах. Есть разные консольные утилиты и npm-пакеты для запуска и ожидания старта сервера, но почему бы не сделать билдер, которые сможет запускать dev-server в watch-режиме перед запуском тестов и убивать dev-server, как только тесты отработают?


С чего начать?


А начать надо с создания заготовки под пакет, в который мы спрячем билдеры. Рабочее пространство я сгенерировал с помощью NX, как и заготовку для библиотеки под билдер.


npx create-nx-workspace ng-builderscd ./ng-buildersnpx ng g @nrwl/node:library build

Для автоматизации релизов и версионирования был подключен semantic-release. В роли CI был использован Github Actions.


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


Конфигурация билдера


Вот так я видел конфигурацию, которая решала мою проблему:


// angular.json{  "version": 1,  "projects": {    "app": {      "architect": {        "stepper": {          "builder": "@ng-builders/build:stepper",          "options": {            "targets": { // описание целей              "jest": { // название цели и его конфигурация                "target": "app:jest", // существующая в angular.json задача                 "deps": ["server"] // зависимые цели, которые нужно запустить перед основной              },              "server": {                "target": "app:serve",                "watch": true // watch-режим              }            },            "steps": ["jest"] // список целей для выполнения          }        }      }    }  }}

В теории, такую задачу в angular.json можно выполнить с помощью команды:


ng run app:stepper

Немного поработав над спецификацией я пришел к таким интерфейсам:


export interface Target {  /**   * Список targetId, которые необходимо выполнить перед запуском задачи   *   * Отличается от Schema#steps тем, что задача не ждет полного    * выполнения зависимых задач   */  deps?: string[];  /**   * Цель для выполнения   */  target: string;  /**   * Включение watch-режима   */  watch?: boolean;  /**   * Переопределение конфигурационных параметров цели   */  overrides?: { [key: string]: any };}export interface Targets {  // targetId - имя задачи  [targetId: string]: Target;}export interface Schema {  /**   * Строгая последовательность выполнения задач, в массиве    * указываются targetId из Targets   *   * Следующая задача запускается только после завершения предыдущей   */  steps: string[];  targets: Targets;}

Из основного вроде все. Конечно, схему можно еще расширить и, например, добавить выбор конфигурации (production, dev и т.д.), но для v1.0 этого будет достаточно.


По этим интерфейсам также была написана json-схема, которая будет использоваться при валидации конфигурационных данных.


Let's code


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


Для начала напишем функцию runStepper и создадим StepperBuilder.


// index.tsexport function runStepper(  input: Schema,  context: BuilderContext): BuilderOutputLike {  return buildSteps(input, context).pipe(    map(() => ({      success: true    })),    catchError(error => {      return of({ error: error.toString(), success: false });    })  );}export const StepperBuilder = createBuilder(runStepper);export default StepperBuilder;

Обратите внимание, что тип первого аргумента функции runStepper это та самая Schema из спецификации конфигурации выше. Функция возвращает Observable<BuilderOutput> .


Дальше мы реализуем функцию buildSteps, которая будет отвечать за выполнение шагов


// index.tsfunction buildSteps(config: Schema, context: BuilderContext): Observable<any> {  return concat(    config.steps.map(step => buildStep(step, config.targets, context))  );}

Кажется ничего сложного. Каждый следующий шаг выполняется после завершения предыдущего.


Фактически у нас осталась одна неизвестная функция buildStep, которая будет запускать каждый отдельный шаг с его зависимостями:


// index.tsfunction buildStep(  stepName: string,  targets: Targets,  context: BuilderContext): Observable<any> {  const { deps = [], overrides, target, watch }: Target = targets[stepName];  const deps$ = deps.length    ? combineLatest(deps.map(depName => buildStep(depName, targets, context)))    : of(null);  return deps$.pipe(    concatMap(() => {      return scheduleTargetAndForget(context, targetFromTargetString(target), {        watch,        ...overrides      });    }),    watch ? tap(noop) : take(1)  );}

В этой функции есть несколько интересных моментов:


  1. Зависимости шага запускаются параллельно, а главная задача шага только после того, как каждая из зависимостей испустит хотя бы одно событие. Это дает нам гарантию, что dev-server (если задача на его запуск в списке зависимостей текущего шага) запуститься перед запуском тестов (если это главная задача шага).
  2. Функция scheduleTargetAndForget, импортируемая из @angular-devkit/architect. Эта функция позволяет запускать цели из angular.json и оверрайдить их настройки. Возвращает эта функция Observable, отписка от которого останавливает выполняемую задачу.
  3. Если параметр watch имеет положительное значение, то главная задача шага не завершится после первого испущенного события, следовательно текущая задача будет жить вечно, пока не завершится сама, либо пока не произойдет отписка от вернувшегося Observable, либо не будет завершен процесс.

Собственно это все, что касается самого билдера. Полный вариант можно посмотреть тут. Уложились в 56 строк кода. Неплохо, правда?


Последний интересующий и важный для нас момент это файл builders.json


{  "$schema": "../../@angular-devkit/architect/src/builders-schema.json",  "builders": {    "stepper": {      "implementation": "./stepper",      "schema": "./stepper-schema.json",      "description": "Stepper"    }  }}

Как вы видите, в этом файле указывается список билдеров с параметрами implementation (входная точка для импорта билдера), schema (схема валидации) и description (описание).


Затем ищем package.json и добавляем свойство builders с относительным путем до файла builders.json


{    "name": "@ng-builders/build",    "builders": "./builders.json",    }

Осталось только собрать пакет:


npm run build

Закидываем все в коммит и пушим всю эту красоту на Github.


И это все?


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


Билдер можно неспокойно (как только появятся тесты обязательно уберу не) использовать в своих проектах.


npm i @ng-builders/build -D

Итоги


CLI Builders API это мощный инструмент для расширения и кастомизации Angular CLI. Билдер, созданный нами, решает не самые популярные проблемы, но на создание всего пакета ушел всего лишь 1 час. Что нам это говорит? А то что создать кастомный билдер для решения частных проблем не такая уж и сложная задача. По этому дерзайте, фантазируйте и создавайте. И помните, что тесты сами себя не напишут!


P.S.:


Angular CLI Builders прекрасно используются и работают в NX Workspace даже без Angular. Пример этого чуда я обязательно покажу вам в будущем. А пока можете читать меня в Twitter, писать мне в Telegram и говорить обо мне только хорошее.

Подробнее..

Бюджетный DI на антипаттернах

03.07.2020 08:04:12 | Автор: admin

image


Согласитесь, приятно бывает после утомительного трудового дня отточенным движением руки решительно закрыть рабочий workspace в Xcode, чтобы, облегченно вздохнув, открыть другой workspace со своим домашним проектом.


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


Советую запастись дошираком и энергетиками. Перед вами серия статей о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня пилотный выпуск про управление зависимостями.


Введение


Перво-наперво хочется совершить виртуальный каминг-аут и признаться, что я большой фанат паттерна MVVM. Для меня он не ограничивается сплошной стрелочкой, направленной от View к ViewModel и пунктирной в обратном направлении. Правильно приготовленный MVVM, как мне кажется, это инфраструктура, если хотите фреймворк, включающий в себя, кроме реализации самого паттерна, решение для управления зависимостями, реализацию роутинга и ряд вспомогательных компонентов для упрощения жизни, здоровья и долголетия.


Именно этим и занимались первые MVVM-фреймворки, с которыми я работал в незапамятные времена, когда мобильных платформ было больше двух. Именно этим я планирую заниматься ближайшие три статьи. А начнем мы с управления зависимостями, потому что это фундамент, на котором держится весь увлекательный мир вашего iOS-приложения.


Однажды я прочитал где-то, что в любой качественной статье должно быть хорошее содержание. Вот и в этой статье пусть будет хоть что-нибудь от качественной.


Хорошее содержание



Принципы


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


  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Иметь такой список принципов очень удобно: это оправдывает все странные решения, которые я собираюсь принимать на протяжении трех статей.


Проблема управления зависимостями


Проблема управления зависимостями довольно типичная в программировании. Мало какая сущность в коде может похвастаться независимостью как твоя бывшая. Обычно все от кого-нибудь зависят. В MVVM, например, вью-контроллер зависит от вью-модели, которая подготавливает для него данные. Вью-модель зависит от сервиса, который за этими данными ходит в сеть. Сервис зависит от другого сервиса низкоуровневой реализации сети, и так далее. Все эти сущности, которых может быть великое множество, нужно где-то создавать и как-то доставлять до потребителей. Для любой типичной проблемы, как правило, есть типичное решение паттерн. В случае с проблемой управления зависимостями таким паттерном является Dependency Injection (DI) контейнер.


У меня нет намерения подробно объяснять, что такое DI-контейнер. Про это классно рассказывают в двух статьях из репозитория Ninject: раз, два (уберите от экрана детей, там код на С#). Еще есть небольшое объяснение в репозитории самого популярного DI-контейнера под iOS Swinject (заметили, что Swinject это Ninject на Swift?). Хардкорщикам могу предложить статью Фаулера от 2004 года.


Тем не менее не могу отказать себе в удовольствии немного поумничать и скажу, что DI-контейнер это такая шляпа, из которой как кролика за уши можно достать практически любую сущность вашей программы. Если эта сущность зависит от других сущностей, а те, в свою очередь, еще от каких-то DI-контейнер знает, что со всем этим графом зависимостей делать. Если у вас в проекте есть DI-контейнер, то на извечный вопрос как мне прокинуть зависимость A до сущности B всегда будет один и тот же ответ: сущность B следует достать из контейнера, который сам рекурсивно разрешит все ее зависимости.


Решение


Существует несколько довольно популярных реализаций DI-контейнеров под iOS (Swinject, Cleanse, Dip, DITranquility, EasyDI), но использовать чужую реализацию, согласитесь, скучно. Гораздо веселее использовать мою.


Готовы немного развлечься и написать DI-контейнер с нуля? Похожую реализацию мне показал однажды один из самых крутых iOS-разработчиков, простой сибирский парень teanet, за что ему огромное спасибо. Я ее немного переосмыслил и готов поделиться с вами. Начнем с протокола IContainer:


protocol IContainer: AnyObject {    func resolve<T: IResolvable>(args: T.Arguments) -> T}

Привычка из прошлой жизни я всегда пишу I перед протоколами. Буква I значит interface. У нашего интерфейса протокола всего один метод resolve(args:), который от нас принимает какие-то аргументы T.Arguments, а взамен возвращает экземпляр типа T. Как видно, не любая сущность может быть Т. Чтобы стать полноправным T, нужно реализовать IResolvable. IResolvable это еще один протокол, о чем нам услужливо подсказывает буква I в начале имени. Он выглядит вот так:


protocol IResolvable: AnyObject {    associatedtype Arguments    static var instanceScope: InstanceScope { get }    init(container: IContainer, args: Arguments)}

Все кролики, которые хотят быть доступны из шляпы, обязаны реализовать IResolvable.


Самое главное здесь инициализатор, принимающий сам контейнер и аргументы. Предполагается, что каждая сущность, реализуя этот инициализатор, сама достанет прямо из контейнера нужные зависимости. А если этому кролику для полноценного существования нужны еще какие-то аргументы, то пожалуйста они тоже есть в инициализаторе.


Свойство instanceScope отвечает за область видимости, в которой будет существовать экземпляр объекта:


enum InstanceScope {    case perRequst    case singleton}

Это довольно стандартная для DI-контейнеров штуковина. Значение perRequest означает, что для каждого вызова resolve(args:) будет создан новый экземпляр T. Значение singleton означает, что экземпляр T будет создан единожды при первом вызове resolve(args:). При последующих вызовах resolve(args:) в случае singleton будет отдаваться закэшированная копия.


С протоколами разобрались, приступаем к реализации:


class Container {    private var singletons: [ObjectIdentifier: AnyObject] = [:]    func makeInstance<T: IResolvable>(args: T.Arguments) -> T {        return T(container: self, args: args)    }}

Тут ничего особенного: кэш синглтонов будем хранить в виде словаря singletons. Ключом словаря нам послужит ObjectIdentifier это стандартный тип, поддерживающий Hashable и представляющий собой уникальный идентификатор объекта ссылочного типа (через него, кстати, реализован оператор === в Swift). Метод makeInstance(args:) умеет на лету создавать любые экземпляры T благодаря тому, что мы обязали все T реализовать один и тот же инициализатор.


В Swift принято выносить реализации протоколов в отдельный экстеншен. Не будем выделяться и поступим как принято покажем старику Латтнеру, что мы говорим на его родном языке без акцента. Нам нужно всего лишь реализовать один метод:


extension Container: IContainer {    func resolve<T: IResolvable>(args: T.Arguments) -> T {        switch T.instanceScope {        case .perRequst:            return makeInstance(args: args)        case .singleton:            let key = ObjectIdentifier(T.self)            if let cached = singletons[key], let instance = cached as? T {                return instance            } else {                let instance: T = makeInstance(args: args)                singletons[key] = instance                return instance            }        }    }}

Здесь все довольно прозаично: если T хочет быть perRequest, сразу возвращаем новый экземпляр. В противном случае нужно залезть в кэш. Что в кэше найдем достаем, отдаем, чего в кэше не найдем создаем, в кэш кладем, отдаем.


Вот, собственно, и все. Мы только что написали свой DI-контейнер в 50 строк кода. Но как этой штукой вообще пользоваться? Да очень просто.


Пример использования


Для примера рассмотрим хрестоматийную историю с клиентами и их заказами. Пусть мы хотим отобразить список заказов конкретного клиента на определенную дату. Заведем для наших целей две сущности: OrdersProvider и OrdersVM эти ребята должны быть доступны из контейнера, а значит, им придется реализовать IResolvable.


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


Полезный экстеншен номер раз:


protocol ISingleton: IResolvable where Arguments == Void { }extension ISingleton {    static var instanceScope: InstanceScope {        return .singleton    }}

И второй такой же, но другой:


protocol IPerRequest: IResolvable { }extension IPerRequest {    static var instanceScope: InstanceScope {        return .perRequst    }}

Теперь вместо IResolvable можно конформить более лаконичным ISingleton/IPerRequest и сэкономить тем самым несколько секунд жизни, потратив их на саморазвитие. А вот и реализация OrdersProvider подъехала:


class OrdersProvider: ISingleton {    required init(container: IContainer, args: Void) { }    func loadOrders(for customerId: Int, date: Date) {        print("Loading orders for customer '\(customerId)', date '\(date)'")    }}

Мы предоставили required init, как того требует протокол, но, так как OrdersProvider ни от чего не зависит, этот инициализатор у нас пустой. Каждый раз, когда мы будем доставать OrdersProvider из контейнера, мы будем получать один и тот же экземпляр, потому что такова дефолтная реализация instanceScope для ISingleton.


А вот и модель представления собственной персоной:


final class OrdersVM: IPerRequest {    struct Args {        let customerId: Int        let date: Date    }    private let ordersProvider: OrdersProvider    private let args: Args    required init(container: IContainer, args: Args) {        self.ordersProvider = container.resolve()        self.args = args    }    func loadOrders() {        ordersProvider.loadOrders(for: args.customerId, date: args.date)    }}

Эта вью-модель не может существовать без аргументов OrdersVM.Args, которые мы получаем через required init. В этот инициализатор также попадает сам контейнер, из которого мы без лишней суеты извлекаем экземпляр OrdersProvider посредством вызова resolve().


Вызов метода loadOrders() использует ordersProvider для загрузки заказов, предоставляя ему необходимые для работы аргументы. Каждый раз, когда мы будем доставать OrdersVM из контейнера, мы будем получать новый экземпляр, потому что такова дефолтная реализация instanceScope для IPerRequest.


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


let container = Container()let viewModel: OrdersVM = container.resolve(args: .init(customerId: 42, date: Date()))viewModel.loadOrders()

Для справки, на момент написания этих строк, код выше производит следующий консольный вывод:


Loading orders for customer '42', date '2020-04-22 17:41:49 +0000'

Критика


Использовать или не использовать DI-контейнер из этой статьи в вашем проекте решать не мне. Как ответственный автор, я могу всего лишь предлагать варианты и рассказывать о плюсах и минусах одинаково объективно.


Пытливый ум усидчивого читателя заметит, что реализация, представленная выше, если честно, не очень похожа на DI-контейнер. Эта реализация больше похожа на Service Locator, который, откровенно говоря, в приличном обществе принято считать не иначе как антипаттерном.


Не хочется в рамках данной статьи углубляться в тонкости отличия DI-контейнера от локатора служб, про это можно почитать у того же Фаулера. Но если грубо, то при использовании DI-контейнера ваша сущность, скорее всего, принимает в конструктор инициализатор некий набор зависимостей, закрытых интерфейсами протоколами. Примерно так:


final class OrdersVM {    private let ordersProvider: IOrdersProvider    init(ordersProvider: IOrdersProvider) {       self.ordersProvider = ordersProvider    }}

Если вы осмелились использовать Service Locator, тогда ваша сущность, вероятно, достает зависимости из какого-нибудь сомнительного места типа статической фабрики. Например, вот так:


final class OrdersVM {    private let ordersProvider: IOrdersProvider    init() {        self.ordersProvider = ServiceLocator.shared.resolve()    }}

Программисты недолюбливают Service Locator в первую очередь за то, что он скрывает истинные зависимости сущностей при их создании.


С практической точки зрения это означает, что нет никакой возможности понять, что OrdersVM зависит от IOrdersProvider для этого нужно читать код инициализатора. Кроме того, OrdersVM напрямую зависит от ServiceLocator, что затрудняет переиспользование этого класса в системах, где для DI может быть выбрано другое решение.


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


Все наши сущности, если приглядеться, вообще ни капельки не зависят от абстракций. Напротив, они сами решают, какую конкретную реализацию своих зависимостей следует использовать. Например, OrdersVM достает из контейнера совершенно конкретный OrdersProvider, а не какой-нибудь протокол IOrdersProvider.


С практической точки зрения это затрудняет подмену одной реализации IOrdersProvider на другую реализацию этого протокола. Между тем, такая подмена может вам пригодиться не только в разработке, но и в рамках рефакторинга, а также при написании юнит-тестов.


Полноценные DI-контейнеры, конечно, лишены всех этих недостатков. Более того, они предлагают нам массу дополнительных возможностей. Лишаясь всего этого, что же вы получаете взамен? Взамен вы получаете простую, легкую, надежную и предсказуемую как выборы президента реализацию, которая либо работает корректно, либо не компилируется.


Например, при резолве из контейнера вы всегда получаете готовую сущность, а не какой-нибудь опциональный тип. При резолве из контейнера не нужно обрабатывать исключения, потому что исключения исключены, они никогда не происходят.


Добавление новых зависимостей в существующий класс выглядит максимально лаконично и сводится к извлечению их из контейнера. Если вы пытаетесь извлечь из контейнера что-то неизвлекаемое, ваш код не скомпилируется. При резолве с аргументами невозможно передать аргументы неверного типа либо забыть передать аргументы: некорректный код не скомпилируется.


И, наконец, очень сложно забыть зарегистрировать какую-то сущность, потому что в контейнере не нужно ничего регистрировать. Многое из перечисленного для большинства промышленных DI-контейнеров, к сожалению, недоступно.


Вот вам смысл этого многословного раздела в виде двух списков.


Короче, минусы


  • Зависимости достаем в конструкторе прямо из контейнера (Service Locator).
  • Не получится закрыть зависимость протоколом (принцип на букву D).

Короче, плюсы


  • Простая и лаконичная реализация (50 строк кода).
  • Не надо регистрировать зависимости (вообще не надо).
  • Извлечение из контейнера никогда не сломается (совсем никогда).
  • Нельзя передать невалидные аргументы (не скомпилируется).

Если упомянутые минусы для вашего проекта не критичны, а плюсы вызывают неконтролируемый выброс дофамина добро пожаловать на борт, нам, скорее всего, по пути.


One More Thing: автоматическое внедрение зависимостей через обертки свойств


В 2019 году в компании Apple придумали инкапсулировать повторяющуюся логику гетеров и сетеров в переиспользуемые атрибуты и назвали это обертками свойств (property wrappers). С помощью таких оберток ваши свойства волшебным образом могут получить новое поведение: запись значения в Keychain или UserDefaults, потокобезопасность, валидацию, логирование да много чего.


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


Чтобы написать свою обертку свойства в минимальной комплектации, нужно создать класс или структуру, предоставить свойство wrappedValue и пометить все это дело атрибутом @propertyWrapper:


@propertyWrapperstruct Resolvable<T: IResolvable> where T.Arguments == Void {    private var cache: T?    var wrappedValue: T {        mutating get {            if let cache = cache {                return cache            }            let resolved: T = ContainerHolder.container.resolve()            cache = resolved            return resolved        }    }}

Из этого незамысловатого кода мы видим, что наш property wrapper называется Resolvable. Он работает со всеми типами Т, которые реализуют одноименный протокол и не требуют аргументов при инициализации.


Волшебство происходит при обращении к свойству wrappedValue: мы возвращаем закэшированное значение, если таковое имеется. Если нет достаем это значение из контейнера и сохраняем в кэш. Чтобы наша обертка получила доступ к контейнеру, пришлось провернуть грязный трюк поместить контейнер в статическое свойство класса ContainerHolder:


final class ContainerHolder {    static var container: IContainer!}

Имея в своем арсенале обертку Resolvable<T>, мы можем применить ее к какой-нибудь зависимости, например к ordersProvider:


@Resolvableprivate var ordersProvider: OrdersProvider

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


private var _ordersProvider = Resolvable<OrdersProvider>()var ordersProvider: OrdersProvider {  get { return _ordersProvider.wrappedValue }}

Видно, что компилятор сгенерировал свойство ordersProvider, при обращении к которому на самом деле используется обертка, которая умеет доставать из контейнера нужную зависимость.


Теперь знакомая нам модель представления может позволить себе не извлекать из контейнера OrdersProvider в инициализаторе, а просто пометить соответствующее свойство атрибутом @Resolvable. Вот так:


final class OrdersVM: IPerRequest {    struct Args {        let customerId: Int        let date: Date    }    @Resolvable    private var ordersProvider: OrdersProvider    private let args: Args    required init(container: IContainer, args: Args) {        self.args = args    }    func loadOrders() {        ordersProvider.loadOrders(for: args.customerId, date: args.date)    }}

Самое время собрать все вместе и порадоваться, что все работает как прежде:


ContainerHolder.container = Container()let viewModel: OrdersVM = ContainerHolder.container.resolve(    args: .init(customerId: 42, date: Date()))viewModel.loadOrders()

Для справки. Этот код производит следующий консольный вывод:


Loading orders for customer '42', date '2020-04-23 18:47:36 +0000'



Unit-тесты, раздел под звездочкой


Думаю, все согласятся, что сложно переоценить важность автоматических тестов в современной разработке. Искренне надеюсь, что вы на постоянной основе используете как минимум unit-тесты и в своих ежедневных рабочих задачах, и в домашних проектах. Лично я нет. Может быть, по этой причине DI-контейнер из этой статьи не очень хорошо подходит для интеграции с unit-тестами. Однако, если вы, будучи в здравом уме и твердой памяти, решили пойти тернистым путем автоматизации, у меня есть для вас пара вариантов.


Чтобы немного разогреться, начнем с первого варианта, который попроще. Допустим, для нужд тестирования мы хотим замокать OrdersProvider, чтобы он не лез в сеть, а отдавал тестовые данные. Для начала закроем его протоколом:


protocol IOrdersProvider {    func loadOrders(for customerId: Int, date: Date)}extension OrdersProvider: IOrdersProvider {}

Теперь во вью-модели можем сделать второй инициализатор, который будет принимать этот протокол:


final class OrdersVM: IPerRequest {    struct Args {        let customerId: Int        let date: Date    }    private let ordersProvider: IOrdersProvider    private let args: Args    required convenience init(container: IContainer, args: Args) {        self.init(            ordersProvider: container.resolve() as OrdersProvider,            args: args)    }    init(ordersProvider: IOrdersProvider, args: Args) {        self.args = args        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders(for: args.customerId, date: args.date)    }}

Такой подход позволяет в реальном приложении создавать сущности через контейнер, используя required init, а в тестах пользоваться вторым инициализатором и создавать сущности с замоканными зависимостями.


Однако граф зависимостей может быть довольно запутан, и чаще всего удобно продолжить использование контейнера даже в тестах, но при этом научиться подменять некоторые зависимости на моковые. Это плавно подводит нас ко второму, более интересному варианту.


Забегая вперед, скажу, что далее от нас потребуется хранить объекты IResolvable в некоторой коллекции. Однако если мы попробуем сделать это, то столкнемся с суровой действительностью в виде ошибки, до боли знакомой каждому iOS-разработчику: protocol 'IResolvable' can only be used as a generic constraint because it has Self or associated type requirements. Типичный способ как-то справиться с этой ситуацией налить себе чего-нибудь покрепче и применить механизм с пугающим названием стирание типов (type erasure).


Стирание типов предполагает, что вместо неполноценного протокола IResolvable мы будем использовать самую обычную структуру AnyResolvable, не обремененную ассоциированным типом. Такую структуру можно спокойно хранить в массиве, но она, конечно, лишена полезной функциональности IResolvable. Последнее мы компенсируем с помощью замыкания. Внимание на код:


struct AnyResolvable {    private let factory: (IContainer, Any) -> Any?    init<T: IResolvable>(resolvable: T.Type) {        self.factory = { container, args in            guard let args = args as? T.Arguments else { return nil }            return T(container: container, args: args)        }    }    func resolve(container: IContainer, args: Any) -> Any? {        return factory(container, args)    }}

Кода здесь немного, но он хитрый. В инициализатор мы принимаем настоящий живой тип T, который не можем никуда сохранить. Вместо этого мы сохраняем замыкание, обученное создавать экземпляры этого типа. Замыкание впоследствии используется по своему прямому назначению в методе resolve(container:args:), который понадобится нам позже.


Вооружившись AnyResolvable, мы можем написать контейнер для unit-тестов в 20 строк, который позволит нам выборочно мокать часть зависимостей. Вот он:


final class ContainerMock: Container {    private var substitutions: [ObjectIdentifier: AnyResolvable] = [:]    public func replace<Type: IResolvable, SubstitutionType: IResolvable>(        _ type: Type.Type, with substitution: SubstitutionType.Type) {        let key = ObjectIdentifier(type)        substitutions[key] = AnyResolvable(resolvable: substitution)    }    override func makeInstance<T: IResolvable>(args: T.Arguments) -> T {        return makeSubstitution(args: args) ?? super.makeInstance(args: args)    }    private func makeSubstitution<T: IResolvable>(args: T.Arguments) -> T? {        let key = ObjectIdentifier(T.self)        let substitution = substitutions[key]        let instance = substitution?.resolve(container: self, args: args)        return instance as? T    }}

Давайте разбираться.


Класс ContainerMock наследуется от обычного Container, переопределяя метод makeInstance(args:), используемый контейнером для создания сущностей. Новая реализация пытается создать подставную зависимость вместо настоящей. Если ей это не удается, она печально разводит руками и фолбечится на реализацию базового класса.


Метод replace(_:with:) позволяет сконфигурировать моковый контейнер, указав тип зависимости и соответствующий ей тип мока. Эта информация хранится в словаре substitutions, который использует уже знакомый нам ObjectIdentifier для ключа и AnyResolvable для хранения типа мока.


Для создания моков используется метод makeInstance(args:), который по ключу пытается достать нужный AnyResolvable из словаря substitutions и создать соответствующий экземпляр с помощью метода resolve(container:args:).


Использовать все это дело мы будем следующим образом. Создаем моковый OrdersProvider, переопределяя метод loadOrders(for:date:):


final class OrdersProviderMock: OrdersProvider {    override func loadOrders(for customerId: Int, date: Date) {        print("Loading mock orders for customer '\(customerId)', date '\(date)'")    }}

Создаем моковый контейнер и конфигурируем его. Вью-модель достаем из контейнера обычным образом. Контейнер создает экземпляр вью-модели, разрешая все ее зависимости с учетом подстановок:


let container = ContainerMock()container.replace(OrdersProvider.self, with: OrdersProviderMock.self)let viewModel: OrdersVM = container.resolve(args: .init(customerId: 42, date: Date()))viewModel.loadOrders()

Для справки, этот код производит следующий консольный вывод:


Loading mock orders for customer '42', date '2020-04-24 17:47:40 +0000'

Заключение


Сегодня мы вероломно поступились принципом инверсии зависимостей и в очередной раз изобрели велосипед, реализовав бюджетный DI с помощью анти-паттерна Service Locator. Попутно мы познакомились с парой полезных техник iOS-разработки, таких как type erasure и property wrappers, и не забыли про unit-тесты.


Автор не рекомендует использовать код из этой статьи в приложении для управления ядерным реактором, но если у вас небольшой проект и вы не боитесь экспериментировать свайп вправо, its a match <3




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Доступный MVVM на хакнутых экстеншенах

10.07.2020 08:14:43 | Автор: admin


Много лет подряд я, помимо всего прочего, занимался настройкой MVVM в своих рабочих и не очень рабочих проектах. Я увлеченно делал это в Windows-проектах, где паттерн является родным. С энтузиазмом, достойным лучшего применения, я делал это в iOS-проектах, где MVVM просто так не приживается.


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


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


Введение


Как я уже говорил в прошлой статье, MVVM успешного программиста должен включать в себя, помимо реализации самого паттерна, решение для управления зависимостями и реализацию роутинга. Про управление зависимостями мы уже поговорили и сейчас перейдем непосредственно к реализации MVVM. В яблочном мире паттерн, придуманный в Microsoft, чувствует себя немного иностранцем, и его реализация потребует от нас дополнительных усилий. Эти усилия мы будем прикладывать к конкретному приложению, на примере которого рассмотрим все тонкости и подводные камни. Так как с идеями у меня традиционно не очень, это приложение будет состоять из одного-единственного экрана, отображающего список заказов.


Напомню также, что в коде я стараюсь с переменным успехом придерживаться нескольких простых правил, о которых уже рассказывал:


Нескольких простых правил, о которых уже рассказывал
  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Для тех, кто не приемлет неожиданностей, вот полное содержание статьи.


Полное содержание



Действующие лица


Я не планирую расшифровывать каждую букву аббревиатуры MVVM и объяснять, как работает паттерн и зачем он вообще нужен. Уверен, все это вы и без меня знаете. Если по какой-то причине MVVM для вас в новинку, советую перестать читать эту статью и поскорее заполнить пробел в знаниях. Невероятно скучная, но познавательная статья из Википедии может послужить неплохим стартом.


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


OrdersVC Вью-контроллер экрана заказов. Без него никак, потому что iOS это вью-контроллеры. Является источником событий жизненного цикла экрана и занимается отображением данных, которые приходят из вью-модели. В нашем случае он будет содержать таблицу для отображения списка заказов
OrdersView Вьюха для контроллера OrdersVC. Хорошая практика для каждого VC заводить свою собственную View отдельным классом, но в этой статье для упрощения мы так делать не будем. Поэтому OrdersView это такая вьюха, которой нет, но нужно помнить, что она очень даже может быть
OrdersVM Модель представления для OrdersVC, а также для его вьюхи, если бы она у него была. С помощью OrdersProvider вью-модель получает заказы и преобразует их в пригодный для отображения вид
Order Ничего особенного, типичная модель, каких много. Представляет собой заказ
OrderCell Ячейка UITableView, отображающая заказ
OrderVM Модель представления для ячейки OrderCell. Это тот же Order, но пригодный для отображения
OrdersProvider Сервис, который будет загружать заказы из базы данных, из файла, с бэкэнда неважно откуда. Для нашего обучающего примера мы будем грузить заказы из бездонной пустоты небытия

Вот так все эти ребята уживаются вместе на диаграмме классов.






Стоит отметить, что в мире MVVM нет такого понятия, как контроллер, в то время как в iOS, где безраздельно властвует MVC, без вью-контроллеров никуда. Чтобы разрешить это противоречие, здесь и далее мы будем считать, что контроллер это просто View, тем более что в iOS эти две сущности традиционно очень тесно связаны.


Запомните: все, что я говорю в этой статье о View, можно в равной степени отнести к контроллеру, и наоборот.


Начиная отсюда и до конца статьи мы будем заниматься реализацией этой картинки в коде.


Знакомим представление с его моделью


Сплошная стрелочка, направленная от View к ViewModel, символизирует их абьюзивные отношения: вьюха владеет вью-моделью, держит ее сильной ссылкой и напрямую вызывает ее методы. Узаконим эти отношения с помощью протокола. Может существовать сколько угодно реализаций MVVM, но одна штука в них будет неизменной: у View должно появиться свойство viewModel:


protocol IHaveViewModel: AnyObject {    associatedtype ViewModel    var viewModel: ViewModel? { get set }    func viewModelChanged(_ viewModel: ViewModel)}

Буква I в начале имени протокола означает interface. В предсказуемом мире статической типизации у разных вьюх экземпляры вью-моделей, скорее всего, будут принадлежать разным классам. Чтобы выразить это средствами языка, нам пригодился протокол с дженериком ассоциированным типом.


Заметим, что свойство viewModel доступно для записи извне. В какой-то момент оно обязательно изменится, что неизбежно приведет к вызову метода viewModelChanged(_:), в котором вьюха обязуется проделать работу по синхронизации своего состояния в соответствии со своей моделью представления. Нехитрая реализация протокола IHaveViewModel на примере связки OrderCell OrderVM могла бы выглядеть вот так:


final class OrderCell: UITableViewCell, IHaveViewModel {    var viewModel: OrderVM? {        didSet {            guard let viewModel = viewModel else { return }            viewModelChanged(viewModel)        }    }    func viewModelChanged(_ viewModel: OrderVM) {        textLabel?.text = viewModel.name    }}

Замечу, что для простоты мы взяли ячейку стандартного лайаута, поэтому пользуемся свойством textLabel для отображения текста. Модель представления заказа нам не очень интересна, поэтому сделаем ее максимально неумной:


final class OrderVM {    let order: Order    var name: String {        return "\(order.name) #\(order.id)"    }    init(order: Order) {        self.order = order    }}

Нетерпеливый читатель может задаться вопросом, кто же проставляет свойство viewModel у ячейки и, что более важно, как именно это происходит. На самом деле типичная OrderCell не очень отличается от своих собратьев, и ее жизненный цикл протекает довольно обычным образом:


  1. В методе делегата таблицы tableView(_:cellForRowAt:) извлекаем ячейку при помощи вызова dequeueReusableCell(withIdentifier:for:) и получаем экземпляр класса UITableViewCell.
  2. Осуществляем приведение типа к протоколу IHaveViewModel, чтобы получить доступ к свойству viewModel и записать туда вью-модель.
  3. Грустим оттого, что код, который мы написали на шаге 2, не компилируется.
  4. Гуглим ошибку Protocol 'IHaveViewModel' can only be used as a generic constraint because it has Self or associated type requirements.

Чтобы справиться с такой ошибкой, нам придется применить специальную технику с загадочным названием стирание типов (type erasure). Некоторые авторы выделяют несколько разновидностей стирания типов. Для нашего случая подходит вариант, похожий на секретную технику ниндзя теневое стирание типов (shadow type erasure). Кто придумывает эти названия? На практике весь пафос сводится к тому, что надо просто завести еще один протокол:


protocol IHaveAnyViewModel: AnyObject {    var anyViewModel: Any? { get set }}

Этот протокол не обременен ассоциированным типом, поэтому к нему можно будет кастить любые объекты. Протокол IHaveViewModel почти не изменился, найдите одно отличие:


protocol IHaveViewModel: IHaveAnyViewModel {    associatedtype ViewModel    var viewModel: ViewModel? { get set }    func viewModelChanged(_ viewModel: ViewModel)}

Реализация OrderCell теперь будет выглядеть так:


final class OrderCell: UITableViewCell, IHaveViewModel {    typealias ViewModel = OrderVM    var anyViewModel: Any? {        didSet {            guard let viewModel = anyViewModel as? ViewModel else { return }            viewModelChanged(viewModel)        }    }    var viewModel: ViewModel? {        get {            return anyViewModel as? ViewModel        }        set {            anyViewModel = newValue        }    }    func viewModelChanged(_ viewModel: ViewModel) {        textLabel?.text = viewModel.name    }}

Свойство anyViewModel, лишенное информации о типе, удобно использовать снаружи класса. Оно позволяет любую вьюху привести к типу IHaveAnyViewModel и задать ей вью-модель. Свойство viewModel, которое содержит типизированную вью-модель, удобно использовать внутри класса, например для того, чтобы в методе viewModelChanged(_:) обновлять состояние вьюхи.


Удивительно, насколько отвратительную реализацию MVVM мы с вами только что написали: пользоваться ею решительно невозможно. Все потому, что в каждой вьюхе и вью-контроллере мы вынуждены будем писать очень много повторяющегося кода для реализации IHaveViewModel, что, вообще говоря, довольно утомительно и ни капельки не весело. Хорошая новость в том, что этот недостаток можно преодолеть с помощью расширения, обеспечивающего реализацию по умолчанию для протокола IHaveViewModel.


Реализация по умолчанию через расширение протокола


В основе концепции расширений (extensions) лежит очень простая идея: расширения не могут добавлять никаких новых данных к типу. Все свойства и методы, предоставляемые расширением, являются не чем иным, как комбинацией данных, которые уже объявлены в типе.


Таким образом, если мы попробуем написать реализацию по умолчанию для IHaveViewModel, то ожидаемо столкнемся с неизбежными сложностями в виде ошибки extensions must not contain stored properties:


extension IHaveViewModel {    var anyViewModel: Any? // Не компилируется :(}

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


Представьте, в какую анархию погрузилось бы программирование, если бы все могли вот так запросто добавлять новые данные к любым типам. Возможно, с помощью ошибки extensions must not contain stored properties создатели языка нежно заботятся о нас, не позволяя пойти по скользкой дорожке, покатиться под откос, ринуться в бурлящую бездну хаоса. Вопреки их стараниям, именно этим мы сейчас и займемся, предварительно хакнув свифтовые расширения с помощью старого доброго Objective-C-рантайма. Читай дальше, если не боишься, что полиция экстеншенов придет за тобой:


private var viewModelKey: UInt8 = 0extension IHaveViewModel {    var anyViewModel: Any? {        get {            return objc_getAssociatedObject(self, &viewModelKey)        }        set {            let viewModel = newValue as? ViewModel            objc_setAssociatedObject(self,                 &viewModelKey,                 viewModel,                 .OBJC_ASSOCIATION_RETAIN_NONATOMIC)            if let viewModel = viewModel {                viewModelChanged(viewModel)            }    }    var viewModel: ViewModel? {        get {            return anyViewModel as? ViewModel        }        set {            anyViewModel = newValue        }    }    func viewModelChanged(_ viewModel: ViewModel) {    }}

Это код компилируется, потому что в расширении мы не объявили ни одного нового хранимого свойства. Необходимого поведения удалось достичь с помощью двух функций языка Си: objc_getAssociatedObject и objc_setAssociatedObject, которые мы используем в гетере и сетере соответственно.


Эти функции с успехом заменяют хранимые свойства, так как позволяют сопоставить объекту некоторое значение по некоторому ключу. Обычно в качестве такого ключа используют адрес глобальной переменной, такой как viewModelKey. Благодаря расширению реализация OrderCell избавилась от лишнего кода и выглядит теперь гораздо привлекательнее:


final class OrderCell: UITableViewCell, IHaveViewModel {    typealias ViewModel = OrderVM    func viewModelChanged(_ viewModel: OrderVM) {        textLabel?.text = viewModel.name    }}

Повторюсь, что способ, показанный выше, серьезно нарушает концепцию экстеншенов. Постарайтесь не подсесть на иглу Objective-C-рантайма и не начать использовать его по поводу и без повода это может существенно запутать ваш и без того неидеальный код. В нашем случае некоторое нарушение правил позволяет писать гораздо меньше кода и поэтому слишком привлекательно, чтобы им не воспользоваться.


Отображение списка заказов (на самом деле нет)


Вооружившись дефолтной реализацией IHaveViewModel можно быстро накидать код связки OrdersVC OrdersVM. Вью-модель выглядит так:


final class OrdersVM {    var orders: [OrderVM] = []    private var ordersProvider: OrdersProvider    init(ordersProvider: OrdersProvider) {        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }        }    }}

OrdersVM использует OrdersProvider для загрузки отзывов. OrdersProvider с умным видом имитирует асинхронный запрос и отвечает списком отзывов через секунду после вызова loadOrders(completion:):


struct Order {    let name: String    let id: Int}final class OrdersProvider {    func loadOrders(completion: @escaping ([Order]) -> Void) {        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {            completion((0...99).map { Order(name: "Order", id: $0) })        }    }}

И, наконец, вью-контроллер:


final class OrdersVC: UIViewController, IHaveViewModel {    typealias ViewModel = OrdersVM    private lazy var tableView = UITableView()    override func viewDidLoad() {        super.viewDidLoad()        tableView.dataSource = self        tableView.register(OrderCell.self, forCellReuseIdentifier: "order")        view.addSubview(tableView)        viewModel?.loadOrders()    }    override func viewDidLayoutSubviews() {        super.viewDidLayoutSubviews()        tableView.frame = view.bounds    }    func viewModelChanged(_ viewModel: OrdersVM) {        tableView.reloadData()    }}

В методе viewDidLoad() посредством вызова loadOrders() мы сообщаем вью-модели, что нам хотелось бы начать загрузку заказов. На изменение вью-модели мы реагируем в методе viewModelChanged(_:), перезагружая таблицу. Работу с источником данных для таблицы мы вынесли в отдельный экстеншен:


extension OrdersVC: UITableViewDataSource {    func tableView(_ tableView: UITableView,         numberOfRowsInSection section: Int) -> Int {        return viewModel?.orders.count ?? 0    }    func tableView(_ tableView: UITableView,         cellForRowAt indexPath: IndexPath) -> UITableViewCell {        let cell = tableView.dequeueReusableCell(withIdentifier: "order",             for: indexPath)        if let cell = cell as? IHaveAnyViewModel {            cell.anyViewModel = viewModel?.orders[indexPath.row]        }        return cell    }}

Здесь все совершенно стандартно, за исключением приведения к протоколу IHaveAnyViewModel, которое жизненно необходимо для того, чтобы сконфигурировать вью-модель ячейки. Вот и все. Теперь мы можем собрать всех ребят вместе, примерно так:


let viewModel = OrdersVM(ordersProvider: OrdersProvider())let viewController = OrdersVC()viewController.viewModel = viewModel

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


Дело в том, что метод loadOrders(completion:) работает асинхронно, список заказов формируется только через секунду после вызова viewDidLoad(), а это значит, что на момент вызова reloadData() массив orders пуст. Для того чтобы все заработало, нам не хватает одной важной детали уведомления об изменениях вью-модели.


Уведомление об изменении модели представления


Одна из ключевых концепций MVVM состоит в том, что ViewModel ничего не желает знать о View. Она не держит ссылку на View и не вызывает ее методы ни напрямую, ни через протокол. Вью-модель ведет себя так, словно View просто-напросто не существует. Компенсируя свое нежелание общаться с View, вью-модель поддерживает механизм уведомления о важных событиях, происходящих в ее жизни. Этим механизмом пользуется View, чтобы поддерживать себя в актуальном виде, и на диаграмме классов это выражается пунктирной стрелкой, направленной от ViewModel к View.


В самобытном мире iOS-разработки сложилась невеселая ситуация: уведомления об изменении свойств модели представления чаще всего реализуют через реактивные сигналы. Этот подход настолько распространен, что некоторые авторы едва ли не ставят знак равенства между MVVM и Rx. Между тем MVVM вовсе не подразумевает использование стороннего реактивного фрэймворка. В том же .NET исторической родине паттерна уведомления работают через интерфейс INotifyPropertyChanged, реализуемый на стороне ViewModel, в связке с декларативными биндингами на стороне View.


Автор этой статьи, мягко говоря, не фанат реактивного подхода. Очень уж непросто бывает разобраться в хитросплетении сигналов, которые стреляют другими сигналами, которые трансформируются в третьи сигналы. Написать запутанный реактивный код слишком просто. Сегодня вы добавляете в проект один маленький сигнальчик, а завтра ваше простое на первый взгляд приложение превращается в неуправляемый реактивный истребитель, несущийся на сверхзвуковой скорости в бездну отчаяния. Да и не хочется в мелкий домашний проект целый RxSwift тащить, а Combine так вообще только с iOS 13.


В общем, мы с вами, как обычно, пойдем скользкой, но интересной дорожкой и напишем под iOS нечто похожее на события из .NET. Наше творение обеспечит нам вечную славу и поддержку уведомлений об изменениях ViewModel.


Заново изобретаем события


События в .NET это реализация известного паттерна Наблюдатель, такой сталкинг от программирования: вьюха очень пристально следит за тем, что происходит c вью-моделью. Для нас критически важно, чтобы событие поддерживало несколько подписчиков, потому что, например, на одно и то же событие вью-модели может подписаться как ViewController, так и его View.


Реализовать такое на Swift можно несколькими способами: через массив делегатов, через массив замыканий, через NotificationCenter. Тот способ, который поджидает читателя ниже по тексту, вынуждает нас для начала написать небольшой вспомогательный класс. Вот такой:


final class Weak<T: AnyObject> {    private let id: ObjectIdentifier?    private(set) weak var value: T?    var isAlive: Bool {        return value != nil    }    init(_ value: T?) {        self.value = value        if let value = value {            id = ObjectIdentifier(value)        } else {            id = nil        }    }}

Это нехитрая обертка, которая держит слабую ссылку на экземпляр ссылочного типа, передаваемый в инициализатор. Если в инициализатор пришло что-то отличное от nil, обертка запоминает ObjectIdentifier этого объекта, который впоследствии используется для реализации Hashable:


extension Weak: Hashable {    static func == (lhs: Weak<T>, rhs: Weak<T>) -> Bool {        return lhs.id == rhs.id    }    func hash(into hasher: inout Hasher) {        if let id = id {            hasher.combine(id)        }    }}

Вооружившись Weak<T>, можно приступить к реализации событий:


final class Event<Args> {    // Тут живут подписчики на событие и обработчики этого события    private var handlers: [Weak<AnyObject>: (Args) -> Void] = [:]    func subscribe<Subscriber: AnyObject>(        _ subscriber: Subscriber,        handler: @escaping (Subscriber, Args) -> Void) {        // Формируем ключ        let key = Weak<AnyObject>(subscriber)        // Почистим массив обработчиков от мертвых объектов, чтобы не засорять память        handlers = handlers.filter { $0.key.isAlive }        // Создаем обработчик события        handlers[key] = {            [weak subscriber] args in            // Захватываем подписчика слабой ссылкой и вызываем обработчик,            // только если подписчик жив            guard let subscriber = subscriber else { return }            handler(subscriber, args)        }    }    func unsubscribe(_ subscriber: AnyObject) {        // Отписываемся от события, удаляя соответствующий обработчик из словаря        let key = Weak<AnyObject>(subscriber)        handlers[key] = nil    }    func raise(_ args: Args) {        // Получаем список обработчиков с живыми подписчиками        let aliveHandlers = handlers.filter { $0.key.isAlive }        // Для всех живых подписчиков выполняем код их обработчиков событий        aliveHandlers.forEach { $0.value(args) }    }}

Этот код не очень сложный, но, чтобы он не казался вам совсем уж примитивным, я написал побольше комментариев. Класс Weak<T>, как оказалось, нужен был для того, чтобы хранить всех подписчиков в словаре и дать им спокойно умереть, если они того желают не держать их сильной ссылкой.


Обработчик события представляет собой замыкание, в аргументы которого попадает живой подписчик и некоторые данные, если таковые актуальны для данного события. Получившийся класс Event<Args> позволяет подписываться на событие с помощью метода subscribe(_:handler:) и отписываться от него с помощью метода unsubscribe(_:). Когда источник события (в нашем случае это вью-модель) захочет уведомить о чем-то свою армию подписчиков, ему следует воспользоваться методом raise(_:).


Для удобства можно обмазаться экстеншеном, который позволяет опустить аргументы типа Void при подписке на событие и при его вызове:


extension Event where Args == Void {    func subscribe<Subscriber: AnyObject>(        _ subscriber: Subscriber,        handler: @escaping (Subscriber) -> Void) {        subscribe(subscriber) { this, _ in            handler(this)        }    }    func raise() {        raise(())    }}

Пользоваться событиями можно примерно так. На стороне источника событий создаем объект события, после чего в какой-то момент стреляем нотификацией:


let event = Event<Void>()event.raise() // Какой-то момент наступил, стреляем

На стороне подписчика подписываемся и выполняем полезную работу в замыкании. Обратите внимание, что в аргументы обработчика события поступает живой экземпляр подписчика, что позволяет не писать weak self, если нам нужен доступ к подписчику внутри замыкания:


event.subscribe(self) { this in    this.foo() // Тут полезная работа}

Если подписчик более не заинтересован в получении событий, он делает вот так:


event.unsubscribe(self) // Нам лучше расстаться

Ура! Вы прочитали почти всю статью, осталось совсем немного. Чтобы передохнуть, отвлекитесь на минутку и подумайте о том, насколько MVVM прекрасен. Следующий раздел почти последний.


Отображение списка заказов


Чтобы научить OrdersVM уведомлять OrdersVC об изменении списка заказов, необходимо во вью-модель добавить соответствующее событие. Однако, согласитесь, не хочется в каждой вью-модели, которая должна уведомлять о своих изменениях, снова и снова писать код по созданию события. Поэтому мы пойдем уже знакомым путем и обратимся за помощью к запретным техникам Objective-C-рантайма, клятвенно пообещав себе больше никогда так не делать:


private var changedEventKey: UInt8 = 0protocol INotifyOnChanged {    var changed: Event<Void> { get }}extension INotifyOnChanged {    var changed: Event<Void> {        get {            if let event = objc_getAssociatedObject(self,                 &changedEventKey) as? Event<Void> {                return event            } else {                let event = Event<Void>()                objc_setAssociatedObject(self,                     &changedEventKey,                     event,                     .OBJC_ASSOCIATION_RETAIN_NONATOMIC)                return event            }        }    }}

С помощью протокола INotifyOnChanged и его дефолтной реализации любая вью-модель сможет бесплатно получить событие changed. С появлением INotifyOnChanged дефолтная реализация протокола IHaveViewModel вынуждена будет немного эволюционировать: в ней мы захотим подписаться на изменение вью-модели и вызвать viewModelChanged(_:) в обработчике события:


extension IHaveViewModel {    var anyViewModel: Any? {        get {            return objc_getAssociatedObject(self, &viewModelKey)        }        set {            (anyViewModel as? INotifyOnChanged)?.changed.unsubscribe(self)            let viewModel = newValue as? ViewModel            objc_setAssociatedObject(self,                 &viewModelKey,                 viewModel,                 .OBJC_ASSOCIATION_RETAIN_NONATOMIC)            if let viewModel = viewModel {                viewModelChanged(viewModel)            }            (viewModel as? INotifyOnChanged)?.changed.subscribe(self) { this in                if let viewModel = viewModel {                    this.viewModelChanged(viewModel)                }            }        }    }}

И, наконец, финальный штрих:


final class OrdersVM: INotifyOnChanged {    var orders: [OrderVM] = []    private var ordersProvider: OrdersProvider    init(ordersProvider: OrdersProvider) {        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(name: $0.name) }            self?.changed.raise() // Пыщ!        }    }}

Все, что мы делали выше класс Weak<T>, класс Event<Args>, протокол INotifyOnChanged и его дефолтная реализация, было нужно ради того, чтобы мы смогли написать одну единственную строчку кода во вью-модели: changed.raise().


Вызов rise(), произведенный в подходящий момент, после получения всех данных, приводит к тому, что в контроллере вызывается метод viewModelChanged(_:), который перезагружает таблицу, и она успешно отображает список заказов.


One More Thing: подписка на изменение отдельных свойств модели представления через обертки свойств


Протокол INotifyOnChanged и событие changed неплохо справляются с задачей уведомления об обновлении всей вью-модели с последующей перерисовкой всей вьюхи. В большинстве случаев этого вполне достаточно, но что, если мы хотим из соображений производительности или, что более важно, ради развлечения рассказать View об изменении какого-то одного свойства ViewModel? Очевидно, что мы можем для этих целей завести во вью-модели отдельное событие myPropertyChanged, подписаться на него на стороне вьюхи и дело сделано.


Но зачем самим писать код, который за нас могут генерировать инженеры Apple?


В предыдущей статье я немного рассказывал про обертки свойств, и вы могли убедиться, что это весьма своеобразная, но занимательная штуковина, которая позволяет наделить ничем не примечательные свойства разными суперспособностями.


Чтобы написать свой property wrapper, нужно создать класс или структуру, предоставить свойство wrappedValue и украсить все это дело, как вишенкой на торте, атрибутом @propertyWrapper. Однако обертки свойств не так просты и позволяют манипулировать не только самим свойством, которое они оборачивают, но и его проекцией через специальное свойство projectedValue. Согласитесь, звучит очень непонятно, поэтому, чтобы еще больше вас запутать, рассмотрим такой код:


@propertyWrapperstruct Observable<T> {    let projectedValue = Event<T>()    init(wrappedValue: T) {        self.wrappedValue = wrappedValue    }    var wrappedValue: T {        didSet {            projectedValue.raise(wrappedValue)        }    }}

Мы только что создали обертку свойства и назвали ее Observable. Она умеет работать со свойствами любых типов и может похвастаться наличием projectedValue. Проекция является событием, которое обучено сообщать своим подписчикам о любых изменениях wrappedValue. Это событие, как видно из кода, мы используем по своему прямому назначению в didSet.


Имея в своем арсенале обертку Observable<T>, мы можем применить ее к списку заказов:


@Observablevar orders: [OrderVM] = []

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


private var _orders = Observable<[OrderVM]>(wrappedValue: [])var orders: [OrderVM] {  get { _orders.wrappedValue }  set { _orders.wrappedValue = newValue }}var $orders: Event<[OrderVM]> {  get { _orders.projectedValue }}

Видно, что, помимо свойства orders, которое через обертку просто возвращает wrappedValue, компилятор сгенерировал дополнительное свойство $orders, которое возвращает нам projectedValue. В нашем случае projectedValue это экземпляр события, что позволяет на стороне вьюхи подписаться на изменение свойства orders вот таким нехитрым образом:


viewModel.$orders.subscribe(self) { this, orders in    this.update(with: orders)}

Поздравляю! Вы только что в 15 строчках кода написали свой собственный аналог атрибута Published из фрэймворка Combine от Apple, а я только что дописал очередную статью.


Заключение


Сегодня мы вероломно поступились основным принципом работы расширений, хакнув их с помощью Objective-C-рантайма. Это позволило нам, используя протоколы и экстеншены, реализовать паттерн MVVM в одном маленьком приложении под iOS. В процессе у нас возникло непреодолимое желание применить реактивный фреймворк, и мы едва удержались, написав вместо этого свою реализацию событий, вдохновившись дружественной технологией .NET. Попутно познакомились с парой полезных техник iOS-разработки, таких как shadow type erasure и property wrappers с применением projected value.




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Легковесный роутинг на микросервисах

17.07.2020 08:05:27 | Автор: admin


Мобильные приложения в последнее время стали по-настоящему большими не только в смысле своей значимости для нас с вами, но и в прямом смысле.


По своей функциональности они бывают просто огромными. Некоторые приложения состоят из десятков, сотен экранов и переходов между ними. И пока пользователь открывает очередной экран с деталями заказа, наслаждаясь плавной анимацией, в мире MVVM происходит много всего интересного: вью-контроллер создается, вью-модель создается, аргументы пробрасываются, зависимости резолвятся.


Кто делает всю эту важную, но незаметную работу? Советую запастись чаем и печеньками: это мини-сериал о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня заключительная серия про слой роутинга в iOS-приложении.


Введение


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



Весь остаток статьи мы будем разбираться, как работает эта строчка кода и зачем вообще она нужна.


Напомню также, что у меня есть некоторые правила и я стараюсь их придерживаться:


Некоторые правила, которых я стараюсь придерживаться
  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Традиционно в начале статьи будет ее содержание.


Традиционное содержание



В чем проблема?


В прошлой статье про MVVM мы написали приложение, которое отображает список заказов (с кем не бывает). У нас есть OrdersVC, у которого имеется личная вью-модель OrdersVM. Предположим, что мы хотим при нажатии на ячейку таблицы отображать экран с информацией о деталях соответствующего заказа:



Для тех, кто предпочитает больше конкретики, вот примитивная до неприличия реализация вью-модели нового экрана:


final class OrderDetailsVM: IPerRequest {    typealias Arguments = Order    let title: String    required init(container: IContainer, args: Order) {        self.title = "Details of \(args.name) #\(args.id)"    }}

Модель представления деталей заказа реализует IPerRequest (подробности в статье про DI), а значит, доступна из DI-контейнера. В качестве аргументов она принимает модель заказа и формирует из нее строковый заголовок, пригодный для отображения пользователю. Контроллер этого экрана будет выглядеть не намного сложнее:


final class OrderDetailsVC: UIViewController, IHaveViewModel {    typealias ViewModel = OrderDetailsVM    private lazy var titleLabel = UILabel()    override func viewDidLoad() {        super.viewDidLoad()        view.backgroundColor = .white        view.addSubview(titleLabel)        titleLabel.translatesAutoresizingMaskIntoConstraints = false        titleLabel.centerXAnchor            .constraint(equalTo: view.centerXAnchor)            .isActive = true        titleLabel.topAnchor            .constraint(equalTo: view.topAnchor, constant: 24)            .isActive = true    }    func viewModelChanged(_ viewModel: OrderDetailsVM) {        titleLabel.text = viewModel.title    }}

Контроллер OrderDetailsVC реализует IHaveViewModel (подробности в статье про MVVM) и просто отображает текст, который подготовила для него вью-модель. Для тестовых целей нам этого вполне достаточно.


Чтобы научить OrdersVC реагировать на тап по ячейке таблицы, дополним его экстеншеном:


extension OrdersVC: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        viewModel?.showOrderDetails(forOrderIndex: indexPath.row)    }}

Кстати, этот короткий кусок кода наглядно показывает, как паттерн MVC, который безраздельно властвует в мире iOS, уживается с паттерном MVVM на территории одного приложения. Действие пользователя (тап по ячейке таблицы) обрабатывается контроллером, потому что в iOS по-другому быть не может. Однако контроллер ничего самостоятельно не делает, вместо этого он делегирует всю грязную работу своей модели представления, снабдив ее необходимой информацией в виде индекса интересующего нас заказа.


Напомню, что OrdersVM, которую мы реализовывали в прошлой статье, выглядит так:


final class OrdersVM: IPerRequest, INotifyOnChanged {    typealias Arguments = Void    var orders: [OrderVM] = []    private let ordersProvider: OrdersProvider    required init(container: IContainer, args: Void) {        self.ordersProvider = container.resolve()    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }            self?.changed.raise()        }    }    func showOrderDetails(forOrderIndex index: Int) {        let order = orders[index].order        // Что было дальше?        // ...    }}

Эта модель представления реализует IPerRequest, а значит, доступна из контейнера. Также из контейнера она извлекает OrdersProvider, с помощью которого осуществляет загрузку заказов. По окончании загрузки список заказов заботливо складывается в массив orders, а вью-контроллер получает соответствующее уведомление посредством вызова changed.raise().


В методе showOrderDetails(forOrderIndex:) мы находим нужный заказ и должны открыть новый экран, который отображает детали этого заказа. Чтобы модально показать экран в iOS, нужно создать контроллер этого экрана и воспользоваться методом present(_:animated:completion:), который следует вызвать на текущем контроллере.


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


Стоп, что за сервисы вообще такие?


Скорее всего, вы заметили, что модель представления OrdersVM не занимается загрузкой заказов самостоятельно, эту работу она поручает сервису OrdersProvider.


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


Если сервис берет на себя слишком много, скорее всего, переиспользовать его будет проблематично. Можно вдохновиться отважными бэкэндерами с их микросервисной архитектурой и сделать вид, что мы тоже пишем микросервисы.



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


Композиция сервисов очень удобный и мощный механизм организации и переиспользования кода. Здесь очень пригождается DI-контейнер, о котором мы говорили в самой первой статье. Когда количество сервисов в вашем приложении начнет исчисляться десятками, это не станет проблемой, потому что DI-контейнер сможет создать для вас любую сущность, попутно разрешив все ее зависимости.


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


  1. Декомпозируйте функциональность приложения на (микро)сервисы с четко определенной зоной ответственности.
  2. Активно используйте композицию сервисов для повторного использования кода.
  3. Используйте DI-контейнер для разрешения зависимостей.

Некоторые сервисы могут заниматься роутингом, помогая вью-моделям осуществлять навигацию на новые экраны приложения.


Обязательно нужен отдельный сервис для роутинга?


Действительно, если действие пользователя, такое как тап в ячейку, прилетает сразу в контроллер, почему бы из этого контроллера не показать новый экран простым вызовом present(_:animated:completion:). Я голосую против, потому что такой подход удобнее только на первый взгляд:


  1. Не всегда переход на другой экран результат действия пользователя. Например, мы можем захотеть показать новый VC по окончании какого-то асинхронного запроса, который будет происходить во вью-модели.
  2. Решение о том, какой экран показать, не всегда тривиальное. Это может быть результат работы сложной бизнес-логики, поэтому удобнее запустить показ экрана в том месте, где вся эта бизнес-логика происходит.
  3. На логику, упомянутую в предыдущем пункте, могут быть написаны тесты. Роутер можно замокать и производить проверки относительно того, какой экран мы собираемся открывать в том или ином случае.
  4. В целях отладки кода удобно иметь единую точку входа для всей навигации в приложении роутер. Это позволяет поставить брейкпоинт в нужном месте и проследить, откуда осуществляется тот или иной переход.

Такие образом, будет полезно вынести функциональность роутинга в отдельный сервис или в несколько сервисов. За показ модальных экранов мог бы отвечать, скажем, PresenterService.


Окей, автор, как мне реализовать роутер?


Вот три простых шага на пути к модальному открытию нового экрана:


  1. Найти экземпляр UIViewController, с которого будет осуществляться переход.
  2. Создать вью-контроллер нового экрана и вью-модель для него.
  3. Осуществить переход на новый экран.

Начнем с того, что объявим сам класс и сделаем его доступным из контейнера:


final class PresenterService: ISingleton {    private unowned let container: IContainer    public required init(container: IContainer, args: Void) {        self.container = container    }}

Мы предусмотрительно сохранили ссылку на контейнер в инициализаторе, он нам еще понадобится. Стоит сказать, что обычно сохранять ссылку на контейнер, чтобы потом в какой-то момент что-то из него извлечь, не очень хорошая практика: это размазывает код создания зависимостей как в пространстве, так и во времени, делая этот код более запутанным, а ваших коллег более раздражительными. Однако, так как PresenterService занимается роутингом и собирается создавать новые экраны, у него нет другого выбора и владеть ссылкой на контейнер его классовая привилегия.


Первый пункт поиск контроллера можно сделать очень просто с помощью нескольких строк не самого элегантного рекурсивного кода:


var topViewController: UIViewController? {   let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }   return findTopViewController(in: keyWindow?.rootViewController)}func findTopViewController(in controller: UIViewController?) -> UIViewController? {   if let navigationController = controller as? UINavigationController {       return findTopViewController(in: navigationController.topViewController)   } else if let tabController = controller as? UITabBarController,       let selected = tabController.selectedViewController {       return findTopViewController(in: selected)   } else if let presented = controller?.presentedViewController {       return findTopViewController(in: presented)   }   return controller}

Метод findTopViewController(in:) врывается в иерархию контроллеров, как товарищ майор с обыском, и пытается найти там контроллер, который в данный момент отображается на экране. Возможно, это не самый универсальный способ решить задачу и, если в вашем приложении используется более запутанная структура экранов, потребуются некоторые правки, но идея, думаю, понятна.


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


func present<VC: UIViewController & IHaveViewModel>(    _ viewController: VC.Type,    args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {    let vc = VC()    vc.viewModel = container.resolve(args: args) // Тут вся магия    topViewController?.present(vc, animated: true, completion: nil)}

Давайте разбираться. Этот метод невероятно тесно интегрирован с нашей реализацией MVVM и с DI-контейнером и состоит, как вы наверняка заметили, всего из трех строк.


  1. В первой строке мы пользуемся тем, что у любого контроллера есть пустой инициализатор, и создаем экземпляр этого контроллера, зная его тип.
  2. Во второй строке мы создаем вью-модель и присваиваем ее соответствующему свойству контроллера. Вью-модель мы можем создать благодаря тому, что обязали ее реализовать IResolvable (про это была статья про DI). Нам всего лишь нужно знать ее тип и аргументы, от которых она зависит. Тип вью-модели известен, потому что все вью-контроллеры предоставляют свойство viewModel в рамках реализации протокола IHaveViewModel (про это была статья про MVVM). Кроме того, у нас имеются необходимые аргументы VC.ViewModel.Arguments и доступ к контейнеру прямо из сервиса. При создании экземпляра вью-модели с помощью магии DI-контейнера самым удобным образом разрешаются все ее зависимости. Прочувствуйте момент: DI-контейнер, MVVM и роутинг сходятся здесь и сейчас в одной точке, и эта точка одна строчка кода. Ух!
  3. И, наконец, в третьей строке, вооружившись знанием о том, какой вью-контроллер сейчас отображается на экране, мы осуществляем показ только что созданного контроллера с помощью банального вызова present(_:animated:completion:).

Чтобы пазл сложился, давайте еще раз взглянем на весь код PresenterService, который до этого мы разбирали по кусочкам:


final class PresenterService: ISingleton {    private unowned let container: IContainer    private var topViewController: UIViewController? {        let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }        return findTopViewController(in: keyWindow?.rootViewController)    }    required init(container: IContainer, args: Void) {        self.container = container    }    func present<VC: UIViewController & IHaveViewModel>(        _ viewController: VC.Type,        args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {        let vc = VC()        vc.viewModel = container.resolve(args: args)        topViewController?.present(vc, animated: true, completion: nil)    }    func dismiss() {        topViewController?.dismiss(animated: true, completion: nil)    }    private func findTopViewController(        in controller: UIViewController?) -> UIViewController? {        if let navigationController = controller as? UINavigationController {            return findTopViewController(in: navigationController.topViewController)        } else if let tabController = controller as? UITabBarController,            let selected = tabController.selectedViewController {            return findTopViewController(in: selected)        } else if let presented = controller?.presentedViewController {            return findTopViewController(in: presented)        }        return controller    }}

Единственный незнакомый метод, который здесь добавился, это dismiss(), позволяющий закрыть текущий модальный экран. Окончательная реализация OrdersVM, которая с помощью PresenterService научилась отображать детали заказа, выглядит так:


final class OrdersVM: IPerRequest, INotifyOnChanged {    typealias Arguments = Void    var orders: [OrderVM] = []    private let ordersProvider: OrdersProvider    private let presenter: PresenterService    required init(container: IContainer, args: Void) {        self.ordersProvider = container.resolve()        self.presenter = container.resolve()    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }            self?.changed.raise()        }    }    func showOrderDetails(forOrderIndex index: Int) {        let order = orders[index].order        // Открываем экран с деталями заказа        presenter.present(OrderDetailsVC.self, args: order)    }}

Как видно, в инициализаторе мы без лишней суеты достаем из контейнера наш PresenterService и используем его по назначению в методе showOrderDetails(forOrderIndex:).


Не хочу модальные экраны, хочу пушить экраны в стэк. Как быть?


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


  1. Найти экземпляр UINavigationController, который сейчас виден на экране.
  2. Создать вью-контроллер нового экрана и вью-модель для него.
  3. Осуществить переход на новый экран.

Как видно, эти шаги очень похожи на таковые для PresenterService, а значит, и код будет аналогичным. Я его даже под спойлер уберу, чтобы под ногами не мешался.


Реализация NavigationService
final class NavigationService: ISingleton {    private unowned let container: IContainer    private var topNavigationController: UINavigationController? {        let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }        let root = keyWindow?.rootViewController        let topViewController = findTopViewController(in: root)        return findNavigationController(in: topViewController)    }    required init(container: IContainer, args: Void) {        self.container = container    }    func pushViewController<VC: UIViewController & IHaveViewModel>(        _ viewController: VC.Type,        args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {        let vc = VC()        vc.viewModel = container.resolve(args: args)        topNavigationController?.pushViewController(vc, animated: true)    }    func popViewController() {        topNavigationController?.popViewController(animated: true)    }    private func findTopViewController(        in controller: UIViewController?) -> UIViewController? {        if let navigationController = controller as? UINavigationController {            return findTopViewController(in: navigationController.topViewController)        } else if let tabController = controller as? UITabBarController,            let selected = tabController.selectedViewController {            return findTopViewController(in: selected)        } else if let presented = controller?.presentedViewController {            return findTopViewController(in: presented)        }        return controller    }    private func findNavigationController(        in controller: UIViewController?) -> UINavigationController? {        if let navigationController = controller as? UINavigationController {            return navigationController        } else if let navigationController = controller?.navigationController {            return navigationController        } else {            for child in controller?.children ?? [] {                if let navigationController = findNavigationController(in: child) {                    return navigationController                }            }        }        return nil    }}

Сервисы, подобные NavigationService и PresenterService, нужно будет написать для всех контроллеров, которые являются контейнерами для других контроллеров как для стандартных типа UITabBarController, так и для кастомных. Группа таких сервисов образует слой роутинга в вашем приложении.


Мне не подходит реализация роутинга. Что делать?


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



Вьюха (контроллер) должна держать вью-модель сильной ссылкой. Вью-модель должна каким-то образом уведомлять вьюху об изменении своего состояния. Вью-модель должна зависеть от многочисленных сервисов, один из которых роутер. Используйте роутер для навигации между экранами. Точка навигации точка большого взрыва, который приводит к созданию нового экрана и всех его зависимостей. Роутер должен уметь создавать пары вьюха вью-модель и все сервисы, от которых они зависят. Для этого он вынужден держать ссылку на DI-контейнер.


Развивайте архитектуру дальше. Например, если у вас длинные цепочки экранов, между которыми передаются полезные данные, попробуйте координатор. Координатор будет держать ссылку на роутер и осуществлять навигацию через него композиция сервисов в действии. Экспериментируйте, пусть ничто не ограничивает вашу фантазию. Веселитесь и получайте удовольствие от своей работы.


Заключение


Сегодня мы говорили про роль (микро)сервисов в мобильных приложениях на примере роутинга. Сервисы роутинга мостик между миром MVC и миром MVVM. Они помогают вью-моделям осуществлять навигацию на новые экраны и имеют право напрямую обращаться к DI-контейнеру для создания пар вьюха вью-модель.


Реализация PresenterService, рассмотренная в этой статье, последний кусочек пазла, необходимый для полноценной работы паттерна MVVM в вашем мобильном приложении. PresenterService глубоко интегрирован с конкретными реализациями MVVM и DI-контейнера, про которые мы говорили в предыдущих статьях, и только в связке с ними раскрывается весь его потенциал.




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Финтех на карантине студенты и преподаватели Тинькофф Финтеха рассказывают о переходе в онлайн

16.07.2020 20:13:21 | Автор: admin
В Тинькофф есть собственные образовательные проекты, один из которых Тинькофф Финтех. У нас учатся аналитике, разработке и тестированию молодые специалисты из разных городов России, где есть Тинькофф Центры разработки.



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

Евгений, преподаватель Python


Я всегда читал лекции перед живыми людьми, а тут был черный экран



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

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

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

Евгений Булыгин, студент курса Бэкэнд-разработка на Python (Екатеринбург)


Я писал копию Инстаграма



Я учусь на втором курсе Уральского федерального университета по направлению программная инженерия. В Тинькофф Финтех пошел, чтобы углубить знания по разработке, ездил после пар раз в неделю.

На середине курса из-за самоизоляции мы перешли в Зум, слушали лекции онлайн, но расписание осталось прежним. Лично я не заметил большой разницы в подаче материала, качество обучения не снизилось.

Домашки и до карантина мы делали самостоятельно, пересылали преподавателям и получали отзывы. У меня уходило много времени на них: каждый день на выполнение домашнего задания тратил около шести часов. Например, было домашнее задание написать асинхронный чат, или написать сервис оценки фильмов типа Кинопоиска, или сервис по обработке картинок. Потом каждый писал курсовой проект: я делал копию Инстаграма, окончил курс на первом месте по баллам.

Лично мне больше нравилось слушать лекции вживую, в офисе: преподаватель шутил, мы могли ответить, все как-то непринужденно и живо. А в Зуме, даже когда преподаватель пытался шутить, в ответ была просто тишина.

У нас был выпускной, если это можно так назвать, тоже в Зуме. Преподаватели похвалили, рассказали о других курсах и попрощались, нам выслали электронные сертификаты. Обещали, что пришлют и реальные сертификаты с подарками. Пока ждем. (От Тинькофф Образования: Подарки и сертификаты мы отправим сразу же, как изменится ситуация с коронавирусом).

Маша, преподаватель курса QA Engineer


Во время лекций приходилось спрашивать ребят: Вы там живы?



Еще до карантина мы думали о том, чтобы снимать лекции на видео и потом выкладывать их для участников курса. Пришла самоизоляция, мы стали вести лекции в Зуме, а там записать видео лекции проще.

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

Во время лекций приходилось спрашивать ребят: Вы там живы? Потому что в начале мы просили их включить камеру и микрофон, но студенты на это не реагировали и с непривычки не были готовы активно общаться онлайн.

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

Я начала больше времени тратить на общение со студентами онлайн в общей телеграм-группе и в личке, отвечать на вопросы по лекциям и домашкам. Раньше их можно было быстро решить после офлайн-лекции.

А вот что мне очень понравилось в онлайн-лекциях: преподаватель может демонстрировать сразу живой процесс кодирования, может показывать сам процесс работы наши инструменты, поиск ошибок прямо в браузере, создание документации и многое другое.

К сожалению, онлайн-формат не всегда дает представление об участнике как о человеке: насколько он деятельный, какой у него характер. Но мы, как преподаватели Финтеха, должны дать свои отзывы о лучших участниках для HR-отдела. И тут, конечно, качество наших отзывов пострадало. Ведь без личного взаимодействия мы не могли сказать, подходит ли этот человек команде по духу или нет.

Михаил Волков, студент курса QA Engineer


На одной из первых лекций преподаватель предложил протестировать стул



Я работаю, и учебу в Финтехе совмещал с полноценным рабочим днем. Чтобы приехать вовремя на лекцию, приходил на работу пораньше, а вечером пораньше уходил. В онлайн-формате я не тратил время на то, чтобы добраться до офиса.

У нас было около шести занятий до перехода в онлайн. Помню, на одной из первых лекций преподаватель предложил в качестве примера протестировать стул, чтобы рассмотреть общие принципы тестирования, а также наглядно показать различия в видах тестирования. На другом занятии была игра, где мы изучали распределение задач в команде. После каждой лекции всегда можно было подойти к преподавателю, задать вопросы по теме или по домашке. В онлайне такое невозможно: все общение перешло в чат курса и в лички с преподавателями. Не хватало живого общения.

До Тинькофф Финтеха я проходил различные обучающие курсы как онлайн, так и офлайн, в других компаниях. В большинстве случаев преподаватели на этих курсах крайне редко отвечали на вопросы студентов. Здесь же я всегда получал фидбэк, даже когда вся учеба ушла в онлайн, в любое время дня и ночи. И, что самое важное, преподаватели были рады помочь. И все это бесплатно.

Мне понравилось, что для курса преподаватели изначально создали веб-приложение с дефектами и на нем мы тренировались искать ошибки, описывать, создавать тестовую документацию, немного автоматизировали процесс тестирования. Вообще на курсе есть две важные части: то, как писать тестовую документацию, и сама техническая часть тестирования. В рамках лекций и домашек мы писали тестовую документацию, на практике использовали PostgreSQL, Postman и другие технологии для тестирования веб-приложения. На мой взгляд, лекции с теоретическим материалом все же лучше проводить вживую: так легче задавать вопросы и рассматривать примеры. Но практику очень удобно проходить в онлайн-режиме.

Антон, преподаватель курса Системный и бизнес-анализ (Рязань)


В аудитории всегда видно, кого нужно разбудить, кто хочет спросить, но стесняется



Мне, как преподавателю, перестало хватать обратной связи от студентов.

Я аналитик, и по роду деятельности у меня много контактов. Мне привычно, когда все друг с другом общаются, активно слушают.

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

Для удаленки пришлось немного переработать программу. В офлайне мы хотели смоделировать для участников ситуации общения с заказчиков. Но в удаленном формате все прошло не так, как хотелось: у нас было две команды студентов. Теоретически они должны были самоорганизоваться и взаимодействовать между собой. В офлайне ты всегда можешь направить работу команд. А в онлайне получилось не так, как хотелось изначально.

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

Артем, студент курса Системный и бизнес-анализ (Рязань)


На время лекции я обычно закрывался в отдельной комнате, чтобы максимально сконцентрироваться на материале



Мне нравился формат реальных лекций, нравилась наша группа, преподаватели, живое взаимодействие. Это привычно.

Офис Тинькофф в центре, я живу недалеко и тратил на дорогу до офиса 15 минут на машине. Да, когда мы перешли в онлайн, не нужно было тратить время на дорогу. Но для Рязани время в дороге не является таким критичным, как, например, в Москве. Хотя кто-то иногда после работы или учебы опаздывал, иногда мы ждали минут 1520 начала лекции. В онлайн-формате все начинали вовремя.

На время лекции я обычно закрывался в отдельной комнате, чтобы максимально сконцентрироваться на материале. Обычно мы слушали преподавателя, а вопросов почти никто не задавал: было ощущение, что или люди стесняются, или просто слушают лекцию фоном, а сами занимаются своими делами.

По рейтингу я закончил вторым на курсе. На домашние задания тратил немного времени час-полтора, так как я уже знаком со сферой бизнес-анализа.

Онлайн очень хорош для тех, кто просто пришел за новыми знаниями: учиться можно и дистанционно, а лекторы давали достаточно информации как офлайн, так и онлайн. Но если ты приходишь в Финтех с дальнейшей целью трудоустроиться, то тут лучше все же формат офлайн-занятий: ты видишь реальных сотрудников, видишь атмосферу в самой компании, понимаешь, подходит ли она тебе и подходишь ли ты этому месту.

Азамат, преподаватель курса QA Automation (Екатеринбург)


В онлайне не мучают вопросами и ты всегда можешь подглядеть в рядом лежащем айпаде



В прошлом году я ходил на курсы по ораторскому мастерству, и все навыки, которые я там получил, направлены на контакт с людьми вживую. Как преподаватель мог легко завладеть вниманием аудитории в офлайне. Например, представьте, что перед вами сидят 25 человек квадратом 5 5. И в самом дальнем углу человек вместо того, чтобы слушать материал, играет в телефон. Тебе не нужно его окликать или как-то обращаться к нему: просто ты обходишь зал, встаешь у него за спиной и продолжаешь рассказывать материал. Человек тут же включается, а ты ведешь лекцию дальше.

Формат реальной лекции позволяет использовать много таких фишек: работать интонацией, паузами. Ты можешь ручку уронить, в конце концов, или постучать, чтобы сохранить внимание аудитории.

В Зуме такого не проделаешь: все ребята замьючены. Они могли по желанию включать камеру и микрофон, конечно, но никто этого не делал, все стеснялись и не задавали вопросов.

Хотя тут есть и плюс для преподавателя: не мучают вопросами и ты всегда можешь подглядеть в рядом лежащем айпаде. В офлайне ты должен быть готов на 100%, чтобы не забыть материал.
Важный момент: когда ты приходишь на лекцию в офис или другое специальное место, ты сконцентрирован, твое внимание активно. Когда ты слушаешь онлайн-лекцию, ты можешь это делать в нижнем белье, тебя могут отвлекать дети, кошки. Так что, думаю, концентрация внимания у студентов была ниже. Хотя мне как преподавателю это было в плюс.

Если сравнивать мой опыт преподавания, безусловно, люди в офлайне проще задают вопросы. В онлайне после лекции у тебя появляется шквал вопросов в общий чат или личку, так как никто не спрашивал во время лекции. В таких случаях мы разделяли: ребята, соблюдайте дедлайны, отвечать на чаты преподаватель будет в определенное время, он не работает день-ночь.

Эвелина Яметова, студентка курса QA Automation (Екатеринбург)


Слушать лекции через видеоконференции в Зуме мне было очень комфортно. Во-первых, ты дома, во-вторых, всегда получаешь видеозапись



Лично для меня переход в онлайн это здорово. Я сама по себе человек стеснительный, мне было некомфортно находиться на лекции в окружении новых для меня людей. Плюс экономия времени на дорогу: 40 минут только в одну сторону.

Еще на обычной лекции преподаватели рассказывали материал быстро, физически невозможно было успевать за ними записывать.

Поэтому слушать лекции через видеоконференции в Зуме мне было очень комфортно. Во-первых, ты дома, во-вторых, всегда получаешь видеозапись. В один из дней я плохо себя чувствовала во время лекции и не могла концентрированно воспринимать материал. Поэтому я пересмотрела запись позже. Это большой плюс для меня как для студента.

Сергей, преподаватель курса разработки на Golang


Ребята не жаловались и камеры включали



Первоначально наш курс построен так, что значительный блок информации мы преподносим студентам в процессе общения. У нас есть непростые разделы, где нужно последовательно усложнять материал, погружать студента в решаемую проблему. Для этого я рисовал на whiteboard, задавал аудитории вопросы, максимально вовлекал в процесс.

В онлайн-формате мы были лишены такой возможности, которую, к сожалению, заменить по-нормальному так и не смогли. Мы заменили whiteboard на слайд в презентации, подробно разбирали со студентами непонятные пункты, но раскачать их в интерактивный режим так и не получилось.

А вот рубежный (проверочный) контроль был более удачным. Мы сразу ввели правила игры: обозначили время, когда студент должен подключиться к звонку с преподавателем, включенная веб-камера стала обязательным условием для прохождения контроля. На удивление, ребята не жаловались и камеры включали.

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

В целом отмечу со своей стороны, что мне больше всего не хватало общения со студентами на лекциях, я бы не стал их заменять на онлайн-формат. Что касается проверочных чек-поинтов, то здесь в принципе онлайн-формат оказался более успешным, если оценивать с точки зрения качества проверки.

Софья Михайлова, студентка курса разработки на Golang


Здорово, что преподаватели всегда отвечали на вопросы по лекции и домашнему заданию, могли ответить и в час ночи

Лично мне переход в онлайн сэкономил время в дороге от университета до офиса и от офиса до дома. Это где-то четыре часа.

Мы достаточно рано перешли на онлайн-формат, не успели подружиться с одногруппниками. Но в чате плотно общались. Здорово, что преподаватели всегда отвечали на вопросы по лекции и домашнему заданию, могли ответить и в час ночи. Ни разу не было, чтобы я не получила обратную связь от преподавателя.

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

Но в целом переход в онлайн никак не повлиял на качество знаний, которые ты получаешь на курсе. Я учусь на третьем курсе МАИ, на факультете Информационные технологии и прикладная информатика, и целенаправленно шла на Golang как на один из перспективных языков.

Боли и радости онлайна


И преподаватели, и студенты сошлись в одном: учиться в онлайне можно, но лучше встречаться вживую.

Плюсы онлайн-обучения:
  • экономия времени на дорогу;
  • комфортные условия дома;
  • есть записи лекций;
  • можно демонстрировать студентам сам процесс работы и инструменты.

Минусы онлайн-обучения:
  • дома комфортно, но постоянно отвлекаешься;
  • хуже обратная связь;
  • не найти новых друзей, не видна атмосфера в коллективе компании.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru