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

Блог компании badoo

Правосторонний интерфейс адаптируем контролы к right-to-left языкам

23.07.2020 14:23:59 | Автор: admin
C адаптацией приложений и сайтов под RTL-языки (right-to-left, справа налево) сталкиваются разработчики многих развивающихся и выходящих на новые рынки продуктов. Мы в Badoo тоже в какой-то момент оказались в этой ситуации: наши приложения переведены на 52 языка и диалекта. В этой статье я расскажу о нескольких интересных нюансах, которые мы обнаружили при адаптации форм на сайте Badoo.сом под иврит и арабский язык.




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

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


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

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

Для начала сравним несколько RTL-интерфейсов приложений, для которых RTL-аудитория не пустяк, и посмотрим, как они справляются с задачей ввода текста.

Пример 1. Одна социальная сеть


1.1 Форма авторизации


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





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

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



Поле для ввода пароля по умолчанию RTL, курсор находится справа.



Но что будет, если мы попробуем ввести пароль из латинских символов или цифр? По мере ввода курсор будет располагаться слева от вводимого текста. Это не критично, поскольку пароль мы всё равно не видим (до тех пор, пока у нас нет кнопочки Показать пароль), но поведение поля можно было бы улучшить, показав юзеру, в какой раскладке он вводит пароль. Специфика RTL-интерфейсов в том, что, несмотря на то, что большая часть текста правосторонняя, пользователям приходится очень часто переключать раскладку клавиатуры для ввода e-mail, логина, пароля, которые обычно пишутся LTR. Небольшая подсказка здесь была бы кстати.



1.2 Форма регистрации


Теперь посмотрим на форму регистрации, также расположенную на главной странице.



Тут видно, что все поля однозначно считаются RTL и выровнены по правому краю, что не очень удобно, когда имеешь дело с LTR-текстом. Особенно неприятно становится при вводе e-mail символы @ и . заставляют части адреса меняться местами в процессе набора. То, что было не очень критично для редких RTL-адресов в предыдущем примере, становится критичным для LTR.



1.3 Мессенджер компактный


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



И правда. Поле считается RTL по умолчанию, но, как только мы начинаем вводить LTR-текст, его направление меняется на удобное для ввода. Ничего не прыгает. Давайте удостоверимся, что в развёрнутой версии тоже всё в порядке.

1.4 Мессенджер развёрнутый


Упс! Несмотря на то, что латинский текст в RTL-поле ведёт себя правильно, он выровнен по правому краю. Некритично, но непонятна логика: почему поведение идеологически одного и того же компонента различается?



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

Можно ли здесь что-то улучшить? Мы считаем, что да. Причём весьма простыми способами, о них ниже.

Пример 2. Довольно известная почтовая служба


2.1 Форма авторизации


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

Например, поле для ввода e-mail ожидает вводимый текст справа.



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

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



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



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



2.2 Форма регистрации

Форма регистрации внезапно гораздо более дружелюбна к пользователям. Но это логично вводимая в неё информация более разнообразна. Тут как минимум появляются имя и фамилия.

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



Единственное исключение поле адреса. Тут выравнивание по левому краю. Немного странно выглядит.



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



Как видно, даже такие простые компоненты вызывают вопросы. Что уж говорить про сложные контролы вроде календаря?

Опыт Badoo


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

  • плейсхолдеры должны быть выровнены в зависимости от направления языка интерфейса;
  • вводимый текст должен автоматически занимать левую или правую сторону поля в зависимости от своего направления;
  • курсор в пустом поле должен быть выровнен в зависимости от направления языка интерфейса;
  • вводимый текст в полях для ввода телефона, URL всегда должен занимать левую сторону;
  • поля для ввода e-mail ожидают левосторонний текст, но готовы и к правостороннему;
  • курсоры в пустых полях для ввода e-mail, телефона, URL выровнены по левой стороне.

И попробуем добиться этого.

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

<html lang=he dir=rtl>...<input type="text">...</html>



Курсор располагается справа как положено, потому что тег INPUT наследует направление текста от HTML и для правостороннего интерфейса мы ожидаем, что введённый текст, скорее всего, будет правосторонним.

Но что будет, если мы начнём вводить LTR-символы? Уже знакомая нам ситуация с непривычным курсором слева.



В примерах выше разработчики подходят к решению проблемы сурово: следят за вводимыми символами с помощью JavaScript и меняют атрибут dir на лету. Это имело смысл до недавнего времени, но сейчас все современные браузеры отлично понимают атрибут dir=auto и сами справляются с разворачиванием текста. Давайте добавим его.

<input type="text" dir=auto>

Получилось то что надо.



Попробуем добавить к полю плейсхолдер.

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

<input type="text" dir=auto placeholder= >

Ой! Почему-то всё выровнялось по левому краю, включая плейсхолдер.



Дело в атрибуте dir=auto. При отсутствии контента браузер может считать направление текста левосторонним. Попробуем разместить плейсхолдеры по правому краю. Обычно это текст на языке интерфейса, так что автоматическое расположение нам не очень нужно.

input::placeholder {    direction: rtl;}



Стало гораздо лучше. Только вот положение курсора смущает он по-прежнему слева. Попробуем стилизовать пустой INPUT с помощью прекрасного псевдокласса :placeholder-shown.

input:placeholder-shown {    direction: rtl;}

К сожалению, в инпутах без плейсхолдера свойство :placeholder-shown не будет работать. Исправить это можно, добавив ко всем таким полям пустой плейсхолдер:

<input type="text" dir=auto placeholder= >

Вот теперь всё как надо. Плейсхолдер справа, курсор справа (потому что в первую очередь ожидаем RTL-текст), текст справа или слева в зависимости от того, на каком языке печатаем.



Но этого недостаточно.

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

<input type="email" dir=auto placeholder=    >

(На Badoo мы ожидаем в первую очередь ввода e-mail, но также допускаем ввод номера телефона, поэтому в плейсхолдере написано об этом.)

Но хотя символы могут быть любые, в первую очередь мы ожидаем ввода адреса латиницей (и в нашем случае номера телефона цифрами). То есть курсор в пустом поле должен быть слева. Сделаем исключение в CSS.

input[type='email']:placeholder-shown {    direction: ltr;}

На этом не стоит останавливаться. У нас ещё остались поля с типами tel, number, url. Они всегда левосторонние, поэтому для них пишем такой код:

<input type="tel" dir=ltr placeholder= >

И добавляем исключения для пустых полей:

input[type='tel']:placeholder-shown,input[type='number']:placeholder-shown,input[type='url']:placeholder-shown {    direction: ltr;}

Вот теперь стало так, как мы планировали.



Совсем маленький штрих для тех, кому небезразлична судьба IE11. Этот браузер не понимает атрибут dir=auto, поэтому автоматической смены направления текста не добиться без привлечения JavaScript.

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

.ie11 input[type="email"],.ie11 input[type="tel"] {    direction: ltr;}.ie11 input[type="email"]:-ms-input-placeholder,.ie11 input[type="tel"]:-ms-input-placeholder {    direction: rtl;}

Если собрать весь CSS, добавить префиксы и препроцессор SCSS, то получится что-то такое (да-да, мы помним, что поля INPUT могут, например, иметь тип checkbox, но для простоты проигнорируем этот момент):

[dir=rtl] input {   &::-webkit-input-placeholder {       direction: rtl;   }   &::-moz-placeholder {       direction: rtl;   }   &:-ms-input-placeholder {       direction: rtl;   }   &::placeholder {       direction: rtl;   }   &:placeholder-shown[type='email'],   &:placeholder-shown[type='tel'],   &:placeholder-shown[type='number'],   &:placeholder-shown[type='url'] {       direction: ltr;   }}.ie11 [dir=rtl] input {   &[type="email"],   &[type="tel"] {       direction: ltr;       &:-ms-input-placeholder {           direction: rtl;       }   }}

Мы в Badoo делим стили на LTR и RTL, поэтому у нас каскада от [dir=rtl] нет, и свойства для LTR мы пишем так, чтобы они перевернулись в RTL. Но принцип, думаю, понятен.

Для TEXTAREA схема такая же за одним исключением: там не нужно учитывать типы полей и размещать курсор или текст у левого края контент всегда автоматически разворачивается за счёт атрибута dir=auto.

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

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

Ask me anything! Задай вопрос Android-команде Badoo

16.07.2020 14:23:13 | Автор: admin
Предлагаем продолжить добрую традицию Ask me anything на Хабре и поговорить про разработку Android-приложений. Сегодня и завтра Android-команда Badoo будет на связи и ответит на любые вопросы о разработке и тестировании приложений с многомиллионной аудиторией, даст советы начинающим и расскажет про особенности платформы. Если вы столкнулись с какой-то проблемой или у вас есть вопрос по теме, пишите нам!



Обещаем ответить на все комментарии первого уровня, которые появятся здесь до 16:00 17 июля по московскому времени, а по возможности и на более поздние.

Немного фактов о нас. Badoo и Bumble одни из самых популярных дейтинг-сервисов в мире: только в Google Play у нас 210 млн скачиваний. В Android-приложениях больше 1,3 млн строк кода. В Android-команде больше 20 разработчиков. Основной язык разработки Kotlin, архитектурные паттерны MVI и RIBs, база данных SQLite.

Под катом подробнее о нашей команде и о темах, на которые мы можем поговорить.


С вами на связи


Иван Бирюков bivy


image

Моя айтишная история началась в 1997 году, когда я выиграл школьную олимпиаду. C тех пор понеслось. В Badoo я работаю семь с половиной лет. Сначала занимался Android-разработкой, потом построил команду мобильных архитекторов, разрабатывающую в том числе протокол коммуникации с сервером. Сейчас отвечаю за все нативные мобильные приложения Badoo и Bumble для iOS и Android.





Анатолий Варивончик ANublo


image

Я работаю в команде регистрации Badoo два с половиной года. До этого жил и работал в Минске. Готов рассказать о регистрации и фотоверификации в приложении: как мы добились нелинейного флоу в регистрации и как используем нейронные сети в фотоверификации.







Аркадий Иванов arkivanov


image

Я в Badoo уже три года и девять месяцев, техлид чат-команды. До этого работал в Яндексе, а ещё раньше в Mail.ru Group. Моё главное хобби играть неоклассику на электрогитаре. Из профессиональных интересов поддержка библиотеки Badoo Reaktive и моей собственной MVIKotlin. Готов ответить на вопросы об архитектуре, MVI, мультиплатформе, Rx.





Николай Чамеев lukaville


image

Я работаю в Badoo два года, в основном над различной инфраструктурой для Android-приложений. Core team, участником которой я являюсь, в последнее время занималась увеличением скорости сборки приложений, инфраструктурой запуска тестов на CI, мониторингом и оптимизацией приложений (app start/ANRs/crashes).







Артём Ушаков temq91


image

Все полгода работы в Badoo я нахожусь в юните Revenue. В наши обязанности входят разработка и поддержка функциональности, тем или иным образом связанной с revenue: paywall, рекламные и платёжные SDK. До Badoo я работал в компании MERA. В последнее время интересуюсь DevOps (контейнеризация, Docker и т. д.) и билд-системами. Экспериментирую с Raspberry Pi 4: делаю из него домашний NAS.





Азат Хайруллин AzatKhairullin


image

Я работаю Android-разработчиком в Badoo около полутора лет. Сейчас в основном занимаюсь профилями пользователей и encounters экраном с карточками. До этого работал в Biglion, а ещё раньше фрилансил и жил на острове Панган. Немного играю в Hearthstone, интересуюсь Flutter.







Юрий Уфимцев yufimtsev


image

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







Темы, на которые мы можем поговорить


  • Архитектура наших приложений и сравнение архитектурных паттернов.
  • Как выстроен процесс разработки.
  • Как мы работаем с легаси-кодом.
  • Как устроен процесс тестирования приложений.
  • A/B-тесты в Badoo и Bumble.
  • Как мы работаем с дизайн-системой.
  • Карьера Android-разработчика.


Самые интересные вопросы и ответы с AMA на Reddit


Недавно мы устраивали сессию вопросов и ответов на Reddit вместе с англоязычной частью нашей команды и получили 163 вопроса от пользователей. Некоторые из них мы перевели для читателей Хабра: надеемся, они станут поводом расспросить нас о чём-то подробнее.

Вопросы и ответы с AMA на Reddit

Какую архитектуру вы используете? Нравится ли она вам и что бы вы изменили, если бы могли? Какие уроки вы извлекли?


Жольт: Мы используем сильно переделанную версию RIBs (под сильной переделкой я подразумеваю В этой ветке 871 коммит и 15 коммитов после uber:master). Получилась древовидная структура, каждый слой которой можно взять и вставить в другое приложение со всеми связанными с ним ветками. В узлах дерева мы применяем паттерн MVI с реактивными биндингами. Мы довольны тем, что получилось!

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

Майкл: Недавно наша команда Revenue Team начала экспериментировать с новым подходом на основе MVI, который мы назвали SubFlow. Большое влияние на него оказал паттерн с акторами (как это видно на примере библиотек Play Framework и Vert.x). Изначально этот подход применялся нашей iOS-командой. Увидев, как он хорошо работает, мы решили тоже попробовать. Суть в разделении бизнес-логики на простые одношаговые акторы. Каждый актор/подпроцесс для выполнения следующего шага может запустить следующий подпроцесс в цепочке. Возможные варианты подпроцессов конфигурируются извне.

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

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


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

Антон: Я стараюсь соблюдать правило скаута: Оставляй после себя код лучше, чем он был, когда ты его нашёл. Работая над фичей, которая затрагивает старый код, я стараюсь улучшить покрытие тестами; возможно, делаю небольшой рефакторинг, например разбиваю длинные методы. А для более масштабных переделок мы составляем конкретный план, выделяем на это время и определяем этапы.

Майкл: У нас есть части кода, написанные в далёком 2012 году. Мы оставили их нетронутыми, потому что было страшно менять их без покрытия тестами. То есть мы сначала улучшаем покрытие, в этом нам помогает наша замечательная команда тестировщиков, которая с помощью Calabash создаёт для нас end-to-end-тесты. Затем мы начинаем маленькими кусочками переписывать старый код, часто объединяя это с работой над фичами. Наконец, мы специально выделяем время на переписывание самых плохих частей. В соответствии с правилом команды Revenue Team мы тратим один день в неделю, чтобы держать под контролем технический долг, выбирая для этого какую-нибудь необычную задачу.

Какую БД вы используете в приложении?


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

Как вы относитесь к Annotation Processing?


Аркадий: Отрицательно. Плагины для компилятора наше будущее!
Николай: Мы стараемся избегать обработки аннотаций и для тестовых сборок по мере возможности используем реализации библиотек обработки аннотаций с поддержкой рефлексии. Сейчас мы применяем обработку аннотаций для Dagger, Room и Toothpick.
Андрей: Apt годится до тех пор, пока это не kapt.

Проект сегментирован по странам? Как это сделано?


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

Николай K. (серверная команда): У нас два основных региона (два местоположения дата-центров). Каждый пользователь обслуживается дата-центром из его первичного региона. Этот регион определяется на основе местоположения пользователя, указанного им при регистрации. Позднее регион может меняться (когда пользователь в офлайне), если пользователь сменил местоположение.

Ваш проект использует App Bundle? Какой был выигрыш в размерах .apk?


Николай: Да, мы используем App Bundle. Размер приложения с использованием App Bundle уменьшился примерно на 17%.

Андрей: Также мы применяем Dynamic Delivery и даже написали об этом статью.

Обсуждала ли команда миграцию на мультиплатформенный подход? На какой именно или почему нет?


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

Для поддержки перехода мы создали Reactive Extensions-библиотеку Reaktive.

MVICore сейчас переводится на Kotlin Multiplatform.

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


Анатолий: Что касается фич, то мы реализовали в Badoo видеочаты и оптимизировали функцию отправки подарков. Раньше пользователю нужно было нажать на кнопку трансляции, затем на Отправить подарок и выбрать подарок. Целых три действия и людям не было очевидно, что есть такая возможность. Поэтому мы добавили нижнюю панель с горизонтальным списком подарков, чтобы пользователи могли выбрать подарок одним нажатием и сразу же отправить его стримеру. На внедрение ушло около трёх дней, но позже мы получили значительное увеличение числа отправленных подарков.

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

Используете ли вы Android Jetpack, Fragments и Activities? Или что-то вместо них?


Жольт: Хороший вопрос!

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

LiveData: нет. Вместо MVVM у нас MVI, и мы разработали для неё собственный инструмент для автоматического определения области видимости Binder. Сейчас он существует как часть нашей библиотеки MVICore, но мы планируем сделать его в виде отдельной библиотеки. Так будет универсальнее по сравнению с LiveData, поскольку в этом случае Binder можно будет использовать и вне контекста Android, причём очень легко (одна строка на Kotlin). Почитать об этом подробнее можно здесь и здесь. Действительно отличный инструмент. Попробуйте его.

Компонент Navigation: нет. Мы применяем паттерн Router со своей версией RIBs. Необходимость поддерживать глобальную навигацию в приложениях с общими компонентами затрудняет сопровождение на уровне приложения, а также требует понимания устройства конкретных компонентов. Последнее является огромным недостатком, если вы хотите переиспользовать какой-то компонент в разных приложениях. Компонент не должен что-либо предполагать относительно приложения, которое его использует (например, какие размеры экранов доступны). Routing, к примеру, это просто локальная навигация. Он переносит задачу навигации с глобального уровня на уровень реализации компонентов, поэтому вы можете спокойно использовать их где угодно.

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

Почему мы пошли своим путём, а не следуем общепринятым практикам? Дело в том, что этот процесс начался много лет назад. Задолго до наступления эры Jetpack мы пытались идти по пути Google и в результате обожглись на Fragments. Ушли от этого, стараясь понять, какая альтернатива подойдёт нам лучше всего. Часто мы были одними из первых, кто внедрял у себя новую технологию (в 2016-м мы попробовали чистую архитектуру и RxJava, в 2017-м Kotlin и MVI на основе Redux), и подобных инструментов, фреймворков и библиотек было не так уж много. К моменту анонса Jetpack мы уже вложились в собственный технологический стек. И в целом он вполне нас устраивает в сравнении с общепринятыми подходами.

К слову, мы используем Room, и лично меня очень интересует Jetpack Compose.


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

Ask me anything поехали!
Подробнее..

Архитектурный шаблон MVI в Kotlin Multiplatform. Часть 3 тестирование

27.08.2020 16:05:58 | Автор: admin


Эта статья является заключительной в серии о применении архитектурного шаблона MVI в Kotlin Multiplatform. В предыдущих двух частях (часть 1 и часть 2) мы вспомнили, что такое MVI, создали общий модуль Kittens для загрузки изображений котиков и интегрировали его в iOS- и Android-приложения.

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

Обновлённый пример проекта доступен на нашем GitHub.

Пролог


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

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

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

Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. ...[Therefore,] making it easy to read makes it easier to write. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship

Kotlin Multiplatform расширяет возможности тестирования. Эта технология добавляет одну важную особенность: каждый тест автоматически выполняется на всех поддерживаемых платформах. Если поддерживаются, например, только Android и iOS, то количество тестов можно умножить на два. И если в какой-то момент добавляется поддержка ещё одной платформы, то она автоматически становится покрытой тестами.

Тестирование на всех поддерживаемых платформах важно, потому что могут быть различия в поведении кода. Например, у Kotlin/Native особенная модель памяти, Kotlin/JS тоже иногда даёт неожиданные результаты.

Прежде чем идти дальше, стоит упомянуть о некоторых ограничениях тестирования в Kotlin Multiplatform. Самое большое из них это отсутствие какой-либо библиотеки моков для Kotlin/Native и Kotlin/JS. Это может показаться большим недостатком, но я лично считаю это преимуществом. Мне довольно трудно давалось тестирование в Kotlin Multiplatform: приходилось создавать интерфейсы для каждой зависимости и писать их тестовые реализации (fakes). На это уходило много времени, но в какой-то момент я понял, что трата времени на абстракции это инвестиция, которая приводит к более чистому коду.

Я также заметил, что последующие модификации такого кода требуют меньше времени. Почему так? Потому что взаимодействие класса с его зависимостями не прибито гвоздями (моками). В большинстве случаев достаточно просто обновить их тестовые реализации. Нет необходимости углубляться в каждый тестовый метод, чтобы обновить моки. В результате я перестал использовать библиотеки моков даже в стандартной Android-разработке. Я рекомендую прочитать следующую статью: "Mocking is not practical Use fakes" (автор Pravin Sonawane).

План


Давайте вспомним, что у нас есть в модуле Kittens и что нам стоит протестировать.

  • KittenStore основной компонент модуля. Его реализация KittenStoreImpl содержит бОльшую часть бизнес-логики. Это первое, что мы собираемся протестировать.
  • KittenComponent фасад модуля и точка интеграции всех внутренних компонентов. Мы покроем этот компонент интеграционными тестами.
  • KittenView публичный интерфейс, представляющий UI, зависимость KittenComponent.
  • KittenDataSource внутренний интерфейс для доступа к Сети, который имеет платформенно-зависимые реализации для iOS и Android.

Для лучшего понимания структуры модуля приведу его UML-диаграмму:



План следующий:

  • Тестирование KittenStore
    • Создание тестовой реализации KittenStore.Parser
    • Создание тестовой реализации KittenStore.Network
    • Написание модульных тестов для KittenStoreImpl

  • Тестирование KittenComponent
    • Создание тестовой реализации KittenDataSource
    • Создание тестовой реализации KittenView
    • Написание интеграционных тестов для KittenComponent

  • Запуск тестов
  • Выводы


Модульное тестирование KittenStore


Интерфейс KittenStore имеет свой класс реализации KittenStoreImpl. Именно его мы и собираемся тестировать. Он имеет две зависимости (внутренние интерфейсы), определённые прямо в самом классе. Начнём с написания тестовых реализаций для них.

Тестовая реализация KittenStore.Parser


Этот компонент отвечает за сетевые запросы. Вот как выглядит его интерфейс:


TestKittenStoreNetwork имеет хранилище строк (как и настоящий сервер) и может их генерировать. По каждому запросу текущий список строк кодируется в одну строку. Если свойство images равно нулю, то Maybe просто завершится, что должно рассматриваться как ошибка.

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

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

Тестовая реализация KittenStore.Parser


Этот компонент отвечает за разбор ответов от сервера. Вот его интерфейс:


Как и в случае с Network, используется TestScheduler для замораживания подписчиков и проверки их совместимости с моделью памяти Kotlin/Native. Ошибки обработки ответов моделируются, если входная строка пуста.

Модульные тесты для KittenStoreImpl


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

Первый шаг создать экземпляры наших тестовых реализаций:


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


Этапы теста:

  • сгенерировать исходные изображения;
  • создать экземпляр KittenStoreImpl;
  • сгенерировать новые изображения;
  • отправить Intent.Reload;
  • убедиться, что состояние содержит новые изображения.

И наконец давайте проверим следующий сценарий: когда в состоянии установлен флаг isLoading во время загрузки изображений.


Есть две зависимости: KittenDataSource и KittenView. Нам понадобятся тестовые реализации для них, прежде чем мы сможем начать тестирование.

Для полноты картины на этой диаграмме показан поток данных внутри модуля:



Тестовая реализация KittenDataSource


Этот компонент отвечает за сетевые запросы. У него есть отдельные реализации для каждой платформы, и нам нужна ещё одна реализация для тестов. Вот как выглядит интерфейс KittenDataSource:


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

Для формирования JSON-массива используется библиотека kotlinx.serialization. Кстати, тестируемый KittenStoreParser использует её же для декодирования.

Тестовая реализация KittenView


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


Нам просто нужно запоминать последнюю принятую модель это позволит проверить правильность отображаемой модели. Мы также можем отправлять события от имени KittenView с помощью метода dispatch(Event), который объявлен в наследуемом классе AbstractMviView.

Интеграционные тесты для KittenComponent


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

Как и раньше, давайте начнём с создания экземпляров зависимостей и инициализации:


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


Этапы:

  • сгенерировать исходные ссылки на изображения;
  • создать и запустить KittenComponent;
  • сгенерировать новые ссылки;
  • отправить Event.RefreshTriggered от имени KittenView;
  • убедиться, что новые ссылки достигли TestKittenView.


Запуск тестов


Чтобы запустить все тесты, нам нужно выполнить следующую Gradle-задачу:

./gradlew :shared:kittens:build

Это скомпилирует модуль и запустит все тесты на всех поддерживаемых платформах: Android и iosx64.

А вот JaCoCo-отчёт о покрытии:



Заключение


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

  • KittenStoreImpl содержит бОльшую часть бизнес-логики;
  • KittenStoreNetwork отвечает за сетевые запросы высокого уровня;
  • KittenStoreParser отвечает за разбор сетевых ответов;
  • все преобразования и связи.

Последний пункт очень важен. Охватить егои возможно благодаря особенности MVI. Единственная ответственность представления отображать данные и отправлять события. Все подписки, преобразования и связи выполняются внутри модуля. Таким образом, мы можем покрыть общими тестами всё, кроме непосредственно отображения.

