まえがき
Apollo Routerを使用するとGraphQL + マイクロサービスなシステム開発ができそうだったので、実際に単純なアプリを作成して試してみました。
対象読者
- GraphQLについて基礎的な知識がある方
- Apollo Federation及びApollo Routerを使用してみたい方
- GraphQLを使用してマイクロサービスを構築したい方
この記事のゴール
今回はApollo Routerを使用して商品のレビューを作成できるアプリを開発します。
条件として
- マイクロサービスで作成する
- 各アプリケーションはGraphQLで通信する
- DBは何でも良いが、関係性の解決はApollo Routerにやらせる
を満たすように作成していきます。
データモデルは最低限に留めるため、以下3点のみ作成します。
- レビューを行うユーザー
- レビュー対象の商品
- レビュー自体
具体的には以下の構成図のようなアーキテクチャでアプリを構築していきます。
用語解説
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を有効にしたので、以下の様な画面が表示されます。
この画面でGraphQLのクエリを作成して実行ボタンを押すことで、Apollo Routerにクエリを投げることができます。
返却されたレスポンスは右側のResponseパネルに表示されます。
では、実際にクエリを投げて結果を確認してみましょう。
ユーザーの作成
まずはレビューを作成するためにユーザーを作成します。
実行するクエリは以下を使用します。
mutation CreateUser {
createUser(name: "test-user") {
id
}
}
実際にユーザーを作成した結果が以下になります。
レスポンスの指定をidのみにしたので、作成したユーザーのIDのみ返却されました。
確認のため、ユーザーの参照も行なってみます。
使用するクエリは以下の通りです。
query getUser {
getUser(id: 1) {
name
}
}
結果は以下の通りです。
上記結果で、Apollo Routerの動作とユーザーアプリとの疎通が確認できました。
商品の作成
次にレビュー対象の商品を作成します。
クエリは以下を使用します。
mutation CreateProd {
createProduct(name: "test-prod", price: 100) {
id
}
}
実行結果は以下の通りです。
一応参照も行ってみます。
使用するクエリと結果は以下の通りです。
query GetProd {
getProduct(id: 1) {
name
price
}
}
これで商品アプリとも疎通できていることを確認できました。
レビューの作成
最後にレビューを作成します。
使用するクエリは以下の通りです。
mutation CreateReview {
createReview(
productId: 1,
userId: 1,
score: 5,
description: "期待を込めて星5です") {
id
}
}
結果は以下の通りです。
ここまでで、レビューアプリとの疎通も取れたことが確認できました。
次の節でApollo Routerの便利機能を使用していきます。
レビューの取得(Apollo Routerにエンティティを取得させる)
一通りのデータを作成することができましたが、まだレビューの参照を行なっていません。
では早速レビューを参照するためのクエリを作成していきましょう。
query GetReview {
getReview(id: 1) {
score
description
user {
name
}
product {
name
price
}
}
}
レビューデータとしてレビューを実施したユーザーと対象の商品を取得するクエリになっています。getReviewクエリ自体はレビューアプリに定義されているクエリであり、ユーザーと商品の情報はIDしか持っていないはずです。
しかし、Supergraphでエンティティを結合したことにより、Apollo Routerがそれぞれのアプリに問い合わせを行うことで要求したデータを全て結合した状態でレスポンスしてくれます。
実際に実行した結果が以下になります。
ちゃんと指定したデータを全て結合した上で返してくれました。
この機能によって、マイクロサービスでアプリを開発した際それぞれのアプリにデータを一元管理させることができると同時に、クライアントがリクエストを行うエンドポイントを一つにまとめることができるようになります。
最終的なフォルダ構成
作成した成果物のフォルダ構成は以下のようになりました。
.
├── 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
最後に
最後まで読んでいただきありがとうございます!
記事中に誤っている箇所がありましたら、ご連絡いただけると助かります。