🔰

GraphQL を公式チュートリアルで始めてみる

2022/10/31に公開

GraphQLについてはよく記事で見ていて存在は知っていたものの、
実際に試せていなかったのですが、改めて調査と公式チュートリアルを実施してみました。

GraphQL 概要

GraphQLとはクライアントのサーバーの通信のための言語仕様です。
様々な言語で様々な実装があり、幅広い環境で利用することが可能となっています。
https://graphql.org/code/

特徴としては以下のものが挙げられます。

  • スキーマにより型安全な開発ができる。
  • 独自のクエリ言語によって一度のリクエストで過不足なくデータが取得できる。

GraphQLの利用のイメージですが大雑把に以下のような流れになります。

  • サーバー側でAPIやデータのスキーマ定義とその実装を行なう。
  • クライアントからはGraphQL独自のクエリ言語を用いてデータの取得、更新などのリクエストを行う。
    • HTTPのPOSTでもリクエストできる。
    • GraphQLクライアントライブラリなどを使うとキャッシュなどもいい感じにやってくれる。

RestAPIだと、ユーザーとユーザーが持っている本データを取得したい場合、ユーザー情報と本情報をそれぞれのAPIから取得して必要な属性を取捨選択し、結合するといった流れになることが多いかと思います。

しかし、GraphQLでは必要なデータと必要なフィールドを指定して取得することができ、様々なエンドポイントから必要なデータを取得して結合するといったことが不要となります。

IDが 0001 のユーザーの情報と持っている本情報を情報を取得するクエリの例

{
    user(id: "0001") { # ID が 0001 のユーザー
        # 名前とemailを取得する
        name 
        email
        books { # ユーザーが持っている本情報も併せて取得する
            title
            author
        }
    }
}

デメリットで言うと以下のような点が挙げられます。

  • クエリを順番に処理していくためN+1問題が発生しやすい
    • dataloader ライブラリの遅延読み込みで対応
  • 単一のエンドポイントに対してリクエストを投げるためURLベースのキャッシュが行えない
    • 専用のクライアントライブラリを利用する
      • Apollo Client
      • Relay

調査もほどほどにチュートリアル進めてみます。


公式チュートリアル (JavaScript)

Getting Started

試したリポジトリはこちら

パッケージ準備

npm init -y && npm i ts-node graphql

GraphQLでデータを取得するには Query型でスキーマの定義と、実際に処理を行う Resolver と呼ばれる関数の実装が必要。
なお、Query型ではデータの取得を行うAPIを定義する。
Query型以外にもデータの追加、変更、削除をするMutation型、イベントの購読を行うSubscription型がある。
Hello Worldを返すQuery型APIの実装例。

import { graphql, buildSchema } from 'graphql';

// Query 型でスキーマを定義
// 文字列を返す hello という API を定義している
const schema = buildSchema(`
  type Query {
    hello: String
  }
`);

// クエリを処理するリゾルバを定義
const rootValue = {
  hello: () => {
    return 'Hello world!';
  },
};

const main = async () => {
  const query = '{ hello }';

  // クエリを実行
  const response = await graphql({
    schema,
    source: query,
    rootValue,
  });

  console.log(response);
};

main();

実行

npx ts-node server

{ data: [Object: null prototype] { hello: 'Hello world!' } }

Running an Express GraphQL Server

Express を使ってHTTPのエンドポイントにGraphQLサーバーをマウントできます。

パッケージ導入

npm init && npm i ts-node graphql express-graphql express @types/express

Express で httpエンドポイントへGraphQLサーバーをマッピングする実装例

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

const schema = buildSchema(`
  type Query {
    hello: String
  }
`);

const root = {
  hello: () => {
    return 'hello world';
  },
};

const app = express();
app.use(
  '/graphql', //  /graphql に GraphQLサーバーをマッピング
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true, // GraphQL の Webクライアント GraphiQLを有効にする。
  })
);

app.listen(4000);
console.log('Running a GraphQL API server at http://localhost:4000/graphql');

graphqlサーバーを起動

npx ts-node server

ブラウザで以下のURLにアクセスすると WebUI でGraphQLのクエリを実行できる GraphiQL にアクセスできる。

https://localhost:4000/graphql

画面の左側に以下のクエリを入力して実行すると結果が返ってくる。

{
    hello
}

GraphQL Clients

GraphQL クライアントライブラリも存在しますが、単純な HTTP の POSTリクエストによって簡単にクエリを発行することができます。
REST APIでは用途によって GET/DELETE/POST/PUT などを使い分けますが、GraphQL では全て POSTを使ってクエリを発行します。

