HUOM osan 8 sisältöä muutettiin Apollo hookien osalta 28.6. noin klo 21. Jos olet aloittanut osan 8 tekemisen tätä aiemmin tarkista täältä että konfiguraatiosi ovat oikeat!

Kurssi lähestyy loppuaan. Katsotaan lopuksi vielä muutamaa GraphQL:ään liittyvää asiaa.

fragmentit

GraphQL:ssä on suhteellisen yleistä, että eri kyselyt palauttavat samanlaisia vastauksia. Esim. puhelinluettelossa yhden henkilön hakeva kysely

query {
  findPerson(name: "Pekka Mikkola") {
    name
    phone
    address{
      street 
      city
    }
  }
}

ja kaikki henkilöt hakeva kysely

query {
  allPersons {
    name
    phone
    address{
      street 
      city
    }
  }
}

palauttavat molemmat henkilöitä. Valitessaan palautettavia kenttiä, molemmat kyselyt joutuvat määrittelemään täsmälleen samat kentät.

Tällaisia tilanteita voidaan yksinkertaistaa fragmenttien avulla. Määritellään kaikki henkilön tiedot valitseva fragmentti:

fragment PersonDetails on Person {
  name
  phone 
  address {
    street 
    city
  }
}

Kyselyt voidaan nyt tehdä fragmenttien avulla kompaktimmassa muodossa:

query {
  allPersons {
    ...PersonDetails  }
}

query {
  findPerson(name: "Pekka Mikkola") {
    ...PersonDetails  }
}

Fragmentteja ei määritellä GraphQL:n skeemassa, vaan kyselyn tekevän clientin puolella. Fragmenttien tulee olla määriteltynä siinä vaiheessa kun client käyttää kyselyssään niitä.

Voisimme periaatteessa määritellä fragmentin jokaisen kyselyn yhteydessä seuraavasti:

const ALL_PERSONS = gql`
{
  allPersons  {
    ...PersonDetails
  }
}
fragment PersonDetails on Person {
  name
  phone 
  address {
    street 
    city
  }
}
`

Huomattavasti järkevämpää on kuitenkin määritellä fragmentti kertaalleen ja sijoittaa se muuttujaan.

const PERSON_DETAILS = gql`
fragment PersonDetails on Person {
  id
  name
  phone 
  address {
    street 
    city
  }
}
`

Näin määritelty fragmentti voidaan upottaa kaikkiin sitä tarvitseviin kyselyihin ja mutaatioihin "prosenttiaaltosulku"-operaatiolla:

const ALL_PERSONS = gql`
{
  allPersons  {
    ...PersonDetails
  }
}
${PERSON_DETAILS}  
`

Subscriptiot eli tilaukset

GraphQL tarjoaa query- ja mutation-tyyppien lisäksi kolmannenkin operaatiotyypin, subscriptionin, jonka avulla clientit voivat tilata palvelimelta tiedotuksia palvelimella tapahtuneista muutoksista.

Subscriptionit poikkeavatkin radikaalisti kaikesta, mitä kurssilla on tähän mennessä nähty. Toistaiseksi kaikki interaktio on koostunut selaimessa olevan React-sovelluksen palvelimelle tekemistä HTTP-pyynnöistä. Myös GraphQL:n queryt ja mutaatiot on hoidettu näin. Subscriptionien myötä tilanne kääntyy päinvastaiseksi. Sen jälkeen kun selaimessa oleva sovellus on tehnyt tilauksen muutostiedoista, alkaa selain kuunnella palvelinta. Muutosten tullessa palvelin lähettää muutostiedon kaikille sitä kuunteleville selaimille.

Teknisesti ottaen HTTP-protokolla ei taivu hyvin palvelimelta selaimeen päin tapahtuvaan kommunikaatioon. Konepellin alla Apollo käyttääkin WebSocketeja hoitamaan tilauksista aiheutuvan kommunikaation.

Tilaukset palvelimella

Toteutetaan nyt sovellukseemme subscriptiot, joiden avulla palvelimelta on mahdollista tilata tieto puhelinluetteloon lisätyistä henkilöistä.

Palvelimella ei ole tarvetta kovin monille muutoksille. Skeemaan tarvitaan seuraava lisäys:

type Subscription {
  personAdded: Person!
}    

Eli kun uusi henkilö luodaan, palautetaan henkilön tiedot kaikille tilaajille.

Määritellylle tilaukselle personAdded tarvitaan resolveri. Myös lisäyksen tekevää resolveria addPerson on muutettava siten, että uuden henkilön lisäys aiheuttaa ilmoituksen tilauksen tehneille.

Muutokset ovat seuraavassa:

const { PubSub } = require('apollo-server')const pubsub = new PubSub()
  Mutation: {
    addPerson: async (root, args, context) => {
      const person = new Person({ ...args })
      const currentUser = context.currentUser

      if (!currentUser) {
        throw new AuthenticationError("not authenticated")
      }

      try {
        await person.save()
        currentUser.friends = currentUser.friends.concat(person)
        await currentUser.save()
      } catch (error) {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      }

      pubsub.publish('PERSON_ADDED', { personAdded: person })
      return person
    },  
  },
  Subscription: {    personAdded: {      subscribe: () => pubsub.asyncIterator(['PERSON_ADDED'])    },  },

Tilausten yhteydessä kommunikaatio tapahtuu publish-subscribe-periaatteella käyttäen rajapinnan PubSub toteuttavaa olioa. Uuden henkilön lisäys julkaisee tiedon lisäyksestä kaikille muutokset tilanneille PubSubin metodilla publish.

Subscriptionin personAdded resolveri rekisteröi tiedotteista kiinnostuneet clientit palauttamalla niille sopivan iteraattoriolion.

Muutetaan palvelimen käynnistävää koodia seuraavasti

// ...

server.listen().then(({ url, subscriptionsUrl }) => {  console.log(`Server ready at ${url}`)
  console.log(`Subscriptions ready at ${subscriptionsUrl}`)})

Nyt näemme, että palvelin kuuntelee subscriptioita osoitteessa ws://localhost:4000/graphql

Server ready at http://localhost:4000/
Subscriptions ready at ws://localhost:4000/graphql

Muita muutoksia palvelimeen ei tarvita.

Tilauksia on mahdollista testata GraphQL-playgroundin avulla seuraavasti:

fullstack content

Kun tilauksen "play"-painiketta painetaan, jää playground odottamaan tilaukseen tulevia vastauksia.

Backendin koodi on kokonaisuudessaan githubissa, branchissa part8-6.

Tilaukset clientissä

Jotta saamme tilaukset käyttöön React-sovelluksessa, tarvitaan hieman enemmän muutoksia, erityisesti konfiguraatioiden osalta. Tiedostossa index.js olevat konfiguraatiot on muokattava seuraavaan muotoon:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

import { ApolloProvider } from '@apollo/react-hooks'

import { ApolloClient } from 'apollo-client'
import { createHttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { setContext } from 'apollo-link-context'

import { split } from 'apollo-link'
import { WebSocketLink } from 'apollo-link-ws'
import { getMainDefinition } from 'apollo-utilities'

const wsLink = new WebSocketLink({
  uri: `ws://localhost:4000/graphql`,
  options: { reconnect: true }
})

const httpLink = createHttpLink({
  uri: 'http://localhost:4000/graphql',
})

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('phonenumbers-user-token')
  return {
    headers: {
      ...headers,
      authorization: token ? `bearer ${token}` : null,
    }
  }
})

const link = split(
  ({ query }) => {
    const { kind, operation } = getMainDefinition(query)
    return kind === 'OperationDefinition' && operation === 'subscription'
  },
  wsLink,
  authLink.concat(httpLink),
)

const client = new ApolloClient({
  link,
  cache: new InMemoryCache()
})

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root')
)

Jotta kaikki toimisi, on asennettava uusia riippuvuuksia:

npm install --save subscriptions-transport-ws apollo-link-ws

Uusi konfiguraatio johtuu siitä, että sovelluksella tulee nyt olla HTTP-yhteyden lisäksi websocket-yhteys GraphQL-palvelimelle:

const wsLink = new WebSocketLink({
  uri: `ws://localhost:4000/graphql`,
  options: { reconnect: true }
})

const httpLink = createHttpLink({
  uri: 'http://localhost:4000/graphql',
})

