Koncepcje, Idee, Strategie
Koncepcje, Idee, StrategieEwoluowanie schematu poprzez wersjonowanie pól

Ewoluowanie schematu poprzez wersjonowanie pól

W miarę jak potrzeby naszej aplikacji ewoluują, API GraphQL dostarczające do niej dane również będzie musiało ewoluować, wprowadzając zmiany w swoim schemacie. Gdy zmiana nie jest przełomowa, jak przy dodawaniu nowego typu lub pola, możemy ją zastosować bezpośrednio bez obawy o skutki uboczne. Ale gdy zmiana jest przełomowa, musimy upewnić się, że nie wprowadzamy błędów ani nieoczekiwanego zachowania w aplikacji.

Przełomowe zmiany to te, które usuwają typ, pole lub dyrektywę, lub modyfikują sygnaturę już istniejącego pola (lub dyrektywy), takie jak:

  • Zmiana nazwy pola
  • Zmiana typu istniejącego argumentu pola lub uczynienie go obowiązkowym
  • Dodanie nowego obowiązkowego argumentu do pola
  • Dodanie non-nullable do typu odpowiedzi pola

Aby poradzić sobie z przełomowymi zmianami, istnieją dwie główne strategie: wersjonowanie i ewolucja, zaimplementowane odpowiednio przez REST i GraphQL.

