e
Fragmentit ja subskriptiot
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:
export 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 "dollariaaltosulku"-operaatiolla:
export 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:
Kun tilauksen "play"-painiketta painetaan, jää playground odottamaan tilaukseen tulevia vastauksia. Aina kun sovellukseen lisätään uusia käyttäjiä, tulee tieto niistä playgroundin oikeanpuoleiseen ikkunaan.
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 {
ApolloClient, ApolloProvider, HttpLink, InMemoryCache,
split} from '@apollo/client'
import { setContext } from 'apollo-link-context'
import { getMainDefinition } from '@apollo/client/utilities'import { WebSocketLink } from '@apollo/link-ws'
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('phonenumbers-user-token')
return {
headers: {
...headers,
authorization: token ? `bearer ${token}` : null,
}
}
})
const httpLink = new HttpLink({
uri: 'http://localhost:4000',
})
const wsLink = new WebSocketLink({ uri: 'ws://localhost:4000/graphql', options: { reconnect: true }})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.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById('root')
)
Jotta kaikki toimisi, on asennettava uusia riippuvuuksia:
npm install --save @apollo/link-ws subscriptions-transport-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`,
options: { reconnect: true }
})
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
})
Tilaukset tehdään hook-funktion useSubscription avulla.
Tehdään koodiin seuraavat muutokset:
export const PERSON_ADDED = gql` subscription { personAdded { ...PersonDetails } } ${PERSON_DETAILS}`
import {
useQuery, useMutation, useSubscription, useApolloClient} from '@apollo/client'
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:
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)
}
})
// ...
}
Funktiota updateCacheWith voidaan hyödyntää myös uuden henkilön lisäyksen yhteydessä tapahtuvassa välimuistin päivityksessä:
const PersonForm = ({ setError, updateCacheWith }) => { // ...
const [ createPerson ] = useMutation(CREATE_PERSON, {
onError: (error) => {
setError(error.graphQLErrors[0].message)
},
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 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 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.
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.