GraphQL w React #3 – cache, WebSocket i debugowanie w Apollo Client

Ten post jest trzecim z serii o GraphQL:


Spis Treści

  1. Wprowadzenie
  2. Zarządzanie cache w Apollo Client
    1. Cykliczne powtarzanie zapytania do serwera
    2. Ręczna aktualizacja cache
  3. Dwukierunkowa komunikacja z serwerem za pomocą Subscription i WebSocket
    1. Obsługa Subscriptions i WebSocket na serwerze
    2. Obsługa Subscriptions i WebSocket na kliencie
    3. Kod obu aplikacji na Github
  4. Debugowanie za pomocą Apollo Developer Tools
    1. Zakładka GraphQL
    2. Zakładka Queries
    3. Zakładka Mutations
    4. Zakładka Cache
  5. Podsumowanie

Wprowadzenie

Poprzednie dwa posty omówiły podstawy zastosowania GraphQL zarówno na stronie serwera jak i klienta. Możliwości Apollo Client wykraczają jednak daleko poza wysyłanie zapytań na serwer.

Czym więc zajmiemy się w tym wpisie?

  1. Cache’owaniem danych przez Apollo Client
  2. Dwukierunkową komunikacją pomiędzy serwerem a klientem
  3. Debugowaniem za pomocą Apollo Developer Tools

Zmęczenie wadami podejścia REST oraz rosnąca popularność GraphQL powodują, że warto przeczytać ten post i zgłębić wiedzę na temat Apollo Client.

Kod tego posta będzie rozszerzeniem kodu 2 poprzednich: GraphQL w React #1 – poznaj podstawy GraphQL! oraz GraphQL w React #2 – połącz się z serwerem dzięki Apollo Client.


Zarządzanie cache w Apollo Client

W poprzednim poście zdefiniowaliśmy aktualizowane cache poprzez odświeżenie rezultatu zapytania dla komponentu Owners:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const {data, refetch} = useQuery(gql`
query {
owners {
name
}
}
`);
const {data, refetch} = useQuery(gql` query { owners { name } } `);
const {data, refetch} = useQuery(gql`
      query {
          owners {
              name
          }
      }
  `);
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const [createOwner] = useMutation(gql`
mutation ($name: String!){
createOwner(owner: {name: $name}) {
id,
name,
}
}
`, {onCompleted: refetch});
const [createOwner] = useMutation(gql` mutation ($name: String!){ createOwner(owner: {name: $name}) { id, name, } } `, {onCompleted: refetch});
const [createOwner] = useMutation(gql`
    mutation ($name: String!){
      createOwner(owner: {name: $name}) {
        id,
        name,
      }
    }
  `, {onCompleted: refetch});

Oznaczało to, że po każdym wywołaniu funkcji createOwner, które tworzyło na serwerze nowy obiekt owner, cała lista była pobierana ponownie. Nie są to jednak jedyne możliwości aktualizowania cache

Cykliczne powtarzanie zapytania do serwera

Jeżeli zrezygnujemy z użycia funkcji refetch i zastąpimy ją ustawieniem pollInterval na wysyłanym na serwer query:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const {data} = useQuery(gql`
query {
owners {
name
}
}
`, {pollInterval: 30000});
const {data} = useQuery(gql` query { owners { name } } `, {pollInterval: 30000});
const {data} = useQuery(gql`
      query {
          owners {
              name
          }
      }
  `, {pollInterval: 30000});

Zauważymy, że co 30 sekund na serwer wysyłane będzie żądanie zwracające obiekty owners. Jeżeli w międzyczasie dodamy jakiś obiekt, nie doda się on do listy od razu – a dopiero po tym, jak zapytanie pobierające listę elementów załaduje nowe dane.

Ręczna aktualizacja cache

Ponieważ w pierwszym poście zdefiniowaliśmy, że żądanie utworzenia obiektu Owner na serwerze zwraca nowo utworzony obiekt:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Mutation: {
createOwner: (parent, args, context, info) => {
let owner = {...args.owner, id: owners.length + 1};
owners.push(owner);
return owner;
}
}
Mutation: { createOwner: (parent, args, context, info) => { let owner = {...args.owner, id: owners.length + 1}; owners.push(owner); return owner; } }
Mutation: {
    createOwner: (parent, args, context, info) => {
      let owner = {...args.owner, id: owners.length + 1};
      owners.push(owner);
      return owner;
    }
  }

