Apollo GraphQLに入門してみた(1)の続きです。Apollo GraphQLの基本がわかったので、データーベース(PostgreSQL)を接続しデーターベースのデータを取得できるGraphQL APIを作ってみました。
今回の目標
- データーベースには以下のような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を使い、以下のような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');
});