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

Блог компании vivid money

IOS интервью в Vivid

11.06.2021 18:10:18 | Автор: admin

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

Скачивания и активные пользователи в Германии с 03.21 по 06.21Скачивания и активные пользователи в Германии с 03.21 по 06.21Количество функций в приложениях в 4 квартале 2020 годаКоличество функций в приложениях в 4 квартале 2020 года

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

Дисклеймер

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

Немного вводных

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

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

Наши особенности

Кроме основных обязанностей интервьюеров мы старались делать следующее:

  • Располагать к себе кандидата и создавать friendly атмосферу

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

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

Случай на собеседовании

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

Для собеседования мы не готовили список вопросов мы готовили темы на которые хотим поговорить. Такими темами были: Swift (куда же без знания языка), UI (так как у нас его очень много, он на 95% кастомный и иногда нетривиальный) и архитектура (для ведущего разработчика это очень важно). По каждой теме мы старались спрашивать только то, что в основном используется в повседневной разработке и то, что связано с нашим приложением. Конечно, иногда мы углублялись в какой-то вопрос, чтобы понять насколько хорошо кандидат знает тему, но тут есть тонкость если вдруг человек не отвечает или отвечает неправильно, мы не делаем на этом сильный акцент, так как это опциональные вопросы и ответы на них не обязан знать каждый.
Также для нас была очень важна практика, так как это то, что раскрывает способности разработчика лучше всяких вопросов. У нас были заготовлены различные задачи, которые мы выбирали в зависимости от ситуации. Среди них не было вопросов по алгоритмам, потому что мы не считаем их показательными они показывают умение находить решения (или вспоминать их), а не умение писать код. Наши задачи показывали то, как кандидат обычно пишет свой код, какие конструкции использует, насколько оптимальны его решения и как он размышляет.

Самая сложная задача

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

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

Экспериментировать и меняться это то, что свойственно нашей компании. Собеседования не исключение. Мы попробовали разные форматы: одно собеседование на 1.5 часа с вопросами "по ситуации", теоретическое собеседование на 1 час и практическое на 1.5 часа, теоретическое собеседование на 1 час и тестовое задание. В конце-концов мы пришли к следующему:

  • Скрининг перед собеседованием из 6 вопросов.

  • Одно собеседование на 1.5-2 часа. Из них 10-20 минут на общение с кандидатом не на технические темы, 30-40 минут на кодинг и остальное время на теорию.

  • Интервью всегда проводят 2 человека это дает более объективную оценку кандидата после собеседования.

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

Скрининг

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

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

Циферки:

  • Средняя длительность скрининга 4 минуты

  • Процент кандидатов, прошедших скрининг 75%

Техническая часть

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

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

Пример одной из веток собеседованияПример одной из веток собеседования

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

Есть одна особенная ветка Swift. Для движения по ней мы проводим лайвкодинг, в рамках которого кандидат решает поставленную задачу, у которой добавляются или меняются требования (прям как в реальной жизни). Задача затраивает практически весь синтаксис языка при ее небольшом объеме. По ходу решения мы задаем вопросы. Например: "Почему использовал class, а не struct?", "Можно ли задачу решить по-другому?" и так далее.

Таким образом, мы получили "фреймворк" для собеседования. Он позволил нам:

  • быстро понимать во время собеседования что спрашивать

  • вести собеседование более структурировано

  • быстро обучать новых интервьюеров

Как оцениваем кандидатов

Нельзя не затронуть столь субъективную тему как оценка уровня кандидата. Мы разделяем уровни разработчика как и многие другие: Junior, Middle, Senior. К каждому уровню еще можем добавлять + или - чтобы оценка была немного более точной.

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

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

  • Решение задачи. Чем быстрее и правильнее решена задача, чем чище код и чем лучше обоснованы решения, тем выше уровень.

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

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

После выхода на работу

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

Напоследок

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

На этом все. Всем удачи на собеседованиях!

Подробнее..

Пишем под android с Elmslie

20.04.2021 08:05:05 | Автор: admin

Вступление

Это третья часть серии статей об архитектуре android приложения vivid.money. В ней мы расскажем об Elmslie - библиотеке для написания кода под android с использованияем ELM архитектуры. Мы назвали ее в честь Джорджа Эльмсли, шотландского архитектора. С сегодняшнего дня она доступна в open source. Это реализация TEA/ELM архитектуры на kotlin поддержкой android. В первой статье мы рассказали о том почему выбрали ELM. Перед прочтением этой статьи лучше ознакомиться как минимум со второй частью, в которой мы более подробно рассказывали том собственно такое ELM.

Оглавление

Что будем писать

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

Модель

Написание экрана проще начинать с проектирования моделей. Для каждого экрана нужны State, Effect, Command и Event. Рассмотрим каждый из них по очереди:

State

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

На нашем экране будет отображаться либо числовое значение, либо состояние загрузки. Это можно задать двумя полями в классе: val isLoading: Boolean и val value: Int?. Для удобства изменения, State лучше реализовывать как data class. В итоге получается так:

data class State(  val isLoading: Boolean = false,  val value: Int? = null)

Effect

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

В нашем примере единственной командой UI будет показ Snackbar при ошибке загрузки value. Для этого заведем Effect ShowError. Для удобства Effect можно создавать как sealed class, чтобы не забыть обработать новые добавленные эффекты:

sealed class Effect {  object ShowError : Effect()}

Command

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

У нас будет одна операция - загрузить данные. Эту Command назовем LoadValue. Команды так же удобнее задавать как sealed class:

sealed class Command {  object LoadValue : Command()} 

Event

Все события, которые влияют на состояние и действия на экране: Ui: ЖЦ экрана, взаимодействие с пользователем, все что приходит из View слоя Internal: Результаты операций с бизнес логикой

Теперь перейдем к событиям. В нашем проекте мы разделяем события на две категории:

  • Event.UI: все события, которые происходят во View слое

  • Event.Internal: результаты выполнения команд в Actor.

В этом примере будет два UI события: Init - открытие экрана и ReloadClick - нажатие на кнопку обновления значение. Internal события тоже два: ValueLoadingSuccess - успешный результат Command LoadValue и ValueLoadingError, которое будет отправляться при ошибке загрузки значения.

Если использовать разделение на UI и Internal, то Event удобнее задавать как иерархию sealed class:

sealed class Event {  sealed class Ui : Event() {    object Init : Ui()    object ReloadClick : Ui()  }     sealed class Internal : Event() {    data class ValueLoadingSuccess(val value: Int) : Internal()    object ValueLoadingError : Internal()  }}

Реализуем Store

Закончив с моделями, перейдем собственно к написанию кода. Сам Store реализовывать не нужно, он предоставляется библиотекой классом ElmStore.

Repository

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

object ValueRepository {private val random = Random()fun getValue() = Single.timer(2, TimeUnit.SECONDS)    .map { random.nextInt() }    .doOnSuccess { if (it % 3 == 0) error("Simulate unexpected error") }}

Actor

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

Для его создания нужно реализовать интерфейс Actor, который предоставляется библиотекой. Actor получает на вход Command, а результатом его работы должен быть Observable<Event>, с событиями, которые сразу будут отправлены в Reducer. Для удобства в библиотеке есть функции mapEvents, mapSuccessEvent, mapErrorEvent и ignoreEvents, которые позволяют преобразовать данные в Event.

В нашем случае Actor будет выполнять только одну команду. При выполнении команды загрузки мы будем обращаться к репозиторию. В случае получения успешного значения будет оправляться событие ValueLoaded, а при ошибке ErrorLoadingValue. B итоге получается такая реализация:

class Actor : Actor<Command, Event> {override fun execute(command: Command): Observable&lt;Event&gt; = when (command) {    is Command.LoadNewValue -&gt; ValueRepository.getValue()        .mapEvents(Internal::ValueLoaded, Internal.ErrorLoadingValue)}}

Reducer

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

В этом классе нужно реализовать функцию reduce для обработки событий. Помимо вашей логики в Reducer можно использовать 3 функции:

  • state - позволяет изменить состояние экрана

  • effects - отправляет эффект во View

  • commands - запускает команду в Actor

class Reducer : DslReducer<Event, State, Effect, Command>() {override fun Result.reducer(event: Event) = when (event) {    is Internal.ValueLoaded -&gt; {        state { copy(isLoading = false, value = event.value) }    }    is Internal.ErrorLoadingValue -&gt; {        state { copy(isLoading = false) }        effects { +Effect.ShowError }    }    is Ui.Init -&gt; {        state { copy(isLoading = true) }        commands { +Command.LoadNewValue }    }    is Ui.ClickReload -&gt; {        state { copy(isLoading = true, value = null) }        commands { +Command.LoadNewValue }    }}}

Собираем Store

После того как написаны все компоненты нужно создать сам Store:

fun storeFactory() = ElmStore(    initialState = State(),    reducer = MyReducer(),    actor = MyActor()).start()

Экран

Для написания android приложений в elmslie есть отдельный модуль elmslie-android, в котором предоставляются классы ElmFragment и ElmAсtivity. Они упрощают использование библиотеки и имеют схожий вид. В них нужно реализовать несколько методов:

  • val initEvent: Event - событие инициализации экрана

  • fun createStore(): Store - создает Store

  • fun render(state: State) - отрисовывает State на экране

  • fun handleEffect(effect: Effect) - обрабатывает side Effect

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

class MainActivity : ElmActivity<Event, Effect, State>() {override val initEvent: Event = Event.Ui.Initoverride fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContentView(R.layout.activity_main)    findViewById&lt;Button&gt;(R.id.reload).setOnClickListener {        store.accept(Event.Ui.ClickReload)     }}override fun createStore() = storeFactory()override fun render(state: State) {    findViewById&lt;TextView&gt;(R.id.currentValue).text = when {        state.isLoading -&gt; &quot;Loading...&quot;        state.value == null -&gt; &quot;Value = Unknown&quot;        else -&gt; &quot;Value = ${state.value}&quot;    }}override fun handleEffect(effect: Effect) = when (effect) {    Effect.ShowError -&gt; Snackbar        .make(findViewById(R.id.content), &quot;Error!&quot;, Snackbar.LENGTH_SHORT)        .show()}}

Заключение

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

Подробнее..

Методы организации DI и жизненного цикла приложения в GO

08.12.2020 00:17:38 | Автор: admin

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


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


Зачем нам всё это нужно


Стоит начать с того, что главный враг всех программистов и главная причина появления практически всех инструментов проектирования это сложность. Тривиальный случай всегда понятен, легко ложится в голову, очевидно и изящно решается одной строчкой кода и с ним никогда не бывает проблем. Иное дело, когда в системе десятки и сотни тысяч (а иногда и больше) строк кода, и великое множество движущихся частей, которые переплетаются, взаимодействуют, да и просто существуют в одном тесном мирке, где кажется невозможным развернуться, не задев кого-то локтями.
Для решения проблемы сложности человечество пока не нашло пути лучше, чем разбивать сложные вещи на простые, изолируя их и рассматривая по отдельности.
Ключевая вещь здесь это изоляция, пока один компонент не влияет на соседние, можно не опасаться неожиданных эффектов и неявного воздействия одним на результат работы второго. Для обеспечения такой изоляции мы решаем контролировать связи каждого компонента, явно описав, от чего и как он зависит.
На этом моменте мы приходим к инъекции (или внедрению) зависимостей, которая на самом деле является просто способом организовать код так, чтобы каждому компоненту (класс, структура, модуль, etc.) были доступны только необходимые ему части приложения, скрывая от него всё излишнее для его работы или, цитируя википедию: DI это процесс предоставления внешней зависимости программному компоненту.


Такой подход решает сразу несколько задач:


  • Скрывает излишнее, уменьшая когнитивную нагрузку на разработчика;
  • Исключает неожиданные побочные эффекты (то есть, неявное влияние одних компонентов на работу других);
  • Абстрагирует одни компоненты от других, позволяя легко их заменять, тестировать и изменять;

Про жизненный цикл или при чём тут DI


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


  • Запуск приложение или компонент должно запустится и провести приготовления к работе: считать и применить конфигурацию, проверить доступ до внешних систем, от которых зависит непосредственно (например, база данных), начать слушать порт и так далее;
  • Работа наше приложение или компонент осуществляет свою полезную деятельность;
  • Завершение работы приложение или компонент прекращают принимать новые сигналы, заканчивают обрабатывать накопившиеся задачи, останавливают свою деятельность, закрывают соединения и так далее.

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


Пример:

Представим, что мы пишем простой и типичный сервер, который принимает JSONы из сети, кладёт их в базу и обратно.
Это означает, что у нас есть:


  • Конфигурация, в которой описано, какой порт слушать и к какой базе присоединяться;
  • Сервер, который слушает порт;
  • Некий коннектор (соединение или пул соединений) к базе данных;

Захотим ли мы поднять сервер или соединение к бд, если у нас не получилось считать конфигурацию?
Устроит ли нас случай, когда сервер уже поднялся, прежде чем выяснилось, что на самом деле база недоступна и часть запросов уже оказалась получена и упала с закономерными internal server error? (или наоборот, мы успели обратиться в базу, создать соединение и тп, прежде чем обнаружили, что указанный порт недоступен?)
Нравится ли нам такой вариант, что при отключении/перезапуске конкретного сервиса пользователи успевают добежать до него и получить ошибку, потому приложение просто моментально завершило работу (возможно даже и в середине обработки чьего-то запроса)?


Эти проблемы решаются иерархией обработки компонентов приложения: сначала мы считываем конфигурацию, потом инициализируем соединение с бд, только потом поднимаем сервер и так далее.
При получении же заветного SIGINT, вместо моментального падения приложение сначала ждёт окончания обработки всех текущих запросов, потом выключает сервер, потом аккуратно закрывает соединение с базой данных и только потом окончательно завершает свою работу. Такое "аккуратное" безопасное завершение работы компонентов, кстати, называется Graceful shutdown.


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


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

DI не нужен или нужен только в Java


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


Теперь перейдём к практике.


Замечание


Данная статья не преследует цель предоставить исчерпывающую документацию по представленным библиотекам и утилитам, поэтому мной были выбраны максимально упрощенные примеры кода, просто чтобы продемонстрировать концептуальную разницу в рассматриваемых подходах. Естественно, все упомянутые инструменты умеют обрабатывать ошибки, возвращаемые конструкторами и обладают множеством дополнительных возможностей, соответствующие примеры можно найти в их документации.
Используемые примеры кода доступны на https://github.com/vivid-money/article-golang-di.


Ещё замечание


Для всех примеров я буду использовать простенькую иерархию, состоящую из трех компонентов, Logger это интерфейс, написанный под логгер из стандартной библиотеки, DBConn будет изображать соединение с базой данных, а HTTPServer, логично, сервер, слушающий определённый порт и производящий некий (фейковый) запрос к базе данных. Соответственно, инициализироваться и запускаться они должны в порядке Logger->DBConn->HTTPServer, а завершаться в обратном порядке.
Для демонстрации работы с блокирующимися и неблокирубщимися компонентами, DBConn не требует постоянной работы (просто необходимо один раз вызвать DBConn.Connect()), а httpServer.Serve, напротив, блокирует текущий поток исполнения.


Reflection based container


Начнём с распространенного в других языках варианта, который в мире го в основном представлен пакетами https://github.com/uber-go/dig и расширяющим его https://github.com/uber-go/fx.
Идея проста, граф зависимостей можно легко динамически описать в рантайме, там же к каждому из компонентов можно привязать хуки на старт и завершение работы. Посмотрим, как это выглядит на простом примере:


// Логгер в качестве исключения создадим заранее, потому что как правило что-то нужно писать в логи сразу, ещё до инициализации графа зависимостей.logger := log.New(os.Stderr, "", 0)logger.Print("Started")container := dig.New() // создаём контейнер// Регистрируем конструкторы.// Dig во время запуска программы будет использовать рефлексию, чтобы по сигнатуре каждой функции понять, что она создаёт и что для этого требует._ = container.Provide(func() components.Logger {    logger.Print("Provided logger")    return logger // Прокинули уже созданный логгер.})_ = container.Provide(components.NewDBConn)_ = container.Provide(components.NewHTTPServer)_ = container.Invoke(func(_ *components.HTTPServer) {    // Вызвали HTTPServer, как "корень" графа зависимостей, чтобы прогрузилось всё необходимое.    logger.Print("Can work with HTTPServer")    // Никаких средств для управления жизненным циклом нет, пришлось бы всё писать вручную.})/*    Output:    ---    Started    Provided logger    New DBConn    New HTTPServer    Can work with HTTPServer*/

Также fx предоставляет возможность работать непосредственно с жизненным циклом приложения:


