Concetti, idee, strategie
Concetti, idee, strategieEvolvere lo schema tramite il versionamento dei campi

Evolvere lo schema tramite il versionamento dei campi

Man mano che le esigenze della nostra applicazione evolvono, anche l'API GraphQL che le fornisce i dati dovrà evolversi, introducendo modifiche al suo schema. Ogni volta che la modifica è non distruttiva, come quando si aggiunge un nuovo tipo o campo, possiamo applicarla direttamente senza temere effetti collaterali. Ma quando la modifica è di rottura, dobbiamo assicurarci di non introdurre bug o comportamenti inattesi nell'applicazione.

Le modifiche di rottura sono quelle che rimuovono un tipo, un campo o una direttiva, oppure modificano la firma di un campo (o di una direttiva) già esistente, come:

  • Rinominare un campo
  • Modificare il tipo di un argomento di campo esistente, o renderlo obbligatorio
  • Aggiungere un nuovo argomento obbligatorio al campo
  • Aggiungere non-nullable al tipo di risposta di un campo

Per gestire le modifiche di rottura, esistono due strategie principali: il versionamento e l'evoluzione, così come implementate rispettivamente da REST e GraphQL.

Le API REST indicano la versione dell'API da utilizzare sia nell'URL dell'endpoint (come https://api.mycompany.com/v1 o https://api-v1.mycompany.com) sia tramite un header (come Accept-version: v1). Tramite il versionamento, le modifiche di rottura vengono aggiunte a una nuova versione dell'API, e poiché i client devono puntare esplicitamente alla nuova versione dell'API, saranno consapevoli delle modifiche.

GraphQL non rifiuta l'uso del versionamento, ma incoraggia l'uso dell'evoluzione. Come indicato nella pagina delle migliori pratiche 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.

L'evoluzione si comporta diversamente in quanto non è destinata ad avvenire una volta ogni pochi mesi, come accade per il versionamento. È piuttosto un processo continuo, che avviene anche quotidianamente se necessario, il che la rende più adatta all'iterazione rapida. Questo approccio è stato formulato da Principled GraphQL, un insieme di buone pratiche per guidare lo sviluppo di un servizio GraphQL, nel suo quinto principio:

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

Evolvere lo schema

Tramite l'evoluzione, i campi con modifiche di rottura devono passare attraverso il seguente processo:

  1. Reimplementare il campo utilizzando un nome diverso.
  2. Deprecare il campo, chiedendo ai client di utilizzare il nuovo campo al suo posto.
  3. Non appena il campo non viene più utilizzato da nessuno, rimuoverlo dallo schema.

