LoginSignup
5
4

More than 1 year has passed since last update.

Apollo RouterでGraphQLサーバーをマイクロサービス構成にする

Posted at

まえがき

Apollo Routerを使用するとGraphQL + マイクロサービスなシステム開発ができそうだったので、実際に単純なアプリを作成して試してみました。

対象読者

  • GraphQLについて基礎的な知識がある方
  • Apollo Federation及びApollo Routerを使用してみたい方
  • GraphQLを使用してマイクロサービスを構築したい方

この記事のゴール

今回はApollo Routerを使用して商品のレビューを作成できるアプリを開発します。

条件として

  • マイクロサービスで作成する
  • 各アプリケーションはGraphQLで通信する
  • DBは何でも良いが、関係性の解決はApollo Routerにやらせる

を満たすように作成していきます。

データモデルは最低限に留めるため、以下3点のみ作成します。

  • レビューを行うユーザー
  • レビュー対象の商品
  • レビュー自体

具体的には以下の構成図のようなアーキテクチャでアプリを構築していきます。

archtecture.png

用語解説

Apollo Routerとは

Appollo RouterとはSupergraphを扱うためのRust製のグラフルーターで、Elastic Licence v2.0で保護されたOSSです。

尚、SupergraphのようなApollo Federationの専門用語が多く出てきますので、詳しくは以下のApollo Federationについての節で説明していきます。

Apollo Federation とは

公式ページによると…

Apollo Federation is a powerful, open architecture for creating a supergraph that combines multiple GraphQL APIs

とのことで、個別のGraphQLスキーマを1つに合体させることができるアーキテクチャを指します。

合体させる元になっている個別のGraphQLスキーマをSubgraph、合体後の1つになったGraphQLスキーマをSupergraphと言います。

どちらもGraphQLのDSLにApollo Federation特有のディレクティブを用いることで表現します。

以下は公式ページから一部だけ抜粋してきたものです。

# subgraph
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable", "@provides", "@external"])

type Product @key(fields: "upc") {
  upc: String!
  reviews: [Review]
}

# supergraph
schema
  @link(url: "https://specs.apollo.dev/link/v1.0")
  @link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION)
{
  query: Query
}

directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR

type Product
  @join__type(graph: PRODUCTS, key: "upc")
  @join__type(graph: REVIEWS, key: "upc")
{
  upc: String!
  name: String! @join__field(graph: PRODUCTS)
  price: Int @join__field(graph: PRODUCTS)
  reviews: [Review] @join__field(graph: REVIEWS)
}

@key@join__typeと言った専用のディレクティブを使用しているのがわかるかと思います。
それぞれの意味は実際に使ってみるの章で説明していきたいと思います。

実際に使ってみる

開発環境

それでは実際にApollo Federationを使用してアプリを開発していきたいと思います。

今回使用した開発環境は以下のとおりです。

  • OS: macOS Ventura 13.0.1
  • IDE: VS Code 1.75.0
  • Docker Engine: v20.10.22
  • App Platform: Deno v1.30.3

各サービスアプリの開発

スキーマ定義を作成する

まずはじめにGraphQLでスキーマ定義を作成していきます。
Subgraph -> Supergraphの順で作成していきます。

Subgraphを作成する

最初にレビューを書くユーザーのスキーマ定義から作成します。

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.0"
    import: ["@key"]
  )

type Query {
  getUser(id: ID!): User
}

type Mutation {
  createUser(name: String!): User
}

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

ユーザー情報は名前だけの簡素な作りになっています。

extend schemaの行でApollo Federationから特有のディレクティブである@keyをインポートしています。

@keyディレクティブは型定義に付与することでエンティティを定義することができます。

エンティティの使い方はSupergraphを定義する際に説明します。

続いて、レビュー対象である商品のスキーマを定義します。

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.0"
    import: ["@key", "@shareable"]
  )

type Query {
  getProduct(id: ID!): Product
}