Tilaukset tehdään komponentin Subscription tai Apollo Client 3.0:n tarjoaman hookin useSubscription avulla. Käytämme jälleen hookeja.

Tehdään koodiin seuraavat muutokset:

import { useQuery, useMutation, useSubscription ,useApolloClient } from '@apollo/react-hooks'
// ...

const PERSON_ADDED = gql`  subscription {    personAdded {      ...PersonDetails    }  }  ${PERSON_DETAILS}`
const App = () => {
  // ...

  useSubscription(PERSON_ADDED, {
    onSubscriptionData: ({ subscriptionData }) => {
      console.log(subscriptionData)
    }
  })

  // ...
}

Kun puhelinluetteloon nyt lisätään henkilöitä, tapahtuupa se mistä tahansa, tulostuvat clientin konsoliin lisätyn henkilön tiedot:

fullstack content

Kun luetteloon lisätään uusi henkilö, palvelin lähettää siitä tiedot clientille ja attribuutin onSubscriptionData arvoksi määriteltyä callback-funktiota kutsutaan antaen sille parametriksi palvelimelle lisätty henkilö.

Laajennetaan ratkaisua vielä siten, että uuden henkilön tietojen saapuessa henkilö lisätään Apollon välimuistiin, jolloin se renderöityy heti ruudulle. Koodissa on jouduttu huomioimaan se, että sovelluksen itsensä lisäämää henkilöä ei saa lisätä välimuistiin kahteen kertaan:

const App = () => {
  // ...

  const updateCacheWith = (addedPerson) => {
    const includedIn = (set, object) => 
      set.map(p => p.id).includes(object.id)  

    const dataInStore = client.readQuery({ query: ALL_PERSONS })
    if (!includedIn(dataInStore.allPersons, addedPerson)) {
      client.writeQuery({
        query: ALL_PERSONS,
        data: { allPersons : dataInStore.allPersons.concat(addedPerson) }
      })
    }   
  }

  useSubscription(PERSON_ADDED, {
    onSubscriptionData: ({ subscriptionData }) => {
      const addedPerson = subscriptionData.data.personAdded
      notify(`${addedPerson.name} added`)
      updateCacheWith(addedPerson)
    }
  })

  const [addPerson] = useMutation(CREATE_PERSON, {
    onError: handleError,
    update: (store, response) => {
      updateCacheWith(response.data.addPerson)
    }
  })

  // ...
}

Clientin lopullinen koodi githubissa, branchissa part8-9.

n+1-ongelma

Laajennetaan vielä backendia hieman. Muutetaan skeemaa siten, että tyypille Person tulee kenttä friendOf, joka kertoo kenen kaikkien käyttäjien tuttavalistalla ko henkilö on.

type Person {
  name: String!
  phone: String
  address: Address!
  friendOf: [User!]!
  id: ID!
}

Sovellukseen tulisi siis saada tuki esim. seuraavalle kyselylle:

query {
  findPerson(name: "Leevi Hellas") {
    friendOf{
      username
    }
  }
}

Koska friendOf ei ole tietokannassa olevien Person-olioiden sarake, on sille tehtävä oma resolveri, joka osaa selvittää asian. Tehdään aluksi tyhjän listan palauttava resolveri:

