Поширені помилки API та способи їх уникнення

18 хв. читання

Поради в цій статті стосуються будь-якого API. Однак деякі проблеми, які ми розбиратимемо, легше розглянути, коли програма написана динамічною мовою, наприклад, JavaScript, у порівнянні з більш статичною мовою, наприклад, Java.

Node.js — єднальна ланка, яка підтримує архітектуру, орієнтовану на систему, завдяки легкій взаємодії між багатьма backend сервісами та «зшиванні» цих результатів разом. Тому приклади, які ми розглянемо, будуть написані на JavaScript з Node.js.

Будьте вибіркові з даними

Коли у вас є об'єкт, який буде використовуватися з боку API відповіді, — дуже легко доставити кожен атрибут об'єкта. Насправді, зазвичай, простіше відправити весь змінений об'єкт, аніж вирішувати, які атрибути додавати або видаляти. Розглянемо ситуацію, коли у вас є користувач із платформи соціальних медіа. Можливо, у вашому застосунку об'єкт виглядатиме наступним чином:

{
  "id": 10,
  "name": "Thomas Hunter II",
  "username": "tlhunter",
  "friend_count": 1337,
  "avatar": "https://example.org/tlhunter.jpg",
  "updated": "2018-12-24T21:13:22.933Z",
  "hometown": "Ann Arbor, MI"
}

Припустімо, що ви створюєте API, і вам необхідно вказати лише наступне: ідентифікатор користувача, ім'я користувача, ім'я читабельне людиною, а також його аватар. Проте надіслати повний об'єкт користувачеві API набагато простіше, оскільки можна просто зробити наступне:

res.send(user);

Тоді ж як надсилання строго запитаних атрибутів користувача виглядатиме наступним чином:

res.send({
  id: user.id,
  name: user.name,
  username: user.username,
  avatar: user.avatar
});

Ви навіть можете виправдати це рішення: «Ми й так вже маємо дані, комусь вони можуть знадобитися!». Проте в майбутньому з цим можуть виникнути проблеми.

По-перше, розгляньте формат зберігання даних, який використовується для цих даних, і подумайте про те, наскільки легко отримати сьогодні дані і як це може змінитися завтра. Можливо, наші дані повністю зберігаються в одній базі даних SQL. Дані, необхідні для відповіді з цим User об'єктом, можуть бути отримані за допомогою одного запиту, що містить підзапит. Можливо, це виглядатиме приблизно так:

SELECT * FROM users,
  (SELECT COUNT(*) AS friend_count FROM user_friends WHERE id = 10)
    AS friend_count
  WHERE id = 10 LIMIT 1;

Проте рано чи пізно ми оновимо механізми зберігання нашого застосунку. Friendships можуть бути переміщені в окрему графову БД. Останній оновлений час може зберігатися в короткотривалій БД у пам'яті. Дані, які ми спочатку вирішили запропонувати користувачеві, тому що їх було легко отримати, стали дуже важкими для доступу. Єдиний ефективний запит тепер повинен бути замінений на три запити на різні системи.

Потрібно завжди дивитися на бізнес-вимоги та визначати який абсолютний мінімальний обсяг даних може бути наданий, що відповідає цим вимогам. Що користувачеві API дійсно потрібно?

Можливо, ніхто, хто використовує цей API, насправді не потребує friend_count і updated полів. Але, як тільки поле було надане в API відповіді, хтось захоче його використати для чогось. Як тільки це станеться — вам назавжди доведеться підтримувати це поле.