Możemy dzięki tej zwrotce zaktualizować cache bez konieczności odpytywania serwera o całą listę owners:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const loadOwnersQuery = gql`
query {
owners {
name
}
}
`;
const {data} = useQuery(loadOwnersQuery);
const [createOwner] = useMutation(gql`
mutation ($name: String!){
createOwner(owner: {name: $name}) {
id,
name,
}
}
`, {update: (store, request) => {
const data = store.readQuery({query: loadOwnersQuery})
const createdOwner = request.data.createOwner
const updatedOwners = [...data.owners, createdOwner]
store.writeQuery({ query: loadOwnersQuery, data: {owners: updatedOwners}})
}});
const loadOwnersQuery = gql` query { owners { name } } `; const {data} = useQuery(loadOwnersQuery); const [createOwner] = useMutation(gql` mutation ($name: String!){ createOwner(owner: {name: $name}) { id, name, } } `, {update: (store, request) => { const data = store.readQuery({query: loadOwnersQuery}) const createdOwner = request.data.createOwner const updatedOwners = [...data.owners, createdOwner] store.writeQuery({ query: loadOwnersQuery, data: {owners: updatedOwners}}) }});
const loadOwnersQuery = gql`
      query {
          owners {
              name
          }
      }
  `;

  const {data} = useQuery(loadOwnersQuery);

  const [createOwner] = useMutation(gql`
    mutation ($name: String!){
      createOwner(owner: {name: $name}) {
        id,
        name,
      }
    }
  `, {update: (store, request) => {
      const data = store.readQuery({query: loadOwnersQuery})
      const createdOwner = request.data.createOwner
      const updatedOwners = [...data.owners, createdOwner]
      store.writeQuery({ query: loadOwnersQuery, data: {owners: updatedOwners}})
    }});

Przekazujemy w opcjach fukcję update do naszej mutacji. Wewnątrz mamy dostęp do stanu, skąd za pomocą metody readQuery pobieramy aktualne dane. Później za pomocą metody writeQuery wpisujemy dane zaktualizowane o obiekt utworzony na serwerze.


Dwukierunkowa komunikacja z serwerem za pomocą Subscription i WebSocket

Ponieważ w poście dotyczącym części serwerowej zajmowaliśmy się tylko obsługą żądań HTTP, konieczne będą zmiany nie tylko na kliencie, ale również na serwerze.

Obsługa Subscriptions i WebSocket na serwerze

Aby umożliwić publikowanie i subskrybowanie należy zaimportować klasę PubSub i utworzyć jej instancję:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const { ApolloServer, gql, PubSub } = require("apollo-server");
const pubsub = new PubSub();
const { ApolloServer, gql, PubSub } = require("apollo-server"); const pubsub = new PubSub();
const { ApolloServer, gql, PubSub } = require("apollo-server");

const pubsub = new PubSub();

Należy zmienić również definicję typów:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// inne typy, Mutation dla lepszej orientacji
type Mutation {
createOwner(owner: OwnerInput!): Owner!
}
type Subscription {
ownerAdded: Owner!
}
// inne typy, Mutation dla lepszej orientacji type Mutation { createOwner(owner: OwnerInput!): Owner! } type Subscription { ownerAdded: Owner! }
// inne typy, Mutation dla lepszej orientacji
type Mutation {
  createOwner(owner: OwnerInput!): Owner!
}
  
type Subscription {
    ownerAdded: Owner!
}

