Architettura
ArchitetturaEliminare il "problema n+1"

Eliminare il "problema n+1"

Scopriamo come Gato GraphQL evita completamente il «problema n+1» già grazie alla sua progettazione architetturale.

Che cos'è il «problema n+1»

Il «problema n+1» significa, in sostanza, che il numero di query eseguite contro il database può essere grande quanto il numero di nodi nel grafo.

Cosa significa? Vediamolo con un esempio: supponiamo di voler recuperare un elenco di registi e, per ciascuno di essi, la sua lista di film, tramite la seguente query:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Per essere efficienti, ci aspetteremmo di eseguire solo 2 query per recuperare i dati dal database: 1 per ottenere i dati dei registi e 1 per recuperare i dati di tutti i film di tutti i registi.

Tuttavia, per soddisfare questa query, GraphQL dovrà eseguire «n+1» query contro il database: 1 dapprima per recuperare l'elenco degli N registi (10 in questo caso) e poi, per ciascuno degli N registi, 1 query per recuperare la sua lista di film. Nel nostro caso, dobbiamo eseguire 1+10=11 query.

Questo problema si verifica perché i resolver di GraphQL gestiscono un solo oggetto alla volta, e non tutti gli oggetti dello stesso tipo contemporaneamente. Nel nostro caso, il resolver che gestisce gli oggetti del tipo Query (che è il tipo radice) verrà chiamato una prima volta per ottenere l'elenco di tutti gli oggetti Director e poi, il resolver che gestisce il tipo Director verrà chiamato una volta per ciascun oggetto Director, per recuperare la sua lista di film.

In altre parole: i resolver di GraphQL vedono l'albero, non la foresta.

Questo problema è in realtà peggiore di quanto sembri inizialmente, perché il numero di nodi in un grafo cresce in modo esponenziale rispetto al numero di livelli del grafo. Quindi, il nome «n+1» è valido solo per un grafo profondo 2 livelli. Per un grafo profondo 3 livelli, dovrebbe essere chiamato il problema «N2+n+1»! E così via...

Per esempio, seguendo il nostro esempio precedente, aggiungiamo anche alla query la lista di attori/attrici di ciascun film, in questo modo:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
        actors(first: 10) {
          name
        }
      }
    }
  }
}

Allora, le query eseguite contro il database sono: 1 dapprima per recuperare l'elenco dei 10 registi, poi 1 query per recuperare la lista di film di ciascun regista per ciascuno dei 10 registi, e infine 1 query per recuperare ogni lista di attori/attrici per ciascuno dei 10 film di ciascuno dei 10 registi. Questo dà un totale di 1+10+100=111 query.

Dopo aver osservato questo comportamento, il «problema n+1» può facilmente essere considerato il maggiore ostacolo prestazionale di GraphQL: se non viene tenuto sotto controllo, interrogare grafi profondi alcuni livelli può diventare così lento da rendere GraphQL praticamente inutile.

Soluzione generale al «problema n+1»

La soluzione standard al «problema n+1» è stata fornita per la prima volta dall'utility DataLoader. La sua strategia è molto semplice: rinviare la risoluzione di segmenti della query a una fase successiva, in cui tutti gli oggetti dello stesso tipo possono essere risolti insieme, in un'unica query. Questa strategia, chiamata «batching», risolve efficacemente il problema «n+1».

Inoltre, DataLoader memorizza gli oggetti nella cache dopo averli recuperati, così che se una query successiva ha bisogno di caricare un oggetto già caricato, può saltarne l'esecuzione e recuperare l'oggetto dalla cache. Questa strategia, chiamata «caching», è soprattutto un'ottimizzazione che si aggiunge al «batching».

Problemi con la soluzione «batching/rinviata»

Tecnicamente parlando, non c'è alcun problema con la strategia «batching» o «rinviata»: funziona e basta.

