🪢

小川のせせらぎを聞きながら穏やかにGraphQLをざっくり理解する

2024/06/27に公開

概要

GraphQLってとっつきにくいですよね。
「GraphQLって何者なんだ」、「どうやって使うんだ」って人に向けて、ざっくり読むだけでも、実際作ってみるのでも、とにかくGraphQLの一歩を理解するためのハードル下げようの記事です。

GraphQLとは

フロントエンドの開発体験を上げるための、クエリ言語になります。
つまり、フロントエンドでデータの取得を柔軟に変更を効かせることができる言語ということです。

よく対立として存在するのが、REST APIが取り上げられますが
RESTは、サーバー主導により、サーバーからのResponseをサーバーが決めるというようなイメージですが
GraphQLは、フロント主導でResponseとして欲しいデータを決めることができるという違いがあります。

これにより、フロントで必要な情報を必要な箇所に注入することができ、柔軟な変更に対応できるようになっているということです。

フロント主導とは

例えば、このような感じのイメージを持ってもらうと分かりやすいかもしれないです。

ユーザープロフィールの画面を想像してみてください。そこには

  • ユーザーの情報
  • ユーザーが投稿したポスト
  • フォロワー数

などの情報がある画面だとします。
以下のようなエンドポイントからデータを取得する必要があると予想されます。

GET /api/users/123 // ユーザーの情報
GET /api/users/123/posts?limit=3 // ユーザーの投稿したポスト
GET /api/users/123/followers/count // フォロワー数

Restはサーバー主導でクエリを発行するので、それぞれのエンドポイントの返す情報というのが分散しがちになってしまいます。
しかし、これをGraphQLでやると、フロント側で欲しい情報を定義するだけで良い感じにクエリをまとめてくれるので、サーバーへのアクセスが1回で済みます。
上の情報を取得するには、以下のように欲しいデータが定義できると予想されます。

query {
  user(id: "123") {
    name
    email
    profileImage
    posts(limit: 3) {
      id
      title
      createdAt
    }
    followersCount
  }
}

スッキリしてますよね。
idが123のuserの情報として

  • name
  • email
  • profileImage

が取得できていて
そのユーザーが持つpostsを3件分

  • id
  • title
  • createdAt

の情報を取得していて、最後にフォロワー数が定義されています。
ここで、仕様変更があって、ユーザー情報に年齢も表示しないといけないとなった時も

query {
  user(id: "123") {
    name
    email
    profileImage
+   age
    posts(limit: 3) {
      id
      title
      createdAt
    }
    followersCount
  }
}

とするだけで、画面への情報が取得できるようになります。

これで、GraphQLの強みがなんとなく把握できたと思います。

フロント主導ってことはフロントだけで完結するの?

と思う方もいるかもしれません。
残念ながら、フロントのための言語というだけで、フロントだけで完結する訳ではないのです。
GraphQLサーバーというのを構築する必要があります。
しかし、ここら辺はPaaSを提供しているところも多くあり

  • Hasura
  • AWS AppSync

のようなサービスがあります。

GraphQLサーバーの構築は結構大変なので、こういったサービスに丸投げしてしまうというのも構築コストを下げるという意味では有効な手ではありますが、サービスの大きさや規模によりますがPaaSに頼らずサーバーを構築する方が柔軟にできて良い場面というのも多くあると思います。

自分達でサーバーも構築する場合は、Apollo Serverなどが有名ですね。
当記事でもApollo Serverを構築しています。

構築してみる

では、概要を掴んだところで、GraphQLの最小レベルでのサーバー構築とクライアントによるデータ取得などを実装してみましょう。

まずは、プロジェクトを作成します。

# プロジェクトフォルダ作成
mkdir graphql-typescript-project
cd graphql-typescript-project
touch package.json

# サーバー用ディレクトリ作成
mkdir -p server/src
touch server/src/index.ts server/src/schema.ts
touch server/package.json server/tsconfig.json

# クライアント用ディレクトリ作成
mkdir -p client/src
touch client/src/index.ts
touch client/package.json client/tsconfig.json

以下のようなフォルダ構成になると思います。

.
├── client
│   ├── package-lock.json
│   ├── package.json
│   ├── src
│   │   └── index.ts
│   └── tsconfig.json
├── package.json
└── server
    ├── package-lock.json
    ├── package.json
    ├── src
    │   ├── index.ts
    │   └── schema.ts
    └── tsconfig.json

