Головна / Статті

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

Клозули-захисники, імена, що відображають намір, межі 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;
}

Це працює, але операція, яка насправді має значення, знаходиться на третьому рівні глибини, а оператор 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 чи будь-якій іншій мові.

    Ключові висновки

    • Після завершення роботи над функцією, моделлю, інтеграцією чи запитом на зміни потрібно не лише перевірити, чи все працює, а й з’ясувати, чи наступний розробник може безпечно її змінити.
    • Клозули захисту, точні назви та точні типи зменшують кількість інформації, яку потрібно запам’ятовувати.
  • Межі та чисті функції прийняття рішень обмежують масштаб поширення змін та полегшують їх тестування.
  • Стабільні коди помилок та структуровані журнали без конфіденційної інформації перетворюють збої на корисну інформацію.
  • Маленькі зміни, призначені для однієї мети, легше перевіряти, скасовувати та довіряти їм.