Кодовой базе iOS-приложения Medium уже более 10 лет, и у нас все еще есть код 2013 и 2014 годов, который используется до сих пор.
Здесь вы можете подумать, почему, черт возьми, они никогда не переписывали его полностью?
На самом деле в iOS-команде Medium мы носим это как почетный знак, и сегодня я хочу объяснить, почему рассказываю о том, как эффективно работать с устаревшим кодом и одновременно не мешать ему (слишком сильно).
Эффективная работа с legacy кодом
Я считаю книгу Майкла К. Фезерса 2004 года «Эффективная работа с legacy кодом» незаменимым чтением и очень удобным справочником для любого инженера, стремящегося быть прагматичным программистом.
Состоящая из двух частей, первая из которых более теоретическая (почему меняется программное обеспечение, как моделировать изменения и т.д.), а вторая — очень практическая, где каждая глава представляет собой собственный небольшой сценарий «рефакторинга», эта книга — то, к чему я очень часто возвращаюсь за советом.
В Medium мы сталкиваемся с такими сценариями практически постоянно. Иногда с очень старым унаследованным кодом, написанным людьми, которые внесли свой вклад и давно ушли. Они использовали паттерны и сталкивались с ограничениями, которых больше не существует в современной iOS-разработке.
Давайте рассмотрим некоторые из этих реальных сценариев и то, как мы к ним подходим.
Изменяя программное обеспечение
У меня мало времени, но я должен это изменить
Код — это ваш дом, и вы должны в нем жить.
Один из самых устаревших кусков кода, на котором сейчас основано приложение Medium для iOS, — это наш рендерер историй. Он был частично переписан в 2019 году, что привело к своим сложностям, которые я не буду описывать здесь, но в нем сохранился тот же движок рендеринга UIKit и общий дизайн, поскольку он связан с тем, как мы моделируем контент и метаданные историй.
Благодаря такому моделированию наша страница истории состоит из трех отдельных частей:
- заголовок, содержащий метаданные об истории (название, автор и т.д.)
- собственно контент, состоящий из текста, изображений, медиа и т.д.
- нижний колонтитул, содержащий дополнительные метаданные (связанные темы и т. д.), но в основном рекомендации о том, что читать дальше
Хотя содержание самой истории никогда не меняется, и мы довольствуемся этим унаследованным кодом, нам часто приходится внедрять новые или совершенствовать существующие функции в верхнем и нижнем колонтитулах страницы.
Чтобы делать это без необходимости изменять старый код, обрабатывающий содержимое истории, мы используем технику “ростки”, описанную в книге(«проращивание» метода или классаа):
Когда вам нужно добавить в систему функцию, которую можно сформулировать полностью в виде нового кода, напишите код в новом методе. Вызывайте его из тех мест, где должна быть новая функциональность. Возможно, вам не удастся легко протестировать эти точки вызова, но, по крайней мере, вы сможете написать тесты для нового кода.
Совсем недавний пример использования возможностей “проращивания” — когда нам понадобилось внедрить несколько новых разделов рекомендаций в нижний колонтитул страницы истории, чтобы показать пользователям больше историй из списка, к которому принадлежала текущая история. Учитывая имеющуюся у нас возможность создавать пользовательские интерфейсы вне рендерера страницы истории и затем встраивать их, мы смогли создать весь этот раздел рекомендаций в SwiftUI (и в собственном модуле SPM), а затем встроить его в рендерер UIKit в качестве нового элемента нижнего колонтитула.
Я не понимаю код настолько хорошо, чтобы изменить его
Каждый человек время от времени сталкивается с демонами, которых он не может победить.
Еще один очень распространенный пример того, как нам приходится иметь дело с устаревшим кодом, — это когда что-то работает правильно, но нам все равно нужно это изменить.
Так случилось пару лет назад с нашей работой с темным режимом. Приложение Medium для iOS поддерживало свою собственную внутреннюю функцию темного режима еще до того, как iOS официально поддержала ее. Это привело к созданию очень сложной и перегруженной кодом системы, в которой каждый элемент пользовательского интерфейса, поддерживающий режим Dark, должен был подписываться на события изменения внешнего вида и реагировать на них, «вручную» обновляя все свои цвета. Работу этой системы можно представить следующим образом:
На схеме выше все красные поля представляют собой части системы, которым необходимо знать, какой внешний вид выбран в данный момент (светлый или темный).
Когда Apple наконец представила официальную поддержку темного режима на iOS 13 в 2019 году, команда Medium решила построить эту поддержку поверх существующего пользовательского дизайна, показанного выше. Струтктура стала такой:
Эта диаграмма довольно похожа на предыдущую, за исключением возможности реагировать на общесистемные изменения внешнего вида. И, как видите, большая часть системы по-прежнему должна знать текущий выбранный внешний вид (все красные поля).
Мы прожили с этой «гибридной» системой пару лет, что привело к нескольким проблемам с пользовательским интерфейсом из-за рассинхронизации между общесистемным внешним видом и внешним видом в приложении, а также к большому количеству обслуживания и дополнительной работы (каждая новая функция должна была явно подписываться на эту систему и поддерживать ее).
Это было не идеально, и мы решили это изменить. Я удовлетворю ваше любопытство, показав вам наш текущий дизайн ниже, но моя цель — рассказать о процессе, с помощью которого команда, не участвовавшая ни в одной из этих двух итераций, смогла составить четкое представление об этой функции, что затем легло в основу того, как мы хотим ее рефакторить.
Когда чтение кода становится запутанным, стоит начать рисовать картинки и делать заметки. […]
Не бойтесь потратить время на то, чтобы набросать, как, по вашему мнению, работает существующая система, пока вы ее изучаете. Ваши первоначальные предположения могут оказаться неверными, и вы вернетесь и отредактируете свой набросок, но после нескольких итераций у вас сформируется прочное и действенное понимание. И тогда вы будете чувствовать себя гораздо увереннее, когда будете менять код этой функции.
Итак, вот как выглядела система темного режима в итоге, когда мы решили принять поддержку iOS и полагаться на нее, а не на собственный код:
Обратите внимание, как мало у нас красных квадратиков. iOS делает большую часть тяжелой работы, что привело к значительному сокращению кода, поддержке темного режима по умолчанию для любой новой функции и отсутствию ошибок десинхронизации.
У моего приложения нет структуры
Архитектура слишком важна, чтобы поручать ее нескольким людям.
Как уже говорилось в предыдущей статье моего коллеги Томаса Рикуарда (см. “Эволюция архитектуры iOS-приложения Medium”), если вы хотите, чтобы ваша кодовая база стала более структурированной и оставалась таковой, запуск многомесячного проекта «реархитектуры», вероятно, не самая лучшая идея. Вместо этого постарайтесь создать минимальную структуру, которая побудит любого члена команды следовать этой структуре и дополнять ее, не ограничивая их слишком сильно.
Для нас это означало предоставление шаблона для модуляризации в виде наших локальных пакетов Swift.
Сегодня любой инженер в iOS-команде, работающий над рефакторингом существующей или созданием новой функции, найдет в нашей модульной архитектуре как стимул для создания модуля (потому что мы сделали это проще и быстрее, чем писать код в нашем унаследованном монолите), так и ряд примеров того, как это сделать, на основе наших существующих модулей.
Благодаря этому у нас даже нет политики, согласно которой вы не должны писать новый код в унаследованном монолите, потому что мы знаем (с учетом того, как наш проект устроен, чтобы подталкивать вас к модульности), что если кто-то решит писать в унаследованном монолите, то у него будет очень веская причина для этого. И поверьте мне, это редко случается.
Этот класс слишком большой, и я не хочу, чтобы он становился еще больше
Структура, которая есть в вашем приложении, работает. Она поддерживает функциональность; просто она может быть не настроена на движение вперед.
С таким сценарием мы сталкиваемся почти каждый раз, когда нам нужно потрогать код в нашем унаследованном монолите, просто потому, что он наполнен гигантскими классами, смешивающими множество вещей в очень больших файлах. Я уверен, что это звучит знакомо.
Например, так было полтора года назад, когда мы хотели добавить новые параметры в экран настроек подписки на push-уведомления в приложении, что означало необходимость переработать функцию как на уровне API (внедрение новых конечных точек GraphQL), так и на уровне пользовательского интерфейса.
Предыдущий экран настроек работал корректно, но был полностью встроен в очень большой класс Settings, обрабатывающий множество различных вещей.
Используя возможность легкого создания модулей, мы извлекли существующий экран настроек push-уведомлений в собственный модуль, убедились, что все по-прежнему работает корректно, а затем приступили к его рефакторингу и реструктуризации. Для этого мы использовали технику, описанную в “Эффективная работа с устаревшим кодом” как “Видя возможности”
В реальных случаях, когда речь идет о больших классах, главное — определить различные обязанности, а затем придумать способ постепенного перехода к более целенаправленным обязанностям. […] На самом деле нет большой разницы между обнаружением ответственности в существующем коде и формулированием ее для кода, который вы еще не написали. […] Если уж на то пошло, унаследованный код предоставляет гораздо больше возможностей для применения навыков проектирования, чем новые функции.
Как только эти обязанности были выявлены и извлечены, это позволило нам шаг за шагом продвигаться вперед по пути рефакторинга, не задумываясь о том, какими безумными способами наши изменения могут повлиять на остальной класс унаследованных настроек, потому что он уже не мог этого сделать.
Как узнать, что я ничего не сломаю?
Большинство материалов, из которых можно делать вещи, […] устают. Они ломаются, когда вы используете их в течение долгого времени. Код — другое дело. Если вы оставите его в покое, он никогда не сломается. […] Единственный способ получить ошибку — отредактировать его.
Я оставил этот сценарий напоследок, потому что он один из моих любимых.
Чаще всего, пытаясь работать над устаревшим кодом, мы оказываемся в ситуации, когда часть устаревшего кода, которую мы хотим заменить, используется во множестве мест для различных сценариев использования. Это особенно верно, когда этот унаследованный код является зависимостью (например, сторонней библиотекой), от которой мы пытаемся избавиться.
Это случилось с нами несколько месяцев назад, когда, пытаясь прекратить использование Cocoapods в кодовой базе Medium на iOS, нам нужно было избавиться от библиотеки под названием KSDeferred, которая представляет собой асинхронную библиотеку Objective-C, вдохновленную JS Promises.
Мы хотели сделать горячую замену этой библиотеки простым асинхронным кодом на основе блоков завершения непосредственно в коде Objective-C, ссылающемся на нее. Для этого мы использовали два моих любимых приема из книги «Эффективная работа с устаревшим кодом»:
- Сохранение сигнатур: этот прием лучше всего использовать в системах, где у вас нет тестов для безопасного изменения кода, но вместо этого вам нужно немного рефакторить, чтобы сделать систему достаточно тестируемой. Благодаря ей горячая замена становится значительно менее подверженной ошибкам.
- Опора на компилятор: главное в этой технике то, что вы позволяете компилятору направлять вас к тем изменениям, которые вам нужно сделать. В конце концов, зачем делать лишнюю работу, которую за вас может сделать машина?
В нашем примере с удалением KSDeferred это означало две вещи: во-первых, подготовить наш новый код, чтобы он имел ту же сигнатуру, что и код, использующий библиотеку, а во-вторых, отвязать библиотеку, чтобы компилятор жаловался, а мы знали все места, где нам нужно перейти и поменять ссылки на KSDeferred на наши собственные новые.
Вот пример того, как применение этих двух техник выглядело для данного проекта.
Изначально код, использующий KSDeferred, выглядел примерно так: метод, возвращающий проис, и вызывающая сторона, обрабатывающая его:
// Async method returning a promise - (KSPromise *)deferSomeWork { KSDeferred *deferred = [KSDeferred defer]; // do some work, potentially return a deferred error. return deferred.promise; } // Caller expecting a promise - (void)someOtherMethod { [[self deferSomeWork] then:^id(id value) { // Everything went well, read the value and continue } error:^id(NSError *error) { // Handle deferred error }]; }
Сначала мы использовали Сохранение сигнатур, чтобы создать аналогичный метод без использования промиса KSDeferred (обратите внимание, что объявление этого метода выглядит иначе, но способ его вызова сохраняется):
// New async method using a completion block - (void)deferSomeWorkThen:(void (^)(id value))thenCallback error:(void (^)(NSError *error))errorCallback { // do some work if (someError != nil) { errorCallback(error); } else { thenCallback(someValue); } }
Теперь, имея этот метод, мы можем просто удалить оригинальный метод — (KSPromise *)deferSomeWork
, и компилятор точно укажет нам, где заменить его на наш новый, показав ошибки!
Итоговый код выглядит следующим образом (обратите внимание, насколько он похож на старый, что помогает избежать ошибок):
// Caller, now without promise - (void)someOtherMethod { [self deferSomeWorkThen:^id(id value) { // Everything went well, read the value and continue } error:^id(NSError *error) { // Handle error }]; }
Вуаля! Если вы никогда не пробовали эти техники, я советую вам попробовать: они дают четкие рекомендации, помогающие избежать ошибок, и приносят огромное удовлетворение.
Переписывать или не переписывать
Я не просто так отложил этот вопрос до конца. Чаще всего первая мысль, которая приходит команде в голову, когда она сталкивается с устаревшим кодом, — это то, что мы должны просто переписать все с нуля.
Если вы дочитали до этого места, то для вас не будет сюрпризом, что мы, iOS-команда Medium, считаем переписывание с нуля крайним решением, которое мы почти никогда не используем, и это по двум основным причинам:
- Читать код сложнее, чем писать его, как очень четко выразился Джоэл Спольски в своей статье «Вещи, которые вы никогда не должны делать«, которую я советую вам прочитать.
- Переписывая проекты, очень легко попасть в ловушку невозвратных затрат.
Вместо этого в нашей команде мы стараемся сосредоточиться на всех способах сохранения унаследованного кода, перерабатывая его части или даже обходя его. Таким образом, мы узнаем все больше и больше о тонкостях и небольших нюансах этого куска кода и, когда мы достаточно его разберем или поймем, тогда мы рассмотрим возможность переписать то, что осталось, и/или все целиком, используя более современные части, которые мы ввели со временем.
Как пишет Майкл К. Фезерс в предисловии к книге “Эффективная работа с унаследованным кодом”:
Хороший дизайн должен быть целью для каждого из нас, но в унаследованном коде он является чем-то, к чему мы приходим отдельными шагами.
И это то, с чем я хотел бы оставить вас: не стоит недооценивать силу и эффективность дискретных шагов.