Costruire un BFF GraphQL con Apollo Server

Ottimizza le API frontend con GraphQL BFF e Apollo Server

Indice

Il modello Backend for Frontend (BFF) combinato con GraphQL e Apollo Server crea un’architettura potente per le moderne applicazioni web.

Questo approccio ti permette di costruire API ottimizzate per il client che aggregano dati da diverse fonti mantenendo una chiara separazione delle responsabilità.

git-flow

Comprendere il Modello BFF

Il modello Backend for Frontend è emerso come soluzione ai problemi legati al supporto di diverse applicazioni frontend (web, mobile, desktop) con requisiti dati diversi. Invece di obbligare tutti i client a utilizzare un’unica API generica, il BFF crea servizi backend dedicati adatti alle specifiche esigenze di ciascun client.

Principali Vantaggi del BFF

  • API Ottimizzate per il Client: Ogni frontend riceve esattamente i dati di cui ha bisogno nel formato che aspetta
  • Riduzione della Complessità del Client: La logica di aggregazione e trasformazione dei dati si sposta sul backend
  • Evoluzione Indipendente: I frontend possono evolvere senza influenzare altri client o servizi principali
  • Migliore Prestazione: Meno round trip e payload più piccoli migliorano la velocità dell’applicazione
  • Autonomia delle Squadre: Le squadre frontend possono gestire il proprio BFF, abilitando un’iterazione più rapida

Perché GraphQL si Adatta Perfettamente al BFF

La flessibile lingua di query di GraphQL lo rende ideale per le implementazioni BFF:

  1. Recupero Preciso dei Dati: I client richiedono solo i campi di cui hanno bisogno
  2. Singola Richiesta: Combina dati da diverse fonti in una singola query
  3. Tipizzazione Forte: Lo schema fornisce un contratto chiaro tra frontend e backend
  4. Capacità in Tempo Reale: Le sottoscrizioni abilitano aggiornamenti in tempo reale
  5. Esperienza dello Sviluppatore: L’introspezione e il playground GraphQL semplificano lo sviluppo

Configurazione di Apollo Server per il BFF

Apollo Server fornisce una solida base per costruire strati BFF GraphQL. Andiamo a vedere come creare un’implementazione pronta per la produzione.

Installazione e Configurazione Base

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';

// Definisci il tuo schema GraphQL
const typeDefs = gql`
  type User {
    id: ID!
    email: String!
    name: String!
    orders: [Order!]!
  }

  type Order {
    id: ID!
    status: String!
    total: Float!
    items: [OrderItem!]!
  }

  type OrderItem {
    id: ID!
    productId: ID!
    quantity: Int!
    price: Float!
  }

  type Query {
    me: User
    user(id: ID!): User
    orders(userId: ID!): [Order!]!
  }
`;

// Implementa i resolver
const resolvers = {
  Query: {
    me: async (_, __, { dataSources, user }) => {
      return dataSources.userAPI.getUser(user.id);
    },
    user: async (_, { id }, { dataSources }) => {
      return dataSources.userAPI.getUser(id);
    },
    orders: async (_, { userId }, { dataSources }) => {
      return dataSources.orderAPI.getOrdersByUser(userId);
    },
  },
  User: {
    orders: async (parent, _, { dataSources }) => {
      return dataSources.orderAPI.getOrdersByUser(parent.id);
    },
  },
};

// Crea un'istanza di Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

// Avvia il server
const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    token: req.headers.authorization,
    dataSources: {
      userAPI: new UserAPI(),
      orderAPI: new OrderAPI(),
    },
  }),
  listen: { port: 4000 },
});

console.log(`🚀 Server pronto all'indirizzo ${url}`);

Implementazione delle Fonti di Dati

Le fonti di dati forniscono un’astrazione pulita per il recupero di dati da diversi backend:

import { RESTDataSource } from '@apollo/datasource-rest';