各package.json

package.json
{
  "name": "graphql-typescript-project",
  "version": "1.0.0",
  "description": "A simple GraphQL project with TypeScript",
  "scripts": {
    "start:server": "cd server && npm start",
    "start:client": "cd client && npm start"
  },
  "author": "",
  "license": "ISC"
}
server/package.json
{
  "name": "graphql-server",
  "version": "1.0.0",
  "description": "GraphQL server with TypeScript",
  "main": "dist/index.js",
  "scripts": {
    "start": "ts-node src/index.ts",
    "build": "tsc",
    "dev": "ts-node-dev --respawn src/index.ts"
  },
  "dependencies": {
    "apollo-server": "^3.13.0",
    "graphql": "^16.9.0"
  },
  "devDependencies": {
    "@types/node": "^18.19.39",
    "ts-node": "^10.9.2",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.5.2"
  }
}
client/package.json
{
  "name": "graphql-client",
  "version": "1.0.0",
  "description": "GraphQL client with TypeScript",
  "main": "dist/index.js",
  "scripts": {
    "start": "ts-node src/index.ts",
    "build": "tsc"
  },
  "dependencies": {
    "@apollo/client": "^3.7.12",
    "graphql": "^16.6.0"
  },
  "devDependencies": {
    "@types/node": "^18.15.11",
    "ts-node": "^10.9.1",
    "typescript": "^5.0.4"
  }
}

tsconfig

server/client同じものです。

/server/tsconfig

{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
},
"include": ["src"]
}

/client/tsconfig

{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
},
"include": ["src"]
}

サーバーを作る

server/src/index.ts
import { ApolloServer } from 'apollo-server';
import { typeDefs, resolvers } from './schema';

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

server.listen().then(({ url }) => {
  console.log(`Server ready at ${url}`);
});
server/src/schema.ts
import { gql } from 'apollo-server';

export const typeDefs = gql`
  type Book {
    id: ID!
    title: String!
    author: String!
  }

  type Query {
    books: [Book!]!
    book(id: ID!): Book
  }

  type Mutation {
    addBook(title: String!, author: String!): Book!
  }
`;

interface Book {
  id: string;
  title: string;
  author: string;
}

let books: Book[] = [
  { id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' },
  { id: '2', title: 'To Kill a Mockingbird', author: 'Harper Lee' },
];

export const resolvers = {
  Query: {
    books: () => books,
    book: (_: any, { id }: { id: string }) => books.find(book => book.id === id),
  },
  Mutation: {
    addBook: (_: any, { title, author }: { title: string; author: string }) => {
      const newBook = { id: String(books.length + 1), title, author };
      books.push(newBook);
      return newBook;
    },
  },
};

クライアントを作る

client/src/index.ts
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache(),
});

async function main() {
  // Query all books
  const { data: booksData } = await client.query({
    query: gql`
      query GetBooks {
        books {
          id
          title
          author
        }
      }
    `,
  });

  console.log('All books:', booksData.books);

  // Add a new book
  const { data: newBookData } = await client.mutate({
    mutation: gql`
      mutation AddBook($title: String!, $author: String!) {
        addBook(title: $title, author: $author) {
          id
          title
          author
        }
      }
    `,
    variables: { title: '1984', author: 'George Orwell' },
  });

  console.log('New book added:', newBookData.addBook);

  // Query a specific book
  const { data: bookData } = await client.query({
    query: gql`
      query GetBook($id: ID!) {
        book(id: $id) {
          id
          title
          author
        }
      }
    `,
    variables: { id: '1' },
  });

  console.log('Specific book:', bookData.book);
}

main().catch(console.error);

実行してみる

サーバーを立ち上げます。

// root
npm run start:server

localhost:4000で立ち上がります。
続いて、クライアントで実行してみます。

// root
npm run start:client

すると、このようなログがclientの方に出ると思います。

All books: [
  {
    __typename: 'Book',
    id: '1',
    title: 'The Great Gatsby',
    author: 'F. Scott Fitzgerald'
  },
  {
    __typename: 'Book',
    id: '2',
    title: 'To Kill a Mockingbird',
    author: 'Harper Lee'
  }
]
New book added: { id: '3', title: '1984', author: 'George Orwell', __typename: 'Book' }
Specific book: {
  __typename: 'Book',
  id: '1',
  title: 'The Great Gatsby',
  author: 'F. Scott Fitzgerald'
}

