Siirry sisältöön

e

Fragmentit ja subskriptiot

Osa 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. Kaikki henkilön tiedot valitseva fragmentti näyttää seuraavalta:

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:

export const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    findPerson(name: $nameToSearch) {
      ...PersonDetails
    }
  }

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

Huomattavasti järkevämpää on kuitenkin määritellä fragmentti kertaalleen ja sijoittaa se muuttujaan. Lisätään tiedoston queries.js alkuun fragmentin määrittely:

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

Nyt fragmentti voidaan upottaa kaikkiin sitä tarvitseviin kyselyihin ja mutaatioihin "dollariaaltosulku"-operaatiolla:

export const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    findPerson(name: $nameToSearch) {
      ...PersonDetails
    }
  }

  ${PERSON_DETAILS}
`

Nyt siis PERSON_DETAILS-muuttujassa oleva template literal sijoitetaan osaksi FIND_PERSON -template literalia. Lopputulos vastaa käytännössä täysin aiempaa esimerkkiä, jossa fragmentti määriteltiin suoraan kyselyn yhteydessä.

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.

expressMiddleware

Apollo Server ei versiosta 3.0 alkaen enää ole tarjonnut suoraa tukea subscriptiolle. Joudummekin tekemään joukon muutoksia backendin koodiin, jotta saamme subscriptionit toimimaan.

Olemme toistaiseksi käynnistäneet sovelluksen helppokäyttöisellä funktiolla startStandaloneServer, jonka ansiosta sovellusta ei ole tarvinnut konfiguroida juuri ollenkaan:

const { startStandaloneServer } = require('@apollo/server/standalone')

// ...

const startServer = (port) => {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  })

  startStandaloneServer(server, {
    listen: { port },
    context: async ({ req }) => {
      // ...
    },
  }).then(({ url }) => {
    console.log(`Server ready at ${url}`)
  })
}

startStandaloneServer ei kuitenkaan mahdollista subscriptioiden lisäämistä sovellukseen, joten siirrytään järeämmän expressMiddleware funktion käyttöön. Kuten funktion nimi jo vihjaa, kyseessä on Expressin middleware, eli sovellukseen on konfiguroitava myös Express jonka middlewarena GraphQL-server tulee toimimaan.

Asennetaan Express ja Apollo Serverin integraatiopaketti:

npm install express cors @as-integrations/express5

ja muutetaan tiedosto server.js seuraavaan muotoon:

const { ApolloServer } = require('@apollo/server')
const {  ApolloServerPluginDrainHttpServer,} = require('@apollo/server/plugin/drainHttpServer')const { expressMiddleware } = require('@as-integrations/express5')const cors = require('cors')const express = require('express')const { makeExecutableSchema } = require('@graphql-tools/schema')const http = require('http')const jwt = require('jsonwebtoken')

const resolvers = require('./resolvers')
const typeDefs = require('./schema')
const User = require('./models/user')

const getUserFromAuthHeader = async (auth) => {
  if (!auth || !auth.startsWith('Bearer ')) {
    return null
  }

  const decodedToken = jwt.verify(auth.substring(7), process.env.JWT_SECRET)
  return User.findById(decodedToken.id).populate('friends')
}

const startServer = async (port) => {  const app = express()  const httpServer = http.createServer(app)   const server = new ApolloServer({    schema: makeExecutableSchema({ typeDefs, resolvers }),    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],  })   await server.start()   app.use(    '/',    cors(),    express.json(),    expressMiddleware(server, {      context: async ({ req }) => {        const auth = req.headers.authorization        const currentUser = await getUserFromAuthHeader(auth)        return { currentUser }      },    }),  )   httpServer.listen(port, () =>    console.log(`Server is now running on http://localhost:${port}`),  )}
module.exports = startServer

Muuttujaan server sijoitettu GraphQL-palvelin on nyt kytketty kuuntelemaan palvelimen juureen, eli reitille / tulevia pyyntöjä expressMiddleware-olion avulla. Kontekstiin asetetaan jo aiemmin määrittelemämme funktion avulla tieto kirjautuneesta käyttäjästä. Koska kyse on Express-palvelimesta, tarvitaan myös middlewaret express-json sekä cors, jotta pyynnöissä mukana oleva data parsitaan oikein ja jotta CORS-ongelmia ei ilmaannu.

GraphQL-palvelin on käynnistettävä ennen kuin Express-sovellus voi alkaa kuuntelemaan määriteltyä porttia, joten startServer-funktiosta on tehty async-funktio, jotta GraphQL-palvelimen käynnistymisen odottaminen on mahdollista:

await server.start()

GraphQL-palvelimen konfiguroinnin yhteyteen on lisätty dokumentaatioiden suositusten mukaan ApolloServerPluginDrainHttpServer:

  const server = new ApolloServer({
    schema: makeExecutableSchema({ typeDefs, resolvers }),
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],  })

Kyseinen plugin huolehtii siitä, että palvelin ajetaan alas siististi, kun palvelimen suoritus lopetetaan. Esimerkiksi käsittelyssä olevat pyynnöt voidaan sen ansiosta käsitellä loppuun, client-yhteydet suljettaan, jotta ne eivät jää roikkumaan.

Backendin tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part8-6.

Tilaukset palvelimella

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

Skeemaan tarvitaan seuraava lisäys:

type Subscription {
  personAdded: Person!
}    

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

Asennetaan tarvittavat kirjastot:

npm install graphql-ws ws @graphql-tools/schema

Tiedosto server.js muuttuu seuraavasti:

const { WebSocketServer } = require('ws')const { useServer } = require('graphql-ws/use/ws')
// ...

const startServer = async (port) => {
  const app = express()
  const httpServer = http.createServer(app)

  const wsServer = new WebSocketServer({    server: httpServer,    path: '/',  })   const schema = makeExecutableSchema({ typeDefs, resolvers })  const serverCleanup = useServer({ schema }, wsServer)
  const server = new ApolloServer({
    schema,     plugins: [      ApolloServerPluginDrainHttpServer({ httpServer }),      {        async serverWillStart() {          return {            async drainServer() {              await serverCleanup.dispose();            },          }        },      },    ],  })

  await server.start()

  // ...
}

GraphQL:n Queryt ja mutaatiot hoidetaan HTTP-protokollaa käyttäen. Tilausten osalta kommunikaatio tapahtuu WebSocket-yhteydellä.

Yllä oleva konfiguraatio luo palvelimeen HTTP-pyyntöjen kuuntelun rinnalle WebSocketeja kuuntelevan palvelun, jonka se sitoo palvelimen GraphQL-skeemaan. Määrittelyn toinen osa rekisteröi funktion, joka sulkee WebSocket-yhteyden palvelimen sulkemisen yhteydessä. Jos olet kiinnostunut tarkemmin konfiguraatioista, Apollon dokumentaatio selittää suhteellisen tarkasti mitä kukin koodirivi tekee.

Toisin kuin HTTP:n yhteydessä, WebSocketteja käyttäessä myös palvelin voi olla datan lähettämisessä aloitteellinen osapuoli. Näin ollen WebSocketit sopivat hyvin GraphQL:n tilauksiin, missä palvelimen on pystyttävä kertomaan kaikille tietyn tilauksen tehneille tilausta vastaavan tapahtuman (esim. henkilön luominen) tapahtumisesta.

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.

Asennetaan ensin publish-subscribe-toiminnallisuuden tarjoava kirjasto:

npm install graphql-subscriptions

Muutokset tiedostoon resolvers.js ovat seuraavassa:

const { GraphQLError } = require('graphql')
const { PubSub } = require('graphql-subscriptions')const jwt = require('jsonwebtoken')

const Person = require('./models/person')
const User = require('./models/user')

const pubsub = new PubSub()
const resolvers = {
  // ...
  Mutation: {
    addPerson: async (root, args, context) => {
        const currentUser = context.currentUser

        if (!currentUser) {
          throw new GraphQLError('not authenticated', {
            extensions: {
              code: 'UNAUTHENTICATED',
            },
          })
        }

        const nameExists = await Person.exists({ name: args.name })

        if (nameExists) {
          throw new GraphQLError(`Name must be unique: ${args.name}`, {
            extensions: {
              code: 'BAD_USER_INPUT',
              invalidArgs: args.name,
            },
          })
        }

      const person = new Person({ ...args })

      try {
        await person.save()
        currentUser.friends = currentUser.friends.concat(person)
        await currentUser.save()
      } catch (error) {
        throw new GraphQLError(`Saving person failed: ${error.message}`, {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
            error,
          },
        })
      }


      pubsub.publish('PERSON_ADDED', { personAdded: person })
      return person
    },
    // ...
  },
  Subscription: {    personAdded: {      subscribe: () => pubsub.asyncIterableIterator('PERSON_ADDED')    },  },}

Tilausten yhteydessä kommunikaatio tapahtuu publish-subscribe-periaatteella käyttäen olioa PubSub.

Koodia on vähän mutta konepellin alla tapahtuu paljon. Tilauksen personAdded resolverissa palvelin rekisteröi ja tallettaa muistiin tiedon kaikista sille tilauksen tehneistä clienteista. Nämä tallentuvat seuraavan koodinpätkän ansiosta nimellä PERSON_ADDED varustettuun "iteraattoriolioon":

Subscription: {
  personAdded: {
    subscribe: () => pubsub.asyncIterableIterator('PERSON_ADDED')
  },
},

Iteraattorin nimi on mielivaltainen merkkijono, joka on nyt valittu kuvaavalla tavalla mutta kirjoitettu konvention mukaan isoin kirjaimin.

Uuden henkilön lisäys julkaisee tiedon lisäyksestä kaikille muutokset tilanneille PubSubin metodilla publish:

pubsub.publish('PERSON_ADDED', { personAdded: person }) 

Koodirivin suoritus saa siis aikaan sen, että kaikille iteraattoriin PERSON_ADDED rekisteröidyille clienteille lähtee WebSocketin avulla tieto luodusta käyttäjästä.

Tilauksia on mahdollista testata Apollo Explorerin avulla seuraavasti:

fullstack content

Tilaus siis on

subscription Subscription {
  personAdded {
    phone
    name
  }
}

Kun tilauksen suorittavaa sinistä PersonAdded-painiketta painetaan, jää Explorer odottamaan tilaukseen tulevia vastauksia. Aina kun sovellukseen lisätään uusia henkilöitä, tulee tieto niistä Explorerin oikeaan reunaan.

Tilausten toteuttamiseen liittyy paljon erilaista konfiguraatiota. Tämän kurssin muutamasta tehtävästä selviät hyvin välittämättä kaikista yksityiskohdista. Jos olet toteuttamassa tilauksia todelliseen käyttöön tulevaan sovellukseen, kannattaa ehdottomasti lukea Apollon tilauksia käsittelevä dokumentaatio.

Backendin koodi on kokonaisuudessaan GitHubissa, branchissa part8-7.

Tilaukset clientissä

Jotta saamme tilaukset käyttöön React-sovelluksessa, tarvitaan jonkin verran muutoksia erityisesti konfiguraatioiden osalta.

Lisätään frontendin riippuvuudeksi kirjasto graphql-ws, joka mahdollistaa WebSocket-yhteydet GraphQL:n tilauksia varten:

npm install graphql-ws

Tiedostossa main.jsx olevat konfiguraatiot on muokattava seuraavaan muotoon:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

import {
  ApolloClient,
  ApolloLink,  HttpLink,
  InMemoryCache,
} from '@apollo/client'
import { ApolloProvider } from '@apollo/client/react'
import { SetContextLink } from '@apollo/client/link/context'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'import { getMainDefinition } from '@apollo/client/utilities'import { createClient } from 'graphql-ws'
const authLink = new SetContextLink(({ headers }) => {
  const token = localStorage.getItem('phonebook-user-token')
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : null,
    },
  }
})

const httpLink = new HttpLink({ uri: 'http://localhost:4000' })

const wsLink = new GraphQLWsLink(  createClient({    url: 'ws://localhost:4000',  }),)
const splitLink = ApolloLink.split(  ({ query }) => {    const definition = getMainDefinition(query)    return (      definition.kind === 'OperationDefinition' &&      definition.operation === 'subscription'    )  },  wsLink,  authLink.concat(httpLink),)
const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: splitLink,})

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

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

const httpLink = new HttpLink({ uri: 'http://localhost:4000' })

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000',
  }),
)

Muokataan sitten ohjelmaa siten, että se tilaa tiedon uusista henkilöistä palvelimelta. Lisätään tiedostoon queries.js tilauksen määrittelevä koodi:

export const PERSON_ADDED = gql`
  subscription {
    personAdded {
      ...PersonDetails
    }
  }

  ${PERSON_DETAILS}
`

Tilaukset tehdään hook-funktion useSubscription avulla. Tehdään tilaus komponentissa App:

import {
  useApolloClient,
  useQuery,
  useSubscription,} from '@apollo/client/react'
import { useState } from 'react'
import LoginForm from './components/LoginForm'
import Notify from './components/Notify'
import PersonForm from './components/PersonForm'
import Persons from './components/Persons'
import PhoneForm from './components/PhoneForm'
import { ALL_PERSONS, PERSON_ADDED } from './queries'
const App = () => {
  const [token, setToken] = useState(
    localStorage.getItem('phonebook-user-token'),
  )
  const [errorMessage, setErrorMessage] = useState(null)
  const result = useQuery(ALL_PERSONS)
  const client = useApolloClient()

  useSubscription(PERSON_ADDED, {    onData: ({ data }) => {      console.log(data)    },  })
  if (result.loading) {
    return <div>loading...</div>
  }

  // ...
}

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 useSubscription-hookin onData-attribuutin arvoksi määriteltyä callback-funktiota kutsutaan antaen sille parametriksi palvelimelle lisätty henkilö.

Voimme näyttää käyttäjälle ilmoituksen uuden henkilön lisäämisen yhteydessä seuraavasti:

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

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

  // ...
}

Laajennetaan ratkaisua vielä siten, että uuden henkilön tietojen saapuessa henkilö lisätään Apollon välimuistiin, jolloin se renderöityy heti ruudulle:

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

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

      client.cache.updateQuery({ query: ALL_PERSONS }, ({ allPersons }) => {        return {          allPersons: allPersons.concat(addedPerson),        }      })    }
  })

  // ...
}

Nyt esimerkiksi Apollo Studio Explorerin kautta lisätty henkilö renderöityy saman tien sovelluksen näkymään.

Ratkaisussa on kuitenkin pieni ongelma. Kun uusi henkilö lisätään sovelluksen lomakkeen kautta, lisätty henkilö päätyy välimuistiin kahdesti, sillä sekä useSubscription-hook että komponentti PersonForm lisävät uuden henkilön välimuistiin. Tämän seurauksena lisätty henkilö renderöidään ruudulle kahteen kertaan.

Eräs ratkaisu ongelmaan voisi olla se, että välimuistin päivitys tehtäisiin ainoastaan useSucscription-hookissa. Tämä ei ole kuitenkaan suositeltavaa. On hyvän käytännön mukaista, että käyttäjä näkee sovelluksessa tekemänsä muutokset välittömästi. Subscriptionin tekemä välimuistin päivitys voi tapahtua viiveellä eikä siihen voi luottaa täysin. Pitäydytään siksi ratkaisussa, jossa välimuistia päivitetään sekä useSucscription-hookissa että PersonForm-komponentissa.

Ratkaistaan ongelma varmistamalla, että henkilö lisätään välimuistiin vain jos sitä ei ole jo lisätty sinne. Eriytetään samalla välimuistin päivitysoperaatio omaan apufunktioonsa tiedostoon utils/apolloCache.js:

import { ALL_PERSONS } from '../queries'

export const addPersonToCache = (cache, personToAdd) => {
  cache.updateQuery({ query: ALL_PERSONS }, ({ allPersons }) => {
    const personExists = allPersons.some(
      (person) => person.id === personToAdd.id,
    )

    if (personExists) {
      return { allPersons }
    }

    return {
      allPersons: allPersons.concat(personToAdd),
    }
  })
}

Apufunktio addPersonToCache päivittää välimuistia tutulla cache.updateQuery-metodilla. Välimuistin päivityslogiikassa tarkistetaan ensin, onko henkilö jo lisätty välimuistiin. Lisättävää henkilöä etsitään välimuistissa olevien henkilöiden joukosta käyttäen JavaScriptin taulukoiden metodia some:

  const personExists = allPersons.some(
    (person) => person.id === personToAdd.id,
  )

some on metodi, joka etsii sopivaa oliota kokoelmasta annetun ehdon perusteella. Se palauttaa totuusarvon, joka kertoo, löytyikö sopivaa alkiota vai ei. Tapauksessamme metodi siis palauttaa True, jos välimuistista löytyy henkilö kyseisellä id:llä, ja muulloin palautetaan False.

Jos henkilö löytyy jo välimuistista, välimuistin sisältö palautetaan sellaisenaan eikä henkilöä lisätä uudestaan. Muussa tapauksessa palautetaan välimuistin sisältö, johon on lisätty concat-metodilla uusi henkilö:

  if (personExists) {
    return { allPersons }
  }

  return {
    allPersons: allPersons.concat(personToAdd),
  }

Muutetaan App-komponentin useSubscription-hookia niin, että se hoitaa välimuistin päivityksen tekemällämme addPersonToCache-apufunktiolla:

import { addPersonToCache } from './utils/apolloCache'
const App = () => {
  const [token, setToken] = useState(
    localStorage.getItem('phonebook-user-token'),
  )
  const [errorMessage, setErrorMessage] = useState(null)
  const result = useQuery(ALL_PERSONS)
  const client = useApolloClient()

  useSubscription(PERSON_ADDED, {
    onData: ({ data }) => {
      const addedPerson = data.data.personAdded
      notify(`${addedPerson.name} added`)
      addPersonToCache(client.cache, addedPerson)    },
  })

  // ...
}

ja hyödynnetään funktiota myös uuden henkilön lisäyksen yhteydessä tapahtuvassa välimuistin päivityksessä:

import { addPersonToCache } from '../utils/apolloCache'
const PersonForm = ({ setError }) => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')
  const [street, setStreet] = useState('')
  const [city, setCity] = useState('')

  const [createPerson] = useMutation(CREATE_PERSON, {
    onError: (error) => setError(error.message),
    update: (cache, response) => {
      const addedPerson = response.data.addPerson      addPersonToCache(cache, addedPerson)    },
  })

  // ...
}

Nyt välimuistin päivitys toimii oikein kaikissa tilanteissa, eli uusi henkilö lisätään välimuistiin vain jos sitä ei ole jo lisätty sinne.

Clientin lopullinen koodi GitHubissa, branchissa part8-6.

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: ({ street, city }) => {
    return {
      street,
      city,
    }
  },
  friendOf: async (root) => {    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. Lisätään resolverien tietokantakyselyn tekeviin kohtiin konsolitulostus:

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

  return Person.find({ phone: { $exists: args.phone === 'YES' } })
}
friendOf: async (root) => {
  console.log('User.find')  const friends = await User.find({
    friends: {
      $in: [root._id],
    },
  })

  return friends
}

Huomaamme, että jos tietokannassa on viisi henkilöä, aiemmin mainittu allPersons-kysely aiheuttaa seuraavat tietokantakyselyt:

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

Eli vaikka pääasiallisesti tehdään ainoastaan yksi kysely, joka hakee 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,
    minlength: 5
  },
  phone: {
    type: String,
    minlength: 5
  },
  street: {
    type: String,
    required: true,
    minlength: 5
  },  
  city: {
    type: String,
    required: true,
    minlength: 3
  },
  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 muokattaisiin tekemään liitoskysely sen varalta, että se aiheuttaa välillä n+1-ongelman, tulisi kyselystä hieman raskaampi niissäkin tapauksissa, 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. Teimme pientä siivousta siirtämällä skeeman ja resolverit omiin tiedostoihin, mutta parantamisen varaa jäi edelleen paljon. 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, joten teknologian voi todeta olevan "battle tested". Facebook julkaisi GraphQL:n vuonna 2015 ja se on sittemmin vakiinnuttanut asemansa. REST:in kuolemaakin ennusteltiin ennen 2020-lukua, mutta näin ei ole kuitenkaan käynyt. REST on edelleen laajasti käytetty ja toimii yhä erinomaisesti monissa tapauksissa, ja GraphQL tuskin syrjäyttää koskaan RESTiä. GraphQL:stä on kuitenkin tullut vaihtoehtoinen tapa rakentaa rajapintoja, ja se on ehdottomasti tutustumisen arvoinen vaihtoehto.