ctx, cancel := context.WithCancel(context.Background())defer cancel()// Логгер в качестве исключения создадим заранее, потому что как правило что-то нужно писать в логи сразу, ещё до// инициализации графа зависимостей.logger := log.New(os.Stderr, "", 0)logger.Print("Started")// На этот раз используем fx, здесь уже у нас появляется объект "приложения".app := fx.New(    fx.Provide(func() components.Logger {        return logger // Добавляем логгер как внешний компонент.    }),    fx.Provide(        func(logger components.Logger, lc fx.Lifecycle) *components.DBConn { // можем получить ещё и lc - жизненный цикл.            conn := components.NewDBConn(logger)            // Можно навесить хуки.            lc.Append(fx.Hook{                OnStart: func(ctx context.Context) error {                    if err := conn.Connect(ctx); err != nil {                        return fmt.Errorf("can't connect to db: %w", err)                    }                    return nil                },                OnStop: func(ctx context.Context) error {                    return conn.Stop(ctx)                },            })            return conn        },        func(logger components.Logger, dbConn *components.DBConn, lc fx.Lifecycle) *components.HTTPServer {            s := components.NewHTTPServer(logger, dbConn)            lc.Append(fx.Hook{                OnStart: func(_ context.Context) error {                    go func() {                        defer cancel()                        // Ассинхронно запускаем сервер, т.к. Serve - блокирующая операция.                        if err := s.Serve(context.Background()); err != nil && !errors.Is(err, http.ErrServerClosed) {                            logger.Print("Error: ", err)                        }                    }()                    return nil                },                OnStop: func(ctx context.Context) error {                    return s.Stop(ctx)                },            })            return s        },    ),    fx.Invoke(        // Конструкторы - "ленивые", так что нужно будет вызвать корень графа зависимостей, чтобы прогрузилось всё необходимое.        func(*components.HTTPServer) {            go func() {                components.AwaitSignal(ctx) // ожидаем сигнала, чтобы после этого завершить приложение.                cancel()            }()        },    ),    fx.NopLogger,)_ = app.Start(ctx)<-ctx.Done() // ожидаем завершения контекста в случае ошибки или получения сигнала_ = app.Stop(context.Background())/*    Output:    ---    Started    New DBConn    New HTTPServer    Connecting DBConn    Connected DBConn    Serving HTTPServer    ^CStop HTTPServer    Stopped HTTPServer    Stop DBConn    Stopped DBConn*/

Может возникнуть вопрос, должен ли метод Serve быть блокирующим (по аналогии с ListenAndServe) или нет? Моя точка зрения на это проста: сделать блокирующий метод неблокирующим очень просто (go blockingFunc()), а вот обратное очень сложно. Так как любой код должен в том числе и облегчать работу с собой тем, кто его использует, логичнее всего предоставлять синхронный код, а ассинхронным его пусть сделает вызывающий, если ему это понадобится.


Возвращаясь к fx, в особенно сложных ситуациях можно использовать разнообразные специальные типы (fx.In, fx.Out и тд) и аннотации (optional, name и тд), позволяющие компонентам, зависящим от одинаковых интерфейсов, получать различные зависимости или просто связывать что-то по кастомным именам.
Также доступны хелперы, дающие дополнительные возможности, например, fx.Supply позволяет добавить в контейнер уже инициализированный объект в случае, если вы по какой-то причине не хотите его инициализировать используя сам контейнер, но хотите использовать его для других компонентов.


Такой "динамический" подход имеет свои плюсы:


  • Нет нужды поддерживать порядок, мы просто регистрируем конструкторы, а потом обращаемся к нужным интерфейсам и всё происходит самостоятельно, "волшебным образом". Соответственно, проще добавлять новый код;
  • За счёт динамического построения графа зависимостей, легко как подменять какие-то части на моки, так и вовсе тестировать отдельные части приложения;
  • Можно запросто использовать любые внешние библиотеки, просто добавив их конструкторы в контейнер;
  • Позволяет писать меньше кода;
  • Не требует xml или yaml;

Минусы:


  • Больше магии, сложнее разбираться с проблемами;
  • Поскольку контейнер собирается динамически, в рантайме, то мы теряем compile-time гарантии узнать о многих проблемах с зависимостями (например, забыли что-то зарегистрировать) можно только запустив приложение, иногда в особой конфигурации. Отчасти надёжность можно было бы повысить тестами, но именно гарантий такой подход всё равно не даст.
  • Конкретно для fx:
    • Нет возможностей обрабатывать ошибки работы компонентов (когда Serve внезапно прекращает работу и возвращает ошибку), придётся писать свои велосипеды, благо, это дело не самое сложное;


Кодогенерация


Остальные способы основываются на статическом коде и первым из них на ум приходит кодогенерация, которая в go представлена преимущественно https://github.com/google/wire за авторством всем известной компании.
Из самого названия этого подхода логично следует, что вместо того, чтобы резолвить зависимости динамически, мы сгенерируем явный статический и типизированный код. Таким образом, в случае ошибки на уровне графа зависимостей он или не сгенерируется, или не скомпилируется, соответственно, мы получаем compile-time гарантии решения зависимостей.
При таком подходе весь вопрос заключается в том, как именно мы будем описывать наш граф зависимостей, чтобы потом сгенерировать для него код. В разных языках для описания связей в коде используются различные средства, от аннотаций до конфигурационных файлов, но, поскольку в мире го аннотаций не существует, а магические комментарии это вещь очень спорная и обладает известными недостатками, разработчики в итоге остановились на конфигурировании кодом. Выглядит это следующим образом:


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


// +build wireinjectpackage mainimport (    "context"    "github.com/google/wire"    "github.com/vivid-money/article-golang-di/pkg/components")func initializeHTTPServer(    _ context.Context,    _ components.Logger,    closer func(), // функция, которая вызовет остановку всего приложения) (    res *components.HTTPServer,    cleanup func(), // функция, которая остановит приложение    err error,) {    wire.Build(        NewDBConn,        NewHTTPServer,    )    return &components.HTTPServer{}, nil, nil}

В итоге, после вызова одноименной утилиты wire (можно делать это через go generate), wire просканирует ваш код, найдёт все вызовы wire и сгенерирует файл с кодом, который проводит все инжекты:


func initializeHTTPServer(contextContext context.Context, logger components.Logger, closer func()) (*components.HTTPServer, func(), error) {    dbConn, cleanup, err := NewDBConn(contextContext, logger)    if err != nil {        return nil, nil, err    }    httpServer, cleanup2 := NewHTTPServer(contextContext, logger, dbConn, closer)    return httpServer, func() {        cleanup2()        cleanup()    }, nil}

Соответственно мы можем сразу же вызывать initializeHTTPServer при старте нашего приложения и использовать сгенерированный код, который создаст и "прокинет" куда надо все зависимости:


package main//go:generate wireimport (    "context"    "fmt"    "log"    "os"    "errors"    "net/http"    "github.com/vivid-money/article-golang-di/pkg/components")// Поскольку wire не поддерживает lifecycle (точнее, поддерживает только Cleanup-функции), а мы не хотим// делать вызовы компонентов в нужном порядке руками, то придётся написать специальные врапперы для конструкторов,// которые при этом будут при создании компонента начинать работу и возвращать cleanup-функцию для его остановки.func NewDBConn(ctx context.Context, logger components.Logger) (*components.DBConn, func(), error) {    conn := components.NewDBConn(logger)    if err := conn.Connect(ctx); err != nil {        return nil, nil, fmt.Errorf("can't connect to db: %w", err)    }    return conn, func() {        if err := conn.Stop(context.Background()); err != nil {            logger.Print("Error trying to stop dbconn", err)        }    }, nil}func NewHTTPServer(    ctx context.Context,    logger components.Logger,    conn *components.DBConn,    closer func(),) (*components.HTTPServer, func()) {    srv := components.NewHTTPServer(logger, conn)    go func() {        if err := srv.Serve(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {            logger.Print("Error serving http: ", err)        }        closer()    }()    return srv, func() {        if err := srv.Stop(context.Background()); err != nil {            logger.Print("Error trying to stop http server", err)        }    }}func main() {    ctx, cancel := context.WithCancel(context.Background())    defer cancel()    // Логгер в качестве исключения создадим заранее, потому что как правило что-то нужно писать в логи сразу, ещё до инициализации графа зависимостей.    logger := log.New(os.Stderr, "", 0)    logger.Print("Started")    // Нужен способ остановить приложение по команде или в случае ошибки. Не хочется отменять "главный" кониекси, так    // как он прекратит все Server'ы одновременно, что лишит смысла использование cleanup-функций. Поэтому мы будем    // делать это на другом контексте.    lifecycleCtx, cancelLifecycle := context.WithCancel(context.Background())    defer cancelLifecycle()    // Ничего не делаем с сервером, потому что вызываем Serve в конструкторах.    _, cleanup, _ := initializeHTTPServer(ctx, logger, func() {        cancelLifecycle()    })    defer cleanup()    go func() {        components.AwaitSignal(ctx) // ждём ошибки или сигнала        cancelLifecycle()    }()    <-lifecycleCtx.Done()    /*        Output:        ---        New DBConn        Connecting DBConn        Connected DBConn        New HTTPServer        Serving HTTPServer        ^CStop HTTPServer        Stopped HTTPServer        Stop DBConn        Stopped DBConn    */}

Плюсы такого подхода:


  • Очень явный и предсказуемый код;
  • Гарании на уровне компиляции;
  • Всё ещё не нужно ничего собирать руками;
  • Конфигурация выглядит достаточно минималистично, мы просто обозначаем интерфейсы и вызываем магическую функцию wire.Build;
  • Всё ещё никаких xml;
  • Wire предоставляет возможность возвращать кроме каждого из компонентов ещё и cleanup-функции, что удобно.

Однако есть и минусы:


  • Приходится делать лишние телодвижения, даже описание графа через инжекторы всё-таки занимает место;
  • Тяжелее использовать для тестов и моков, из-за отстутствия явных инструментов работы с абстрактными зависимостями; Это конечно решаемо, например, инжектом конструкторов, но всё равно тянет "лишние" сложности;
  • Конкретно для wire (нужно учитывать, что он ещё в бете):
    • Не умеет соотносить конструктор, возвращающий конкретный объект с зависимостью от интерфейса, если он этот объект реализует;

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

    • По той же причине приходится изобретать свой велосипед для остановки приложения в случае "падения" одного из компонентов;

    • Cleanup'функции вызываются просто по порядку, если в процессе одной из них произойдёт паника, то остальные не вызовутся.


Собираем граф руками


Для пришедших из других языков это могло бы звучать дико, но на самом деле вам не нужны серьёзные и сложные инструменты для того, чтобы управлять небольшим (или большим, но стабильным) графом зависимостей. Если это вызывает проблемы, то, конечно, лучше взять wire или dig/fx, но я могу вас уверить, что проблем с таким подходом у вас будет значительно меньше, чем вам кажется (или не будет вообще).
Одной из причин этому будет отсутствие у гошников манеры создавать избыточное количество компонентов (вместо отдельных классов-фабрик или даже фабрик-для-фабрик обычно создаётся простая функция-конструктор), другой некоторые специфические возможности го.


Так вот, давайте представим простой код, который сделает все необходимые инжекты:


logger := log.New(os.Stderr, "", 0)dbConn := components.NewDBConn(logger)httpServer := components.NewHTTPServer(logger, dbConn)doSomething(httpServer)

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


Используем errgroup.


Выглядит оно вот так:


func main() {    ctx, cancel := context.WithCancel(context.Background())    defer cancel()    logger := log.New(os.Stderr, "", 0)    logger.Print("Started")    g, gCtx := errgroup.WithContext(ctx)    dbConn := components.NewDBConn(logger)    g.Go(func() error {        // dbConn умеет останавливаться по отмене контекста.        if err := dbConn.Connect(gCtx); err != nil {            return fmt.Errorf("can't connect to db: %w", err)        }        return nil    })    httpServer := components.NewHTTPServer(logger, dbConn)    g.Go(func() error {        go func() {            // предположим, что httpServer (как и http.ListenAndServe, кстати) не умеет останавливаться по отмене            // контекста, тогда придётся добавить обработку отмены вручную.            <-gCtx.Done()            if err := httpServer.Stop(context.Background()); err != nil {                logger.Print("Stopped http server with error:", err)            }        }()        if err := httpServer.Serve(gCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {            return fmt.Errorf("can't serve http: %w", err)        }        return nil    })    go func() {        components.AwaitSignal(gCtx)        cancel()    }()    _ = g.Wait()    /*        Output:        ---        Started        New DBConn        New HTTPServer        Connecting DBConn        Connected DBConn        Serving HTTPServer        ^CStop HTTPServer        Stop DBConn        Stopped DBConn        Stopped HTTPServer        Finished serving HTTPServer    */}

Как это работает?
Мы запускаем все компоненты нашего приложения в отдельных горутинах, но при этом запускаем не вручную, а через специальную структуру g, которая:


  1. Будет считать запущенные через неё функции (чтобы потом дождаться всех);
  2. Предоставляет собственный контекст с возможностью отмены (получаем иерархию ctx.cancel->gCtx.cancel для каждой конечной функции);
  3. Будет внимательно смотреть на результаты функций, если хоть одна из них завершится ошибкой то отменит свой контекст, в результате чего все функции смогут получить сигнал отмены через переданные им gCtx и завершить свою работу.

Такая схема в целом неплоха, но я нахожу в ней определённый фатальный недостаток: errgroup заставляет положиться на событие отмены контекста. Такой подход не гарантирует порядка отмены каждой из функций, каждая из них может проверить переданный ей gCtx на .Done() в любой удобный для неё момент и в итоге мы теоретически можем получить ситуацию, когда у вас соединение с базой получило cancel и завершилось до того, как какой-то более высокоуровневый компонент (например, обрабатывающий важный сетевой запрос) завершил свою работу.
Кроме того:


  • errgroup возвращает только первую ошибку, остальные игнорирует;
  • errgroup отменяет контекст только в том случае, если какой-то из компонентов вернул ошибку. Если же по какой-то причине некий компонент завершится без ошибки, то система не отреагирует, продолжив работать, как ни в чём не бывало. Да, это можно исправить каким-нибудь велосипедом, но в таком случае зачем мы вообще что-то брали, если потом всё равно придётся дописывать?

Следующий способ это самописный lifecycle.


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


ctx, cancel := context.WithCancel(context.Background())defer cancel()logger := log.New(os.Stderr, "", 0)logger.Print("Started")lc := lifecycle.NewLifecycle()dbConn := components.NewDBConn(logger)lc.AddServer(func(ctx context.Context) error { // просто регистриуем в правильном порядке серверы и шатдаунеры    return dbConn.Connect(ctx)}).AddShutdowner(func(ctx context.Context) error {    return dbConn.Stop(ctx)})httpSrv := components.NewHTTPServer(logger, dbConn)lc.Add(httpSrv) // потому что httpSrv реализует интерфейсы Server и Shutdownergo func() {    components.AwaitSignal(ctx)    lc.Stop(context.Background())}()_ = lc.Serve(ctx)

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


Способ финальный


Существуй мы в мире Java или где-то ещё, то остановились бы на предыдущем варианте, поскольку отслеживать порядок инициализации, запуска и остановки сервисов "руками" звучит, как очень неблагодарная работа без права на ошибку.
Но в го есть три удобных инструмента, которые значительно облегчают это дело.
Про горутины в курсе, вероятно, все, кто хоть чуть-чуть этим интересовался, и если вы не в их числе, то вряд ли вы поняли предыдущие примеры кода, так что я не стану добавлять пояснения, тем более, что это вопрос буквально одного абзаца из первой же ссылки в гугле.
Второй такой удобный инструмент, это контекст, некий "волшебный" интерфейс, который принимает, наверное, уже почти любая функция в го и который кроме всего прочего предоставляет функциям возможность узнать, был ли данный контекст отменён (или отменить его самостоятельно для нижележащих функций). В результате такой механизм даёт нам контроль, позволяя каскадно завершать работу функции или группы функций в том числе и из main-функции.
Третий удобный и чуть менее очевидный инструмет, defer, является просто ключевым словом, добавляющим в некий стек текушей функции другую функцию, которая должна быть выполнена после завершения текущей.
А это означает, что во-первых, после defer'а можно делать сколько угодно return'ов не боясь, что где-то забудешь разблокировать мьютекс или закрыть файл (кстати, очень способствует сокращению ветвлений в коде), а во-вторых, они вызываются в обратном порядке. Можно вызывать конструкторы и каждый раз при вызове регистрировать деструктор и они вызовутся сами, по очереди, в правильном порядке с точки зрения графа зависимостей, не требуя никаких дополнительных инструментов:


a, err := NewA()if err != nil {    panic("cant create a: " + err.Error())}go a.Serve()defer a.Stop()b, err := NewB(a)if err != nil {    panic("cant create b: " + err.Error())}go b.Serve()defer b.Stop()/*    Порядок старта: A, B    Порядок остановки: B, A*/

Правда, остаётся ещё вопрос обработки ошибок, а также возврата первоначальной ошибки (что необязательно, но мне нравится делать именно так). Дело не обойдётся без трех маленьких хелперов:


  • ErrSet хранилище ошибок для их использования на уровне старта/остановки приложения;
  • Serve получает контекст и функцию-server, стартует этот server в отдельной горутине и при этом возвращает новый контекст, обернутый в WithCancel, вызываемый при завершении функции-server'а (что позволяет прекратить запуск приложения на середине, если один из предыдущих server'ов завершился);
  • Shutdown просто вызывает функцию и пишет возможную ошибку в ErrSet, потому что когда приложение уже завершается, нет необходимости как-либо отдельно обрабатывать ошибки завершения компонентов;

В итоге, код будет выглядеть так:


package mainimport (    "context"    "fmt"    "log"    "os"    "errors"    "net/http"    "github.com/vivid-money/article-golang-di/pkg/components")func main() {    ctx, cancel := context.WithCancel(context.Background())    defer cancel()    logger := log.New(os.Stderr, "", 0)    logger.Print("Started")    go func() {        components.AwaitSignal(ctx)        cancel()    }()    errset := &ErrSet{}    errset.Add(runApp(ctx, logger, errset))    _ = errset.Error() // можно обработать ошибку    /*        Output:        ---        Started        New DBConn        Connecting DBConn        Connected DBConn        New HTTPServer        Serving HTTPServer        ^CStop HTTPServer        Stop DBConn        Stopped DBConn        Stopped HTTPServer        Finished serving HTTPServer    */}func runApp(ctx context.Context, logger components.Logger, errSet *ErrSet) error {    var err error    dbConn := components.NewDBConn(logger)    if err := dbConn.Connect(ctx); err != nil {        return fmt.Errorf("cant connect dbConn: %w", err)    }    defer Shutdown("dbConn", errSet, dbConn.Stop)    httpServer := components.NewHTTPServer(logger, dbConn)    if ctx, err = Serve(ctx, "httpServer", errSet, httpServer.Serve); err != nil && !errors.Is(err, http.ErrServerClosed) {        return fmt.Errorf("cant serve httpServer: %w", err)    }    defer Shutdown("httpServer", errSet, httpServer.Stop)    components.AwaitSignal(ctx)    return ctx.Err()}

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


Что нам даёт такой подход?


  • Добавление компонентов происходит как и раньше, копипастом магических четырех слов New-Serve-defer-Shutdown (будь у нас дженерики, кстати, можно было бы ещё набросать простенький хелпер, чтобы было ещё меньше кода и совсем симпатично);
  • Поскольку при таком подходе вы можете инициализировать компоненты только в том порядке, в каком они зависят друг от друга, то ошибка, при которой вы начнёте или завершите работу компонентов в неправильном порядке сведена к нулю;
  • Ошибка в середине инициализации сервиса приводит к досрочному завершению приложения;
  • Завершение работы компонентов происходит в правильной (с точки зрения порядка зависимостей) последовательности;
  • Ошибка работы случайного компонента приведет к завершению приложения, но последовательность завершения всё равно останется правильной, от конца к началу;
  • Мы 100% дождёмся окончания всех компонентов, прежде, чем завершить приложение;
  • Весь код, осуществляющий работу жизненного цикла, описан очень явно и не содержит никакий магии;

Недостатки


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

Выводы


Самой лучшей практикой всегда остаётся выбор подходящего инструмента под определённую задачу.
Все рассмотренные мной решения имеют свои достоинства и недостатки, как сами по себе, так и применительно к специфике разработки на golang.
Описанный первым fx несмотря на свою некоторую неидиоматичность (в контексте go), выглядит хорошо проработанными и решает практически все необходимые задачи, а что не решает несложно дописать руками.
Wire несмотря на громкое имя создателей выглядит сыроватым и несколько недоработанным, но при этом безусловно идиоматичен и в состоянии продемонстрировать преимущества кодогенерации.
При этом инжекты руками не выглядят (да и не являются, по моему опыту) особенно болезненными, а все необходимые задачи можно решить с помощью стандартных go, context, defer и пары хелперов минимального размера.
Важнейшим делом всегда является архитектура, правильное моделирование предметной области и правильное разделение логики приложения на части с правильными зонами ответственности, а вопрос автоматизации инжектов зависимостей не является критичным, до определённого размера или определённой сложности. Лично я бы до действительно сотен компонентов без проблем использовал подход сбора графа зависимостей руками, а уже потом присмотрелся к wire (может, к тому времени он научиться решать вообще все задачи, решения которых хотелось бы от него ожидать).

Подробнее..

Асинхронное взаимодействие. Брокеры сообщений. Apache Kafka

24.12.2020 18:19:54 | Автор: admin
Данная публикация предназначена для тех, кто интересуется устройством распределенных систем, брокерами сообщений и Apache Kafka.
Здесь вы не найдете эксклюзивного материала или лайфхаков, задача этой статьи заложить фундамент и рассказать о внутреннем устройстве упомянутого брокера. Таким образом, в следующих публикациях мы сможем делать ссылки на данную статью, рассказывая о более узкоспециализированных темах.


Привет! Меня зовут Дмитрий Шеламов и я работаю в Vivid.Money на должности backend-разработчика в отделе Customer Care. Наша компания европейский стартап, который создает и развивает сервис интернет-банкинга для стран Европы. Это амбициозная задача, а значит и ее техническая реализация требует продуманной инфраструктуры, способной выдерживать высокие нагрузки и масштабироваться согласно требованиям бизнеса.

В основе проекта лежит микросервисная архитектура, которая включает в себя десятки сервисов на разных языках. В их числе Scala, Java, Kotlin, Python и Go. На последнем я пишу код, поэтому практические примеры, приведенные в этой серии статей, будут задействовать по большей части Go (и немного docker-compose).

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

Асинхронное взаимодействие


Итак, представим что у нас есть два микросервиса (А и Б). Будем считать, что коммуникация между ними осуществляется через API и они ничего не знают о внутренней реализации друг друга, как и предписывает микросервисный подход. Формат передаваемых между ними данных заранее оговорен.

image

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

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

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

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

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

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

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

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

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


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

Однако выбор СУБД в качестве инструмента для обмена данными может привести к проблемам с производительностью с ростом нагрузки. Причина в том, что большинство баз данных не предназначены для такого сценария использования. Также во многих СУБД отсутствует возможность разделения подключенных клиентов на получателей и отправителей (Pub/Sub) в этом случае, логика доставки данных должна быть реализована на клиентской стороне.
Вероятно, нам нужно нечто более узкоспециализированное, чем база данных.

Брокеры сообщений


Брокер сообщений (очередь сообщений) это отдельный сервис, который отвечает за хранение и доставку данных от сервисов-отправителей к сервисам-получателям с помощью модели Pub/Sub.
Эта модель предполагает, что асинхронное взаимодействие осуществляется согласно следующей логике двух ролей:

  • Publishers публикуют новую информацию в виде сгруппированных по некоторому атрибуту сообщений;
  • Subscribers подписываются на потоки сообщений с определенными атрибутами и обрабатывают их.

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

image

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

В общем случае, тело представляет из себя набор байт определенного формата.
Получатель обязательно должен знать этот формат, чтобы после получения сообщения иметь возможность десериализовать его тело для дальнейшей обработки.
Использовать можно любой удобный формат, однако, важно помнить об обратной совместимости, которую поддерживают, например, бинарный Protobuf и фреймворк Apache Avro.

По такому принципу работает большинство брокеров сообщений, построенных на AMQP (Advanced Message Queuing Protocol) протоколе, который описывает стандарт отказоустойчивого обмена сообщениями посредством очередей.
Данный подход обеспечивает нам несколько важных преимуществ:

  • Слабая связанность. Она достигается за счет асинхронной передачи сообщений: то есть, отправитель скидывает данные и продолжает работать, не дожидаясь ответа от получателя, а получатель вычитывает и обрабатывает сообщения, когда удобно ему, а не когда они были отправлены. В данном случае очередь можно сравнить с почтовым ящиком, в который почтальон кладет ваши письма, а вы их забираете, когда удобно вам.
  • Масштабируемость. Если сообщения появляются в очереди быстрее, чем консьюмер успевает их обрабатывать (речь идет не о пиковых нагрузках, а о стабильном разрыве между скоростью записи и обработки), мы можем запустить несколько экземпляров приложения-консьюмера и подписать их на одну очередь.
    Этот подход называется горизонтальным масштабированием, а экземпляры одного сервиса принято называть репликами. Реплики сервиса-консьюмера будут читать сообщения из одной очереди и обрабатывать их независимо друг от друга.
  • Эластичность. Наличие между приложениями такой прослойки, как очередь, помогает справляться с пиковыми нагрузками: в этом случае очередь будет выступать буфером, в котором сообщения будут копиться и по мере возможности считываться консьюмером, вместо того, чтобы ронять приложение-получатель, отправляя данные ему напрямую.
  • Гарантии доставки. Большинство брокеров предоставляют гарантии at least once и at most once.


At most once исключает повторную обработку сообщений, однако допускает их потерю. В этом случае брокер будет доставлять сообщения получателям по принципу отправил и забыл. Если получатель не смог по какой-то причине обработать сообщение с первой попытки, брокер не будет осуществлять переотправку.

At least once, напротив, гарантирует получение сообщения получателем, однако при этом есть вероятность повторной обработки одних и тех же сообщений.
Зачастую эта гарантия достигается с помощью механизма Ack/Nack (acknowledgement/negative acknowledgement), который предписывает совершать переотправку сообщения, если получатель по какой-то причине не смог его обработать.
Таким образом, для каждого отправленного брокером (но еще не обработанного) сообщения существует три итоговых состояния получатель вернул Ack (успешная обработка), вернул Nack (неуспешная обработка) или разорвал соединение.
Последние два сценария приводят в переотправке сообщения и повторной обработке.

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

Стоит отметить, что существует еще одна гарантия доставки, которая называется exactly once. Ее трудно достичь в распределенных системах, но при этом она же является наиболее желаемой.
В этом плане, Apache Kafka, о которой мы будем говорить далее, выгодно выделяется на фоне многих доступных на рынке решений. Начиная с версии 0.11, Kafka предоставляет гарантию доставки exactly once в пределах кластера и транзакций, в то время как AMQP-брокеры таких гарантий предоставить не могут.
Транзакции в Кафке тема для отдельной публикации, сегодня же мы начнем со знакомства с Apache Kafka.

Apache Kafka


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

image

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

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

Commit log


Структура данных, лежащая в основе Kafka, называется commit log или журнал фиксации изменений.

image

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

Свойство упорядоченности журнала фиксаций позволяет использовать его, например, для репликации по принципу eventual consistency между репликами БД: в них хранят журнал изменений, производимых над данными в мастер-ноде, последовательное применение которых на слейв-нодах позволяет привести данные в них к согласованному с мастером виду.
В Кафке эти журналы называются партициями, а данные, хранимые в них, называются сообщениями.
Что такое сообщение? Это основная единица данных в Kafka, представляющая из себя просто набор байт, в котором вы можете передавать произвольную информацию ее содержимое и структура не имеют значения для Kafka.
Сообщение может содержать в себе ключ, так же представляющий из себя набор байт. Ключ позволяет получить больше контроля над механизмом распределения сообщений по партициям.

Партиции и топики


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

image

Так вот в Кафке функцию очереди выполняет не партиция, а topic. Он нужен для объединения нескольких партиций в общий поток. Сами же партиции, как мы сказали ранее, хранят сообщения в упорядоченном виде согласно структуре данных commit log.
Таким образом, сообщение, относящееся к одному топику, может хранится в двух разных партициях, из которых читатели могут вытаскивать их по запросу.

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

Pull и Push


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

image

Производители, формируя сообщения, прикрепляют к нему ключ и номер партиции. Номер партиции может быть выбран рандомно (round-robin), если у сообщения отсутствует ключ.

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

Каждый получатель закреплен за определенной партицией (или за несколькими партициями) в интересующем его топике, и при появлении нового сообщения получает сигнал на вычитывание следующего элемента в commit log, при этом отмечая, какое последнее сообщение он прочитал. Таким образом при переподключении он будет знать, какое сообщение ему вычитать следующим.

Какие преимущества имеет данный подход?


  • Персистентность. В классических брокерах сообщение хранится в памяти брокера ровно до того момента, как брокер получит сигнал об успешной обработке сообщения читателем, который это сообщение вытащил из очереди. В Кафке же сообщения хранятся столько, сколько нужно (в зависимости от Retention Policy, об этом позднее), а значит из одной партиции одновременно могут читать сообщения несколько получателей.
  • Message Replay. Читатели могут перечитывать сообщения сколько угодно раз, начиная с произвольного места в партиции. Это может быть полезно, например, для восстановления данных на стороне читателя при потере части изменений в БД.
  • Упорядоченность. Она гарантируется в том числе потому, что нет механизма переотправки (в силу ненадобности) в обычных брокерах в процессе доставки переотправлямые сообщения постоянно перетасовываются в очереди, так как они закидываются в нее снова после каждой неудачной попытки их обработать.
  • Чтение и запись пачками. Читатель может читать сообщения пачками (batch) из одной партиции, а не по отдельности, как это происходит с обычными брокерами. Это бывает полезно для уменьшения сетевой задержки: при передаче большого количества сообщений (1кк и выше), гонять по сети каждое сообщение отдельно становится дорого.


Недостатки


К недостаткам данного подхода можно отнести работу с проблемными сообщениями. В отличие от классических брокеров, битые сообщения (которые не удается обработать с учетом существующей логики получателя или из-за проблем с десериализацей) нельзя бесконечно перезакидывать в очередь, пока получатель не научится их корректно обрабатывать.
В Кафке по умолчанию вычитывание сообщений из партиции останавливается, когда получатель доходит до битого сообщения, и до тех пор, пока оно не будет пропущено и закинуто в карантинную очередь (также именуемой dead letter queue) для последующей обработки, чтение партиции продолжить не получится.

Также в Кафке сложнее (в сравнении с AMQP-брокерами) реализовать приоритет сообщений. Это напрямую вытекает из того факта, что сообщения в партициях хранятся и читаются строго в порядке их добавления. Один из способов обойти данное ограничение в Кафке создать нескольких топиков под сообщения с разным приоритетом (отличаться топики будут только названием), например, events_low, events_medium, events_high, а затем реализовать логику приоритетного чтения перечисленных топиков на стороне приложения-консьюмера.

Еще один недостаток данного подхода связан тем, что необходимо вести учет последнего прочитанного сообщения в партиции каждым из читателей.
В силу простоты структуры партиций, эта информация представлена в виде целочисленного значения, именуемого offset (смещение). Оффсет позволяет определить, какое сообщение в данный момент читает каждый из читателей. Ближайшая аналогия оффсета это индекс элемента в массиве, а процесс чтения похож на проход по массиву в цикле с использованием итератора в качестве индекса элемента.
Однако этот недостаток нивелируется за счет того, что Kafka, начиная с версии 0.9, хранит оффсеты по каждому пользователю в специальном топике __consumer_offsets (до версии 0.9 оффсеты хранились в ZooKeeper).
К тому же, вести учет оффсетов можно непосредственно на стороне получателей.

image

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

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

Consumer Group


Чтобы избежать ситуации с чтением одной партиции конкурентными читателями, в Кафке принято объединять несколько реплик одного сервиса в consumer Group, в рамках которого Zookeeper будет назначать одной партиции не более одного читателя.

Так как читатели привязываются непосредственно к партиции (при этом читатель обычно ничего не знает о количестве партиций в топике), ZooKeeper при подключении нового читателя производит перераспределение участников в Consumer Group таким образом, чтобы каждая партиция имела одного и только одного читателя.
Читатель обозначает свою Consumer Group при подключении к Kafka.

image

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

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

image

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

Retention Policy


Теперь пришло время поговорить о Retention Policy.
Это настройка, которая отвечает за удаление сообщений с диска при превышении пороговых значений даты добавления (Time Based Retention Policy) или занимаемого на диске пространства (Size Based Retention Policy).

  • Если вы настроите TBRP на 7 суток, то все сообщения старше 7 суток будут помечаться для последующего удаления. Иными словами, эта настройка гарантирует, что в каждый момент времени будут доступны для чтения сообщения младше порогового возраста. Можно задавать в часах, минутах и милисекундах.
  • SBRP работает аналогичным образом: при превышении порога занимаемого дискового пространства, сообщения будут помечаться для удаления с конца (более старые). Нужно иметь в виду: так как удаление сообщений происходит не мгновенно, занимаемый объем диска всегда будет чуть больше указанного в настройке. Задается в байтах.


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

Compaction Policy


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

Сценарии использования Kafka


  • Отслеживание действий пользователей на клиентской части. При этом логгируемая информация может быть самой разной: от списка просмотренных страниц до щелчков мыши. Сообщения о действиях публикуются в один или несколько топиков, где потребителем может выступать, например, хранилище аналитических данных (Clickhouse можно подписать непосредственно на топик Кафки!) для дальнейшего построения отчетов или рекомендательных систем.
    В Customer Care отделе Vivid.Money мы используем топик Кафки для доставки в аналитическое хранилище логов о действиях операторов в нашей CRM.
  • Обмен сообщениями. Кафка может выступать этаким единым интерфейсом для отправки различных уведомлений, пушей или электронных писем во всем проекте. Любой сервис может подключиться к Кафке и отправить сообщение в определенный топик для уведомлений, из которого на той стороне сервис-консьюмер (имеющий доступ к контактной информации клиентов) его считает, преобразует в формат пригодный для отправки нотификации непосредственно клиенту, и осуществит фактическую отправку.
    Благодаря этому мы можем отправить пуш буквально из любой части нашей инфраструктуры, без необходимости получения контактных данных пользователя (и его предпочтений по способу связи) в инициирующем отправку нотификации сервисе. В свою свою очередь, успешно получив сообщение, Кафка гарантирует то, что оно будет доставлено клиенту, даже если на стороне сервиса нотификаций возникли неполадки.
  • Мониторинг. Через топики кафки можно организовать сбор и агрегацию логов и метрик для их централизованной обработки, используя ее как транспорт.
  • Журнал фиксации (commit log). Можно дублировать в топик транзакции БД, чтобы сервисы-потребители синхронизировали состояние связанных данных уже в своих базах/сторонних системах.
    Опять же, долгосрочное хранение сообщений позволяет выступать Кафке этаким буфером для изменений, который позволяет переиграть изменения из топика Кафки для приведения данных на стороне получателя к согласованному виду в случае сбоев приложений получателей или повреждению данных в их БД.
    По такому принципу у нас в Customer Care организована синхронизация данных профиля клиента в используемых нами CRM-системах с изменениями данных пользователей в наших внутренних базах.


Подытожим основные преимущества Kafka


  • В один топик может писать один или несколько производителей идеально для агрегирования данных из большого количества источников, что становится особенно полезно при использовании Кафки в качестве системы доставки сообщений в микросервисной архитектуре;
  • Несколько потребителей с учетом особенностей механизма получения сообщений (pull) один и тот же поток сообщений может читать несколько потребителей, не мешая при этом друг другу.
    При этом конкурентных читателей (например, реплики одного сервиса) можно объединить в Consumer Group, а ZooKeeper, в свою очередь, будет следить, чтобы каждая партиция одновременно читалась не более, чем одним участником каждой группы;
  • Хранение данных на диске в течение длительного времени позволяет не беспокоится о потере данных при резком росте нагрузки. Кафка, будучи своего рода буфером, компенсирует отставание потребителей, позволяя накапливать в себе сообщения до нормализации нагрузки или масштабирования консьюмеров. Также обеспечивается гибкая конфигурация, где отдельные потоки данных (топики) хранятся на диске с разным сроком;
  • Хорошо масштабируется, засчет меньшей, в сравнении с AMQP брокерами, единицей параллелизма партицией. Разные партиции могут храниться в разных брокерах, обеспечивая дополнительную гибкость при горизонтальном масштабировании;
  • Быстродействие. В силу простоты механизма, при которой процесса доставки нет как такового, а процесс передачи данных представляет из себя запись-хранение-выдача, Кафка обладает очень большой пропускной способностью она исчисляется миллионами сообщений в секунду.
Подробнее..

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

22.01.2021 12:16:53 | Автор: admin

Мой новый пост был навеян последним квизом по го. Обратите внимание на бенчмарк [1]:


func BenchmarkSortStrings(b *testing.B) {        s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}        b.ReportAllocs()        for i := 0; i < b.N; i++ {                sort.Strings(s)        }}

Будучи удобной обёрткой вокруг sort.Sort(sort.StringSlice(s)), sort.Strings изменяет переданные ей данные, сортируя их, так что далеко не каждый (по-крайней мере, как минимум, 43% подписчиков из twitter) мог бы предположить, что это приведёт к аллокациям [выделениям памяти на куче]. Однако, по-крайней мере в последних версиях Go это так и каждая итерация этого бенчмарка вызовет одну аллокацию. Но почему?


Как многие разработчики на Go должны знать, интерфейсы реализованы в виде структуры из двух (машинных) слов. Каждое значение интерфейса содержит два поля: одно содержит тип хранимого интерфейсом значения, а второе указатель на это значение. [2]


В псевдокоде это бы выглядело вот так:


type interface struct {        // порядковый номер типа значения, присвоенного интерфейсу        type uintptr        // (обычно) указатель на значение, присвоенное интерфейсу        data uintptr}

Поле interface.data может содержать одно машинное слово, чаще всего оно занимает 8 байт. Но, к примеру, слайс []string займёт уже 24 байта: одно слово на указатель на массив, лежащий внутри слайса; одно слово на длину; и одно слово на оставшуюся вместимость массива (capacity). Но как Go должен втиснуть эти 24 байта в необходимые 8? Используя очень старый трюк, а именно косвенную адресацию. Да, слайс []string занимает 24 байта, но вот указатель на слайс *[]string уже опять 8.


Выделение [Escaping] на куче


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


func BenchmarkSortStrings(b *testing.B) {        s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}        b.ReportAllocs()        for i := 0; i < b.N; i++ {                var ss sort.StringSlice = s                var si sort.Interface = ss // allocation                sort.Sort(si)        }}

Чтобы заставить магию интерфейсов работать, компилятор перепишет присваивание var si sort.Interface = ss, как var si sort.Interface = &ss, используя адрес переменной ss [3]. Теперь Мы оказались в ситуации, когда интерфейс содержит указатель на ss, но куда он будет указывать? В каком участке памяти будет расположена ss?


Похоже, что ss будет перемещена в кучу [heap], что и отображается как аллокация при бенчмарке.


  Total:    296.01MB   296.01MB (flat, cum) 99.66%      8            .          .           func BenchmarkSortStrings(b *testing.B) {       9            .          .             s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}      10            .          .             b.ReportAllocs()      11            .          .             for i := 0; i < b.N; i++ {      12            .          .                 var ss sort.StringSlice = s      13     296.01MB   296.01MB                 var si sort.Interface = ss // allocation      14            .          .                 sort.Sort(si)      15            .          .             }      16            .          .           } 

Аллокация происходит потому, что компилятор не может быть уверен, что что ss переживет si (кстати, есть мнение, что компилятор можно было бы и улучшить, но об этом мы поговорим как-нибудь в другой раз). Так, ss будет выделена в куче. Таким образом, возникает вопрос: сколько байтов будет выделяться на итерацию? Что же, давайте проверим.


% go test -bench=. sort_test.gogoos: darwingoarch: amd64cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHzBenchmarkSortStrings-4          12591951                91.36 ns/op           24 B/op          1 allocs/opPASSok      command-line-arguments  1.260s

При использовании Go 1.16beta1, на amd64, при каждой операции будет выделяться 24 байта[4].


Но при этом на прошлой версии Go при каждой операции будет выделяться уже 32 байта.


% go1.15 test -bench=. sort_test.gogoos: darwingoarch: amd64BenchmarkSortStrings-4          11453016                96.4 ns/op            32 B/op          1 allocs/opPASSok      command-line-arguments  1.225s

И это приводит нас к основной теме текущей статьи поста: улучшениям в новой версии Go. Но перед тем, как обсудить это, давайте поговорим о классах размеров [size classes].


Классы размеров


Чтобы объяснить, что это такое, рассмотрим, как теоретическая среда выполнения [рантайм] Go могла бы выделить 24 байта в своей куче. Самый простой способ так сделать это отслеживать всю выделенную на данный момент память, используя указатель на последний выделенный байт в куче. К примеру, чтобы выделить 24 байта, мы бы увеличили указатель кучи на 24, а предыдущее значение вернули вызывающей стороне. Пока код, запросивший эти 24 байта, не выйдет за пределы указателя, этот механизм не имеет накладных расходов. К сожалению, в реальной жизни аллокаторы не только выделяют память, иногда им нужно ее освобождать.


Так, в конце концов среда выполнения Go должна будет освободить эти 24 байта, но с точки зрения среды выполнения единственное, что она знает это начальный адрес, который она дала вызывающей стороне. Она не знает, сколько байтов было выделено после этого адреса. Чтобы сделать возможным освобождение памяти, наш гипотетический аллокатор должен будет записывать для каждой аллокации в куче её размер. Где же будут располагаться эти размеры? Конечно, на куче.


В нашем случае, когда среда выполнения хочет выделить память, она может запросить чуть больше, чем необходимо и использовать этот "излишек", чтобы записать туда размер выделенной памяти. Для нашего слайса, когда мы захотели выделить 24 байта, реально выделилось бы несколько больше. Но насколько больше? Оказывается, как минимум одно машинное слово [5].


Для выделения 24 байт, мы бы реально выделили на 8 байт больше, чем хотели. 25% "излишней" памяти не очень здорово, но и не очень плохо и чем больше памяти мы будем выделять, тем меньше нас будут волновать эти излишки. Но что есть мы захотим выделить в куче все лишь один байт? Получается, что мы выделим под хранение в 9 раз больше памяти, чем нужно! Можно ли это как-то оптимизировать?


Как вариант, вместо хранения длины каждой аллокации, мы могли бы хранить вместе объекты одного размера. Если все 24-байтовые объекты будут храниться вместе, то рантайм мог бы автоматически знать, какой размер занимает каждый из них, где он начинается и где заканчивается. Всё, что понадобилось бы такому рантайму это один бит для того, чтобы разметить, занята ли кем-то конкретная 24-байтовая область или ещё нет. В Go такие области называются классами размеров, потому что все объекты одного размера хранятся вместе (класс здесь значит, что как в школе, все ученики одного возраста учатся в одном классе, не путайте с классами из C++). Когда же рантайм будет должен выделить память под объект меньшего размера, он будет использовать самый меньший класс размера из подходящих.


Классы размеров для всех, даром и без ограничений


Теперь мы знаем, как такие классы работают и можно дать ответ на очевидный вопрос: где будет выделяться память под них? Ответ вас не удивит: на куче. Для минимизации расходов, рантайм будет сначала выделяет побольше памяти (обычно, несколько страниц памяти из системы), а потом использует её для размещения на ней множества объектов одного размера. Но здесь есть проблема.


Такой способ выделения памяти работал бы хорошо [6], если бы количество видов объектов (а точнее их размеров) было сильно ограничено. Конечно, как правило это не так и программы используют объекты всевозможных размеров[7].


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


А теперь все вместе


Вот и финальный кусочек пазла. Go 1.15 не имеет 24 байтовых классов размеров, так что для переменной ss будет выделено 32 байта от минимально возможного класса размеров. Но благодаря Martin Mhrmann в Go 1.16 теперь есть 24 байтовый класс размера, так что память под использованием слайсов в интерфейсах будет выделяться эффективнее.


[1] Это неправильный способ тестирования функции сортировки, потому что после первой итерации входные данные уже будут отсортированы. Но в данном контексте это не важно.
[2] Точность этого утверждения зависит от используемой версии Go. Например, в Go 1.15 была добавлена возможность хранить некоторые целые числа непосредственно в значении интерфейса. Однако для большинства не-указателей, в качестве значения будет использован адрес переменной.
[3] Компилятор запомнит, что тип значения интерфейса здесь sort.StringSlice, а не *sort.StringSlice.
[4] На 32битных платформах оно займёт в два раза меньше памяти, но не будем оглядываться на прошлое.
[5] Если бы вы были готовы не выделять больше 4G (или, может быть, 64 Кб), вы могли бы использовать меньший объем памяти для хранения размера выделяемой памяти, но это означало бы проблемы с выравниванием [aligment] и необходимостью сдвига [padding] (почитать об этом можно, например, здесь прим. переводчика).
[6] Хранение объектов одного размера рядом это ещё и эффективный способ борьбы с фрагментацией памяти.
[7] Это не надуманный сценарий, те же строки бывают разных форм и размеров, и создать строку нового уникального размера можно просто добавив пробел в любую из них.

Подробнее..

Архитектура в Django проектах как выжить

01.03.2021 18:19:29 | Автор: admin

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

Немного теории

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

Django это фреймворк, использующий шаблон проектирования Model-View-Controller (MVC).

И дальше куча неточных схем и объяснений о том, что такое MVC. Почему они неточные и что с ними не так, можно посмотреть здесь или здесь.

Обычно в таких схемах MVC описывают подобным образом:

  • Model доступ к хранилищу данных

  • View это интерфейс, с которым взаимодействует пользователь

  • Controller некий связывающий объект между model и view.

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

Стоит обратить внимание на две вещи.

Первое, часто под M в MVC подразумевают модель данных, и говорят, что это некий класс, который отвечает за предоставление доступа к базе данных. Что неверно, и не соответствует классическому MVC и его потомкам MV*. В классическом MVC под M подразумевается domain model объектная модель домена, объединяющая данные и поведение. Если говорить точнее, то M в MVC это интерфейс к доменной модели, так как domain model это некий слой объектов, описывающий различные стороны определенной области бизнеса. Где одни объекты призваны имитировать элементы данных, которыми оперируют в этой области, а другие должны формализовать те или иные бизнес-правила.

Второе, в Django нет выделенного слоя controller, и когда вам говорят, что в Django слой views это контроллер, не верьте этим людям. Обратитесь к официальной документации, а точнее к FAQ, тогда можно увидеть, что этот слой вписывается в принципы слоя View в MVC, особенно, если рассматривать DRF, а как такового слоя Controller в Django нет. Как говорится в FAQ, если вам очень хочется аббревиатур, то можно использовать в контексте Django аббревиатуру MTV (Model, Template, and View). Если очень хочется рассматривать Web MVC и сравнивать Django с другими фреймворками, то для простоты можно считать view контроллером.

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

Перейдем к практике

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

  • front-end/templates

  • serializers/forms

  • views

  • models

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

Первый кейс создание заказа. При создании заказа нам нужно:

  • проверить валидность заказа и доступность товаров

  • создать заказ

  • зарезервировать товар на складе

  • передать заявку менеджеру

  • оповестить пользователя о том, что его заказ принят в работу

Второй кейс просмотр списка моих заказов. Здесь все просто, мы должны показать пользователю список его заказов:

  • получить список заказов пользователя

Слой serializers/forms

У слоя serializers три основные функции (все выводы для serializers справедливы и для forms):

  • валидировать данные

  • преобразовывать данные запроса в типы данных Python

  • преобразовывать сложные Python объекты в простые типы данных Python (например, Django модели в dict)

Дополнительно сериалайзеры имеют два метода, create и update, которые вызываются в методе save() и почти всегда используются во view.

Пример использования из документации:

class CommentSerializer(serializers.Serializer):email = serializers.EmailField()  content = serializers.CharField(max_length=200)  created = serializers.DateTimeField()  def create(self, validated_data):  return Comment.objects.create(**validated_data)  def update(self, instance, validated_data):    instance.email = validated_data.get('email', instance.email)    instance.content = validated_data.get('content', instance.content)    instance.created = validated_data.get('created', instance.created)    instance.save()    return instance

Где-то в нашей view:

# .save() will create a new instance.serializer = CommentSerializer(data=data)# .save() will update the existing `comment` instance.serializer = CommentSerializer(comment, data=data)comment = serializer.save()

В данном подходе за сохранение и обновление сущностей отвечает сериалайзер, точнее он оперирует методами модели.

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

# serializers.pyclass OrderSerializer(serializers.ModelSerializer):class Meta:  model = Order    fields = __all__# views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer

Если углубиться в реализацию ModelViewSet и ModelSerializer, то можно заметить, что за сохранение и обновление сущностей также отвечает сериалайзер.

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

# serializers.pyclass OrderSerializer(serializers.ModelSerializer):class Meta:model = Orderfields = []def create(self, validated_data):# Проверяем, что все товары есть и заказ валиден...# Создаем запись о заказе в БДinstance = super(OrderSerializer, self).create(validated_data)    # Бронируем товары на складе    ...    # Передаем заявку менеджеру    ...    # Оповещаем пользователя    ...    return instance  # views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer

Если с методом создания мы что-то придумали, то логику получения заказов пользователя придется помещать в view.

Получается такая схема:

Плюсы данного подхода:

Легко делать CRUD

Django и DRF предоставляют очень удобные инструменты с помощью которых можно легко создавать CRUD.

Минусы данного подхода:

Нарушение идей MVC

Мы смешиваем бизнес-логику с задачей сериализации (получения/отображения) данных в одном слое. Ни о каком выделенном слое бизнес-логики у нас нет и речи.

Сериалайзеры стоит относить к слою View в MVC в контексте Django. Когда мы располагаем в сериалайзерах свою бизнес логику, мы нарушаем главный принцип MVC отделение логики представления данных от бизнес-логики.

Нельзя переиспользовать

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

Сложно тестировать

Сложно протестировать бизнес-логику независимо от логики сериализации и валидации данных.

Сложно поддерживать

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

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

Высокая зависимость от фреймворка

Высокая зависимость от DRF Serializers или Django Forms. Если мы захотим поменять способ сериализации и отказаться от serializers, то придется переносить или переписывать нашу логику. Также будет сложно переехать с Django Forms на DRF Serializers или наоборот.

Правильные обязанности слоя

Сериализация/десериализация данных

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

Валидация данных

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

Заключение

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

Стоит отказаться от ModelSerializer с его магическими методами create и update и заменить их на обычные сериалайзеры (можно использовать ModelSerializer как read only для удобства). Если вы пишете какое-то простое приложение, где кроме CRUD ничего не нужно, то можно не отказываться от удобства DRF и использовать сериалайзеры как предлагается в документации.

Слой Views

Слой View в контексте Django отвечает за представление и обработку пользовательских данных, в нем мы описываем, какие данные нам нужны и как мы хотим их представить. Если вспомнить то, о чем мы говорили в начале, то можно сразу сделать вывод, что во views не нужно писать бизнес-логику, иначе мы смешиваем логику представления данных с бизнес-логикой. Даже если считать, что views в Django это контроллеры, то размещать в них бизнес-логику тоже не стоит, иначе у вас получатся ТТУКи (Толстые, тупые, уродливые контроллеры; Fat Stupid Ugly Controllers).

Но часто можно увидеть что-то подобное:

# views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer                                  def perform_create(self, serializer):  # Проверяем, что все товары есть и заказ валиден    ...    # Создаем запись о заказе в БД    super(OrderViewsSet, self).perform_create(serializer)    # Бронируем товары на складе    ...    # Передаем заявку менеджеру    ...    # Оповещаем пользователя    ...

По дефолту, в ModelViewSet для сохранения и обновления данных используется сериалайзер, что не входит в его обязанности. Это можно исправить, полностью переопределить метод perform_create (не вызывать super, но тогда встает вопрос об объективности наследования от ModelViewSet). Можно написать кастомные методы в ModelViewSetили написать кастомные APIView:

# views.pyclass OrderCreateApi(views.APIView):class InputSerializer(serializers.ModelSerializer):  number = serializers.IntegerField()    ...                     def post(self, request):  serializer = self.InputSerializer(data=request.data)    serializer.is_valid(raise_exception=True)    # Проверяем, что все товары есть и заказ валиден    ...    # Создаем запись о заказе в БД                                             order = Order.objects.create(**serializer.validated_data)    # Бронируем товары на складе    ...    # Передаем заявку менеджеру    ...    # Оповещаем пользователя    ...                       return Response(status=status.HTTP_201_CREATED)

В отличии от слоя serializers, мы теперь легко можем поместить нашу логику получения заказов в слой views.

# views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer                            def get_queryset(self):  queryset = super(OrderViewsSet, self).get_queryset()    queryset = queryset.filter(user=self.request.user)    return queryset

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

Получается такая схема:

Стоит помнить, что мы отказались от использования saveу serializers. В таком случае слой serializers остается чистым и выполняет только правильные обязанности.

Плюсы данного подхода

Легко делать CRUD

Django и DRF предоставляют очень удобные инструменты, с помощью которых можно легко создавать CRUD и views не исключение.

Минусы данного подхода

Нарушение идей MVC

Мы смешиваем в одном слое логику представления данных и бизнес-логику приложения.

Нельзя переиспользовать

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

Высокая зависимость от фреймворка

Высокая зависимость от DRF View или Django View. Если мы захотим поменять способ обработки запроса и отказаться от views, то придется переносить или переписывать нашу логику. Будет сложно переехать с Django View на DRF View или наоборот.

Сложно тестировать

Достаточно сложно протестировать код во views независимо от serializers и остальной инфраструктуры Django + придется использовать http client для тестирования.

Сложно поддерживать

Со временем views разрастаются, часть логики переносится в Celery задачи, часть в модели, код во views дублируется, так как их нельзя переиспользовать все это приводит к тому, что проект сложно поддерживать.

Правильные обязанности слоя

Обработка запроса

Во view мы принимаем и обрабатываем запрос клиента, подготавливаем данные для передачи в бизнес-логику.

Делегирование сериализации данных сериалайзерам

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

Вызов методов бизнес-логики

После подготовки и сериализации данных вызывается интерфейс бизнес-логики.

Логика представления данных

Мы должны обработать ответ от методов бизнес-логики и предоставить нужные данные клиенту.

Заключение

Вывод примерно такой же как и с serializers в views не стоит размещать бизнес-логику.

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

Если вам нужен только CRUD, то можно использовать подход который предоставляет ModelViewSet и не усложнять себе жизнь.

Слой models

Если опираться на более продвинутые туториалы или вспомнить слой Model в MVC, то кажется, что models отличное место для размещения бизнес-логики.

Это может выглядеть примерно так:

# models.pyclass Order(models.Model):number = serializers.IntegerField()  created = models.DateTimeField(auto_now_add=True)  status = models.CharField(max_length=16)                                               def update_status(self, status: str) -> None:  self.status = status    self.save(update_fields=('status',))  ...    @classmethod  def create(cls, data...):  instance = cls(...)    # Проверяем, что все товары есть и заказ валиден    ...    # Создаем запись о заказе в БД    instance = instance.save()    # Бронируем товары на складе    ...    # Передаем заявку менеджеру (например на почту или создаем какую то запись в БД)    ...    # Оповещаем пользователя    ...# views.pyclass OrderCreateApi(views.APIView):class InputSerializer(serializers.ModelSerializer):  number = serializers.IntegerField()    ...      def post(self, request):  serializer = self.InputSerializer(data=request.data)    serializer.is_valid(raise_exception=True)        Order.create(**serializer.validated_data)                    return Response(status=status.HTTP_201_CREATED)

В view мы сериализуем данные с помощью serializer и вызываем метод создания заказа у класса модели.В данном случае мы реализовали classmethod, что бы не было необходимости создавать экземпляр модели. Иначе нам придется понимать какие данные относятся к полям модели, а какие мы должны передать в метод создания, а это уже некие бизнес- правила.

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

Для методов получения данных в таком случае стоит использовать Managers.

# views.pyclass OrderListApi(views.APIView):class OutputSerializer(serializers.ModelSerializer):  class Meta:    model = Order      fields = __all__                             def get(self, request):  orders = Order.objects.filter(user=request.user)    # если у вас сложные условия фильтрации    # например, Order.objects.filter(user=request.user, is_deleted=False, is_archived=False...)    # то стоит написать кастомные методы в Manager        data = self.OutputSerializer(orders, many=True).data        return Response(data)

Получается такая схема:

В данном случае слои serializers и views становятся чистыми и правила бизнес-логики концентрируются в одном слое.

Плюсы данного подхода

Следование идеям MVC

Мы отделили логику представления данных от логики предметной области. View только подготавливает данные и вызывает методы модели, все бизнес правила и процессы описаны в методах модели. Данный подход соответствует главной идее MVC.

Легко тестировать

Вся бизнес-логика собрана в одном слое, который не зависит от других слоев, например, от views или serializers. Каждый метод модели можно протестировать по отдельности как обычный python код. Остается только замокать метод save и базовые методы managers или использовать базу данных, если требуется.

Можно переиспользовать

Методы модели можно вызывать из любого компонента, DRF Views, Django Views, Celery задачи и т.д.

Минусы данного подхода

Зависимость от фреймворка

У нас все еще есть зависимость от фреймворка, но это не так критично. Так как отказ от Django models и ORM или их замена очень редкий кейс.

Сложно поддерживать большие проекты

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

Усложнение CRUD проектов

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

Заключение

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

Слой Services

Мы перебрали все дефолтные слои в Django приложении, теперь можем вспомнить о том, что под слоем Model в MVC подразумевается не один объект, а набор объектов.

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

# models.pyclass Order(models.Model):number = serializers.IntegerField()  created = models.DateTimeField(auto_now_add=True)  status = models.CharField(max_length=16)                                               def update_status(self, status: str) -> None:  self.status = status    self.save(update_fields=('status',))...# services.py# вместо пречесления всех аргументов можно реализовать DTOdef order_create(name: str, number: int ...) -> bool:# Проверяем, что все товары есть и заказ валиден  ...  # Создаем запись о заказе в БД  order = Order.objects.create(...)  # Бронируем товары на складе  ...  # Передаем заявку менеджеру (например на почту или создаем какую то запись в БД)  ...  # Оповещаем пользователя  ...# views.pyclass OrderCreateApi(views.APIView):class InputSerializer(serializers.ModelSerializer):  number = serializers.IntegerField()    ...                             def post(self, request):  serializer = self.InputSerializer(data=request.data)    serializer.is_valid(raise_exception=True)      services.order_create(**serializer.validated_data)                 return Response(status=status.HTTP_201_CREATED)

Стоит придерживаться следующему подходу:

  • views подготовка данных запроса, вызов бизнес логики, подготовка ответа

  • serializers сериализация данных, простая валидация

  • services простые функции с бизнес правилами или классы (Service Objects)

  • managers содержит в себе правила работы с данными (доступ к данным)

  • models единственный окончательный источник правды о анных

Получение заказов пользователя:

# services.pydef order_get_by_user(user: User) -> Iterable[Order]:return Order.objects.filter(user=user)# views.pyclass OrderListApi(views.APIView):class OutputSerializer(serializers.ModelSerializer):  class Meta:    model = Order      fields = ('id', 'number', ...)                            def get(self, request):  orders = services.order_get_by_user(user=request.user)                                             data = self.OutputSerializer(orders, many=True).data                                return Response(data)

Получается такая схема:

Плюсы данного подхода

Следование идеям MVC

Как и в предыдущем подходе, мы полностью отделили бизнес-логику от логики представления.

Легко тестировать

Сервисы представляют собой простые Python функции, которые легко тестировать.

Можно переиспользовать

Мы можем вызывать наши сервисы из любого компонента + можем повторно использовать какие-то сервисы в других проектах.

Легко поддерживать и расширять

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

Гибкость

Существует множество подходов написания и расширения сервисного слоя.

Минусы данного подхода

Зависимость от фреймворка

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

Усложнение CRUD проектов

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

Заключение

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

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

Что касается Django, советую обратить внимание на стайл гайд от HackSoftware у них схожие взгляды, но они разделяют сервисный слой на два компонента (services и selectors) и не используют кастомные методы в managers. Подход написания serializers и включения их во views я взял у них. Также стоит посмотреть на идеи ребят из dry-python.

Общий итог

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

Подробнее..

Vivid UI

19.01.2021 18:13:47 | Автор: admin

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

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

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

Декларативный стиль UI

View modifiers aka decorator

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

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

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

public struct ViewDecorator<View: UIView> {    let decoration: (View) -> Void    func decorate(_ view: View) {        decoration(view)    }}public protocol DecoratableView: UIView {}extension DecoratableView {    public init(decorator: ViewDecorator<Self>) {        self.init(frame: .zero)        decorate(with: decorator)    }    @discardableResult    public func decorated(with decorator: ViewDecorator<Self>) -> Self {        decorate(with: decorator)        return self    }    public func decorate(with decorator: ViewDecorator<Self>) {        decorator.decorate(self)        currentDecorators.append(decorator)    }    public func redecorate() {        currentDecorators.forEach {            $0.decorate(self)        }    }}

Почему мы не стали использовать сабклассы:

  • Их трудно соединять в цепочки;

  • Невозможно отказаться от функциональности родительского класса;

  • Нужно описывать отдельно от контекста применения (в отдельном файле)

Декораторы помогли настроить UI компонентов унифицированно и здорово сократили количество кода.

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

static var headline2: ViewDecorator<View> {    ViewDecorator<View> {        $0.decorated(with: .font(.f2))        $0.decorated(with: .textColor(.c1))    }}

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

private let titleLabel = UILabel()        .decorated(with: .headline2)        .decorated(with: .multiline)        .decorated(with: .alignment(.center))

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

А теперь сравним код с декораторами и без них.

Пример использования декоратора:

private let fancyLabel = UILabel(    decorator: .text("?? ????   ???"))    .decorated(with: .cellTitle)    .decorated(with: .alignment(.center))

Без декораторов аналогичный код выглядел бы примерно так:

private let fancyLabel: UILabel = {   let label = UILabel()   label.text = "???? ? ????"   label.numberOfLines = 0   label.font = .f4   label.textColor = .c1   label.textAlignment = .center   return label}()

Что здесь плохо 9 строк кода против 4. Внимание рассеивается.

Для navigation bar особенно актуально, так как под строчками вида:

navigationController.navigationBar                        .decorated(with: .titleColor(.purple))                        .decorated(with: .transparent)

Скрывается:

static func titleColor(_ color: UIColor) -> ViewDecorator<UINavigationBar> {    ViewDecorator<UINavigationBar> {        let titleTextAttributes: [NSAttributedString.Key: Any] = [            .font: UIFont.f3,            .foregroundColor: color        ]        let largeTitleTextAttributes: [NSAttributedString.Key: Any] = [            .font: UIFont.f1,            .foregroundColor: color        ]        if #available(iOS 13, *) {            $0.modifyAppearance {                $0.titleTextAttributes = titleTextAttributes                $0.largeTitleTextAttributes = largeTitleTextAttributes            }        } else {            $0.titleTextAttributes = titleTextAttributes            $0.largeTitleTextAttributes = largeTitleTextAttributes        }    }}
static var transparent: ViewDecorator<UINavigationBar> {    ViewDecorator<UINavigationBar> {        if #available(iOS 13, *) {            $0.isTranslucent = true            $0.modifyAppearance {                $0.configureWithTransparentBackground()                $0.backgroundColor = .clear                $0.backgroundImage = UIImage()            }        } else {            $0.setBackgroundImage(UIImage(), for: .default)            $0.shadowImage = UIImage()            $0.isTranslucent = true            $0.backgroundColor = .clear        }    }}

Декораторы показали себя хорошим инструментом и помогли нам:

  • Улучшить переиспользование кода

  • Сократить время разработки

  • Через связанность компонентов легко накатывать изменения в дизайне

  • Легко настраивать navigation bar через перегрузку свойства с массивом декораторов базового класса экрана

override var navigationBarDecorators: [ViewDecorator<UINavigationBar>] {    [.withoutBottomLine, .fillColor(.c0), .titleColor(.c1)]}
  • Сделать код единообразным: не рассеивается внимание, знаешь где что искать.

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

HStack, VStack

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

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

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

Сначала используем наиболее актуальную версию констрейнтов - anchors.

[expireDateTitleLabel, expireDateLabel, cvcCodeView].forEach {    view.addSubview($0)    $0.translatesAutoresizingMaskIntoConstraints = false}NSLayoutConstraint.activate([    expireDateTitleLabel.topAnchor.constraint(equalTo: view.topAnchor),    expireDateTitleLabel.leftAnchor.constraint(equalTo: view.leftAnchor),    expireDateLabel.topAnchor.constraint(equalTo: expireDateTitleLabel.bottomAnchor, constant: 2),    expireDateLabel.leftAnchor.constraint(equalTo: view.leftAnchor),    expireDateLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor),    cvcCodeView.leftAnchor.constraint(equalTo: expireDateTitleLabel.rightAnchor, constant: 44),    cvcCodeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),    cvcCodeView.rightAnchor.constraint(equalTo: view.rightAnchor)])

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

let stackView = UIStackView()stackView.alignment = .bottomstackView.axis = .horizontalstackView.layoutMargins = .init(top: 0, left: 16, bottom: 0, right: 16)stackView.isLayoutMarginsRelativeArrangement = truelet expiryDateStack: UIStackView = {    let stackView = UIStackView(        arrangedSubviews: [expireDateTitleLabel, expireDateLabel]    )    stackView.setCustomSpacing(2, after: expireDateTitleLabel)    stackView.axis = .vertical    stackView.layoutMargins = .init(top: 8, left: 0, bottom: 0, right: 0)    stackView.isLayoutMarginsRelativeArrangement = true    return stackView}()let gapView = UIView()gapView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)gapView.setContentHuggingPriority(.defaultLow, for: .horizontal)stackView.addArrangedSubview(expiryDateStack)stackView.addArrangedSubview(gapView)stackView.addArrangedSubview(cvcCodeView)

Как видите, в обоих случаях код получился громоздким. Сама идея верстать на стеках имела больше декларативного потенциала. И если быть честными, то этот подход был предложен одним из разработчиков еще до сессии WWDC про SwiftUI. И мы рады, что в данном подразделении Apple работают наши единомышленники! Тут не будет сюрпризов, еще раз взглянем на иллюстрацию, показанную ранее и представим ее в виде стеков.

view.layoutUsing.stack {    $0.hStack(        alignedTo: .bottom,        $0.vStack(            expireDateTitleLabel,            $0.vGap(fixed: 2),            expireDateLabel        ),        $0.hGap(fixed: 44),        cvcCodeView,        $0.hGap()    )}

А так выглядит тот же код, если написать его на SwiftUI

var body: some View {    HStack(alignment: .bottom) {        VStack {            expireDateTitleLabel            Spacer().frame(width: 0, height: 2)            expireDateLabel        }        Spacer().frame(width: 44, height: 0)        cvcCodeView        Spacer()    }}

Коллекции как инструмент построения

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

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

private let listAdapter = VerticalListAdapter<CommonCollectionViewCell>()private let collectionView = UICollectionView(    frame: .zero,    collectionViewLayout: UICollectionViewFlowLayout())

И далее настраиваем основные свойства адаптера.

func setupCollection() {    listAdapter.heightMode = .fixed(height: 8)    listAdapter.setup(collectionView: collectionView)    listAdapter.spacing = Constants.pocketSpacing    listAdapter.onSelectItem = output.didSelectPocket}

И на этом все. Осталось загрузить модели.

listAdapter.reload(items: viewModel.items)

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

В итоге:

  • Абстрагировали от конкретной коллекции (UITableView -> UICollectionView).

  • Ускорили время построения экранов со списками

  • Обеспечили единообразие архитектуры всех экранов, построенных на коллекциях

  • На основе адаптера списка разработали адаптер для смеси динамических и статических ячеек

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

Состояния экрана.

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

Давайте поговорим более подробно о состоянии загрузки экрана.

Shimmering Views

Отображение состояний загрузки разных экранов в приложении обычно не сильно отличается и требует одних и тех же инструментов. В нашем проекте таким визуальным инструментом стали шиммеры (shimmering views).

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

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

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

Поэтому мы создали SkeletonView, в который добавили анимацию градиента:

func makeStripAnimation() -> CAKeyframeAnimation {    let animation = CAKeyframeAnimation(keyPath: "locations")    animation.values = [        Constants.stripGradientStartLocations,        Constants.stripGradientEndLocations    ]    animation.repeatCount = .infinity    animation.isRemovedOnCompletion = false    stripAnimationSettings.apply(to: animation)    return animation}

Основными методами для работы со скелетоном являются показ и скрытие его на экране:

protocol SkeletonDisplayable {...}protocol SkeletonAvailableScreenTrait: UIViewController, SkeletonDisplayable {...}extension SkeletonAvailableScreenTrait {    func showSkeleton(animated: Bool = false) {        addAnimationIfNeeded(isAnimated: animated)        skeletonViewController.view.isHidden = false        skeletonViewController.setLoading(true)    }    func hideSkeleton(animated: Bool = false) {        addAnimationIfNeeded(isAnimated: animated)        skeletonViewController.view.isHidden = true        skeletonViewController.setLoading(false)    }}

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

setupSkeleton()

Smart skeletons

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

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

public protocol SkeletonDrivenLoadableView: UIView {    associatedtype LoadableSubviewID: CaseIterable    typealias SkeletonBone = (view: SkeletonBoneView, excludedPinEdges: [UIRectEdge])    func loadableSubview(for subviewId: LoadableSubviewID) -> UIView    func skeletonBone(for subviewId: LoadableSubviewID) -> SkeletonBone}

Для примера, рассмотрим простенький компонент, состоящий из иконки и лейбла заголовка.

extension ActionButton: SkeletonDrivenLoadableView {    public enum LoadableSubviewID: CaseIterable {        case icon        case title    }    public func loadableSubview(for subviewId: LoadableSubviewID) -> UIView {        switch subviewId {        case .icon:            return solidView        case .title:            return titleLabel        }    }    public func skeletonBone(for subviewId: LoadableSubviewID) -> SkeletonBone {        switch subviewId {        case .icon:            return (ActionButton.iconBoneView, excludedPinEdges: [])        case .title:            return (ActionButton.titleBoneView, excludedPinEdges: [])        }    }}

Теперь мы можем запустить загрузку такого UI компонента с возможностью выбора дочерних элементов для шиммеринга:

actionButton.setLoading(isLoading, shimmering: [.icon])// oractionButton.setLoading(isLoading, shimmering: [.icon, .title])// which is equal toactionButton.setLoading(isLoading)

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

Машина состояний

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

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

Для экрана она выглядит следующим образом:

final class ScreenStateMachine: StateMachine<ScreenState, ScreenEvent> {    public init() {        super.init(state: .initial,           transitions: [               .loadingStarted: [.initial => .loading, .error => .loading],               .errorReceived: [.loading => .error],               .contentReceived: [.loading => .content, .initial => .content]           ])    }}

Ниже мы привели свою реализацию.

class StateMachine<State: Equatable, Event: Hashable> {    public private(set) var state: State {        didSet {            onChangeState?(state)        }    }    private let initialState: State    private let transitions: [Event: [Transition]]    private var onChangeState: ((State) -> Void)?    public func subscribe(onChangeState: @escaping (State) -> Void) {        self.onChangeState = onChangeState        self.onChangeState?(state)    }    @discardableResult    open func processEvent(_ event: Event) -> State {        guard let destination = transitions[event]?.first(where: { $0.source == state })?.destination else {            return state        }        state = destination        return state    }    public func reset() {        state = initialState    }  }

Остается вызвать нужные события, чтобы запустить переход состояний.

func reloadTariffs() {   screenStateMachine.processEvent(.loadingStarted)   interactor.obtainTariffs()}

Если есть состояния, то кто-то должен уметь эти состояния показывать.

protocol ScreenInput: ErrorDisplayable,                      LoadableView,                      SkeletonDisplayable,                      PlaceholderDisplayable,                      ContentDisplayable

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

  • Показ ошибок

  • Управление загрузкой

  • Показ скелетонов

  • Показ заглушек с ошибкой и возможностью попытаться снова

  • Показ контента

Также для state machine можно реализовать собственные переходы между состояниями:

final class DogStateMachine: StateMachine&lt;ConfirmByCodeResendingState, ConfirmByCodeResendingEvent> {    init() {        super.init(            state: .laying,            transitions: [                .walkCommand: [                    .laying => .walking,                    .eating => .walking,                ],                .seatCommand: [.walking => .sitting],                .bunnyCommand: [                    .laying => .sitting,                    .sitting => .sittingInBunnyPose                ]            ]        )    }}

Трейт экрана с машиной состояний

Хорошо, а как все это связать воедино? Для этого потребуется еще один протокол оркестратор.

public extension ScreenStateMachineTrait {    func setupScreenStateMachine() {        screenStateMachine.subscribe { [weak self] state in            guard let self = self else { return }            switch state {            case .initial:                self.initialStateDisplayableView?.setupInitialState()                self.skeletonDisplayableView?.hideSkeleton(animated: false)                self.placeholderDisplayableView?.setPlaceholderVisible(false)                self.contentDisplayableView?.setContentVisible(false)            case .loading:                self.skeletonDisplayableView?.showSkeleton(animated: true)                self.placeholderDisplayableView?.setPlaceholderVisible(false)                self.contentDisplayableView?.setContentVisible(false)            case .error:                self.skeletonDisplayableView?.hideSkeleton(animated: true)                self.placeholderDisplayableView?.setPlaceholderVisible(true)                self.contentDisplayableView?.setContentVisible(false)            case .content:                self.skeletonDisplayableView?.hideSkeleton(animated: true)                self.placeholderDisplayableView?.setPlaceholderVisible(false)                self.contentDisplayableView?.setContentVisible(true)            }        }    }    private var skeletonDisplayableView: SkeletonDisplayable? {        view as? SkeletonDisplayable    }    // etc.}

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

Отображение ошибок

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

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

На помощь снова спешат протоколы и трейты.

Для описания представления всех видов ошибок определена единая вьюмодель.

struct ErrorViewModel {    let title: String    let message: String?    let presentationStyle: PresentationStyle}enum PresentationStyle {    case alert    case banner(        interval: TimeInterval = 3.0,        fillColor: UIColor? = nil,        onHide: (() -> Void)? = nil    )    case placeholder(retryable: Bool = true)    case silent}

Дальше мы передаём её в метод протокола ErrorDisplayable:

public protocol ErrorDisplayable: AnyObject {    func showError(_ viewModel: ErrorViewModel)}
public protocol ErrorDisplayableViewTrait: UIViewController, ErrorDisplayable, AlertViewTrait {}

В зависимости от стиля презентации используем конкретный инструмент отображения.

public extension ErrorDisplayableViewTrait {    func showError(_ viewModel: ErrorViewModel) {        switch viewModel.presentationStyle {        case .alert:            // show alert        case let .banner(interval, fillColor, onHide):            // show banner        case let .placeholder(retryable):            // show placeholder        case .silent:            return        }    }}

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

extension APIError: ErrorViewModelConvertible {    public func viewModel(_ presentationStyle: ErrorViewModel.PresentationStyle) -> ErrorViewModel {        .init(            title: Localisation.network_error_title,            message: message,            presentationStyle: presentationStyle        )    }}extension CommonError: ErrorViewModelConvertible {    public func viewModel(_ presentationStyle: ErrorViewModel.PresentationStyle) -> ErrorViewModel {        .init(            title: title,            message: message,            presentationStyle: isSilent ? .silent : presentationStyle        )    }}

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

Занимательные цифры

  • Средний размер вьюконтроллера - 196,8934010152 строк

  • Средний размер компонента - 138,2207792208 строк

  • Время написания экрана - 1 день

  • Время написания скрипта для подсчёта этих строк кода ? - 1 час

Выводы

Благодаря нашему подходу к построению UI новые разработчики довольно быстро вливаются в процесс разработки. Есть удобные и простые в использовании инструменты, которые позволяют сократить время, обычно съедаемое рутинными процессами.

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

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

Еще не может не радовать сильно похудевшая кодовая база. Это мы осветили в занимательных цифрах. А ясное разделение на компоненты и их взаимное расположение не дают запутаться в коде даже самого сложного экрана.

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

Подробнее..

Онбординг нового разработчика с помощью Ansible

29.01.2021 14:10:10 | Автор: admin

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

  1. Установка Xcode

  2. Настройка локального git репозитория

  3. Настройка окружения

  4. Настройка проекта

  5. Ознакомление с документацией

  6. Настройка таск-трекера (Jira/Youtrack)

Чтобы не тратить драгоценное время разработчика на ручную настройку нового ноутбука, мы автоматизировали 3 и 4 пункты, т.к. они являются самыми трудозатратными.

Итак, давайте сперва рассмотрим настройку окружения.

Настройка окружения

В нашем случае настройка окружения состоит из нескольких пунктов:

1. Установка brew зависимостей

2. Установка mint зависимостей

3. Установка и настройка ruby

4. Установка и настройка python

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

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

В конечном итоге скрипт получился вот таким:

#!/usr/bin/env bashcurl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && sudo python get-pip.pysudo pip install ansiblecd $(dirname $0) && ansible-playbook main.yml

Тут происходит следующее:

  1. Установка менеджера пакетов pip

  2. Установка Ansible

  3. Запуск наших задач из main.yml

Осталось разобраться, что находится в main.yml. А там всего лишь

- hosts: localhost  roles:- common_setup

Здесь hosts отвечает за те машины, на которых мы хотим запустить наши задачи, в нашем случае это локальная машина, а roles содержит информацию о том, какие задачи нужно выполнить, переменные и метаинформацию.

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

Каждая папка (meta, tasks и vars) должна содержать файл main.yml.

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

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

---- name: Check if RVM already installedstat: path=~/.rvmregister: rvmdirchangedwhen: false- name: Install RVM for a usercommand: bash -c "\curl -sSL https://get.rvm.io | bash -s -- --ignore-dotfiles"when: rvmdir.stat.exists == False- name: Add RVM to profilecommand: bash -c "~/.rvm/bin/rvm get stable --auto-dotfiles"when: rvmdir.stat.exists == False- name: Add RVM to zshrccommand: bash -c "echo '\n[ -s ${HOME}/.rvm/scripts/rvm ] && source ${HOME}/.rvm/scripts/rvm' >> ~/.zshrc"when: rvmdir.stat.exists == False- name: Install {{ rubyversion }}command: bash -c "~/.rvm/bin/rvm install {{ rubyversion }}"when: rvmdir.stat.exists == False- name: Set Ruby Defaultcommand: bash -c "~/.rvm/bin/rvm --default use {{ rubyversion }}"when: rvmdir.stat.exists == False- name: Install Ruby Gems required for iOS app developementgem: name={{ item.name }} version={{ item.version }} state={{ if item.version is defined nil else latest }}withitems: "{{ rubygems_packages_to_install }}"when: rvmdir.stat.exists == False
  1. Проверяем, установлен ли rvm.

    name - название задачи

    stat - путь до rvm

    register сохраняет переменную для последующего использования

    changed_when переопределяет поведение смены состояния машины после исполнения

    Мы точно знаем, что после исполнения этой задачи состояние не изменится, поэтому просто ставим false. Более подробно использование этой команды описано здесь.

  2. Устанавливаем rvm.

    name - название задачи

    command - команда для выполнения

    when - условие для выполнения

    Условием для выполнения здесь является отсутствие установленного ранее rvm, мы можем узнать это из сохраненной на предыдущем шаге переменной rvmdir.

  3. Настраиваем rvm. Параметры аналогичны.

  4. Добавляем строки в .zshrc конфиг. На MacOS Catalina этот шаг обязателен, так как zsh теперь используется по умолчанию.

  5. Устанавливаем нужную нам для разработки версию Ruby. Тут параметры тоже аналогичны, единственным отличием будет использование нашей переменной из папки vars. Объявлена она в main.yml следующим образом:

    rubyversion: ruby-2.6.5

  6. Назначаем свежеустановленную версию Ruby версией по умолчанию

  7. Устанавливаем нужные гемы, в нашем случае это просто bundler. Тут также используется переменная из папки vars, объявленная таким образом:

    rubygems_packages_to_install:

    - name: bundler

    version:2.1.4

На этом настройка окружения закончена, можем переходить к настройке самого проекта.

Настройка проекта

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

Скрипт для настройки проекта выглядит следующим образом:

#!/usr/bin/rubyrequire 'FileUtils'require 'colorize'RESOURCES_DIRECTORY = './Scripts/Resources'.freezeXCODE_DIRECTORY = '~/Library/Developer/Xcode'.freezedef setup_git_hooks  puts "Setting up git hooks".blue.bold  git_hooks_path = '.git/hooks'  FileUtils.mkdir_p(git_hooks_path)  Dir["#{RESOURCES_DIRECTORY}/Git hooks/*"].each do |file|FileUtils.cp_r(file, "#{git_hooks_path}/#{File.basename(file)}")  endenddef setup_file_templates  puts "\nSetting up file templates".blue.bold  file_templates_path = File.expand_path("#{XCODE_DIRECTORY}/Templates/File Templates/Tests")  FileUtils.mkdir_p(file_templates_path)  Dir["#{RESOURCES_DIRECTORY}/Templates/*.xctemplate"].each do |file|FileUtils.cp_r(file, "#{file_templates_path}/#{File.basename(file)}")  endenddef setup_xcode_snippets  puts "\nSetting up xcode snippets".blue.bold  need_to_reboot_xcode = false  code_snippets_path = File.expand_path("#{XCODE_DIRECTORY}/UserData/CodeSnippets")  FileUtils.mkdir_p(code_snippets_path)  Dir["#{RESOURCES_DIRECTORY}/Snippets/*.codesnippet"].each { |file|path = "#{code_snippets_path}/#{File.basename(file)}"next if File.file?(path)need_to_reboot_xcode = trueFileUtils.cp_r(file, path)  }  return unless need_to_reboot_xcode  puts 'Quiting Xcode'.blue  system('killall Xcode')enddef setup_gems  puts "\nSetting up gems".blue.bold  system('bundle install')enddef setup_mocks  puts "\nSetting up mocks".blue.bold  system('cd $(pwd) && swiftymocky generate')enddef setup_projects  puts "\nSetting up projects".blue.bold  system('bundle exec rake update_projects')end# StepsDir.chdir("#{File.expand_path(File.dirname(__FILE__))}/..")setup_git_hookssetup_file_templatessetup_xcode_snippetssetup_gemssetup_mockssetup_projects

Что тут вообще происходит:

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

  2. Установка темплейтов для генерации модулей.

  3. Установка полезных сниппетов.

  4. Установка гемов.

  5. Генерация моков.

  6. Настройка проекта.

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

Заключение

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

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

Подробнее..

Подходы к спискам на UICollectionView

14.04.2021 18:22:15 | Автор: admin

Введение

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

Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.

Почему коллекция?

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

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

Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.

  • Ячейки в таблице содержат лишние элементы: content view, group editing view, slide actions view, accessory view.

  • Использование UICollectionView дает единообразность при работе с любыми списками объектов, так как ее API в целом схож с UITableView.

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

Так же у нас были некоторые опасения:

  • Возможность использовать Pull to refresh

  • Отсутсвие лагов при отрисовке

  • Возможность скролла в ячейках

Но в ходе реализации все они развеялись.

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

Адаптеры

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

final class CurrencyViewController: UIViewController {    var tableView = UITableView()    var items: [ViewModel] = []    func setup() {        tableView.delegate = self        tableView.dataSource = self        tableView.backgroundColor = .white    tableView.rowHeight = 72.0                        tableView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)        tableView.reloadData()    }}extension CurrencyViewController: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        output.didSelectBalance(at: indexPath.row)    }}extension CurrencyViewController: UITableViewDataSource {    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        items.count    }    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {                let cell = tableView.dequeueReusable(cell: object.cellClass, at: indexPath)        cell.setup(with: object)                return cell    }}extension UITableView {    func dequeueReusable(cell type: UITableViewCell.Type, at indexPath: IndexPath) -> UITableViewCell {        if let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name()) {            return cell        }        self.register(cell: type)        let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name(), for: indexPath)        return cell    }    private func register(cell type: UITableViewCell.Type) {        let identifier: String = type.name()                self.register(type, forCellReuseIdentifier: identifier)     }}

Приходят на помощь джедаи адаптеры.

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

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

private let listAdapter = CurrencyVerticalListAdapter()private let collectionView = UICollectionView(    frame: .zero,    collectionViewLayout: UICollectionViewFlowLayout())private var viewModel: BalancePickerViewModelfunc setup() {    listAdapter.setup(collectionView: collectionView)    collectionView.backgroundColor = .c0    collectionView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)    listAdapter.onSelectItem = output.didSelectBalance    listAdapter.heightMode = .fixed(height: 72.0)    listAdapter.spacing = 8.0    listAdapter.reload(items: viewModel.items)}

Однако внутри адаптер представляет собой даже не один класс.

Рассмотрим для начала базовый (и вообще говоря абстрактный) класс адаптера списков:

public class ListAdapter<Cell> : NSObject, ListAdapterInput, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDragDelegate, UICollectionViewDropDelegate, UIScrollViewDelegate where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView {    public typealias Model = Cell.Model    public typealias ResizeCallback = (_ insertions: [Int], _ removals: [Int], _ skipNext: Bool) -> Void    public typealias SelectionCallback = ((Int) -> Void)?    public typealias ReadyCallback = () -> Void    public enum DragAndDropStyle {        case reorder        case none    }    public var dragAndDropStyle: DragAndDropStyle { get set }    internal var headerModel: ListHeaderView.Model?    public var spacing: CGFloat    public var itemSizeCacher: UICollectionItemSizeCaching?    public var onSelectItem: ((Int) -> Void)?    public var onDeselectItem: ((Int) -> Void)?    public var onWillDisplayCell: ((Cell) -> Void)?    public var onDidEndDisplayingCell: ((Cell) -> Void)?    public var onDidScroll: ((CGPoint) -> Void)?    public var onDidEndDragging: ((CGPoint) -> Void)?    public var onWillBeginDragging: (() -> Void)?    public var onDidEndDecelerating: (() -> Void)?    public var onDidEndScrollingAnimation: (() -> Void)?    public var onReorderIndexes: (((Int, Int)) -> Void)?    public var onWillBeginReorder: ((IndexPath) -> Void)?    public var onReorderEnter: (() -> Void)?    public var onReorderExit: (() -> Void)?    internal func subscribe(_ subscriber: AnyObject, onResize: @escaping ResizeCallback)    internal func unsubscribe(fromResize subscriber: AnyObject)    internal func subscribe(_ subscriber: AnyObject, onReady: @escaping ReadyCallback)    internal func unsubscribe(fromReady subscriber: AnyObject)    internal weak var collectionView: UICollectionView?    public internal(set) var items: [Model] { get set }    public func setup(collectionView: UICollectionView)    public func setHeader(_ model: ListHeaderView.Model)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: @escaping (ListAdapter) -> Void, completion: ((Bool) -> Void)?)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)    }public typealias ListAdapterCellConstraints = UICollectionViewCell & RegistrableView & AnimatedConfigurableViewpublic typealias VerticalListAdapterCellConstraints = ListAdapterCellConstraints & HeightMeasurableViewpublic typealias HorizontalListAdapterCellConstraints = ListAdapterCellConstraints & WidthMeasurableView

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

Как можно увидеть из примера выше: сначала идёт блок typealias'ов для того, чтобы определить ограничения на используемые типы.

DragAndDropStyle отвечает за возможность менять местами ячейки внутри коллекции.

headerModel - модель, которая представляет заголовок коллекции

spacing - расстояние между элементами

Дальше идёт блок замыканий, которые позволяют подписаться на определённые изменения в коллекции.

Методы для подписки onReady и onResize позволяют понять, когда коллекция адаптера стала готова к работе, и когда изменился размер коллекции из-за добавления или удаления объектов, соответственно.

collectionView, setup(collectionView:) - непосредственно используемый экземпляр коллекции и метод для её установки

items - набор моделей для отображения

setHeader - метод для установки заголовка коллекции

itemSizeCacher - класс, реализующий кеширование размеров элементов списка. Дефолтная реализация представлена ниже:

final class DefaultItemSizeCacher: UICollectionItemSizeCaching {        private var sizeCache: [IndexPath: CGSize] = [:]        func itemSize(cachedAt indexPath: IndexPath) -> CGSize? {        sizeCache[indexPath]    }        func cache(itemSize: CGSize, at indexPath: IndexPath) {        sizeCache[indexPath] = itemSize    }        func invalidateItemSizeCache(at indexPath: IndexPath) {        sizeCache[indexPath] = nil    }        func invalidate() {        sizeCache = [:]    }    }

Остальную часть интерфейса представляют методы для обновления элементов.

Также есть конкретные реализации, которые, например, заточены под определенное расположение ячеек по оси.

AnyListAdapter

До тех пор, пока мы работаем с динамическим контентом, все хорошо. Но во введении мы не зря говорили про infinite-scroll дизайн. Что делать, если в таблице нужно одновременно отображать и ячейки динамического контента(данные из сети) и статические вью? Для этого нам послужит AnyListAdapter.

public typealias AnyListSliceAdapter = ListSliceAdapter<AnyListCell>public final class AnyListAdapter : ListAdapter<AnyListCell>, UICollectionViewDelegateFlowLayout {    public var dimensionCalculationMode: DesignKit.AnyListAdapter.DimensionCalculationMode    public let axis: Axis    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.HeightMeasurableView, Cell : DesignKit.RegistrableView    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView, Cell : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.HeightMeasurableView, C3 : DesignKit.RegistrableView    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.RegistrableView, C3 : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    public enum Axis {        case horizontal        case vertical    }    public enum DimensionCalculationMode {        case automatic        case fixed(constant: CGFloat? = nil)    }}

Как не трудно догадаться, AnyListAdapter абстрагируется от конкретного типа ячейки. Его можно проинициализировать несколькими типами ячеек, но они все должны быть либо для горизонтального лейаута, либо вертикального. Условием здесь выступает удовлетворение протоколу HeightMeasurableView и WidthMeasurableView.

public protocol HeightMeasurableView where Self: ConfigurableView {    static func calculateHeight(model: Model, width: CGFloat) -> CGFloat    func measureHeight(model: Model, width: CGFloat) -> CGFloat   }public protocol WidthMeasurableView where Self: ConfigurableView {    static func calculateWidth(model: Model, height: CGFloat) -> CGFloat    func measureWidth(model: Model, height: CGFloat) -> CGFloat}

У списка так же фиксируется алгоритм подсчета высоты:

  • фиксированный(константа или статический метод расчета по модели)

  • автоматический (на основе лейаута).

Сила вся внутри ячейки-контейнера AnyListCell спрятана.

public class AnyListCell: ListAdapterCellConstraints {        // MARK: - ConfigurableView        public enum Model {        case `static`(UIView)        case `dynamic`(DynamicModel)    }        public func configure(model: Model, animated: Bool, completion: (() -> Void)?) {        switch model {        case let .static(view):            guard !contentView.subviews.contains(view) else { return }                        clearSubviews()            contentView.addSubview(view)            view.layout {                $0.pin(to: contentView)            }        case let .dynamic(model):            model.configure(cell: self)        }        completion?()    }        // MARK: - RegistrableView        public static var registrationMethod: ViewRegistrationMethod = .class        public override func prepareForReuse() {        super.prepareForReuse()                clearSubviews()    }        private func clearSubviews() {        contentView.subviews.forEach {            $0.removeFromSuperview()        }    }    }

Такая ячейка конфигурируется двумя видами модели: статической и динамической.

Первая как раз отвечает за отображение в списке обычных вью.

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

struct DynamicModel {    public init<Cell>(model: Cell.Model,                    cell: Cell.Type) {            // ...    }    func dequeueReusableCell(from collectionView: UICollectionView, for indexPath: IndexPath) -> UICollectionViewCell    func configure(cell: UICollectionViewCell)    func calcucalteDimension(otherDimension: CGFloat) -> CGFloat    func measureDimension(otherDimension: CGFloat) -> CGFloat}

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

private let listAdapter = AnyListAdapter(    dynamicCellTypes: (CommonCollectionViewCell.self, OperationCell.self))func configureSearchResults(with model: OperationsSearchViewModel) {    var items: [AnyListCell.Model] = []    model.sections.forEach {        let header = VerticalSectionHeaderView().configured(with: $0.header)        items.append(.static(header))        switch $0 {        case .tags(nil), .operations(nil):            items.append(                .static(OperationsNoResultsView().configured(with: Localisation.feed_search_no_results))            )        case let .tags(models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: CommonCollectionViewCell.self                    ))                }            )        case .operations(let models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: OperationCell.self                    ))                }            )        }    }    UIView.performWithoutAnimation {        listAdapter.deleteItemsIfNeeded(at: 0...)        listAdapter.reloadItems(items, at: 0...)    }}

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

