Tworzenie GraphQL BFF z użyciem Apollo Server

Optymalizuj API前端 z użyciem GraphQL BFF i Apollo Server

Page content

Wzorzec Backend for Frontend (BFF) w połączeniu z GraphQL i Apollo Server tworzy potężną architekturę dla nowoczesnych aplikacji webowych.

Ten podejście umożliwia tworzenie API zoptymalizowanych pod kątem klienta, które agregują dane z wielu źródeł, jednocześnie zachowując czystą separację obowiązków.

git-flow

Zrozumienie wzorca BFF

Wzorzec Backend for Frontend pojawił się jako rozwiązanie problemów związanych z obsługą wielu aplikacji frontendowych (web, mobilnych, desktop) z różnymi wymaganiami danych. Zamiast wymuszać na wszystkich klientach użycie jednego ogólnego API, BFF tworzy dedykowane usługi backendowe dopasowane do konkretnych potrzeb każdego klienta.

Główne korzyści z użycia BFF

  • API zoptymalizowane pod kątem klienta: Każdy frontend otrzymuje dokładnie te dane, które potrzebuje, w oczekiwanym formacie
  • Zmniejszenie złożoności klienta: Logika agregacji i transformacji danych przenoszona jest do backendu
  • Niezależna ewolucja: Frontendy mogą ewoluować bez wpływu na inne klienty lub usługi główne
  • Lepsza wydajność: Mniejsza liczba rund i mniejsze ładunki poprawiają prędkość aplikacji
  • Autonomia zespołów: Zespoły frontendowe mogą posiadać swoje BFF, umożliwiając szybsze iteracje

Dlaczego GraphQL idealnie nadaje się do BFF

Wzorniczy język zapytań GraphQL sprawia, że jest idealny do implementacji BFF:

  1. Precyzyjne pobieranie danych: Klienci żądają tylko pól, które potrzebują
  2. Jedno zapytanie: Połączenie danych z wielu źródeł w jednym zapytaniu
  3. Silne typowanie: Schemat dostarcza wyraźnego kontraktu między frontendem a backendem
  4. Możliwości w czasie rzeczywistym: Subskrypcje umożliwiają aktualizacje danych w czasie rzeczywistym
  5. Doświadczenie dewelopera: Introspekcja i GraphQL playground upraszczają rozwój

Konfiguracja Apollo Server dla BFF

Apollo Server dostarcza solidnej podstawy do budowania warstw GraphQL BFF. Przejdźmy przez tworzenie gotowej do produkcji implementacji.

Instalacja i podstawowa konfiguracja

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

// Zdefiniuj swój schemat 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!]!
  }
`;

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

// Utwórz instancję Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

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

console.log(`🚀 Serwer gotowy na ${url}`);

Implementacja źródeł danych

Źródła danych dostarczają czystej abstrakcji do pobierania danych z różnych backendów:

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

Optymalizacja za pomocą DataLoader

DataLoader grupuje i cacheuje żądania, aby uniknąć problemu 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));
  });

// Użyj w kontekście
const context = async ({ req }) => ({
  dataSources: {
    userAPI: new UserAPI(),
    orderAPI: new OrderAPI(),
  },
  loaders: {
    userLoader: createUserLoader(new UserAPI()),
  },
});

Zaawansowane wzorce BFF

Agregowanie wielu usług

Jedną z głównych sił BFF jest łączenie danych z wielu usług backendowych:

const resolvers = {
  Query: {
    dashboard: async (_, __, { dataSources, user }) => {
      // Pobierz dane z wielu usług równolegle
      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,
      };
    },
  },
};

Obsługa błędów i odporność

Zaimplementuj solidną obsługę błędów dla produkcyjnego BFF:

import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      try {
        const user = await dataSources.userAPI.getUser(id);
        
        if (!user) {
          throw new GraphQLError('Użytkownik nie znaleziony', {
            extensions: {
              code: 'NOT_FOUND',
              http: { status: 404 },
            },
          });
        }
        
        return user;
      } catch (error) {
        if (error instanceof GraphQLError) throw error;
        
        throw new GraphQLError('Nie udało się pobrać użytkownika', {
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
            originalError: error.message,
          },
        });
      }
    },
  },
};

Strategie cacheowania

Zaimplementuj wydajne cacheowanie, aby poprawić wydajność:

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 minut
  }),
  plugins: [
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response, contextValue }) {
            // Ustaw nagłówki cache
            response.http.headers.set(
              'Cache-Control',
              'max-age=300, public'
            );
          },
        };
      },
    },
  ],
});

Autoryzacja i uwierzytelnienie

Zabezpiecz swój BFF poprzez odpowiednie uwierzytelnienie i autoryzację:

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('Nieprawidłowy token uwierzytelnienia', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
};

// Ochrona rozdzielaczy
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) {
        throw new GraphQLError('Musisz być zalogowany', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return user;
    },
  },
};

Apollo Federation dla mikroserwisów

Pracując z wieloma zespołami i usługami, Apollo Federation umożliwia architekturę rozproszoną GraphQL:

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

Wskazówki dotyczące optymalizacji wydajności

  1. Użyj DataLoader: Zawsze implementuj DataLoader, aby grupować i cacheować żądania
  2. Zaimplementuj cacheowanie na poziomie pól: Cacheuj kosztowne obliczenia na poziomie pól
  3. Analiza złożoności zapytań: Ogranicz głębokość i złożoność zapytań, aby zapobiec nadużyciu
  4. Zapamiętane zapytania: Używaj zapamiętanych zapytań w produkcji, aby zmniejszyć rozmiar ładunku
  5. Kompresja odpowiedzi: Włącz kompresję gzip/brotli dla odpowiedzi
  6. Monitorowanie wydajności zapytań: Śledź wolne zapytania i optymalizuj rozdzielacze
  7. Użyj CDN dla statycznych schematów: Cacheuj zapytania introspekcji na krawędzi

Testowanie swojego BFF

Napisz komprehensywne testy dla swojego GraphQL BFF:

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

describe('Zapytania użytkownika', () => {
  let server: ApolloServer;

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

  it('pobiera użytkownika po 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: 'Test User',
      });
    }
  });
});

Rozważania dotyczące wdrażania

Konteneryzacja

FROM node:20-alpine

WORKDIR /app

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

COPY . .

EXPOSE 4000

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

Konfiguracja środowiska

Użyj zmiennych środowiskowych do konfiguracji:

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

Monitorowanie i obserwowalność

Zaimplementuj kompleksowe monitorowanie:

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(`Operacja ${operationName} zajęła ${duration}ms`);
          },
        };
      },
    },
  ],
});

Powszechne pułapki do uniknięcia

  1. Problem N+1: Zawsze używaj DataLoader do danych powiązanych
  2. Zbyt duże pobieranie danych z backendu: Optymalizuj zapytania backendowe na podstawie zestawów wyboru GraphQL
  3. Brak obsługi błędów: Zaimplementuj odpowiednią obsługę błędów i logowanie
  4. Brak ograniczania przepływu: Ochrona BFF przed nadużyciem za pomocą ograniczania przepływu
  5. Ignorowanie bezpieczeństwa: Walidacja danych wejściowych, implementacja autoryzacji i ograniczanie złożoności zapytań
  6. Słabe projektowanie schematu: Projektuj schematy myśląc o potrzebach klienta
  7. Brak strategii cacheowania: Zaimplementuj cacheowanie na wielu poziomach

Przydatne linki