これで、GraphQLサーバーに対して、クライアントから実行するというところまでをやりました。
意外と簡単だったのではないでしょうか。
内容自体、非常に小さいものなので、schemaが小さく簡単に見えたかもしれません。
実際はもっとschemaは多く、より複雑になります。

何をしているか

まずサーバー側を見ていきます。

サーバー

gql

GraphQLは、gqlというGraphQLが解釈する言語を利用してスキーマの定義を行います。

export const typeDefs = gql`
  type Book {
    id: ID!
    title: String!
    author: String!
  }

  type Query {
    books: [Book!]!
    book(id: ID!): Book
  }

  type Mutation {
    addBook(title: String!, author: String!): Book!
  }
`;

ここがその部分です。
ここれは

  • Bookというモデルの定義
  • Queryにはbooksとbook(個別ID)というRestでいうGETのインターフェース
  • MutationにはaddBookというRestでいうPOSTのインターフェース

が定義されています。
規模の大きいサービスなどになると、モデルは当然増えますし、それに伴ってQueryやMutationのインターフェースも増えていきます。
今は一つのファイルで後述するresolverと一緒に定義してますが、分けた方がいい場合もあります。

resolver

実際に、インターフェースが呼び出された時の実体の処理が書かれます。

export const resolvers = {
  Query: {
    books: () => books,
    book: (_: any, { id }: { id: string }) => books.find(book => book.id === id),
  },
  Mutation: {
    addBook: (_: any, { title, author }: { title: string; author: string }) => {
      const newBook = { id: String(books.length + 1), title, author };
      books.push(newBook);
      return newBook;
    },
  },
};

この部分です。
インターフェースで定義したように、books, book, addBookが定義されており
実際の処理が書かれています。
インターフェースで引数や、返り値を定義しているので、それに合わせた実装が必要になります。
これはよくスキーマファーストと呼ばれます。
こちらの記事に詳しく、分かりやすく書かれていたので紹介します。
https://zenn.dev/chillnn_tech/articles/15462cffcdecd3

これにより、クライアントとのやりとりはこのスキーマを通して行われるようになるため、インターフェースの齟齬がなくなります。
Restであれば、OpenAPIなどを立てたりなど、一手間必要でしたが、GraphQLの世界だけで閉じることができます。

そして、今は一つのファイルにまとめて定義してしまっていますが
Resolverを分けたりして、ビルダーを挟むことでより分かりやすく、拡張性のある構成にすることができます。

サーバーまとめ

サーバー部分に関しては、今はこれくらいです。
一つ注意点としては、今回は特にデータの永続性などを考慮せず、サーバーを立ててクライアントから叩くことだけを目的としていたので、データはメモリ上に保存されるようになっています。
つまり、サーバーを再起動したらデータは消えます。

books.push(newBook);

この部分で、グローバルに定義したbooksの情報に追加したり参照しているという感じになります。
普通のサービスではそんなデータの管理はしないので、DBなどに格納することになると思います。
そこはGraphQLでも同じになります。

クライアント

では、次はクライアントになります。

クライアントでも、gqlというGraphQLが解釈する言語を利用します。
GraphQLの記法を使って、先ほどサーバーの方で定義されているbooksやaddBookの定義を利用するように書きます。

const { data: booksData } = await client.query({
  query: gql`
    query GetBooks {
      books {
        id
        title
        author
      }
    }
  `,
});

この部分ですね。
GetBooksというqueryの名前を定義して、実際にサーバーで定義されているbooksから、どの情報を取得するかを記載しています。
これによって、booksの持つ、id, title, authorが取得できるようになります。
得られた情報はdataという名前で格納され受け取ることになります。
エラーなどをサーバーでカスタムすることもでき、GraphQLエラーとして処理されたエラーが返ってくることもあり、errorsといった名前で返ってくるので必ずエラーハンドリングはしないといけない部分です。
GraphQLエラーを、どういったエラーにするかといった考えは、実際の仕様に沿って定めることとなります。
https://spec.graphql.org/October2021/#sec-Errors.Error-result-format

