Разработка
Быстрее переписать с нуля на новой технологии: как приложение Uber переписывали на Swift
Эта история о переписывании приложения Uber на Swift читается как настоящий детектив.
Ладно, ребята, соберитесь вокруг и позвольте мне рассказать вам историю (почти) самой большой инженерной катастрофы, в которой я когда-либо имел несчастье принимать участие. Это рассказ о политике, архитектуре и заблуждении о невозвратных затратах.
Alright folks, gather round and let me tell you the story of (almost) the biggest engineering disaster I’ve ever had the misfortune of being involved in. It’s a tale of politics, architecture and the sunk cost fallacy [I’m drinking an Aberlour Cask Strength Single Malt Scotch] https://t.co/KWnZKIpycp
— McLaren Stanley (@StanTwinB) December 10, 2020
Был 2016 год. Дональд Трамп еще не был президентом, поэтому движения #DeleteUber еще не было. ТК по-прежнему был генеральным директором, мы все еще находились в фазе быстрого роста и международной экспансии, общественное мнение было исключительно позитивным, Uber был на высоте.
Но быстрый рост вызвал и проблемы — само приложение начало сбоить. Размер инженерной организации увеличивался вдвое почти каждый год, и когда вы растете так быстро, вы получаете невероятно широкий спектр навыков. Это, в сочетании с хакерским менталитетом, который мы назвали «Пусть строители строят» (Let builder’s build), означало, что архитектура приложения была сложной и хрупкой. Uber в то время был чрезвычайно загружен логикой на стороне клиента, поэтому приложение часто ломалось. Мы постоянно вносили хот фиксы, выпускали горящие релизы и т.д. Дизайн также плохо масштабировался.
Вследствие всех этих проблем на всех уровнях организации началось растущее движение, которое сплотилось вокруг идеи «переписать приложение с нуля». Общее мнение заключалось в том, что архитектура тормозит нас, и начать все сначала будет быстрее.
Так была сформирована команда для создания новой мобильной архитектуры этого нового приложения. Главной задачей команды было создание архитектуры, которая «поддерживала бы мобильную разработку в Uber в течение следующих 5 лет». Мы делали сразу обе платформы. Продукт и Дизайн тоже менялись.
Что касается iOS, переписывание с нуля дало возможность перейти на Swift (который был в версии 2.x в течение этого периода времени). Uber и раньше пробовал Swift, но как и для многих из тех, кто принял его тогда, на раннем этапе, это было крайне проблематично, поэтому до переписывания его использование запретили.
По общему мнению команды разработчиков архитектуры, большинство проблем Swift было связано с ненадежностью взаимодействия с Objective-C в то время, поэтому, если бы мы написали чистое приложение на Swift, мы могли бы избежать большинства проблем.
Также было стремление использовать одни и те же основные архитектурные шаблоны как на Android, так и на iOS. Ребята из Android в то время были большими поклонниками RxJava, и была эквивалентная библиотека RxSwift, которая использовала преимущества парадигмы функционального программирования в Swift. Все казалось простым и понятным.
Таким образом, эта небольшая основная команда по Дизайну, Продукту и Архитектуре ушла в комнату со своими новыми функциональными/реактивными шаблонами, новым языком и новым приложением на несколько месяцев. Все прошло гладко. Архитектура в значительной степени опиралась на расширенные языковые функции Swift. Дизайн пользовательского интерфейса был масштабируемым для растущего числа продуктов, которые предлагал Uber, парадигма функционального программирования была мощной (хотя и требовала некоторого обучения), архитектура была сосредоточена на нашем новом сетевом протоколе на основе потоков в реальном времени (это часть, которую я написал).
Спустя несколько месяцев и нескольких ярких демонстраций импульс нарастал. Проект выглядел успешным. С небольшим количеством инженеров они за короткое время создали потрясающий опыт. Большая часть основного продукта была переписана. Руководителям идею «продали».
Началось внедрение в масштабах всей компании. Команды начали сосредотачиваться на добавлении своих функций в новое приложение. Сначала ощущение и волнение вокруг нового вызвало волну мотивации и продуктивности. Архитектура была построена для изоляции функций, что позволило командам быстро двигаться вперед.
Но как только Swift начал расти дальше десяти инженеров, колеса начали отрываться от земли. Даже сейчас компилятор Swift намного медленнее, чем Objective-C, а тогда он был практически непригоден для использования. Время сборки пробило потолок. Ввод во время зависаний (Typeahead) и отладка перестал работать полностью.
Где-то у нас есть видео, в котором инженер Uber набирает однострочный оператор в Xcode и затем ждет 45 секунд, пока буквы медленно, одна за другой, появляются в редакторе.
Затем мы столкнулись с проблемой динамического компоновщика. В то время вы могли только динамически связывать библиотеки Swift. К сожалению, время работы компоновщика полиномиально зависело от количества библиотек, поэтому Apple рекомендовала максимальное их количество двоичном файле — 6. У нас было 92 и это число росло.
В результате после нажатия на иконку приложения проходило 8-12 секунд, прежде чем даже вызывался main. Наше новое блестящее приложение работало медленнее, чем старое неуклюжее. Затем возникла проблема с размером двоичного файла.
Почему было не повернуть назад, к старому приложению? Когда проблемы начали проявляться всерьез, мы уже прошли точку невозврата (ошибка невозвратных затрат). В этот момент вся компания вкладывала свои силы в новое приложение. Были потрачены тысячи человекочасов во всех областях, миллионы и миллионы (я не могу сказать вам реальное число, но это было намного больше 1) долларов. Вся управленческая цепочка была полностью занята новым приложением. Я в частном порядке разговаривал с моим директором — «нам нужно прекратить» — но он сказал мне, что если этот проект потерпит неудачу, он может собирать чемоданы. То же самое касалось его босса вплоть до вице-президента.
Выхода не было.
Поэтому мы засучили рукава и поручили нашим лучшим специалистам решить каждую из проблем, расставив приоритеты в критически важных вопросах запуска (динамическое связывание, размер двоичного файла). Мне было поручен как динамический линкинг, так и размер файла — именно в таком порядке.
Мы быстро обнаружили, что размещение всего нашего кода в основном исполняемом файле решает проблему линковки при запуске приложения. Но, как все мы знаем, Swift объединяет пространство имен с фреймворками, поэтому для этого потребовались огромные изменения в коде, включающие бесчисленные проверки пространства имен.
Именно тогда блестящий Ричард Хауэлл обнаружил, читая выходные данные сборки Xcode, что он может взять все промежуточные объектные файлы и повторно связать их с основным исполняемым файлом с помощью специального сценария после завершения сборки.
Поскольку Swift превращает пространство имен объекта в само символьное имя во время компиляции, это означало, что он мог безопасно сохранить пространство имен при этом. Это позволило нам эффективно статически связать наши библиотеки и сократить время предварительной подготовки к запуску с 10 секунд до практически 0.
Следующая проблема — размер приложения. В то время мы планировали включить новое приложение в старый пакет и медленно развертывать его в качестве подстраховки. Первое, что мы сделали, чтобы выгадать с размером — просто удалили старое приложение. Мы назвали эту стратегию выпуска «Yolo» (You Only Live Once, живешь только раз). TK сам принял решение.
Мы также заменили все наши структуры Swift классами. Типы значений обычно несут массу накладных расходов из-за выравнивания объектов и дополнительного машинного кода, необходимого для поведения копирования, автоинициализаторов и т.д. Это сэкономило нам место, поэтому мы продолжили работу.
Но приложение продолжало расти. Вскоре мы достигли предела загрузки (100 МБ) для наших универсальных двоичных файлов (iOS 8 и ранее). Это означало значительное количество потерянных регистраций (в долларах это стоило бы нам восьмизначных потерь от пользователей, которые еще не обновили ОС).
К этому моменту до даты публичного запуска оставались недели. Мы любезно получили помощь от одной компании, с которой я все еще нахожусь под соглашением о неразглашении, да и они не смогли до конца решить нашу проблему, но единственное, что мы могли сделать, это перегенерировать весь код модели (25% от общего количества строк).
Поскольку в iOS 9 была введен архитектурный слайсинг, фактически размер приложения для этой версии был вдвое меньше (плюс-минус). Надо было отказываться от Objective-C и поддержки iOS 8. За неделю до срока мы решили подавиться восьмизначными потерями и отказаться от поддержки iOS 8.
Общее мнение заключалось в том, что при половинном размере у нас все еще будет достаточно места для развития в бинарнике iOS 9, и после запуска мы сможем решить проблему в будущем, потому что все немного замедлится. К сожалению, мы совершенно ошибались.
После релиза приложения мы устроили грандиозный праздник. Приложение было хорошо встречено прессой. Оно было быстрым и с ярким новым дизайном. Группа людей после 90 рабочих недель получила небольшую передышку.
Но затем общественное мнение начало меняться. Новое приложение было сосредоточено на том, чтобы позволить пользователю сперва ввести пункт назначения, чтобы получить цену поездки еще до нее (в старые времена вы просто получали множитель рядом со ставкой).
Без ручного ввода, местоположение людей просто отображалось в соответствии с последней полученной GPS-локацией. Это могло быть очень неточным (особенно в городах с высокими зданиями), и водители оказывались бы вообще не в том квартале. Это был бы ужасный опыт.
Поэтому, чтобы улучшить определение местоположения, мы изменили разрешение на получение местоположения для сбора сигналов в фоновом режиме, чтобы мы могли отправлять водителей в ваше текущее место. Люди испугались. Некоторые из моих бывших коллег в Твиттере призывали меня уйти из такой злой компании, которая вот так отслеживала пользователей.
В результате этого сумасшествия люди отключали разрешение на определение местоположения. Но новое приложение не было предназначено для такого варианта использования.
Итак, мы должны были вернуться к предыдущему варианту. Мы обсуждали отключение фонового определения местоположения, но тогда это снова испортило бы пользовательский опыт и увеличило время до начала поездки.
Когда Трамп вошел в Белый дом (это было примерно через три месяца после выпуска нового приложения), это вызвало цепную реакцию, которая привела к началу движения #DeleteUber. Это еще одна большая тема для обсуждения, но смысл в том, что профсоюз такси Нью-Йорка воспользовался возмущением, вызванным запретом на поездки, и обвинил Uber в заработке на забастовке. Это была полная ложь, без резкого повышения цен предложение немедленно прекратило бы работу (никто не поедет в аэропорт без дополнительного стимула для этого). Но ложь стала вирусной.
Все это время продолжался рост Swift-кода . Продолжающиеся проблемы и медленная среда разработки создали две противоборствующие политические фракции среди инженеров iOS в Uber. Я назову их “Swift фанатиками” (или «стремительные фанатики» — Swift Zealots) и “Objective-C скрягами” (Objective-C curmudgeons).
Таким образом, сочетание внешнего давления и внутренних фракций означало, что напряженность стала высокой. Фанатики отрицали проблемы, которые создал Swift. Скряги жаловались на все, что можно было вообразить, не предлагая особых решений.
Примерно в это же время нас настигла проблема с размером приложения. Я был на связи, и у команды разработчиков возникли проблемы с отправкой приложения в App Store. Оказывается, наше блестящее решение проблемы динамического линкинга теперь создало главный исполняемый файл, который был слишком большим.
Итак, после исправления этой проблемы мы с Aqua Geek немного покопались и обнаружили, что размер нашего скомпилированного кода растет со скоростью 1.3 МБ в неделю. Я сказал всем, что надо что-то делать. Если бы мы что-то не предприняли, мы бы достигли предела загрузки сотовой связи через 3 недели.
Но внутренние разногласия стали настолько большими, что мы полностью отрицали эту проблему. Один из технических руководителей в лагере Swift написал двухстраничный документ о том, что лимит загрузки сотовой связи не имеет значения (Facebook все-таки давно миновал его). Мы очень устали бороться с пожарами.
Итак, один из наших специалистов по обработке данных разработал тест, искусственно превысив предел одного из архитектурных вариантов приложения и измерил влияние этого на бизнес-показатели. На следующей неделе мы вернули этот фрагмент в прежнее состояние и увеличили другой за пределы доступного (чтобы контролировать всю сборку).
Эффект был катастрофическим. Негативное влияние на бизнес было на несколько порядков больше, чем вся стоимость годового переписывания приложения на Swift. Оказывается, множество людей работало в сотовой сети при загрузке приложения Uber (кто знал?).
Итак, мы сформировали еще одну ударную группу. Мы начали декомпилировать наши объектные файлы и перебирать символы строка за строкой, чтобы определить, почему размер нашего кода Swift был намного больше. Мы удалили неиспользуемые функции. Тайлеру пришлось переписать приложение watchOS обратно в Obj-C.
Мы были на пределе возможностей. Такими уставшими. Но все сплотились. Вот тогда и начали сиять настоящие гении инженеров. Один из разработчиков в Амстердаме выяснил, как перестроить оптимизацию компилятора. Для тех из нас, кто не является разработчиками компиляторов, я объясню.
Современные компиляторы выполняют множество проходов по нашему коду. Например, один проход может выравнивать ваши функции. Другой может заменять константы их значениями. В зависимости от порядка их выполнения машинный код может быть меньше или больше.
Если ваша выровненная функция получает константу, компилятор может понять это и заменить, например, такое:
int x = 3 func(x) { X + 4 }
просто на константу 7, если сначала идет inline проход (что дает намного меньше кода).
Если выравнивание идет вторым, проход констант не сможет определить тело функции, и вы получите больше кода. Все это, конечно, полностью зависит от того, как выглядит код, который вы пишете, так как сложно оптимизировать общий порядок проходов.
Блестящий инженер из Амстердама в релизной сборке перенастроил алгоритм так, чтобы проходы оптимизации минимизировали размер. Это сократило общий размер машинного кода на 11 Мегабайт и дало нам достаточно времени для продолжения разработки.
В свою очередь это напугало инженеров компилятора Swift, они были обеспокоены тем, что непроверенные проходы компилятора могут привести к появлению неизвестных ошибок (хотя каждый проход должен быть сам по себе безопасным, трудно рассуждать о возможных комбинациях). Однако серьезных проблем у нас не возникло.
Мы также применили кучу других решений (линтинг для особо дорогих шаблонов кода). Мы измеряли каждое решение в размерах обычных недель разработки, которые оно экономит. Но настоящей проблемой была кривая роста. Рост всегда съедал наши победы.
В конце концов мы получили достаточно времени, чтобы Apple увеличила лимит загрузки через сотовую сеть до 150 Мб. Они также добавили ряд функций компилятора, чтобы помочь с оптимизацией размера (-Osize). По их собственному признанию, Swift никогда не будет таким же маленьким, как Objective-C.
Но потом они уменьшили размер машинного кода Swift до 1.5х по сравнению с Objective-C и в конечном итоге снова увеличили лимит в 200 МБ. У нас оказалось достаточно времени, чтобы продержаться еще несколько лет.
Однако мы почти проиграли. Если бы Apple не повысила предел, нам пришлось бы переписать приложение Uber обратно в Objective-C. В конце концов мы смогли исправить и другие проблемы. Гениальный Alanzeino и его команда добавили поддержку Swift в BUCK, что значительно сократило время сборки.
По пути сгорела куча людей. Было потрачено много денег, извлечены тяжелые уроки, но по сей день большинство людей настаивает, что переписывание того стоило. Новым инженерам, которые присоединялись к команде, нравилась архитектурное единообразие, но они никогда не понимали, какой ценой оно получилось.
Все сообщество извлекло пользу из наших знаний. Ellsk1 подготовила потрясающую презентацию и отправилась в турне, чтобы поделиться своими знаниями. После того, как я ушел, я смог поделиться своим опытом и научить другие команды принимать лучшие решения.
Итак, мой совет. Все в компьютерных науках — это компромисс. Нет универсального лучшего языка. Что бы вы ни делали, поймите, почему вы идете на компромисс. Не позволяйте этому перерасти в политическую войну между категоричными фракциями.
Спроектируйте возможные точки отказа. Выясните, как найти компромисс и выход, если вы дошли до определенной точки и осознали, что совершили ошибку. Сложно отказаться от больших уже вложенных усилий, но чем дольше вы идете на неверный компромисс, тем дороже они обходятся.
Не будьте скрягой, которая не способствует решению проблемы. Не будьте фанатиком, который создает большие проблемы для всех остальных. Лучшие инженеры, с которыми я когда-либо работал, действительно хорошо умеют не попадать ни в одну из этих ловушек.
Спасибо всем, что отправились со мной в это путешествие. Это был скорее терапевтический рассказ. Доброй ночи!