class UserAPI extends RESTDataSource {
  override baseURL = 'https://api.example.com/users/';

  async getUser(id: string) {
    return this.get(`${id}`);
  }

  async getUsersByIds(ids: string[]) {
    return Promise.all(ids.map(id => this.getUser(id)));
  }

  async updateUser(id: string, data: any) {
    return this.patch(`${id}`, { body: data });
  }
}

class OrderAPI extends RESTDataSource {
  override baseURL = 'https://api.example.com/orders/';

  async getOrdersByUser(userId: string) {
    return this.get('', {
      params: { userId },
    });
  }

  async getOrder(id: string) {
    return this.get(`${id}`);
  }
}

Ottimizzazione con DataLoader

DataLoader raggruppa e memorizza in cache le richieste per evitare il problema N+1:

import DataLoader from 'dataloader';

const createUserLoader = (userAPI: UserAPI) => 
  new DataLoader(async (ids: readonly string[]) => {
    const users = await userAPI.getUsersByIds([...ids]);
    return ids.map(id => users.find(user => user.id === id));
  });

// Utilizzo nel contesto
const context = async ({ req }) => ({
  dataSources: {
    userAPI: new UserAPI(),
    orderAPI: new OrderAPI(),
  },
  loaders: {
    userLoader: createUserLoader(new UserAPI()),
  },
});

Pattern BFF Avanzati

Aggregazione di Più Servizi

Uno dei punti di forza del BFF è l’unione dei dati da diversi servizi backend:

const resolvers = {
  Query: {
    dashboard: async (_, __, { dataSources, user }) => {
      // Recupera dati da diversi servizi in parallelo
      const [userData, orders, recommendations, analytics] = await Promise.all([
        dataSources.userAPI.getUser(user.id),
        dataSources.orderAPI.getRecentOrders(user.id),
        dataSources.recommendationAPI.getRecommendations(user.id),
        dataSources.analyticsAPI.getUserStats(user.id),
      ]);

      return {
        user: userData,
        recentOrders: orders,
        recommendations,
        stats: analytics,
      };
    },
  },
};

Gestione degli Errori e Resilienza

Implementa una gestione robusta degli errori per il BFF in produzione:

import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      try {
        const user = await dataSources.userAPI.getUser(id);
        
        if (!user) {
          throw new GraphQLError('Utente non trovato', {
            extensions: {
              code: 'NOT_FOUND',
              http: { status: 404 },
            },
          });
        }
        
        return user;
      } catch (error) {
        if (error instanceof GraphQLError) throw error;
        
        throw new GraphQLError('Impossibile recuperare l\'utente', {
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
            originalError: error.message,
          },
        });
      }
    },
  },
};

Strategie di Caching

Implementa un caching efficiente per migliorare le prestazioni:

import { KeyValueCache } from '@apollo/utils.keyvaluecache';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  cache: new InMemoryLRUCache({
    maxSize: Math.pow(2, 20) * 100, // 100 MB
    ttl: 300, // 5 minuti
  }),
  plugins: [
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response, contextValue }) {
            // Imposta gli header di controllo del cache
            response.http.headers.set(
              'Cache-Control',
              'max-age=300, public'
            );
          },
        };
      },
    },
  ],
});

Autenticazione e Autorizzazione

Proteggi il tuo BFF con un’adeguata autenticazione e autorizzazione:

import jwt from 'jsonwebtoken';

const context = async ({ req }) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  
  if (!token) {
    return { user: null };
  }
  
  try {
    const user = jwt.verify(token, process.env.JWT_SECRET);
    return { user };
  } catch (error) {
    throw new GraphQLError('Token di autenticazione non valido', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
};

// Proteggi i resolver
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) {
        throw new GraphQLError('Devi essere autenticato', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return user;
    },
  },
};

Apollo Federation per Microservizi

Quando si lavora con più team e servizi, Apollo Federation abilita un’architettura GraphQL distribuita:

import { buildSubgraphSchema } from '@apollo/subgraph';

const typeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.3",
          import: ["@key", "@shareable"])

  type User @key(fields: "id") {
    id: ID!
    email: String!
    name: String!
  }
`;

const resolvers = {
  User: {
    __resolveReference: async (reference, { dataSources }) => {
      return dataSources.userAPI.getUser(reference.id);
    },
  },
};

const server = new ApolloServer({
  schema: buildSubgraphSchema({ typeDefs, resolvers }),
});

Consigli per l’ottimizzazione delle Prestazioni

  1. Utilizza DataLoader: Implementa sempre DataLoader per raggruppare e memorizzare in cache le richieste
  2. Implementa Caching a Livello di Campo: Memorizza in cache i calcoli costosi a livello di campo
  3. Analisi della Complessità delle Query: Limita la profondità e la complessità delle query per prevenire l’abuso
  4. Query Persistite: Utilizza query persistite in produzione per ridurre la dimensione del payload
  5. Compressione delle Risposte: Abilita la compressione gzip/brotli per le risposte
  6. Monitoraggio delle Prestazioni delle Query: Traccia le query lente e ottimizza i resolver
  7. Utilizza un CDN per gli Schema Statici: Memorizza in cache le query di introspezione all’edge

Test del Tuo BFF

Scrivi test completi per il tuo BFF GraphQL:

import { ApolloServer } from '@apollo/server';

describe('Query degli Utenti', () => {
  let server: ApolloServer;

  beforeAll(() => {
    server = new ApolloServer({
      typeDefs,
      resolvers,
    });
  });

  it('recupera l\'utente per ID', async () => {
    const result = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            email
            name
          }
        }
      `,
      variables: { id: '123' },
    });

    expect(result.body.kind).toBe('single');
    if (result.body.kind === 'single') {
      expect(result.body.singleResult.data?.user).toEqual({
        id: '12键',
        email: 'test@example.com',
        name: 'Test User',
      });
    }
  });
});

Considerazioni per la Distribuzione

Containerizzazione

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 4000

CMD ["node", "dist/index.js"]

Configurazione dell’ambiente

Utilizza le variabili d’ambiente per la configurazione:

const config = {
  port: process.env.PORT || 4000,
  userServiceUrl: process.env.USER_SERVICE_URL,
  orderServiceUrl: process.env.ORDER_SERVICE_URL,
  jwtSecret: process.env.JWT_SECRET,
  nodeEnv: process.env.NODE_ENV || 'development',
};

Monitoraggio e Osservabilità

Implementa un monitoraggio completo:

import { ApolloServerPluginInlineTrace } from '@apollo/server/plugin/inlineTrace';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginInlineTrace(),
    process.env.NODE_ENV === 'production'
      ? ApolloServerPluginLandingPageDisabled()
      : ApolloServerPluginLandingPageLocalDefault(),
    {
      async requestDidStart() {
        const start = Date.now();
        return {
          async willSendResponse({ operationName, contextValue }) {
            const duration = Date.now() - start;
            console.log(`Operazione ${operationName} richiesta in ${duration}ms`);
          },
        };
      },
    },
  ],
});

Errori Comuni da Evitare

  1. Problema N+1 delle Query: Utilizza sempre DataLoader per i dati correlati
  2. Eccessivo Recupero di Dati dal Backend: Ottimizza le query del backend in base ai set di selezione GraphQL
  3. Manca di Gestione degli Errori: Implementa una corretta gestione degli errori e del logging
  4. Nessun Limite di Velocità: Proteggi il tuo BFF dall’abuso con il limite di velocità
  5. Ignorare la Sicurezza: Verifica gli input, implementa l’autenticazione e limita la complessità delle query
  6. Progettazione dello Schema Scadente: Progetta gli schemi pensando alle esigenze dei client
  7. Nessuna Strategia di Caching: Implementa il caching a diversi livelli