Такие тесты имеют следующие преимущества:

  • не используют платформенные API;
  • выполняются очень быстро;
  • надёжные (не мигают);
  • выполняются на всех поддерживаемых платформах.

Мы также смогли проверить код на совместимость со сложной моделью памяти Kotlin/Native. Это тоже очень важно из-за отсутствия безопасности во время сборки: код просто падает во время выполнения с исключениями, которые трудно отлаживать.

Надеюсь, это поможет вам в ваших проектах. Спасибо, что читали мои статьи! И не забудьте подписаться на меня в Twitter.



Бонусное упражнение


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

Рефакторинг KittenDataSource


В модуле существуют две реализации интерфейса KittenDataSource: одна для Android и одна для iOS. Я уже упоминал, что они отвечают за доступ к сети. Но на самом деле у них есть ещё одна функция: они генерируют URL-адрес для запроса на основе входных аргументов limit и page. В то же время у нас есть класс KittenStoreNetwork, который ничего не делает, кроме делегирования вызова в KittenDataSource.

Задание: переместить логику генерирования URL-запроса из KittenDataSourceImpl (на Android и iOS) в KittenStoreNetwork. Вам нужно изменить интерфейс KittenDataSource следующим образом:



Как только вы это сделаете, вам нужно будет обновить тесты. Единственный класс, к которому вам потребуется прикоснуться, это TestKittenDataSource.

Добавление постраничной загрузки


TheCatAPI поддерживает разбивку на страницы, поэтому мы можем добавить эту функцию для лучшего взаимодействия с пользователем. Вы можете начать с добавления нового события Event.EndReached для KittenView, после чего код перестанет компилироваться. Затем вам нужно будет добавить соответствующий Intent.LoadMore, преобразовать новый Event в Intent и обработать последний в KittenStoreImpl. Вам также потребуется изменить интерфейс KittenStoreImpl.Network следующим образом:



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

Подробнее..

Влияние data-классов на вес приложения

04.03.2021 18:06:42 | Автор: admin


Kotlin имеет много классных особенностей: null safety, smart casts, интерполяция строк и другие. Но одной из самых любимых разработчиками, по моим наблюдениям, являются data-классы. Настолько любимой, что их часто используют даже там, где никакой функциональности data-класса не требуется.


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


Data-классы и их функциональность


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


  • component1(), component2() componentX() для деструктурирующего присваивания (val (name, age) = person);
  • copy() с возможностью создавать копии объекта с изменениями или без;
  • toString() с именем класса и значением всех полей внутри;
  • equals() & hashCode().



Но мы платим далеко не за всю эту функциональность. Для релизных сборок используются оптимизаторы R8, ProGuard, DexGuard и другие. Они могут удалять неиспользуемые методы, а значит, могут и оптимизировать data-классы.


Будут удалены:


  • component1(), component2() componentX() при условии, что не используется деструктурирующее присваивание (но даже если оно есть, то при более агрессивных настройках оптимизации эти методы могут быть заменены на прямое обращение к полю класса);
  • copy(), если он не используется.

Не будут удалены:


  • toString(), поскольку оптимизатор не может знать, будет ли этот метод где-то использоваться или нет (например, при логировании); также он не будет обфусцирован;
  • equals() & hashCode(), потому что удаление этих функций может изменить поведение приложения.

Таким образом, в релизных сборках всегда остаются toString(), equals() и hashCode().


Масштаб изменений


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


data class SomeClass(val text: String) {- override fun toString() = ...  - override fun hashCode() = ...- override fun equals() = ...}

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


data class SomeClass(val text: String) {+ override fun toString() = super.toString()+ override fun hashCode() = super.hashCode()+ override fun equals() = super.equals()}

Вручную для 7749 data-классов в проекте.



Ситуацию усугубляет использование монорепозитория для приложений. Это означает, что я не знаю точно, сколько из этих 7749 классов мне нужно изменить, чтобы измерить влияние data-классов только на одно приложение. Поэтому придётся менять все!


Плагин компилятора


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


В открытом доступе на GitHub есть плагин Sekret, который позволяет скрывать в toString() указанные аннотацией поля в data-классах. Его я и взял за основу своего плагина.


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


  • Gradle-плагин для простой интеграции;
  • плагин компилятора, который будет подключён через Gradle-плагин;
  • проект с примером, на котором можно запускать различные тесты.

Самая важная часть в Gradle-плагине это объявление KotlinGradleSubplugin. Этот сабплагин будет подключён через ServiceLocator. С помощью основного Gradle-плагина мы можем конфигурировать KotlinGradleSubplugin, который будет настраивать поведение плагина компилятора.