API REST wskazują wersję API do użycia w URL endpointu (jak https://api.mycompany.com/v1 lub https://api-v1.mycompany.com) lub za pomocą nagłówka (jak Accept-version: v1). Dzięki wersjonowaniu przełomowe zmiany są dodawane do nowej wersji API, a ponieważ klienci muszą jawnie wskazać nową wersję API, będą świadomi tych zmian.

GraphQL nie odrzuca stosowania wersjonowania, ale zachęca do stosowania ewolucji. Jak stwierdzono na stronie najlepszych praktyk GraphQL:

While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.

Ewolucja zachowuje się inaczej, ponieważ nie oczekuje się, że będzie odbywać się raz na kilka miesięcy, jak wersjonowanie. Jest to raczej ciągły proces, który może odbywać się nawet codziennie, jeśli jest to konieczne, co czyni go bardziej odpowiednim do szybkiej iteracji. Podejście to zostało sformułowane przez Principled GraphQL, zestaw najlepszych praktyk do prowadzenia rozwoju usługi GraphQL, w jego piątej zasadzie:

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

Ewoluowanie schematu

Poprzez ewolucję pola z przełomowymi zmianami muszą przejść następujący proces:

  1. Zaimplementowanie pola ponownie z użyciem innej nazwy.
  2. Oznaczenie pola jako przestarzałe, prosząc klientów o korzystanie z nowego pola.
  3. Gdy pole nie jest już używane przez nikogo, usunięcie go ze schematu.

Przyjrzyjmy się przykładowi. Załóżmy, że mamy typ Account, modelujący konto dla osoby z imieniem i nazwiskiem za pomocą tego schematu (przy użyciu SDL GraphQL — Schema Definition Language):

type Account {
  id: Int
  name: String!
  surname: String!
}

W tym schemacie zarówno pole name, jak i pole surname są obowiązkowe (to symbol ! dodany po typie String), ponieważ oczekujemy, że każda osoba ma zarówno imię, jak i nazwisko.

Z czasem pozwalamy również organizacjom na otwieranie kont. Organizacje jednak nie mają nazwiska, więc musimy zmienić sygnaturę pola surname, aby uczynić je nieobowiązkowym:

type Account {
  id: Int
  name: String!
  surname: String # To się zmieniło
}

Jest to przełomowa zmiana, ponieważ aplikacja nie spodziewa się, że pole surname zwróci null, więc może nie sprawdzać tego warunku, jak przy wykonywaniu tego kodu JavaScript:

// To się nie powiedzie, gdy account.surname jest null
const upperCaseSurname = account.surname.toUpperCase();

Potencjalnych błędów wynikających z przełomowych zmian można uniknąć, ewoluując schemat:

  • Nie modyfikujemy sygnatury pola surname; zamiast tego oznaczamy je jako przestarzałe, dodając pomocną wiadomość wskazującą nazwę pola, które je zastępuje
  • Wprowadzamy nową nazwę pola personSurname (lub accountSurname) do schematu

Nasz typ Account wygląda teraz tak:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

Na koniec, zbierając logi queries od naszych klientów, możemy przeanalizować, czy dokonali przejścia na nowe pole. Gdy zauważymy, że pole surname nie jest już używane przez nikogo, możemy je usunąć ze schematu:

type Account {
  id: Int
  name: String!
  personSurname: String
}

Problemy z ewolucją

Opisany powyżej przykład jest bardzo prosty, ale już demonstruje kilka potencjalnych problemów związanych z ewoluowaniem schematu:

ProblemOpis
Nazwy pól stają się mniej eleganckieZa pierwszym razem, gdy nadajemy polu nazwę, prawdopodobnie znajdziemy dla niego optymalną nazwę, jak surname. Gdy jednak musimy ją zastąpić, będziemy musieli stworzyć inną nazwę, która może być nieoptymalną (optymalna jest już zajęta!). Wszystkie możliwe zamienniki w powyższym przykładzie mają problemy:

- personName wyraźnie wskazuje, że konto jest dla osoby, więc jeśli później będziemy musieli otworzyć konto dla nie-osoby z nazwiskiem (nie wiem... Marsjanina?), będziemy musieli ewoluować schemat ponownie, aby zachować spójne nazwy
- Fragment "account" w accountName jest całkowicie zbędny, ponieważ typ to już Account
- W przeciwnym razie, jakiej innej nazwy użyć? surname1? surnameNew? Albo jeszcze gorzej, surnameV2?

W konsekwencji zaktualizowany schemat będzie mniej zrozumiały i bardziej rozwlekły.
Schemat może gromadzić przestarzałe polaOznaczanie pól jako przestarzałe ma największy sens jako okoliczność tymczasowa; ostatecznie naprawdę chcielibyśmy usunąć te pola ze schematu, aby go oczyścić, zanim zaczną się gromadzić.

Jednak mogą istnieć klienci, którzy nie aktualizują swoich queries i nadal pobierają informacje z przestarzałego pola. W takim przypadku nasz schemat powoli, ale nieubłaganie stanie się swego rodzaju cmentarzem pól, gromadząc kilka różnych pól dla tej samej funkcjonalności.

Zobaczmy, jak rozwiązać te problemy.

Wersjonowanie pól

Możemy utworzyć nasze pole z argumentem o nazwie version, za pomocą którego określamy, której wersji pola użyć.

W tym scenariuszu nadal będziemy musieli zachować implementację dla przestarzałego pola, więc nie poprawiamy tej kwestii. Jednak jego kontrakt staje się ukryty: nowe pole może teraz zachować swoją oryginalną nazwę (nie ma potrzeby zmieniania jej z surname na personSurname), co zapobiega temu, aby nasz schemat stał się zbyt rozwlekły.

Należy zauważyć, że ten koncept wersjonowania różni się od tego w REST:

  • REST ustanawia sytuację wszystko-albo-nic, w której całe zapytane API ma tę samą wersję, ponieważ wersja do użycia jest częścią endpointu
  • W tym innym podejściu każde pole jest wersjonowane niezależnie

Dlatego możemy uzyskać dostęp do różnych wersji dla różnych pól, w taki sposób:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

Ponadto, opierając się na semantic versioning, możemy używać ograniczeń wersji do wyboru wersji, stosując te same zasady używane przez Composer do deklarowania zależności pakietów. Następnie zmieniamy nazwę argumentu pola version na versionConstraint i aktualizujemy query:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

Stosując tę strategię do naszego przestarzałego pola surname, możemy teraz oznaczać przestarzałą implementację jako wersję "1.0.0", a nową implementację jako wersję "2.0.0" i uzyskiwać dostęp do obu, nawet w tej samej query:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

Ta funkcja jest dostępna w Gato GraphQL:

Odpytywanie pól za pomocą ograniczeń wersji

Wersjonowanie dyrektyw

Ponieważ dyrektywy również otrzymują argumenty, możemy zastosować dokładnie tę samą metodologię do wersjonowania dyrektyw!

Na przykład, uruchamiając tę query:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

Może ona wygenerować inną odpowiedź dla każdej wersji dyrektywy:

Odpytywanie wersjonowanej dyrektywy