Oraz dodać resolver dla typu Subscription:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const resolvers = {
//inne resolvery
Subscription: {
ownerAdded: {
subscribe: () => pubsub.asyncIterator(['OWNER_ADDED']),
}
},
}
const resolvers = { //inne resolvery Subscription: { ownerAdded: { subscribe: () => pubsub.asyncIterator(['OWNER_ADDED']), } }, }
const resolvers = {
  //inne resolvery
  Subscription: {
    ownerAdded: {
      subscribe: () => pubsub.asyncIterator(['OWNER_ADDED']),
    }
  },
}

Zmodyfikujmy również resolver odpowiadający za dodawanie nowego obiektu typu Owner:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const resolvers = {
Mutation: {
createOwner: (parent, args, context, info) => {
let id = owners.length + 1;
let owner = {...args.owner, id};
owners.push(owner);
pubsub.publish('OWNER_ADDED', {ownerAdded: owner})
return owner;
}
},
}
const resolvers = { Mutation: { createOwner: (parent, args, context, info) => { let id = owners.length + 1; let owner = {...args.owner, id}; owners.push(owner); pubsub.publish('OWNER_ADDED', {ownerAdded: owner}) return owner; } }, }
const resolvers = {
  Mutation: {
    createOwner: (parent, args, context, info) => {
      let id = owners.length + 1;
      let owner = {...args.owner, id};
      owners.push(owner);
      pubsub.publish('OWNER_ADDED', {ownerAdded: owner})
      return owner;
    }
  },
}

Podczas dodawania obiektu publikowany jest event, który zawiera świeżo dodany obiekt.

Zmodyfikować powinniśmy również start serwera jak, by móc wyświetlić URL pod którym subskrypcja będzie możliwa:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
server.listen()
.then(({url, subscriptionsUrl}) => {
console.log(`Sub url: ${subscriptionsUrl}`);
console.log(`Server url: ${url}`);
})
server.listen() .then(({url, subscriptionsUrl}) => { console.log(`Sub url: ${subscriptionsUrl}`); console.log(`Server url: ${url}`); })
server.listen()
  .then(({url, subscriptionsUrl}) => {
    console.log(`Sub url: ${subscriptionsUrl}`);
    console.log(`Server url: ${url}`);
  })

Obsługa Subscriptions i WebSocket na kliencie

Zainstalujmy bibliotekę która obsłuży komunikację WebSocket, wymaganą przez Apollo Client:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
npm install subscriptions-transport-ws
npm install subscriptions-transport-ws
npm install subscriptions-transport-ws

Następnie, aby móc używać równocześnie komunikacji WebSocket i HTTP, konieczne będzie decydowanie którego adresu należy użyć do realizacji Query które otrzyma instancja klienta Apollo Client:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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,
httpLink,
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
});
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, httpLink, ); const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache() });
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,
  httpLink,
);

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

Natomiast do zasubskrybowania do zdefiniowanego na serwerze eventu ownerAdded użyjemy hooka useSubscription:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const { data: subData } = useSubscription(gql`
subscription onOwnerAdded {
ownerAdded {
id
}
}
`)
useEffect(() => {
if (subData?.ownerAdded?.id) {
alert(`Owner with id: ${subData.ownerAdded.id} added`);
}
}, [subData])
const { data: subData } = useSubscription(gql` subscription onOwnerAdded { ownerAdded { id } } `) useEffect(() => { if (subData?.ownerAdded?.id) { alert(`Owner with id: ${subData.ownerAdded.id} added`); } }, [subData])
  const { data: subData } = useSubscription(gql`
      subscription onOwnerAdded {
          ownerAdded {
              id
          }
      }
  `)

  useEffect(() => {
    if (subData?.ownerAdded?.id) {
      alert(`Owner with id: ${subData.ownerAdded.id} added`);
    }
  }, [subData])

Kod obu aplikacji na Github

Kod aplikacji serwera:

https://github.com/radek-anuszewski/graphql-server-example-2

Kod aplikacji klienta:

https://github.com/radek-anuszewski/graphql-client-example-2


Debugowanie za pomocą Apollo Developer Tools

Zainstalujmy oficjalne rozszerzenie do przeglądarki Chrome. Po zainstalowaniu pojawił się dodatkowy tab „Apollo”.

Zakładka GraphQL

W zakładce GraphQL mamy możliwość wysyłania zapytań na serwer, co ważne – po kliknięciu przycisku Explorer możemy wyklikać sobie co serwer ma nam zwrócić:

Zakładka Queries

W zakładce Queries znajduje się historia wszystkich zapytań wysłanych na serwer które pobrały dane. Mamy możliwość bezpośredniego uruchomienia tych zapytań dzięki przyciskowi „Run in GraphQL”:

Zakładka Mutations

W zakładce Mutations znajdują się wszystkie zapytania modyfikujące dane na serwerze:

Zakładka Cache

W zakłace Cache znajdziemy zapisane dane z wysłanych już zapytań:


Podsumowanie

Apollo pozwala nam w prosty obsłużyć zaawansowane przypadki wymagające użycia dwukierunkowej komunikacji między serwerem a klientem czy też obsługę cache. Udostępnia też narzędzia mocno wspomagające debugowanie. Platforma ta będzie doskonałym wyborem implementacji GraphQL w projekcie.