@AutoService(KotlinGradleSubplugin::class)class DataClassNoStringGradleSubplugin : KotlinGradleSubplugin<AbstractCompile> {    // Проверяем, есть ли основной Gradle-плагин    override fun isApplicable(project: Project, task: AbstractCompile): Boolean =        project.plugins.hasPlugin(DataClassNoStringPlugin::class.java)    override fun apply(        project: Project,        kotlinCompile: AbstractCompile,        javaCompile: AbstractCompile?,        variantData: Any?,        androidProjectHandler: Any?,        kotlinCompilation: KotlinCompilation<KotlinCommonOptions>?    ): List<SubpluginOption> {        // Опции плагина компилятора настраиваются через DataClassNoStringExtension с помощью Gradle build script        val extension =            project                .extensions                .findByType(DataClassNoStringExtension::class.java)                ?: DataClassNoStringExtension()        val enabled = SubpluginOption("enabled", extension.enabled.toString())        return listOf(enabled)    }    override fun getCompilerPluginId(): String = "data-class-no-string"    // Это артефакт плагина компилятора, и он должен быть доступен в репозитории Maven, который вы используете    override fun getPluginArtifact(): SubpluginArtifact =        SubpluginArtifact("com.cherryperry.nostrings", "kotlin-plugin", "1.0.0")}

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


ClassBuilderInterceptorExtension.registerExtension(    project = project,    extension = DataClassNoStringClassGenerationInterceptor())

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


class DataClassNoStringClassGenerationInterceptor : ClassBuilderInterceptorExtension {    override fun interceptClassBuilderFactory(        interceptedFactory: ClassBuilderFactory,        bindingContext: BindingContext,        diagnostics: DiagnosticSink    ): ClassBuilderFactory =        object : ClassBuilderFactory {            override fun newClassBuilder(origin: JvmDeclarationOrigin): ClassBuilder {                val classDescription = origin.descriptor as? ClassDescriptor                // Если класс является data-классом, то изменяем процесс генерации кода                return if (classDescription?.kind == ClassKind.CLASS && classDescription.isData) {                    DataClassNoStringClassBuilder(interceptedFactory.newClassBuilder(origin), removeAll)                } else {                    interceptedFactory.newClassBuilder(origin)                }            }        }}

Теперь необходимо не дать компилятору создать некоторые методы. Для этого воспользуемся DelegatingClassBuilder. Он будет делегировать все вызовы оригинальному ClassBuilder, но при этом мы сможем переопределить поведение метода newMethod. Если мы попытаемся создать методы toString(), equals(), hashCode(), то вернём пустой MethodVisitor. Компилятор будет писать в него код этих методов, но он не попадёт в создаваемый класс.


class DataClassNoStringClassBuilder(    val classBuilder: ClassBuilder) : DelegatingClassBuilder() {    override fun getDelegate(): ClassBuilder = classBuilder    override fun newMethod(        origin: JvmDeclarationOrigin,        access: Int,        name: String,        desc: String,        signature: String?,        exceptions: Array<out String>?    ): MethodVisitor {        return when (name) {            "toString",            "hashCode",            "equals" -> EmptyVisitor            else -> super.newMethod(origin, access, name, desc, signature, exceptions)        }    }    private object EmptyVisitor : MethodVisitor(Opcodes.ASM5)}

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


class AppTest {    data class Sample(val text: String)    @Test    fun `toString method should return default string`() {        val sample = Sample("test")        // toString должен возвращать результат метода Object.toString        assertEquals(            "${sample.javaClass.name}@${Integer.toHexString(System.identityHashCode(sample))}",            sample.toString()        )    }    @Test    fun `hashCode method should return identityHashCode`() {         // hashCode должен возвращать результат метода Object.hashCode, он же по умолчанию System.identityHashCode        val sample = Sample("test")        assertEquals(System.identityHashCode(sample), sample.hashCode())    }    @Test    fun `equals method should return true only for itself`() {        // equals должен работать как Object.equals, а значит, должен быть равным только самому себе        val sample = Sample("test")        assertEquals(sample, sample)        assertNotEquals(Sample("test"), sample)    }}

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


Результаты



Для сравнения мы будем использовать релизные сборки Bumble и Badoo. Результаты были получены с помощью утилиты Diffuse, которая выводит детальную информацию о разнице между двумя APK-файлами: размеры DEX-файлов и ресурсов, количество строк, методов, классов в DEX-файле.


Приложение Bumble Bumble (после) Разница Badoo Badoo (после) Разница
Data-классы 4026 - - 2894 - -
Размер DEX (zipped) 12.4 MiB 11.9 MiB -510.1 KiB 15.3 MiB 14.9 MiB -454.1 KiB
Размер DEX (unzipped) 31.7 MiB 30 MiB -1.6 MiB 38.9 MiB 37.6 MiB -1.4 MiB
Строки в DEX 188969 179197 -9772 244116 232114 -12002
Методы 292465 277475 -14990 354218 341779 -12439


Количество data-классов было определено эвристическим путём с помощью анализа удалённых из DEX-файла строк.



Реализация toString() у data-классов всегда начинается с короткого имени класса, открывающей скобки и первого поля data-класса. Data-классов без полей не существует.


Исходя из результатов, можно сказать, что в среднем каждый data-класс обходится в 120 байт в сжатом и 400 байт в несжатом виде. На первый взгляд, не много, поэтому я решил проверить, сколько получается в масштабе целого приложения. Выяснилось, что все data-классы в проекте обходятся нам в ~4% размера DEX-файла.


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


Использование data-классов


Я ни в коем случае не призываю вас отказываться от data-классов, но, принимая решение об их использовании, нужно тщательно всё взвесить. Вот несколько вопросов, которые стоит задать себе перед объявлением data-класса:


  • Нужны ли реализации equals() и hashCode()?
    • Если нужны, лучше использовать data-класс, но помните про toString(), он не обфусцируется.
  • Нужно ли использовать деструктурирующее присваивание?
    • Использовать data-классы только ради этого не лучшее решение.
  • Нужна ли реализация toString()?
    • Вряд ли существует бизнес-логика, зависящая от реализации toString(), поэтому иногда можно генерировать этот метод вручную, средствами IDE.
  • Нужен ли простой DTO для передачи данных в другой слой или задания конфигурации?
    • Обычный класс подойдёт для этих целей, если не требуются предыдущие пункты.

Мы не можем совсем отказаться от использования data-классов в проекте, и приведённый выше плагин ломает работоспособность приложения. Удаление методов было сделано ради оценки влияния большого количества data-классов. В нашем случае это ~4% от размера DEX-файла приложения.


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

Подробнее..

Мёртвый код найти и обезвредить

19.08.2020 14:16:03 | Автор: admin


Меня зовут Данил Мухаметзянов, я работаю бэкенд-разработчиком в Badoo уже семь лет. За это время я успел создать и изменить большое количество кода. Настолько большое, что в один прекрасный день ко мне подошёл руководитель и сказал: Квота закончилась. Чтобы что-то добавить, нужно что-то удалить.

Ладно, это всего лишь шутка он такого не говорил. А жаль! В Badoo за всё время существования компании накопилось больше 5,5 млн строк логического бизнес-кода без учёта пустых строк и закрывающих скобок.

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

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



С этим докладом я выступал на Badoo PHP Meetup #4

Откуда берётся мёртвый код


Мы начали искать причины проблем. Разделили их на две категории:

  • процессные те, что возникают в результате разработки;
  • исторические легаси-код.

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

A/B-тестирование


Активно использовать A/B-тестирование в Badoo начали четыре года назад. Сейчас у нас постоянно крутится около 200 тестов, и все продуктовые фичи обязательно проходят эту процедуру.

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

Решение проблемы пришло быстро: мы начали автоматически создавать тикет на выпиливание кода по завершении А/В-теста.


Пример тикета

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

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

С помощью такого нехитрого механизма мы избавились от большого пласта работы.

Многообразие клиентов


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

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

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


Скрин для хард-лимита

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

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

Отдельное решение мы придумали для случая, когда прекратилась поддержка Windows Phone. Мы подготовили скрин, который сообщал пользователю: Мы тебя очень любим! Ты очень классный! Но давай ты начнёшь пользоваться другой платформой? Тебе станут доступны новые крутые функции, а здесь мы уже ничего сделать не можем. Как правило, в качестве альтернативной платформы мы предлагаем веб-платформу, которая всегда доступна.

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

Фиче-флаги


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

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

У нас было два типа фиче-флагов. Расскажу о них на примерах.

Минорные фичи


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

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

Аппликейшен-фичи


Тот же клиент, тот же сервер. Клиент говорит: Сервер, я научился поддерживать видеостриминг. Включить его?. Сервер отвечает: Спасибо, я буду это иметь в виду. И добавляет: Отлично. Давай покажем нашему любимому пользователю эту функциональность, он будет рад. Или: Хорошо, но включать пока не будем.

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

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

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


Скрин дашборда

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

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

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

Что делать с легаси-кодом


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

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

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

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

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

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

Собираем список методов


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

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

Недостатки XHProf


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

И тут мы убедились, что XHProf для нас неудобен.

  • Он требует изменения PHP-кода. Нужно вставить код старта трейсинга, закончить трейсинг, получить собранные данные, записать их в файл. Всё-таки это профайлер, а у нас продакшен, то есть запросов много, нужно думать и о семплировании. В нашем случае это усугубилось большим количеством кластеров с разными entry points.
  • Дублирование данных. У нас работала оптимизация для файлов. Вместо того чтобы получать список методов на каждый реквест, мы просто опрашивали OPCache. Реквестов много: если использовать XHProf, то нужно на каждый реквест записать некое количество данных и используемые методы. Но большая часть методов вызывается из раза в раз, потому что это core-методы или использование стандартного фреймворка.
  • Человеческий фактор. У нас произошла одна интересная ситуация. Мы запустили XHProf на кластере обработки очередей. Запустили в облегчённом режиме (через опции XHProf): выключили сбор метрик потребления CPU, памяти, потому что они были бесполезны и только нагружали сервер. По сути, это был эксперимент, который мы не анонсировали до получения результатов. Но в какой-то момент мейнтейнер XHProf aggregator (наш внутренний компонент на базе XHProf с официальным названием Live Profiler, который мы выложили в open-source) это заметил, подумал, что это баг, и включил все обратно. Включил, проанализировал и сообщил: Ребята, кажется, у нас проблемы, потому что потребление CPU выросло на этом кластере, так как мы включили профилирование для большого числа запросов, о чём Live Profiler не знал. Мы, конечно, быстро заметили это и всё пофиксили.
  • Сложность изменения XHProf. Данных собиралось много, поэтому нам хотелось автоматизировать их доставку и хранение. У нас уже был процесс доставки логов для ошибок и статистики. Мы решили использовать ту же самую схему вместо того, чтобы плодить новые. Но она требовала изменения формата данных: например, специфичной обработки переводов строк (стоит отметить, как правильно заметил youROCK, этого не требует lsd, но так было удобнее для поддержки единой обертки над ним). Патчить XHProf это не то, что нам хотелось делать, потому что это достаточно большой профайлер (вдруг что-нибудь сломаем ненароком?).

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

Требования к решению


Мы ещё раз собрались и посмотрели, какие решения существуют. И сформулировали итоговый список требований.

Первое: минимальные накладные расходы. Для нас планкой был XHProf: не больше, чем требует он.

Второе: мы не хотели изменять PHP-код.

Третье: мы хотели, чтобы решение работало везде и в FPM, и в CLI.

Четвёртое: мы хотели обработать форки. Они активно используются в CLI, на облачных серверах. Делать специфическую логику для них внутри PHP не хотелось.

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

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

Принцип работы funcmap


В результате у нас родилось решение, которое мы назвали funcmap.

По сути funcmap это PHP extension. Если говорить в терминах PHP, то это PHP-модуль. Чтобы понять, как он работает, давайте посмотрим, как работает PHP-процесс и PHP-модуль.

Итак, у вас запускается некий процесс. PHP даёт возможность при построении модуля подписываться на хуки. Запускается процесс, запускается хук GINIT (Global Init), где вы можете инициализировать глобальные параметры. Потом инициализируется модуль. Там могут создаваться и выделяться в память константы, но только под конкретный модуль, а не под реквест, иначе вы выстрелите себе в ногу.

Затем приходит пользовательский реквест, вызывается хук RINIT (Request Init). При завершении реквеста происходит его шатдаун, и уже в самом конце шатдаун модуля: MSHUTDOWN и GSHUTDOWN. Всё логично.

Если мы говорим об FPM, то каждый пользовательский реквест приходит в уже существующий воркер. По сути, RINIT и RSHUTDOWN просто работают по кругу, пока FPM не решит, что воркер своё отжил, пора его пристрелить и создать новый. Если мы говорим о CLI, то это просто линейный процесс. Всё будет вызвано один раз.


Принцип работы funcmap

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

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

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

Второй хук MSHUTDOWN. Хочу заметить, что именно MSHUTDOWN, а не RSHUTDOWN. Мы не хотели отрабатывать что-то на каждый реквест нас интересовал именно весь воркер. На MSHUTDOWN мы берём нашу хеш-таблицу, пробегаемся по ней и пишем файл (что может быть надёжнее, удобнее и универсальнее старого доброго файла?).

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

Данный хук не записывает встроенные функции. Если вы хотите подменить встроенные функции, для этого есть отдельная функциональность, которая называется zend_execute_internal.

Конфигурация


Как это конфигурировать, не изменяя PHP-код? Настройки очень простые:

  • enabled: включён он или нет.
  • Файл, в который мы пишем. Здесь есть плейсхолдер pid для исключения race condition при одновременной записи в один файл разными PHP-процессами.
  • Вероятностная основа: наш probability-флаг. Если вы выставляете 0, значит, никакой запрос записан не будет; если 100 значит, все запросы будут логироваться и попадать в статистику.
  • flush_interval. Это периодичность, с которой мы сбрасываем все данные в файл. Мы хотим, чтобы сбор данных исполнялся в CLI, но там есть скрипты, которые могут выполняться достаточно долго, съедая память, если вы используете большое количество функционала.

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

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

Накладные расходы


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

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

Мы включили наше расширение последовательно на 25%, 50% и 100% и увидели вот такую картину:



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

Этот график в первую очередь доказывает, что нам важно семплирование: мы не можем тратить 20% ресурсов сервера на сбор статистики.

Ложный результат


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

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

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

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

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

Мы создали интерфейс, в котором видно, какие методы используются (они на белом фоне), а какие потенциально не используются (они на красном фоне). Здесь можно и промаркировать нужные методы.

Скрин интерфейса


Интерфейс это здорово, но давайте вернёмся к началу, а именно к тому, какую проблему мы решали. Она заключалась в том, что наши инженеры читают мёртвый код. Читают его где? В IDE. Представляете, каково это заставить фаната своего дела уйти из IDE-мира в какой-то веб-интерфейс и что-то там делать! Мы решили, что надо пойти навстречу коллегам.

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

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

Расширение funcmap выложено на GitHub. Будем рады, если оно кому-то пригодится.

Альтернативы


Со стороны может показаться, что мы в Badoo не знаем, чем себя занять. Почему бы не посмотреть, что есть на рынке?

Это справедливый вопрос. Мы смотрели и на рынке в тот момент ничего не было. Только когда мы начали активно внедрять своё решение, мы обнаружили, что в то же самое время человек по имени Joe Watkins, живущий в туманной Великобритании, реализовал аналогичную идею и создал расширение Tombs.

Мы не очень внимательно его изучили, потому что у нас уже было своё решение, но тем не менее обнаружили несколько проблем:

  • Отсутствие семплирования. Выше я объяснял, почему оно нам необходимо.
  • Использование разделяемой памяти. В своё время мы столкнулись с тем, что воркеры начинали упираться в запись конкретного ключа при использовании APCu (модуль кеширования), поэтому в своём решении мы не используем разделяемую память.
  • Проблемная работа в CLI. Всё работает, но, если вы одновременно запустите два CLI-процесса, они начнут конфликтовать и публиковать ненужные ворнинги.
  • Сложность постобработки. Расширение Tombs, в отличие от нашего, само понимает, что было загружено, что было исполнено, и в конце концов выдаёт то, что инвертировано. Мы же в funcmap делаем инверсию уже после (вычитаем из всего кода тот, который использовался): у нас тысячи серверов, и эти множества нужно правильно пересечь. Tombs отлично сработает, если у вас небольшое количество серверов, вы используете FPM и не используете CLI. Но если вы используете что-то сложнее, попробуйте оба решения и выберите более подходящее.

Выводы


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

Второе: знайте своих клиентов в лицо. Неважно, внутренние они или внешние вы должны их знать. В какой-то момент надо сказать им: Родной, стоп! Нет.

Третье: чистите свой API. Это ведёт к упрощению всей системы.

И четвёртое: автоматизировать можно всё, даже поиск мёртвого кода. Что мы и сделали.
Подробнее..

EBPF современные возможности интроспекции в Linux, или Ядро больше не черный ящик

22.09.2020 14:11:12 | Автор: admin


У всех есть любимые книжки про магию. У кого-то это Толкин, у кого-то Пратчетт, у кого-то, как у меня, Макс Фрай. Сегодня я расскажу вам о моей любимой IT-магии о BPF и современной инфраструктуре вокруг него.

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

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

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

Что такое eBPF?


Итак, о какой такой магии вам тут собирается рассказывать 34-летний бородатый мужик с горящими глазами?

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



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

BIOS, EFI, операционная система, драйвера, модули, библиотеки, сетевое взаимодействие, базы данных, кеши, оркестраторы типа K8s, контейнеры типа Docker, наконец, наш с вами софт с рантаймами и сборщиками мусора. Настоящий профессионал может отвечать на вопрос о том, что происходит после того, как вы вбиваете ya.ru в браузере, несколько дней.

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

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

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

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

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

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



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

Ядро Linux event-driven-система. Практически всё, что происходит в ядре, да и в системе в целом, можно представить в виде набора событий. Прерывание событие, получение пакета по сети событие, передача процессора другому процессу событие, запуск функции событие.
Так вот, BPF это подсистема ядра Linux, дающая возможность писать небольшие программы, которые будут запущены ядром в ответ на события. Эти программы могут как пролить свет на происходящее в вашей системе, так и управлять ею.

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

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


Простенький фильтр для tcpdump представлен в виде BPF-программы

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

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



В 2014 году Алексей Старовойтов расширил функциональность BPF. Он увеличил количество регистров и допустимый размер программы, добавил JIT-компиляцию и сделал верификатор, который проверял программы на безопасность. Но самым впечатляющим было то, что новые BPF-программы могли запускаться не только при обработке пакетов, но и в ответ на многочисленные события ядра, и передавали информацию туда-сюда между kernel и user space.

Эти изменения открыли возможности для новых вариантов использования BPF. Некоторые вещи, которые раньше реализовывались путём написания сложных и опасных модулей ядра, теперь делались относительно просто через BPF. Почему это круто? Да потому что любая ошибка при написании модуля часто приводила к панике. Не к пушистой Go-шной панике, а к панике ядра, после которой только ребут.

У обычного пользователя Linux появилась суперспособность заглядывать под капот, ранее доступная только хардкорным разработчикам ядра или недоступная никому. Эта опция сравнима с возможностью без особых усилий написать программу для iOS или Android: на старых телефонах это было или невозможно, или значительно сложнее.

Новая версия BPF от Алексея получила название eBPF (от слова extended расширенная). Но сейчас она заменила все старые версии BPF и стала настолько популярной, что для простоты все называют её просто BPF.

Где используют BPF?


Итак, что же это за события, или триггеры, к которым можно прицепить BPF-программы, и как люди начали использовать новоприобретённую мощь?

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

Первая группа используется для обработки сетевых пакетов и для управления сетевым трафиком. Это XDP, traffic control-ивенты и ещё несколько.

Эти ивенты нужны, чтобы:

  • Создавать простые, но очень эффективные файрволы. Компании вроде Cloudflare и Facebook с помощью BPF-программ отсеивают огромное количество паразитного трафика и борются с самыми масштабными DDoS-атаками. Так как обработка происходит на самой ранней стадии жизни пакета и прямо в ядре (иногда BPF-программа даже пушится сразу в сетевую карту для обработки), то таким образом можно обрабатывать колоссальные потоки трафика. Раньше такие вещи делали на специализированных сетевых железках.
  • Создавать более умные, точечные, но всё ещё очень производительные файрволы такие, которые могут проверить проходящий трафик на соответствие правилам компании, на паттерны уязвимостей и т. п. Facebook, например, занимается таким аудитом внутри компании, несколько проектов продают такого рода продукты наружу.
  • Создавать умные балансировщики. Самым ярким примером является проект Cilium, который чаще всего используется в кластере K8s в качестве mesh-сети. Cilium управляет трафиком: балансирует, перенаправляет и анализирует его. И всё это с помощью небольших BPF-программ, запускаемых ядром в ответ на то или иное событие, связанное с сетевыми пакетами или сокетами.

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

В данной группе есть такие триггеры, как:

  • perf events ивенты, связанные с производительностью и с Linux-профилировщиком perf: железные процессорные счётчики, обработка прерываний, перехват minor/major-исключений памяти и т. п. Например, вы можете поставить обработчик, который будет запускаться каждый раз, когда ядру надо вытащить из свопа какую-то страницу памяти. Представьте себе, например, утилиту, которая отображает программы, которые в данный момент используют своп.
  • tracepoints статические (определённые разработчиком) места в исходниках ядра, при прикреплении к которым можно достать статическую же информацию (ту, которую заранее подготовил разработчик). Может показаться, что в данном случае статичность это плохо, ведь я говорил, что один из недостатков логов заключается в том, что они содержат только то, что изначально добавил программист. В каком-то смысле это так, но tracepoints обладают тремя важными преимуществами:
    • их довольно много раскидано по ядру в самых интересных местах;
    • когда они не включены, они не тратят ресурсы;
    • они являются частью API, стабильны и не меняются, что очень важно, так как другие триггеры, о которых пойдёт речь, не имеют стабильного API.

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

  • USDT то же самое, что tracepoints, только для user space-программ. То есть вы как программист можете добавлять такие места в свою программу. И многие крупные и известные программы и языки программирования уже обзавелись такими трейсами: MySQL, например, или языки PHP, Python. Часто они выключены по умолчанию и для их включения нужно пересобрать интерпретатор с параметром enable-dtrace или подобным. Да, в Go у нас тоже есть возможность регистрировать такие трейсы. Кто-то, может быть, узнал слово DTrace в названии параметра. Дело в том, что такого рода статические трейсы были популяризированы в одноимённой системе, которая зародилась в ОС Solaris: например, момент создания нового треда, запуска GC или чего-то, связанного с конкретным языком или системой.

Ну а дальше начинается ещё один уровень магии:

  • ftrace-триггеры дают нам возможность запускать BPF-программу в начале практически любой функции ядра. Полностью динамически. Это значит, что ядро вызовет вашу BPF-функцию до начала выполнения любой выбранной вами функции ядра. Или всех функций ядра как угодно. Вы можете прикрепиться ко всем функциям ядра и получить на выходе красивую визуализацию всех вызовов.
  • kprobes/uprobes дают почти то же самое, что ftrace, только у нас есть возможность прицепиться к любому месту при исполнении функции как ядра, так и в user space. В середине функции есть какой-то if по переменной и вам надо построить гистограмму значений этой переменной? Не проблема.
  • kretprobes/uretprobes здесь всё аналогично предыдущим триггерам, но мы можем стриггернуться при завершении выполнения функции ядра и программы в user space. Такого рода триггеры удобны для просмотра того, что функция возвращает, и для замера времени её выполнения. Например, можно узнать, какой PID вернул системный вызов fork.

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

Не знаю, как для вас, но для меня новая инфраструктура как игрушка, которую я долго и трепетно ждал.

API, или Как это использовать


Окей, Марко, ты нас уговорил посмотреть в сторону BPF. Но как к нему подступиться?

Давайте посмотрим, из чего состоит BPF-программа и как с ней взаимодействовать.



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

У BPF-программы есть возможность взаимодействовать со второй частью с user space-программой. Для этого есть два способа. Мы можем писать в циклический буфер, а user space-часть может из него читать. Также мы можем писать и читать в key-value-хранилище, которое называется BPF map, а user space-часть, соответственно, может делать то же самое, и, соответственно, они могут перекидывать друг другу какую-то информацию.

Прямолинейный путь


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



Первое доступное упрощение использование библиотеки libbpf, которая поставляется с исходниками ядра и позволяет не работать напрямую с системным вызовом BPF. По сути, она предоставляет удобные обёртки для загрузки кода, работы с так называемыми мапами для передачи данных из ядра в user space и обратно.

bcc


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



По сути, он готовит всё сборочное окружение и даёт нам возможность писать единые BPF-программы, где С-часть будет собрана и загружена в ядро автоматически, а user space-часть может быть сделана на простом и понятном Python.

bpftrace


Но и BCC выглядит сложным для многих вещей. Особенно люди почему-то не любят писать части на С.

Те же ребята из iovizor представили инструмент bpftrace, который позволяет писать BPF-скрипты на простеньком скриптовом языке а-ля AWK (либо вообще однострочники).



Знаменитый специалист в области производительности и observability Брендан Грегг подготовил следующую визуализацию доступных способов работы с BPF:



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

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


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

И BCC, и bpftrace содержат папку Tools, где собрано огромное количество готовых интересных и полезных скриптов. Они одновременно являются и местным Stack Overflow, с которого вы можете копировать куски кода для своих скриптов.

Вот, например, скрипт, который показывает latency для DNS-запросов:

 marko@marko-home ~$ sudo gethostlatency-bpfccTIME   PID  COMM         LATms HOST16:27:32 21417 DNS Res~ver #93    3.97 live.github.com16:27:33 22055 cupsd         7.28 NPI86DDEE.local16:27:33 15580 DNS Res~ver #87    0.40 github.githubassets.com16:27:33 15777 DNS Res~ver #89    0.54 github.githubassets.com16:27:33 21417 DNS Res~ver #93    0.35 live.github.com16:27:42 15580 DNS Res~ver #87    5.61 ac.duckduckgo.com16:27:42 15777 DNS Res~ver #89    3.81 www.facebook.com16:27:42 15777 DNS Res~ver #89    3.76 tech.badoo.com :-)16:27:43 21417 DNS Res~ver #93    3.89 static.xx.fbcdn.net16:27:43 15580 DNS Res~ver #87    3.76 scontent-frt3-2.xx.fbcdn.net16:27:43 15777 DNS Res~ver #89    3.50 scontent-frx5-1.xx.fbcdn.net16:27:43 21417 DNS Res~ver #93    4.98 scontent-frt3-1.xx.fbcdn.net16:27:44 15580 DNS Res~ver #87    5.53 edge-chat.facebook.com16:27:44 15777 DNS Res~ver #89    0.24 edge-chat.facebook.com16:27:44 22099 cupsd         7.28 NPI86DDEE.local16:27:45 15580 DNS Res~ver #87    3.85 safebrowsing.googleapis.com^C%

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

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

 marko@marko-home ~$ sudo bashreadline-bpfccTIME   PID  COMMAND16:51:42 24309 uname -a16:52:03 24309 rm -rf src/badoo

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

Скрипт для просмотра флоу вызовов высокоуровневых языков:

 marko@marko-home ~/tmp$ sudo /usr/sbin/lib/uflow -l python 20590Tracing method calls in python process 20590... Ctrl-C to quit.CPU PID  TID  TIME(us) METHOD5  20590 20590 0.173  -> helloworld.py.hello5  20590 20590 0.173   -> helloworld.py.world5  20590 20590 0.173   <- helloworld.py.world5  20590 20590 0.173  <- helloworld.py.hello5  20590 20590 1.174  -> helloworld.py.hello5  20590 20590 1.174   -> helloworld.py.world5  20590 20590 1.174   <- helloworld.py.world5  20590 20590 1.174  <- helloworld.py.hello5  20590 20590 2.175  -> helloworld.py.hello5  20590 20590 2.176   -> helloworld.py.world5  20590 20590 2.176   <- helloworld.py.world5  20590 20590 2.176  <- helloworld.py.hello6  20590 20590 3.176  -> helloworld.py.hello6  20590 20590 3.176   -> helloworld.py.world6  20590 20590 3.176   <- helloworld.py.world6  20590 20590 3.176  <- helloworld.py.hello6  20590 20590 4.177  -> helloworld.py.hello6  20590 20590 4.177   -> helloworld.py.world6  20590 20590 4.177   <- helloworld.py.world6  20590 20590 4.177  <- helloworld.py.hello^C%

Здесь на примере показан стек вызовов программы на Python.

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


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

А что у нас с Go?


Теперь давайте поговорим о Go. У нас два основных вопроса:

  • Можно ли писать BPF-программы на Go?
  • Можно ли анализировать программы, написанные на Go?

Пойдём по порядку.

На сегодняшний день единственный компилятор, который умеет компилировать в формат, понимаемый BPF-машиной, Clang. Другой популярный компилятор, GСС, пока не имеет BPF-бэкенда. И единственный язык программирования, который может компилироваться в BPF, очень ограниченный вариант C.

Однако у BPF-программы есть и вторая часть, которая находится в user space. И её можно писать на Go.

Как я уже упоминал выше, BCC позволяет писать эту часть на Python, который является первичным языком инструмента. При этом в главном репозитории BCC также поддерживает Lua и C++, а в стороннем ещё и Go.



Выглядит такая программа точно так же, как программа на Python. В начале строка, в которой BPF-программа на C, а затем мы сообщаем, куда прицепить данную программу, и как-то с ней взаимодействуем, например достаём данные из EPF map.

Собственно, все. Рассмотреть пример подробнее можно на Github.
Наверное, основной недостаток заключается в том, что для работы используется C-библиотека libbcc или libbpf, а сборка Go-программы с такой библиотекой совсем не похожа на милую прогулку в парке.

Помимо iovisor/gobpf, я нашёл ещё три актуальных проекта, которые позволяют писать userland-часть на Go.


Версия от Dropbox не требует никаких C-библиотек, но вот kernel-часть BPF-программы вам придётся собрать самостоятельно с помощью Clang и затем загрузить в ядро Go-программой.

Версия от Cilium имеет те же особенности, что версия от Dropbox. Но она стоит упоминания хотя бы потому, что делается ребятами из проекта Cilium, а значит, обречена на успех.

Третий проект я привёл для полноты картины. Как и два предыдущих, он не имеет внешних C-зависимостей, требует ручной сборки BPF-программы на C, но, похоже, не имеет особых перспектив.

На самом деле, есть ещё один вопрос: зачем вообще писать BPF-программы на Go? Ведь если посмотреть на BCC или bpftrace, то BPF-программы в основном занимают меньше 500 строк кода. Не проще ли написать скриптик на bpftrace-языке или расчехлить немного Python? Я тут вижу два довода.

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

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



Проект Scope выглядит как один бинарник, который при запуске в инфраструктуре K8s или другого облака анализирует всё, что происходит вокруг, и показывает, какие есть контейнеры, сервисы, как они взаимодействуют и т. п. И многое из этого делается с использованием BPF. Интересный проект.

Анализируем программы на Go


Если помните, у нас был ещё один вопрос: можем ли мы анализировать программы, написанные на Go, с помощью BPF? Первая мысль конечно! Какая разница, на каком языке написана программа? Ведь это просто скомпилированный код, который так же, как и все остальные программы, что-то считает на процессоре, кушает память как не в себя, взаимодействует с железом через ядро, а с ядром через системные вызовы. В принципе, это правильно, но есть особенности разного уровня сложности.

Передача аргументов


Одна из особенностей состоит в том, что Go не использует ABI, который использует большинство остальных языков. Так уж получилось, что отцы-основатели решили взять ABI системы Plan 9, хорошо им знакомой.

ABI это как API, соглашение о взаимодействии, только на уровне битов, байтов и машинного кода.

Основной элемент ABI, который нас интересует, то, как в функцию передаются её аргументы и как из функции передаётся обратно ответ. Если в стандартном ABI x86-64 для передачи аргументов и ответа используются регистры процессора, то в Plan 9 ABI для этого использует стек.

Роб Пайк и его команда не планировали делать ещё один стандарт: у них уже был почти готовый компилятор для C для системы Plan 9, простой как дважды два, который они в кратчайшие сроки переделали в компилятор для Go. Инженерный подход в действии.

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

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

Уникальный идентификатор треда


Вторая особенность связана с любимой фичей Go горутинами. Один из способов измерить latency функций сохранить время вызова функции, засечь время выхода из функции и вычислить разницу; и сохранять время старта с ключом, в котором будет название функции и TID (номер треда). Номер треда нужен, так как одну и ту же функцию могут одновременно вызывать разные программы или разные потоки одной программы.

Но в Go горутины гуляют между системными тредами: сейчас горутина выполняется на одном треде, а чуть позже на другом. И в случае с Go нам бы в ключ положить не TID, а GID, то есть ID горутины, но получить мы его не можем. Чисто технически этот ID существует. Грязными хаками его даже можно вытащить, так как он где-то в стеке, но делать это строго запрещено рекомендациями ключевой группы разработчиков Go. Они посчитали, что такая информация нам не нужна будет никогда. Как и Goroutine local storage, но это я отвлёкся.

Расширение стека


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

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

Если говорить о C, то стек там имеет фиксированный размер. Если мы вылезем за пределы этого фиксированного размера, то произойдёт знаменитый stack overflow.

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

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

Тут и кроется основная проблема: uretprobes, которые используют для прикрепления BPF-функции, к моменту завершения выполнения функции динамически изменяют стек, чтобы встроить вызов своего обработчика, так называемого trampoline. И такое неожиданное для Go изменение его стека в большинстве случаев заканчивается падением программы. Упс!

Впрочем, эта история не уникальна. Разворачиватель стека C++ в момент обработки исключений тоже падает через раз.

Решения у данной проблемы нет. Как обычно в таких случаях, стороны перекидываются абсолютно обоснованными аргументами виновности друг друга.

Но если вам очень нужно поставить uretprobe, то проблему можно обойти. Как? Не ставить uretprobe. Можно поставить uprobe на все места, где мы выходим из функции. Таких мест может быть одно, а может быть 50.

И здесь уникальность Go играет нам на руку.

В обычном случае такой трюк не сработал бы. Достаточно умный компилятор умеет делать так называемый tail call optimization, когда вместо возврата из функции и возврата по стеку вызовов мы просто прыгаем в начало следующей функции. Такого рода оптимизация критически важна для функциональных языков вроде Haskell. Без неё они и шагу бы не могли ступить без stack overflow. Но с такой оптимизацией мы просто не сможем найти все места, где мы возвращаемся из функции.

Особенность в том, что компилятор Go версии 1.14 пока не умеет делать tail call optimization. А значит, трюк с прикреплением ко всем явным выходам из функции работает, хоть и очень утомителен.

Примеры


Не подумайте, что BPF бесполезен для Go. Это далеко не так: все остальное, что не задевает вышеуказанные нюансы, мы делать можем. И будем.
Давайте рассмотрим несколько примеров.

Для препарирования возьмём простенькую программку. По сути, это веб-сервер, который слушает на 8080 порту и имеет обработчик HTTP-запросов. Обработчик достанет из URL параметр name, параметр Go и сделает какую-то проверку сайта, а затем все три переменные (имя, год и статус проверки) отправит в функцию prepareAnswer(), которая подготовит ответ в виде строки.



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

Триггерить нашу программу будем простым запросом через curl:



В качестве первого примера с помощью bpftrace напечатаем все вызовы функций нашей программы. Мы здесь прикрепляемся ко всем функциям, которые попадают под main. В Go все ваши функции имеют символ, который выглядит как название пакета-точка-имя функции. Пакет у нас main, а рантайм функции был бы runtime.



Когда я делаю curl, то запускаются хендлер, функция проверки сайта и подфункция-горутина, а затем и функция подготовки ответа. Класс!

Дальше я хочу не только вывести, какие функции выполняются, но и их аргументы. Возьмём функцию prepareAnswer(). У неё три аргумента. Попробуем распечатать два инта.
Берём bpftrace, только теперь не однострочник, а скриптик. Прикрепляемся к нашей функции и используем алиасы для стековых аргументов, о которых я говорил.

В выводе мы видим то, что передали мы 2020, получили статус 200, и один раз передали 2021.



Но у функции три аргумента. Первый из них строка. Что с ним?

Давайте просто выведем все стековые аргументы от 0 до 4. И что мы видим? Какая-то большая цифра, какая-то цифра поменьше и наши старые 2021 и 200. Что же это за странные цифры в начале?



Вот здесь уже полезно знать устройство Go. Если в C строка это просто массив символов, который заканчивается нулём, то в Go строка это на самом деле структура, состоящая из указателя на массив символов (кстати, не заканчивающийся нулём) и длины.



Но компилятор Go при передаче строчки в виде аргумента разворачивает эту структуру и передаёт её как два аргумента. И получается, что первая странная цифра это как раз указатель на наш массив, а вторая длина.

И правда: ожидаемая длина строки 22.

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



Ну и заглянем в рантайм. Например, мне захотелось узнать, какие горутины запускает наша программа. Я знаю, что горутины запускаются функциями newproc() и newproc1(). Подконнектимся к ним. Первым аргументом функции newproc1() является указатель на структуру funcval, которая имеет только одно поле указатель на функцию:



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



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

Заключение


Это всё, о чём я хотел вам рассказать. Надеюсь, что у меня получилось вдохновить вас.

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

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

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

Система под контролем как автоматизировать интеграционные тесты

29.10.2020 16:15:50 | Автор: admin

Привет! Меня зовут Ксения Якиль. Я пишу core-сервисы на C и Go в бэкенд-отделе Badoo и Bumble. Наш бэкенд это высоконагруженная распределённая система, обслуживающая пользователей по всему миру. Она оперирует большими массивами данных и делает всю ту магию, благодаря которой люди находят друг друга.

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

Знакомьтесь, сервис М!

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

Сервис состоит из фронта (Front) и нескольких шардов (S1SN):

Но с увеличением количества задач в одиночку М перестал справляться так хорошо, как раньше. Поэтому у него появились товарищи другие сервисы: мы выделили отдельные логические части M и обернули их в сервисы на Go (Search и Supervisor), добавили Kafka и Consul.

И стало так:

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

  • Работает ли функционал, в котором участвуют несколько сервисов?

  • Поднимается ли система в заданной конфигурации?

  • Что будет, если один из сервисов вернёт некорректный ответ?

  • Что сделает наша система, если один из сервисов будет недоступен: вернёт ожидаемые ошибки, повторит отправку, выберет другой инстанс и отправит запрос туда или вернет закешированные данные?

Мы знаем, как должна вести себя система. Но совпадают ли ожидания и реальность?

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

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

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

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

Требования к фреймворку

Какие требования мы предъявляли к интеграционному фреймворку?

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

  • Обозримое (по возможности небольшое) время прохождения тестов. Требовались быстро поднять инфраструктуру и осуществить прогон тестов.

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

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

С высоты МКС (схематичный план)

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

Модули нашего фреймворка предоставляют всё необходимое для генерации данных, ожидания выполнения запросов, проверки ответов, работы с инфраструктурой и многое другое. Поскольку фреймворк написан на Go, мы решили использовать go testing, а сами тесты поместили в отдельные файлы.

Для настройки и очистки окружения мы используем модуль Testify. Он позволяет создать suite, в котором определены функции:

  • SetupSuite. Вызывается до прохождения всех тестов для данного suite. Именно здесь мы будем осуществлять подготовку окружения.

  • TearDownSuite. Вызывается после прохождения всех тестов для suite. Тут мы почистим за собой инфраструктуру.

  • SetupTest. Вызывается перед каждым тестом для suite. Здесь мы можем осуществлять какую-то локальную подготовку к тесту.

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

Собираем инфраструктуру

Инфраструктура должна предоставлять много возможностей:

  1. Настраивать разную конфигурацию наших сервисов.

  2. Поднимать сторонние сервисы (наподобие Kafka и Consul). Если использовать инстансы внешних сервисов на devel, то проведение интеграционного тестирования может влиять на его состояние. Это приведёт к нестабильному и неожиданному для наших коллег поведению системы. Кроме того, на результаты наших интеграционных тестов смогут влиять действия других отделов придётся тратить больше времени на расследование падений. Повысить стабильность и воспроизводимость тестов можно с помощью изоляции двух сред. Поэтому мы хотели использовать отдельные запущенные инстансы в своей тестовой среде. В качестве бонуса это даёт возможность использовать сервисы любой версии и конфигурации, быстрее проверять гипотезы и не согласовывать изменения с другими отделами.

  3. Работать с этой инфраструктурой: остановить Kafka/Consul/свои сервисы, исключить их из сети или включить в сеть. Нужна большая вариативность.

  4. Запускать на разных машинах, например на машинах разработчиков, QA-инженеров и CI.

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

Мы решили использовать Docker и обернули сервисы в контейнеры: тесты будут создавать свою сеть (Docker network) для каждого прогона и включать контейнеры в неё. Это хорошая изоляция для тестов из коробки.

Запуск в контейнере

Во фреймворке мы запускаем сервис в контейнере с помощью модуля testcontainers-go, который по факту представляет собой прослойку между Docker и нашими тестами на Go.

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

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

Рабочее окружение

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

  • Создаём иерархию каталогов на хосте.

  • Копируем все необходимые данные для нашего сервиса (скрипты, файлы, снепшоты и т.д.) в соответствующие директории.

  • Создаём дефолтный файл конфигурации и тоже помещаем его в эту иерархию.

  • Монтируем корень этой иерархии на хосте в Docker-контейнер.

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

Конфигурация

Здесь мы использовали простое решение.

Через Entrypoint задаём переменные окружения, аргументы запуска и подготовленный файл конфигурации. Когда контейнер поднимется, он выполнит всё, что указано в Entrypoint.

После этого сервис можно считать сконфигурированным. Пример:

Адрес сервиса

Итак, сервис поднялся в контейнере. У него есть рабочее окружение и определённая конфигурация для теста. Как найти другие сервисы?

Внутри Docker network всё просто.

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

  • Порты мы знаем заранее, так как на предыдущем этапе подготавливали файлы конфигурации и указывали порты для наших сервисов.

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

Если тесты запускаются на локальной машине, они не могут обращаться к сервису по имени его контейнера, так как адресация по имени контейнера внутри Docker network это абстракция самого Docker. Нам нужно найти номер порта на локальном хосте, который соответствует порту сервиса в Docker network. После поднятия контейнера мы получим соответствие внутреннего порта сервиса (inner port) порту на локальном хосте (external port). Последний и будем использовать в тестах.

Внешние сервисы

Наверняка в вашей инфраструктуре присутствуют сторонние сервисы, например базы данных и service discovery. Их конфигурация в идеале должна совпадать с той, что на продакшене. Если сервис простой (например, Consul в конфигурации одного процесса) мы его тоже можем запустить с помощью testcontainers-go. Но если сервис многокомпонентный (например, Kafka из нескольких брокеров, где требуется ZooKeeper), то можно не страдать и использовать для этого Docker Compose.

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

Фаза загрузки

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

Что делать?

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

  2. Открывать порты сервиса по мере готовности. Как только сервис прошёл фазу загрузки и готов принимать запросы клиентов, он открывает порты. Для тестового окружения это знак разрешения на запуск тестов. Однако есть нюанс: при создании контейнера Docker сразу открывает external port для сервиса, даже если последний ещё не начал слушать соответствующий internal port в контейнере. Поэтому в тестах сразу будет установлено соединение и попытка чтения из соединения приведёт к EOF. Когда сервис откроет internal port, тестовый фреймворк сможет отправить запрос. Только после этого мы будем считать, что сервис готов к работе.

  3. Запрашивать статус сервиса. Cервис сразу открывает порты, на запрос статуса отвечает Готов, если уже загрузился, и Не готов, если нет. В тестах мы будем периодически спрашивать сервис о его статусе и, как только получим ответ Готов, перейдём к фазе тестирования.

  4. Регистрировать в стороннем сервисе или базе данных. Мы регистрируем сервисы в Consul. Можно использовать:

    1. Факт появления сервиса в Consul как сигнал о готовности. Состояние сервиса можно отслеживать с помощью блокирующего запроса с тайм-аутом. Как только сервис зарегистрируется, Consul пришлет ответ на запрос с информацией об изменении статуса сервиса.

    2. Анализ состояния сервиса с помощью проверки его checka. Фреймворк для интеграционного тестирования получает информацию о новом сервисе из пункта 1 и начинает отслеживать изменения его статуса. Когда статусы всех сервисов будут passing, считаем, что они готовы к работе.

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

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

Поднятие всех сервисов

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

В какой последовательности осуществлять запуск? Идеальный вариант не иметь строгой последовательности. Это позволяет запускать сервисы параллельно и значительно сократить время создания инфраструктуры (время запуска контейнера + время загрузки сервиса). Чем меньше связей, тем проще добавлять новый сервис в инфраструктуру.

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

Инфраструктура во время тестирования

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

Изменение конфигурации сервиса

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

Добавление нового сервиса

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

Работа с сетью

Включение контейнеров в сеть и их исключение из неё, приостановка (pause/unpause) работы контейнеров, iptables позволяют нам эмулировать сетевые ошибки и проверять реакцию системы на них.

Инфраструктура после тестирования

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

  • Если было изменение конфигурации сервиса, делаем откат на предыдущую (дефолтную) конфигурацию.

  • Если было добавление нового сервиса, удаляем его.

  • Если были любые изменения в сети (iptables, приостановка контейнеров и т. д.), отменяем их.

  • Если были добавлены или изменены данные, запускаем очистку. Тут важно иметь механизм определения её завершения, чтобы обязательно его дождаться. Например, в случае очистки данных в стороннем сервисе базы данных недостаточно просто отправить запрос на удаление нужно удостовериться, что он отработан (а не застрял в очереди, в то время как мы уже новый тест запустили и он успел обратиться к данным, которые вот-вот будут удалены).

После завершения test suite работа всех сервисов в инфраструктуре тоже завершается, контейнеры убиваются, тестовая сеть опускается. Если test suite не успел завершиться по истечении тайм-аута или в результате ошибки, действуем точно так же. Только в случае явного указания фреймворку оставить контейнеры после прогона (например, для отладки), инфраструктура остаётся.

Ускорение тестов

Ждать вечность, пока пройдут интеграционные тесты, как правило, никому не хочется. Хотя в это время можно выпить кофе и сделать ещё много чего интересного.

Что мы можем сделать для ускорения тестов?

  • Группировать read-only-тесты и запускать их параллельно в рамках одного теста (в Go при помощи горутин это делается максимально просто). Эти тесты должны работать на изолированном множестве данных.

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

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

  • Запускать несколько тестовых инфраструктур параллельно (если позволяют ресурсы). По сути, это параллельный прогон test suite.

  • Переиспользовать контейнеры.

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

В тестах мы запускаем мок на определённом адресе. Этот адрес уже поднятые сервисы в текущей инфраструктуре узнают через конфиг или service discovery (Consul в нашем случае) и могут отправлять на него запросы.

Мок получает запрос и вызывает handler, который мы указали. На Go в тесте это выглядит примерно так:

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

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

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

Реализация

Наш фреймворк располагается в том же репозитории, что и тестируемые сервисы, лежит в отдельной директории autotests. Внутри неё есть несколько модулей:

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

Mock содержит реализацию мока-сервера для каждого нестороннего сервиса.

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

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

Помимо модулей самого фреймворка, на момент написания статьи у нас были созданы 21 test suites, в том числе и smoke test suite. Каждый создаёт свою инфраструктуру с необходимым набором сервисов. Тесты находятся в файлах внутри test suite.

Запуск конкретного test suite выглядит примерно так:

go test -count=1 -race -v ./testsuite $(TESTSFLAGS) -autotests.timeout=15m

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

Отладка

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

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

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

  • Перед началом теста отправляем log_notice с названием теста во все поднятые сервисы. По окончании теста делаем то же самое.

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

Как быть, если сервис не смог подняться и не успел сделать запись в лог? Скорее всего, он записал в stderr/stdout дополнительную информацию. Команда docker logs позволяет получать данные из стандартных потоков ввода-вывода это поможет нам понять, что случилось.

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

Указываем в конфигурации фреймворка необходимость оставлять инфраструктуру после прогона всех тестов в suite. Благодаря этому мы получаем полный доступ к системе. Можно узнать статус сервиса, получить данные из него, отправлять различные запросы, анализировать файлы сервиса на диске, а так же использовать gdb/strace/tcpdump и профилирование. Дальше мы строим гипотезу, пересобираем образ, запускаем тесты и итеративно находим корень проблемы.

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

QA

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

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

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

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

CI

Оказалось, что встроить интеграционные тесты в CI очень просто. Мы используем TeamCity. Код фреймворка находится в репозитории с кодом сервисов. Сначала собираются сервисы, создаются образы, далее происходят сборка фреймворка и наконец его запуск.

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

Итоги

Ниже результаты проделанной работы.

  • Жить стало спокойнее. Меньше проблем с интеграцией просачивается на продакшен. Как следствие более стабильный прод.

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

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

  • Мы ловим больше багов на этапе разработки. Positive-сценарии пишут сами разработчики, отлавливая часть ошибок и сразу их решая. Уменьшается round-trip бага.

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

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

  • Мы написали MVP фреймворка для интеграционного тестирования довольно быстро за пару недель. Задача оказалась не слишком трудоёмкой.

  • Мы используем фреймворк уже больше года.

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

Однако интеграционное тестирование имеет ряд минусов, которые стоит учитывать.

  • Увеличение времени прогона тестов. Системы сложные, запросы выполняются в нескольких сервисах.

  • Нестабильность, так как система состоит из асинхронных компонентов. С этим можно и нужно бороться. Мы тоже это делаем: доля нестабильных тестов у нас близка к нулю.

  • Сложность написания тестов. Нужно понимать, как работает система в целом, каково её ожидаемое поведение и как её можно сломать.

  • Возможность расхождения инфраструктуры в тестах и на продакшене. Если не все сервисы на проде в контейнерах, то тестовое окружение не на 100% совпадает с продакшеном. У нас как раз часть сервисов на проде не в контейнерах, но мы пока не сталкивались с проблемами из-за их тестирования в контейнерах.

И, наконец, главный вопрос, на который нужно ответить: необходим ли вам фреймворк для интеграционных тестов?

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

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

Успехов и удачи!

Подробнее..

К порядку правила создания конвейеров обработки данных

30.12.2020 16:07:24 | Автор: admin

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

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

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

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

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

Первое правило сформулировать легко: каждое отдельное взятое преобразование должно быть как можно проще и меньше.

Допустим, данные поступают на машину с POSIX-совместимой операционной системой. Каждая единица данных это JSON-объект, и эти объекты собираются в большие файлы-пакеты, содержащие по одному JSON-объекту на строку. Пускай каждый такой пакет весит около 10 Гб.

Над пакетом надо произвести три преобразования:

  1. Проверить ключи и значения каждого объекта.

  2. Применить к каждому объекту первую трансформацию (скажем, изменить схему объекта).

  3. Применить вторую трансформацию (внести новые данные).

Совершенно естественно всё это делать с помощью единственного скрипта на Python:

python transform.py < /input/batch.json > /output/batch.json

Блок-схема такого конвейера не выглядит сложной:

Проверка объектов в transform.py занимает около 10% времени, первое преобразование 70%, на остальное уходит 20% времени.

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

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

python validate.py < /input/batch.json > /tmp/validated.jsonpython transform1.py < /input/batch.json > /tmp/transformed1.jsonpython transform2.py < /input/transformed1.json > /output/batch.json

Блок-схема превращается в симпатичный паровозик:

Выгоды очевидны:

  • конкретные преобразования проще понять;

  • каждый этап можно протестировать отдельно;

  • промежуточные результаты отлично кешируются;

  • систему легко дополнить механизмами обработки ошибок;

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

Правило атомарности

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

Давайте вернёмся к первому примеру. Есть входные данные, над которыми мы проводим преобразование:

python transform.py < /input/batch.json > /output/batch.json

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

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

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

В POSIX-совместимых файловых системах всегда есть атомарные операции (скажем, mv или ln), с помощью которых можно имитировать транзакции:

python transform.py < /input/batch.json > /output/batch.json.tmpmv /output/batch.json.tmp /output/batch.json

В этом примере испорченные промежуточные данные окажутся в файле *.tmp, который можно изучить позднее при проведении отладки или просто удалить.

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

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

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

Википедия

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

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

python transform.py < /input/batch.json > /output/batch1.jsonpython transform.py < /input/batch.json > /output/batch2.jsondiff /input/batch1.json /output/batch2.json# файлы те жеpython transform.py < /input/batch.json > /output/batch3.jsondiff /input/batch2.json /output/batch3.json# никаких изменений

На входе у нас /input/batch.json, а на выходе /output/batch.json. И вне зависимости от того, сколько раз мы применим преобразование, мы должны получить одни и те же данные:

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

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

Чем важна идемпотентность? В первую очередь это свойство упрощает обслуживание конвейера. Оно позволяет легко перезагружать подмножества данных после изменений в transform.py или входных данных в /input/batch.json. Информация будет идти по тем же маршрутам, попадёт в те же таблицы базы данных, окажется в тех же файлах и т. д.

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

Правило избыточности

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

Пример:

python transform1.py < /input/batch.json > /tmp/batch-1.jsonpython transform2.py < /tmp/batch-1.json > /tmp/batch-2.jsonpython transform3.py < /tmp/batch-2.json > /tmp/batch-3.jsoncp /tmp/batch-3.json /output/batch.json.tmp # не атомарно!mv /output/batch.json.tmp /output/batch.json # атомарно

Сохраняйте сырые (input/batch.json) и промежуточные (/tmp/batch-1.json, /tmp/batch-2.json, /tmp/batch-3.json) данные как можно дольше по меньшей мере до завершения цикла работы конвейера.

Вы скажете мне спасибо, когда аналитики решат поменять алгоритм вычисления какой-то метрики в transform3.py и вам придётся исправлять данные за несколько месяцев.

Другими словами: избыточность избыточных данных ваш лучший избыточный друг.

Заключение

Давайте подведём итоги:

  • разбивайте конвейер на изолированные маленькие этапы;

  • стремитесь делать этапы атомарными и идемпотентными;

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

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

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

А у вас есть свои правила обработки данных?

Подробнее..

Run, config, run как мы ускорили деплой конфигов в Badoo

25.02.2021 18:11:56 | Автор: admin

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

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

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

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


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

Случай из жизни

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

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

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

Для отключения сервисов мы используем свою систему с говорящим названием Выключалка хостов (disable hosts). Принцип её работы довольно прост:

  • выбираем в веб-интерфейсе сервисы, которые нужно отключить (или, наоборот, включить);

  • нажимаем на кнопку Deploy;

  • изменения сохраняются в базе данных и затем доставляются на все машины, на которых выполняется PHP-код.

В коде при подключении к сервису стоит проверка вида:

if (\DownChecker\Host::isDisabled($host)) {   $this->errcode = self::ERROR_CONNECT_FAILED;   return false;}

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

Процесс деплоя разбит на несколько шагов:

  • упаковываем конфиги в tar-архив;

  • копируем архив на сервер через rsync или scp;

  • распаковываем архив в отдельную директорию;

  • переключаем симлинк на новую директорию.

Особенность mcode в том, что она ждёт завершения выполнения текущего шага на каждом сервере, прежде чем перейти к следующему. С одной стороны, это даёт больше контроля на каждом этапе и гарантирует, что на 99% серверов изменения доедут примерно в один момент времени (псевдоатомарность). С другой проблемы с одним из серверов приводят к тому, что процесс деплоя зависает, ожидая завершения выполнения текущей команды по тайм-ауту. Если на каком-то сервере несколько раз подряд не удалось выполнить команду, то он временно исключается из раскладки. Это позволяет уменьшить влияние проблемных серверов на общую продолжительность деплоя.

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

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

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

В поисках альтернативного транспорта

Тут может возникнуть резонный вопрос: зачем изобретать велосипед, если можно использовать классическую схему с базой данных (БД) и кешированием?

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

  • использование БД и кеша это дополнительная завязка на внешний сервис, а значит, ещё одна потенциальная точка отказа;

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

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

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

Но в этой схеме нам не понравилась идея с запросами в цикле. У нас около 2000 серверов, на которых может выполняться PHP-код. Если мы будем делать запрос в базу/кеш хотя бы один раз в секунду, то это будет создавать довольно большой фон запросов (2k rps), а сами данные при этом обновляются не так часто. На этот случай есть решение событийная модель, например шаблон проектирования Publisher-Subscriber (PubSub). Из популярных решений можно было использовать Redis, но у нас он не прижился (для кеширования мы используем Memcache, а для очередей у нас есть своя отдельная система).

Зато прижился Consul, у которого есть механизм отслеживания изменений (watches) на базе блокирующих запросов. Это в целом похоже на PubSub и вписывается в нашу схему с обновлением конфига по событию. Мы решили сделать прототип нового транспорта на базе Consul, который со временем эволюционировал в отдельную систему под названием AutoConfig.

Как работает AutoConfig

Общая схема работы выглядит следующим образом:

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

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

  • на каждом сервере запущен Consul watch, который отслеживает изменения индексной карты и вызывает специальный скрипт-обработчик, когда она обновляется;

  • обработчик записывает изменения в файл.

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

Обновление ключа

$record = \AutoConfig\AutoConfigRecord::initByKeyData('myKey', 'Hello, Habr!', 'Eugene Tupikov');$storage = new \AutoConfig\AutoConfigStorage();$storage->deployRecord($record);

Чтение ключа

$reader = new \AutoConfig\AutoConfigReader();$config = $reader->getFromCurrentSpace('myKey');

Удаление ключа

$storage = new \AutoConfig\AutoConfigStorage();$storage->removeKey('example');

Подробнее про Consul watch

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

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

curl -X PUT --data 'hello, harb!' http://127.0.0.1:8500/v1/kv/habr-key

Затем отправляем запрос на чтение нашего ключа

curl -v http://127.0.0.1:8500/v1/kv/habr-key

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

......< X-Consul-Index: 266834870< X-Consul-Knownleader: true......<[  {    "LockIndex": 0,    "Key": "habr-key",    "Flags": 0,    "Value": "dXBkYXRlZCAy",    "CreateIndex": 266833109,    "ModifyIndex": 266834870  }]

Мы отправляем новый запрос на чтение и дополнительно передаем значение из заголовка X-Consul-Index в параметре запроса index

curl http://127.0.0.1:8500/v1/kv/habr-key?index=266834870Ждем изменений ключа...

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

Затем открываем новую вкладку терминала и отправляем запрос на обновление ключа

curl -X PUT --data 'updated value' http://127.0.0.1:8500/v1/kv/habr-key

Возвращаемся на первую вкладку и видим, что запрос на чтение вернул обновленное значение (изменились ключи Value и ModifyIndex):

[  {    "LockIndex": 0,    "Key": "habr-key",    "Flags": 0,    "Value": "dXBkYXRlZA==",    "CreateIndex": 266833109,    "ModifyIndex": 266835734  }]

При вызове команды

consul watch -type=key -key=habr_key <handler>

Consul watch автоматически выполнит указанную выше последовательность запросов и вызовет обработчик в случае изменения значения ключа.

Зачем нужна индексная карта

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

consul watch -type=keyprefix -prefix=auto_config/ <handler>

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

По этому поводу на GitHub уже довольно давно открыт Issue и, судя по комментариям, лёд тронулся. Разработчики Consul начали работу над улучшением подсистемы блокирующих запросов, что должно решить описанную выше проблему.

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

Она имеет следующий формат:

return [    'value' => [        'version' => 437036,        'keys' => [            'my/awesome/key' => '80003ff43027c2cc5862385fdf608a45',            ...            ...        ],        'created_at' => 1612687434    ]]

В случае если карта обновилась, обработчик:

  • считывает текущее состояние карты с диска;

  • находит изменившиеся ключи (для этого и нужен хеш значения);

  • вычитывает через HTTP API актуальные значения изменившихся ключей и обновляет нужные файлы на диске;

  • сохраняет новую индексную карту на диск.

И ещё немного про Consul

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

Ограничение размера значения ключа (и не только)

Consul это распределённая система, и для достижения согласованности используется протокол Raft. Для стабильной работы протокола в Consul установлен максимальный размер значения одного ключа 512 Кб. Его можно изменить, воспользовавшись специальной опцией, но делать это крайне не рекомендуется, так как изменение может привести к непредсказуемому поведению всей системы.

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

Чтобы обойти эти ограничения, мы сделали следующее:

  • разбили индексную карту на несколько частей (по 1000 ключей в каждой) и обновляем только части, содержащие изменённые ключи;

  • ограничили максимальный размер одного ключа AutoConfig 450 Кб, чтобы оставить место для шардов индексной карты (значение выбрано опытным путём);

  • доработали скрипт, обрабатывающий очередь на деплой таким образом, что он

    • сначала вычитывает N ключей из очереди и проверяет их суммарный размер;

    • если размер не превышает заданный предел, то деплоит все ключи разом, а в противном случае деплоит ключи по одному.

Отсутствие встроенной репликации

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

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

В качестве заключения

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

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

За последние пару лет система стала популярна у наших разработчиков и сегодня фактически является стандартом при работе с конфигами, для которых не требуется атомарная раскладка. Например, через AutoConfig у нас деплоятся параметры A/B-тестов, настройки промокампаний, на его базе реализован функционал Service Discovery и многое другое.

Общее количество ключей на данный момент порядка 16 000, а их суммарный размер примерно 120 Мб.

Спасибо за внимание!

Расскажите в комментариях, как вы деплоите конфиги в своих проектах.

Подробнее..

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

16.03.2021 18:09:12 | Автор: admin

Меня зовут Дмитрий Макаренко, я Mobile QA Engineer в Badoo и Bumble: занимаюсь тестированием новой функциональности в наших приложениях вручную и покрытием её автотестами.

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

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

В подготовке текста мне помогал мой коллега Виктор Короневич: с этой темой мы вместе выступали на конференции Heisenbug.

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

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

Спойлер

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

Практика 4. Верификация изменения состояния элементов

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

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

Таким образом, прежде чем начать проверять элементы, нам необходимо дождаться их появления на экране. Естественно, эта проблема не нова и существуют стандартные решения. Например, в Selenium это различные типы методов wait, а в Calabash метод wait_for.

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

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

После того как мы добавили логи и проанализировали их, оказалось, что эти зависания были связаны с реализацией метода wait_for, входящего в состав фреймворка Calabash. wait_for использует метод timeout модуля Ruby Timeout, который реализован на глобальном потоке. А тесты зависали, когда этот метод timeout использовался вложено в других методах: наших и фреймворка Calabash.

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

def scroll_to_block_button  wait_for(timeout: 30) do    ui.scroll_down    ui.wait_until_no_animation    ui.element_displayed?(BLOCK_BUTTON)  endend

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

Рассмотрим реализацию метода wait_until_no_animation.

def wait_until_no_animation  wait_for(timeout: 10) do    !ui.any_element_animating?  endend

Метод wait_until_no_animation реализован так же с wait_for. Он ждёт, когда на экране закончится анимация. Получается, что wait_for, вызванный внутри wait_for, вызывает другие методы. Представьте себе, что вызовы wait_for также есть внутри методов Calabash. С увеличением цепочки wait_for внутри wait_for внутри wait_for риск зависания увеличивается. Поэтому мы решили отказаться от использования этого метода и придумать своё решение.рый бы повторял проверку до тех пор, пока не выполнится заданное условие либо пока не истечёт отведённое время. Если проверка не проходит успешно за отведённое время, наш метод должен выбрасывать ошибку.

Сначала мы создали модуль Poll с одним методом for, который повторял стандартный метод wait_for. Со временем собственная реализация позволила нам расширять функциональность модуля по мере того, как у нас появлялась такая необходимость. Мы добавили методы, ожидающие конкретные значения заданных условий. Например, Poll.for_true и Poll.for_false явно ожидают, что исполняемый код вернёт true либо false. В примерах ниже я покажу использование разных методов из модуля Poll.

Также мы добавили разные параметры методов. Рассмотрим подробнее параметр return_on_timeout. Его суть в том, что при использовании этого параметра наш метод Poll.for перестаёт выбрасывать ошибку, даже если заданное условие не выполняется, а просто возвращает результат выполнения проверки.

Предвижу вопросы Как это работает? и Зачем это нужно?. Начнём с первого. Если в методе Poll.for мы будем ждать, пока 2 станет больше, чем 3, то мы всегда будем получать ошибку по тайм-ауту.

Poll.for { 2 > 3 }> WaitError

Но если мы добавим наш параметр return_on_timeout и всё так же будем ждать, пока 2 станет больше, чем 3, то после окончания тайм-аута, 2 всё ещё не станет больше, чем 3, но наш тест не упадёт, а метод Poll.for вернёт результат этой проверки.

Poll.for(return_on_timeout: true) { 2 > 3 }> false

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

Варианты изменения состояния элементов

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

Он умеет всего две вещи: появляться на экране и пропадать с экрана.

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

Должен появитьсяДолжен появиться

Если он появляется, то проверка проходит успешно.

Второй вариант изменения состояния называется Должен пропасть. Происходит он тогда, когда в состоянии 1 отображается наш объект тестирования, а в состоянии 2 его быть не должно.

 Должен пропасть Должен пропасть

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

 Не должен появиться Не должен появиться

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

Не должен пропастьНе должен пропасть

Реализация проверок разных вариантов

Мы зафиксировали все возможные варианты изменения состояния элементов. Как же их проверить? Разобьём реализацию на проверки первых двух вариантов и проверки третьего и четвёртого.

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

# вариант "Должен появиться"Poll.for_true { ui.elements_displayed?(locator) }

Для проверки второго подождать, пока элемент пропадёт:

# вариант "Должен пропасть"Poll.for_false { ui.elements_displayed?(locator) }

Но в случае с третьим и четвёртым вариантами всё не так просто.

Рассмотрим вариант Не должен появиться:

# вариант "Не должен появиться"ui.wait_for_elements_not_displayed(locator)actual_state = Poll.for(return_on_timeout: true) { ui.elements_displayed?(locator) }Assertions.assert_false(actual_state, "Element #{locator} should not appear")

Здесь мы, во-первых, фиксируем состояние отсутствия элемента на экране.

Далее, используя Poll.for с параметром return_on_timeout, мы ждём появления элемента. При этом метод Poll.for не выбросит ошибку, а вернёт false, если элемент не появится. Значение, полученное из Poll.for, сохраняется в переменной actual_state.

После этого происходит проверка неизменности состояния элемента с использованием метода assert.

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

# вариант "Не должен пропасть"ui.wait_for_elements_displayed(locator)actual_state = Poll.for(return_on_timeout: true) { !ui.elements_displayed?(locator) }Assertions.assert_false(actual_state, "Element #{locator} should not disappear")

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

def verify_dynamic_state(state:, timeout: 10, error_message:)  options = {    return_on_timeout: true,    timeout:           timeout,  }  case state    when 'should appear'      actual_state = Poll.for(options) { yield }      Assertions.assert_true(actual_state, error_message)    when 'should disappear'      actual_state = Poll.for(options) { !yield }      Assertions.assert_true(actual_state, error_message)    when 'should not appear'      actual_state = Poll.for(options) { yield }      Assertions.assert_false(actual_state, error_message)    when 'should not disappear'      actual_state = Poll.for(options) { !yield }      Assertions.assert_false(actual_state, error_message)    else      raise("Undefined state: #{state}")  endend

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

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

Выводы:

  • важно не забывать про все четыре варианта изменения состояния при проверках UI-элементов;

  • полезно вынести эти проверки в общий метод.

Мы рекомендуем использовать полную систему проверок всех вариантов изменения состояния. Что мы имеем в виду? Представьте, что когда элемент есть это состояние true, а когда его нет false.

Состояние 1

Состояние 2

Должен появиться

FALSE

TRUE

Должен пропасть

TRUE

FALSE

Не должен появиться

FALSE

FALSE

Не должен пропасть

TRUE

TRUE

Мы строим матрицу всех комбинаций. При появлении нового состояния таблицу можно расширить и получить новые комбинации.

Практика 5. Надёжная настройка предусловий тестов

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

Рассмотрим два примера. Первый отключение сервиса локации на iOS в настройках. Второй создание истории чата.

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

def switch_off_location_service  ui.wait_for_elements_displayed(SWITCH)  if ui.element_value(SWITCH) == ON    ui.tap_element(SWITCH)    ui.tap_element(TURN_OFF)  endend

Мы ждём, пока переключатель (элемент switch) появится на экране. Потом проверяем его состояние. Если оно не соответствует ожидаемому, мы его изменяем.

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

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

def send_message(from:, to:, message:, count:)  count.times do    QaApi.chat_send_message(user_id: from, contact_user_id: to, message: message)  endend

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

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

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

Как же решить эту проблему? Мы можем добавить гарантию выполнения действия в методы установки предусловий.

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

def ensure_location_services_switch_in_state_off  ui.wait_for_elements_displayed(SWITCH)  if ui.element_value(SWITCH) == ON    ui.tap_element(SWITCH)    ui.tap_element(TURN_OFF)    Poll.for(timeout_message: 'Location Services should be disabled') do      ui.element_value(SWITCH) == OFF    end  endend

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

Во втором примере нам снова помогут наши методы QAAPI.

def send_message(from:, to:, message:, count:)  actual_messages_count = QaApi.received_messages_count(to, from)  expected_messages_count = actual_messages_count + count  count.times do    QaApi.chat_send_message(user_id: from, contact_user_id: to, message: message)  end  QaApi.wait_for_user_received_messages(from, to, expected_messages_count)end

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

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

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

Более подробно о проблемах, описанных в этом разделе, можно прочитать в статье Мартина Фаулера.

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

Простые действия

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

Начнём с теста поиска и отправки GIF-сообщений.

Сначала нам нужно открыть чат с пользователем, которому мы хотим отправить сообщение:

When  primary_user opens Chat with chat_user

Потом открыть поле ввода GIF-сообщений:

And   primary_user switches to GIF input source

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

And   primary_user searches for "bee" GIFsAnd   primary_user sends 7th GIF in the listThen  primary_user verifies that the selected GIF has been sent

Целиком сценарий выглядит так:

Scenario: Searching and sending GIF in Chat  Given users with following parameters    | role         | name |    | primary_user | Dima |    | chat_user    | Lera |  And   primary_user logs in  When  primary_user opens Chat with chat_user  And   primary_user switches to GIF input source  And   primary_user searches for "bee" GIFs  And   primary_user sends 7th GIF in the list  Then  primary_user verifies that the selected GIF has been sent

Обратим внимание на шаг, который отвечает за поиск гифки:

And(/^primary_user searches for "(.+)" GIFs$/) do |keyword|  chat_page = Pages::ChatPage.new.await  TestData.gif_list = chat_page.gif_list  chat_page.search_for_gifs(keyword)  Poll.for_true(timeout_message: 'Gif list is not updated') do    (TestData.gif_list & chat_page.gif_list).empty?  endend

Здесь, как и почти во всех остальных шагах, мы делаем следующее:

  1. сначала ожидаем открытия нужной страницы (ChatPage);

  2. потом сохраняем список всех доступных GIF-изображений;

  3. далее вводим ключевое слово для поиска;

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

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

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

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

  1. сохранение текущего списка;

  2. поиск;

  3. проверку обновления списка.

Решением проблемы переиспользования будет разделение этого шага на три простых и независимых.

Первый шаг сохраняет текущий список изображений:

And(/^primary_user stores the current list of GIFs$/) do  TestData.gif_list = Pages::ChatPage.new.await.gif_listend

Второй шаг поиск гифки позволяет напечатать ключевое слово для поиска:

And(/^primary_user searches for "(.+)" GIFs$/) do |keyword|  Pages::ChatPage.new.await.search_for_gifs(keyword)end

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

And(/^primary_user verifies that list of GIFs is updated$/) do  chat_page = Pages::ChatPage.new.await  Poll.for_true(timeout_message: 'Gif list is not updated') do    (TestData.gif_list & chat_page.gif_list).empty?  endend

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

Scenario: Searching and sending GIF in Chat  Given users with following parameters    | role         | name |    | primary_user | Dima |    | chat_user    | Lera |  And   primary_user logs in  When  primary_user opens Chat with chat_user  And   primary_user switches to GIF input source  And   primary_user stores the current list of GIFs  And   primary_user searches for "bee" GIFs  Then  primary_user verifies that list of GIFs is updated  When  primary_user sends 7th GIF in the list  Then  primary_user verifies that the selected GIF has been sent

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

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

Сложные действия

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

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

Тестовый пользовательТестовый пользователь

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

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

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

When(/^primary_user votes No in Messenger mini game (\d+) times$/) do |count|  page = Pages::MessengerMiniGamePage.new.await  count.to_i.times do    page.vote_no  endend

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

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

When(/^primary_user votes No in Messenger mini game (\d+) times$/) do |count|  page = Pages::MessengerMiniGamePage.new.await  count.to_i.times do    progress_before = page.progress    page.vote_no    Poll.for_true do      page.progress > progress_before       end  endend

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

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

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

Практика 7. Верификация необязательных элементов

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

Примеры диалоговых оконПримеры диалоговых окон

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

Проанализируем скриншоты выше.

  • Скриншот 1: заголовок, описание и две кнопки.

  • Скриншот 2: заголовок, описание и одна кнопка.

  • Скриншот 3: описание и две кнопки.

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

Начнём с того, как выглядит вызов метода для верификации каждого из диалогов:

class ClearAccountAlert < AppAlertAndroid  def verify_alert_lexemes    verify_alert(title:        ClearAccount::TITLE,                 description:  ClearAccount::MESSAGE,                 first_button: ClearAccount::OK_BUTTON,                 last_button:  ClearAccount::CANCEL_BUTTON)  endend
class WaitForReplyAlert < AppAlertAndroid  def verify_alert_lexemes    verify_alert(title:        WaitForReply::TITLE,                 description:  WaitForReply::MESSAGE,                 first_button: WaitForReply::CLOSE_BUTTON)  endend
class SpecialOffersAlert < AppAlertAndroid  def verify_alert_lexemes    verify_alert(description:  SpecialOffers::MESSAGE,                 first_button: SpecialOffers::SURE_BUTTON,                 last_button:  SpecialOffers::NO_THANKS_BUTTON)  endend

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

Рассмотрим реализацию метода verify_alert:

def verify_alert(title: nil, description:, first_button:, last_button: nil)  ui.wait_for_elements_displayed([MESSAGE, FIRST_ALERT_BUTTON])  ui.wait_for_element_text(expected_lexeme: title, locator: ALERT_TITLE) if title  ui.wait_for_element_text(expected_lexeme: description, locator: MESSAGE)  ui.wait_for_element_text(expected_lexeme: first_button, locator: FIRST_ALERT_BUTTON) ui.wait_for_element_text(expected_lexeme: last_button, locator: LAST_ALERT_BUTTON) if last_buttonend

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

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

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

Для этого в тестах мы меняем проверку

ui.wait_for_element_text(expected_lexeme: title, locator: ALERT_TITLE) if title

на

if title.nil?  Assertions.assert_false(ui.elements_displayed?(ALERT_TITLE), "Alert title should not be displayed")else  ui.wait_for_element_text(expected_lexeme: title, locator: ALERT_TITLE)end

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

def wait_for_optional_element_text(expected_lexeme:, locator:)  GuardChecks.not_nil(locator, 'Locator should be specified')  if expected_lexeme.nil?    Assertions.assert_false(elements_displayed?(locator), "Element with locator #{locator} should not be displayed")  else    wait_for_element_text(expected_lexeme: expected_lexeme, locator: locator)  endend

Реализация метода verify_alert тоже изменилась:

def verify_alert(title: nil, description:, first_button:, last_button: nil)  ui.wait_for_elements_displayed([MESSAGE, FIRST_ALERT_BUTTON])  ui.wait_for_optional_element_text(expected_lexeme: title, locator: ALERT_TITLE)  ui.wait_for_element_text(expected_lexeme: description, locator: MESSAGE)  ui.wait_for_element_text(expected_lexeme: first_button, locator: FIRST_ALERT_BUTTON)  ui.wait_for_optional_element_text(expected_lexeme: last_button, locator: LAST_ALERT_BUTTON)end

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

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

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

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

Общие рекомендации

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

  • так как проверки это то, ради чего мы пишем тесты, всегда используйте полную систему проверок;

  • не забывайте добавлять проверку предустановки для асинхронных действий;

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

  • делайте объект тестирования простым;

  • выделяйте независимые методы для простых действий в тестах.

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

Бонус

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

Mobile Automation Sample Project

Подробнее..

Работа с частичными моками в PHPUnit 10

26.04.2021 16:20:03 | Автор: admin

В этом году должен выйти PHPUnit 10 (релиз планировался на 2 апреля 2021 года, но был отложен). Если посмотреть на список изменений, то бросается в глаза большое количество удалений устаревшего кода. Одним из таких изменений является удаление метода MockBuilder::setMethods(), который активно использовался при работе с частичными моками. Этот метод не рекомендуется использовать с версии 8.0, но тем не менее он описан в документации без каких-либо альтернатив и упоминания о его нежелательности. Если почитать исходники PHPUnit, issues и пул-реквесты на GitHub, то станет понятно, почему так и какие есть альтернативы.

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

Что такое частичные моки?

У программного кода, который мы пишем, чаще всего есть какие-то зависимости.

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

Про название "мок"

У этого термина в русском языке есть несколько обозначений: мок, mock-объект, подставной объект, имитация. Я буду пользоваться калькой английского слова mock (мок).

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

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

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

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

abstract class AbstractCommand{    /**     * @throws \PhpUnitMockDemo\CommandException     * @return void     */    abstract protected function execute(): void;    public function run(): bool    {        $success = true;        try {            $this->execute();        } catch (\Exception $e) {            $success = false;            $this->logException($e);        }        return $success;    }    protected function logException(\Exception $e)    {        // Logging    }} 

Реальное поведение команды задаётся в методе execute классов-наследников, а метод run() добавляет общее для всех команд поведение (в данном случае делает код exception safe и логирует ошибки).

Если мы хотим написать тест для метода run, мы можем воспользоваться частичными моками, функционал которых предоставляет класс PHPUnit\Framework\MockObject\MockBuilder, доступ к которому предоставляется через вспомогательные методы класса TestCase (в примере это getMockBuilder и createPartialMock):

use PHPUnit\Framework\TestCase;class AbstractCommandTest extends TestCase{    public function testRunOnSuccess()    {        // Arrange        $command = $this->getMockBuilder(AbstractCommand::class)            ->setMethods(['execute', 'logException'])            ->getMock();        $command->expects($this->once())->method('execute');        $command->expects($this->never())->method('logException');        // Act        $result = $command->run();        // Assert        $this->assertTrue($result, "True result is expected in the success case");    }    public function testRunOnFailure()    {        // Arrange        $runException = new CommandException();        // It's an analogue of $this->getMockBuilder(...)->setMethods([...])->getMock()        $command = $this->createPartialMock(AbstractCommand::class, ['execute', 'logException']);        $command->expects($this->once())            ->method('execute')            ->will($this->throwException($runException));        $command->expects($this->once())            ->method('logException')            ->with($runException);        // Act        $result = $command->run();        // Assert        $this->assertFalse($result, "False result is expected in the failure case");    }} 

Исходный код, результаты прогона тестов

В методе testRunOnSuccess с помощью MockBuilder::setMethods() мы задаём список методов оригинального класса, которые мы заменяем (вызовы которых хотим проверить или результаты которых нужно зафиксировать). Все остальные методы сохраняют свою реализацию из оригинального класса AbstractCommand (и их логику можно тестировать). В testRunOnFailure через метод createPartialMock мы делаем то же самое, но явно.

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

  • подготовка или освобождение каких-то ресурсов (например, соединения с базой данных);

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

  • отправка какой-то отладочной информации или статистики.

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

Кроме переопределения существующих методов, MockBulder::setMethods() позволяет добавлять в класс мока новые методы, которых нет в оригинальном классе. Это может быть полезно при использовании в тестируемом коде магического метода __call.

Возьмём в качестве примера класс \Predis\Client. Он использует метод __call для обработки передаваемых клиенту команд. При этом во внешнем коде это выглядит как вызов конкретного метода и кажется естественным переопределить в создаваемом моке этот вызываемый в коде метод, а не переопределять __call, вдаваясь в детали реализации.

Пример:

   public function testRedisHandle()    {        if (!class_exists('Redis')) {            $this->markTestSkipped('The redis ext is required to run this test');        }        $redis = $this->createPartialMock('Redis', ['rPush']);        // Redis uses rPush        $redis->expects($this->once())            ->method('rPush')            ->with('key', 'test');        $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]);        $handler = new RedisHandler($redis, 'key');        $handler->setFormatter(new LineFormatter("%message%"));        $handler->handle($record);    } 

Источник: тест RedisHandlerTest из monolog 2.2.0

Какие проблемы возникают при использовании setMethods?

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

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

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

--- a/src/AbstractCommand.php+++ b/src/AbstractCommand.php@@ -13,6 +13,7 @@ abstract class AbstractCommand     public function run(): bool     {+        $this->timerStart();         $success = true;         try {             $this->execute();@@ -21,6 +22,7 @@ abstract class AbstractCommand             $this->logException($e);         }+        $this->timerStop();         return $success;     }@@ -28,4 +30,14 @@ abstract class AbstractCommand     {         // Logging     }++    protected function timerStart()+    {+        // Timer implementation+    }++    protected function timerStop()+    {+        // Timer implementation+    } } 

Исходный код

В код тестов добавим в мок новые методы, но не будем проверять вызовы через expectations:

--- a/tests/AbstractCommandTest.php+++ b/tests/AbstractCommandTest.php@@ -11,7 +11,7 @@ class AbstractCommandTest extends TestCase     {         // Arrange         $command = $this->getMockBuilder(AbstractCommand::class)-            ->setMethods(['execute', 'logException'])+            ->setMethods(['execute', 'logException', 'timerStart', 'timerStopt']) // timerStopt is a typo             ->getMock();         $command->expects($this->once())->method('execute');         $command->expects($this->never())->method('logException');

Исходный код, результаты прогона тестов

Если прогнать этот тест в PHPUnit версий 8.5 или 9.5, то он успешно пройдёт без каких-то предупреждений:

PHPUnit 9.5.0 by Sebastian Bergmann and contributors..                                                                   1 / 1 (100%)Time: 00:00.233, Memory: 6.00 MBOK (1 test, 2 assertions) 

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

Ещё сложнее отслеживать подобные проблемы при использовании MockBuilder::setMethodsExcept, который переопределяет все методы класса, кроме заданных.

Как эта проблема решена в PHPUnit 10?

Начало решению этой проблемы молчаливого переопределения несуществующих методов было положено в 2019 году в пул-реквесте #3687, который вошёл в релиз PHPUnit 8.

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

В том же PHPUnit 8 setMethods был помечен устаревшим и появилось предупреждение при передаче несуществующих методов в TestCase::createPartialMock().

Если взять предыдущий пример с некорректным названием метода и использовать createPartialMock вместо вызовов getMockBuilder(...)->setMethods(...), то тест пройдёт, но появится предупреждение о будущем изменении этого поведения:

createPartialMock() called with method(s) timerStopt that do not existin PhpUnitMockDemo\AbstractCommand. This will not be allowed in future versions of PHPUnit.

К сожалению, это изменение никак не было отражено в документации там по по-прежнему была описана только работа setMethods(), а всё остальное было скрыто в недрах кода и GitHub.

В PHPUnit 10 проблема setMethods() решена радикально: setMethods и setMethodsExcept окончательно удалены. Это означает, что если вы используете их в своих тестах и хотите перейти на новую версию PHPUnit, то вам нужно убрать все использования этих методов и заменить их на onlyMethods и addMethods.

Как мигрировать частичные моки из старых тестов на PHPUnit 10?

В этой части я дам несколько советов о том, как это можно сделать.

Сразу скажу, что для использования этих советов не обязательно ждать выхода PHPUnit 10 и переходить на него. Всё это можно делать в процессе работы с тестами, которые запускаются в PHPUnit 8 или 9.

Везде, где возможно, замените вызовы MockBuilder::setMethods() на onlyMethods()

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

Используйте MockBuilder::addMethods() для классов с магией

Если метод, который вы хотите переопределить в моке, работает через магический метод __call, то используйте MockBuilder::addMethods().

Если раньше для классов с магией вы использовали TestCase::createPartialMock() и это работало, то в PHPUnit 10 это сломается. Теперь createPartialMock умеет заменять только существующие методы мокаемого класса, и нужно заменить использование createPartialMock на getMockBuilder()->addMethods().

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

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

Приведу пример из библиотеки PhpAmqpLib.

Допустим, вам нужен мок для класса \PhpAmqpLib\Channel\AMQPChannel.

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

В версии 2.5 этот метод был удалён и мокать его уже не нужно.

Если в composer.json зависимость прописана подобным образом: "php-amqplib/php-amqplib": "~2.4", то обе версии буду подходить (но моки для них нужны разные) и нужно будет смотреть, какая из них используется.

Решать это можно несколькими способами:

  • максимально фиксировать версию библиотеки (например, в приведённом примере можно использовать ~2.4.0 и тогда разница будет только в patch-версиях);

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

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

Заключение

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

См. также

Подробнее..

Перевод Что нам стоит автоматизацию построить три паттерна для повышения эффективности процессов

20.05.2021 20:12:40 | Автор: admin

Меня зовут Владислав Романенко, я старший iOS QA Engineer в Badoo и Bumble. Несколько лет назад мы начали активнее использовать автотесты в разработке, но столкнулись с некоторыми трудностями.

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

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

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

Паттерн 1. Просьба о помощи (Ask for Help)

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

Просите о помощи, а не теряйте время, пытаясь всё сделать самостоятельно.

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

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

Внутрениий StackOverflow

Стоит отметить, что в нашей вики есть документация и описания решений для большинства распространённых проблем. Возможно, документировать все возможные ошибки и объяснения в вики не лучший подход, но мы считали, что сохранить их в одном месте будет полезно. Так у нас возникла идея создания локального Stack Overflow. Для этого мы воспользовались open source-решением Scoold. Оно предназначено для использования на первой линии обработки запросов и работает как обычная Stack Overflow, только для сотрудников компании. Когда у кого-нибудь возникает проблема, достаточно зайти в наш локальный Stack Overflow, чтобы найти решение или написать вопрос, на который ответит кто-то из специалистов.

Так выглядит наш локальный StackOverflowТак выглядит наш локальный StackOverflow

Преимущества

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

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

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

Недостатки

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

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

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

Паттерн 2. Введение стандартов (Set Standards)

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

Для решения этой проблемы мы выбрали паттерн SET STANDARDS:

Введите и соблюдайте стандарты для артефактов автоматизации.

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

Что мы сделали

  • Создали локальную вики с актуальными руководствами и документами (например, как настроить окружение, как запускать и поддерживать тесты), которая помогла сократить циклы ревизии кода. Всю документацию мы храним в Confluence, который разделили на несколько частей. Команды регулярно проверяют актуальность своей документации.

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

  • Что касается самого кода тестов и ограничений на уровне кода для основных этапов и методов верификации, мы внедрили стандартные инструменты, включая RuboCop (статический анализатор и инструмент форматирования кода для Ruby), защитные проверки (Guard Cheсks) и pre-commit-хуки.

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

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

Преимущества

  • На сопровождение тестов и исправление ошибок тратится меньше времени и сил. Руководство по написанию кода очень облегчает понимание того, что и как писать.

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

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

Недостатки

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

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

Паттерн 3. Делимся информацией (Share Information)

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

Этот паттерн называется SHARE INFORMATION:

Просите и давайте информацию начальству, разработчикам и другим тестировщикам.

Для нас это способ улучшить автоматизацию тестирования за счёт более широкого набора знаний. Для реализации этого паттерна мы запустили еженедельные короткие презентации QA Lightning Talks. Любой человек может предложить тему и в течение 10-15 минут выступить с ней. Поскольку у встреч чёткий тайминг, это хорошая возможность узнать что-то новое, не потратив на это много времени. Для тех, кто не смог присутствовать, мы сохраняем видеозаписи встреч во внутренней библиотеке.

Доклады могут быть посвящены не только тестированию и его автоматизации. Например, однажды bayandin рассказывал о своём опыте поддержки проекта Homebrew. А я как-то рассказывал о том, что я диванный картограф в Humanitarian OpenStreetMap. Я создаю карты районов, которые плохо картографированы, и потом ими пользуются в работе сотрудники разных организаций вроде Красного Креста или Врачей без границ. Сокращённая версия этого доклада позднее была представлена на сессии Soapbox конференции EuroSTAR 2020.

Преимущества

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

  • Общий объём знаний растёт. А согласно исследованию, обмен знаниями улучшает взаимодействие между департаментами и внутри них.

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

Недостатки

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

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

Бонус: паттерн для обучения разделение на пары (Pair Up)

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

Чтобы достичь цели, мы воспользовались паттерном PAIR UP:

Более опытный сотрудник получает себе в пару менее опытного.

Для QA-инженеров мы запустили внутреннюю программу менторства. Что это такое? QA-инженеры присоединяются к автоматизаторам на короткий промежуток времени (обычно на две недели) и работают над задачами из бэклога этой команды, а не из своего. Помогают им в этом менторы из числа старших инженеров-автоматизаторов. Цель внутреннего менторства заключается в обучении. Мы разработали такой манифест:

  • Сосредоточенность на обучении, а не на строгом соблюдении сроков.

  • Интерактивные обсуждения, а не работа в бункере.

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

Хотя утверждения справа ценны, утверждения слева для нас ценнее.

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

Итоги

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

Какие проблемы мы смогли решить

  • Нежелание обращаться за помощью к коллегам (решили с помощью паттерна Ask for help). Как отметили в своей книге A Journey through Test Automation Patterns: One teams adventures with the Test Automation Серетта Гамба и Дороти Грэхем, не бойтесь просить о помощи: большинству людей на самом деле нравится помогать.

  • Высокая стоимость сопровождения кодовой базы тестирования (решили с помощью паттерна Set Standards). Если вы давно работаете над автоматизацией, то стандарты просто необходимы.

  • Информационный бункер (решили с помощью паттерна Share Information). Общайтесь с другими людьми: в процессе обсуждения часто рождаются новые идеи.

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

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

Подробнее..

Восстановление данных в современной инфраструктуре как один админ бэкапы настраивал

30.09.2020 20:16:11 | Автор: admin

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

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

Я не буду говорить о себе, а расскажу чужую историю. Все имена в ней выдуманные, а совпадения случайны. Её главный герой человек по имени Савелий. И, по случайному совпадению, он админ, как и я.

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

Из этой статьи вы узнаете об эволюции взглядов Савелия: как с усложнением инфраструктуры менялся его подход к бэкапам и к каким выводам он пришёл. Я рассказывал об этом на прошлогоднем HighLoad++, а сегодня делюсь текстом по мотивам доклада.

Глава 1, в которой Савелий полагается на систему управления конфигурациями

Наш Савелий жил-поживал и продолжал изучать материалы по Linux даже на работе. И однажды он узнал, что существуют системы управления конфигурациями: Ansible, Puppet, Chef и им подобные. Они избавляют от ручной работы при подготовке сервера ко вводу в эксплуатацию.

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

Со временем инфраструктура проекта усложнилась.

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

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

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

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

Так Савелий отказался от бэкапов.

Глава 2, в которой появляется карта расположения сервисов

Савелий работал в компании не один, с ним было много программистов. Один из них Аристофан был его добрым другом.

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

Савелий хороший админ. Когда он настраивал репликацию, то следил за отставанием от мастера. Точнее он его не допускал.

Поэтому, как только дроп приехал на мастер, он разъехался по всем репликам. Данных больше не осталось.

Что тут сказать: такое бывает не только в сказках.

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

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

  1. Он должен находиться в стороне и не должен быть подвержен изменению.

  2. Он обязательно должен восстанавливаться.

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

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

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

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

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

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

  1. Восстановить весь бэкап сервера и потом взять нужные данные. Минусы:

    1. увеличение нагрузки на сеть;

    2. увеличение нагрузки на устройство хранения;

    3. увеличение времени на выборку нужных данных из общей кучи.

  2. Выбрать вручную только те данные, которые нужно восстановить. Минусы:

    1. увеличение времени на выборку нужных данных из общей кучи;

    2. высокая вероятность того, что будут выбраны не все данные и процедуру придётся повторить (опять-таки потраченное время).

Как видно, оба варианта имеют существенные недостатки.

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

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

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

Вывод, к которому пришёл Савелий, прост: современным бэкапам современную оркестрацию.

Глава 3, в которой Савелий понимает, что серверы созданы не только для того, чтобы их бэкапить (а голова не только для того, чтобы в неё есть)

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

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

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

Минусы:

  • формирование инкрементального бэкапа на клиенте в некоторых случаях более затратно с точки зрения ресурсов, так как нужно вычислять разницу было-стало;

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

Плюсы:

  • экономия ресурсов сети, так как по ней передаётся не весь объём данных, а лишь те, которые изменились;

  • экономия места в хранилище.

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

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

В общем, Савелий молодец.

Глава 4, в которой появляется devel

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

Проблема была в том, что devel это территория разработчика, вотчина Аристофана. Но Савелий понимал, что рано или поздно Аристофан придёт к нему с вопросом, а есть ли у него бэкап данных за какой-то день.

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

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

Как Савелий организовал процесс бэкапа виртуальных машин:

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

  2. Если на виртуальной машине есть снапшот, сделанный вручную Аристофаном, то:

    • не удалять снапшот;

    • не выполнять бэкап;

    • сообщать об этом Аристофану и уточнять, нужен ли ему ещё снапшот, и действовать в зависимости от его ответа.

  3. По окончании бэкапа удалять временный снапшот.

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

Почему Савелий не бэкапил виртуалки с базами данных? На самом деле всё просто: теперь он всегда бэкапит все инстансы с базами данных. По умолчанию. Потому что он помнит прекрасную историю про дроп. А на машинах с базами данных ничего ценного, кроме самих данных в базе (для всего остального есть Puppet!), нет.

Глава 5, в которой появляются отличия бэкапа базы данных от бэкапа сервиса

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

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

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

Поэтому Савелий разрешил Аристофану:

  • включать и выключать бэкап;

  • менять параметры утилит бэкапа;

  • менять расписание бэкапа.

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

  • устройство хранения может стать узким местом, так как сеть не резиновая (источников много, а устройство хранения -- одно)

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

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

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

Глава 6, в которой Савелий не может жить без уведомлений

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

Но всё оказалось не так просто.

Савелий решил проверить по метрикам, как ведёт себя сервис. Когда приходит бэкап, не начинает ли он, например, отвечать медленнее?

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

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

Последствия этого:

  • тратится место в хранилище;

  • потребляются ресурсы сети;

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

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

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

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

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

Как об этом узнать?

Глава 7, в которой необходимо проверять бэкап

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

Шаг 1

Проверяется статус: бэкап завершился со статусом ОК или произошла ошибка. Если ошибка, то разбираемся почему. Если ОК, переходим к следующему шагу.

Шаг 2

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

Шаг 3

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

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

Шаг 4

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

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

Глава 8, в которой появляется реализация бэкапов с помощью Bareos

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

На момент выбора решений было не то чтобы очень много:

  • коммерческие системы резервного копирования (чаще всего на них не хотят тратиться);

  • AMANDA;

  • Bacula;

  • возможно, было что-то ещё, но про это уже никто не помнит.

Савелий решил действовать по хардкору: начал использовать Bacula, а позже Bareos (форк Bacula). Он никогда не искал лёгких путей.

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

Совет 1

Используйте virtual changer (VC). Это не какой-то конкретный инструмент, а целый класс утилит.

Для примера представьте набор сервисов, который мы бэкапим в два пула. Один пул называется SOMETHING-full, второй SOMETHING-incremental. В этом случае можно бэкапить данные в два пула и не переживать о том, что пачка заданий, ожидающих полного бэкапа, встанет в очередь, пока не закончатся инкрементальные бэкапы. Это довольно часто бывает нужно.

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

Совет 2

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

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

Совет 3

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

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

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

Совет 4

В Bareos много собственных параметров и крутилок. Крутить можно всё что угодно. Основное, на что Савелий обращает внимание:

  • параметры, которые относятся к Maximum Volume Jobs и Concurrent Jobs, как раз для настройки количества заданий на кассете;

  • параметры, которые касаются block size и file size, влияют на скорость записи бэкапа, если их можно откуда-то линейно быстро читать и быстро писать;

  • параметры, которые дадут чуть больше гарантий того, что будет корректная цепочка бэкапов Max Full Interval;

  • параметры, которые остановят выполнение задачи, если нет подходящей кассеты Max Wait Time;

  • параметры Spool Attributes и Spool Data относятся больше к ленточному бэкапу, чем к файловому, но Савелий считает, что имеет смысл их тоже покрутить.

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

Совет 5

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

  • Full: два последних успешных бэкапа (делается один раз в неделю);

  • Incremental: всё до самого старого Full (делается один раз в сутки).

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

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

Совет 6

Если необходимо забэкапить кусок файловой системы с кучей директорий, миллионами мелких файлов и т. п., то простой вариант Bareos-бэкапа не подходит. Файловый демон будет тратить всё время на процесс построения дерева того, что необходимо забэкапить, а не заниматься копированием бэкапа в хранилище.

Нужно искать другие варианты решения, например:

  • бэкапиться снапшотом;

  • делать бэкап через pipe.

Или рассмотреть вариант не бэкапить Bareos по классической схеме File daemon -> Storage daemon, а придумать другую систему, которая будет заливать необходимые файлы в хранилище. Если выбран такой вариант, то его стоит обозначить как задачу с пустым fileset в Bareos. Нужно это для того, чтобы сведения обо всех бэкапах находились в одном месте.

Совет 7

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

Совет 8

Проверяйте базу Bareos: bareos-dbcheck в помощь. Обратите внимание на DB backend: MySQL, PostgreSQL. Как минимум стоит принять во внимание мнение разработчиков по данному вопросу, чтобы потом не удивляться.

Совет 9

Собирайте логи и метрики: в Elasticsearch, InfluxDB, Prometheus на ваш вкус. Они помогут ответить на кучу вопросов, а при построении бэкапа они возникнут абсолютно точно.

Приблизительно такие графики можно строить, собирая различные метрики:

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

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

Также будут видны попытки что-то ресторить.

Там, где график идёт вверх и скорость увеличивается, меняли параметр read ahead. В данном случае это изменение стандартного read ahead для блочного устройства.

Можно увидеть, какое количество данных поминутно, по часам сливается в бэкап-систему.

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

Начало работ:

Окончание и результаты работ:

Работы, завершённые с ошибками:

Выводы

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

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

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

Не надо бояться экспериментировать. Хотя Bareos по своей сути довольно консервативен, всегда можно прикрутить что-то сбоку, чтобы сделать возможным и эффективным его использование в современных инфраструктурах. Рано или поздно всё точно получится. Савелий смог и вы сможете!

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

Подробнее..

Кто, где, когда система компонентов для разделения зон ответственности команды

09.06.2021 18:07:37 | Автор: admin

Меня зовут Евгений Тупиков, я ведущий PHP-разработчик в Badoo и Bumble. У нас в команде более 200 бэкенд-разработчиков, которые работают над сотнями модулей и отдельных сервисов в наших приложениях. Но поначалу всё было не так масштабно. В 2006 году это был один проект, над которым работала небольшая команда. Каждый разработчик хорошо понимал, как всё устроено: легко ориентировался в коде, знал, какие есть сервисы и как они взаимодействуют между собой. Однако по мере роста проекта всё больше времени занимал поиск хранителей знаний тех, кто отвечает за ту или иную функциональность и к кому можно обратиться с вопросом или предложением.

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

Предыстория

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

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

  • @team команда, ответственная за данную часть системы;

  • @maintainer человек, разрабатывающий данную функциональность (таких сотрудников может быть несколько).

/** * @team Team name <team@example.com> * @maintainer John Smith <john.smith@example.com> * @maintainer .... */

Такой подход очень просто внедрить и использовать. Например, можно настроить шаблон в PhpStorm и нужные теги будут автоматически проставляться при создании нового файла. Мы ещё сделали отдельный Git hook, следящий за тем, чтобы у всех файлов были проставлены нужные теги в требуемом формате.

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

Нам же хотелось иметь возможность обновлять список ответственных в одном месте. И желательно, чтобы все остальные системы автоматически подхватывали изменения.

Так мы пришли к компонентному подходу.

Что такое компонент

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

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

При переходе на компонентный подход мы сформировали ряд правил и ограничений:

  • структура компонентов должна быть линейной;

  • каждая команда должна иметь свой набор компонентов (один и тот же компонент не может относиться к нескольким командам);

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

  • только менеджер или тимлид команды может добавлять и удалять компоненты;

  • у каждого компонента должен быть уникальный идентификатор (alias).

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

Пример страницы компонента в интранете Пример страницы компонента в интранете

Мы видим, что у компонента есть:

  • уникальный идентификатор;

  • email;

  • название команды, которая отвечает за данный компонент;

  • название проекта, к которому относится компонент, в Jira;

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

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

Как мы используем компоненты в коде

Докблок файла

/** * @component component_alias */

Мы доработали Git hook, проверяющий докблок. Он следит за тем, чтобы файлы, в которые были внесены изменения, содержали тег @component и чтобы указанный компонент существовал.

remote: ERROR in SomeClass.php:        remote: * Unknown @component: UnknownComponent. You have to create component before using it in the code   

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

Сервис для работы с компонентами в коде

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

$componentManager = new \Components\ComponentManager();$component = $componentManager->getComponent('component_alias');$recipients = [];foreach ($component->getMaintainers() as maintainer) {    $recipients[] = $maintainer->getEmail();}

или найти дежурного по компоненту:

$componentManager = new \Components\ComponentManager();$component = $componentManager->getComponent('component_alias');foreach ($Component->getMaintainers() as $maintainer) {    if ($maintainer->isDuty()) {        return $maintainer;    }}

Интеграция с PhpStorm

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

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

Дежурный по компоненту

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

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

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

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

Интеграция с внутренними системами

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

Система сбора и анализа PHP-ошибок

Исторически для сбора и анализа PHP-ошибок мы используем самописную систему, которая по функциональности похожа на популярные Sentry и Splunk, но адаптирована к нашим внутренним процессам. В неё первую мы добавили поддержку компонентов.

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

Эту информацию можно использовать:

  • для поиска ошибок по определённому компоненту;

  • для построения отчётов и графиков в разбивке по компонентам.

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

Реестр баз данных

Бэкенд наших приложений Badoo и Bumble состоит из сотен различных модулей, систем и сервисов. Большинство из них для хранения данных использует MySQL.

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

  1. Найти в коде, на каком хосте живёт база.

  2. Подключиться к хосту через любой удобный инструмент (консольная утилита, phpMyAdmin, Sequel Pro, IDE и т. д.).

  3. Найти нужную базу и таблицу.

  4. Изучить информацию о таблице.

А если нужно узнать размер таблицы на продакшене?

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

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

Чтобы упростить жизнь разработчикам, мы создали систему под названием DBRegistry. Она хранит в себе информацию для всех баз данных, доступную через INFORMATION_SCHEMA.

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

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

Заключение

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

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

На этом всё. Спасибо за внимание!

Подробнее..

Релиз мобильных приложений одной кнопкой

02.07.2020 18:23:23 | Автор: admin


Всем привет! Меня зовут Михаил Булгаков (нет, не родственник), я работаю релиз-инженером в Badoo. Пять лет назад я занялся автоматизацией релизов iOS-приложений, о чём подробно рассказывал в этой статье. А после взялся и за Android-приложения.

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

За подробностями добро пожаловать под кат!

Что стоит за мобильным релизом


Выпуск приложения в Badoo состоит из трёх этапов:

  1. Разработка.
  2. Подготовка витрины в магазине приложений: тексты, картинки всё то, что видит пользователь в App Store или Google Play.
  3. Релиз, которым занимается команда релиз-инжиниринга.

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

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

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

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

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



Первые шаги на пути к автоматизации: загрузка метаданных


Как это работало в самом начале: для каждого релиза создавалась таблица в Google Sheets, в которую продакт-менеджер заливал выверенный мастер-текст на английском, после чего переводчики адаптировали его под конкретную страну, диалект и аудиторию, а затем релиз-инженер переносил всю информацию из этой таблицы в App Store или Google Play.

Первый шаг к автоматизации, который мы сделали, интегрировали перевод текстов в наш общий процесс переводов. Останавливаться на этом не буду это отдельная большая система, про которую можно прочитать в нашей недавней статье. Основной смысл в том, что переводчики не тратят время на таблички и работают с интерфейсом для удобной загрузки руками (читай: ctrl+c ctrl+v) переведённых вариантов в стор. Кроме того, присутствуют задатки версионирования и фундамент для Infrastructure-as-Code.

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

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


Наша реальность по состоянию на 2015 год

В среднем на релиз одного приложения при наличии актуальной версии скриншотов уходило около полутора-двух часов работы релиз-инженера в случае с iOS и около получаса в случае с Android. Разница обусловлена тем, что iOS-приложения должны пройти так называемый Processing, который занимает некоторое время (отправить приложение на Review до успешного завершения Processing невозможно). Кроме того, App Store сам по себе по большинству операций в тот момент работал гораздо медленнее, чем Google Play.

Стало очевидно, что нам нужен дополнительный инструмент для доставки приложений в сторы. И как раз в тот момент на open-source-рынке начал набирать популярность продукт под названием Fastlane. Несмотря на то, что он тогда ещё был сыроватый, он уже мог решить огромный пласт наших проблем

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

Коротко о Fastlane


Сегодня Fastlane это продукт, который способен практически полностью автоматизировать все действия от момента окончания разработки до релиза приложения в App Store и Google Play. И речь не только о загрузке текстов, скриншотов и самого приложения здесь и управление сертификатами, и бета-тестирование, и подписывание кода, и многое другое.

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

Доверие внушает и то, что основателя и главного разработчика Fastlane взяли на работу в Google: теперь компонент поддерживает не только комьюнити, но и Сам.

Со временем мы внедрили большинство предоставляемых Fastlane возможностей в системы сборки, подписания, заливки и т. д. наших приложений. И несказанно этому рады. Зачем изобретать колесо, да ещё и поддерживать его правильную форму, когда можно один раз написать унифицированный сценарий, который будет сам крутиться в CI/CD-системе?

Автоматизация iOS-релизов


По причине того, что Google Play более дружелюбен к разработчикам, на релиз Android-приложения уходило очень мало времени: без обновления текстов, видео и скриншотов пара минут. Отсюда и отсутствие необходимости в автоматизации. А вот с App Store проблема была очень даже осязаемой: слишком много времени уходило на отправку приложений на Review. Поэтому было решено начать автоматизацию именно с iOS.

Подобие своей системы автоматизации взаимодействия с App Store мы обдумывали (и даже сделали прототипы), но у нас не было ресурсов на допиливание и актуализацию. Также не было никакого мало-мальски адекватного API от Apple. Ну и последний гвоздь в гроб нашего кастомного решения вбили регулярные обновления App Store и его механизмов. В общем, мы решили попробовать Fastlane тогда ещё версии 2015 года.

Первым делом был написан механизм выгрузки переведённых текстов для приложений в нужную структуру как компонент нашей общей внутренней системы AIDA (Automated Interactive Deploy Assistant). Эта система своеобразный хаб, связующее звено между всеми системами, технологиями и компонентами, используемыми в Badoo. Работает она на самописной системе очередей, реализованной на Golang и MySQL. Поддерживает и совершенствует её в основном отдел Release Engineering. Подробнее о ней мы рассказывали в статье ещё в 2013 году, с тех пор многое изменилось. Обещаем рассказать про неё снова AIDA классная!

На следующем этапе выгруженные тексты скармливались Fastlane, который загружал их в App Store. После этого приходилось заходить в интерфейс App Store, вручную выбирать нужную загруженную версию и отправлять приложение на проверку, если к тому времени уже был завершён Processing.

Это сократило время подготовки релиза с пары часов до примерно 30 минут, из которых только полторы минуты надо было что-то делать руками! Остальное время ждать. Ждать окончания Processing. Механизм стал прорывом на тот момент как раз потому, что почти полностью избавил нас от ручной работы при подготовке AppStore к релизу. Под скрипт мы сделали репозиторий, к которому дали доступ людям, имеющим непосредственное отношение к релизам (проджект-менеджерам, релиз-инженерам).

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

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

  1. Нужно было идти в TeamCity за свежей сборкой, скачивать оттуда IPA-файл, загружать его в App Store через Application Manager.
  2. Потом идти в интерфейс с переводами в AIDA, смотреть, готовы ли все переводы, запускать скрипт, убеждаться, что он правильно сработал (всё-таки на тот момент Fastlane был ещё сыроват).
  3. После этого залезать в App Store и обновлять страницу с версией до того момента, пока не завершится Processing.
  4. И только после этого отправлять приложение на Review.

И так с каждым приложением. Напомню, на тот момент у нас их было восемь.

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

TestFlight это приложение сторонних разработчиков, когда-то купленное Apple для тестирования готового приложения внешними тестировщиками практически в продакшен-окружении, то есть с push-оповещениями и вот этим всем.


AIDA молодец, будь как AIDA!

Всё это привело к сокращению времени с получаса до полутора минут на всё про всё: IPA-файл успевал пройти Processing ещё до того момента, когда команда QA-инженеров давала отмашку на запуск релиза. Тем не менее нам всё равно приходилось идти в App Store, выбирать нужную версию и отправлять её на Review.

Плюс, был нарисован простенький интерфейс: мы же все любим клац-клац.


Вот так, вкладка за вкладкой, Ctrl+C Ctrl+V...

Автоматизация Android-релизов


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

  1. Заходить в консоль Google Play, чтобы убедиться, что предыдущая версия раскатана на 100% пользователей или заморожена.
  2. Создавать новую версию релиза с обновлёнными текстами и скриншотами (при наличии).
  3. Загружать APK-файл (Android Package), загружать Mapping-файл.
  4. Идти в HockeyApp (использовался в то время для логирования крашей), загружать туда APK-файл и Mapping-файл.
  5. Идти в чат и отписываться о статусе релиза.

И так с каждым приложением.

Да, у Google Play есть свой API. Но зачем делать обёртку, следить за изменениями в протоколе, поддерживать её и плодить сущности без необходимости, если мы уже используем Fastlane для iOS-релизов? К тому же он комфортно существует на нашем сервере, варится в своём соку и вообще обновляется. А к тому времени он ещё и научился адекватно релизить Android-приложения. Звёзды сошлись!

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

Заливкой APK- и Mapping-файлов в Google Play занимался Fastlane. Надо сказать, что по проторенной тропе идти гораздо проще: реализовано это было достаточно быстро с минимальным количеством усилий.

На определённом этапе реализации автоматизации случился переход с APK-архивов на AAB (Android App Bundle). Опять же, нам повезло, что по горячим следам довольно быстро получилось всё поправить, но и развлечений добавилось в связи с этим переходом. Например, подгадил HockeyApp, который не умел использовать AAB-архивы в связи с подготовкой к самовыпиливанию. Так что для того чтобы комфортно продолжать его использовать, нужно было после сборки AAB разобрать собранный архив, доставать оттуда Mapping-файл, который полетит в HockeyApp, а из AAB нужно было отдельно собрать APK-файл и только потом загружать его в тот же HockeyApp. Звучит весело. При этом сам Google Play отлично раскладывает AAB, достаёт оттуда Mapping-файл и вставляет его куда нужно. Так что мы избавились от одного шага и добавили несколько, но от этого было никуда не деться.

Был написан интерфейс (опять же, по аналогии с iOS), который умел загружать новую версию, проверять релиз вдоль и поперёк, управлять текущим активным релизом (например, повышать rollout percentage). В таком виде мы отдали его ответственным за релизы членам команды Android QA, стали собирать фидбэк, исправлять недочёты, допиливать логику (и что там ещё бывает после релиза 1.0?).

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

Унификация флоу мобильных релизов


К моменту автоматизации Android-релизов Fastlane наконец-то научился отправлять версии iOS-приложений на ревью. А мы немного усовершенствовали систему проверки версий в AIDA.

Пришла пора отдать iOS-релизы на откуп команде QA-инженеров. Для этого мы решили нарисовать красивую формочку, которая бы полностью покрывала потребности, возникающие в процессе релиза iOS-приложений: давала бы возможность выбирать нужный билд в TeamCity по предопределённым параметрам, выбирать вариант загружаемых текстов, обновлять или нет опциональные поля (например, Promotional Text).

Сказано сделано. Формочка получилась очень симпатичная и полностью удовлетворяет все запросы. Более того, с её внедрением появилась возможность выбирать сразу все необходимые приложения со всеми требуемыми параметрами, так что и взаимодействие с интерфейсом свелось к минимуму. AIDA по команде присылает ссылку на build log, по которому можно отслеживать возникающие ошибки, убеждаться, что всё прошло хорошо, получать какую-то debug-информацию вроде версии загружаемого IPA-файла, версии релиза и т. д. Вот так красиво iOS-релизы и были переданы команде iOS QA.


Ну симпатично же?

Идея с формочкой понравилась нам настолько, что мы решили сделать аналогичную и для Android-релизов. Принимая во внимание то, что у нас есть приложение, полностью написанное на React Native, и что команда QA-инженеров этого приложения отвечает как за iOS-, так и за Android-релизы.

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

Вывод


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

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

Здорово, что сейчас есть возможность автоматизации большинства рутинных действий, что инженеры могут писать код когда хотят, а любой код могут поддерживать и сторонние разработчики, не тратя драгоценное время на копание в сложных интерфейсах. Особенно трудно бывает разобраться в моментах типа Где надо поставить галочку?, когда на часах полночь, в офисе никого нет, а hotfix надо залить здесь и сейчас.

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

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

Модуляризация iOS-приложения Badoo борьба с последствиями

21.01.2021 20:07:17 | Автор: admin

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

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

В этой статье я расскажу:

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

  • как спасли CI от чрезмерной нагрузки;

  • что делать, если с каждым новым модулем приложение запускается всё медленнее;

  • мониторинг каких показателей стоит предусмотреть и почему это необходимо.

Сложный граф зависимостей

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

Так выглядел граф зависимостей Badoo к моменту, когда у нас было около 50 модулей:

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

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

  2. Сложности визуализации порождают сложности отладки. Найти фундаментальные проблемы в сложном графе зависимостей крайне непросто.

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

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

Основные характеристики утилиты:

  • это консольное Swift-приложение;

  • работает с xcodeproj-файлами с помощью фреймворка XcodeProj;

  • понимает сторонние зависимости (мы не очень активно и охотно принимаем их в проект, но некоторые всё же используем; загружаются и собираются они через Carthage);

  • включена в процессы непрерывной интеграции;

  • знает о требованиях к нашему графу зависимостей и работает в соответствии с ними.

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

  • статическая или динамическая линковка;

  • инструменты поддержки сторонних зависимостей (Carthage, CocoaPods, Swift Package Manager);

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

  • и другие.

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

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

  1. Doctor. Команда проверяет, все ли зависимости корректно связаны и встроены в приложение. После исполнения мы либо получаем список ошибок в графе (например, отсутствие чего-либо в фазе Link with binaries или Embedded frameworks), либо скрипт говорит, что всё хорошо и можно двигаться дальше.

  2. Fix. Развитие команды doctor. Эта команда в автоматическом режиме исправляет проблемы, найденные командой doctor.

  3. Add. Добавляет зависимость между модулями. Пока у вас простое небольшое приложение, добавление зависимости между двумя фреймворками кажется простой задачей. Но когда граф сложный и многоуровневый, а вы работаете с включёнными явными зависимостями, добавление нужных зависимостей становится задачей, которую вы не захотите из раза в раз делать руками. Благодаря команде add разработчики могут просто указать два названия фреймворков (зависимый и зависящий) и все фазы сборки заполнятся необходимыми зависимостями в соответствии с графом.

Впоследствии скрипт создания нового модуля по шаблону также стал частью утилиты deps. Что мы получили в итоге?

  1. Автоматизированную поддержку графа. Мы находим ошибки прямо в pre-commit hook, сохраняя стабильность и правильность графа и давая возможность разработчику в автоматическом режиме эти ошибки исправлять.

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

Непрерывная интеграция не справлялась

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

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

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

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

Очевидным решением было перестать собирать и проверять всё и всегда. Нужно было, чтобы CI проверял только то, что нужно проверить. Что не сработало:

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

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

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

    1. Те, кто на всякий случай проверяет всё.

    2. Те, кто уверен, что не мог ничего сломать.

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

В итоге мы вернулись к идее автоматизации вычисления изменений, но немного с другой стороны. У нас была утилита deps, которая знала про граф зависимостей и файлы проекта. А Git позволяла получить список изменённых файлов. Мы расширили deps командой affected, с помощью которой можно было получить список затронутых модулей, исходя из изменений, отражаемых системой контроля версий. Ещё более важно то, что она учитывала зависимости между модулями (если от затронутого модуля зависят другие модули, их тоже необходимо проверить, чтобы, например, в случае изменения интерфейса более низкого модуля верхний не перестал собираться).

Пример: изменения в блоках Регистрация и Аналитика на нашей схеме указывают на необходимость проверить также модули Чат, Sign In with Apple, Видеостриминг и само приложение.

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

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

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

  2. Продолжительность CI-проверок перестала линейно зависеть от количества модулей.

  3. Разработчик понимает, что его изменения могут затронуть и где нужно быть осторожным.

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

Ждём завершения Е2Е-тестов

Для приложения Badoo у нас есть более 2000 сквозных (end-to-end) тестов, которые его запускают и проходят по сценариям использования для проверки ожидаемых результатов. Если запустить все эти тесты на одной машине, то прогон всех сценариев займёт около 60 часов. Поэтому на CI все тесты запускаются параллельно насколько это позволяет количество свободных агентов.

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

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

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

Подобное решение едва ли можно назвать оптимальным, но всё же преимущества от его внедрения были ощутимы:

  1. Нагрузка на CI существенно снизилась. Чтобы не быть голословным, привожу график времени, которое задача на прогон сквозных тестов провела в очереди:

  2. Уменьшился шум инфраструктурных проблем (меньше запусков тестов меньше падений из-за зависших агентов, сломавшихся симуляторов, недостатка места и т. д.).

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

Медленный запуск приложения

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

В середине графика видно резкое снижение времени. Причина переход на статическую линковку. С чем это связано? Инструмент динамической загрузки модулей dyld от Apple выполняет трудоёмкие задачи не совсем оптимальными способами, время исполнения которых линейно зависит от количества модулей. В этом и была основная причина замедления запуска нашего приложения: мы добавляли новые модули dyld работал всё медленнее (на графике синяя линия отражает количество добавляемых модулей).

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

Стоит сказать, что статическая линковка несёт с собой и ряд ограничений:

  1. Одно из наиболее неудобных невозможность хранения ресурсов в статических модулях. Эта проблема частично решается в относительно новых XCFrameworks, но технологию пока что нельзя считать проверенной временем и полноценно готовой к Enterprise. Мы решили эту проблему созданием рядом с модулем отдельных бандлов, которые уже упаковываются в готовое приложение или тестовый бандл для прогона тестов. Обеспечением целостности графа при работе с ресурсами также занимается утилита deps, которая получила несколько новых правил.

  2. После перехода на статическую линковку нужно хорошенько протестировать приложение на предмет рантайм-падений. Чтобы исправить многие из них, вам просто придётся использовать не самые оптимальные параметры оптимизаций. Например, почти для всех Objective-C-модулей придётся включить флаг -all-load. Отмечу ещё раз, что решение всех этих проблем с вынесенными xcconfigами (про xcconfig в первой части) не было таким мучительным, каким могло бы быть.

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

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

  • размер приложения уменьшился примерно на 30%;

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

Цифры подскажут, куда двигаться дальше

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

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

  • уменьшение нагрузки на CI за счёт фильтрации проверяемых модулей и умных тестов: не попадайтесь в ловушку прямой зависимости продолжительности CI-проверок от количества модулей;

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

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

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

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

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

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

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

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

Например, видно, что iMac Pro 5K 2017 года выпуска не лучшее железо для сборки Badoo, в то время как MacBook Pro 15 2018 года ещё вполне неплох.

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

Измеряем время сборки

Чтобы получать данные о продолжительности сборки на компьютерах разработчиков, мы создали специальное macOS-приложение Zuck. Оно сидит в статус-баре и следит за всеми xcactivitylog-файлами в DerivedData. xcactivitylog файлы, которые содержат ту же информацию, которую мы видим в билд-логах Xcode в непростом для парсинга формате от Apple. По ним можно понять, когда началась и закончилась сборка отдельного модуля и в какой последовательности они собирались.

В утилите есть white- и black-листы, так что мы отслеживаем только рабочие проекты. Если разработчик скачал демо-проект какой-то библиотеки с GitHub, мы не будем отправлять данные о её сборке куда-либо.

Информацию о сборке наших проектов мы передаем во внутреннюю систему аналитики, где имеется широкий инструментарий для построения графиков и анализа данных. Например, у нас есть инструмент Anomaly Detection, который предсказывает аномалии в виде слишком сильных отклонений от прогнозируемых значений. Если время сборки резко изменяется по сравнению с предыдущим днём, Core-команда получает уведомление и начинает разбираться, где и что пошло не так.

P. S. Мы дорабатываем Zuck, чтобы выпустить его в open source.

В целом измерение локального времени сборки даёт важные результаты:

  • мы измеряем влияние изменений на разработчиков;

  • имеем возможность сравнивать чистые и инкрементальные сборки;

  • знаем, что надо улучшить;

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

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

  1. Время запуска приложения. Последние версии Xcode предоставляют эту информацию в разделе Organizer. Метрика быстро укажет на появившиеся проблемы.

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

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

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

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

Заключение

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

  1. Всего сейчас у нас работают 43 iOS-разработчика.

  2. Четыре из них в Core-команде.

  3. Сейчас у нас два основных приложения и N экспериментальных.

  4. Около 2 миллионов строк кода.

  5. Около 78% из них находятся в модулях.

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

В двух статьях я рекламировал вам модуляризацию, но, конечно, у неё есть свои минусы:

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

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

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

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

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

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

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

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

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

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

Подробнее..

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

28.01.2021 18:09:16 | Автор: admin

В первой части статьи мы поговорили о том, что такое ANR (Application Not Responding), и рассмотрели несколько способов сбора информации об этих ошибках. А сегодня я расскажу о проблемах, которые мы обнаружили в нашем приложении, о том, как мы их исправляли и что из этого в итоге получилось.

Время запуска приложения

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

Почти каждая группа содержала заголовок Broadcast of Intent { act=com.google.android.c2dm.intent.RECEIVE } , но на первый взгляд все они были разные, так как стек-трейс основного потока каждой группы указывал на разные места в приложении. Мы попытались найти что-то общее между этими группами и в итоге, воспользовавшись локальными отчётами, которые мы получили с помощью скрапера, выяснили, что около 60% ANR-ошибок возникало во время исполнения метода Application.onCreate.

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

Мы добавили искусственную задержку в Application.onCreate и поэкспериментировали с несколькими сценариями на последних версиях Android. Вот что мы выяснили:

  • когда пользователь вручную запускает приложение через лаунчер, то ANR-диалог не появляется и приложение стартует в нормальном режиме, даже если мы добавляем задержку на несколько минут;

  • когда процесс приложения запускается через BroadcastReceiver, то ошибка ANR возникает при задержке от 10 секунд. Это не очень жёсткое ограничение, но в любом случае оно гораздо строже, чем в предыдущем сценарии.

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

Поэтому мы пришли к выводу, что основной причиной возникновения наших ANR-ошибок, вероятно, является длительное время выполнения метода Application.onCreate. И судя по информации из консоли Google Play, в большинстве случаев это происходило во время исполнения Firebase Cloud Messaging BroadcastReceiver: иногда мы выходили за 10-секундный лимит, что приводило к фоновым ANR-ошибкам.

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

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

Длительность фонового холодного запуска приложенияДлительность фонового холодного запуска приложенияДлительность холодного запуска приложенияДлительность холодного запуска приложения

Можно заметить, что обычный запуск занимал в среднем 2,2 секунды, а фоновый 5 секунд. Около 3% фоновых запусков длилось дольше 10 секунд. Это говорит о том, что вполне вероятно, в этих случаях могли возникать ANR-ошибки. Чтобы подтвердить или опровергнуть эту гипотезу, мы решили попытаться ускорить запуск приложения.

Ускорение запуска приложения

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

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

  • startMethodTracingSampling

  • startMethodTracing

  • stopMethodTracing

Начать трассировку можно в статическом инициализаторе класса Application (или в конструкторе), а завершить её либо в конце метода Application.onCreate, либо в onResume первого Activity. Например, класс Application может выглядеть как-то так:

class Application {    constructor() {        Debug.startMethodTracingSampling("/sdcard/startup.trace", 64 * 1024 * 1024, 50)    }    override fun onCreate() {        //         Debug.stopMethodTracing()    }} 

После этого файл с трассировкой можно открыть в Android Studio:

http://personeltest.ru/aways/habrastorage.org/webt/sv/ze/jv/svzejva_ajvhkc_nhukxcjkhfiq.pnghttps://habrastorage.org/webt/sv/ze/jv/svzejva_ajvhkc_nhukxcjkhfiq.png

При анализе результатов важно помнить о паре важных моментов.

  • Разница между sample-based- и method-based-трассировкой. При использовании второго подхода можно столкнуться с достаточно большими искажениями в тех случаях, когда есть большое количество вызовов довольно быстрых методов. Sample-based-трассировка работает точнее, но при её использовании могут теряться некоторые вызовы методов. В обоих случаях нет смысла использовать абсолютные значения времени, но sample-based-трассировка даёт более подходящие результаты для относительного анализа.

  • Разница между debug- и release-сборкой приложения. При профайлинге запуска приложения имеет смысл выполнять тестирование в окружении, которое как можно ближе к продакшен-версии. Для этого желательно отключить все отладочные инструменты (такие как Leak Canary) либо собрать релизную сборку приложения.

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

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

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

Фоновая инициализация и инициализация с задержкой

Если при запуске приложения вам нужно инициализировать какие-либо структуры или объекты, подумайте, возможно ли это сделать немного позже и не на основном потоке. Если же код обязательно должен быть выполнен на основном потоке, то может помочь обычная задержка в несколько секунд (Handler.postDelayed). Распределение задач по времени также снижает вероятность блокирования главного потока.

Сторонние ContentProviderы

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

Если для каких-то библиотек вам не нужна автоматическая инициализация ContentProvider, вы можете отключить её, добавив в AndroidManifest.xml такую запись:

<provider    android:name="some.ContentProvider"    tools:node="remove" /> 

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

Когда мы выпустили обновление с нашими оптимизациями, 95-й перцентиль холодного запуска приложения уменьшился примерно на 50% (с ~10 секунд до ~5 секунд):

А что насчёт количества ANR-ошибок? Вот что мы увидели в консоли Google Play:

Источник: сериал ОфисИсточник: сериал Офис

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

SharedPreferences и метод apply()

При анализе стек-трейсов в Google Play и изучении внутренних отчётов мы заметили вот такие странные группы:

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

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

Мы проверили свою кодовую базу и не обнаружили использования метода commit, который выполняет запись на диск в блокирующем режиме. Все операции записи в SharedPreferences были сделаны с применением метода apply. Почему же тогда мы столкнулись с этой проблемой, если мы используем неблокирующий API?

Найти причину поможет внимательное изучение исходного кода Android. Стандартная реализация SharedPreferences находится в SharedPreferencesImpl.java. При внесении изменений в SharedPreferences записи о них сохраняются во временную HashMap, которая затем при вызове commit или apply применяется к кешу, хранящемуся в оперативной памяти. Во время обновления кеша также вычисляется, какие ключи были изменены, чтобы потом, когда нам нужно будет записать эти изменения на диск, мы не делали лишнюю работу. Информация об изменениях хранится в MemoryCommitResult.

Если посмотреть на тело метода apply(), то мы увидим, что он просто отправляет лямбду записи на диск в метод enqueueDiskWrite(), что выглядит довольно безобидно и намекает на то, что эта лямбда будет выполнена на фоновом потоке. Вот упрощённая реализация метода apply():

@Overridepublic void apply() {    final MemoryCommitResult mcr = commitToMemory();    final Runnable awaitCommit = () -> mcr.writtenToDiskLatch.await();    QueuedWork.addFinisher(awaitCommit);    Runnable postWriteRunnable = () -> {        awaitCommit.run();        QueuedWork.removeFinisher(awaitCommit);    }    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);} 

Здесь сначала создаётся объект Runnable лямбда, которая может синхронно выполнить запись. Этот Runnable добавляется в QueuedWork. Если мы посмотрим JavaDoc для QueueWork класса, то увидим примерно следующее:

Internal utility class to keep track of process-global work that's outstanding and hasn't been finished yet.

This was created for writing SharedPreference edits out asynchronously so we'd have a mechanism to wait for the writes in Activity.onPause and similar places, but we may use this mechanism for other things in the future.

Этот класс хранит в себе список всех отложенных асинхронных операций с возможностью исполнить их синхронно в случае наступления таких событий, как Activity.onStop, Service.onStartCommand или Service.onDestroy.

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

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

Чтобы узнать, как SharedPreferences влияют на количество ANR-ошибок и полезна ли эта синхронная логика apply, мы решили провести A/B-тест, который меняет поведение этого метода. Для этого мы заменили все операции создания SharedPreferences на функцию-фабрику:

fun Context.createPreferences(name: String, mode: Int = Context.MODE_PRIVATE): SharedPreferences = getSharedPreferences(name, mode) 

Теперь мы можем управлять реализацией SharedPreferences в нашем приложении и использовать любую другую альтернативную реализацию вместо системной. Мы создали простой класс-имплементацию SharedPreferences, который делегирует в настоящую реализацию всё, кроме метода apply(): вместо него мы вызываем метод commit() на фоновом потоке. Эта реализация очень похожа на то, что сделано в другой библиотеке-альтернативе SharedPreferences Binary Preferences, только в нашем случае мы не меняем механизм сериализации-десериализации для упрощения обратной миграции в случае возникновения проблем.

В итоге код с новой реализацией в A/B-тесте выглядел примерно так:

fun Context.createPreferences(name: String, mode: Int = Context.MODE_PRIVATE): SharedPreferences =    if (isAsyncCommitAbTestEnabled()) {        getAsyncCommitSharedPrefs(this, name, mode)    } else {        getSharedPreferences(name, mode)    } 

Затем мы начали медленно раскатывать A/B-тест, следя за основными показателями. У нас достаточно хорошее покрытие продуктовыми и техническими метриками и есть инструменты, которые уведомляют разработчиков о значительных отклонениях в любой из них.

http://personeltest.ru/aways/habrastorage.org/webt/pz/kx/hm/pzkxhmwesuucvusye6qlix_2ffa.pnghttps://habrastorage.org/webt/pz/kx/hm/pzkxhmwesuucvusye6qlix_2ffa.png

В результате мы не обнаружили никаких проблем, а общее количество ANR-ошибок уменьшилось примерно на 4% по сравнению с контрольной группой A/B теста. Неплохо, но это всё ещё выше порогового значения.

Обработка push-уведомлений

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

До этого мы выяснили, что существует практически линейная корреляция между длительностью запуска приложения и ANR rate. Скорее всего, дело в том, что большинство ANR-ошибок в нашем приложении возникало при обработке BroadcastReceiver, на стадии Application.onCreate. К сожалению, у нас больше не осталось простых способов ускорения запуска всё остальное требовало серьёзного рефакторинга и большого объёма работ.

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

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

Но есть способы запустить некоторые части приложения в отдельных процессах. В таких случаях будут создаваться отдельные экземпляры класса Application для каждого процесса. Это может помочь нам в решении задачи, так как даёт возможность убрать практически всё содержимое метода Application.onCreate, перенеся в отдельный процесс только код, связанный с обработкой push-уведомлений. Это должно значительно ускорить обработку BroadcastReceiver и снизить вероятность возникновения ANR-ошибок:

Вы можете контролировать процесс, в котором будет запускаться компонент, с помощью AndroidManifest.xml. Например, для запуска BroadcastReceiver в нестандартном процессе нужно добавить название процесса в тег receiver с помощью атрибута android:process. Но как это сделать, если мы используем внешнюю библиотеку вроде Firebase Cloud Messaging?

Есть специальные теги, которые контролируют процесс объединения манифестов из библиотек. Можно пропатчить исходное объявление манифеста FCM BroadcastReceiver с помощью атрибута tools:node=replace. Помимо FCM BroadcastReceiver, за обработку push-уведомлений отвечает ещё и FirebaseMessagingService, и нам нужно запускать его в том же процессе. В итоге нужно добавить в манифест следующие записи:

<serviceandroid:name="com.google.firebase.messaging.FirebaseMessagingService"android:exported="false"android:process=":light"tools:node="replace">  <intent-filter android:priority="-500">  <action android:name="com.google.firebase.MESSAGING_EVENT" />  </intent-filter></service><receiverandroid:name="com.google.firebase.iid.FirebaseInstanceIdReceiver"android:exported="true"android:permission="com.google.android.c2dm.permission.SEND"android:process=":light"tools:node="replace">  <intent-filter>  <action android:name="com.google.android.c2dm.intent.RECEIVE" />  </intent-filter></receiver> 

Теперь, когда сервисы Google Play отправляют broadcast с новым сообщением, мы можем выключить обычную инициализацию в Application.onCreate, проверив, находимся ли мы в главном процессе:

class Application {override fun onCreate() {if (isMainProcess) {// perform usual initialization}}}

Есть много способов имплементации этой проверки. Один из них можно посмотреть здесь.

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

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

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

Примерно через неделю большинство наших пользователей обновили приложение и вот что мы получили:

  • доля ANR-ошибок в Badoo уменьшилась с 0,8% до 0,41% и в конце концов стала ниже, чем у пиров*, достигнув отметки в 0,28%:

*в Google Play имеется возможность выбрать произвольный набор приложений-пиров и выполнять сравнение ваших метрик с медианой пиров.

  • абсолютное количество ANR-ошибок в день уменьшилось более чем вдвое.

Хотя в нашем случае эти изменения сильно повлияли на ANR rate, стоит иметь в виду несколько вещей, прежде чем реализовывать что-то похожее в своём приложении:

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

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

  • Добавление дополнительного процесса требует больше ресурсов памяти и процессора

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

Общие результаты

В результате всех изменений мы уменьшили ANR rate и общее количество ANR-ошибок примерно в шесть раз:

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

Кто сталкивался с интересными багами, связанными с ANR? Расскажите о своём опыте в комментариях :)

Ссылки

  1. Методы управления трассировщиком

  2. Статья Snap Engineering про переписывание приложения

  3. Описание внутреннего устройства SharedPreferences

  4. Исходный код SharedPreferences

  5. Библиотека для выполнения инициализации в разных процессах

Подробнее..

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

27.01.2021 18:04:51 | Автор: admin

Пожалуй, одна из худших проблем, которая может случиться с вашим приложением, ошибка ANR (Application Not Responding), когда приложение не отвечает. Если таких ошибок много, они могут негативно влиять не только на пользовательский опыт, но и на позицию в выдаче Google Play и фичеринг.

В начале прошлого года количество ANRs в приложении Badoo превышало порог Bad Behaviour в Google Play. Поэтому мы собрали команду для решения этой проблемы и потратили несколько месяцев, экспериментируя с разными подходами. В результате мы смогли уменьшить количество таких ошибок более чем в шесть раз.

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

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

Что такое ошибка ANR?

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

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

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

Когда UI-поток Android-приложения блокируется слишком долго, выдаётся ошибка Application Not Responding (ANR).

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

на переднем плане находится Activity, приложение в течение пяти секунд не отвечает на входящие события или BroadcastReceiver, например нажатия на кнопки или касания экрана;

на переднем плане нет Activity, ваш BroadcastReceiver не закончил исполнение в течение длительного времени.

Если ANR случается, когда на переднем плане находится Activity вашего приложения, Android показывает диалоговое окно с предложением закрыть приложение или подождать.

Довольно легко принудительно вызвать ANR, написав Thread.sleep() в любом обработчике интерфейса, например обработчик нажатия кнопки. После нажатия на кнопку вы увидите примерно следующее:

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

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

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

Отслеживание ANR

Локальный анализ

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

Первое, что можно сделать, это проверить дамп стек-трейсов для всех потоков (thread dump). Когда приложение перестает отвечать, Android создаёт дамп всех текущих потоков, который может помочь в анализе проблемы. Обычно он находится в директории /data/anr/, точный путь можно найти в Logcat сразу после сообщения об ошибке ANR.

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

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

Отслеживание с помощью Google Play

Google Play автоматически отправляет отчёты об ошибках ANR, если у пользователя включена такая опция. В консоли Google Play есть несколько метрик и инструментов для анализа ANR.

Во-первых, можно увидеть агрегированные графики с общим количеством ANR-ошибок за день. Также есть такая метрика, как ANR rate отношение количества сессий за день, в которых возникала хотя бы одна ANR-ошибка, к общему количеству сессий за сутки. Для этой метрики задан порог в 0,47%, превышение которого считается неудовлетворительным поведением (Bad Behaviour) и может плохо повлиять на позицию приложения в Google Play.

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

Если вы активно используете консоль Google Play, вы могли заметить некоторые недостатки. Например, к отчётам нельзя прикрепить дополнительную информацию, такую как логи для отладки. Также невозможно настроить логику группировки отчётов. Иногда система помещает в одну группу ошибки, возникшие по разным причинам, а иногда раскидывает по разным группам ошибки, у которых причина одна.

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

Скачивание данных из Google Play

Для решения проблемы с логикой группировки можно попробовать скачать сырые отчёты об ANR-ошибках из Google Play для последующего ручного анализа. Раньше была возможность выгрузить эти данные из Google Cloud Storage, но несколько лет назад Google перестала поддерживать этот функционал:

Однако всё ещё можно просматривать отдельные отчёты в консоли. Но как нам экспортировать тысячи отчётов, не потратив при этом кучу времени на рутинную работу?

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

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

Мы реализовали скрапер на Selenium и получили сырые отчёты об ANR-ошибках для одного из релизов. Благодаря этому нам удалось проанализировать их так, как не получилось бы сделать с помощью встроенных в консоль Google Play инструментов. Например, просто поискав в отчётах по ключевым словам Application.onCreate, мы обнаружили, что около 60% ошибок произошло во время выполнения метода Application.onCreate. При этом в консоли Google Play нет возможности получить такую информацию, так как отчёты разбиты по группам.

Внутренняя аналитика

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

Его функциональность схожа с возможностями других инструментов для краш-репортинга, таких как Firebase Crashlytics и App Center, но ещё и позволяет нам полностью контролировать сохраняемые данные, менять логику группировки и применять сложную фильтрацию:

Это не реальные данные приложения Bumble, иллюстрация сделана просто для примераЭто не реальные данные приложения Bumble, иллюстрация сделана просто для примера

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

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

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

Вот пример отчёта в нашей системе:

Это не реальные данные приложения Bumble, иллюстрация сделана просто для примераЭто не реальные данные приложения Bumble, иллюстрация сделана просто для примера

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

Если у вас нет своего решения для сбора отчётов о падениях приложения, вы можете настроить репортинг и в сторонние инструменты. Например, можно отправлять ANR-ошибки в App Center или Firebase Crashlytics, так как они предоставляют API для отправки кастомных крашей.

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

В завершение

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

Ссылки

  1. Android Vitals в Google Play: https://developer.android.com/distribute/best-practices/develop/android-vitals

  2. Отладка ANR: https://developer.android.com/topic/performance/vitals/anr

  3. API для получения причин завершения процесса: https://developer.android.com/reference/kotlin/android/app/ActivityManager#gethistoricalprocessexitreasons

  4. Фреймворк для тестирования веб-страниц: https://www.selenium.dev/

  5. Библиотека для определения зависаний: https://github.com/SalomonBrys/ANR-WatchDog

Подробнее..

Ваш безлимит как увеличить пропускную способность автомерджа

21.06.2021 14:12:41 | Автор: admin

Отыщи всему начало, и ты многое поймёшь (Козьма Прутков).

Меня зовут Руслан, я релиз-инженер в Badoo и Bumble. Недавно я столкнулся с необходимостью оптимизировать механизм автомерджа в мобильных проектах. Задача оказалась интересной, поэтому я решил поделиться её решением с вами. В статье я расскажу, как у нас раньше было реализовано автоматическое слияние веток Git и как потом мы увеличили пропускную способность автомерджа и сохранили надёжность процессов на прежнем высоком уровне.

Свой автомердж

Многие программисты ежедневно запускают git merge, разрешают конфликты и проверяют свои действия тестами. Кто-то автоматизирует сборки, чтобы они запускались автоматически на отдельном сервере. Но решать, какие ветки сливать, всё равно приходится человеку. Кто-то идёт дальше и добавляет автоматическое слияние изменений, получая систему непрерывной интеграции (Continuous Integration, или CI).

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

Такой автоматизации может быть достаточно для небольших проектов. Но с увеличением количества разработчиков и веток, ограничения, накладываемые сервисами, могут существенно повлиять на производительность CI. Например, раньше у нас была система мерджа, при которой основная ветка всегда находилась в стабильном состоянии благодаря последовательной стратегии слияний. Обязательным условием слияния была успешная сборка при наличии всех коммитов основной ветки в ветке разработчика. Работает эта стратегия надёжно, но у неё есть предел, определяемый временем сборки. И этого предела оказалось недостаточно. При времени сборки в 30 минут на обработку 100 слияний в день потребовалось бы более двух суток. Чтобы исключить ограничения подобного рода и получить максимальную свободу выбора стратегий мерджа и моделей ветвления, мы создали собственный автомердж.

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

Термины

Main. Так я буду ссылаться на основную ветку репозитория Git. И коротко, и безопасно. =)

Сборка. Под этим будем иметь в виду сборку в TeamCity, ассоциированную с веткой Git и тикетом в трекере Jira. В ней выполняются как минимум статический анализ, компиляция и тестирование. Удачная сборка на последней ревизии ветки в сочетании со статусом тикета To Merge это однo из необходимых условий автомерджа.

Пример модели ветвления

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

На основе ветки main разработчик создаёт ветку с названием, включающим идентификатор тикета в трекере, например PRJ-k. По завершении работы над тикетом разработчик переводит его в статус Resolved. При помощи хуков, встроенных в трекер, мы запускаем для ветки тикета сборку. В определённый момент, когда изменения прошли ревью и необходимые проверки автотестами на разных уровнях, тикет получает статус To Merge, его забирает автоматика и отправляет в main.

Раз в неделю на основе main мы создаём ветку релиза release_x.y.z, запускаем на ней финальные сборки, при необходимости исправляем ошибки и наконец выкладываем результат сборки релиза в App Store или Google Play. Все фазы веток отражаются в статусах и дополнительных полях тикетов Jira. В общении с Jira помогает наш клиент REST API.

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

Первая версия: жадная стратегия

Сначала мы шли от простого и очевидного. Брали все тикеты, находящиеся в статусе To Merge, выбирали из них те, для которых есть успешные сборки, и отправляли их в main командой git merge, по одной.

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

Наличие в TeamCity актуальной успешной сборки мы проверяли при помощи метода REST API getAllBuilds примерно следующим образом (псевдокод):

haveFailed = False # Есть ли неудачные сборкиhaveActive = False # Есть ли активные сборки# Получаем сборки типа buildType для коммита commit ветки branchbuilds = teamCity.getAllBuilds(buildType, branch, commit)# Проверяем каждую сборкуfor build in builds:  # Проверяем каждую ревизию в сборке  for revision in build.revisions:    if revision.branch is branch and revision.commit is commit:      # Сборка актуальна      if build.isSuccessful:        # Сборка актуальна и успешна        return True      else if build.isRunning or build.isQueued        haveActive = True      else if build.isFailed:        haveFailed = Trueif haveFailed:  # Исключаем тикет из очереди, переоткрывая его  ticket = Jira.getTicket(branch.ticketKey)  ticket.reopen("Build Failed")  return Falseif not haveActiveBuilds:  # Нет ни активных, ни упавших, ни удачных сборок. Запускаем новую  TriggerBuild(buildType, branch)

Ревизии это коммиты, на основе которых TeamCity выполняет сборку. Они отображаются в виде 16-ричных последовательностей на вкладке Changes (Изменения) страницы сборки в веб-интерфейсе TeamCity. Благодаря ревизиям мы можем легко определить, требуется ли пересборка ветки тикета или тикет готов к слиянию.

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

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

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

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

Конфликты слияния

Если изменить одну и ту же строку кода в разных ветках и попытаться соединить их в main, то Git попросит разрешить конфликты слияния. Из двух вариантов нужно выбрать один и закоммитить изменения.

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

Если команда git merge завершилась с ошибкой и для всех файлов в списке git ls-files --unmerged заданы обработчики конфликтов, то для каждого такого файла мы выполняем парсинг содержимого по маркерам конфликтов <<<<<<<, ======= и >>>>>>>. Если конфликты вызваны только изменением версии приложения, то, например, выбираем последнюю версию между локальной и удалённой частями конфликта.

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

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

Логические конфликты

А может ли случиться так, что, несмотря на успешность сборок пары веток в отдельности, после слияния их с main сборка на основной ветке упадёт? Практика показывает, что может. Например, если сумма a и b в каждой из двух веток не превышает 5, то это не гарантирует того, что совокупные изменения a и b в этих ветках не приведут к большей сумме.

Попробуем воспроизвести это на примере Bash-скрипта test.sh:

#!/bin/bashget_a() {    printf '%d\n' 1}get_b() {    printf '%d\n' 2}check_limit() {    local -i value="$1"    local -i limit="$2"    if (( value > limit )); then        printf >&2 '%d > %d%s\n' "$value" "$limit"        exit 1    fi}limit=5a=$(get_a)b=$(get_b)sum=$(( a + b ))check_limit "$a" "$limit"check_limit "$b" "$limit"check_limit "$sum" "$limit"printf 'OK\n'

Закоммитим его и создадим пару веток: a и b.
Пусть в первой ветке функция get_a() вернёт 3, а во второй get_b() вернёт 4:

diff --git a/test.sh b/test.shindex f118d07..39d3b53 100644--- a/test.sh+++ b/test.sh@@ -1,7 +1,7 @@ #!/bin/bash get_a() {-    printf '%d\n' 1+    printf '%d\n' 3 } get_b() {git diff main bdiff --git a/test.sh b/test.shindex f118d07..0bd80bb 100644--- a/test.sh+++ b/test.sh@@ -5,7 +5,7 @@ get_a() { }  get_b() {-    printf '%d\n' 2+    printf '%d\n' 4 }  check_limit() {

В обоих случаях сумма не превышает 5 и наш тест проходит успешно:

git checkout a && bash test.shSwitched to branch 'a'OKgit checkout b && bash test.shSwitched to branch 'b'OK

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

git merge a bFast-forwarding to: aTrying simple merge with bSimple merge did not work, trying automatic merge.Auto-merging test.shMerge made by the 'octopus' strategy. test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)bash test.sh7 > 5

Было бы проще, если бы вместо get_a() и get_b() использовались присваивания: a=1; b=2, заметит внимательный читатель и будет прав. Да, так было бы проще. Но, вероятно, именно поэтому встроенный алгоритм автомерджа Git успешно обнаружил бы конфликтную ситуацию (что не позволило бы продемонстрировать проблему логического конфликта):

git merge a Updating 4d4f90e..8b55df0Fast-forward test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-)git merge b Auto-merging test.shCONFLICT (content): Merge conflict in test.shRecorded preimage for 'test.sh'Automatic merge failed; fix conflicts and then commit the result.

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

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

Превентивные меры

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

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

Вторая версия: последовательная стратегия

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

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

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

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

Но есть у этой схемы существенный недостаток: пропускная способность автомерджа линейно зависит от времени сборки. При среднем времени сборки iOS-приложения в 25 минут мы можем рассчитывать на прохождение максимум 57 тикетов в сутки. В случае же с Android-приложением требуется примерно 45 минут, что ограничивает автомердж 32 тикетами в сутки, а это даже меньше количества Android-разработчиков в нашей компании.

На практике время ожидания тикета в статусе To Merge составляло в среднем 2 часа 40 минут со всплесками, доходящими до 10 часов! Необходимость оптимизации стала очевидной. Нужно было увеличить скорость слияний, сохранив при этом стабильность последовательной стратегии.

Финальная версия: сочетание последовательной и жадной стратегий

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

Давайте вспомним идею жадной стратегии: мы сливали все ветки готовых тикетов в main. Основной проблемой было отсутствие синхронизации между ветками. Решив её, мы получим быстрый и надёжный автомердж!

Раз нужно оценить общий вклад всех тикетов в статусе To Merge в main, то почему бы не слить все ветки в некоторую промежуточную ветку Main Candidate (MC) и не запустить сборку на ней? Если сборка окажется успешной, то можно смело сливать MC в main. В противном случае придётся исключать часть тикетов из MC и запускать сборку заново.

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

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

Следуя этому алгоритму, для k проблемных тикетов в худшем случае нам придётся выполнить O(k*log2(n)) сборок, прежде чем мы обработаем все проблемные тикеты и получим удачную сборку на оставшихся.

Вероятность благоприятного исхода велика. А ещё в то время, пока сборки на ветке MC падают, мы можем продолжать работу при помощи последовательного алгоритма!

Итак, у нас есть две автономные модели автомерджа: последовательная (назовём её Sequential Merge, или SM) и жадная (назовём её Greedy Merge, или GM). Чтобы получить пользу от обеих, нужно дать им возможность работать параллельно. А параллельные процессы требуют синхронизации, которой можно добиться либо средствами межпроцессного взаимодействия, либо неблокирующей синхронизацией, либо сочетанием этих двух методов. Во всяком случае, мне другие методы неизвестны.

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

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

  1. SM-SM и GM-GM: между командами одного типа.

  2. SM-GM: между SM и GM в рамках одного репозитория.

Первая проблема легко решается при помощи мьютекса по токену, включающему в себя имя команды и название репозитория. Пример: lock_${command}_${repository}.

Поясню, в чём заключается сложность второго случая. Если SM и GM будут действовать несогласованно, то может случиться так, что SM соединит main с первым тикетом из очереди, а GM этого тикета не заметит, то есть соберёт все остальные тикеты без учёта первого. Например, если SM переведёт тикет в статус In Master, а GM будет всегда выбирать тикеты по статусу To Merge, то GM может никогда не обработать тикета, соединённого SM. При этом тот самый первый тикет может конфликтовать как минимум с одним из других.

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

Таким образом, мы получили своего рода неблокирующую синхронизацию.

Немного о TeamCity

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

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

Кто-то посчитает решение очевидным, но я нашёл его не сразу. Оказывается, прикрепить ревизию к сборке при её добавлении в очередь можно при помощи параметра lastChanges метода addBuildToQueue:

<lastChanges>  <change    locator="version:{{revision}},buildType:(id:{{build_type}})"/></lastChanges>

В этом примере {{revision}} заменяется на 16-ричную последовательность коммита, а {{build_type}} на идентификатор конфигурации сборки. Но этого недостаточно, так как TeamCity, не имея информации о новом коммите, может отказать нам в запросе.

Для того чтобы новый коммит дошёл до TeamCity, нужно либо подождать примерно столько, сколько указано в настройках конфигурации корня VCS, либо попросить TeamCity проверить наличие изменений в репозитории (Pending Changes) при помощи метода requestPendingChangesCheck, а затем подождать, пока TeamCity скачает изменения, содержащие наш коммит. Проверка такого рода выполняется посредством метода getChange, где в changeLocator нужно передать как минимум сам коммит в качестве параметра локатора version. Кстати, на момент написания статьи (и кода) на странице ChangeLocator в официальной документации описание параметра version отсутствовало. Быть может, поэтому я не сразу узнал о его существовании и о том, что это 40-символьный 16-ричный хеш коммита.

Псевдокод:

teamCity.requestPendingChanges(buildType)attempt = 1while attempt <= 20:  response = teamCity.getChange(commit, buildType)  if response.commit == commit:    return True # Дождались  sleep(10)return False

О предельно высокой скорости слияний

У жадной стратегии есть недостаток на поиск ветки с ошибкой может потребоваться много времени. Например, 6 сборок для 20 тикетов у нас может занять около трёх часов. Можно ли устранить этот недостаток?

Допустим, в очереди находится 10 тикетов, среди которых только 6-й приводит к падению сборки.

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

Если бы мы сразу запустили сборку на левой половине очереди, то не потеряли бы времени. А если бы проблемным оказался не 6-й тикет, а 4-й, то было бы выгодно запустить сборку на четверти длины всей очереди, то есть на тикетах с 1 по 3, например.

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

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

Примерно такой же алгоритм реализован в премиум-функции GitLab под названием Merge Trains. Перевода этого названия на русский язык я не нашёл, поэтому назову его Поезда слияний. Поезд представляет собой очередь запросов на слияние с основной веткой (merge requests). Для каждого такого запроса выполняется слияние изменений ветки самого запроса с изменениями всех запросов, расположенных перед ним (то есть запросов, добавленных в поезд ранее). Например, для трёх запросов на слияние A, B и С GitLab создаёт следующие сборки:

  1. Изменения из А, соединённые с основной веткой.

  2. Изменения из A и B, соединённые с основной веткой.

  3. Изменения из A, B и C, соединённые с основной веткой.

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

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

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

Но если преград человеческой мысли нет, то пределы аппаратных ресурсов видны достаточно отчётливо:

  1. Каждой сборке нужен свой агент в TeamCity.

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

  3. Сборки автомерджа мобильных приложений в main составляют лишь малую часть от общего количества сборок в TeamCity.

Взвесив все плюсы и минусы, мы решили пока остановиться на алгоритме SM + GM. При текущей скорости роста очереди тикетов алгоритм показывает хорошие результаты. Если в будущем заметим возможные проблемы с пропускной способностью, то, вероятно, пойдём в сторону Merge Trains и добавим пару параллельных сборок GM:

  1. Вся очередь.

  2. Левая половина очереди.

  3. Левая четверть очереди.

Что в итоге получилось

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

  • уменьшение среднего размера очереди в 2-3 раза;

  • уменьшение среднего времени ожидания в 4-5 раз;

  • мердж порядка 50 веток в день в каждом из упомянутых проектов;

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

Примеры графиков слияний за несколько дней:

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

Среднее количество тикетов в очереди (AVG) уменьшилось в 2,5 раза (3,95/1,55).

Время ожидания тикетов в минутах:

Среднее время ожидания (AVG) уменьшилось в 4,4 раза (155,5/35,07).

Подробнее..

Ловим баги на клиенте как мы написали свою систему для сбора клиентских ошибок

08.10.2020 16:23:54 | Автор: admin

У нас в Badoo довольно много клиентских приложений. Помимо основных продуктов Badoo и Bumble, у которых есть как веб-версии (десктопная и мобильная), так и клиенты под нативные платформы (Android и iOS), ещё есть с десяток внутренних инструментов со своими UI. Для сбора клиентских ошибок мы используем собственную разработку под кодовым названием Gelatо. Последние два года я работал над её серверной частью и за это время открыл для себя много нового из мира разработки Error Tracking систем.

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


Что вас ждёт:

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

  • дам краткий обзор нашей системы, её архитектуры и технологического стека;

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

Как мы используем информацию об ошибках

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

Второе проводим анализ ошибок. Мы в Badoo релизимся довольно часто:

веб-приложения: один-два раза в день, включая серверную часть;

нативные приложения: раз в неделю (хотя многое зависит от того, как быстро билд примут в App Store и Google Play).

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

Третье. Информация об ошибках, собранная в одном месте, значительно упрощает работу разработчиков и QA-инженеров.

Что использовали раньше

Исторически для сбора клиентских ошибок в Badoo использовались две системы: HockeyApp для сбора краш-репортов из нативных приложений и самописная система для сбора JS-ошибок.

HockeyApp

HockeyApp полностью удовлетворяла наши потребности, до тех пор пока в 2014 году её не приобрела Microsoft и не начала менять политику использования, чтобы подтолкнуть людей к переходу на свою систему App Center. App Center на тот момент нашим требованиям не соответствовала: она находилась на стадии активной разработки, и часть необходимой нам функциональности отсутствовала, в частности деобфускация стек-трейсов Android-приложений с использованием DexGuard mapping-файлов, без которой невозможна группировка ошибок. О деобфускации я расскажу ниже; если вы слышите об этом впервые, значит, точно узнаете что-то новое.

Microsoft установила дедлайн 16 октября 2019 года, к этому дню все пользователи HockeyApp должны были мигрировать в App Center. К слову, поддержка DexGuard появилась в App Center лишь в конце декабря 2019 года, спустя несколько месяцев после официального закрытия HockeyApp.

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

Наша система для сбора JS-ошибок

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

Архитектура у неё была довольно простой:

  • данные хранились в MySQL (мы хранили информацию о последних 1020 релизах);

  • для каждой версии приложения была отдельная таблица;

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

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

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

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

Требования к новой системе

  • Хранение всех клиентских ошибок в одном месте.

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

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

  • Отказоустойчивость чтобы минимизировать риск потери данных.

  • Способность давать ответ не только на вопрос Сколько произошло ошибок?, но и на вопрос Сколько пользователей это затронуло?.

  • Группировка похожих ошибок с сохранением мета-информации (время первой и последней ошибки в группе, в каком релизе появилась ошибка, статус ошибки и т. п.).

  • Гибкий поиск по любой комбинации полей с поддержкой полнотекстового поиска.

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

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

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

Почему мы не выбрали готовое решение

Конечно, прежде чем писать своё решение, мы проанализировали существующие на рынке системы.

Платные сервисы

Сбором клиентских ошибок занимаются многие SaaS-решения, и это неудивительно: быстрое обнаружение и исправление ошибок один из ключевых аспектов современной разработки. Среди самых популярных решений можно выделить Bugsnag, TrackJS, Raygun, Rollbar и Airbrake. Все они обладают богатым функционалом и в целом соответствуют нашим требованиям, но мы не рассматривали облачные решения: не было уверенности в том, что ценовая политика и политика использования со временем не изменятся, как это случилось с HockeyApp. А миграция на новое решение довольно сложная и длительная процедура.

Open-source-решения

С open-source-системами всё было не так радужно. Большинство из них либо перестали развиваться, либо так и не вышли из стадии разработки и были непригодны для использования в продакшене.

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

  • вместо хранения всех событий в ней использовалось семплирование;

  • в качестве базы данных использовалась PostgreSQL, с которой у нас не было опыта работы;

  • были сложности с добавлением новой функциональности, так как система написана на Python, а наши основные языки PHP и Go;

  • отсутствие части необходимой нам функциональности (например, интеграции с Jira).

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

Так на свет появилась система под кодовым названием Gelato (General Error Logs And The Others), о разработке которой и пойдёт речь дальше.

Краткий обзор системы

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

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

Кликнув на то или иное приложение, мы попадём на страницу со статистикой по его релизам.

Кликнув на версию, мы попадём на страницу со списком ошибок.

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

Так эта страница выглядит для нативных приложений:

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

Здесь доступны общая информация об ошибке (1), график общего количества ошибок (2) и различная аналитика (3).

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

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

Выбираем версии для сравнения:

И попадаем на страницу со списком ошибок, которые есть в одной версии и которых нет в другой:

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

  • интеграция с нашим A/B-фреймворком для отслеживания ошибок, которые появились в том или ином сплит-тесте;

  • система уведомлений о новых ошибках;

  • расширенная аналитика (больше графиков и диаграмм);

  • email-дайджесты со статистикой по приложениям.

Общая схема работы

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

  1. Сбор данных.

  2. Обработка данных.

  3. Хранение данных.

Схематически это можно изобразить следующим образом:

Давайте для начала разберёмся со сбором данных.

Сбор данных

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

Что входит в задачи API:

  • прочитать данные;

  • проверить данные на соответствие требуемому формату, чтобы сразу отсечь шум (чаще всего это запросы от мамкиных хакеров, которые натравили сканеры на наше API);

  • сохранить всё в промежуточную очередь.

Для чего нужна промежуточная очередь?

Если полагаться на то, что у нас достаточно низкий EPS (errors per second), и на то, что все части нашей системы будут работать стабильно всё время, то можно значительно упростить систему и сделать весь процесс синхронным.

Но мы-то с вами знаем, что в реальном мире так не бывает и на каждом из этапов в самый неподходящий момент может произойти нечто непредвиденное. И к этому наша система должна быть готова. Так, ошибка в одной из внешних зависимостей приложения приведёт к тому, что оно начнёт крашиться, что повлечёт за собой рост EPS (как это было с iOS Facebook SDK 10 июля 2020 года). Как следствие, значительно увеличится нагрузка на всю систему, а вместе с этим и время обработки одного запроса.

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

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

Что можно использовать в качестве очереди?

  1. Первое, что приходит в голову, популярные брокеры сообщений, например Redis или RabbitMQ.

  2. Также можно использовать Apache Kafka, которая хорошо подходит для случаев, когда требуется хранить хвост входящих данных за определённый период (например, для какой-то внутренней аналитики). Kafka используется в частности в последней (десятой) версии Sentry.

  3. Мы остановились на LSD (Live Streaming Daemon). По сути, это очередь на файлах. Система используется в Badoo довольно давно и хорошо себя зарекомендовала, плюс у нас в коде уже есть вся необходимая обвязка для работы с ней.

Хранение данных

Тут нужно ответить на два вопроса: Где хранить? (база данных) и Как хранить? (модель данных).

База данных

При реализации прототипа системы мы остановились на двух претендентах: Elasticsearch и ClickHouse.

Elasticsearch

Из очевидных плюсов данной системы можно выделить следующие:

  • горизонтальное масштабирование и репликация из коробки;

  • большой набор агрегаций, что удобно при реализации аналитической части нашей системы;

  • полнотекстовый поиск;

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

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

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

Конечно, как у любой системы, у Elasticsearch есть и недостатки:

  • сложный язык запросов, поэтому документация всегда должна быть под рукой (в последних версиях появилась поддержка синтаксиса SQL, но это доступно только в платной версии (X-Pack) либо при использовании Open Distro от Amazon);

  • JVM, которую нужно уметь готовить, в то время как наши основные языки это PHP и Go (например, для оптимизации сборщика мусора под определённый профиль нагрузки требуется глубокое понимание того, как всё работает под капотом; мы столкнулись с этой проблемой при обновлении с версии 6.8 до 7.5, благо тема не нова и есть довольно много статей в интернете (например, тут и тут));

  • плохое сжатие строк; мы планируем хранить довольно много данных и, хотя железо с каждым годом дешевеет, хотим использовать ресурсы максимально эффективно (конечно, можно использовать deflate-сжатие вместо LZ4, но это увеличит потребление CPU, что может негативно сказаться на производительности всего кластера).

ClickHouse

Плюсы данной базы:

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

  • хорошее сжатие данных, особенно длинных строк;

  • MySQL-совместимый синтаксис запросов, что избавляет от необходимости учить новый язык запросов, как в случае с Elasticsearch;

  • горизонтальное масштабирование и репликация из коробки, хоть это и требует больше усилий по сравнению с Elasticsearch.

Но на начало 2018 года в ClickHouse отсутствовала часть необходимых нам функций:

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

  • поддержка UPDATE по условию: ClickHouse заточена под неизменяемые данные, поэтому реализация обновления произвольных записей задача не из лёгких (этот вопрос не раз поднимался на GitHub и в конце 2018 года функцию всё же реализовали, но она не подходит для частых обновлений);

  • полнотекстовый поиск (была опция поиска по RegExp, но он требует сканирования всей таблицы (full scan), а это довольно медленная операция).

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

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

Модель данных

Теперь немного поговорим о том, как мы храним события.

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

  • мета-информация;

  • сырые события.

Данные изолированы в рамках конкретного приложения (отдельный индекс) это позволяет кастомизировать настройки индекса в зависимости от нагрузки. Например, для непопулярных приложений можно хранить данные на warm-нодах в кластере (мы используем hot-warm-cold-архитектуру).

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

Сама идея была позаимствована из Sentry и незначительно доработана в процессе эксплуатации. В Sentry у события есть базовые поля, есть поле tags для данных, по которым нужна возможность поиска, и поле extra для всех остальных специфических данных.

Обработка данных

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

Начнём со случая попроще.

Обработка краш-репортов из Android-приложений

Чтобы максимально уменьшить размер приложения, в Android-мире принято при сборке билда использовать специальные утилиты, которые:

  • удаляют весь неиспользуемый код (code shrinking сокращение);

  • оптимизируют всё, что осталось после первого этапа (optimization оптимизация);

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

Подробнее про это можно узнать из официальной документации.

Сегодня есть несколько популярных утилит:

  • ProGuard (бесплатная версия);

  • DexGuard на базе ProGuard (платная версия с расширенным функционалом);

  • R8 от Google.

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

o.imc: Error loading resources: Security check requiredat o.mef.b(:77)at o.mef.e(:23)at o.mef$a.d(:61)at o.mef$a.invoke(:23)at o.jij$c.a(:42)at o.jij$c.apply(Unknown Source:0)at o.wgv$c.a_(:81)at o.whb$e.a_(:64)at o.wgs$b$a.a_(:111)at o.wgy$b.run(:81)at o.vxu$e.run(:109)at android.os.Handler.handleCallback(Handler.java:790)at android.os.Handler.dispatchMessage(Handler.java:99)at android.os.Looper.loop(Looper.java:164)at android.app.ActivityThread.main(ActivityThread.java:6626)at java.lang.reflect.Method.invoke(Native Method)at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:811)

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

AllGoalsDialogFragment -> o.a:    java.util.LinkedHashMap goals -> c    kotlin.jvm.functions.Function1 onGoalSelected -> e    java.lang.String selectedId -> d    AllGoalsDialogFragment$Companion Companion -> a    54:73:android.view.View onCreateView(android.view.LayoutInflater,android.view.ViewGroup,android.os.Bundle) -> onCreateView    76:76:int getTheme() -> getTheme    79:85:android.app.Dialog onCreateDialog(android.os.Bundle) -> onCreateDialog    93:97:void onDestroyView() -> onDestroyView

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

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

На её базе наши Android-разработчики написали простенький сервис на Kotlin, который:

  • принимает на вход стек-трейс и версию приложения;

  • скачивает необходимый mapping-файл из Ceph (маппинги заливаются автоматически при сборке релиза в TeamCity);

  • деобфусцирует стек-трейс.

Обработка краш-репортов из iOS-приложений

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

Thread 0:0   libsystem_kernel.dylib              0x00000001bf3468b8 0x1bf321000 + 1537841   libobjc.A.dylib                     0x00000001bf289de0 0x1bf270000 + 1059522   Badoo                               0x0000000105c9c6f4 0x1047ec000 + 216941963   Badoo                               0x000000010657660c 0x1047ec000 + 309755004   Badoo                               0x0000000106524e04 0x1047ec000 + 306416685   Badoo                               0x000000010652b0f8 0x1047ec000 + 306670006   Badoo                               0x0000000105dce27c 0x1047ec000 + 229464287   Badoo                               0x0000000105dce3b4 0x1047ec000 + 229467408   Badoo                               0x0000000104d41340 0x1047ec000 + 5591872

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

Что можно использовать для символикации?

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

  • Можно взять сервис Symbolicator от Sentry, который недавно был выложен в открытый доступ (рекомендую почитать статью о том, как он разрабатывался). Мы какое-то время экспериментировали с ним и пришли к выводу, что as is этот сервис будет сложно интегрировать в нашу схему: пришлось бы допиливать его под наши нужды, а опыта использования Rust у нас нет.

  • Можно написать свой сервис на базе библиотеки Symbolic от Sentry, которая, хоть и написана на Rust, но предоставляет C-ABI её можно использовать в языке с поддержкой FFI.

Мы остановили свой выбор на последнем варианте и написали сервис на Golang с учётом всех наших нюансов, который под капотом обращается к Symbolic через cgo.

Группировка ошибок

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

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

Мы решили не усложнять свою систему и остановились на группировке по хешу:

  • JS-ошибки группируются по полям message, type и origin;

  • Android краш-репорты группируются по первым трём строчкам стек-трейсов (плюс немного магии);

  • iOS краш-репорты группируются по первому несистемному фрейму из упавшего треда (тред, который отмечен как crashed в краш-репорте).

В качестве заключения

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

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

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

Если резюмировать, то:

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

  • будьте готовы к резкому росту количества ошибок;

  • чем больше аналитики вы построите, тем лучше;

  • символикация iOS краш-репортов это сложно, присмотритесь к Symbolicator;

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

  • запаситесь терпением и приготовьтесь к увлекательному путешествию в мир разработки систем сбора ошибок.

Подробнее..

Категории

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

  • Имя: Макс
    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