(D'ora in poi, riferiamoci alla strategia solo come «rinviata».)

Il problema, però, è che questa strategia è un ripensamento: lo sviluppatore può prima implementare il server e poi, notando quanto sia lenta la risoluzione delle query, decidere di introdurre il meccanismo di rinvio. Pertanto, l'implementazione dei resolver può comportare alcuni passi falsi, aggiungendo attrito al processo di sviluppo. Inoltre, poiché lo sviluppatore deve capire come funziona il meccanismo «rinviato», la sua implementazione diventa più complessa di quanto potrebbe essere altrimenti.

Questo problema non risiede nella strategia in sé, ma nel fatto che il server GraphQL offra questa funzionalità come un componente aggiuntivo, anche se senza di essa l'interrogazione può essere così lenta da rendere GraphQL praticamente inutile.

La soluzione a questo problema è quindi semplice: la strategia «rinviata» non dovrebbe essere un componente aggiuntivo, ma integrata nel server GraphQL stesso. Invece di avere 2 strategie di esecuzione delle query, «normale» e «rinviata», dovrebbe esisterne solo 1, «rinviata». E il server GraphQL deve eseguire il meccanismo «rinviato» anche se lo sviluppatore implementa il resolver in modo «normale» (in altre parole, il server GraphQL si occupa della complessità aggiuntiva, non lo sviluppatore).

Ed è esattamente ciò che fa Gato GraphQL.

Rendere la strategia «rinviata» l'unica eseguita dal server GraphQL

Il problema con la maggior parte dei server GraphQL è che la responsabilità di risolvere i tipi di oggetto (object, union e interface) come oggetti spetta ai resolver stessi durante l'elaborazione del nodo genitore (ad esempio: films => directors), invece di delegare questo compito al motore di caricamento dei dati.

Gato GraphQL trasferisce questa responsabilità lontano dal resolver e verso il motore di caricamento dei dati del server, in questo modo:

  1. I resolver restituiscono ID, e non oggetti, quando risolvono una relazione tra i nodi genitore e figlio
  2. Data una lista di ID di un certo tipo, un'entità DataLoader ottiene gli oggetti corrispondenti di quel tipo
  3. Il motore di caricamento dei dati del server è il collante tra queste 2 parti: ottiene dapprima gli ID degli oggetti dai resolver e, appena prima di eseguire la query annidata per la relazione (momento in cui avrà accumulato tutti gli ID da risolvere per il tipo specifico), recupera gli oggetti per quegli ID tramite il DataLoader (che può efficacemente includere tutti gli ID in un'unica query).

Questo approccio può essere riassunto così: «Lavora con gli ID, non con gli oggetti».

Usiamo lo stesso esempio di prima per visualizzare questo nuovo approccio. La query qui sotto recupera un elenco di registi e i loro film:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Fai attenzione ai 2 campi da recuperare per ciascun regista, name e films, e a come sono attualmente diversi:

Il campo name è di tipo scalare. È immediatamente risolvibile, poiché possiamo aspettarci che l'oggetto di tipo Director contenga una proprietà di tipo string chiamata name, contenente il nome del regista. Pertanto, una volta che abbiamo l'oggetto Director, non è necessario eseguire una query aggiuntiva per risolvere questa proprietà.

Il campo films, invece, è una lista di tipo oggetto. Normalmente non è immediatamente risolvibile, poiché fa riferimento a una lista di oggetti, di tipo Film, che devono ancora essere recuperati dal database tramite 1 o più query aggiuntive. Pertanto, lo sviluppatore dovrebbe implementare il meccanismo «rinviato» per esso.

Ora, consideriamo il comportamento differente, e facciamo in modo che il campo films sia risolto come una lista di ID (anziché una lista di oggetti). Poiché possiamo aspettarci che l'oggetto Director contenga una proprietà chiamata filmIDs contenente gli ID di tutti i suoi film, di tipo array of string (supponendo che l'ID sia rappresentato come una stringa), allora anche questo campo può essere risolto immediatamente, senza dover implementare il meccanismo «rinviato».

Infine, oltre all'ID, il resolver deve fornire un'informazione aggiuntiva: il tipo dell'oggetto atteso (nel nostro esempio, potrebbe essere [(Film, 2), (Film, 5), (Film, 9)]). Questa informazione è però interna, trasmessa al motore, e non ha bisogno di essere inclusa nella risposta alla query.

Implementare l'approccio adattato nel codice

Vediamo come Gato GraphQL implementa questo approccio nel codice PHP. Il codice qui sotto illustra i diversi resolver (per chiarezza, tutto il codice qui sotto è stato modificato).

FieldResolvers

I FieldResolvers ricevono un oggetto di un tipo specifico e ne risolvono i campi. Per le relazioni, devono anche indicare il tipo dell'oggetto verso cui si risolvono. Questo è il loro contratto:

interface FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = []);
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}