Express GraphQL Server で作成したGraphQLサーバーに対して以下のようなcurlコマンドを実行するとJSON形式でデータが返却されます。

curl -X POST -H "Content-Type: Application/json" -d '{"Query": "{hello}"}' http://localhost:4000/graphql

また、GraphQL でも API のエンドポイントに引数を渡すことができます。

queryに$をプレフィックスとしたキーワードを指定し、variablesでキーワードに対応したプロパティを持つオブジェクト渡すことで値を自動的にエスケープしてクエリを発行できます。

const dice = 3;
const sides = 7;
      
// dice と sides を変数として渡すために 
// $dice と $sides を指定してクエリを作成する。
const query = `query RollDice($dice: Int!, $sides: Int) {
    rollDice(numDice: $dice, numSides: $sides)
}`;

const result = await fetch("/graphql2", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json",
  },
  body: JSON.stringify({
    query,
    variables: { dice, sides }, // dice と sides を含むオブジェクトを渡す
  }),
});

Basic Types

GraphQLでの基本型とJSでの対応は以下。

  • String : 文字型 → string
  • Int : 整数型 → number
  • Float : 不動小数点 → number
  • Boolean : 真偽 → boolean
  • ID : ユニークな識別子 → string
    • GraphQLクライアントはこのIDをもとにキャッシュなどを行なってくれるらしい

基本型は全て Nullable。
Nullを許容しない場合は末尾に ! (eg. String!)
リスト型の場合は [] で囲む (eg. [String])

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

const schema = buildSchema(`
  type Query {
    quoteOfTheDay: String
    random: Float!
    rollThreeDice: [Int]
  }
`);

const root = {
  quoteOfTheDay: () => {
    return Math.random() < 0.5 ? 'Take it easy' : 'Salvation lies within';
  },
  random: () => {
    return Math.random();
  },
  rollThreeDice: () => {
    return [1, 2, 3].map((_) => 1 + Math.floor(Math.random() * 6));
  },
};

const app = express();
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  })
);

app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');

Passing Arguments

GraphQLでもエンドポイントに引数を持たせることが可能です。

引数には名前と型を指定する必要があります。

type Query {
  rollDice(numDice: Int!, numSides: Int): [Int]
}

numDiceは ! によってnullではないことが保証されるため、サーバーのバリデーションを省略できます。

引数があるAPIではリゾルバの最初の引数にオブジェクトとして渡される。

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

const schema = buildSchema(`
type Query {
    rollDice(numDice: Int!, numSides: Int): [Int]
}
`);

// RollDice の引数型
interface RollDiceArgs {
  numDice: number;
  numSides: number;
}

const root = {
  // リゾルバの第一引数にオブジェクトとしてパラメータが渡される。
  rollDice: ({ numDice, numSides }: RollDiceArgs) => {
    const output: number[] = [];

    for (let i = 0; i < numDice; i++) {
      output.push(1 + Math.floor(Math.random() * (numSides || 6)));
    }

    return output;
  },
};

const app = express();
app.use(express.static('public'));
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  })
);

app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');

Object Type

GraphQL のスキーマでは独自の振る舞いを持ったオブジェクトを定義することができます。

// 独自の振る舞いを持った RandomDie を定義
type RandomDie {
  roll(numRolls: Int!): [Int]
}

type Query {
  // RandomDie を返す API
  getDie(numSides: Int): RandomDie
}

getDie と RandomDie の実装例

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

// RandomDie の publicメンバーにアクセスできるので、
// numSides, rollOnceについてもスキーマを定義しておく
const schema = buildSchema(`
type RandomDie {
    numSides: Int!
    rollOnce: Int!
    roll(numRolls: Int!): [Int!]!
}

type Query {
    getDie(numSides: Int): RandomDie
}
`);

class RandomDie {
  constructor(private _numSides: number) {}

  get numSides() {
    return this._numSides;
  }

  rollOnce() {
    return 1 + Math.floor(Math.random() * this._numSides);
  }

  // 引数はオブジェクトで渡されるので分割代入で受け取っている
  roll({ numRolls }: { numRolls: number }) {
    const output: number[] = [];

    for (let i = 0; i < numRolls; i++) {
      output.push(this.rollOnce());
    }

    return output;
  }
}

interface GetDieArg {
  numSides: number;
}

const root = {
  getDie: ({ numSides }: GetDieArg) => {
    return new RandomDie(numSides || 6);
  },
};

const app = express();
app.use(express.static('public'));
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  })
);

app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');

getDieで取得した RandomDie に対して各メソッドを実行した結果が返ってくる。

