Skip to content

Latest commit

 

History

History
319 lines (167 loc) · 39.4 KB

File metadata and controls

319 lines (167 loc) · 39.4 KB

Рефакторинг на максималках

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

Из доклада вы узнаете:

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

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

История коммитов

Каждый коммит в этом репозитории — один этап рефакторинга приложения. Вся история коммитов отражает процесс рефакторинга в целом.

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

Добавляем приложение с «грязным» кодом. Приложение — это корзина интернет-магазина. В корзине находится список товаров, поле ввода купона на скидку и кнопка отправки заказа.

Этот коммит будет отправной точкой. Код из него мы приведём в порядок к концу истории.

Определим рамки рефакторинга

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

Это нужно по двум причинам:

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

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

В нашем случае рамки рефакторинга — это юзкейс оформления заказа.

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

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

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

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

Начнём с простого — с форматирования. Чем унифицированнее кодовая база, тем проще в ней ориентироваться.

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

Бывает, что Prettier ломает работу кода, когда, например, переносит что-то на новую строку. Чтобы такого не случилось, мы проверяем, проходят ли написанные ранее тесты.

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

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

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

Когда встречаются сущности с непонятными именами, нам стоит выяснить, за что они отвечают.

Как правило, «непонятное имя» — это сигнал о слабом понимании предметной области или проблемах с разделением кода и выделением слоёв абстракции (об этом позже).

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

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

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

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

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

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

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

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

Об иммутабельности мы поговорим ещё позже. Этим же коммитом решим первую проблему — объявим переменную ближе к месту её использования.

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

Декларативный код — такой, который рассказывает, что он делает. Императивный же рассказывает, как он что-то делает.

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

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

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

(Особенно это опасно, если код мутабелен. Изменение одной переменной может случайно задеть другую с таким же именем.)

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

Мы можем, например, заменить методом .includes() старую функциональность, которая имитировала его работу. Или, как в нашем случае, не обрабатывать два отдельных события (клик по кнопке и нажатие Enter в поле), а использовать событие отправки формы.

Браузерная FormData избавляет от написания кучи лишнего кода. (И скорее всего решает задачу лучше наших костылей.)

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

Бизнес-логика — самое главное в приложении. Чем проще она написана, тем проще её тестировать, проверять и изменять.

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

Чтобы не смешивать бизнес-логику и побочные эффекты, я пользуюсь принципом организации кода, который называется «Функциональное ядро в императивной оболочке». (Или как это называет Марк Зиманн — Impureim Sandwich.)

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

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

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

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

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

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

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

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

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

Отделяем «служебный» код от остального.

Работа с API — утилитарная задача, которая не относится напрямую к оформлению заказа. Мы можем назвать это «сервисным» кодом, а сущность, которая этим будет заниматься, — сервисом.

Функции оформления заказа не нужно знать детали того, как «сервис отправки данных» будет отправлять данные. Единственное, что ей надо знать — что при вызове функции makePurchase данные отправятся.

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

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

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

Часто бывает, что две «казалось бы одинаковые» сущности ведут себя «почти одинаково», но деле они абсолютно разные. Лучше сперва понаблюдать за ними и объединить их позже, если потребуется.

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

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

Улучшаем декларативность, используя Math.min.

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

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

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

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

Состояния и преобразования сущностей продиктованы бизнес-процессами и событиями в них. Работая с такими функциями нам проще соотнести преобразования в реальном мире и в коде.

Также мы избавляем создание заказа ото всех сайд-эффектов и делаем так, чтобы функция createOrder только возвращала новый заказ. Так мы делим функции, которые возвращают значения, от функций, которые производят сайд-эффекты. Это называется разделением на команды и запросы, Command-Query Separation. CQS помогает предупредить нежелательные изменения данных и сделать ожидания от функций прозрачнее и надёжнее.

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

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

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

Расцепляем функциональность «оформления заказа» и «товаров». (А если не расцепляем, то как минимум делаем зацепление заметнее с помощью прямых импортов.)

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

Снова расцепляем функциональность разных модулей: «корзины» и заказа».

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

Прорабатываем жизненный цикл сущности «заказа».

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

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

В этом случае все функции преобразования данных — это запросы из CQS. Мы не меняем созданный объект заказа order, чтобы избежать нежелательных или неконтролируемых сайд-эффектов. Вместо этого мы преобразуем данные в новое состояние («заказ со скидкой») и возвращаем новый объект.

Такой подход не даёт данным «случайно попасть» в невалидное состояние и вызвать из-за этого ошибку.

Вместо switch мы используем словарь, в котором ключ — это купон, а значение — величина скидки.

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

(Этот код ещё и надёжнее, потому что в словаре невозможно сделать ошибку с пропущенным break.)

При проверке статуса мы пару раз сравниваем его с idle и пару раз — с loading. Нам будет проще увидеть паттерны в условиях, если мы вынесем эти проверки в переменные и понятно назовём их.

Упрощённое условие помогло увидеть, что вместо запутанной проверки и else-ветки можно «вывернуть» условие и сперва обработать эту самую else-ветку.

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

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

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

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

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

Обработка ошибок — тоже функциональность, детали реализации которой бизнес-логике не важны. Мы можем вынести отлов ошибок в «сервис». Это сделает проверку декларативной, расцепит функциональность и уменьшит возможное дублирование.

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

Используем result-контейнер для упрощения работы с отказными случаями. Теперь мы точно будем знать, в каком формате ожидать ответ от «небезопасных» функций.

Пример контейнера в этом репо — игрушечный и не «канонический». Я рекомендую изучить ваш проект и посмотреть, не используется ли уже какая-либо реализация контейнера. Также стоит поглядеть на уже существующие решения, такие как fp/ts, перед написанием контейнеров с нуля.

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

Это ещё сильнее расцепляет код и позволяет заменять сервис как на другой сервис в продакшене, так и на моки во время тестов.

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

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

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

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

Заключение

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

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

Об авторе

Саша Беспоясов, консультант в 0+X. Пишу код больше 10 лет. Веду технический блог, менторю начинающих разработчиков.