Komunikace je základem dobrého vztahu! To víme. Stejné pravidlo můžeme aplikovat i na naše webové služby. To, jakým způsobem mezi sebou komunikují, představuje základní kámen naší architektury. Už dlouho proto používáme jako hlavní způsob komunikace REST API přes HTTP. Máme už s REST API poměrně dost zkušeností, přesto se jedna a tatáž otázka opakuje stále dokola: jakým způsobem zařídit, abychom snížili závislost mezi klientem a serverem? Jakým způsobem zařídit, abychom mohli vyvíjet API na serveru a neohrozili přitom klienta? Jak zařídit, aby klient a server sdíleli co nejméně dat?
Nultý pokus: neřeším a vše hardcoduju
Představte si, že máme server, který poskytuje toto API
GET /user
které nám vrací seznam všech aktivních uživatelů. A teď si představme, že máme nějakého klienta, třeba frontend, který potřebuje znát seznam uživatelů. Jakým způsobem má frontend zjistit, že seznam všech uživatelů má dostupný na adrese https://example.com/api/user a ne někde jinde? Nejjednodušší způsob je to zkrátka zahardcodovat do kódu frontendu a víc neřešit. Co se stane, pokud pak zjistíme, že máme špatně resource a že tam nemá být /user, ale /users? Změníme na server routu na
GET /users
a přesměrujeme /user na /users. Pak bychom měli jít a upravit kód FE, ať už se neptá staré routy. Nedá se to vymyslet tak, aby tento krok úpravy FE nebyl nutný?
První pokus: sdílená knihovna
Před osmi lety jsme se pokusili tento problém vyřešit sdílenou knihovnou. Všechny BE služby jsme měli v NodeJS, FE byl samozřejmě v JavaScriptu, tak jsme si řekli, že vytvoříme NPM modul, který bude obsahovat definice všech akcí, které server zvládá. Kdo chce s tímto API pracovat, tak si zkrátka nainstaluje knihovnu, která obsahuje všechny požadované informace k tomu, aby klient mohl server používat.
class GetUsersQuery {
this.routePattern = '/users';
this.method = 'GET';
}
U POST/PUT akcí jsme například mohli zapsat i validační schéma dat:
class SetCampaignNameCommand {
this.routePattern = '/campaigns/{id}/name';
this.method = 'PUT';
this.validationScheme = {
properties: {
id: {
type: 'string',
required: true
},
name: {
type: 'string',
required: true
}
}
};
}
Výhody:
- Bylo to vlastně super jednoduché. FE i BE aplikace si nainstalovali knihovnu, která obsahovala požadované definice a díky nim věděli, kde najdou požadované akce.
- Pokud jsme nějakou akci upravili, jen jsme aktualizovali knihovnu a postupně jsme releasli aplikace, které to API používaly.
- Díky těmto definicím jsme byli schopni později generovat třeba dokumentaci typu Apiari.
- Klient – v našem případě hlavně FE – mohl validovat data ještě předtím, než je odeslal na server, přičemž jsme měli jistotu, že server bude validovat data podle stejného schématu.
Použité řešení mělo i své mouchy:
- Releasovat všechny aplikace s každou změnou v deskriptoru akcií není zrovna vlhký sen každého vývojáře. Release navíc není atomický – stávalo se, že server s novými definicemi byl venku dříve než FE.
- Nebylo to úplně použitelné pro ostatní klienty mimo naši firmu, kterým jsme knihovny nechtěli poskytovat. Případně to ani nebylo použitelné v případě, kdy bychom chtěli použít jiný jazyk než JavaScript, třeba Javu.
- Sdílet kód mezi FE a BE před osmi lety byla vlastně docela výzva a nebylo to něco úplně běžného. Proto se občas stávalo, že někdo upravil knihovnu a přidal do ní kus kódy, který běžel jen na BE v NodeJS, ale neběžel na na FE a pak byl strašně překvapený, že to na FE nefungovalo.
Nešlo by to přeci jenom ještě lépe? Abychom nemuseli releasovat klienta pokaždé, když aktualizujeme API na serveru?
Intermezzo: Co je to REST?
Pojďme se na chvíli zastavit a povědět si něco o tom, co je to vlastně REST. Spousta lidí má za to, že REST je to, že nového uživatele POSTuju do kolekce /users (množné číslo!) a zpátky ho GETuji na adrese /users/123. Myšlenek za RESTem je ale mnohem více. Základem je disertační práce Roye Fieldinga, který REST popsal. Co nás bude v této části zajímat nejvíce, je Hypermedia as the engine of application state (HATEOAS). Co to znamená?
Když jako uživatel přijdete na http://seznam.cz , uvidíte v prohlížeči několik odkazů. Kliknutím se dostanete na další stránku, třeba na recenze aut, na které vyplníte a odešlete komentář, že Fordem tam, vlakem zpátky. HATEOAS je totéž, ale pro API.
Smyslem HATEOAS v REST API je, že vás API samo navádí, co tak můžete s tím daným API dělat. Příklad si vypůjčím z již odkázaného článku. Představte si, že chcete přes API ovládat toaster. Pošlete tedy požadavek
GET /toaster HTTP/1.1
na což dostanete odpověď
HTTP/1.1 200 OK
{
"id": "/toaster",
"state": "off",
"operations": [{
"rel": "on",
"method": "PUT",
"href": "/toaster",
"expects": { "state": "on"}
}]
}
Toaster vám říká, že je aktuálně vypnutý ("state": "off”
). To zajímavé je v poli operations
, ve kterém nám API říká, že jestli chceme toaster zapnout, máme poslat PUT request na adresu /toaster
s daty { "state": "on”
}. API nám napovídá úplně stejně jako hypertextové odkazy na http://seznam.cz . Naučíte-li klienta rozumět tomuto formátu, máte vyhráno. Serverové API diktuje klientovi, co lze s resourcy dělat a jak to lze udělat. Představte si ty možnosti – u každého resourcu můžete klientovi ukázat, že s ním lze dělat těchto dvacet operací. Jsou některé operace dostupné jen klientům s vyšším oprávněním? OK, klienti s nižším oprávněním uvidí pouze patnáct operací apod.
Člověk dokonce ani nemusí vymýšlet od nuly vlastní formát pro to, jakým způsobem vracet data o operacích. I na to už existují standardy typu JSON-LD, Hydra, Linked Data a mnoho dalších.
Druhý pokus: jak jsme chtěli, aby na nás byl Roy Fielding pyšný
Celí nadšení jsme si řekli, že zkusíme naimplementovat REST tak, jak má ve skutečnosti vypadat.
Naše představa byla, že bude existovat jeden “entry point” pro naše API sídlící na nějaké hlavní adrese typu example.com/api. Tady klient zjistí, co všechno může s naším API dělat, takže bychom vrátili něco ve smyslu “jestli chceš spravovat kampaně, GETni /api/campaigns; jestli chceš spravovat uživatele, GETni /api/users” apod. Klient by pak GETnul /api/users, tam bychom mu vylistovali všechny uživatele a u každého uživatele by byla informace typu “pro správu tohoto uživatele GETni /api/users/123”, kde by se klient mohl dovědět, že “jméno uživatele 123 se změní PUT requestem na /api/users/123/name” a tady by klient konečně poslal kýžený PUT request na změnu jména.
V naší představě by klient toto dělal pokaždé, když by chtěl něco dělat s našimi daty. Chceš změnit jméno jiného uživatele? OK, běž hezky od začátku: GET /api → GET /api/users → GET /api/users/124 → PUT /api/users/124/name. Zahlcení requesty jsme chtěli řešit HTTP kešováním.
V jednu chvíli jsme dokonce laškovali s představou, že bychom nepoužívali žádné čitelné adresy typu /api/users/123, ale že bychom vždy generovali náhodné a s každým requestem měnící se adresy typu “uživatele 123 najdeš na adrese /api/DF073897-E2C8-4881-B2A5-3EA40EFEA568”, abychom donutili klienty procházet všechny kroky vždy hezky od začátku. Nám to mělo dát možnost evolvovat API bez toho, aniž bychom se museli stresovat s tím, že nějakého klienta rozbijeme.
Výhodou také mělo být “proklikávací” API. Vize byla, že když v prohlížeči otevřete /api, tak vám to zobrazí to API jako normální HTML stránku a uvidíte tam odkazy na další routy. Takže byste si mohli kliknout a vidět tam odkazy na /api/users apod. API, které zároveň funguje jako dokumentace! Chceš vědět, co API umí? Si ho otevři v prohlížeči! Bude tam aktuální API a k němu aktuální dokumentace. Co víc byste si přáli?
Dost bylo představ, jdeme programovat. Uvařili jsme si kafe, rozjeli jsme firemní notebooky, usedli za klávesnice a po pěti minutách jsme si řekli, že je to moc složité a že na to prdíme.
Třetí pokus: snažili jsme se, ale…
Nebojte, ke sdílení knihoven jsme se nevrátili. Proběhlo rokování, jehož výsledkem bylo, že “úplné Fieldingovo REST API” je stále něco, co bychom chtěli, jen na to zrovna teď není tak úplně čas, takže zkusíme naimplementovat něco, co nás k našemu snu alespoň přiblíží. Něco, co nás dostane alespoň na půli cesty k cíli. Zhruba po vzoru tohohle obrázku…
…jsme se zkrátka rozhodli naimplementovat… řekněme kolo. A někdy časem zkusíme motorku nebo už třeba celé auto. Po měsíci práce jsme tak měli něco, čemu jsme říkali “Entry point”. Vytvořili jsme systém pojmenovaných akcí, například akce “ListUsers” reprezentuje “dotaz, který vrátí seznam uživatelů”. Celý entry point pak fungoval takto:
- Na hlavní adrese typu /api server vrátil klientovi informaci “pro data o akci ListUsers GETni /api/actions/ListUsers”.
- Na adrese /api/actions/ListUsers jsme pak vraceli informace o akci, cca v této podobě:
{
"endpoint": {
"method": "GET",
"route": "/users",
"responseType": "json"
},
"actionDataDescriptor": {
"queryData": {
"limit": {
"type": "Number",
"optional": true
}
}
}
}
Pokud chtěl klient získat seznam deseti uživatelů, musel poslat tři HTTP requesty: GET /api → GET /api/actions/ListUsers → GET /api/users?limit=10.
Výborně, máme hotovo! Pojďme se teď spolu podívat, jak naše kolo jezdí.
Jaké má výhody oproti koloběžce?
- Nemusíme releasovat nové verze klientů, pokud změníme deskriptor akce. Jsme schopni opravovat překlepy typu “ježiš já jsem tam releasnul /api/usrs/123, ale ono to mělo být /api/users/123” tak, že releasneme pouze novou verzi serveru. Tedy můžeme měnit API bez toho, aniž bychom museli upravovat klienty!
- Není to závislé na použité technologii! NPM knihovnu jsme mohli nainstalovat zase jen do JavaScriptového projektu. Entry point protokol jsme byli schopni použít třeba i v Javových projektech.
- Žádný sdílený kód mezi FE a BE. Vše se řídí daty.
- Pro klienta je použití poměrně jednoduché. Potřebuje znát jen URL entry pointu, název akce a data. V principu tak použití vypadá cca takto:
const entryPoint = new EntryPoint("https://example.com/api");
const apiAction = new ApiAction(“ListUsers”, {limit: 10});
entryPoint.execute(apiAction);
- Žádné velké cavyky, dokonce i to, že limit patří do query stringu lze pochopit z deskriptorů a není třeba to uvádět přímo v kódu, což je moc fajn!
Jaké má nevýhody?
Jenomže použité řešení mělo i své mou… no, to už snad ani nejsou mouchy, to jsou přímo sršně:
- Ve skutečnosti jsme byli schopni upravovat pouze malou část deskriptorů, aby to klient pochopil. V příkladě je uvedeno, že routa /users přijímá jako parametr limit. Jenomže kdybychom se rozhodli přejmenovat tento parametr na “Limit” a starý “limit” bychom zrušili, klient by si s tím neporadil. Klient by stále volal limit=10 a request by failoval. Musíme tak stejně jít a upravit kód klienta na
new ApiAction(“ListUsers”, {Limit: 10})
- Totéž nastane, když máme překlep v názvu akce ListUsrs.
- Nijak jsme nevynucovali, aby naše API bylo dostupné jedině skrze entry point. Výsledkem je, že někde existuje alespoň jeden klient, který má URL zahardcodovanou a nepoužívá entry point.
- My neposkytujeme našim zákazníkům žádné knihovny na práci s API. Pokud chtějí naše API používat, musí si svého klienta napsat sami. Jenomže to znamená, že když nás nějaký zákazník požádá o přístup k API, aby mohl volat “tuhle jednu routu na vytvoření uživatele”, tak si musí naimplementovat celý náš entry point protokol. Pro zákazníka je to pak rozhodování “zavolám jeden stupidní POST /api/users požadavek na této adrese, který dělá přesně co chci” vs. “naimplementuji stovky řádků kódu, aby mi entry point vrátil, že mám zavolat POST /api/users”.
- K tomu nápadu kešovat to na úrovni HTTP se nejvíce hodí tento starý vtípek: There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.
- Obecně to přidalo šílenou komplexitu na všech úrovních:
- V prohlížeči v konzoli vidíte hromadu requestů na entry point, které vás většinou vlastně moc nezajímají.
- Chtěli jsme, ať je pro klienta více různých BE služeb dostupno za jedním entry pointem. Reálně tak máme jednu službu, která funguje jako entry point, a za ní je několik dalších služeb, kterých se entry point ptá v realtimu na deskriptory.
- Takže když klient pošle request GET /api/actions/ListUsers, tak daná služba pošle další tři HTTP requesty na další tři služby, jestli znají akci ListUsers a až pak se vrací odpověď klientovi.
- Každá z těchto částí se samozřejmě může rozbít, každá z těch částí může vracet nějaké chyby, na které je třeba správně reagovat a tedy: každá z těchto součástek přidává komplexitu do celkového řešení.
- Dost vtipné bylo, když jsme zjistili, že i ta myšlenka “klient si přečte URL z deskriptoru” byla rozbitá. Totiž, my jsme tam nikdy nevraceli celou URL. My jsme tam vraceli pouze routu, na které je akce dostupná. Takže jsme našemu klientovi řekli, že entry point je dostupný na adrese example.com/api a on se pak dotázal na detail akce:
GET example.com/api/actions/ListUsers
{
"endpoint": {
"method": "GET",
"route": "/users",
"responseType": "json"
}
}
- A teď otázka pro vás: na jaké adrese je dostupná akce ListUsers? Každý klient si to totiž vyhodnotil trochu jinak ?
- Měli jsme klienta, který prostě vzal tu první základní adresu example.com/api a spojil ji s routou, tj dostal adresu example.com/api/users. ?
- Pak jsme dostali skvělý nápad, že na spojování adres přece musí existovat knihovny, tak jsme jednu našli a použili jsme ji: resolve(“example.com/api”, “/users”) nám ale vrátilo example.com/users (jako by to vyhodnocoval prohlížeč kdyby to byl cíl odkazu). ?
- Občas se nám tam vloudila chyba v konfiguraci a někdo místo example.com/api napsal jako URL entry pointu example.com/api/ (lomítko na konci). Po spojení jsme pak dostali example.com/api//users (dvě lomítka) a zase to nefungovalo. ?
- Jak jsme to vyřešili? Routy v České republice nechceme! Aby v tom měl klient jasno, tak jsme se rozhodli, že místo routy tam budeme cpát celou adresu. Usedli jsme za klávesnice a po měsíci programování nám začal entry point vracet cca toto:
{
"endpoint": {
"method": "GET",
"responseType": "json",
"url": "https://example.com/api/users"
}
}
- Pak už jenom upravit všechny klienty a job well done! ?
- “Tak rozjedeme frontend na druhé doméně, ne? To musí fungovat.” Vždycky, když programátor řekne, že to musí fungovat, tak to fungovat zaručeně nebude. Chtěli jsme, aby naše aplikace byly dostupné na dvou doménách současně, http://example.com a http://example2.com , ale aby reálně běžela na backendu jenom jedna instalace. Jenomže náš entry point mohl být nakonfigurovaný jen pro použití na jedné doméně. Takže když jste se zeptali na deskriptor akce na http://example2.com/api/actions/ListUsers , tak vám to stejně vrátilo, že ta akce je dostupná na http://example.com /api/users ???.
- Aby toho nebylo málo, tak nedopatřením osudu máme těch entry pointů víc. Máme produkt na správu kampaní, který se skládá z několika služeb a máme produkt na správu uživatelů, který se také skládá z několika služeb. No a každý produkt má svůj vlastní entry point. Původní myšlenka, že by byl jeden entry point pro všechny produkty se nějak vytratila. Máme jich třeba deset.
- Entry point protokol byl námi vymyšlený a nebyl to standard. To věci nepomáhá. OpenAPI/Swagger jsme znali, ale tou dobou ještě neuměl všechno, co bychom od něj požadovali, tak jsme se ho nakonec rozhodli nepoužít.
- Ale rozhodli jsme se, že už nastal čas na něj přejít, takže naše nejnovější produkty nemají entry point, ale vystavují OpenAPI deskriptory.
Poučení
Povedlo se nám naprogramovat “kolo”, jak jsme chtěli? Jako asi jo. Ale někdo mu nasypal písek do řetězu a prohodil brzdu se zvonkem. Entry point řeší některé problémy, umožňuje klientovi snadno vytvářet akce, ale spolu s tím přinesl entry point hromadu nových problémů, které není jednoduché opravit. Přitom to není prototyp, který testujeme čtyři měsíce, ale je to věc, která je v produkci už čtyři roky a spálili jsme na ní několik člověkoměsíců práce. A jaké z toho plyne ponaučení?
- Používat standardní řešení. I když OpenAPI nesplňovalo naše požadavky 100%, asi by to stále bylo lepší řešení než náš proprietární protokol.
- Předtím, než jsme začali Entry point implementovat, jsme si měli sednout a nadefinovat si přesně, co od něj chceme. Nadefinovat si API celého entry pointu a důkladně to promyslet.
- Co nám nedošlo je, že tenhle protokol bude používat každá naše služba a tedy že nebude vůbec jednoduché se toho zbavit nebo ten protokol nějak změnit. Nad kdejakou mikroservicou jsme strávili mnohem více času než nad návrhem entry pointu. Přitom zbavit se microservicy je mnohem jednodušší než se zbavit entry pointu, který používají všechny naše microservicy!
- Nejen, že jsme měli definovat, co od něj chceme. Měli jsme naprosto přesně popsat use casy, které by měl entry point řešit. V průběhu času lidé přestali chápat, k čemu je entry point dobrý anebo si naopak mysleli, že entry point řeší use casy, které ve skutečnosti neřeší. Nemáme nikde dobrou dokumentaci, ke které bychom se mohli vrátit a mohli si říct, že “entry point v současné implementaci má řešit use case X, Y a Z, ale už neřeší use case A, B a C”. Měli jsme si sepsat, jak očekáváme, že to bude používat klient a co to vyřeší pro něj. Nic z toho jsme neudělali, akorát v hlavách různých lidí byly různé navzájem nekompatibilní vize toho, co to bude umět a co to nebude umět. ¯\_(ツ)_/¯