EY-Office ブログ

Apollo GraphQLに入門してみた(2)

Apollo GraphQLに入門してみた(1)の続きです。Apollo GraphQLの基本がわかったので、データーベース(PostgreSQL)を接続しデーターベースのデータを取得できるGraphQL APIを作ってみました。

Apollo

今回の目標

  • データーベースには以下のようなusersとgroupsテーブルがあります。
CREATE TABLE groups (
  id SERIAL PRIMARY KEY,
  name VARCHAR NOT NULL,
  created_at TIMESTAMP DEFAULT current_timestamp
);

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name VARCHAR NOT NULL,
  email VARCHAR NOT NULL,
  group_id INTEGER REFERENCES groups(id),
  created_at TIMESTAMP DEFAULT current_timestamp
);
  • これに対し以下のようなqueryで
query {
  users {
    id
    name
    email
    created_at
    group {
      id
      name
    }
  }
}
  • groups情報を含むusersの一覧が取得できます
{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "山田太郎",
        "email": "yama@mail.com",
        "created_at": "2021-09-05T16:35:57.424Z",
        "group": {
          "id": 1,
          "name": "土"
        }
      },
      {
        "id": 2,
        "name": "岡田太郎",
        "email": "oka@mail.com",
        "created_at": "2021-09-05T16:35:57.435Z",
        "group": {
          "id": 1,
          "name": "土"
        }
      },

      ....

    ]
  }
}

node-postgresが嫌になりPostgres.jsを採用

PostgreSQLへの接続ライブラリーは定番のnode-postgres(pg)を使っていたのですが・・・

  • 昔作られてものでしょうかAPI, クラス構成が好きになれません
  • ログが簡単に取れない! 調べてみたら自分でパッチ的なものを書かないといけません😰 → 詳細
  • SQLのINが書けない! SELECT * FROM tbl WHERE col1 IN ($1)がエラーになります😰 → 詳細

もっと良いライブラリーがあるに違いないと調べPostgres.js(postgres)を発見しました。とてもモダンなAPI😘
TypeScriptのサポートはベータ版の2.0.0-betaからですが、ベータ版を使うことにしました。

Postgres.js

Postgres.jsを使い、以下のようなResolverが書けました。

const resolvers:Resolvers = {
  Query: {
    user: async (_parent, {id}: {id:number}, context, _info) =>
      (await sql<User[]>`SELECT * FROM users WHERE id = ${id}`)[0],
    users: async (_parent, _args , context) =>
      (await sql<User[]>`SELECT * FROM users`),
    group: async (_parent, {id}: {id:number}, context) =>
      (await sql<Group[]>`SELECT * FROM groups WHERE id = ${id}`)[0],
    groups: async (_parent, _args , context) =>
      (await sql<Group[]>`SELECT * FROM groups`)
  }
};

関連情報の取得

GraphQLのqueryは1つのリソース(たとえばテーブルのデータ)の取得だけでなく、関連する情報も一緒に取得できます。今回の目標ではusers情報とuserと所属するgroupが取得されています。

これを実装すれには、Resolverに User: { group: () => }のようにuserのgroup情報を組み立てるコードを追加すれば良いのです。
今回のResolverは以下のようになります。

const resolvers:Resolvers = {
  Query: {
    user: async (_parent, {id}: {id:number}, context, _info) =>
      (await sql<User[]>`SELECT * FROM users WHERE id = ${id}`)[0],
    users: async (_parent, _args , context) =>
      (await sql<User[]>`SELECT * FROM users`),
    group: async (_parent, {id}: {id:number}, context) =>
      (await sql<Group[]>`SELECT * FROM groups WHERE id = ${id}`)[0],
    groups: async (_parent, _args , context) =>
      (await sql<Group[]>`SELECT * FROM groups`)

  },
  User: {
    group: async (parent,  _args , context) =>
      (await sql<Group[]>`SELECT * FROM groups WHERE id = ${parent.group_id}`)[0]
  }
}

N+1問題が発生しました

上のResolverでusers queryを実行すると、以下のようにusersの件数個のgroups取得SQLが実行されています。もしusersの件数が多いとSQLの実行件数が膨大なります、一般的にN+1問題といわれる問題です。

2021/10/6 22:10:38 SQL: SELECT * FROM users
2021/10/6 22:10:38 SQL: SELECT * FROM groups WHERE id = $1 -- Params: [1]
2021/10/6 22:10:38 SQL: SELECT * FROM groups WHERE id = $1 -- Params: [1]
2021/10/6 22:10:38 SQL: SELECT * FROM groups WHERE id = $1 -- Params: [2]
2021/10/6 22:10:38 SQL: SELECT * FROM groups WHERE id = $1 -- Params: [2]