Це настільки важливе поняття в програмуванні, що воно навіть має свою назву: Вам це не знадобиться (You aren't gonna need it — YAGNI) . Завжди залишайтеся вибіркові з даними, які ви надсилаєте. Розв'язання цього питання, як і інші питання, може бути реалізовано шляхом подання даних з чітко визначеними об'єктами.

Відображення даних як чітко визначених об'єктів

Відображаючи дані як чітко визначені об'єкти, тобто створюючи з них клас JavaScript, ми можемо уникнути декількох проблем при проектуванні API. Це те, що багато мов приймають як належне — приймання даних з однієї системи, і обов'язкове перетворення їх в екземпляр класу. З JavaScript і, зокрема, Node.js, цей крок зазвичай пропускається.

Розглянемо простий приклад, коли Node.js API отримує дані з іншої служби та передає далі через відповідь:

const request = require('request-promise');
const user = await request('https://web.archive.org/web/20230402091819/https://api.github.com/users/tlhunter');
res.send(user);

Які саме атрибути передаються? Проста відповідь: всі, незалежно від того, якими вони можуть бути. Що станеться, якщо один з атрибутів, які ми отримали, має неправильний тип? Або якщо цей відсутній атрибут є життєво необхідним для користувача? Сліпо відправляючи атрибути, API не має контролю над тим, що отримав користувач послуги. Тому коли ми запитуємо дані та конвертуємо їх в об'єкт, зазвичай використовуючи JSON.parse(), то на виході ми отримаємо POJO (Plain Old JavaScript Object). Такий об'єкт є і зручним, і ризикованим.

Замість цього, відобразімо ці об'єкти як DO (Domain Object). Ці об'єкти вимагатимуть, аби ми застосували певну структуру до об'єктів, які ми отримали. Вони також можуть бути використані для забезпечення того, що атрибути існують і мають потрібний тип, інакше API може не виконати запит. Такий Domain Object для нашого User може виглядати приблизно так:

class User {
  constructor(user) {
    this.login = String(user.login);
    this.id = Number(user.id);
    this.avatar = String(user.avatar_url);
    this.url = String(user.html_url);
    this.followers = Number(user.followers);

    // Don't pass along
    this.privateGists = Number(user.private_gists);

    if (!this.login || !this.id || !this.avatar || !this.url) {
      throw new TypeError("User Object missing required fields");
    }
  }

  static toJSON() {
    return {
      login: this.login,
      id: this.id,
      avatar: this.avatar,
      url: this.url,
      followers: this.followers
    };
  }
}

Цей клас просто витягує атрибути зі вхідного об'єкта, перетворює дані в очікуваний тип і видає помилку, якщо дані відсутні. Якщо ми хочемо зберегти екземпляр нашого User DO в пам'яті, то не здійснивши подання всього POJO, ми в результаті будемо використовувати менше RAM. Метод toJSON() викликається коли об'єкт перетвориться в формат JSON і дозволяє нам, як і раніше, використовувати простий res.send(user) синтаксис. Маючи можливість відповідати помилкою, ми завжди впевнені, що дані, які надсилаються, є коректними. Якщо API є внутрішнім для нашої структури і вирішує надавати поле з користувацьким email, то наш API не розмістить випадково цю інформацію в публічному доступі.

Обов'язково переконайтеся, що ви використовуєте ті ж самі Domain Objects в API відповідях. Наприклад, ваш API може відповідати використовуючи User об'єкт верхнього рівня, коли робить запит для конкретного користувача, а також масив User об'єктів під час запиту списку друзів. Використовуючи той самий Domain Object в обох ситуаціях, користувач послуги може послідовно десеріалізувати ваші дані в їх власному внутрішньому поданні.

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

Використовуйте назви атрибутів, які є сумісними знизу догори

При іменуванні атрибутів об'єктів у ваших API відповідях обов'язково назвіть їх так, аби вони були сумісні з будь-якими оновленнями, які ви плануєте зробити в майбутньому. Одна з найгірших речей, яку ми можемо зробити для API, — випустити backwards-breaking change. Як правило, додавання нових полів до об'єкта не порушує сумісність. Клієнти можуть просто ігнорувати нові поля. Зміна типу або видалення поля призведе до втрати зв'язку, тому їх слід уникати.

Розглянемо приклад нашого об'єкта User знову. Можливо, сьогодні наша програма просто надає інформацію про розташування за допомогою простого рядка City, State. Але ми знаємо, що хочемо оновити нашу службу, аби надати більш детальну інформацію про місцезнаходження. Якщо ми назвемо атрибут hometown і збережемо лише рядок інформації, то ми втратимо можливість легко вставити детальнішу інформацію в майбутній реліз. Тому аби бути сумісним наперед, ми можемо зробити одну з двох речей.

Перший варіант, швидше за все, порушить принцип YAGNI. Ми можемо надати атрибут, який User назвав hometown. Він може бути об'єктом з атрибутами city і municipality. Цей документ може виглядати приблизно так:

{
  "name": "Thomas Hunter II",
  "username": "tlhunter",
  "hometown": {
    "city": "Ann Arbor",
    "municipality": "MI"
  }
}

Другий варіант менш імовірно порушує принцип YAGNI. У цій ситуації ми можемо використовувати ім'я атрибута hometown_name. Потім, у майбутньому оновленні, ми зможемо надати об'єкт із назвою hometown, який міститиме детальнішу інформацію. Це добре, тому що ми підтримуємо зворотну сумісність. Якщо ж раптом компанія вирішить більше не надавати цю детальнішу інформацію, то ми ніколи не застрягнемо з hometown об'єктом. Проте, ми назавжди застрягли як з атрибутом hometown_name, так і з hometown атрибутом. А користувач при цьому застряг, намагаючись з'ясувати, що саме використовувати:

{
  "name": "Thomas Hunter II",
  "username": "tlhunter",
  "hometown_name": "Ann Arbor, MI",
  "hometown": {
    "city": "Ann Arbor",
    "municipality": "MI",
    "country": "US",
    "latitude": 42.279438,
    "longitude": -83.7458985
  }
}

Жоден із варіантів не є досконалим, та все ж багато відомих API використовують той чи інший підхід.

Уніфікація понять і атрибутів

Як уже було згадано вище: Node.js — єднальна ланка між багатьма сервісами, яка утримує їх разом. Швидкість написання та розгортання на сервері Node.js застосунків не має собі рівних.

Загальним шаблоном є те, що великі компанії глибоко у своїй інфраструктурі матимуть численні сервіси. Наприклад, Java застосунок для пошуку та C# сервіс із даними, що зберігатимуться в SQL. Потім з'являються front-end розробники, і їм потрібні дані з обох сервісів, об'єднані в одному HTTP-запиті, так аби їхній мобільний застосунок залишався швидким. Але ми не можемо просто попросити C# чи Java створити сервіс тільки для front-end розробників. Такий процес був би повільним і не відповідав обов'язкам команд вищого рівня. Саме в такій ситуації Node.js і приходить на допомогу. Front-end розробник може досить легко створити сервіс, який використовує дані з обох систем і об'єднує її в один запит.

При створенні сервісу, який поєднує дані з декількох служб — фасаду API — ми повинні розкрити API, який є узгодженим всередині себе та є послідовним у порівнянні з відомими «іменниками», що використовуються іншими службами.

Наприклад, можливо, Java сервіс використовує camelCase , а C# сервіс використовує PascalCase. Створення API, що відповідає комбінацією обох випадків, призведе до проблематичного досвіду розробників. Будь-який користувач сервісу повинен буде постійно звертатися до документації для кожної кінцевої точки. При тому, що кожна система регістру, навіть snake_case, чудово працює сама по собі. Тому вам потрібно вибрати одну і дотримуватися лише її.

Інша проблема, яка може статися, полягає в тому, що різні служби використовують різні іменники для посилання на дані. У якості іншого прикладу, Java сервіс може посилатися на об'єкт, як company , у той час, як C# сервіс може посилатися на неї, як organization. Коли таке трапляється, спробуйте визначити який саме іменник, буде більш «коректно» відображати об'єкт. Можливо, ви створюєте API для громадського використання, і всі документи, що стосуються користувачів, відноситимуться до об'єкта як organization. У цьому випадку легко вибрати назву. В інших випадках вам потрібно буде зустрітися з іншими командами і узгодити це питання разом.

Також важливо нормалізувати типи. Наприклад, якщо ви використовуєте дані з MongoDB сервісу, то можете застрягнути в шістнадцяткових ObjectID типах. При використанні даних з SQL ви можете залишитися з цілими числами, які потенційно можуть стати дуже великими. Найчастіше безпечно посилатися на всі ідентифікатори як на рядки. У таких ситуаціях не має значення, якщо основні дані є шістнадцятковим «54482E» або base64 «VEg =» поданням двійкового або числа, представленого у вигляді рядка типу «13». До тих пір, поки тип, що використовується користувачем, є рядком — вони будуть задоволені.

Використовуйте позитивні, «щасливі» назви

Чи використовували ви коли-небудь API, де вони змішують «позитивні» та «негативні» назви атрибутів? Приклади «негативних» полів: disable_notification або hidden: false. Їхні «позитивні» протилежності — enable_notification або visible: true. Як правило, рекомендується вибрати будь-який підхід і використовувати лише його. Але коли мова йде про назви атрибутів, то краще використовувати «позитивні» варіанти.

Причина полягає в тому, що будучи розробником, легко заплутатися подвійними «негативами». Наприклад, гляньте на наступний атрибут і спробуйте засікти скільки часу буде потрібно, аби зрозуміти, що це означає: unavailable: false. Вам набагато швидше зрозуміти, що таке available: true. Ось деякі приклади «негативних» атрибутів, яких варто уникати: broken, taken, secret, debt. Ось їхні відповідні «позитивні» атрибути: functional, free, public, credit.

Однак, стосовно цього є певне застереження. Залежно від того, яка маркетингова стратегія застосовується стосовно продукту, може виникнути потреба у виборі негативних імен у ситуаціях, коли негативний посил є ясним і зрозумілим. Візьміть до уваги сервіс, який дозволяє користувачеві опублікувати оновлення статусу. Традиційно цей сервіс мав оновлення статусу видимого для всіх, але нещодавно запровадили концепцію приватного оновлення статусу.

Якщо слово public є позитивною версією, а private є негативною. Чому ж тоді всі маркетингові матеріали, які стосуються статутних постів, позначаються як private? Тому що в ситуації використання public: false поля в API оновлення статусу призведе до плутанини користувачів послуги, відповідно вони очікують атрибут private: true. Тому зрідка негативна назва атрибута є прийнятною. Але лише тоді, коли користувачі API очікують, що вони будуть названі саме так.

Застосовуйте принцип Robustness

Обов'язково дотримуйтесь Robustness Principle , де б він не задіювався до вашого API. Цитуючи з Вікіпедії, цей принцип означає:

Будьте консервативними в тому, що робите ви, і будьте ліберальними в тому, що ви приймаєте від інших.

Часто цей принцип ще перефразовують наступним чином:

Будьте консервативними в тому, що надсилаєте, і будьте ліберальними в тому, що отримуєте.

Найбільш очевидним є застосування цього принципу до заголовків HTTP. Відповідно до HTTP RFC, слова заголовків повинні починатися символом верхнього регістру і бути розділеними дефісами. Як приклад цього ми мали б Content-Type. Проте, технічно вони можуть бути будь-якої капіталізації і все ще бути прийнятними, наприклад, content-TYPE.

Перша частина принципу Robustness полягає у тому, аби бути консервативним. Це означає, що ви завжди повинні відповідати клієнту, використовуючи бажаний регістр шапки. Ви не можете знати напевно, що користувач вашого API здатний правильно читати, як добре відформатовані, так і нестандартно відформатовані заголовки. А ваш API повинен бути придатний до використання якомога більшою кількістю різних користувачів.

Друга частина принципу — бути ліберальним у тому, що ви приймаєте від інших. Це означає, що у випадку шапки HTTP, ви повинні нормалізувати кожну вхідну шапку у відповідний формат, аби можна було читати значення незалежно від регістру.

По можливості, доки не існує двозначності, візьміть до уваги підтримку принципу Robustness також із внутрішніми елементами вашого API. Наприклад, якщо ви очікуєте, що ваш API отримає username атрибут, а ви отримаєте Username атрибут, то чи дійсно буде якась шкода, якщо прийняти такий варіант?

Так, насправді може бути! Якщо ми можемо прийняти і Username, і username, то що ми будемо робити, коли одночасно отримаємо обидва? Заголовки HTTP мають визначену семантику для обробки дубльованих записів шапки запиту. Однак, JSON такої функції не має. Прийняття обох регістрів username може призвести до помилок, які важко виправити.

Що робити API, якщо він отримує атрибут неправильного типу, наприклад, рядок, коли очікувалося число? Можливо, це не така вже й біда, особливо, якщо наданий рядок є числовим. Наприклад, якщо ваш API приймає числовий width аргумент і отримує рядок "640", то важко уявити будь-яку неоднозначність у цій ситуації. Вирішуючи, які поля "примушувати" змінюватися з одного типу в інший, зважте всі переваги і недоліки. А також, обов'язково задокументуйте ситуації, коли ви виконуєте такі примусові зміни.

Перевірте всі помилки

Коли користувач взаємодіє з сервісом, то він очікує послідовно відформатовані відповіді на всі запити. Наприклад, якщо користувач регулярно передає і отримує інформацію у форматі JSON, то розумно очікувати, що він буде приймати будь-які відповіді та буде аналізувати вміст так, ніби він є у форматі JSON. У разі виникнення помилки, якщо відповідь не буде відформатована як JSON, то це призведе до проблеми у користувача. Існують всілякі цікаві випадки, які необхідно протестувати, аби запобігти цьому.

Розглянемо застосунок Node.js, написаний за допомогою Express. Якщо в обробнику запитів застосунок видає помилку, то експрес-сервер може відповісти Content-Type: text/plain та тілом, яке містить трасу стеку. Тепер ми зламали синтаксичний аналіз JSON користувачів. Зазвичай цьому можна запобігти, написавши проміжне програмне забезпечення, яке перетворює будь-які виявлені помилки в добре відформатовані JSON відповіді:

app.get('/', (req, res) => {
  res.json({
    error: false, // очікувана JSON відповідь
    data: 'Hello World!'
  });
});

app.get('/trigger-error', (req, res) => {
  // зазвичай повертається текст / звичайна траса стеку викликів
  throw new Error('oh no something broke');
});

// узагальнений обробник помилок
app.use((err, req, res, next) => {
  console.log(err.stack); // запис помилки
  res.status(500).json({
    error: err.message // відповідь помилкою JSON
  });
});

Якщо можливо, то створіть acceptance тести, які викликатимуть різні помилки та перевірятимуть відповіді. Створіть секретну кінцеву точку у вашій програмі, яка викидає помилку. Спробуйте завантажити файл, який є занадто великим, надішліть пакет даних із неправильним типом, надішліть неправильний JSON-запит тощо. Якщо ваш API не використовує JSON через HTTP, наприклад, сервіс gRPC, то, звичайно, необхідно знайти підхід для відповідного тестування.

Зробіть крок назад

У корпоративному середовищі дуже легко потрапити до шаблону, що дозволяє складній клієнтській бібліотеці обробляти всі взаємозв'язки з сервісом. Крім того, легко дозволити складній бібліотеці сервісу обробляти всю серіалізацію об'єктів у форматі, необхідному клієнту. З такою абстракцією компанія може опинитися в ситуації, коли ніхто не знає, як виглядають дані, які надсилаються мережею.

У таких ситуаціях кількість даних, що передаються по мережі, може вийти з-під контролю. Також збільшується ризик передачі особистої інформації (Personally Identifiable Information). І, якщо коли-небудь ваш API буде використовуватися публічно, то це може спричинити багато «болючого» рефакторингу для очищення.

Дуже важливо час від часу «робити крок назад». Перестаньте шукати API, використовуючи інструменти de facto. Замість цього, подивіться на API, використовуючи загальні продукти. При роботі з HTTP API, одним з таких продуктів є Postman . Цей інструмент корисний для перегляду сирих HTTP пакетних даних. Він навіть має зручний інтерфейс для ґенерації запитів і розбору відповідей.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація