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. 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:

export const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    findPerson(name: $nameToSearch) {
      ...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 "dollariaaltosulku"-operaatiolla:

export const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    findPerson(name: $nameToSearch) {
      ...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.

Backendin refaktorointia

Apollo Server ei versiosta 3.0 alkaen enää ole tarjonnut suoraa tukea subscriptiolle ja joudummekin tekemään joukon muutoksia että saamme ne toimimaan. Siistitään samalla myös sovelluksen rakennetta hiukan.

Aloitetaan eriyttämällä backendissä skeeman määrittely omaan tiedostoon schema.js

const typeDefs = `
  type User {
    username: String!
    friends: [Person!]!
    id: ID!
  }

  type Token {
    value: String!
  }

  type Address {
    street: String!
    city: String!
  }

  type Person {
    name: String!
    phone: String
    address: Address!
    id: ID!
  }

  enum YesNo {
    YES
    NO
  }

  type Query {
    personCount: Int!
    allPersons(phone: YesNo): [Person!]!
    findPerson(name: String!): Person
    me: User
  }

  type Mutation {
    addPerson(
      name: String!
      phone: String
      street: String!
      city: String!
    ): Person
    editNumber(name: String!, phone: String!): Person
    createUser(username: String!): User
    login(username: String!, password: String!): Token
    addAsFriend(name: String!): User
  }
`
module.exports = typeDefs

Siirretään resolverien määrittely tiedostoon resolvers.js

const { GraphQLError } = require('graphql')
const jwt = require('jsonwebtoken')
const Person = require('./models/person')
const User = require('./models/user')

const resolvers = {
  Query: {
    personCount: async () => Person.collection.countDocuments(),
    allPersons: async (root, args, context) => {
      if (!args.phone) {
        return Person.find({})
      }
  
      return Person.find({ phone: { $exists: args.phone === 'YES'  }})
    },
    findPerson: async (root, args) => Person.findOne({ name: args.name }),
    me: (root, args, context) => {
      return context.currentUser
    }
  },
  Person: {
    address: ({ street, city }) => {
      return {
        street,
        city,
      }
    },
  },
  Mutation: {
    addPerson: async (root, args, context) => {
      const person = new Person({ ...args })
      const currentUser = context.currentUser

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

      try {
        await person.save()
        currentUser.friends = currentUser.friends.concat(person)
        await currentUser.save()
      } catch (error) {
        throw new GraphQLError('Saving user failed', {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
            error
          }
        })
      }
  
      return person
    },
    editNumber: async (root, args) => {
      const person = await Person.findOne({ name: args.name })
      person.phone = args.phone
      
      try {
        await person.save()
      } catch (error) {
        throw new GraphQLError('Editing number failed', {
          extensions: {
            code: 'BAD_USER_INPUT',
            invalidArgs: args.name,
            error
          }
        })
      }

      return person
    },
    createUser: async (root, args) => {
      const user = new User({ username: args.username })
  
      return user.save()
        .catch(error => {
          throw new GraphQLError('Creating the user failed', {
            extensions: {
              code: 'BAD_USER_INPUT',
              invalidArgs: args.name,
              error
            }
          })
        })
    },
    login: async (root, args) => {
      const user = await User.findOne({ username: args.username })
  
      if ( !user || args.password !== 'secret' ) {
        throw new GraphQLError('wrong credentials', {
          extensions: { code: 'BAD_USER_INPUT' }
        })        
      }
  
      const userForToken = {
        username: user.username,
        id: user._id,
      }
  
      return { value: jwt.sign(userForToken, process.env.JWT_SECRET) }
    },
    addAsFriend: async (root, args, { currentUser }) => {
      const nonFriendAlready = (person) => 
        !currentUser.friends.map(f => f._id.toString()).includes(person._id.toString())
  
      if (!currentUser) {
        throw new GraphQLError('wrong credentials', {
          extensions: { code: 'BAD_USER_INPUT' }
        }) 
      }
  
      const person = await Person.findOne({ name: args.name })
      if ( nonFriendAlready(person) ) {
        currentUser.friends = currentUser.friends.concat(person)
      }
  
      await currentUser.save()
  
      return currentUser
    },
  }
}

module.exports = resolvers

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 server = new ApolloServer({
  typeDefs,
  resolvers,
})

startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req, res }) => {
    /// ...
  },
}).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:

npm install express cors

ja muutetaan tiedosto index.js seuraavaan muotoon:

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

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

const typeDefs = require('./schema')
const resolvers = require('./resolvers')

const MONGODB_URI = process.env.MONGODB_URI

console.log('connecting to', MONGODB_URI)

mongoose
  .connect(MONGODB_URI)
  .then(() => {
    console.log('connected to MongoDB')
  })
  .catch((error) => {
    console.log('error connection to MongoDB:', error.message)
  })

// setup is now within a functionconst start = async () => {  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 ? req.headers.authorization : null        if (auth && auth.startsWith('Bearer ')) {          const decodedToken = jwt.verify(auth.substring(7), process.env.JWT_SECRET)          const currentUser = await User.findById(decodedToken.id).populate(            'friends'          )          return { currentUser }        }      },    }),  )  const PORT = 4000  httpServer.listen(PORT, () =>    console.log(`Server is now running on http://localhost:${PORT}`)  )}start()

