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:

Sama tieto REST- ja GraphQL-rajapinnasta
RESTGraphQL
Osoitteetyksi resurssia kohden yksi koko rajapinnalle, yleensä /graphql
Vastauksen muotopalvelin päättää asiakas päättää kyselyssä
Varaukset ja niiden huoneetuseita pyyntöjä tai valmiiksi upotettu vastausyksi kysely
Lukeminen ja muuttaminenHTTP-metodi kyselyn tyyppi: query tai mutation
Rajapinnan kuvausOpenAPI, 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
  }
}

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.

Kokeile esimerkissä

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:

{
  "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:

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?

Milloin kumpikin sopii
TilanneParempi valinta
Asiakkaita on monenlaisia ja niiden tietotarpeet vaihtelevat (verkko, mobiili, kumppanit)GraphQL
Näkymä yhdistelee paljon toisiinsa liittyvää tietoaGraphQL
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 suhteitaREST
Oma sovellus, jolla on yksi asiakaskumpi 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.

Kokeile ainakin näitä:

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti