GraphQL
REST-luennolla rajapinta koostui resursseista, joilla kullakin oli oma osoitteensa, ja HTTP-metodit ja tilakoodit kantoivat merkitystä. GraphQL on toisenlainen ratkaisu samaan ongelmaan: rajapinnalla on yksi osoite, ja asiakas kertoo kyselyssä täsmälleen, mitä tietoa se tarvitsee. Tämä luento olettaa, että REST-luennon asiat ovat tuttuja, koska GraphQL:n vahvuudet ja heikkoudet näkyvät parhaiten sitä vasten. Kaikki luennon asiat löytyvät esimerkkisovelluksesta, jossa on sama varausdata kuin REST-luennolla.
GraphQL kehitettiin Facebookissa vuonna 2012 mobiilisovelluksen tarpeisiin, ja se julkaistiin avoimena määrittelynä vuonna 2015. Nykyään sitä ylläpitää GraphQL Foundation.
Perusidea
Kysely kirjoitetaan samassa muodossa kuin vastaus halutaan. Kysely
{
varaukset(ensimmaiset: 2) {
otsikko
huone { nimi kapasiteetti }
}
}
tuottaa vastauksen
{
"data": {
"varaukset": [
{"otsikko": "Projektipalaveri", "huone": {"nimi": "Neuvotteluhuone A", "kapasiteetti": 8}},
{"otsikko": "Koodikatselmointi", "huone": {"nimi": "Neuvotteluhuone B", "kapasiteetti": 12}}
]
}
}
Vastauksessa on vain pyydetyt kentät, ja varauksen huone tulee samalla pyynnöllä. Tämä ratkaisee kaksi REST-rajapinnan tyypillistä ongelmaa:
- Liikaa dataa (engl. over-fetching): REST-rajapinta palauttaa varauksesta aina samat kentät, vaikka mobiilisovellus tarvitsisi vain otsikon.
- Liian vähän dataa (under-fetching): REST-rajapinnasta saa varauksen, mutta huoneen kapasiteetti vaatii toisen pyynnön. Luettelossa se tarkoittaa pahimmillaan yhtä lisäpyyntöä jokaista riviä kohden.
| REST | GraphQL | |
|---|---|---|
| Osoitteet | yksi resurssia kohden | yksi koko rajapinnalle, yleensä /graphql |
| Vastauksen muoto | palvelin päättää | asiakas päättää kyselyssä |
| Varaukset ja niiden huoneet | useita pyyntöjä tai valmiiksi upotettu vastaus | yksi kysely |
| Lukeminen ja muuttaminen | HTTP-metodi | kyselyn tyyppi: query tai mutation |
| Rajapinnan kuvaus | OpenAPI, erillinen dokumentti | skeema, joka on osa rajapintaa |
Skeema ja tyypit
GraphQL-rajapinnan pohjana on skeema, joka kuvaa kaikki tyypit ja niiden kentät. Skeema kirjoitetaan omalla kuvauskielellään (SDL, Schema Definition Language):
type Varaus {
vid: ID!
otsikko: String!
alkaa: String!
loppuu: String!
huone: Huone!
varaaja: Kayttaja!
}
type Huone {
nimi: String!
kapasiteetti: Int!
varaukset: [Varaus!]!
}
type Query {
varaukset(huone: String, ensimmaiset: Int! = 20): [Varaus!]!
varaus(vid: ID!): Varaus
huoneet: [Huone!]!
}
Huutomerkki tarkoittaa, että arvo ei voi olla null. Hakasulkeet
tarkoittavat listaa. Query on erityistyyppi: sen kentät ovat kyselyjen
aloituskohtia. Vastaavasti Mutation-tyypin kentät ovat muutosten
aloituskohtia.
Skeemaa ei tarvitse kirjoittaa käsin. Pythonin Strawberry-kirjastossa skeema syntyy tavallisista luokista ja tyyppimerkinnöistä:
import strawberry
@strawberry.type(description="Huoneen varaus")
class Varaus:
vid: strawberry.ID
otsikko: str
alkaa: str
loppuu: str
@strawberry.field(description="Varattu huone")
async def huone(self, info) -> Huone:
return await info.context["lataajat"]["huone"].load(self.hid)
Tyyppimerkinnöistä syntyvät GraphQL-tyypit: str on
String!, Optional[str] on String ja
list[Varaus] on [Varaus!]!.
Skeema on myös rajapinnan dokumentaatio. Asiakas voi kysyä sen palvelimelta itsekuvauksella (introspection), ja työkalut kuten GraphiQL tarjoavat sen avulla automaattisen täydennyksen ja tarkistavat kyselyt jo kirjoitettaessa.
Kyselyt
Kyselyssä voi käyttää argumentteja, muuttujia ja aliaksia:
query HuoneenVaraukset($nimi: String!) {
auditorio: varaukset(huone: $nimi) {
otsikko
alkaa
}
}
- Muuttujat (
$nimi) annetaan erikseen JSON-objektina. Kyselyyn ei siis liitetä arvoja merkkijonoja yhdistelemällä — sama periaate kuin SQL:n parametrisoiduissa kyselyissä. - Alias (
auditorio:) nimeää kentän vastauksessa uudelleen, jolloin samaa kenttää voi kysyä useaan kertaan eri argumenteilla. - Operaation nimi (
HuoneenVaraukset) ei ole pakollinen, mutta helpottaa lokien lukemista.
HTTP:n tasolla kysely lähetetään yleensä POST-pyyntönä, jonka runko on JSON:
const vastaus = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: "query HuoneenVaraukset($nimi: String!) { varaukset(huone: $nimi) { otsikko } }",
variables: { nimi: "Auditorio" }
})
});
const { data, errors } = await vastaus.json();
Resolverit
Palvelin tuottaa vastauksen kenttä kerrallaan. Jokaisella kentällä on
resolveri, funktio joka palauttaa kentän arvon. Tavallisille
kentille, kuten otsikko, resolveri vain lukee olion attribuutin. Suhteille,
kuten huone, resolveri hakee tiedon erikseen.
@strawberry.type
class Query:
@strawberry.field(description="Tulevat varaukset aikajärjestyksessä")
def varaukset(self, huone: Optional[str] = None, ensimmaiset: int = 20) -> list[Varaus]:
rivit = kanta().execute(
"SELECT * FROM varaus WHERE loppuu >= ? ORDER BY alkaa LIMIT ?",
(datetime.now().strftime(MUOTO), ensimmaiset)).fetchall()
return [Varaus.rivista(r) for r in rivit]
Malli on joustava: asiakas voi yhdistellä kenttiä vapaasti, ja palvelin kutsuu vain niiden kenttien resolverit, joita kysyttiin. Juuri tästä joustavuudesta seuraa GraphQL:n tunnetuin suorituskykyongelma.
N+1-ongelma
Kysely { varaukset { otsikko huone { nimi } varaaja { nimi } } } kutsuu
ensin varaukset-resolveria kerran. Sitten se kutsuu jokaiselle
varaukselle huone- ja varaaja-resolveria. Jos resolveri tekee
oman tietokantakyselyn, 12 varausta tuottaa 1 + 12 + 12 = 25 kyselyä. Määrä kasvaa
rivien mukana, vaikka selaimen näkökulmasta kyseessä on yksi pyyntö.
Ratkaisu on eräajo (batching). DataLoader-niminen apuluokka kerää saman pyynnön aikana kaikki pyydetyt tunnisteet ja hakee ne yhdellä kyselyllä:
from strawberry.dataloader import DataLoader
async def lataa_huoneet(tunnisteet):
paikat = ",".join("?" * len(tunnisteet))
rivit = kanta().execute(f"SELECT * FROM huone WHERE hid IN ({paikat})",
list(tunnisteet)).fetchall()
loydetyt = {r["hid"]: r for r in rivit}
# Tulokset palautetaan samassa järjestyksessä kuin tunnisteet pyydettiin.
return [Huone.rivista(loydetyt[t]) for t in tunnisteet]
huonelataaja = DataLoader(load_fn=lataa_huoneet)
# resolverissa:
return await huonelataaja.load(self.hid)
Nyt sama kysely tekee kolme tietokantakyselyä riippumatta siitä, montako varausta on. Lataajat luodaan jokaiselle pyynnölle erikseen, koska niillä on oma välimuisti, joka ei saa vuotaa käyttäjältä toiselle.
Esimerkkisovellus kertoo jokaisen vastauksen extensions-kentässä,
montako tietokantakyselyä pyyntö teki. Kyselyeditorin Eräajo-valinnalla
DataLoaderin voi kytkeä pois ja nähdä N+1-ongelman: 25 kyselyä kolmen sijaan.
Mutaatiot ja virheet
Tietoja muuttavat operaatiot ovat mutaatioita. Ne kirjoitetaan kuten kyselyt, mutta
avainsanalla mutation, ja niiden tulos voidaan kysyä samaan tapaan:
mutation Uusi($syote: VarausSyote!) {
luoVaraus(syote: $syote) {
__typename
... on Varaus { vid otsikko }
... on Tarkistusvirhe { virheet { kentta viesti } }
... on Paallekkainen { viesti este { otsikko alkaa } }
}
}
GraphQL:ssä on kaksi tapaa ilmaista virhe, ja niiden ero on olennainen:
- Virheet datana. Mutaation tulos on union-tyyppi: onnistuessa
Varaus, muutenTarkistusvirhetaiPaallekkainen.... on Tyyppivalitsee kentät tyypin mukaan, ja__typenamekertoo asiakkaalle, mikä tapahtui. Tämä sopii odotettaviin tilanteisiin, joihin asiakas osaa reagoida, kuten tarkistusvirheisiin. - Vastauksen
errors-kenttä. Odottamattomat tilanteet, kuten puuttuva kirjautuminen tai oikeuden puute, palautetaan erillisessäerrors-listassa. Virhetyyppi kerrotaan tavallisestiextensions.code-kentässä:
{
"data": null,
"errors": [{
"message": "Voit poistaa vain omia varauksiasi",
"path": ["poistaVaraus"],
"extensions": {"code": "FORBIDDEN"}
}]
}
GraphQL ja HTTP
GraphQL käyttää HTTP:tä pelkkänä kuljetuskerroksena. Tästä seuraa kolme asiaa, jotka poikkeavat REST-luennon opeista.
Tilakoodi on yleensä 200, myös virheissä. Esimerkin palvelin vastaa
tilakoodilla 200 syntaksivirheeseen, tuntemattomaan kenttään, puuttuvaan kirjautumiseen
ja oikeuden puutteeseen; virhe on rungon errors-kentässä. Vastaus voi myös
olla osittain onnistunut: data sisältää ne kentät, jotka saatiin, ja
errors kertoo muista. Selaimen koodissa vastaus.ok ei siis
riitä, vaan errors-kenttä on tarkistettava aina. Kehitteillä oleva
GraphQL over HTTP -määrittely suosittelee mediatyyppiä
application/graphql-response+json, jonka kanssa kyselyn virheet
palautetaan 4xx-tilakoodeilla, mutta käytäntö vaihtelee palvelimittain.
HTTP-välimuisti ei auta. Kaikki POST-pyynnöt menevät samaan osoitteeseen, joten välityspalvelimet ja selaimen välimuisti eivät voi hyödyntää niitä. Kyselyt voi lähettää myös GET-pyyntöinä, jolloin ne ovat välimuistikelpoisia, mutta mutaatiot eivät koskaan: esimerkin palvelin hylkää GET-pyynnöllä lähetetyn mutaation. Välimuisti toteutetaan GraphQL-sovelluksissa yleensä asiakkaassa, esimerkiksi kirjastoissa kuten Apollo Client, jotka tunnistavat oliot tunnisteen perusteella.
Lokeista ei näe, mitä tapahtui. Kaikki pyynnöt näkyvät
palvelimen lokissa muodossa POST /graphql 200. Siksi operaatioille
kannattaa antaa nimet, ja sovelluksen oma lokitus on tehtävä operaation tasolla.
Tietoturva
GraphQL:n joustavuus on myös sen suurin tietoturvariski: asiakas päättää, kuinka paljon työtä yksi pyyntö teettää. OWASP:n GraphQL Cheat Sheet kokoaa suojaukset; tärkeimmät ovat:
- Kyselyn syvyyden rajoittaminen. Skeemassa on sykli: huoneella
on varauksia, varauksella huone. Kysely
huoneet { varaukset { huone { varaukset { … } } } }voi jatkua rajatta, ja jokainen taso moninkertaistaa työn. Esimerkki hylkää yli viisi tasoa syvät kyselyt. - Aliasten ja koon rajoittaminen. Aliaksilla yhteen pyyntöön voi pakata saman raskaan kentän satoja kertoja, mikä ohittaa pyyntömäärän rajoituksen. Esimerkki sallii enintään 15 aliasta ja 1000 kyselykielen alkiota (engl. token).
- Valtuutus jokaisessa resolverissa. Koska mihin tahansa kenttään voi päästä monta reittiä pitkin, oikeus on tarkistettava siellä, missä tieto haetaan, ei vain kyselyn aloituskohdassa.
- Itsekuvaus pois tuotannossa. Itsekuvaus kertoo kenelle tahansa rajapinnan koko rakenteen, myös sisäiseen käyttöön tarkoitetut kentät. Se on hyödyllinen kehityksessä mutta tarpeeton julkisessa palvelussa.
- CSRF. Jos palvelin hyväksyy GET-kyselyt tai
application/x-www-form-urlencoded-runkoja ja tunnistaa käyttäjän evästeellä, toinen sivusto voi lähettää kyselyjä käyttäjän nimissä. Esimerkin mutaatiot tunnistetaan vainAuthorization-otsakkeesta, jolloin CSRF ei ole mahdollinen, kuten REST-luennolla todettiin. - Virheilmoitusten peittäminen. Tuotannossa odottamattomien poikkeusten viestit kannattaa korvata yleisellä tekstillä, jotta ne eivät paljasta tietokannan rakennetta tai tiedostopolkuja.
from strawberry.extensions import (DisableIntrospection, MaxAliasesLimiter,
MaxTokensLimiter, QueryDepthLimiter)
laajennukset = [
QueryDepthLimiter(max_depth=5),
MaxAliasesLimiter(max_alias_count=15),
MaxTokensLimiter(max_token_count=1000),
]
if TUOTANTO:
laajennukset.append(DisableIntrospection())
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=laajennukset)
REST vai GraphQL?
| Tilanne | Parempi valinta |
|---|---|
| Asiakkaita on monenlaisia ja niiden tietotarpeet vaihtelevat (verkko, mobiili, kumppanit) | GraphQL |
| Näkymä yhdistelee paljon toisiinsa liittyvää tietoa | GraphQL |
| Julkinen rajapinta, jota käyttävät tuntemattomat asiakkaat | REST: yksinkertaisempi, HTTP-välimuisti toimii, kuormaa helpompi rajoittaa |
| Tiedostojen lataus, yksinkertaiset tietueet, vähän suhteita | REST |
| Oma sovellus, jolla on yksi asiakas | kumpi tahansa; REST on kevyempi aloittaa |
GraphQL siirtää monimutkaisuutta asiakkaalta palvelimelle: asiakkaan elämä helpottuu, mutta palvelimen on hallittava N+1-ongelma, kyselyjen kustannukset ja välimuisti ilman HTTP:n apua. Valinta ei myöskään ole joko–tai: moni palvelu tarjoaa molemmat.
Esimerkkisovellus
Esimerkki on sama kokoushuoneiden varaus kuin
REST-luennolla, nyt GraphQL-rajapintana. Etusivulla
on pieni kyselyeditori valmiine esimerkkikyselyineen; se näyttää vastauksen,
HTTP-tilakoodin ja tietokantakyselyjen määrän. Kehityskäytössä osoitteessa
/graphql on myös GraphiQL-editori.
- Sovellus ja kyselyeditori sekä GraphiQL
- varaus-graphql.zip — lähdekoodi, testit ja julkaisuohjeet
Kokeile ainakin näitä:
- N+1-ongelma. Suorita Sisäkkäinen kysely eräajon kanssa ja ilman, ja vertaa tietokantakyselyjen määrää.
- Vain tarvittavat kentät. Poista kyselystä kenttiä ja katso, miten vastaus muuttuu. Lisää kenttä, jota skeemassa ei ole: kysely hylätään jo ennen suoritusta.
- Virheet datana. Kirjaudu ja suorita Mutaatio: uusi
varaus kahdesti: ensin tulos on
Varaus, sittenPaallekkainen. Tyhjennä otsikko muuttujista, niin tuloksena onTarkistusvirhe. - Rajoitukset. Suorita Liian syvä kysely, ja huomaa, että virheestä huolimatta tilakoodi on 200.
Lisätietoa
- GraphQL: Learn ja GraphQL Specification
- GraphQL over HTTP (määrittelyluonnos)
- Strawberry ja sen DataLoader-opas
- OWASP: GraphQL Cheat Sheet
Käyttäjien kommentit