この問題にはDataLoaderを使うと良いとApollo公式ドキュメント:Data sources/Using with DataLoaderに書かれています。 DataLoaderはRDB取得データのキャッシュを行ってくれるのでN+1問題を解決してくれます。

2021/10/6 22:33:29 SQL: SELECT * FROM users
2021/10/6 22:33:29 SQL: SELECT * FROM groups WHERE id IN ($1,$2) -- Params: [1,2]

具体的なコードはUsing DataLoader with GraphQL: A Concrete Exampleを参考にDataLoaderを追加してみました、完成したコードは最後に置きました。

まとめ

さて、データーベースのデータを取得してGraphQLのqueryをサポートするAPIサーバーが書けました。しかし、前回書いたように、GraphQLのデータ定義には型がありますが、それを直接TypeScriptからは利用できません。今回はGraphQL Code Generatorツールを使いTypeScript用定義ファイルを手動で生成して使っています。なんかモヤモヤします😒

そんなとき、TypeScriptをベースに作られたTypeGraphQLを見つけました。TypeScriptでクラスを定義すると、そこからGraphSQLのschemaなどを生成してくれます!
これは福音か?

しかし、TypeGraphQLのドキュメントを見ると、TypeScriptの定義で足りない情報はデコレータで指定します。ホームページには以下のようなコードがあります。

@ObjectType()
class Recipe {
  @Field()
  title: string;

  @Field({ nullable: true })
  description?: string;

  @Field(type => [Rate])
  ratings: Rate[];

  @Field(type => Float, { nullable: true })
  get averageRating() {
    const sum = this.ratings.reduce((a, b) => a + b, 0);
    return sum / this.ratings.length;
  }
}

やはり何かモヤモヤします😒 慣れの問題でしょうか?・・・・次回へ続きます


今回の最終コード

import express from 'express';
import cors from 'cors';
import { ApolloServer, gql } from 'apollo-server-express';
import postgres from 'postgres';
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs as scalarsTypeDefs, resolvers as scalarsResolvers } from 'graphql-scalars';
import { Resolvers, User, Group } from './generated/graphql'
import DataLoader from 'dataloader';

const log = (s: any) => console.log(`${(new Date()).toLocaleString('ja-JP')} ${s}`)

const postgresLog = (_connection: number, query: string, parameters: any[]) => {
  const params = parameters.length > 0 ? ` -- Params: [${parameters.map(t => t.raw)}]` : ''
  log(`SQL: ${query}${params}`)
}

const sql = postgres({
  host: process.env.DB_HOST,
  database: process.env.DB_DATABASE,
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  debug: postgresLog
});

const schema = gql`
type Query {
  user(id: Int!): User
  users: [User]
  group(id: Int!): Group
  groups: [Group]
}
type User {
    id: Int!
    name: String!
    email: String!
    group_id: Int!
    group: Group!
    created_at: DateTime!
}
type Group {
  id: Int!
  name: String!
  created_at: DateTime!
}
`;

const resolvers:Resolvers = {
  Query: {
    user: async (_parent, {id}: {id:number}, context, _info) =>
      (await sql<User[]>`SELECT * FROM users WHERE id = ${id}`)[0],
    users: async (_parent, _args , context) =>
      (await sql<User[]>`SELECT * FROM users`),
    group: async (_parent, {id}: {id:number}, context) =>
      (await sql<Group[]>`SELECT * FROM groups WHERE id = ${id}`)[0],
    groups: async (_parent, _args , context) =>
      (await sql<Group[]>`SELECT * FROM groups`)

  },
  User: {
    group: async (parent,  _args , context) =>
      await context.loaders.group.load(parent.group_id)
  }
};

const loaders = {
  group: new DataLoader<number, Group[]>(async (keys: readonly number[]) =>
     await sql`SELECT * FROM groups WHERE id IN (${[...keys]})`
)};

const app = express();
app.use(cors());

(async () => {
  const server = new ApolloServer({
    schema: makeExecutableSchema({
      typeDefs: [...scalarsTypeDefs, schema],
      resolvers: {...scalarsResolvers, ...resolvers}
    }),
    context: () => ({
      sql,
      loaders,
      log
     })
  });

  await server.start()
  server.applyMiddleware({ app });
})()

app.listen({ port: 5000 }, () => {
  console.log('server on http://localhost:5000/graphql');
});

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