type Mutation {
  createProduct(name: String!, price: Int!): Product
}

type Product @key(fields: "id") {
  id: ID!
  name: String!
  price: Int!
}

こちらも名前と値段だけの簡素な作りになっています。

続いて、レビューそのもののスキーマを定義します。

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.0"
    import: ["@key", "@shareable", "@provides", "@external"]
  )

type Query {
  getReview(id: ID!): Review
  latestReviews: [Review!]!
}

type Mutation {
  createReview(
    productId: ID!
    userId: ID!
    score: Int!
    description: String!
  ): Review
}

type Product @key(fields: "id") {
  id: ID!
}

type User @key(fields: "id") {
  id: ID!
}

type Review {
  id: ID!
  score: Int!
  description: String!
  product: Product!
  user: User!
}

こちらは先の2つとは異なり、エンティティ同士の関連が定義されています。

また、別のスキーマで定義されているユーザーと商品のIDだけを定義しています。
これはApollo Federationが関心ごとの分離を設計原則として推奨しているため、それに倣って作成しています。

簡単に説明すると、レビューそのものを管理するアプリからは誰がどの商品に対して作成したレビューであるのかという部分だけが表現できれば良いので、その他の情報を省いています。

ただ、データを取得するときは一緒に取得できた方がAPI的にも利便性が高いので、そこは後述のSupergraphで表現していきます。

Supergraphを作成する

さて、ここまででアプリを作成するのに必要なSubgraphを作成できたので、Supergraphを作成してスキーマを連結していきたいと思います。

実際に作成したSupergraphが以下になります。

schema
  @link(url: "https://specs.apollo.dev/link/v1.0")
  @link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION) {
  query: Query
}

directive @join__field(
  graph: join__Graph!
  requires: join__FieldSet
  provides: join__FieldSet
  type: String
  external: Boolean
  override: String
  usedOverridden: Boolean
) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION

directive @join__graph(name: String!, url: String!) on ENUM_VALUE

directive @join__implements(
  graph: join__Graph!
  interface: String!
) repeatable on OBJECT | INTERFACE

directive @join__type(
  graph: join__Graph!
  key: join__FieldSet
  extension: Boolean! = false
  resolvable: Boolean! = true
) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR

directive @link(
  url: String
  as: String
  for: link__Purpose
  import: [link__Import]
) repeatable on SCHEMA

scalar join__FieldSet

enum join__Graph {
  USERS @join__graph(name: "users", url: "http://user-app")
  PRODUCTS @join__graph(name: "products", url: "http://product-app")
  REVIEWS @join__graph(name: "reviews", url: "http://review-app")
}

scalar link__Import

enum link__Purpose {
  """
  `SECURITY` features provide metadata necessary to securely resolve fields.
  """
  SECURITY
  """
  `EXECUTION` features provide metadata necessary for operation execution.
  """
  EXECUTION
}

type Mutation {
  createUser(name: String!): User @join__field(graph: USERS)
  createProduct(name: String!, price: Int!): Product
    @join__field(graph: PRODUCTS)
  createReview(
    productId: ID!
    userId: ID!
    score: Int!
    description: String!
  ): Review @join__field(graph: REVIEWS)
}

type User
  @join__type(graph: USERS, key: "id")
  @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  name: String! @join__field(graph: USERS)
}

type Query
  @join__type(graph: REVIEWS)
  @join__type(graph: PRODUCTS)
  @join__type(graph: USERS) {
  getUser(id: ID!): User @join__field(graph: USERS)
  getProduct(id: ID!): Product @join__field(graph: PRODUCTS)
  getReview(id: ID!): Review @join__field(graph: REVIEWS)
  latestReviews: [Review] @join__field(graph: REVIEWS)
}

type Review @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  score: Int!
  description: String!
  product: Product! @join__field(graph: REVIEWS)
  user: User! @join__field(graph: REVIEWS)
}

