Koncepcje, Idee, Strategie
Koncepcje, Idee, StrategieProjektowanie aplikacji do pracy z różnymi serwerami GraphQL

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.

Query GraphQL działa jako interfejs między klientem a serwerem

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:

CechaSerwer #1Serwer #2
Pole kategorii postówcategoriespostCategories
Argument pola do ograniczenia liczby wynikówfirstpagination.limit
Pole id obiektu reprezentujejego unikalne globalne IDjego unikalne ID dla swojego typu
Kształt querygłębszy ze względu na edges.nodepł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:

  1. Utrzymanie queries GraphQL oddzielonych od aplikacji
  2. Dostosowanie nazw pól za pomocą aliasów
  3. 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.