Person: {
  address: (root) => {
    return { 
      street: root.street,
      city: root.city
    }
  },
  friendOf: (root) => {    // return list of users     return [    ]  }},

Resolverin parametrina root on se henkilöolio jonka tuttavalista on selvityksen alla, eli etsimme olioista User ne, joiden friends-listalle sisältyy root._id:

  Person: {
    // ...
    friendOf: async (root) => {
      const friends = await User.find({
        friends: {
          $in: [root._id]
        } 
      })

      return friends
    }
  },

Sovellus toimii nyt.

Voimme samantien tehdä monimutkaisempiakin kyselyitä. On mahdollista selvittää esim. kaikkien henkilöiden tuttavat:

query {
  allPersons {
    name
    friendOf {
      username
    }
  }
}

Sovelluksessa on nyt kuitenkin yksi ongelma, tietokantakyselyjä tehdään kohtuuttoman paljon. Jos lisäämme palvelimen jokaiseen tietokantakyselyn tekevään kohtaan konsoliin tehtävän tulostuksen, huomaamme että jos tietokannassa on viisi henkilöä, tehdään seuraavat tietokantakyselyt:


Person.find
User.find
User.find
User.find
User.find
User.find

Eli vaikka pääasiallisesti tehdään ainoastaan yksi kysely, haetaan kaikki henkilöt, aiheuttaa jokainen henkilö yhden kyselyn omassa resolverissaan.

Kyseessä on ilmentymä kuuluisasta n+1-ongelmasta, joka ilmenee aika ajoin eri yhteyksissä, välillä salakavalastikin sovelluskehittäjän huomaamatta aluksi mitään.

Sopiva ratkaisutapa n+1-ongelmaan riippuu tilanteesta. Usein se edellyttää jonkinlaisen liitoskyselyn tekemistä usean yksittäisen kyselyn sijaan.

Tilanteessamme helpoimman ratkaisun toisi se, että tallettaisimme Person-olioihin viitteet niistä käyttäjistä kenen ystävälistalla henkilö on:

const schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    minlength: 5
  },
  phone: {
    type: String,
    minlength: 5
  },
  street: {
    type: String,
    required: true,
    minlength: 5
  },  
  city: {
    type: String,
    required: true,
    minlength: 5
  },
  friendOf: [    {      type: mongoose.Schema.Types.ObjectId,      ref: 'User'    }  ], })

Tällöin voisimme tehdä "liitoskyselyn", eli hakiessamme Person-oliot, voimme populoida niiden friendOf-kentät:

Query: {
  allPersons: (root, args) => {    
    console.log('Person.find')
    if (!args.phone) {
      return Person.find({}).populate('friendOf')    }

    return Person.find({ phone: { $exists: args.phone === 'YES' } })
      .populate('friendOf')  },
  // ...
}

Muutoksen jälkeen erilliselle friendOf-kentän resolverille ei enää olisi tarvetta.

Kaikkien henkilöiden kysely ei aiheuta n+1-ongelmaa, jos kyselyssä pyydetään esim. ainoastaan nimi ja puhelinnumero:

query {
  allPersons {
    name
    phone
  }
}

Jos kyselyä allPersons muokataan tekemään liitoskysely sen varalta, että se aiheuttaa välillä n+1-ongelman, tulee kyselystä hieman raskaampi niissäkin tapaukisssa, joissa henkilöihin liittyviä käyttäjiä ei tarvita. Käyttämällä resolverifunktioiden neljättä parametria olisi kyselyn toteutusta mahdollista optimoida vieläkin pidemmälle. Neljännen parametrin avulla on mahdollista tarkastella itse kyselyä, ja näin liitoskysely voitaisiin tehdä ainoastaan niissä tapauksissa, joissa on n+1-ongelman uhka. Tämänkaltaiseen optimointiin ei toki kannata lähteä ennen kun on varmaa, että se todellakin kannattaa.

Donald Knuthin sanoin:

Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs, and these attempts at efficiency actually have a strong negative impact when debugging and maintenance are considered. We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.

Erään varteenotettavan ratkaisun monien muiden seikkojen lisäksi n+1-ongelmaan tarjoaa Facebookin kehittämä dataloader-kirjasto, dataloaderin käytöstä Apollo serverin kanssa täällä ja täällä.

Loppusanat

Tässä osassa rakentamamme sovellus ei ole optimaalisella tavalla strukturoitu: skeeman, kyselyiden ja mutaatioiden määrittely olisi ainakin syytä siirtää muualle sovelluskoodin seasta. Esimerkkejä GraphQL-sovellusten parempaan strukturointiin löytyy internetistä, esim. serveriin täältä ja clientiin täältä.

GraphQL on jo melko iäkäs teknologia, se on ollut Facebookin sisäisessä käytössä jo vuodesta 2012 lähtien, teknologian voi siis todeta olevan "battle tested". Facebook julkaisi GraphQL:n vuonna 2015 ja se on pikkuhiljaa saanut enenevissä määrin huomiota ja nousee ehkä lähivuosina uhmaamaan REST:in valta-asemaa. REST:in kuolemaakin on jo ennusteltu. Vaikka se ei tulekaan ihan heti tapahtumaan, on GraphQL ehdottomasti tutustumisen arvoinen.