Rajapinnat ovat haastava alue ohjelmistosuunnittelussa. Ei niinkään siksi, että niiden tekeminen olisi jotenkin poikkeuksellisen vaikeaa, vaan ehkä enemmänkin siksi, että niiden tekeminen on ohjelmallisesti niin helppoa, että on vaikea perustella itselleen useiden tuntien lisätyö niiden parantamiseksi, kun jo ensimmäinen nopea toteutus on periaatteessa rajapinta.
REST -rajapinnat (sanoista representational state transfer) ovat käytännössä yhteyspisteitä (endpoints), joita voidaan kutsuta HTTP -verbeillä (GET, POST, PUT, PATCH, DELETE, ...) ja jotka palauttavat jonkin resurssin tai kokoelman resursseja. Tarkkaan määrittelyyn en tässä mene, mutta yksikertaisimmillaan rajapintaa voitaisiin kutsua seuraavalla tavalla:
HTTP GET http://coolnewfoursquarelikeapp.com/api/places/1
Tämä kutsu palauttaisi paikat kokoelmasta resurssin tunnisteella 1 JSON (JavaScript Object Notation) -enkoodattuna.
Liiallisen helppouden sudenkuoppa
Tämän hetken suosituimmat web -ohjelmointikehykset (Framework) toteuttavat reitityksen (Routing) eri HTTP -verbien mukaan ja abstraktoivat tietokannan ORM:in (Object-relational mapping) taakse. Käytän tässä esimerkkinä suosittua PHP -ohjelmointikehystä, Laravelia. Laravelilla tuohon yllä esitettyyn rajapintakutsuun voitaisiin vastata seuraavalla tavalla:
Route::get('/api/places', function()
{
return Place::all();
}
Route::get('/api/places/{id}', function($id)
{
return Place::find($id);
}
HTTP GET -kutsu suoraan yhteyspisteeseen /api/places palauttaisi kannasta ORM:in kautta suoraan kaikki kohteet JSON -notaatiolla ja kutsu yhteyspisteeseen /api/places/1 palauttaisi yhden kohteen sillä rajauksella, että annettu id vastaa kannassa yhtä auto_incrementing_id kenttää. Eli jokaiselle uudelle riville on annettu juokseva numerointi yksilöintiä varten.
Näillä muutamalla rivillä, olettaen, että meillä on Place malli määriteltynä, saisimme toteutetuksi REST -rajapinnan, joka periaatteessa toimii aivan oikein. Se, että olisiko se todella RESTfull -rajapinta jää määrittelykysymykseksi. Yksi asia on kuitenkin selvää, nimittäin se, että tämä olisi todella huono rajapinta. Useastakin syystä.
Tyyppimuunnos, mikä "tyyppimuunnos"?
Tässä yksi yksittäinen resurssi rajapinnan ensimmäisestä versiosta. Meillä on kokoelma, jossa resursseja ja niillä ominaisuuksia ja arvoja. Nämä ominaisuudet tässä esimerkissä ovat kokonaislukuja (integer), desimaalilukuja (double) ja merkkijonoja (string). Meillä voisi olla myös esimerkiksi totuusarvoja (boolean), joita MySQL kannassa kuvattaisiin varmaankin tyypillä tinyint(1), joka olisi joko 0 tai 1.
[{
"sid": "0x00060100",
"val": "-19.90000000",
"ts": "1453370011287",
"created_at": "2016-01-21 09:55:38",
"device_id": "2"
}, ...]
Mutta rajapinnan palauttamassa datassa meillä on vain merkkijonoja. Esimerkiksi ts, oletettavasti aikaleima (timestamp), kertoo ajanhetken laskettuna Unix Epoch -hetkestä. Eli se on kokonaisluku. Val kertoo tässä esimerkissä ulkolämpötilan muutaman desimaalin tarkkuudella, eli se on desimaaliluku. Tai sen pitäisi olla. Tässä rajapinnassa se on merkkijono - hajoa siihen, sovelluskoodari.
Syynä tähän kauheuteen on tapa, jolla useimmat tietokanta-ajurit käsittelevät tietoa. Jos esimerkissä olisi ollut mukana totuusarvoja, MySQL olisi palauttanut ne muodossa "aktiivinen": "0". Ja MySQL ei ole näistä edes "kummallisin", PostgreSQL palauttaisi tuon joko "t" tai "f". Hän, joka rajapintaa hyödyntää seuraavassa loistavassa iPhone -applikaatiossaan (joita meillä ei ole vielä tarpeeksi), joutuu tulkitsemaan, tyyppimuuntamaan useimmat arvot jonkin valistuneen veikkauksen pohjalta. Ja sitten, kun päätämme vaihtaa tietokanta-ajuria ja totuusarvo muuttuu arvosta "0" arvoon "f", tuo kyseinen kehittäjä varmasti muistaa meitä lämmöllä.
Tässä tulee ilmi myös toinen ongelmakohta.
API v1, v2, v3, ...
Jos palautamme ORM:in mallin sellaisenaan, JSON:ssa toistuvat kenttien nimet juuri sellaisina, kuin ne ovat kannassa. Jokainen kantaan tehty skeeman muutos muuttaisi siten rajapinnan tulosta. Mutta eihän skeemaa koskaan tarvitse muuttaa, koska kun se kerran kunnolla tehdään, niin mitä sitä täydellistä enää parantamaan, vai?
Kenties niinkin, koska ohjelmistosuunnittelijathan eivät koskaan tee virheitä ja maailma on oikeudenmukainen paikka ja kaikilla on samat mahdollisuudet.
Näihin kahteen kohtaan voidaan kuitenkin vaikuttaa pienellä datan transformaatiolla ennen sen palauttamista. Esimerkki:
$place = Place::find($id);
return json_encode([
'id' => (int) $place->id,
'name' => $place->venue_name,
'has_parking' => (boolean) $place->parking,
'created_at' => (string) $place->created_at,
]);
Tällä transformaatiolla saamme tietotyypit asiakkaalle oikeassa muodossa, jolloin esimerkiksi JavaScript -kehittäjä voi käyttää totuusarvoa sellaisenaan, ilman tulkinnan tarvetta. Kentän created_at pakottaminen tyypiksi string perustuu siihen, että käytössä voi olla esimerkiksi Carbon -kirjasto aikatietojen käsittelyyn ja se palauttaa objektin, jollei sitä muuta merkkijonoksi. Lisäksi saimme kenttien nimien väliin yhden sisäisen muuntotaulukon, joten skeeman muutokset voidaan vielä estää päätymästä suoraan rajapinnan asiakkaalle. Jos jossain kohtaa tietokantaylläpitäjä päättää, että venue_name on huono ilmaisu, koska kaikissa muissa malleissa samaa asiaa kutsutaan vaikkapa nimellä paikan_nimi, se voidaan vaihtaa ilman, että rajapinta muuttuisi.
En tiedä miten yleistä tietokantamoottorin vaihtaminen kesken projektin on, itselläni ei ole moiseen ollut tarvetta julkaisun jälkeen kuin ehkä kerran, mutta tällä lähestymistavalla eri tietokanta-ajureiden erilaiset tavat palauttaa totuusarvoja eivät aiheuttaisi ongelmia, vaikka tietokanta-ajuria pitäisikin vaihtaa kesken tuotannon.
Sivumennen sanoen yllä oleva väite voidaan vahvistaa poikkeuksella, sillä joskus paikallisessa kehityksessä voi olla mielekästä käyttää vaikkapa SqLite:a, vaikka tuotantoympäristössä olisikin PostgreSQL.
Haluatko tietää salasanani? Tässä se olisi...
Toinen asia, jonka transformaatiokerros ratkaisee on se, että Place::all() todella palauttaa kaiken tiedon kannasta. Tai no, ei välttämättä tässä esimerkissä kaikkea, mutta periaatteessa kaiken. Ainakin Laravelin ORM:in malleissa voidaan määrittää kenttiä kätketyiksi ja näitä kätkettyjä kenttiä ei sitten palauteta JSON -muodossa.
protected $hidden = ['password'];
Tämä on ihan ok, jos olet aivan varma ja vakuuttunut, että muistat, ja että kaikki muutkin kehittäjät muistavat erikseen määritellä salaiset asiat salaisiksi. Paljon parempi käytäntö olisi kuitenkin käänteinen ajattelu, jossa jokin tieto ei ole julkista, ellei sitä erikseen julkaista. Yllä oleva transformaatiokerros palauttaa vain ne kentät, jotka se on koodattu palauttamaan.
Hyvänä sivuhuomiona useimmat kehittäjät varmaankin muistavat jo selkäytimestään, että salasanaa, tai edes sen tiivistettä ei vahingossakaan tule vuotaneeksi, mutta paljon merkittävämmän asian vuotaminen ei välttämättä ole yhtä hyvin sisäistetty. Nimittäin password_reset_token, jos sellainen on käytössä ja useimmitenhan sellainen on käytössä.
Se, että onko ongelmallista vuotaa julki rajapinnan käyttäjille skeeman sarakkeiden nimet on mielipidekysymys. En pidä mielekkäänä sitä vaihtoehtoa, että kaikki sarakkeet pitäisi nimetä uudelleen, sillä onko sillä todella mitään merkitystä, vaikka rajapinnan käyttäjä tietäisi luotipäivän löytyvät skeemasta sarakkeesta created_at? Sen sijaan id –kenttä saattaa olla sellainen tieto, jota ei haluta jakaa suoraan sellaisena, kuin se kannassa on, mutta palataan siihen vielä uudelleen.
Footnote
Tässä esimerkin vuoksi datan transformaatio on toteutettu suoraan kyseisen resurssin kontrolleriin, todellisuudessa se voisi sijaita geneerisemmässä muodossa jossain ylemmän tason rajapintakontrollerissa, jota nämä resurssikohtaiset kontrollerit sitten perisivät.
Where's my metadata?
Jos palautamme kaikki resurssit suoraan JSON notaatiolla, yksittäinen resurssi on JSON puussa juuritasolla. Alla jälleen huono ensimmäisen rajapinnan tuottama resurssilistaus. Meillä on kokoelma [ ... ] ja sen sisällä meillä on resurssi { ... }. Mihin kohtaan meidän tulisi laittaa resurssiin, tai kokoelmaan liittyvä metadata? Jos esimerkiksi haluaisimme antaa, ja tulemme myöhemmässä kohdassa haluamaan antaa, mukana tieto siitä, montako resurssiriviä kokoelmaan kuuluu ja montako niistä on tässä palautettuna ja millä kutsulla saadaan seuraava setti haettua, niin mihin tässä mahtuisi moinen tieto?
[{
"sid": "0x00060100",
"val": "-19.90000000",
"ts": "1453370011287",
"created_at": "2016-01-21 09:55:38",
"device_id": "2"
}, ...]
Palataan jälleen tuohon aiempaan esimerkkiin ja jatketaan datan transfromaation tekemistä. Voimme tehdä tilaa metadatalle yksinkertaisesti nestaamalla, sisentämällä, varsinaista dataa puussa yhden tason.
Se, miksi tuota tasoa tulisi kutsua, on kiisteltyä. Joskus näkee käytettävän notaatiota, jossa resurssin nimi valitaan kokoelman nimenksi, eli tässä tapauksessa:
[ 'places' => { {"id" => 1, "name" => "foo", ...}, ...}, ... ]
Tämä on ihan ymmärrettävää, koska places -kokoelma löytyy avaimella places. Hieman useammin näkee kuitenkin käytettävän notaatiota, jossa abstraktoidaan kokoelman tyyppi ja kuvataan mielummin korkeammalla tasolla sitä, mitä kukin avain sisältää. Tässähän meillä on kuitenkin jako dataan ja metadataan. Tällöin voisimme käyttää jokaisessa kokoelmassa resurssikokoelman avaimena data.
$place = Place::find($id);
return json_encode([
'data' => [
'id' => (int) $place->id,
'name' => $place->venue_name,
'has_parking' => (boolean) $place->parking,
'created_at' => (string) $place->created_at,
],
]);
Nyt tieto on hieman rakenteisempaa sillä meillä on "tilaa" sijoittaa metadataa samaan palautukseen. Esimerkiksi siten, että data sisältää kokoelman tai resurssin, links sisältää URL -osoitteet edelliseen ja/tai seuraavaan "sivuun" kokoelmassa ja jokin metrics voisi sisältää tiedon siitä, kuinka kauan pyynnön käsittelyyn meni aikaa. Siis jos sellaista joku haluaisi tietää, mutta joka tapauksessa meillä on nyt vastauksen rakenteen puolesta mahdollisuus lisätä metadataa. Ja se on hyvä.
Lopuksi
Lähdimme liikkeelle todella huonosta rajapintatoteutuksesta ja muutamalla pienellä muutoksella saimme siitä hieman paremman. Emme käyneet läpi koodiesimerkein jokaista kohtaa, mutta pohjimmiltaan käydyt asiat ovat teknisesti perin yksinkertaisia. Ne vain olivat sellaisia, joita en tullut suoraan ajatelleeksi ensimmäisellä kerralla.
Monta asiaa jäi vielä sanomatta ja monta kohtaa olisi voinut sanoa paremmin ja selittää tarkemmin. Kentien palaamme vielä asiaan. Yksi iso asia tähän ei mahtunut, eikä varsin tähän otsikkoon kuulunutkaan, vain rajapintoihin yleisemmin, nimittäin autentikaatio. Siinäpä vasta herkullinen aihe, aihe, jossa asiat voidaan tehdä todella väärin, jos ei ymmärretä eri tekniikoiden rajoitteita. Vai pitäisikö sanoa ominaisuuksia.
Lähteinä tälle kirjoitukselle toimi useita keskusteluita, blogeja, screencasteja ja Phil Sturgeon kirja Build APIS You Won't Hate, josta otsikonkin tyylittelin.