La sua implementazione è simile a questa:

class PostFieldResolver implements FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = [])
  {
    $post = $object;
    switch ($field) {
      case 'title':
        return $post->title;
      case 'author':
        return $post->authorID; // This is an ID, not an object!
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
  {
    switch ($field) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

Nota come, rimuovendo la logica che gestisce le promesse/oggetti rinviati, il codice che risolve il campo author sia diventato molto semplice e conciso.

TypeResolvers

I TypeResolvers sono oggetti che trattano un tipo specifico: conoscono il nome del tipo e quale TypeDataLoader carica gli oggetti del suo tipo, tra le altre cose.

Il motore di caricamento dei dati, durante la risoluzione dei campi, riceverà gli ID da una certa classe TypeResolver. Poi, durante il recupero degli oggetti per quegli ID, il motore di caricamento dei dati chiederà al TypeResolver quale oggetto TypeDataLoader usare per caricare quegli oggetti.

Il loro contratto è definito così:

interface TypeResolverInterface
{
  public function getTypeName(): string;
  public function getTypeDataLoaderClass(): string;
}

Nel nostro esempio, la classe UserTypeResolver definisce che il tipo User deve avere i suoi dati caricati tramite la classe UserTypeDataLoader:

class UserTypeResolver implements TypeResolverInterface
{
  public function getTypeName(): string
  {
    return 'User';
  }
 
  public function getTypeDataLoaderClass(): string
  {
    return UserTypeDataLoader::class;
  }
}

TypeDataLoaders

I TypeDataLoaders ricevono una lista di ID di un tipo specifico e restituiscono gli oggetti corrispondenti di quel tipo. Questo è il loro contratto:

interface TypeDataLoaderInterface
{
  public function getObjects(array $ids): array;
}

Il recupero degli utenti avviene così:

class UserTypeDataLoader implements TypeDataLoaderInterface
{
  public function getObjects(array $ids): array
  {
    $userAPI = UserAPIFacade::getInstance();
    return $userAPI->getUsers($ids);
  }
}

Eseguire una query (davvero) grande

Verifichiamo che questa strategia funzioni. Vai al client GraphiQL in Gato GraphQL ed esegui la query qui sotto, che coinvolge un grafo profondo 10 livelli (posts => author => posts => tags => posts => comments => author => posts => comments => author) e che non potrebbe essere risolta in un tempo decente se il «problema n+1» si verificasse.

query {
  posts(pagination:{ limit:10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination:{ limit:10 }) {
        title
        tags(pagination:{ limit:10 }) {
          slug
          url
          posts(pagination:{ limit:10 }) {
            title
            comments(pagination:{ limit:10 }) {
              content
              date
              author {
                name
                posts(pagination:{ limit:10 }) {
                  title
                  url
                  comments(pagination:{ limit:10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Scorrendo i risultati vedrai quanto è grande la risposta, quante entità coinvolge e quanti livelli ha recuperato, eppure è stata eseguita prontamente, senza alcuna difficoltà.