type Product
  @join__type(graph: PRODUCTS, key: "id")
  @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  name: String! @join__field(graph: PRODUCTS)
  price: Int! @join__field(graph: PRODUCTS)
}

Subgraphに比べるとだいぶ記述量が増えてきました。
専用のディレクティブも大量に追加されています。

以降で重要な箇所をピックアップして解説していきたいと思います。

Subgraphの解決先の指定

SupergraphはSubgraphを連結して定義するものですが、実際にリクエストを受けて解決するのは各Subgraphのアプリケーションが行います。
そのため、各アプリケーションの解決先となるエンドポイントを定義する必要があります。

enum join__Graph {
  USERS @join__graph(name: "users", url: "http://user-app")
  PRODUCTS @join__graph(name: "products", url: "http://product-app")
  REVIEWS @join__graph(name: "reviews", url: "http://review-app")
}

上記に示したように、@join__graphディレクティブを使用して、エンドポイントとSupergraph上での名前を設定していきます。

エンティティの結合

Subgraphを作成する過程で、@keyディレクティブを使用してエンティティを定義してきました。
Supergraphでは@keyで定義したキー情報を元にエンティティを結合します。

type Product
  @join__type(graph: PRODUCTS, key: "id")
  @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  name: String! @join__field(graph: PRODUCTS)
  price: Int! @join__field(graph: PRODUCTS)
}

上記の例で言うと、商品スキーマとレビュースキーマのエンティティを統合して新たに商品スキーマを定義しています。この際、@join__typeディレクティブでどこのSubgraphに定義されたエンティティなのかとkeyが何であるかを記述する必要があります。

また、共通項目でないプロパティが存在する場合、どのSubgraphから取得できる情報であるかを@join__fieldディレクティブで定義する必要があります。

@join__fieldディレクティブはQueryやMutationがそれぞれどのSubgraphで定義されているものなのかを定義するのにも使用します。

type Query
  @join__type(graph: REVIEWS)
  @join__type(graph: PRODUCTS)
  @join__type(graph: USERS) {
  getUser(id: ID!): User @join__field(graph: USERS)
  getProduct(id: ID!): Product @join__field(graph: PRODUCTS)
  getReview(id: ID!): Review @join__field(graph: REVIEWS)
  latestReviews: [Review] @join__field(graph: REVIEWS)
}

type Mutation {
  createUser(name: String!): User @join__field(graph: USERS)
  createProduct(name: String!, price: Int!): Product
    @join__field(graph: PRODUCTS)
  createReview(
    productId: ID!
    userId: ID!
    score: Int!
    description: String!
  ): Review @join__field(graph: REVIEWS)
}

バックエンドアプリを実装する

ここまでで、GraphQLスキーマ定義の作成が完了したので、GraphQLリクエストを受け付けるアプリを実装していきます。

基本的には以下のライブラリを使用して、GraphqlサーバーをDeno向けに実装していきます。

  • Apollo Server
  • @graphql-tools/load-files
  • denodb

以下に実際に実装したサーバーアプリのソースを記載します。
(内容はほぼ一緒なので、代表して商品アプリのソースを載せます)

import { Env } from "https://deno.land/x/env@v2.2.0/env.js";
import { ApolloServer } from "npm:@apollo/server";
import { startStandaloneServer } from "npm:@apollo/server/standalone";
import { buildSubgraphSchema } from "npm:@apollo/subgraph";
import { loadFiles } from "npm:@graphql-tools/load-files";
import {
  Database,
  PostgresConnector,
} from "https://deno.land/x/denodb@v1.2.0/mod.ts";
import Product from "./models/product.ts";

const env = new Env();

const connector = new PostgresConnector({
  database: env.require("DATABASE_NAME"),
  host: env.require("DATABASE_HOST"),
  username: env.require("DATABASE_USER"),
  password: env.require("DATABASE_PASS"),
  port: 5432,
});

const typeDefs = await loadFiles("schema.graphql");