Vediamo un esempio. Supponiamo di avere un tipo Account, che modella un account per una persona con un nome e un cognome tramite questo schema (utilizzando l'SDL di GraphQL - Schema Definition Language):

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

In questo schema, i campi name e surname sono entrambi obbligatori (è il simbolo ! aggiunto dopo il tipo String) poiché ci aspettiamo che tutte le persone abbiano sia un nome che un cognome.

In seguito, permettiamo anche alle organizzazioni di aprire degli account. Le organizzazioni, però, non hanno un cognome, quindi dobbiamo modificare la firma del campo surname per renderlo non obbligatorio:

type Account {
  id: Int
  name: String!
  surname: String # Questo è cambiato
}

Si tratta di una modifica di rottura perché l'applicazione non si aspetta che il campo surname restituisca null, quindi potrebbe non verificare questa condizione, come durante l'esecuzione di questo codice JavaScript:

// Questo fallirà quando account.surname è null
const upperCaseSurname = account.surname.toUpperCase();

I potenziali bug derivanti dalle modifiche di rottura possono essere evitati facendo evolvere lo schema:

  • Non modifichiamo la firma del campo surname; al contrario, lo contrassegniamo come deprecato, aggiungendo un messaggio utile che indica il nome del campo che lo sostituisce
  • Introduciamo un nuovo nome di campo personSurname (o accountSurname) nello schema

Il nostro tipo Account ora appare così:

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

Infine, raccogliendo i log delle queries dei nostri client, possiamo analizzare se hanno effettuato il passaggio al nuovo campo. Non appena notiamo che il campo surname non è più utilizzato da nessuno, possiamo quindi rimuoverlo dallo schema:

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

Problemi con l'evoluzione

L'esempio descritto sopra è molto semplice, ma dimostra già un paio di potenziali problemi legati all'evoluzione dello schema:

ProblemaDescrizione
I nomi dei campi diventano meno ordinatiLa prima volta che diamo un nome al campo, troveremo probabilmente il nome ottimale per esso, come surname. Quando però dobbiamo sostituirlo, dovremo creare un nome diverso che potrebbe essere subottimale (quello ottimale è già stato preso!). Tutte le possibili sostituzioni nell'esempio sopra presentano dei problemi:

- personName rende esplicito che l'account è per una persona, quindi se, in seguito, dovessimo aprire un account per un non-umano con un cognome (chi lo sa... un marziano?), dovremo allora far evolvere di nuovo lo schema per mantenere nomi coerenti
- Il termine «account» in accountName è completamente ridondante dato che il tipo è già Account
- Altrimenti, quale altro nome usare? surname1? surnameNew? O ancora peggio, surnameV2?

Di conseguenza, lo schema aggiornato sarà meno comprensibile e più verboso.
Lo schema può accumulare campi deprecatiDeprecare i campi è più sensato come circostanza temporanea; alla fine, vorremmo davvero rimuovere quei campi dallo schema per ripulirlo prima che inizino ad accumularsi.

Tuttavia, potrebbero esserci client che non rivedono le loro queries e continuano a recuperare informazioni dal campo deprecato. In questo caso, il nostro schema diventerà lentamente ma inesorabilmente una sorta di cimitero di campi, accumulando diversi campi differenti per la stessa funzionalità.

Vediamo come risolvere questi problemi.

Versionamento dei campi

Possiamo creare il nostro campo con un argomento chiamato version, tramite il quale specifichiamo quale versione del campo utilizzare.

In questo scenario, dovremo comunque mantenere l'implementazione per il campo deprecato, quindi non miglioriamo su questo punto. Tuttavia, il suo contratto diventa nascosto: il nuovo campo può ora mantenere il suo nome originale (non è necessario rinominarlo da surname a personSurname), impedendo al nostro schema di diventare troppo verboso.

Si noti che questo concetto di versionamento è diverso da quello di REST:

  • REST stabilisce una situazione tutto-o-niente in cui l'intera API interrogata ha la stessa versione, poiché la versione da utilizzare fa parte dell'endpoint
  • In quest'altro approccio, ogni campo è versionato in modo indipendente

In questo modo, possiamo accedere a versioni diverse per campi diversi, in questo modo:

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

Inoltre, basandoci sul semantic versioning, possiamo utilizzare i vincoli di versione per scegliere la versione, seguendo le stesse regole utilizzate da Composer per dichiarare le dipendenze dei pacchetti. Rinominiamo quindi l'argomento di campo version in versionConstraint e aggiorniamo la query:

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

Applicando questa strategia al nostro campo deprecato surname, possiamo ora etichettare l'implementazione deprecata come versione "1.0.0" e la nuova implementazione come versione "2.0.0" e accedere a entrambe, anche nella stessa query:

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

Questa funzionalità è disponibile in Gato GraphQL:

Interrogare i campi tramite vincoli di versione

Versionamento delle direttive

Poiché anche le direttive ricevono argomenti, possiamo implementare esattamente la stessa metodologia per versionare anche le direttive!

Ad esempio, durante l'esecuzione di questa query:

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

Può produrre una risposta diversa per ogni versione della direttiva:

Interrogare una direttiva versionata