Apollo Server を使用した GraphQL BFF の構築

GraphQL BFF と Apollo Server を使用してフロントエンド API を最適化する

目次

Backend for Frontend (BFF) パターンをGraphQLとApollo Serverと組み合わせることで、現代のウェブアプリケーションに強力なアーキテクチャを構築できます。

このアプローチにより、複数のソースからデータを集約しながらも、関心の分離を保つことでクライアント最適化されたAPIを構築できます。

git-flow

BFF パターンの理解

Backend for Frontend パターンは、複数のフロントエンドアプリケーション(ウェブ、モバイル、デスクトップ)が異なるデータ要件を持つという課題に対処するための解決策として登場しました。すべてのクライアントが単一の汎用APIを使用するのではなく、BFFは各クライアントの特定のニーズに合わせた専用のバックエンドサービスを作成します。

BFF の主な利点

  • クライアント最適化されたAPI: 各フロントエンドは、必要なデータと期待される形式で正確にデータを受け取ります
  • クライアントの複雑性の削減: データの集約および変換ロジックはバックエンドに移動します
  • 独立した進化: フロントエンドは他のクライアントやコアサービスに影響を与えることなく進化できます
  • パフォーマンスの向上: ラウンドトリップが少なく、ペイロードが小さくなることでアプリケーションの速度が向上します
  • チームの自律性: フロントエンドチームが自身のBFFを所有できるため、イテレーションが速くなります

GraphQL が BFF に最適な理由

GraphQL の柔軟なクエリ言語は、BFF の実装に最適です:

  1. 正確なデータフェッチ: クライアントは必要なフィールドのみを要求します
  2. 単一のリクエスト: 複数のソースからのデータを1つのクエリで組み合わせることができます
  3. 強い型付け: スキーマはフロントエンドとバックエンドの間で明確な契約を提供します
  4. リアルタイム機能: サブスクリプションにより、ライブデータの更新が可能になります
  5. 開発者体験: インスペクションとGraphQLプレイグラウンドにより、開発が簡素化されます

Apollo Server を使用した BFF の設定

Apollo Server はGraphQL BFFレイヤーを構築するための堅牢な基盤を提供します。生産環境で使用可能な実装の作成方法を確認しましょう。

インストールと基本的な設定

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

// 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!]!
  }
`;

// リゾルバを実装
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);
    },
  },
};

// Apollo Server インスタンスの作成
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

// サーバーの起動
const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    token: req.headers.authorization,
    dataSources: {
      userAPI: new UserAPI(),
      orderAPI: new OrderAPI(),
    },
  }),
  listen: { port: 4000 },
});

console.log(`🚀 サーバーが ${url} で準備完了しました`);

データソースの実装

データソースは、さまざまなバックエンドからデータをフェッチするためのクリーンな抽象化を提供します:

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

DataLoader を使用した最適化

DataLoader は、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));
  });

// コンテキストで使用
const context = async ({ req }) => ({
  dataSources: {
    userAPI: new UserAPI(),
    orderAPI: new OrderAPI(),
  },
  loaders: {
    userLoader: createUserLoader(new UserAPI()),
  },
});

高度な BFF パターン

複数のサービスの集約

BFF のコアの強みの一つは、複数のバックエンドサービスからのデータを組み合わせることです:

const resolvers = {
  Query: {
    dashboard: async (_, __, { dataSources, user }) => {
      // 複数のサービスからデータを並列してフェッチ
      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,
      };
    },
  },
};

エラーハンドリングと耐障害性

生産環境用の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('ユーザーが見つかりません', {
            extensions: {
              code: 'NOT_FOUND',
              http: { status: 404 },
            },
          });
        }
        
        return user;
      } catch (error) {
        if (error instanceof GraphQLError) throw error;
        
        throw new GraphQLError('ユーザーのフェッチに失敗しました', {
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
            originalError: error.message,
          },
        });
      }
    },
  },
};

キャッシュ戦略

パフォーマンス向上のため、効率的なキャッシュを実装します:

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 分
  }),
  plugins: [
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response, contextValue }) {
            // キャッシュ制御ヘッダーを設定
            response.http.headers.set(
              'Cache-Control',
              'max-age=300, public'
            );
          },
        };
      },
    },
  ],
});

認証と認可

BFF を適切な認証と認可で保護します:

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('無効な認証トークン', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
};

// リゾルバを保護
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) {
        throw new GraphQLError('ログインしてください', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return user;
    },
  },
};

Apollo Federation によるマイクロサービス

複数のチームとサービスで作業する場合、Apollo Federation は分散型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 }),
});

パフォーマンス最適化のヒント

  1. DataLoader の使用: リクエストをバッチ処理およびキャッシュするために常にDataLoaderを実装してください
  2. フィールドレベルのキャッシュ: 高コストな計算をフィールドレベルでキャッシュしてください
  3. クエリ複雑性分析: クエリの深さと複雑性を制限して悪用を防ぎましょう
  4. 永続クエリ: 生産環境では永続クエリを使用してペイロードサイズを減らしてください
  5. 応答圧縮: gzip/brotli圧縮を応答に有効にしてください
  6. クエリパフォーマンスの監視: 遅いクエリを追跡し、リゾルバを最適化してください
  7. 静的スキーマのCDN使用: インスペクションクエリをエッジでキャッシュしてください

BFF のテスト

GraphQL BFF に包括的なテストを書きます:

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

describe('ユーザークエリ', () => {
  let server: ApolloServer;

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

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

デプロイに関する考慮事項

コンテナ化

FROM node:20-alpine

WORKDIR /app

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

COPY . .

EXPOSE 4000

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

環境設定

環境変数を使用して設定を行います:

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

モニタリングと観測性

包括的なモニタリングを実装します:

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(`Operation ${operationName} took ${duration}ms`);
          },
        };
      },
    },
  ],
});

避けたい一般的な落とし穴

  1. N+1クエリ問題: 関連データのフェッチには常にDataLoaderを使用してください
  2. バックエンドからの過剰フェッチ: GraphQLの選択セットに基づいてバックエンドクエリを最適化してください
  3. エラーハンドリングの欠如: 適切なエラーハンドリングとロギングを実装してください
  4. レート制限の欠如: BFFを悪用から保護するためにレート制限を実装してください
  5. セキュリティの無視: 入力検証、認証の実装、クエリ複雑性の制限を行ってください
  6. 悪いスキーマ設計: クライアントのニーズを考慮してスキーマを設計してください
  7. キャッシュ戦略の欠如: 複数のレベルでキャッシュを実装してください

有用なリンク