const resolvers = {
  Query: {
    async getProduct(parent: any, args: any, context: any, info: any) {
      return await Product.find(args.id);
    },
  },
  Mutation: {
    async createProduct(parent: any, args: any, context: any, info: any) {
      let newProduct = new Product();
      newProduct.name = args.name;
      newProduct.price = args.price;
      return await newProduct.save();
    },
  },
  Product: {
    async __resolveReference(product: Product, context: any) {
      return await Product.find(product.id);
    },
  },
};

if (import.meta.main) {
  const server = new ApolloServer({
    schema: buildSubgraphSchema({ typeDefs, resolvers }),
  });

  const db = new Database(connector);
  await db.link([Product]);
  await db.sync();

  const { url } = await startStandaloneServer(server, {
    listen: env.require("APP_PORT"),
  });
  console.log(`🚀  Server ready at ${url}`);
}

GraphQL APIとしての実装はresolversの部分になります。
基本的には、QueryやMutation内にGraphQLスキーマ定義で定義したクエリに対するメソッドを実際に実装します。

今回はリクエスト内容のデータを登録する or 参照すると言った簡単な機能だけを実装しているため、処理内容はDBへの登録、参照のみです。

余談ですが、DBアクセスはdenodbを使用してモデルクラスを作成することで実装しています。
例えば、商品モデルのモデルクラスは以下の様になります。

import { DataTypes, Model } from "https://deno.land/x/denodb@v1.2.0/mod.ts";

export default class Product extends Model {
  static table = "products";

  static fields = {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
    },
    name: DataTypes.STRING,
    price: DataTypes.INTEGER,
  };

  id!: number;
  name!: string;
  price!: number;
}

Subgraphの実装は、通常のリゾルバと異なる部分があります。
それは、エンティティ用のプロパティと __resolveReference メソッドです。

  Product: {
    async __resolveReference(product: Product, context: any) {
      return await Product.find(product.id);
    },
  },

この部分ではSupergraphによってエンティティを解決する際に使用されるメソッドを定義します。
上記の例では商品エンティティを解決する際の問い合わせとしてidを渡されるため、それを元にDBから取得したデータを返却する実装を行なっています。
idの部分を他の値にしたい場合は、GraphQLスキーマで@keyディレクティブに設定するフィールドを変更します。

動かしてみる

コンテナの準備

アプリケーションの実装までできたので、ここからは実際にアプリケーションを動かしてSupergraphが期待通り動作するのか確認していきたいと思います。

今回はマイクロサービスアーキテクチャをとっていることもあるので、アプリの構築にdocker-composeを使用します。

Apollo Router 用コンテナ定義の作成

早速、Apollo Routerのコンテナを用意していきます。
Dockerfileの内容は、Apollo GraphQLのGitHubページを参考に作成します。

FROM debian:bullseye-slim

ARG ROUTER_RELEASE=latest
ARG DEBUG_IMAGE=false

WORKDIR /dist

# Install curl
RUN \
  apt-get update -y \
  && apt-get install -y \
  curl

# If debug image, install heaptrack and make a data directory
RUN \
  if [ "${DEBUG_IMAGE}" = "true" ]; then \
  apt-get install -y heaptrack && \
  mkdir data; \
  fi