{
    getDie(numSides: 6) {
        rollOnce
        roll(numRolls: 3)
        numSides
    }
}

{
  "data": {
    "getDie": {
      "rollOnce": 5,
      "roll": [
        5,
        4,
        5
      ],
      "numSides": 6
    }
  }
}

Mutations and Input Types

データの挿入やデータの変更などを行うAPIでは Query ではなく Mutation として定義する必要があります。

メッセージの更新と取得するAPIのスキーマ

type Mutation {
  setMessage(message: String): String
}

type Query {
  getMessage(message: String): String
}

メッセージの更新と取得するAPIの実装例

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

const schema = buildSchema(`
type Mutation {
    setMessage(message:String!): String!
}

type Query {
  getMessage: String!
}
`);

const inmemoryDB = {
  message: 'default',
};

const root = {
  setMessage: ({ message }: { message: string }) => {
    inmemoryDB.message = message;

    return inmemoryDB.message;
  },
  getMessage: () => {
    return inmemoryDB.message;
  },
};

const app = express();
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  })
);

app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');

初期メッセージ取得

{
	getMessage
}

{
	"getMessage": "default"
}

メッセージ更新

mutation {
	setMessage(message: "hello")
}

{
  "setMessage": "hello"
}

新後メッセージ取得

{
	getMessage
}

{
	"getMessage": "hello"
}

また、同じ入力パラメータを複数のAPIで使う時などは input キーワードを使って入力型としてまとめることができます。

input MessageInput {
  content: String
  author: String
  # message: Message  -> オブジェクト型はダメ
}

type Message {
  id: ID!
  content: String
  author: String
}

type Query {
  getMessage(id: ID!): Message
}

type Mutation {
  createMessage(input: MessageInput): Message
  updateMessage(id: ID!, input: MessageInput): Message
}

入力タイプはフィールドとして、基本型、リスト型、入力型を持つことができます。
オブジェクト型は持つことはできません。

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';
import crypto from 'crypto';

const schema = buildSchema(`
  input MessageInput {
    content: String
    author: String
  }

  type Message {
    id: ID!
    content: String
    author: String
  }

  type Query {
    getMessage(id: ID!): Message
  }

  type Mutation {
    createMessage(input: MessageInput): Message
    updateMessage(id: ID!, input: MessageInput): Message
  }
`);

interface MessageInput {
  content: string;
  author: string;
}

class Message {
  private _content: string;
  private _author: string;
  constructor(private _id: string, { content, author }: MessageInput) {
    this._content = content;
    this._author = author;
  }

  get id() {
    return this._id;
  }

  get content() {
    return this._content;
  }
  get author() {
    return this._author;
  }
}

type MessageInfo = {
  content: string;
  author: string;
};

const inmemoryDB: {
  [key: string]: MessageInfo;
} = {};

const root = {
  getMessage: ({ id }: { id: string }) => {
    if (!inmemoryDB[id]) {
      throw new Error('no message exists with id ' + id);
    }

    return new Message(id, inmemoryDB[id]);
  },
  createMessage: ({ input }: { input: MessageInfo }) => {
    const id = crypto.randomUUID();

    inmemoryDB[id] = input;
    return new Message(id, input);
  },
  updateMessage: ({ id, input }: { id: string; input: MessageInput }) => {
    if (!inmemoryDB[id]) {
      throw new Error('no message exists with id ' + id);
    }

    inmemoryDB[id] = input;
    return new Message(id, input);
  },
};

const app = express();
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  })
);

app.listen(4001, () => {
  console.log('Running a GraphQL API server2 at localhost:4001/graphql');
});

Authentication and Express Middleware

express-graphqlと組み合わせて、Expressミドルウェアを簡単に利用できます。

また、リゾルバでは第2引数からrequestにアクセスすることができます。

import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

var schema = buildSchema(`
  type Query {
    ip: String
  }
`);

const loggingMiddleware = (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) => {
  console.log('ip:', req.ip);
  next();
};

const root = {
  ip: (_args: any, context: express.Request) => {
    // graphqlHTTP の context オプションを指定していないので
    // 第二引数には express.Request の値が渡される
    return context.ip;
  },
};

const app = express();
app.use(loggingMiddleware);
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
    // resolver の第二引数に渡す値。指定しないと requestオブジェクトが渡される
    // context: { hoge: 'context' }, 
  })
);

app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');

さっくりとチュートリアルを試してみました。

型安全な仕組みは開発体験を向上させる素晴らしい仕組みだと思うので、引き続きクライアントライブラリやスキーマからtypescriptの型を生成する code generator など色々試していきたいと思います。

GitHubで編集を提案

Discussion