Список по кускам

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

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

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

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

Приведем пример сложного экрана.

let subjectsSectionHeader = SectionHeaderView(title: "Subjects")let pocketsSectionHeader = SectionHeaderView(title: "Pockets")let cardsSectionHeader = SectionHeaderView(title: "Cards")let categoriesHeader = SectionHeaderView(title: "Categories")let list = AnyListAdapter()listAdapter.reloadItems([    .static(subjectsSectionHeader),    .static(pocketsSectionHeader)    .static(cardsSectionHeader),    .static(categoriesHeader)])

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

class PocketsViewController: UIViewController {    var listAdapter: AnyListSliceAdapter! {        didSet {reload()        }    }    var pocketsService = PocketsService()    func reload() {        pocketsService.fetch { pockets, error in            guard let pocket = pockets else { return }            listAdapter.reloadItems(                pockets.map { .dynamic(.init(model: $0, cell: PocketCell.self)) },                at: 1...            )        }    }    func didTapRemoveButton(at index: Int) {listAdapter.deleteItemsIfNeeded(at: index)    }}let subjectsVC = PocketsViewController()subjectsVC.listAdapter = list[1..<2]

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

public extension ListAdapter {    subscript(range: Range<Int>) -> ListSliceAdapter<Cell> {        .init(listAdapter: self, range: range)    }    init(listAdapter: ListAdapter<Cell>,               range: Range<Int>) {        self.listAdapter = listAdapter        self.sliceRange = range        let updateSliceRange: ([Int], [Int], Bool) -> Void = { [unowned self] insertions, removals, skipNextResize in            self.handleParentListChanges(insertions: insertions, removals: removals)            self.skipNextResize = skipNextResize        }        let enableWorkingWithSlice = { [weak self] in            self?.onReady?()            return        }        listAdapter.subscribe(self, onResize: updateSliceRange)        listAdapter.subscribe(self, onReady: enableWorkingWithSlice)    }}

