У ефекту Даннінга-Крюгера можуть бути різні прояви. Наприклад, коли досконало опановуєш складний молоток, весь світ починає здаватися цвяхами. З’являється ілюзія, що цим складним інструментом треба вирішувати абсолютно всі задачі, а будь-яке спрощення сприймається як некомпетентність.
В інженерії зазвичай в кожної проблеми є декілька варіантів рішення, і в кожного рішення і відповідного інструменту є свої переваги і недоліки. Робота інженера полягає в тому, щоб обрати найбільш оптимальну комбінацію рішень, яка задовольняє вимоги при заданих вхідних умовах.
Ви вказуєте на проблему еволюції контракту, яка існує на різних рівнях — в бібліотеках, базах даних, АПІ контрактах різної природи (не тільки HTTP), тощо. І для цієї проблеми давно існують сталі рішення і практики за межами REST.
Ще складніше ситуація з оновленнями IoT клієнтів — часто це взагалі неможливо.
Я не є експертом в області IoT, але, наскільки мені відомо, кінцеві пристрої використовують «легкі» бінарні протоколи, бо там з наявними ресурсами дуже важко і треба економити.
Тим не менш, щоб остаточно зрозуміти, який саме сенс ви вкладаєте в HATEOAS — запропонуйте, будь ласка, API для взаємодії IoT сенсора з головною системою, через яку він має посилати дані телеметрії. І поясніть, яка має бути при цьому реалізація клієнта, щоб його не треба було оновлювати при зміні протоколу.
Думаю відповідь очевидна.
Неочевидна, є багато «але». Щоб не збивати фокус с більш принципових деталей, поки залишу без додаткових коментарів.
Оскільки сутність `job` передбачає якийсь процес, подовжений у часі, то він повинен передбачати можливість достатнього моніторингу його стану ззовні, тобто можливість оцінити його поточний та минулий стан.
Я не казав, що GET непотрібен, про три методи зі статті сказано «HTTP-ендпоїнти для керування виконанням вже створеного завдання виглядали б так». Ось вам повний набір методів як я його бачу:
POST /api/v1/jobs — створити нове
GET /api/v1/jobs/{id} — прочитати
PUT /api/v1/jobs/{id} — оновити властивості (які можна оновлювати)
POST /api/v1/jobs/{id}/start — почати виконання (в тому числі після призупинки)
POST /api/v1/jobs/{id}/stop — зупинити
POST /api/v1/jobs/{id}/pause — призупинити
DELETE /api/v1/jobs/{id} — видалити
Необхідність синхронізованої зміни стану, наприклад використовуючи оптимістичне блокування, вимагає якогось REST ресурсу, до якого ми могли б надіслати умовний запит, використовуючи заголовок `If: {ETAG}`.
Поясніть, будь ласка, для чого нам потрібне оптимістичне блокування на методах зміни стану (start, stop, pause).
еобхідність в `Vary:` заголовках зазвичай викликана неправильним дизайном REST ресурсів, через наявність ресурсів типу `/my-profile` замість використання канонічних ресурсів `/profiles/{id}`
Типова ситуація: є система де користувач має бачити список проєктів компанії, але через різний рівень пермісій кожен користувач бачить різний список проєктів. Чи правильно я вас розумію, що замість /projects мені треба зробити щось на кшталт /user/{id}/projects?
Мова про те, що HATEOAS, який є невід’ємною частиною архітектурного стилю REST, по факту використовується в дуже обмеженій кількості випадків. В його парадигмі клієнти не мають ніяких знань про схему доменної моделі наперед. Для більшості систем, які ми всі робимо, таке обмеження значно ускладнює реалізацію не надаючи помітних переваг. А тому і не бачу сенсу суворо дотримуватися практик, які були сформульовані саме для підтримки такої парадигми.
У висновках до статті я нещодавно додав, якого підходу в іменуванні ендпоїнтів я завжди дотримувався, щоб було зрозуміліше чи пропагандую я щось чи ні. Ніякої революції тут немає — просто констатація того, що вже давно є поширеною практикою.
GET — повністю актуальний бо навіть в прикладі що я навів саме так вони це і роблять, ви без цього ніяк. Щоб керувати станом вам майже 100% треба буде знати в якому зараз стані задача
DELETE — чому ні якщо треба відмінити задачу
POST — можливо не треба якщо ми не створюємо саму задачу, але не рідко його використовують як для створення так і для апдейта просто в залежності чи надається ІД чи ні виконуються різні дії.
Коли я підкреслив «для керування виконанням вже створеного завдання», я мав на увазі тільки методи які змінюють стан виконання.
DELETE в цьому прикладі не підходить для зупинки задачі, тому що зупинка і видалення — це різні операції. Після зупинки я хочу залишити задачу в історії, щоб можна було подивитися дані по завданню і якщо що — запустити нове з такими самими вхідними даними. Те на що вказуєте ви — це специфічний випадок, де зупинка/відміна еквівалентна видаленню.
Якщо що, я б робив повний набір методів для свого прикладу таким:
POST /api/v1/jobs — створити нове
GET /api/v1/jobs/{id} — прочитати
PUT /api/v1/jobs/{id} — оновити властивості (які можна оновлювати)
POST /api/v1/jobs/{id}/start — почати виконання (в тому числі після призупинки)
POST /api/v1/jobs/{id}/stop — зупинити
POST /api/v1/jobs/{id}/pause — призупинити
DELETE /api/v1/jobs/{id} — видалити
Питання на якому я фокусуюся в прикладі зі статті — чи має сенс ці три окремих методи (start/stop/pause) ховати за PUT/PATCH чи ні.
Те що далі може бути прорва роботи то інша тема, я лише про ту частину яка формує зовнішній інтерфейс.
Про те і мова, що зовнішній інтерфейс має бути якомога зрозумілішим і легким в розширенні.
Але ж в черзі у вас задачі будуть мати цілком конретні статуси щоб ваш код який їх виконує завжди знав на чому він зупинився і яка частина системи повинна далі з ним працювати.
Будуть мати статус, і це буде read-only властивість. Ми не просто встановлюємо статус «Running», ми наказуємо системі «Запустити процес». Згідно загальній логіці, статус «Running» — це лише наслідок успішного виконання цієї команди, а не вхідний параметр.
Формально я не можу сказати чи один підхід кращий за інший, але якщо ми додаємо якісь дії в path частину url це певною мірою розходиться з тим до чого люди звикли.
Про це і стаття, що «люди звикли», хоча на практиці ця звичка часто не несе практичної користі.
Та все що завгодно можна додати в body.
Коли ви робите PUT на якийсь ресурс, вказуючи перелік властивостей, то скоріш за все маєте на увазі, що у цього ресурсу такі властивості є, а не просто що у дії «оновити статус» є додаткові параметри. В найпростішому випадку можна було б сказати — «оскільки завдання можна зупинити один раз, то нормально мати властивість з причиною зупинки», і тоді ваш приклад був би валідним. Але, наприклад, завдання можна поставити на паузу багато разів, і бажано зберігати історію причин кожної зупинки. В цьому випадку ваш приклад не дуже підійде — бо у ресурсу є історія, в якій буде декілька переходів стану, і деякі з них — з причиною від користувача. Тобто властивості «причина» на самому ресурсі завдання не буде, а додавати її штучно в пейлоад для PUT — ще сильніше заплутує інтерфейс.
Є дії які можна зробити з сутністю, а є властивості. Для зміни властивостей нормально мати загальний метод на всіх, але для окремих дій краще виділяти окремі методи, бо у них, окрім всього іншого, можуть бути параметри і з плином часу їх список може розширюватися. Заміна явної дії оновленням властивості зменшує гнучкість і прозорість АПІ.
В натуральних мовах ми передаємо інформацію присудком (delete) + підметом (job) + «аргументами» (id=123).
В мовах програмування зводиться до того ж самого: чи то виклик методу deleteJob(id=123), чи то відправка меседжа об’єкту job.
В remote API задача та ж сама. REST-ish чи не RPC-ish — вже деталь навіть не реалізації, а «серіалізації» цих викликів.
Саме на це я і вказую у статті. Нюанс в тому, що іноді для слідування класичним рекомендаціям для RESTful API, деякі дії ховають за зміною властивостей об’єкту, що робить семантику взаємодії з таким сервісом менш зрозумілою.
Чи хочу я писати всраті OpenAPI-специфікації, якщо можу десь використати gRPC натомість? Майже ніколи.
Повністю згоден, стаття сфокусована саме на «класичних» HTTP API, тому gRPC та інші аналоги я не згадував. Щодо OpenAPI — досить часто достатньо генерити специфікації кодом бекенду автоматично, якщо серверний фреймворк таке дозволяє. Отримуєте і плейграунд, і можливість згенерити клієнти по специфікації не витрачаючи час на написання специфікації вручну. Трохи пізніше додам в статтю більш розгорнуту думку про OpenAPI, бо схоже тут мене багато хто неправильно зрозумів.
Чи візьму я REST-ish + OpenAPI замість того, щоб придумувати власний RPC-протокол? Майже завжди.
Чи є мені різниця, як виглядає протокол, якщо мені дають класно зроблений SDK під мою мову? Ні. Аби тільки продебажити можна було у разі проблем.
І тут я повністю згоден, і стаття цьому не протирічить :) Якщо вкажете які саме формулювання призвели до непорозуміння — буду вдячним.
Так саме про це і стаття, що витрачати зайві зусілля на спроби зробити «все по ресту» не має практичних переваг у більшості випадків.
Так я і не кажу про якісь з вказаних практик, що вони погані. Проблема саме в тому, що для розробки традиційних HTTP API, які ніколи не будуть використані в рішенні на базі HATEOAS, все одно витрачають час на «слідування традиціям». Щодо «складних систем» — це занадто неконкретне узагальнення. Я бачив щось схоже на HATEOAS в рішеннях для Data Governance, де можна навігувати графами доменних об’єктів і редагувати їх, але це дуже специфічний випадок. Також, з деякими нюансами, можу уявити як це спрацює в контент-порталах. Можна ще уявити сценарії, де кінцевий користувач взаємодіє з контентом через узагальнений UI, але в будь-якому випадку — це не мейн-стрим, тому не бачу в чому моє твердження «не має жодних переваг у більшості сценаріїв» некоректне.
Я не просто так ці «канони» взяв в лапки, але, тим не менш, у терміна є першоджерело. В ньому є набір архітектурних обмежень, але про прив’язку до HTTP нічого не говориться, тому що сам концепт високорівневий і protocol-agnostic. Відповідно, твердження
REST — це набір практик для HTTP API, не більше.
фактично некоректне.
А ось з цим твердженням погоджусь:
Те що ми розробляємо це не обов’зяквово API що реалізує паттерни REST, це HTTP API, просто ютубери, блоггери та інфобізнесмени вигадали поняття RESTfull, і просували його, що було не вірно.
І для розробки зрозумілого HTTP API яке легко використовувати і підтримувати, зовсім необов’язково слідувати усім «заповідям» які асоціюють з терміном RESTful, про що власне і стаття.
Я переписав речення у статті на «Найочевидніші HTTP-ендпоїнти для керування виконанням вже створеного завдання виглядали б так», щоб акцент був зрозумілішим. Ваші приклади POST/GET/DELETE тут нерелевантні, бо мова йде про керування виконанням.
Щодо
PUT /api/v1/jobs/{id} + {body} return {job + id} (оновлюємо задачу можливо переведеням на паузу)
Я саме про недоліки такого варіанту нижче в статті і пояснюю. В даному випадку зміна стану — це фізично не просто оновлення поля, а цілий набір дій з «виконавцем» — чи то системний процес, джоба в k8s, чи ще щось.
З точки зору клієнта який є чи користувачем, що *натискає кнопку* «старт» або «пауза», чи автоматизацією, якій треба виконати *дію*, це не оновлення властивості, а конкретна дія — «почати виконання», «призупинити виконання», тощо.
Тобто в вас і у клієнта це дії, і на сервері — окремі функції під кожен перехід стану. Нащо між ними додавати штучну абстракцію через оновлення властивості? Що це дає практично?
Інший аспект — наскільки очевидним буде такий АПІ для девелопера або для якого-небудь АІ агента. Коли читаєш окремі методи під типові дії — все видно відразу, а коли цілий набір доступних дій прихований під зміною властивостей — можуть виникати питання.
І ще одне — а що якщо нам знадобляться параметри у таких операцій — наприклад, причина зупинки чи паузи?
Як я розумію, первинна ідея HATEOAS полягала не в тому, щоб клієнт міг «інтуїтивно досліджувати» незнайомий API. Сенс був у тому, щоб клієнт працював із символічними лінками, які повертає бекенд, і саме назви цих лінків є частиною контракту. Натомість реальні URL-адреси за цими лінками не повинні бути для клієнта значущими — бекенд має право змінювати їх без необхідності оновлювати клієнт.
Якщо ви про «Хтось може поспорити, що HATEOAS усе ще актуальний, адже його динамічна структура, яка дозволяє клієнту самостійно орієнтуватися в API без знання специфікації, добре підходить для сучасних AI-агентів» — то це тільки приклад думки, яку я зустрічав як виправдання HATEOAS. Основні мої власні міркування про HATEOAS я написав в розділі про ресурси і вони не протирічать написаному вами.
Щодо використання пост-запитів з командами (POST на ендпоінт-команду, що описується дієсловом), то це також є частиною загальноприйнятої практики, що описується в багатьох керівництвах, наприклад: https://dotnet.rest/docs/bestpractises/post-vs-put/
Воно все так, але формально цей підхід протирічить «канонам REST», адже ендпоїнт стає не ресурс-орієнтованим, і тому не всі на нього погоджуються з релігійних міркувань. Саме тому я в статті хотів підкреслити, що це цілком нормальний варіант.
Щодо ресурсно-орієнтованості, то її основна перевага — зрозумілість для команд клієнтів, коли сервер не надає «клієнтську бібліотеку». Але це також залежить від того наскільки ви прив’язуєте ваше АПІ до РЕСТ як архітектурного стилю і як результат варіативність АПІ, яку ви хочете мати. Наприклад, чи хочете ви мати можливість віддавати дані у різних форматах (ХМЛ на додачу до ДжСОН).
Я не розумію що буде незрозумілого для команд клієнтів, якщо сервер буде виставляти ендпоїнти в RPC стилі, а не в ресурсному. Формати — взагалі ортогональне питання, можна керувати параметрами та заголовками незалежно від того ресурсно-орієнтована семантика чи RPC.
Одна крайність — це пуризм (в нашому випадку щодо РЕСТ), але інша — це відкидання всіх практик, як непотрібних, мотивуючи істуванням альтернативного підходу.
Я у висновках як раз і пишу «Ідея, що всі ендпоїнти повинні бути ресурсно-орієнтованими, не має жодних переваг у більшості сценаріїв». Тобто я не кажу що ресурсна орієнтованість це в цілому погана практика, а наголошую на тому, що якщо зручніше щось зробити без неї — go for it.
Але нижче Nikita Podgorbunskyi запропонував ресурсно-орієнтоване АПІ, яке мені дещо зрозуміліше ніж запропоноване вами. Я не бачу об’єктивних/вимірюваних критеріїв чому треба обрати те чи інше.
Тут я зрозумів що можливо недостатньо чітко окреслив скоуп прикладу. Я навів приклади ендпоїнтів тільки для стану виконання завдання (запуск, зупинка, пауза), а не всього життєвого циклу самого об’єкту (створити, прочитати, видалити). Завтра напишу детальніше, може статтю трохи оновлю.
А тепер хвилинка крику і болю: OpenAPI — гівно!!!
У нього є недоліки, але для мейнстрім АПІ зрілих поширених альтернатив небагато :-)
— Клієнт — це компайл тайм залежність. Клієнтська команда не може почати розробку поки не отримає бібліотеку.
Можна робити specification-first і генерувати бібліотеку для клієнта і стаби для сервера зі специфікації.
— Комбінації схем (oneOf, anyOf, allOf, not). Воно не працює нормально навіть в мовах де підтримуються такі типи, а в джаваподібних то взагалі пекло.
Є таке, тому іноді набір використаних конструкцій з цієї специфікації має сенс обмежувати.
Якщо не знаурюватися в різноманітні техніки вивчення нових слів та закріплення мовних конструкцій, я би додав ще пару порад:
Нарешті хоч хтось про це написав, дякую :). Все ніяк не знайду час написати статтю «Що не так з Реакт», в якій хотів пройтися по цих сталих практиках, що генерять складність на рівному місці. Ну і також по Redux за оверінжиніринг заради оверінжиніринга. На щастя, зараз вже у фронтовий код нечасто доводиться дивитися :).
Раджу ознайомитися з теорією парсингу виразів, принаймні базові речі типу рекурсивного спуску (recursive descent parser). На практиці ваше рішення для задачі з парсингом було б важко підтримувати і розширяти новими можливостями виразів. Ось як би я розв’язував цю задачу (тут можуть бути неточності, бо накидував швидко, але основна ідея така)
public class ExpressionEvaluator
{
public static bool Evaluate(string expression) => Evaluate(new StringReader(expression));
public static bool Evaluate(TextReader reader) =>
reader.Read() switch
{
-1 => throw new ArgumentException("Unexpected end of string"),
't' => true,
'f' => false,
'!' => EvaluateUnary(reader, a => !a),
'&' => EvaluateBinary(reader, (a, b) => a && b),
'|' => EvaluateBinary(reader, (a, b) => a || b),
var ch => throw new ArgumentException($"Unknown token \"{(char)ch}\"")
};
private static bool EvaluateUnary(TextReader reader, Func<bool, bool> predicate)
{
EnsureToken(reader.Read(), '(', "Missing opening bracket");
var result = predicate(Evaluate(reader));
EnsureToken(reader.Read(), ')', "Missing closing bracket");
return result;
}
private static bool EvaluateBinary(TextReader reader, Func<bool, bool, bool> predicate)
{
EnsureToken(reader.Read(), '(', "Missing opening bracket");
List<bool> values = [];
int token;
do
{
values.Add(Evaluate(reader));
token = reader.Read();
}
while (token == ',');
EnsureToken(token, ')', "Missing closing bracket");
if (values.Count < 2)
{
throw new ArgumentException("Not enough operands for a binary operator");
}
return values.Aggregate(predicate);
}
private static void EnsureToken(int token, char expectedToken, string errorMessage)
{
if (token == -1)
{
throw new ArgumentException("Unexpected end of string");
}
else if (token != expectedToken)
{
throw new ArgumentException(errorMessage);
}
}
}Є підозра що воно генерує нову перестановку бітів з числа що на вході. Якщо не помиляюся, кількість одиниць на вході і на виході виходить однаковою.
Почуйте мене, будь ласка. Володимир каже, що термін LINQ — це тільки про query syntax. Я кажу — що ні, не тільки. І цитати яки ви наводите з документації саме це і підтверджують. Тут суто термінологічне питання. Про що ми споримо?
Тобто ви підтверджуєте думку Володимира що LINQ — то тільки SQL-подібні експрешени? А екстеншен-методи Where і Select з бібліотеки System.Linq — то не LINQ? Бо його ствердження
Linq то трохи не про те
я розумію саме так. Чи костиль воно чи ні — то вже інше питання.
Мій комент був про цю частину ствердження Володимира:
Linq то трохи не про те. Linq то отой sql-подібний костиль,
Тому що в LINQ можна використовувати два вида синтаксису — або Query Syntax, або Method Syntax, і незалежно від обраного вами типа синтаксису це все одно буде LINQ. Бо LINQ — це не тільки Query Syntax, а цілий набір засобів в .NET. Можна ще тут більш загальне формулювання почитати — learn.microsoft.com/...en-us/dotnet/csharp/linq
но через симуляцію обміну мессаджами назвавши це «іншої реалізації по різному» )) профітЪ
Цей комент каже про те, що ти прирівнюєш поняття «асинхронна взаємодія» і «асинхронний обмін повідомленнями». І схоже весь твій потік демагогії, пересмикувань і намагань «підколоти» викликаний саме цим базовим непорозумінням. Просто уточню: стаття про обмін повідомленнями, а не про асинхронну взаємодію в цілому. Тут я можу тільки порадити Google та ChatGPT для з’ясування відмінностей, можеш ще англійською порівняти asynchronous communication та asynchronous messaging, щоб виключити «складнощі перекладу».
Це якби я написав статтю про плюси і мінуси вантажівок, де б зазначив, що тільки для того щоб возити дитину до школи вантажівка непотрібна, а ти б відповідав що я дебіл, бо вантажівка це теж машина, а возити дітей до школи на машині — це норм.
Нажаль, вести конструктивну дискусію ти чи не хочеш чи не вмієш. Робити припущення щодо причин не бачу сенсу, як і продовжувати розмову. Але удачі тобі в пошуках самоутвердження!
Accept-Encoding вирішує лише проблему ширини каналу. Для IoT пристрою вузьким місцем часто є не мережа, а CPU та RAM. Розпарсити стиснутий JSON, проаналізувати HATEOAS-посилання і вирішити, куди йти далі — це на порядок важча операція, ніж прочитати байти по фіксованому зміщенню в бінарному протоколі.
Фраза «краще ніж нічого» — це не інженерний аргумент. Щодо HATEOAS в телеметрії Ви так і не навели приклад, як саме клієнт має «адаптуватися». Якщо зміна протоколу вимагає перепрошивки пристрою, то HATEOAS там був лише зайвим вантажем.
Згадка вами MQTT дуже доречна — вона якраз підтверджує мою тезу: для специфічних задач ми беремо специфічні інструменти, а не намагаємося всюди використовувати REST.
І взагалі, коли у вас клієнту все одно необхідно мати якісь «домовленності» з сервером, будь-який формальний контракт буде кращим за його відсутність. Він дозволяє автоматизувати контроль зворотної сумісності, генерувати клієнти та сигналізувати розробникам клієнтів про deprecation частин контракту і т.д.
Ні, не дозволено. Зміна статусу є результатом дії а не її тригером.
Оптимістичне блокування призначене для запобігання неконсистентності збереженого стану, і тут зміна опису і зупинка завдання один одному не заважають, тому не потребують блокування між собою. Те що ви кажете про клієнт не є проблемою — якщо хтось інший натисне «Стоп» через мілісекунду після того як завершилася зміна опису, я все одно не побачу зміну статусу, якщо не відбудеться ручного чи автоматичного оновлення поточного стану на клієнті.
І створить проблему з розкриттям даних, про яку я кажу у статті: тепер знаючи ідентифікатори користувачів я можу ходити на відповідні URL, і якщо їх списки проєктів вже встигли закешуватися на якомусь вузлі, то я їх побачу без авторизації.