# Clean up apt lists
RUN rm -rf /var/lib/apt/lists/*

# Run the Router downloader which puts Router into current working directory
RUN curl -sSL https://router.apollo.dev/download/nix/${ROUTER_RELEASE}/ | sh

# Make directories for config and schema
RUN mkdir config schema

# Copy configuration for docker image
COPY router.yml config

LABEL org.opencontainers.image.authors="Apollo Graph, Inc. https://github.com/apollographql/router"
LABEL org.opencontainers.image.source="https://github.com/apollographql/router"

ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml"

COPY supergraph-schema.graphql schema

# Create a wrapper script to run the router, use exec to ensure signals are handled correctly
RUN \
  echo '#!/usr/bin/env bash \
  \nset -e \
  \n \
  \nif [ -f "/usr/bin/heaptrack" ]; then \
  \n    exec heaptrack -o /dist/data/router_heaptrack /dist/router "$@" \
  \nelse \
  \n    exec /dist/router "$@" \
  \nfi \
  ' > /dist/router_wrapper.sh

# Make sure we can run our wrapper
RUN chmod 755 /dist/router_wrapper.sh

# Default executable is the wrapper script
ENTRYPOINT ["/dist/router_wrapper.sh", "--config", "/dist/config/router.yml", "--supergraph", "/dist/schema/supergraph-schema.graphql"]

注意点として、ここまでで作成したSupergraphのスキーマ定義ファイルが同梱されるように作成します。(Apollo RouterはSupergraphのスキーマ定義を読み込んで動作するため)

また、併せてApollo Router用の設定ファイルも同梱します。今回はApollo Sandboxを使用して動作確認を行いたいので、最低限の設定を記述していきます。

# ---router.yml---

# Configuration of the router's HTTP server
# Default configuration for container
supergraph:
  # The socket address and port to listen on
  listen: 0.0.0.0:80
  introspection: true

sandbox:
  enabled: true

# Sandbox requires the default landing page to be disabled.
homepage:
  enabled: false

Subgraphアプリケーション用コンテナ定義の作成

次にApollo Routerからリクエストを受け取るバックエンドアプリ用のコンテナ定義を作成します。
Denoが動かせる内容であれば良いので、denolandのイメージにアプリを同梱する形で定義します。

FROM denoland/deno:1.30.3

# The port that your application listens to.
EXPOSE 80

WORKDIR /app

# Prefer not to run as root.
USER deno

# These steps will be re-run upon each file change in your working directory:
COPY src/ .
# Compile the main app so that it doesn't need to be compiled each startup/entry.
RUN deno cache main.ts

CMD ["run", "--allow-net", "--allow-env", "--allow-read", "main.ts"]

docker-compose.ymlの作成

最後に、docker-composeファイルに作成したコンテナ定義をまとめて完成です。

version: '3.1'
services:
  db:
    image: postgres
    restart: always
    ports:
      - 5432:5432
    environment:
      POSTGRES_DB: $DATABASE_NAME
      POSTGRES_PASSWORD: $DATABASE_PASS
      POSTGRES_USER: $DATABASE_USER
  
  router:
    build:
      context: .
      dockerfile: ./Dockerfile.router
    ports:
        - 8080:80
    depends_on:
      - user-app
      - product-app
      - review-app

  user-app:
    build:
      context: users
      dockerfile: ../Dockerfile.deno
    environment:
      DATABASE_NAME: $DATABASE_NAME
      DATABASE_USER: $DATABASE_USER
      DATABASE_PASS: $DATABASE_PASS
      DATABASE_HOST: db
      APP_PORT: $APP_PORT
    ports:
      - 80
    depends_on:
      - db
  
  product-app:
    build:
      context: products
      dockerfile: ../Dockerfile.deno
    environment:
      DATABASE_NAME: $DATABASE_NAME
      DATABASE_USER: $DATABASE_USER
      DATABASE_PASS: $DATABASE_PASS
      DATABASE_HOST: db
      APP_PORT: $APP_PORT
    ports:
      - 80
    depends_on:
      - db

  review-app:
    build:
      context: reviews
      dockerfile: ../Dockerfile.deno
    environment:
      DATABASE_NAME: $DATABASE_NAME
      DATABASE_USER: $DATABASE_USER
      DATABASE_PASS: $DATABASE_PASS
      DATABASE_HOST: db
      APP_PORT: $APP_PORT
    ports:
      - 80
    depends_on:
      - db

volumes:
  graphql-supergraph-test-vol:

動作確認

以上で準備が整いましたので、早速 docker-compose up -d を実行してアプリを起動します。

起動が完了すると、Apollo Routerが http://localhost:8080 で待ち受けるのでブラウザで開きます。

今回はApollo Sandboxを有効にしたので、以下の様な画面が表示されます。

スクリーンショット 2023-02-27 13.16.28.png

この画面でGraphQLのクエリを作成して実行ボタンを押すことで、Apollo Routerにクエリを投げることができます。

返却されたレスポンスは右側のResponseパネルに表示されます。

では、実際にクエリを投げて結果を確認してみましょう。

ユーザーの作成

まずはレビューを作成するためにユーザーを作成します。
実行するクエリは以下を使用します。

mutation CreateUser {
    createUser(name: "test-user") {
        id
    }
}

実際にユーザーを作成した結果が以下になります。

スクリーンショット 2023-02-27 13.25.43.png

レスポンスの指定をidのみにしたので、作成したユーザーのIDのみ返却されました。

確認のため、ユーザーの参照も行なってみます。
使用するクエリは以下の通りです。

query getUser {
    getUser(id: 1) {
        name
    }
}

結果は以下の通りです。

スクリーンショット 2023-02-27 13.30.23.png

上記結果で、Apollo Routerの動作とユーザーアプリとの疎通が確認できました。

商品の作成

次にレビュー対象の商品を作成します。
クエリは以下を使用します。

mutation CreateProd {
    createProduct(name: "test-prod", price: 100) {
        id
    }
}

実行結果は以下の通りです。

スクリーンショット 2023-02-27 13.34.23.png

一応参照も行ってみます。
使用するクエリと結果は以下の通りです。

query GetProd {
    getProduct(id: 1) {
        name
        price
    }
}

スクリーンショット 2023-02-27 13.37.18.png

これで商品アプリとも疎通できていることを確認できました。

レビューの作成

最後にレビューを作成します。

使用するクエリは以下の通りです。

mutation CreateReview {
    createReview(
        productId: 1,
        userId: 1,
        score: 5,
        description: "期待を込めて星5です") {
            id
    }
}

結果は以下の通りです。

スクリーンショット 2023-02-27 13.42.47.png

ここまでで、レビューアプリとの疎通も取れたことが確認できました。
次の節でApollo Routerの便利機能を使用していきます。

レビューの取得(Apollo Routerにエンティティを取得させる)

一通りのデータを作成することができましたが、まだレビューの参照を行なっていません。
では早速レビューを参照するためのクエリを作成していきましょう。

query GetReview {
    getReview(id: 1) {
        score
        description
        user {
            name
        }
        product {
            name
            price
        }
    }
}

レビューデータとしてレビューを実施したユーザーと対象の商品を取得するクエリになっています。getReviewクエリ自体はレビューアプリに定義されているクエリであり、ユーザーと商品の情報はIDしか持っていないはずです。
しかし、Supergraphでエンティティを結合したことにより、Apollo Routerがそれぞれのアプリに問い合わせを行うことで要求したデータを全て結合した状態でレスポンスしてくれます。

実際に実行した結果が以下になります。

スクリーンショット 2023-02-27 13.53.39.png

ちゃんと指定したデータを全て結合した上で返してくれました。
この機能によって、マイクロサービスでアプリを開発した際それぞれのアプリにデータを一元管理させることができると同時に、クライアントがリクエストを行うエンドポイントを一つにまとめることができるようになります。

最終的なフォルダ構成

作成した成果物のフォルダ構成は以下のようになりました。

.
├── Dockerfile.deno
├── Dockerfile.router
├── docker-compose.yml
├── products
│   └── src
│       ├── main.ts
│       ├── models
│       │   └── product.ts
│       └── schema.graphql
├── reviews
│   └── src
│       ├── main.ts
│       ├── models
│       │   └── review.ts
│       └── schema.graphql
├── router.yml
├── supergraph-schema.graphql
└── users
    └── src
        ├── main.ts
        ├── models
        │   └── user.ts
        └── schema.graphql

最後に

最後まで読んでいただきありがとうございます!
記事中に誤っている箇所がありましたら、ご連絡いただけると助かります。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4