Теперь работать с секцией списка можно ничего не зная об оригинальном списке и не беспокоясь о правильности индексации.

Кроме данных о рендже слайса, интерфейс слайс адаптера мало чем отличается от оригинального ListAdapter.

public final class ListSliceAdapter<Cell> : ListAdapterInput where Cell : UICollectionViewCell, Cell : ConfigurableView, Cell : RegistrableView {    public var items: [Model] { get }    public var onReady: (() -> Void)?    internal private(set) var sliceRange: Range<Int> { get set }    internal init(listAdapter: ListAdapter<Cell>, range: Range<Int>)    convenience internal init(listAdapter: ListAdapter<Cell>, index: Int)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)}

Нетрудно догадаться, что внутри проксирующих методов происходит математика индексов.

public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>) {    guard canDelete(index: range.lowerBound) else { return }    let start = globalIndex(of: range.lowerBound)    let end = sliceRange.upperBound - 1    listAdapter.deleteItems(at: Array(start...end))}

При этом ключевую роль играет поддержка кусков внутри самого ListAdapter.

public class ListAdapter {    // ...    var resizeSubscribers = NSMapTable<AnyObject, NSObjectWrapper<ResizeCallback>>.weakToStrongObjects()}extension ListAdapter {public func appendItem(_ item: Model) {        let index = items.count               let changes = {            self.items.append(item)            self.handleSizeChange(insert: self.items.endIndex)            self.collectionView?.insertItems(at: [IndexPath(item: index, section: 0)])        }                if #available(iOS 13, *) {            changes()        } else {            performBatchUpdates(updates: changes, completion: nil)        }    }    func handleSizeChange(removal index: Int) {        notifyAboutResize(removals: [index])    }    func handleSizeChange(insert index: Int) {        notifyAboutResize(insertions: [index])    }    func notifyAboutResize(insertions: [Int] = [], removals: [Int] = [], skipNextResize: Bool = false) {        resizeSubscribers            .objectEnumerator()?            .allObjects            .forEach {                ($0 as? NSObjectWrapper<ResizeCallback>)?.object(insertions, removals, skipNextResize)            }    }    func shiftSubscribers(after index: Int, by shiftCount: Int) {        guard shiftCount > 0 else { return }        notifyAboutResize(            insertions: Array(repeating: index, count: shiftCount),            skipNextResize: true        )    }}

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

Выводы

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

И, что для ленивых самое важное - сетап экрана со списком занимает меньше 10 строк кода.

Если мы раньше боялись усложнять экран работой с таблицей для отображения разнородных данных, то сейчас смело пишем каждый третий экран( ~30%) на списках, вооружившись одним из нашего обширного арсенала адаптеров. А если хотите в модульную декомпозицию - то к вашим услугам адаптеры для куска списка.

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

Подробнее..

Погружение в автотестирование на iOS. Часть 2. Как взаимодействовать с ui-элементами iOS приложения в тестах

25.01.2021 06:07:56 | Автор: admin

Привет, Хабр!

В прошлой статье мы разобрались:

  • Что такое ui-тесты и для чего они нужны;

  • Как настроить окружение для тестов;

  • Как находить ui-элементы в проекте и проставлять им accessibilityidentifier.

В этой статье мы разберем:

  1. Как обращаться и инициализировать ui-элементы в ваших тестах;

  2. Как взаимодействовать с ui-элементами приложения;

  3. Как писать ассерты для проверки в автотесте ожидаемого результата.

Как обращаться и инициализировать ui-элементы

При наличии айдишника у ui-элемента, достаточно указать его при обращении.

XCUIApplication().buttons["Help"]

Если же у вас нет id у элемента, есть способ найти его при помощи XCUIElementQuery. Этот класс позволяет искать элемент несколькими способами.

// Находит все кнопки внутри scroll view (отобразит кнопки только прямого потомка scroll view)XCUIApplication().scrollViews["Main"].children(matching: .button)// Находит все кнопки внутри scroll view (отобразит кнопки прямого потомка scroll view, но также и его потомков)XCUIApplication().scrollViews["Main"].descendants(matching: .button)    // Находит четвертую кнопку на экранеXCUIApplication().buttons.element(boundBy: 3)// Находит в scroll view ui-элемент содержащий label = identifierXCUIApplication().scrollViews["Main"].containing(NSPredicate(format: "label == %@","identifier").element// Находит первую кнопку на экранеXCUIApplication().buttons.firstMatch

Немного про NSPredicate это класс, который позволяет фильтровать объекты по нужному вам условию. Статья с хорошим объяснением как использовать NSPredicate.

Пример иницилизации переменной:

let moneyTitle: XCUIElement = XCUIApplication().staticTexts["accessibilityID"]

Взаимодействия с ui-элементами приложения

Нажатие и удержание

Вы можете в своих тестах совершать: нажатие, удержание и drag&drop ui-элементов.

Перечень методов можно посмотреть здесь, раздел Tapping and Pressing.

// Совершаем нажатие на ui-элементXCUIApplication().buttons.element.tap()// Cовершаем двойное нажатие на ui-элементXCUIApplication().buttons.element.doubleTap()// Удерживаем нажатие в течение времени, которое передали в forDurationXCUIApplication().buttons.element.press(forDuration: 3)// Совершаем нажатие на ui-элемент и затем перетаскиваем его к другому ui-элементуXCUIApplication().buttons.element.press(forDuration: 3, thenDragTo: XCUIApplication().searchFields.element)

Ввод текста

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

XCUIApplication().textFields.element.tap()XCUIApplication().keys["h"].tap()XCUIApplication().keys["e"].tap()XCUIApplication().keys["l"].tap()XCUIApplication().keys["p"].tap()

Либо вводить целую строку:

XCUIApplication().textFields.element.typeText("help")

Информация по методу typeText

Множественные нажатия

Вы можете совершать множественные нажатия в своих тестах.

// Совершаем нажатие двумя пальцами на ui-элементXCUIApplication().buttons.element.twoFingerTap()/*     Совершаем нажатие на элемент столько раз сколько передали     в withNumberOfTaps и столькими "пальцами" сколько передали     в numberOfTouches*/XCUIApplication().buttons.element.tap(withNumberOfTaps: 1, numberOfTouches: 1)

Перечень методов можно посмотреть здесь, раздел Multiple Taps.

Жесты

Вы можете совершать разные жесты в своих тестах.

// Совершаем свайп в указанном направленииswipeLeft()swipeRight()swipeUp()swipeDown()// Совершаем свайп в указанном направлении с заданной скоростьюswipeLeft(velocity: 0.5)swipeRight(velocity: 0.5)swipeUp(velocity: 0.5)swipeDown(velocity: 0.5)// Совершаем приближения ui-элемента (withScale указываем больше 1)XCUIApplication().images.element(boundBy: 0).pinch(withScale: 2, velocity: 1) // Совершаем отдаления ui-элемента (withScale указываем от 0 до 1)XCUIApplication().images.element(boundBy: 0).pinch(withScale: 0.5, velocity: 1)// Совершаем вращение ui-элементаXCUIApplication().images.element(boundBy: 0).rotate(0.5, withVelocity: 0.5)

Перечень методов можно посмотреть здесь, раздел Performing Gestures.

Взаимодействие с UISlider

UISlider это элемент управления для выбора одного значения из диапазона значений.

Когда мы хотим изменить положение ползунка в слайдере, мы не передаем значение, которое хотим установить. Вместо этого мы выбираем число в диапазоне от 0 до 1. Где 0 это минимальное значение в слайдере, а 1 максимальное. Представим, что у нас есть слайдер с максимальным значением 100 и нам нужно сдвинуть ползунок на значение 25. Это будет выглядеть так:

XCUIApplication().sliders.element.adjust(toNormalizedSliderPosition: 0.25)

Взаимодействие с UIPickerView и UIDatePicker

UIPickerView и UIDatePicker это ui-элементы, которые используют "колесики" для выбора необходимых значений.

XCUIElement имеет специальный метод для взаимодействия с UIPickerView и UIDatePicker:

  • Для пикеров с одним колесом, мы можем получить доступ через element(), и указать значение, которое хотим выбрать;

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

// Пикер с одним колесомXCUIApplication().pickerWheels.element.adjust(toPickerWheelValue: "BMW")// Пикер с несколькими колесамиXCUIApplication().pickerWheels.elementBoundByIndex(0).adjust(toPickerWheelValue: "BMW")XCUIApplication().pickerWheels.elementBoundByIndex(1).adjust(toPickerWheelValue: "X6")

Взаимодействие с системным алертом

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

Чтобы взаимодействовать с ним, вам понадобится использовать метод addUIInterruptionMonitor(withDescription:handler:)

Где вы передаете:

  • withDescription заголовок алерта;

  • handler - действие, которое хотите совершить.

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

addUIInterruptionMonitor(withDescription: "Current Location Not Available") { alert in    alert.buttons["OK"].tap()    return true}

Взаимодействие с Navigation Bar

Navigation bar это панель навигации, отображается в верхней части экрана приложения под status bar и позволяет перемещаться по приложению.

Представим, что у нас есть две кнопки и текст по середине в Navigation Bar.

Вот пример того как можно их иницилизировать и в дальнейшем с ними взаимодействовать:

// Иницилизируем крайнюю левую кнопку в Navigation bar let leftNavBarButton = XCUIApplication().navigationBars.children(matching: .button).firstMatch// Иницилизируем тест посередине в Navigation bar let topicNavBar = XCUIApplication().navigationBars.children(matching: .staticTexts).firstMatch// Иницилизируем крайнюю правую кнопку в Navigation barlet rightNavBarButton = XCUIApplication().navigationBars.children(matching: .button).element(boundBy: 1)// Нажимаем на кнопки в Navigation bar leftNavBarButton.tap()rightNavBarButton.tap()// Проверяем заголовок в Navigation bar XCTAssertEqual(topicNavBar.title, "Topic")

Взаимодействие с Tab bar

Tab bar это панель вкладок, отображается в нижней части экрана приложения. Она даёт возможность быстро переключаться между различными разделами приложения.

Для переключения между вкладками достаточно тапать на индекс элемента в Tab bar.

// Открываем первую вкладку XCUIApplication().tabBars.buttons.element(boundBy: 0)// Открываем третью вкладкуXCUIApplication().tabBars.buttons.element(boundBy: 2)

Создание ассертов:

Ассерты это проверки необходимого условия.

Рассмотрим несколько вариантов их использования:

// Ассерт, что кнопка отображается на экранеXCTAssertTrue(XCUIApplication().buttons["Warning"].exists)// Ассерт, что кнопка не выделенаXCTAssertFalse(XCUIApplication().buttons["Warning"].isSelected)// Ассерт, что title кнопки равен - BuyXCTAssertEqual(XCUIApplication().buttons.element.title, "Buy")// Ассерт, что placeholder в textFields не равен - placeHolderXCTAssertNotEqual(XCUIApplication().textFields.element.placeholderValue, "placeHolder")// Ассерт, что value в textFields равно - valueXCTAssertEqual(XCUIApplication().textFields.element.value, "value")

Полный перечень возможных ассертов можно посмотреть здесь, раздел Test Assertions

Перечень возможных атрибутов ui-элементов можно посмотреть здесь

Заключение:

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

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