Koodissa on useita muutoksia. GraphQL-palvelimen konfiguroinnin yhteyteen on nyt lisätty dokumentaatioiden suositusten mukaan ApolloServerPluginDrainHttpServer:

We highly recommend using this plugin to ensure your server shuts down gracefully.

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.

Koska GraphQL-palvelin on käynnistettävä ennen kuin Express-sovellus voi alkaa kuuntelemaan määriteltyä porttia, on koko alustus jouduttu sijoittamaan async funktioon, joka mahdollistaa GraphQL-palvelimen käynnistymisen odottamisen:

await server.start()

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 index.js muuttuu seuraavasti

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

const start = async () => {
  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()

  app.use(
    '/',
    cors(),
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => {
        const auth = req ? req.headers.authorization : null
        if (auth && auth.startsWith('Bearer ')) {
          const decodedToken = jwt.verify(auth.substring(7), process.env.JWT_SECRET)
          const currentUser = await User.findById(decodedToken.id).populate(
            'friends'
          )
          return { currentUser }
        }
      },
    }),
  )

  const PORT = 4000

  httpServer.listen(PORT, () =>
    console.log(`Server is now running on http://localhost:${PORT}`)
  )
}

start()

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

Yllä oleva konfio 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, selittää Apollon dokumentaatio 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.

Muutokset ovat seuraavassa:

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

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

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

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

Toimiakseen koodi vaatii kirjaston asennuksen

npm install graphql-subscriptions

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.asyncIterator('PERSON_ADDED')
  },
},

Iteraattorin nimi on mielivaltainen merkkijono, joka on nyt valittu kuvaavalla tavalla mutta kirjoitettu konventtion 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ä (joudut tekemään lisäyksen frontendista tai toisesta selainikkunasta), tulee tieto niistä Explorerin oikeaan reunaan.

Jos tilaus ei toimi, tarkasta, että yhteysasetukset on määritelty oikein:

fullstack content

Backendin koodi on kokonaisuudessaan GitHubissa, branchissa part8-7.

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

Tilaukset clientissä

Jotta saamme tilaukset käyttöön React-sovelluksessa, tarvitaan jonkin verran muutoksia erityisesti konfiguraatioiden osalta. Tiedostossa main.jsx olevat konfiguraatiot on muokattava seuraavaan muotoon:

import { 
  ApolloClient, InMemoryCache, ApolloProvider, createHttpLink, 
  split} from '@apollo/client'
import { setContext } from 'apollo-link-context'

import { getMainDefinition } from '@apollo/client/utilities'import { GraphQLWsLink } from '@apollo/client/link/subscriptions'import { createClient } from 'graphql-ws'
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('phonenumbers-user-token')
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : null,
    }
  }
})

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

const wsLink = new GraphQLWsLink(createClient({  url: 'ws://localhost:4000',}))
const splitLink = 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})

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

Jotta kaikki toimisi, on asennettava uusi riippuvuus:

npm install graphql-ws

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

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

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

Tilaukset tehdään hook-funktion useSubscription avulla.

Tehdään koodiin seuraavat muutokset. Lisätään tiedostoon queries.js tilauksen määrittelevä koodi:

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

ja tehdään tilaus komponentissa App:

import { useQuery, useApolloClient, useSubscription } from '@apollo/client'
import { PERSON_ADDED } from './queries.js'

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

  useSubscription(PERSON_ADDED, {
    onData: ({ data }) => {
      console.log(data)
    }
  })

  // ...
}

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 onData 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:

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),        }      })    }
  })

  // ...
}

Ratkaisussa on kuitenkin pieni ongelma. Itse lisätty henkilö tulee nyt välimuistiin sekä renderöityy ruudulle kahteen kertaan, sillä myös komponentti PersonForm lisää uuden henkilön välimuistiin.

Ratkaistaan ongelma varmistamalla, että sama henkilö ei päädy välimuistiin kahteen kertaan:

// function that takes care of manipulating cacheexport const updateCache = (cache, query, addedPerson) => {  // helper that is used to eliminate saving same person twice  const uniqByName = (a) => {    let seen = new Set()    return a.filter((item) => {      let k = item.name      return seen.has(k) ? false : seen.add(k)    })  }
  cache.updateQuery(query, ({ allPersons }) => {    return {      allPersons: uniqByName(allPersons.concat(addedPerson)),    }  })}
const App = () => {
  const result = useQuery(ALL_PERSONS)
  const [errorMessage, setErrorMessage] = useState(null)
  const [token, setToken] = useState(null)
  const client = useApolloClient() 

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

  // ...
}

Funktio updateCache lisää uuden henkilön tiedot välimuistin queryn allPersons tallentamiin henkilöihin, mutta varmistaa kuitenkin funktion uniqByName avulla, että yhden henkilön tiedot eivät tallennu välimuistiin useampaan kertaan.

Funktiota updateCache voidaan hyödyntää myös uuden henkilön lisäyksen yhteydessä tapahtuvassa välimuistin päivityksessä:

import { updateCache } from '../App'
const PersonForm = ({ setError }) => { 
  // ...

  const [createPerson] = useMutation(CREATE_PERSON, {
    onError: (error) => {
      setError(error.graphQLErrors[0].message)
    },
    update: (cache, response) => {
      updateCache(cache, { query: ALL_PERSONS }, response.data.addPerson)    },
  })
   
  // ..
} 

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: (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 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,
    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 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, 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.