{
  "errors": [
    {
      "message": "Name for character with ID 1002 could not be fetched.",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["hero", "heroFriends", 1, "name"]
    }
  ],
  "data": {
    "hero": {
      "name": "R2-D2",
      "heroFriends": [
        {
          "id": "1000",
          "name": "Luke Skywalker"
        },
        null,
        {
          "id": "1003",
          "name": "Leia Organa"
        }
      ]
    }
  }
}

このような形でエラーが格納されていきます。

フラグメント

ここまでで、今回のコードとしてのクライアント処理は以上なのですが
フラグメントという概念があります。

フラグメントは、破片といった意味合いがあります。
つまり、クエリに必要なものを必要な箇所で細かく定義できるようにしたものがフラグメントになります。
この、「必要なものを必要な箇所で」定義し、最終的にクエリにまとめるという考えを、コロケーションといいます。

今回は特にフロントエンドの部分がなく、DOMを構成するコンポーネントが登場しないため、どこで何のデータが必要かというのがないため、フラグメントを使うメリットがあまり感じられないかもしれません。

が、もしフラグメントを作るとしたらこんな感じになります。

import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache(),
});

// フラグメントの定義
const BOOK_FIELDS = gql`
  fragment BookFields on Book {
    id
    title
    author
  }
`;

async function main() {
  // Query all books
  const GET_BOOKS = gql`
    query GetBooks {
      books {
        ...BookFields
      }
    }
    ${BOOK_FIELDS}
  `;

  const { data: booksData } = await client.query({ query: GET_BOOKS });
  console.log('All books:', booksData.books);

  // Add a new book
  const ADD_BOOK = gql`
    mutation AddBook($title: String!, $author: String!) {
      addBook(title: $title, author: $author) {
        ...BookFields
      }
    }
    ${BOOK_FIELDS}
  `;

  const { data: newBookData } = await client.mutate({
    mutation: ADD_BOOK,
    variables: { title: '1984', author: 'George Orwell' },
  });

  console.log('New book added:', newBookData.addBook);

  // Query a specific book
  const GET_BOOK = gql`
    query GetBook($id: ID!) {
      book(id: $id) {
        ...BookFields
      }
    }
    ${BOOK_FIELDS}
  `;

  const { data: bookData } = await client.query({
    query: GET_BOOK,
    variables: { id: '1' },
  });

  console.log('Specific book:', bookData.book);
}

main().catch(console.error);

フラグメントはフロントエンドのコンポーネントを考える上で、非常に重要で避けては通れない設計の一つなので、試してみて、良い方法を馴染ませていく必要があります。

クライアントまとめ

クライアントとしてはこれくらいになります。
冒頭でも言ったように、GraphQLはクライアント主導と言われるだけあり、クライアントの使い方が柔軟で考え方がシンプルになります。

その反面、サーバー側が大変になってしまうイメージが強いです。
例えば、フロントで好きなように欲しい情報を定義できるとなったら、循環参照(ユーザーのフォロワーのフォロワーのフォロワーのような感じ)をしてしまうことも容易となってしまいます。
そうなると、大量のデータを取得することになり、データベースへの負荷が高まってしまったりします。
こういった制約を考慮し、サーバーでの処理を制御しないと、DBのパフォーマンスが落ちたり、DBサーバーの使用料金が増えたりと弊害が出てしまうこともあるでしょう。

クライアントの考えはシンプルである一方、サーバーからだと少し複雑性が増すといったことがGraphQLの特徴ですね。

まとめ

GraphQLは非常に強力なツールです。
上記の使い方は、GraphQLという言語を使うことだけに焦点を充てましたが、あくまでGraphQLの記法に乗っただけの言語なので、TypeScriptで使う場合には型などを生成する必要があったりします。

そういった型の生成や、ビルダーなどのエコシステムツールを多く開発している the guild という開発者グループがあります。

https://the-guild.dev/

こういったツールを駆使して、よりGraphQLを使いやすくしていくことができます。
少し複雑性が増しますが、一度構築すれば非常に開発体験は良くなるので、GraphQLを導入する際には検討した方がいいでしょう。

いかがでしたでしょうか。
少しでもGraphQLへのハードルが下がっていれば嬉しいです。

Discussion