  • Как делать предусловия и послеусловия;

  • Как сбрасывать статус пермишенов приложения перед запуском тестов (доступ к галерее, фото и так далее);

  • Как запускать приложения по bundle identifier (например запуск сафари, документов и так далее);

  • И многое другое.

Подробнее..

Перевод Нестабильные(Flaky) тесты одна из основных проблем автоматизированного тестирования

26.04.2021 10:24:01 | Автор: admin

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

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

Данная статья призвана рассказать как бороться с каждой из причин.

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

  • Сами тесты;

  • Фреймворк для запуска тестов;

  • Сервисы и библиотеки, от которых зависит тестируемая система и тестовый фреймворк;

  • Операционная система и устройство с которым взаимодействует фреймворк автотестирования.

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

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

Как обсуждалось выше, каждый из этих компонентов является потенциальной областью нестабильных тестов

Сами тесты

Сами тесты могут вызвать нестабильность. Типичные причины:

  • Неправильная инциализация или очистка;

  • Неправильно подобранные тестовые данные;

  • Неправильное предположение о состоянии системы. Примером может служить системное время;

  • Зависимость от асинхроных действий;

  • Зависимость от порядка запуска тестов.

Фреймворк для запуска тестов

Ненадежный фреймворк для запуска тестов может привести к нестабильности. Типичные причины:

  • Неспособность выделить достаточно ресурсов для тестируемой системы, что приводит к ее сбою;

  • Неправильное планирование тестов, поэтому они "противоречат" и приводят к сбою друг друга;

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

Сервисы и библиотеки, от которых зависит тестируемая система и тестовый фреймворк

Приложение (или тестируемая система) может быть источником нестабильности

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

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

Типичные причины:

  • Состояние гонки;

  • Непроинициализированные переменные;

  • Медленный ответ или отсутствие ответа при запросе от теста;

  • Утечки памяти;

  • Избыточная подписка на ресурсы;

  • Изменения в приложении и в тестах происходят с разной скоростью.

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

Герметичная среда менее подвержена нестабильности.

Операционная система и устройство с которым взаимодействует фреймворк автотестирования

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

  • Сбои или нестабильность сети;

  • Дисковые ошибки;

  • Ресурсы, потребляемые другими задачами / службами, не связанными с выполняемыми тестами.

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

В следующих статьях мы рассмотрим способы решения этих проблем.

Ссылки на источники

Подробнее..

Перевод Нестабильные тесты одна из основных проблем автоматизированного тестирования(Часть 2)

05.05.2021 08:04:43 | Автор: admin

Это продолжение серии статей о нестабильных тестах.

В первой статье(оригинал/перевод на хабре) говорилось о 4 компонентах, в которых могут возникать нестабильные тесты.

В этой статье дадим советы как избежать нестабильных тестов в каждом из 4 компонентов.

Компоненты

Итак 4 компонента в которых могут возникать нестабильные тесты:

  • Сами тесты;

  • Фреймворк для запуска тестов;

  • Сервисы и библиотеки, от которых зависит тестируемая система и тестовый фреймворк;

  • Операционная система и устройство с которым взаимодействует фреймворк автотестирования.

Это отображено на рисунке 1.

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

Сами тесты

Сами тесты могут быть нестабильными.

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

Таблица 1 Причины, варианты локализации проблемы и варианты решения нестабильности в самих тестах.

Причины нестабильных тестов

Варианты локализации проблемы

Варианты решения

Неправильная инициализация или очистка.

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

Явно инициализируйте все переменные правильными значениями перед их использованием. Правильно настройте и очистите тестовую среду. Убедитесь, что первый тест не вредит состоянию тестовой среды.

Неправильно подобранные тестовые данные.

Перезапустите тесты самостоятельно.

Сделайте тесты независимыми от какого-либо состояния из других тестов и предыдущих запусков.

Неправильное предположение о состоянии системы. Примером может служить системное время.

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

Удалите или изолируйте зависимости вашего приложение от аспектов среды, которые вы не контролируете.

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

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

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

Зависимость от порядка запуска тестов (Вариант решения схож с второй причиной).

Перезапустите тесты самостоятельно.

Сделайте тесты независимыми от какого-либо состояния из других тестов и предыдущих запусков.

Фреймворк для запуска тестов

Ненадежный фреймворк для запуска тестов может привести к нестабильности

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

Причины нестабильных тестов

Варианты локализации проблемы

Варианты решения

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

Проверьте логи, чтобы удостовериться появилось ли приложение.

Выделите достаточно ресурсов.

Неправильное планирование тестов, поэтому они "противоречат" и приводят к сбою друг друга.

Запустите тесты в другом порядке.

Сделайте тесты независимыми друг от друга.

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

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

Устрани утечки памяти или другие утечки ресурсов. Выделите достаточно ресурсов для прогона тестов.

Сервисы и библиотеки, от которых зависит тестируемая система и тестовый фреймворк

Приложение (или тестируемая система) может быть источником нестабильности.

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

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

Таблица 3 Причины, варианта локализации проблемы, и варианты решения нестабильности в приложении или тестируемой системе

Причины нестабильных тестов

Варианты локализации проблемы

Варианты решения

Состояние гонки.

Логируйте доступ к общим ресурсам.

Добавьте в тесты элементы синхронизации, чтобы они ждали определенных состояний приложения. НЕ ДОБАВЛЯЙТЕ явные ожидания, это может привести к нестабильности тестов в будущем.

Непроинициализированные переменные.

Ищите предупреждения компилятора о неинициализированных переменных.

Явно инициализируйте все переменные правильными значениями перед их использованием.

Медленный ответ или отсутствие ответа при запросе от теста.

Логируйте время когда делаются запросы и ответы.

Проверьте и устраните все причины задержек.

Утечки памяти.

Посмотрите на потребление памяти во время прогона тестов. В обнаружении проблемы поможет инструмент Valgrind.

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

Избыточная подписка на ресурсы.

Проверьте логи, чтобы узнать не закончились ли ресурсы.

Выделите достаточно ресурсов для запуска тестов.

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

Изучите историю изменений.

Введите правило при изменении кода, писать на это тесты.

Операционная система и устройство с которым взаимодействует фреймворк автотестирования

Наконец, оборудование и операционная система могут быть источником нестабильности тестов.

Таблица 4 Причины, варианта локализации проблемы, и варианты решения нестабильности в ОС и устройстве с которым взаимодействует фреймворк автотестирования

Причины нестабильных тестов

Варианты локализации проблемы

Варианты решения

Сбои или нестабильность сети.

Проверьте наличие ошибок в системных логах.

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

Дисковые ошибки.

Проверьте наличие ошибок в системных логах.

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

Ресурсы, потребляемые другими задачами / службами, не связанными с выполняемыми тестами.

Изучите активность системного процесса.

Сократите активность процессов не связанных с прогоном тестов.

Заключение

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

Ссылки на источники

Подробнее..

Как сохранить нервы тестировщика или ускорить регресс с 8 до 2 часов

24.05.2021 22:08:50 | Автор: admin

Кукусики!

Меня зовут Юля, и я Mobile QA в компании Vivid Money.

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

В этой статье я расскажу, КАК ОБЛЕГЧИТЬ ЖИЗНЬ ТЕСТИРОВЩИКУ ВО ВРЕМЯ РЕГРЕССА!

Расскажу по порядку:

  1. Наши процессы (для полноты картины)

  2. Основную проблему

  3. Анализ

  4. Методы решения, с полученными результатами

Немного о наших процессах

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

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

Практически все позитивные сценарии проверки покрыты тест кейсами, которые ведутся в Allure TestOps.

У каждой платформы (я имею ввиду iOS, Android) своя документация и автотесты, но все хранится в одном месте. Любой QA из команды может посмотреть и отредактировать их. Если добавляются новые кейсы, то они обязательно проходят ревью. Тестировщик Android проводит ревью для iOS, и наоборот. Это актуально для ручных тестов.

Про тест план для регресса:

Для проведения регрессионного тестирования, составляется тест план с ручными тест кейсами и автотестами, отдельно для Android и iOS. Тестировщик собирает лаунч (запуск тест плана), в котором указывает версию релизной сборки и платформу. После создания лаунча, запускаются автотесты с выбранными кейсами, а ответственный за ручное тестирование назначает мануальные тест кейсы на себя. Каждый проходимый кейс отмечается статусом: Passed, Failed или Skipped. В ходе проверки сразу отображаются результаты.

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

Определим проблему

Увеличение объема тестируемого функционала при проведении регресса, и выход из временных рамок.
Или тест кейсов все больше и больше, а времени у нас только 8 часов максимум!

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

Анализ и решение

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

Расписав слабые места, мы решили доработать подход к автоматизации, а еще воспользовались импакт-анализом для выделения методов решения.

Impact Analysis (импакт анализ) это исследование, которое позволяет указать затронутые места в проекте при разработке новой или изменении старой функциональности.

Что же мы решили изменить, чтобы разгрузить ручное тестирование и сократить регресс:

  1. Увеличение количества автотестов и разработка единого сценария перевода тест кейсов в автоматизацию

  2. Разделение тестируемого функционала на фронтенд и бэкенд

  3. Изменение подхода к формированию тест плана на регресс и смоук

  4. Подключение автоматического анализа изменений, входящих в релизную сборку

Ниже я расскажу про каждый пункт более подробно и какие результаты были получены после введения.

Увеличение количества автотестов

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

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

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

  2. В Allure TestOps дорабатываются тест кейсы, например добавляется больше описания или json.

  3. Переводятся соответствующие тест кейсы в статус need to automate (так же в Allure TestOps)

  4. Создается задача в Youtrack. В ней описывается, что нужно автоматизировать. Прикладываются ссылки на тест кейсы из Allure TestOps. И назначается ответственный AQA.

  5. Затем, задачи из Youtrack берутся в работу исходя из приоритетов. После того как изменения влиты в нужные ветки и прошли ревью, задачи закрываются, а тест кейсы в Allure переводятся в Automated со статусом Active. Ревью кода автотестов проводят разработчики.

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

Результаты:

  • Сокращение нагрузки на ручное тестирование.

  • Четкий и простой механизм перевода в автоматизацию. Все заняты - нет простоев.

  • Больше функционала покрыто автотестами, которые гоняются каждый день. Раньше обнаруживаются баги.

Backend и frontend отдельно

Автоматизация тестирования у нас разделена для backend и frontend.

Но есть E2E тесты, которые тестируют взаимодействие.

E2E (end-to-end) или сквозное тестирование это когда тестируется вся система от начала до конца. Включает в себя обеспечение того, чтобы все интегрированные части приложения функционировали и работали вместе, как ожидалось.

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

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

Было принято четко разделить функционал на модули с выделением логики на фронтенде и бэкенде. Оставить минимальное количество Е2Е тестов для ручного тестирования. Остальные сценарии упростить и автоматизировать. И так на бэкенде мы проверяем бизнес логику, а на клиенте корректное отображение данных от бэке и ui элементы.

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

Для наглядности вот табличка:

Описание функционала

Локализация тестов

Простая валидация полей (например при смене пароля)

клиент

Размещение ui элементов на экране

клиент

Отрисовка ui элементов

клиент

Отображение информации от бэка

клиент

Навигация по экранам

клиент

Корректная обработка и отображение ошибок

клиент

Сложная валидация (например проверка формата TIN)

бэк

Сбор данных для профиля

бэк

Сбор и обработка данных по операциям

бэк

Создание и сохранение данных при работе с картами

бэк

Работа сервисов

бэк

Взаимодействие с БД

бэк

Обработка ошибок

бэк

Результаты

После разделения:

  • Стало проще локализовать проблему

  • Раньше определяются проблемы и соответственно решаются быстрее

  • Есть четкое разграничение зон ответственности. Нет лишних проверок на клиенте.

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

  • Сократилось время на реализацию автотестов, не нужно добавлять json в тест кейсы дополнительно при написании

Отфильтровали тест кейсы в тест плане на регресс

Формирование тест плана на регресс, исходя из того, в каких блоках были внесены изменения. А также выбор основных постоянных сценариев проверки.

Для того, чтобы проще было формировать план, мы стали использовать теги.

Пример: Regress_Deeplink, Regress_Profile, Regress_CommonMobile

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

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

Результаты

Введение дополнительного анализа, при формировании тест планов, помогло сократить общее время прохождения регрессионного тестирования всего до 2 часов с 8ми изначальных. У нас есть несколько тест планов full и light. Обычно мы проходим light и он состоит из 98 кейсов (автотестов+ручных). Как видно на скрине, полный план регресса состоит из 297 тест кейсов!

Время на прохождение Regress iOS light в среднем составляет около 2 часов, но когда изменения были только в паре модулей, то можно провести регресс и за час. Это большой плюс, потому что остается запас на багофиксы (если понадобится что-то срочно исправить). Также, в будущем, всегда есть возможность посмотреть по отчетам, в какой сборке что проверялось.

Разработали скрипт с анализом изменений и оповещением через Slack

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

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

Логически возникло следующее решение - сделать этот процесс автоматическим!

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

Скрипт работает просто:

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

  • Получает список файлов, которые отражают изменения в каком-то экране

  • Группирует эти изменения по фичам и командам, чтобы упростить жизнь тестировщикам

  • Посылаем сообщение в специальный Slack канал со всей информацией по изменениям

Результаты

Какие плюсы мы получили, подключив аналитику по сборкам:

  • Сократили время разработчиков на ручной анализ внесенных изменений

  • Снизили вероятность упустить из виду и недопроверить необходимый функционал

  • Упростили коммуникацию по данному вопросу

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

Коротко о главном

  1. Использование тегов в тест кейсах и при формировании тест планов сократило объем тест плана, соответственно и время на тестирование.

  2. Разработка и использование скрипта для оповещение об изменения дало возможность четко понимать какие модули были затронуты при разработке задач для релиза. Или при исправлении багов. Так же тестировщики перестали отвлекать разработчиков с такими вопросами.

  3. Автоматизацией было покрыто около 46% тест кейсов, что сильно облегчило ручное тестирование. К тому же остается время на актуализацию кейсов и написание новых.

  4. Разделение тестирования на backend и frontend помогло заранее определять локализацию проблем и своевременное исправление.

Подробнее..

Категории

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

  • Имя: Макс
    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