Дизайн на API: договор, версии, повторени заявки
Накратко: Доброто API е договор: ресурси, методи, статуси и полета, на които клиентът разчита; ключ за идемпотентност прави повторената заявка безопасна, а версиите добавят, без да чупят.
Проблем
Платено два пъти
Клиентът плаща поръчка 1001 от мобилното приложение. Приложението праща заявка към API-то на магазина, сървърът взима 120 €, но връзката прекъсва, преди отговорът да стигне. Приложението не знае дали е платило. Затова прави това, което прави всеки разумен клиент: опитва пак.
Сървърът взима парите така:
class PaymentApi implements Api { final Payments payments = new Payments();
public Response handle(Request req) { return payments.charge(req.order(), req.amount()); }}А приложението опитва до три пъти:
static Response pay(Api api, int order, int amount) { String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, null, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("опит " + attempt + ": " + res); return res; } System.out.println("опит " + attempt + ": отговорът се губи"); } return null;}Мрежата е записан сценарий: отговорът на първото извикване се губи. Сървърът е свършил работата, губи се само отговорът.
var api = new PaymentApi();pay(api, 1001, 120);System.out.println(api.payments.report());опит 1: отговорът се губиопит 2: 201 платено 1001 (pay-2)плащания: 2, общо 240 €Клиентът вижда „платено“ веднъж, а от картата му са излезли 240 €. Сървърът няма как да познае, че втората заявка е същото плащане: за него това е второ плащане. Изгубен отговор не е рядкост: мобилна мрежа в метрото, асансьор, таймаут на прокси.
Идеята
API-то е договор
API-то е обещание към клиентите: на какви адреси какво има, какво приема и какво връща. Клиентът е написан срещу това обещание, често от друг екип или друга фирма, и не може да се промени заедно със сървъра. Това обещание е договорът на API-то.
В HTTP API договорът се строи от няколко части:
- ресурс е нещо с адрес:
/orders/1001е поръчка,/orders/1001/paymentsса плащанията ѝ. Адресът назовава нещо, не действие:/payments, не/makePayment; - методът казва какво се прави с ресурса:
GETчете,POSTсъздава,PUTзаписва цялото,PATCHпроменя част,DELETEтрие; - статусът казва как е минало:
200наред,201създадено,400грешна заявка,404няма такъв ресурс,409конфликт,422заявката е разбрана, но не може да се изпълни,5xxгрешката е в сървъра; - полетата на заявката и отговора, с имената и типовете им.
GET, PUT и DELETE са идемпотентни по правилата на HTTP: втори път дават същия резултат. Затова клиентите и проксита ги повтарят спокойно. POST не е: всеки POST създава ново нещо. Плащането е POST, затова повторението му взима пари пак.
Как изглеждат HTTP заявка и отговор, видя в урока за мрежата.
Виж го
Заявка, отговор и мрежа в паметта
Тук няма HTTP сървър. API-то е обект с метод handle: получава заявка, връща отговор. Заявката носи метод, път, ключ и сума, а отговорът носи статус и текст:
record Request(String method, String path, String key, int amount) { // /orders/1001/payments → 1001 int order() { return Integer.parseInt(path.split("/")[2]); }}
record Response(int status, String body) { public String toString() { return status + " " + body; }}
interface Api { Response handle(Request req);}Мрежата пуска заявката до API-то и по сценарий губи отговора:
// записан сценарий: на кои извиквания отговорът се губиclass Network { private final Set<Integer> lost; private int calls = 0;
Network(Integer... lost) { this.lost = Set.of(lost); }
Response send(Api api, Request req) { calls++; Response res = api.handle(req); // сървърът е свършил работата; губи се само отговорът return lost.contains(calls) ? null : res; }}Плащанията помнят сумите:
class Payments { private final List<Integer> amounts = new ArrayList<>();
Response charge(int order, int amount) { amounts.add(amount); int n = amounts.size(); return new Response(201, "платено " + order + " (pay-" + n + ")"); }
String report() { int sum = amounts.stream().mapToInt(Integer::intValue).sum(); return "плащания: " + amounts.size() + ", общо " + sum + " €"; }}Идеята
Ключ за идемпотентност
Сървърът трябва да различи „същото плащане, опитано пак“ от „ново плащане“. Сам не може: двете заявки изглеждат еднакво. Затова клиентът му казва. Преди първия опит клиентът прави уникален ключ за идемпотентност (обикновено UUID) и го праща с всеки опит, в заглавката Idempotency-Key.
Сървърът пази „ключ → отговор“. Ако ключът е нов, сървърът върши работата и запомня отговора. Ако ключът вече е виждан, сървърът не върши нищо и връща запазения отговор. Клиентът получава същото, което щеше да получи първия път.
Това е идеята на идемпотентния потребител от урока за събитията, обърната към клиента: там потребителят помни id-то на събитието, тук сървърът помни ключа на заявката.
Виж го
Повтореното плащане с ключ
Клиентът прави ключа веднъж, преди цикъла:
static int keys = 0;
static Response payWithKey(Api api, int order, int amount) { // един ключ за всички опити; в истински клиент UUID String key = "k-" + (++keys); String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, key, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("опит " + attempt + ": " + res); return res; } System.out.println("опит " + attempt + ": отговорът се губи"); } return null;}Сървърът проверява ключа, преди да взима пари:
class IdempotentApi implements Api { final Payments payments = new Payments(); // ключ → отговорът, който вече е даден final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) { if (req.key() == null) return new Response(400, "без Idempotency-Key"); Response saved = done.get(req.key()); if (saved != null) return saved; var res = payments.charge(req.order(), req.amount()); done.put(req.key(), res); return res; }}Същият сценарий: първият отговор се губи.
var api = new IdempotentApi();payWithKey(api, 1001, 120);System.out.println(api.payments.report());опит 1: отговорът се губиопит 2: 201 платено 1001 (pay-1)плащания: 1, общо 120 €class IdempotentApi implements Api { final Payments payments = new Payments(); // ключ → отговорът, който вече е даден final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) { if (req.key() == null) return new Response(400, "без Idempotency-Key"); Response saved = done.get(req.key()); if (saved != null) return saved; var res = payments.charge(req.order(), req.amount()); done.put(req.key(), res); return res; }}static Response pay(Api api, int order, int amount) { String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, null, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("опит " + attempt + ": " + res); return res; } System.out.println("опит " + attempt + ": отговорът се губи"); } return null;}class PaymentApi implements Api { final Payments payments = new Payments();
public Response handle(Request req) { return payments.charge(req.order(), req.amount()); }}static int keys = 0;
static Response payWithKey(Api api, int order, int amount) { // един ключ за всички опити; в истински клиент UUID String key = "k-" + (++keys); String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, key, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("опит " + attempt + ": " + res); return res; } System.out.println("опит " + attempt + ": отговорът се губи"); } return null;}class IdempotentApi implements Api { final Payments payments = new Payments(); // ключ → отговорът, който вече е даден final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) { if (req.key() == null) return new Response(400, "без Idempotency-Key"); Response saved = done.get(req.key()); if (saved != null) return saved; var res = payments.charge(req.order(), req.amount()); done.put(req.key(), res); return res; }}Стъпка по стъпка, с думи
- Сценарий 1, без ключ: телефонът праща POST /orders/1001/payments за 120 €.
- Сървърът взима 120 € (pay-1) и отговаря с 201.
- Връзката прекъсва: отговорът се губи по пътя. Телефонът не знае дали е платил.
- Телефонът опитва пак със същата заявка.
- Сървърът не може да познае, че е същото плащане: взима още 120 € (pay-2). Общо 240 €.
- Сценарий 2, с ключ: клиентът прави ключа k-1 веднъж и го праща с всеки опит.
- Ключът е нов: сървърът взима 120 € и запомня k-1 → отговора.
- Отговорът пак се губи.
- Повторният опит носи същия ключ k-1.
- Сървърът намира k-1 и връща запазения отговор pay-1, без да взима пари пак.
- Без ключ: 2 плащания, 240 €. С ключ: 1 плащане, 120 €.
Вторият опит получава pay-1, същия отговор като първия, а парите са взети веднъж. Без ключ сървърът отказва с 400: плащане без ключ не може да се повтори безопасно, затова това API не го приема. Някои API-та (Stripe например) приемат и заявки без ключ, но тогава не обещават нищо при повторение.
Пробвай
Намери грешката
Колега пренарежда клиента и ключът попада в цикъла. API-то е същото, с ключа:
опит 1: отговорът се губиопит 2: 201 платено 1001 (pay-2)плащания: 2, общо 240 €Пак две плащания. На кой ред е грешката?
Избери реда с грешката.
Ключът се прави при всеки опит, затова вторият опит носи k-2. За сървъра това е нова операция и той взима пари пак. Ключът назовава плащането, не опита: прави се веднъж, преди цикъла, и се пази, докато плащането не получи отговор. Истински клиент го пази дори след рестарт на приложението.
Идеята
Нова версия, без да чупиш стари клиенти
Мобилното приложение от миналия месец е още на хиляди телефони и няма да се обнови днес. Всяка промяна на API-то трябва да работи и с него. Може ли промяната да счупи стар клиент? Провери договора на отговора: всяко старо поле трябва да е там, със същия тип.
// договорът на отговора: име на поле и типrecord Field(String name, String type) {}
static String check(List<Field> old, List<Field> next) { List<String> problems = new ArrayList<>(); for (Field f : old) { Field now = next.stream() .filter(n -> n.name().equals(f.name())) .findFirst().orElse(null); if (now == null) problems.add("липсва " + f.name()); else if (!now.type().equals(f.type())) problems.add(f.name() + ": " + f.type() + " → " + now.type()); } if (problems.isEmpty()) return "✓ не чупи"; return "✗ чупи: " + String.join(", ", problems);}var id = new Field("id", "string");var status = new Field("status", "string");var v1 = List.of(id, status, new Field("amount", "number"));var added = new ArrayList<>(v1);added.add(new Field("currency", "string"));var renamed = List.of(id, status, new Field("total", "number"));var retyped = List.of(id, status, new Field("amount", "string"));System.out.println("добавено поле currency: " + check(v1, added));System.out.println("amount стана total: " + check(v1, renamed));System.out.println("amount стана текст: " + check(v1, retyped));добавено поле currency: ✓ не чупиamount стана total: ✗ чупи: липсва amountamount стана текст: ✗ чупи: amount: number → stringПравилата, които излизат от това:
- добавяй, не махай. Ново поле в отговора е безопасно: старият клиент не го чете. Махнато, преименувано или с друг тип поле чупи;
- в заявката е обратното. Ново задължително поле в заявката чупи старите клиенти, защото не го пращат. Ново поле трябва да е по избор, със стойност по подразбиране;
- клиентът да е толерантен. Клиент, който пада при непознато поле или нова стойност в списък от статуси, чупи сам себе си. Клиентът чете само каквото му трябва и пропуска останалото;
- чупещата промяна е нова версия.
/v2/ordersдо/v1/orders, и двете работят, докато старите клиенти се изместят. Старата версия се обявява за остаряла с дата, после се спира.
Идеята
Страниране: offset или курсор
Поръчките са хиляди, а приложението показва по 20. Най-простото страниране е offset: „прескочи първите 40, дай 20“. Курсорът е другият начин: „дай 20 след поръчка 1007“. Курсорът е последното id от предишната страница, а сървърът го връща в отговора, за да поиска клиентът следващата страница.
// поръчките, най-новите първиstatic List<Integer> orders = new ArrayList<>(List.of(1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001));
static List<Integer> byOffset(int offset, int limit) { return orders.stream().skip(offset).limit(limit).toList();}
// курсорът: последното id от предишната страницаstatic List<Integer> afterCursor(int cursor, int limit) { return orders.stream().filter(id -> id < cursor).limit(limit).toList();}Клиентът чете първата страница, а между двете страници идва нова поръчка:
var page1 = byOffset(0, 3);System.out.println("стр. 1: " + page1);orders.add(0, 1010);System.out.println("идва нова поръчка 1010");System.out.println("offset, стр. 2: " + byOffset(3, 3));int cursor = page1.get(page1.size() - 1);System.out.println("cursor, стр. 2: " + afterCursor(cursor, 3));стр. 1: [1009, 1008, 1007]идва нова поръчка 1010offset, стр. 2: [1007, 1006, 1005]cursor, стр. 2: [1006, 1005, 1004]С offset поръчка 1007 идва два пъти: новата поръчка е избутала всичко надолу. С курсор втората страница започва точно след 1007, каквото и да е дошло отгоре.
Курсорът е бърз и в базата. OFFSET 10000 кара базата да прочете и изхвърли 10 000 реда, преди да върне 20. WHERE id < 1007 ORDER BY id DESC LIMIT 20 отива направо на мястото през индекса. Цената на курсора: не можеш да скочиш на страница 7 и броят на страниците не идва наготово.
В истинско API курсорът е непрозрачен низ (например "eyJpZCI6MTAwN30", кодирано {"id":1007}), за да не разчита клиентът на вътрешността му и сървърът да може да я смени.
Пробвай
Предвиди изхода
Сега между двете страници поръчка 1008 е изтрита.
Последният ред на изхода е:
var page1 = byOffset(0, 3);System.out.println("стр. 1: " + page1);orders.remove(Integer.valueOf(1008));System.out.println("поръчка 1008 е изтрита");System.out.println("offset, стр. 2: " + byOffset(3, 3));След изтриването 1007 е втора, 1006 е трета, а 1005 е четвърта. Offset 3 прескача 1009, 1007 и 1006, затова 1006 не се показва на никоя страница. Изтриване по време на четене прескача ред, вмъкване повтаря ред. Курсорът „след 1007“ щеше да върне 1006, 1005, 1004.
Идеята
Кога това вреди
Ключ за идемпотентност струва таблица с ключове, проверка на всяка заявка и чистене (ключовете обикновено се пазят около ден). Не го слагай:
- на заявки, които вече са идемпотентни.
GET,PUTсъс стойност,DELETEповторени не правят второ нещо; - където второто изпълнение не вреди. Двойно „маркирай като прочетено“ е същото като единично.
Курсорът е излишен, когато списъкът е малък и почти не се мени (категориите на магазина) или когато хората наистина скачат на страница 7 (таблица в админ панела). Там offset е по-прост и честен.
Версия на API-то е излишна, докато всички клиенти са в същото хранилище и се пускат заедно със сървъра: тогава ги промени заедно. Версиите са за клиенти, които не контролираш. Всяка поддържана версия е код, който трябва да тестваш и пазиш, затова не прави v2 за промяна, която може да е съвместима.
И не гони „чист REST“ на всяка цена. POST /orders/1001/cancel е по-ясно от изкуствен ресурс „отказ“. Договорът трябва да е ясен и стабилен, не красив.
Как се пуска API gateway пред услугите (TLS, ограничаване на заявките, маршрути) е тема на DevOps курса ZeroToOps.
По-точно, за любопитните
Стандартите. Заглавката Idempotency-Key е описана в чернова на IETF („The Idempotency-Key HTTP Header Field“): същият ключ с друга заявка е 422, а повторение, докато първата заявка още се изпълнява, е 409. Stripe, Adyen и PayPal ползват ключове за плащанията си. Грешките в JSON обикновено следват RFC 9457 (Problem Details): type, title, status, detail.
Договорът като файл. OpenAPI описва пътищата, полетата и статусите в YAML. От него се генерират клиенти и документация, а инструменти като oasdiff сравняват две версии и казват коя промяна чупи, точно като check от урока. Pact проверява договора от страната на клиента (consumer-driven contract tests).
По езици. Spring Boot: @RestController, а springdoc-openapi прави OpenAPI от кода. Spring Data има Slice и Pageable за offset и ScrollPosition за курсор.
Не само REST. GraphQL има един адрес и клиентът казва кои полета иска, затова добавянето на полета е естествено, а курсорите са в стандарта (Relay connections). gRPC описва договора в .proto файлове с номерирани полета: ново поле с нов номер не чупи, преизползван номер чупи.
Курсор с повторения в ключа. Ако подреждаш по дата, две поръчки могат да имат една и съща дата. Курсорът тогава е двойката (дата, id), иначе редове се губят на границата между страници.
Упражнение
Същият ключ, друго плащане
Реши задачата в редактора си.
Клиент с бъг праща ключ k-1 за плащане от 120 €, после същия ключ за плащане от 90 €. Сега сървърът връща запазения отговор за 120 €, а клиентът мисли, че е платил 90 €. Промени IdempotentApi така, че да помни и заявката: същият ключ със същата заявка връща запазения отговор, а същият ключ с друга заявка е 422. Пусни три заявки (120 €, пак 120 €, 90 €) и отчета:
201 платено 1001 (pay-1)201 платено 1001 (pay-1)422 ключът k-1 е за друга заявкаплащания: 1, общо 120 €Още задачи
- Изтичане. Пази при ключа и тик на създаване. Ключ, по-стар от 100 тика, се забравя, а заявката с него е нова.
- Отговор на грешка. Ако плащането хвърли грешка (картата е отказана), запомни ли се отговорът? Реши и обоснови: повторение след
402трябва ли да опита пак? - Курсор по дата. Направи поръчките двойки (дата, id) с две поръчки на една дата и страница от 1. Покажи, че курсор само по дата губи поръчка, а курсор (дата, id) не.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Stream · record, switch и шаблони · Мрежово програмиране · Събития и опашки
- Продължава в: ADR: записани архитектурни решения · Когато съседът е бавен: timeout, retry, circuit breaker · Транзакция през няколко услуги (saga) · Интеграция със стари и чужди системи
- Задачи по темата: Софтуерни архитектури