Projektowanie aplikacji do pracy z różnymi serwerami GraphQL
"Programowanie przeciwko interfejsom, nie implementacjom" to praktyka wywoływania funkcjonalności nie bezpośrednio, lecz poprzez kontrakt, który określa jakie dane wejściowe są wymagane i jaki jest oczekiwany wynik, ukrywając sposób realizacji implementacji. Ta strategia pomaga oddzielić aplikację od konkretnej implementacji, dostawcy lub stosu technologicznego, umożliwiając zamianę między nimi bez konieczności zmiany kodu aplikacji.
Możemy zastosować tę strategię również z GraphQL. GraphQL może działać jako pośrednik między aplikacją a serwerem, pozwalając nam wykonywać wszystkie potrzebne modyfikacje jedynie w queries GraphQL, zachowując logikę biznesową nietkniętą.
Query GraphQL działa jako interfejs między klientem a serwerem. Podczas wykonywania query serwer GraphQL przetworzy ją i zwróci wymagane dane do klienta. Skąd pochodzą dane? W jaki sposób zostały uzyskane? Klient nie wie i nie dba o to.

Odpowiedź na query będzie miała ten sam kształt co query. Dla tej query GraphQL:
{
post(by: { id: 1 }) {
id
title
}
}...odpowiedź będzie:
{
"data": {
"post": {
"id": 1,
"title": "Hello world!"
}
}
}Dla tej samej query z różnymi parametrami zwrócone dane będą inne, ale kształt pozostanie stały. Oznacza to, że dopóki query się nie zmienia, aplikacja nie musi zmieniać swojej logiki dotyczącej sposobu odczytu i przetwarzania danych, i podobnie nie będzie miało znaczenia, który serwer GraphQL wykonuje query.
Dzięki temu możemy bezproblemowo zamienić jeden serwer GraphQL na inny.
Queries zależą od schematu GraphQL
Ostatni akapit jest nieco zbyt optymistyczny, ponieważ query GraphQL może wymagać zmiany w zależności od serwera GraphQL. Mówiąc precyzyjniej, query jest oparta na schemacie GraphQL, a jeśli różne serwery udostępniają różne schematy, to query również będzie inna.
Na przykład serwer GraphQL korzystający ze specyfikacji Cursor Connections może wykonywać następującą query:
{
categories(first: 10000) {
edges {
node {
categoryId
description
id
name
slug
}
}
}
}A inny serwer korzystający z paginacji w stylu WordPress (taki jak Gato GraphQL) wykona tę samą query w ten sposób:
{
postCategories(pagination: { limit: 10000 }) {
id
description
globalID
name
slug
}
}Możemy dostrzec różnice między tymi dwiema queries:
| Cecha | Serwer #1 | Serwer #2 |
|---|---|---|
| Pole kategorii postów | categories | postCategories |
| Argument pola do ograniczenia liczby wyników | first | pagination.limit |
Pole id obiektu reprezentuje | jego unikalne globalne ID | jego unikalne ID dla swojego typu |
| Kształt query | głębszy ze względu na edges.node | płaski |
Samo zastąpienie query pierwszego serwera równoważną query drugiego wewnątrz aplikacji nie zadziała. Dzieje się tak, ponieważ logika nadal będzie odczytywać dane z odpowiedzi zgodnie z kształtem i polami oryginalnej query.
Jednym z możliwych rozwiązań jest również zastąpienie logiki pobierania danych po stronie klienta. Na przykład następująca logika:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);...może zostać zastąpiona w ten sposób:
const categories = data?.data.postCategories;Ale to jest właśnie to, czego chcemy uniknąć. Chcemy ograniczyć zmiany do absolutnego minimum, modyfikując jedynie interfejs (query GraphQL) i pozostawiając logikę biznesową bez zmian.
Na szczęście możliwe jest wypełnienie różnic poprzez modyfikację jedynie queries GraphQL, wykonując następujące kroki:
- Utrzymanie queries GraphQL oddzielonych od aplikacji
- Dostosowanie nazw pól za pomocą aliasów
- Dostosowanie kształtu odpowiedzi za pomocą pola
self
Zobaczmy, jak za pomocą tych 3 kroków możemy dostosować aplikację do wskazywania na inny serwer GraphQL.
Utrzymanie queries GraphQL oddzielonych od aplikacji
Oddzielenie queries GraphQL od logiki aplikacji obejmuje:
- Przechowywanie każdej query GraphQL (lub grupy queries) w osobnym pliku, a wszystkich w konkretnym folderze
- Eksportowanie queries i importowanie ich do aplikacji
Na przykład możemy umieścić każdą query GraphQL w osobnym pliku w katalogu src/data i wyeksportować ją:
// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
{
categories(first: 10000) {
edges {
node {
databaseId
description
id
name
slug
}
}
}
}
`;Aplikacja może następnie zaimportować i użyć query GraphQL:
import { QUERY_ALL_CATEGORIES } from 'data/categories';
export async function getAllCategories() {
const apolloClient = getApolloClient();
const data = await apolloClient.query({
query: QUERY_ALL_CATEGORIES,
});
const categories = data?.data.categories.edges.map(({ node = {} }) => node);
return {
categories,
};
}Dzięki tej konfiguracji wszystkie modyfikacje muszą być wykonywane jedynie w plikach w katalogu src/data.
Dostosowanie nazw pól za pomocą aliasów
Alias pola może być użyty do zmiany nazwy pola w odpowiedzi drugiego serwera GraphQL na nazwę tego pola w pierwszym serwerze.
W ten sposób pola postCategories, id i globalID mogą być pobierane przy użyciu nazw oczekiwanych przez aplikację: odpowiednio categories, categoryId i id:
{
categories: postCategories(pagination: { limit: 10000 }) {
categoryId: id
description
id: globalID
name
slug
}
}Należy zauważyć, że pole categories ma argument first, podczas gdy odpowiadające mu pole postCategories używa argumentu pagination.limit. Ponieważ jednak argumenty pola nie są odzwierciedlone w nazwie pola w odpowiedzi, nie musimy się nimi martwić.
Dostosowanie kształtu odpowiedzi za pomocą pola self
Ostatnie wyzwanie jest nieco trudniejsze: musimy zmodyfikować kształt odpowiedzi, dodając dodatkowe poziomy dla edges i node pochodzące ze specyfikacji Cursor Connections.
Aby to osiągnąć, wprowadzamy pole self do wszystkich typów w schemacie GraphQL, które zwraca ten sam obiekt, na którym jest stosowane:
type QueryRoot {
self: QueryRoot!
}
type Post {
self: Post!
}
type User {
self: User!
}Pole self umożliwia dodanie dodatkowych poziomów do query bez opuszczania odpytywanego obiektu. Wykonanie tej query:
{
__typename
self {
__typename
}
post(by: { id: 1 }) {
self {
id
__typename
}
}
user(by: { id: 1 }) {
self {
id
__typename
}
}
}...daje tę odpowiedź:
{
"data": {
"__typename": "QueryRoot",
"self": {
"__typename": "QueryRoot"
},
"post": {
"self": {
"id": 1,
"__typename": "Post"
}
},
"user": {
"self": {
"id": 1,
"__typename": "User"
}
}
}
}Teraz możemy użyć self, aby sztucznie dodać poziomy nodes i edge:
{
categories: self {
edges: postCategories(pagination: { limit: 10000 }) {
node: self {
categoryId: id
description
id: globalID
name
slug
}
}
}
}Typ obiektu w schemacie GraphQL dla edges i dla self jest oczywiście różny. Jednak nie ma to znaczenia dla aplikacji, ponieważ nie wchodzi ona w interakcję z rzeczywistym obiektem zamodelowanym w serwerze GraphQL. Zamiast tego otrzymuje dane jako obiekt JSON, a ta porcja danych dla pola pochodzącego z obiektu PostConnection lub obiektu Post będzie taka sama.
Należy zauważyć, że pole categories jest rozwiązywane przez self, a edges jest rozwiązywane przez postCategories, a nie odwrotnie. Jest to konieczne, aby zachować kardynalność zwracanych elementów zgodną z tą zdefiniowaną przez pola korzystające ze specyfikacji Cursor Connections:
type RootQuery {
categories: RootQueryToCategoryConnection
}
type RootQueryToCategoryConnection {
edges: [RootQueryToCategoryConnectionEdge]
}
type RootQueryToCategoryConnectionEdge {
node: Category
}Gdyby dostosowana query GraphQL była odwrócona (tj. odpytując categories: postCategories i edges: self), dostęp do danych zawiódłby, ponieważ data.categories byłoby tablicą, więc data.categories.edges rzuciłoby błąd podczas wykonywania:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);Dostosowanie wszystkich queries
Po zastosowaniu tej samej strategii do wszystkich queries GraphQL w src/data, aplikacja może łatwo przełączyć się z jednego serwera GraphQL na inny.