Créer un BFF GraphQL avec Apollo Server

Optimisez les API frontend avec GraphQL BFF et Apollo Server

Sommaire

Le Backend for Frontend (BFF) combiné à GraphQL et à Apollo Server crée une architecture puissante pour les applications web modernes.

Cette approche vous permet de construire des API optimisées pour le client qui agrègent des données provenant de multiples sources tout en maintenant une séparation claire des responsabilités.

git-flow

Comprendre le modèle BFF

Le modèle Backend for Frontend est apparu comme une solution aux défis liés au soutien de plusieurs applications frontend (web, mobile, desktop) avec des besoins de données différents. Au lieu d’imposer à tous les clients d’utiliser une seule API générique, le BFF crée des services backend dédiés adaptés aux besoins spécifiques de chaque client.

Avantages clés du BFF

  • API optimisées pour le client : Chaque frontend reçoit exactement les données dont il a besoin, dans le format qu’il attend
  • Réduction de la complexité client : La logique d’agrégation et de transformation des données est déplacée vers le backend
  • Évolution indépendante : Les frontends peuvent évoluer sans affecter d’autres clients ou services centraux
  • Meilleures performances : Moins de round-trip et des payloads plus petits améliorent la vitesse de l’application
  • Autonomie des équipes : Les équipes frontend peuvent gérer leur propre BFF, permettant une itération plus rapide

Pourquoi GraphQL s’adapte parfaitement au BFF

Le langage de requête flexible de GraphQL le rend idéal pour les implémentations BFF :

  1. Récupération précise des données : Les clients demandent uniquement les champs dont ils ont besoin
  2. Une seule requête : Combinez des données provenant de multiples sources dans une seule requête
  3. Typage fort : Le schéma fournit un contrat clair entre le frontend et le backend
  4. Capacités en temps réel : Les abonnements permettent des mises à jour de données en direct
  5. Expérience développeur : L’introspection et le playground GraphQL simplifient le développement

Mise en place d’Apollo Server pour le BFF

Apollo Server fournit une base solide pour la création de couches GraphQL BFF. Commençons par créer une implémentation prête pour la production.

Installation et configuration de base

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

// Définir votre schéma 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!]!
  }
`;

// Implémenter les résolveurs
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);
    },
  },
};

// Créer une instance Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

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

console.log(`🚀 Serveur prêt à ${url}`);

Implémentation des sources de données

Les sources de données offrent une abstraction propre pour récupérer des données provenant de différents backends :

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}`);
  }
}

Optimisation avec DataLoader

DataLoader regroupe et cache les requêtes pour éviter le problème 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));
  });

// Utiliser dans le contexte
const context = async ({ req }) => ({
  dataSources: {
    userAPI: new UserAPI(),
    orderAPI: new OrderAPI(),
  },
  loaders: {
    userLoader: createUserLoader(new UserAPI()),
  },
});

Modèles avancés de BFF

Agrégation de plusieurs services

L’une des forces principales du BFF est de combiner des données provenant de plusieurs services backend :

const resolvers = {
  Query: {
    dashboard: async (_, __, { dataSources, user }) => {
      // Récupérer des données de plusieurs services en parallèle
      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,
      };
    },
  },
};

Gestion des erreurs et résilience

Implémenter une gestion robuste des erreurs pour le BFF en production :

import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      try {
        const user = await dataSources.userAPI.getUser(id);
        
        if (!user) {
          throw new GraphQLError('Utilisateur non trouvé', {
            extensions: {
              code: 'NOT_FOUND',
              http: { status: 404 },
            },
          });
        }
        
        return user;
      } catch (error) {
        if (error instanceof GraphQLError) throw error;
        
        throw new GraphQLError('Échec de la récupération de l’utilisateur', {
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
            originalError: error.message,
          },
        });
      }
    },
  },
};

Stratégies de mise en cache

Implémenter une mise en cache efficace pour améliorer les performances :

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 minutes
  }),
  plugins: [
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response, contextValue }) {
            // Définir les en-têtes de mise en cache
            response.http.headers.set(
              'Cache-Control',
              'max-age=300, public'
            );
          },
        };
      },
    },
  ],
});

Authentification et autorisation

Sécurisez votre BFF avec une authentification et une autorisation appropriées :

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('Jeton d’authentification invalide', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
};

// Protéger les résolveurs
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) {
        throw new GraphQLError('Vous devez être connecté', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return user;
    },
  },
};

Apollo Federation pour les microservices

Lorsque vous travaillez avec plusieurs équipes et services, Apollo Federation permet une architecture GraphQL distribuée :

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 }),
});

Conseils pour optimiser les performances

  1. Utiliser DataLoader : Implémentez toujours DataLoader pour regrouper et mettre en cache les requêtes
  2. Mise en cache au niveau des champs : Mettez en cache les calculs coûteux au niveau des champs
  3. Analyse de la complexité des requêtes : Limitez la profondeur et la complexité des requêtes pour éviter les abus
  4. Requêtes persistantes : Utilisez des requêtes persistantes en production pour réduire la taille des payloads
  5. Compression des réponses : Activez la compression gzip/brotli pour les réponses
  6. Suivi des performances des requêtes : Suivez les requêtes lentes et optimisez les résolveurs
  7. Utiliser un CDN pour les schémas statiques : Mettez en cache les requêtes d’introspection à l’edge

Tests de votre BFF

Écrivez des tests complets pour votre BFF GraphQL :

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

describe('Requêtes utilisateur', () => {
  let server: ApolloServer;

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

  it('récupère l’utilisateur par 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: '123',
        email: 'test@example.com',
        name: 'Utilisateur Test',
      });
    }
  });
});

Considérations pour le déploiement

Conteneurisation

FROM node:20-alpine

WORKDIR /app

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

COPY . .

EXPOSE 4000

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

Configuration de l’environnement

Utilisez des variables d’environnement pour la configuration :

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',
};

Surveillance et observabilité

Implémentez une surveillance complète :

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(`Opération ${operationName} a pris ${duration}ms`);
          },
        };
      },
    },
  ],
});

Pièges courants à éviter

  1. Problème N+1 : Utilisez toujours DataLoader pour les données liées
  2. Sur-récupération depuis le backend : Optimisez les requêtes backend en fonction des ensembles de sélection GraphQL
  3. Manque de gestion des erreurs : Implémentez une gestion d’erreurs et de journalisation appropriée
  4. Aucune limitation de débit : Protégez votre BFF contre les abus avec une limitation de débit
  5. Ignorer la sécurité : Validez les entrées, implémentez l’authentification et limitez la complexité des requêtes
  6. Conception de schéma insuffisante : Concevez des schémas en pensant aux besoins des clients
  7. Aucune stratégie de mise en cache : Implémentez une mise en cache à plusieurs niveaux

Liens utiles