Главная / Статьи

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

Клаузулы защиты, имена, указывающие на намерение автора, границы API, точные типы, чистые функции принятия решений, структурированные ошибки и целенаправленные пул-запросы с примерами на JavaScript.

Издатель: REACTAPP.TOP10 мин чтения2220 слов

Источники: 7 Coding Habits That Separate Senior Developers From the Rest

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

Для формирования этих привычек не требуется новых инструментов. Это решения, которые вы принимаете при написании функции, присвоении имени переменной, моделировании данных или создании pull request.

1. Сохраняйте видимостью основной путь выполнения с помощью защитных условий

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

function performAction(userId) {
  const user = findUser(userId);
if (user) {
    if (user.isActive) {
      if (user.hasPermission) {
        return executeAction(user);
      }
    }
  }
  return null;
}

Этот код работает, но операция, которая действительно важна, находится на третьем уровне вложенности, а операция fallback return null в конце визуально не связана с условиями, приводящими к её выполнению.

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

function performAction(userId) {
  const user = findUser(userId);
    if (!user) return null;
      if (!user.isActive) return null;
      if (!user.hasPermission) return null;
      return executeAction(user);
}

Теперь основной путь выполнения кода выглядит логично: проверка, проверка, проверка, действие. Это не означает, что вложенная логика всегда неверна. Когда ветви представляют собой действительно разные результаты, а не просто предусловия, конструкция if/else может быть наиболее понятной формой. Цель состоит в том, чтобы не допустить, чтобы основная бизнес-логика оказывалась скрытой под условиями, которые на самом деле выполняют лишь функцию фильтрации.

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

2. Давайте названия в соответствии с их значением

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

const data = getOrder();
process(data);

Что такое data, и что делает с ним process? Чтобы узнать это, приходится читать другой код. Сравните:

const pendingOrder = getPendingOrder();
processOrder(pendingOrder);

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

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

handle(order);

в то время как в этой версии она описывается четко:

calculateOrderTotal(order);

Аналогично, простое использование send не указывает, что именно отправляется и кому:

send(order);

А описание в названии отвечает на оба этих вопроса:

sendOrderConfirmation(order);

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

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

3. Держите внешние системы за границей

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

const customer = await stripeCustomer.get();
console.log(customer.email_address);
console.log(customer.customer_name);

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

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

async function getCustomer(customerId) {
  const response = await externalService.getCustomer(customerId);
return {
    email: response.email_address,
    name: response.customer_name
  };
}

Остальная часть кода использует этот внутренний формат и не имеет отношения к названиям полей у поставщика:

const customer = await getCustomer(customerId);
console.log(customer.email);
console.log(customer.name);

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

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

4. Сделать недопустимые состояния трудно представимыми

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

type Order = {
  id?: string;
  paymentId?: string;
  status?: string;
};

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

if (order.id) {
  // ...
}
if (order.paymentId) {
  // ...
}
if (order.status === "paid") {
  // ...
}

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

Более точная модель описывает конкретное состояние:

type PaidOrder = {
  id: string;
  paymentId: string;
  status: "paid";
};

Значение типа PaidOrder гарантирует три вещи:

  • у него есть идентификатор
  • у него есть идентификатор оплаты
  • его статус точно равен "paid"

Теперь этот тип служит одновременно документацией и механизмом обеспечения безопасности во время компиляции. На практике для каждого состояния определяется отдельный тип — например, заказ в ожидании без идентификатора оплаты и уже оплаченный заказ с таким идентификатором, — после чего они объединяются в дискриминированный союз по полю status. TypeScript может автоматически сузить диапазон типов после проверки вроде order.status === "paid" и предупредить о случаях, когда новое состояние не обрабатывается. Пример реализации этого подхода смотрите по ссылке замена флагов загрузки на дискриминированный союз.

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

5. Разделяйте решения и действия

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

async function processUser(userId) {
  const user = await database.getUser(userId);
if (user.isVerified && user.age >= 18) {
    await database.enableFeature(userId);
    await emailService.send(user.email);
  }
}

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

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

function canUseFeature(user) {
  return user.isVerified && user.age >= 18;
}

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

async function processUser(userId) {
  const user = await database.getUser(userId);
  if (!canUseFeature(user)) {
      return;
    }
  await database.enableFeature(userId);
  await emailService.send(user.email);
}

Теперь правило можно тестировать напрямую с обычным объектом:

expect(
  canUseFeature({
    isVerified: true,
    age: 25
  })
).toBe(true);

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

Привычка: разделять вопрос «стоит ли это делать?» и вопрос «как это сделать?». Хорошие примеры для такого разделения включают:

  • проверки доступа и разрешений
  • расчёт цен
  • проверку входных данных
  • решение о необходимости уведомления кого-либо
  • политики повторных попыток
  • правила отбора кандидатов и активации функций

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

6. Сделайте ошибки полезными для людей и программного обеспечения

Подобная ошибка почти не предоставляет ничего для работы:

throw new Error("Something went wrong");

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

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

throw new AppError(
  "PAYMENT_FAILED",
  "The payment could not be completed."
);

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

{
  "code": "PAYMENT_FAILED",
  "message": "The payment could not be completed."
}

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

if (error.code === "PAYMENT_FAILED") {
  showPaymentError();
}

Логи требуют такого же подхода. Простое сообщение трудно использовать для действий в 3 часа ночи:

console.log("Payment failed");

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

logger.error("Payment failed", {
  orderId,
  provider,
  attempt
});

Тщательно подбирайте содержимое этого контекста. Никогда не записывайте:

  • пароли
  • токены доступа
  • ключи API
  • другие секреты
  • чувствительные личные данные

Если вам нужна стандартная форма тела ошибок HTTP, стоит ознакомиться с подробностями проблемы RFC 9457.

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

7. Сохраняйте фокус на каждой изменении

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

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

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

Разделив задачу на более узкие изменения, та же работа выглядит так:

PR #1 → Add checkout validation
PR #2 → Refactor payment service
PR #3 → Update checkout UI
PR #4 → Improve payment retry logic

Каждое из них легче:

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

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

    Общая идея: облегчить следующее изменение

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

    • Видимый «путь счастья» облегчает понимание логики
    • Значимые имена делают намерение очевидным
    • Границы вокруг внешних систем ограничивают влияние их изменений
    • Точные типы исключают целые категории ошибок
    • Разделение решений и действий позволяет тестировать бизнес-правила
    • Структурированные ошибки облегчают диагностику сбоев
    • Целенаправленные изменения делают кодовую базу легкой для развития

    Фреймворки и библиотеки появляются и исчезают, но эти принципы одинаково применимы в JavaScript, TypeScript, Python, Java, Go или любом другом языке.

    Основные выводы

    • После завершения работы над функцией, моделью, интеграцией или pull request задавайте себе вопрос не только о том, работает ли она, но и о том, сможет ли следующий разработчик безопасно внести в нее изменения.
    • Клозы безопасности, точные имена и точные типы сокращают объем информации, который читателю необходимо запоминать.
  • Границы и чистые функции принятия решений ограничивают распространение изменений и облегчают тестирование поведения.
  • Стабильные коды ошибок и структурированные логи без секретной информации превращают сбои в полезные данные.
  • Небольшие изменения с одной целью легче проверять, откатывать и доверять им.