はじめに
Node.jsでGraphQL APIを実装します.GraphQLの概要やクライアントスキーマの記述について日本語のドキュメントは見かけました.しかし,API側の実装についてのドキュメントをあまり見かけなかったので記載します.
自分が調査も兼ねて実装したサンプルを紹介します.一例として参考にしていただければ幸いです.もっと良い実装法がありましたら教えていただければと思います.
GraphQLの概要については省略します.公式サイトや下記の記事等で紹介されています.(2つ目の記事の後半ではRubyでのAPI実装も紹介しています.)
作るもの(概要設計)
言語・フレームワーク
- TypeScript
- Node.js
- express
Node.jsのWebフレームワークexpress上で稼働するGraphQL APIを作成します.
構成
構成は以下の図の通りです.データベースにMySQLが設置され,GraphQL APIがMySQLをラッパしています.
APIはGraphQLでのリクエストを通常のSQLに変換しデータベースに問い合わせを行い,その結果をレスポンスします.
題材
とある通販サイトのデータベースを想定します.このデータベースは以下の構成で管理しています.
お客様情報, 製品情報, メーカ情報, 注文情報があります. すべてのテーブルがプライマリキーとしてオートインクリメントの"id"を持っているとします.
お客様情報(users)
フィールド名 | 型 | 概要 |
---|---|---|
id | int | プライマリキー, AUTO_INCREMENT |
name | text | 氏名 |
gender | text | 性別 (male, female, other) |
rank | text | 会員ランク (general, premium) |
製品情報(products)
フィールド名 | 型 | 概要 |
---|---|---|
id | int | プライマリキー, AUTO_INCREMENT |
name | text | 製品名 |
model_number | text | 型番 |
price | int | 価格 |
maker_id | int | 商品のメーカのid |
メーカ情報(makers)
フィールド名 | 型 | 概要 |
---|---|---|
id | int | プライマリキー, AUTO_INCREMENT |
name | text | 会社名 |
注文情報(orders)
フィールド名 | 型 | 概要 |
---|---|---|
id | int | プライマリキー, AUTO_INCREMENT |
order_date | int | 注文日時 |
user_id | int | 注文したユーザのID |
注文情報と製品情報の連結テーブル(order_product)
上記のordersとproductsを結合するためのテーブルです.各々の注文で購入された商品を記録するためのものです.
フィールド名 | 型 | 概要 |
---|---|---|
id | int | プライマリキー, AUTO_INCREMENT |
order_id | int | 注文ID |
product_id | int | 製品ID |
スキーマ定義
上記のテーブル構成をGraphQLのスキーマ定義に置き換えたものが以下になります.なお,一部を抜粋しています.すべての定義はこちら(github.com)を参照してください. (なおこちらはJavaScriptでの実装から自動生成したものです.)
"""お客様の情報"""
type User {
id: ID
name: String
"""性別"""
gender: Gender
"""会員ランク"""
rank: MemberRank
orders: OrderConnection
}
"""商品"""
type Product {
id: ID
"""商品名"""
name: String
"""型番"""
modelNumber: String
"""販売価格"""
price: Int
maker: Maker
}
"""製造業者"""
type Maker {
id: ID
name: String
products: ProductConnection
}
"""注文情報"""
type Order {
id: ID
"""注文日時(UNIX TIME)"""
orderDate: Int
"""注文したユーザ"""
user: User
"""注文に含まれた商品"""
products(limit: Int, offset: Int = 0): ProductConnection
}
また, Userのランクと性別のために以下の列挙体を定義しています.
"""性別"""
enum Gender {
"""男性"""
male
"""女性"""
female
"""その他"""
other
}
"""会員ランク"""
enum MemberRank {
"""一般会員"""
general
"""ゴールド会員"""
gold
"""プラチナ会員"""
platinum
}
Connectionについて
GraphQLにはConnectionと呼ばれるお作法があります.Connectionと言うと難しそうですが,ようするにページネーションです.データベースのlimit, offsetで取得件数を制限したり,表示開始位置を設定したりします.
また,offsetで開始位置を数字で指定するのではなく,カーソルで指定する方法もあります.カーソル指定とはデータベースの各行にプライマリキー(ハッシュ値など)を設定し,その値で開始位置を指定する方法です.常に新しい行が追加されてしまうデータを取り扱う場合に非常に有効です. 例えばTwitterやFacebookのタイムラインは常に新しい行が追加されています.offsetで位置を指定してもその行の位置が次々に更新されてしまうため,目的の行を指定できません.こういった場合,カーソルを使って行の値自体で位置を指定します.
GraphQLのConnectionについては下記で紹介されていますので,参照してみてください.
今回は単純化のためにoffset, limitを使ったページネーションで実装します.
上記のスキーマ定義でXxxxxConnectionといったものがページネーションに該当します.
例えば製造業者にはproductsでProductConnectionという項目を定義しています.
"""製造業者"""
type Maker {
id: ID
name: String
products: ProductConnection
}
これに対するクエリは以下のようになります.
製造業者(maker)が作った製品群(products)を3番目から10件取得するクエリです.
edges, nodeという項目がありますがこれはお作法です.
query {
makers {
id,
name,
products(limit:10, offset: 3) {
edges {
node {
id,
name
}
}
}
}
}
実際にConnectionを実装する場合, graphql-relayというライブラリが便利な関数などを用意してくれているので活用しましょう.
実装
それでは実際に実装した部分を紹介します.
リポジトリ
今回,自分が作った内容はGithubの下記のリポジトリににアップロードしております.ご参考になれば幸いです.
expressとGraphQL
まずはNodejsのエントリポイントであるapp.tsと/graphql
のエントリポイントであるgraphql.tsを掲載します.
app.ts
起動処理自体は一般的なexpressによるWebアプリケーションと同じです.
app.use("/graphql, "...")
でGraphQLへのルーティングを設定しています.
また, データベースへの接続処理も行っています.MysqlDriverは名前通りMySQLへの接続等を行うためのクラスです.
(終了時の切断処理もありますが記載を省略しています.)
import bodyParser from "body-parser";
import express from "express";
import http from "http";
import graphql from "./graphql";
import MysqlDriver from "./drivers/mysql-driver";
const port = process.env.PORT || 3000;
const app = express();
const mysqlDriver = new MysqlDriver();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// /graphqlのパスに対してGraphQLへのルーティングを設定
app.use("/graphql", (req, res) => graphql(mysqlDriver)(req, res));
// 初期化処理完了時の処理
// HTTPリクエストを受け付けるようにする.
app.on("ready", () => {
app.listen(port, () => {
console.log("ready");
});
});
http.createServer(app);
// 初期化関数が完了したらreadyイベントを発火する.
init().then(() => app.emit("ready"));
// 初期化処理
// MySQLへの接続を行っている.
async function init() {
await mysqlDriver.open({
host: process.env.DB_HOSTNAME,
user: process.env.DB_USERNAME,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT as string),
});
}
graphql.ts
express-graphqlに含まれるgraphqlHTTP()
の戻り値がGraphQLのエントリポイントとして稼働します.
下記コードでは後述するschemaとMySQL接続のためのドライバクラスのインスタンスdb
を渡しています.
contextにオブジェクトをセットすることで,後述するresolve関数にそのままそれを渡すことができます.
resolve関数で使いたいオブジェクト等をcontextにセットしておきましょう.
import graphqlHTTP from "express-graphql";
import MySqlDriver from "./drivers/mysql-driver";
import schema from "./schema/schema";
export default function getGraphQLHttp(db: MySqlDriver) {
return graphqlHTTP(() => ({
graphiql: true,
schema,
context: { db },
}));
}
スキーマの定義
さて, GraphQLのスキーマ定義を実装しましょう.
schema.ts
まずはスキーマの大枠をschema.tsに定義しています.その中にquery(参照系),mutation(更新系)の定義をそれぞれセットしています.schema.ts内にすべての定義を記述しても問題ありませんが,可読性のために分割しました.ここでexportしているschemaを前途のgraphqlHTTP()にschemaとしてセットします.
import { GraphQLObjectType, GraphQLSchema } from "graphql";
import mutations from "./mutations";
import queries from "./queries";
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: "GraphQLSampleQueries",
fields: () => ({
...queries,
}),
}),
mutation: new GraphQLObjectType({
name: "GraphQLSampleMutations",
fields: () => ({
...mutations,
}),
}),
});
export default schema;
queries.ts
queries.tsにquery(参照系)の定義を実装します.なお,ここではUser(お客様情報), Product(製品情報)のみを記載しており,他の定義は省略しております.他の定義の実装はGithub上のコードをご覧ください.
user, users, product, productsに対してそれぞれ以下を設定しています.
-
type
ではそのリソースの型定義をセットします. -
args
ではリソースを取得・更新するときに利用できる引数の定義をセットします. -
resolve
ではそのリソースを取得するための関数をセットします.
user, usersのように単数形と複数形を用意して
- 単数系ではプライマリキーを使って1行のみを返す
- 複数形ではヒットした複数行を返す
とします.
また,複数形では前途したConnectionを使ってページネーションに対応した型をセットしています.
import { resolveProduct, resolveProducts } from "../resolvers/product-resolver";
import { resolveUser, resolveUsers } from "../resolvers/user-resolver";
import { IdArgument, ProductArgment, UserArgument } from "./argument";
import {
Product,
ProductConnection,
User,
UserConnection,
} from "./query-type";
const userQueries = {
user: {
type: User,
args: IdArgument,
resolve: resolveUser,
},
users: {
type: UserConnection,
args: UserArgument,
resolve: resolveUsers,
},
};
const productQueries = {
product: {
type: Product,
args: IdArgument,
resolve: resolveProduct,
},
products: {
type: ProductConnection,
args: ProductArgment,
resolve: resolveProducts,
},
};
export default {
...userQueries,
...productQueries,
};
この定義により,例えば下記のクエリをリクエストされたとき, usersにセットされたresolve関数:resolveUsers()が実行され,その戻り値がAPIのレスポンスとなります.
query {
users(gender:"female", limit:10, offset:0) {
name
}
}
mutations.ts
mutations.tsにmutation(更新系)の定義を実装します.書き方はquery(参照系)と同じです.
import { GraphQLNonNull } from "graphql";
import { createUser, updateUser } from "../resolvers/user-resolver";
import { UserInsertArgument, UserUpdateArgument } from "./argument";
import { User } from "./query-type";
const userMutations = {
createUser: {
type: User,
args: {
user: {
type: new GraphQLNonNull(UserInsertArgument),
},
},
resolve: createUser,
},
updateUser: {
type: User,
args: {
user: {
type: new GraphQLNonNull(UserUpdateArgument),
},
},
resolve: updateUser,
},
};
export default {
...userMutations,
};
これにより以下のようなクエリが実行できるようになります. createUserにセットされたresolve関数:createUser()が実行されますので,この関数がデータベースへの書き込み処理を行います.また,書き込み後の値を返すことでレスポンスを作成します.
mutation {
createUser(
user: {
name:"原田",
gender: "female"
}
) {
id, name, gender
}
}
スキーマ定義(型定義)
先程,typeで指定した型定義(User, Product等)を実装していきましょう.query-type.tsに記述しています.
また,その中で利用する列挙体についてはenum-type.tsに分けて記述しています.(分ける必要はないですが,現段階では可読性があがるのではないかと判断)
query-type.ts
GraphQLObjectTypeでtypeを定義します.そして次の内容を定義しています.構造体の定義とやっていることは同じです.
- name: 型名
- description: 型の説明(必須ではない)
- fields: 型の内容
また,後半ではページネーションを実現するためのConnectionの型を定義しています.graphql-relayが提供するconnectionDefinitions()を利用すれば簡単に実現できます.
import { GraphQLID, GraphQLInt, GraphQLObjectType, GraphQLString } from "graphql";
import { connectionDefinitions } from "graphql-relay";
import { resolveByParentMakerId } from "../resolvers/maker-resolver";
import { resolveProductsByRelatedOrder } from "../resolvers/product-resolver";
import { resolveUserByParentId } from "../resolvers/user-resolver";
import { ConnectionArgument } from "./argument";
import { GenderEnum, MemberRank } from "./enum-type";
export const User = new GraphQLObjectType({
name: "User",
description: "お客様の情報",
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
gender: { type: GenderEnum, description: "性別" },
rank: { type: MemberRank, description: "会員ランク" },
orders: { type: OrderConnection },
}),
});
export const Product = new GraphQLObjectType({
name: "Product",
description: "商品",
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString, description: "商品名" },
modelNumber: { type: GraphQLString, description: "型番" },
price: { type: GraphQLInt, description: "販売価格" },
maker: {
type: Maker,
resolve: resolveByParentMakerId,
},
}),
});
export const Maker = new GraphQLObjectType({
name: "Maker",
description: "製造業者",
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
products: { type: ProductConnection },
}),
});
export const Order = new GraphQLObjectType({
name: "Order",
description: "受注情報",
fields: () => ({
id: { type: GraphQLID },
orderDate: { type: GraphQLInt, description: "受注日時(UNIX TIME)" },
user: {
type: User,
description: "注文したユーザ",
resolve: resolveUserByParentId,
},
products: {
type: ProductConnection,
args: ConnectionArgument,
description: "注文に含まれた商品",
resolve: resolveProductsByRelatedOrder,
},
}),
});
export const { connectionType: UserConnection } = connectionDefinitions({
name: "User",
nodeType: User,
});
export const { connectionType: ProductConnection } = connectionDefinitions({
name: "Product",
nodeType: Product,
});
export const { connectionType: MakerConnection } = connectionDefinitions({
name: "Maker",
nodeType: Maker,
});
export const { connectionType: OrderConnection } = connectionDefinitions({
name: "Order",
nodeType: Order,
});
enum-type.ts
enum-type.tsでは列挙体を定義しています.
import { GraphQLEnumType } from "graphql";
export const GenderEnum = new GraphQLEnumType({
name: "Gender",
description: "性別",
values: {
male: { value: "male", description: "男性" },
female: { value: "female", description: "女性" },
other: { value: "other", description: "その他" },
},
});
export const MemberRank = new GraphQLEnumType({
name: "MemberRank",
description: "会員ランク",
values: {
general: { value: "general", description: "一般会員" },
gold: { value: "gold", description: "ゴールド会員" },
platinum: { value: "platinum", description: "プラチナ会員" },
},
});
Argumentの定義
先程, argsで指定したArgumentの定義を実装していきましょう.ここで定義していない値を指定しても無視されます.
以下のクエリのgender: "male"
に当たる部分です.
query {
users(gender: "male") {
id, name
}
}
argument.ts
argument.tsにArgumentの型を記述します.ここではUser, Productのみを記載しており,ほかは省略しております.すべての定義はGithub上のコードでご確認ください.
前途した通りここで定義したArgumentをそれぞれのargsにセットしています.
- UserArgumentをuser, usersのargs
- UserInsertArgumentをcreateUserのargs
- UserUpdateArgumentをupdateUserのargs
- ProductArgumentをproduct,productsのargs
import { GraphQLID, GraphQLInputObjectType, GraphQLInt, GraphQLNonNull, GraphQLString } from "graphql";
import { GenderEnum, MemberRank } from "./enum-type";
const RangeIntType = new GraphQLInputObjectType({
name: "RangeInt",
fields: () => ({
min: { type: GraphQLInt },
max: { type: GraphQLInt },
}),
});
export const IdArgument = {
id: { type: GraphQLID },
};
export const ConnectionArgument = {
limit: { type: GraphQLInt },
offset: { type: GraphQLInt, defaultValue: 0 },
};
export const UserArgument = {
...IdArgument,
...ConnectionArgument,
name: { type: GraphQLString },
gender: { type: GenderEnum },
rank: { type: MemberRank },
};
export const UserInsertArgument = new GraphQLInputObjectType({
name: "UserInputArgument",
fields: () => ({
name: { type: new GraphQLNonNull(GraphQLString) },
gender: { type: new GraphQLNonNull(GraphQLString) },
rank: { type: GraphQLString },
}),
});
export const UserUpdateArgument = new GraphQLInputObjectType({
name: "UserUpdateArgument",
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: GraphQLString },
gender: { type: GraphQLString },
rank: { type: GraphQLString },
}),
});
export const ProductArgment = {
...IdArgument,
...ConnectionArgument,
name: { type: GraphQLString },
modelNumber: { type: GraphQLString },
price: { type: RangeIntType },
};
resolve関数の実装
先程のスキーマ定義でセットしたresolve関数の実装をします.このresolve関数がMySQLへの問い合わせを行い,その戻り値がレスポンスとなります.今回はバックエンドのデータベースがMySQLであるため以下の処理になります.
- argsの内容をもとにMySQL用のSQL文を作成する.
- MySQLに問い合わせを行う(参照, 更新)
- 問い合わせ結果をGraphQLのスキーマ定義に合うように加工する.
- 戻り値として返す.
当然ながら,バックエンドのデータストレージが異なればそれに合った処理を書く必要があります.いずれにせよresolve関数がデータの取得・更新処理を行い,スキーマ定義に合う形式で戻り値を返します.
resolve関数は以下のように3つの引数を取ります.
function resolveUsers(parent: any, args: any, context: any) {
}
- parent: 親となっているresolve関数の戻り値
- args: ユーザがGraphQLの引数の値
- context: graphqlHTTP()のcontextで指定した値
例えば製品情報(product)のクエリの場合を考えましょう.次のクエリではid: 312
を引数にして,該当する製品のid, 名前と製造会社の場前を要求しています.
query {
product(id: 312) {
id,
name,
maker {
name
}
}
}
前途したとおりproductsは以下のように設定しております.
product: {
type: Product,
args: IdArgument,
resolve: resolveProduct,
}
const Product = new GraphQLObjectType({
name: "Product",
description: "商品",
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString, description: "商品名" },
modelNumber: { type: GraphQLString, description: "型番" },
price: { type: GraphQLInt, description: "販売価格" },
maker: {
type: Maker,
resolve: resolveMakerByParentMakerId,
},
}),
});
この場合, resolveProduct()とresolveMakerByParentMakerId()の2つの関数が呼ばれます.後者はProductの入れ子になっています.
また,最初のgraphql.tsでcontext
を以下のようにセットしました.
graphqlHTTP(() => ({
graphiql: true,
schema,
context: { db },
}));
したがってresolve関数に渡される3つの引数の値はそれぞれ以下のとおりです.
- resolveProduct
- parent: なし
- args: { id: 312 }
- context: { db: db },
- resolveMakerByParentMakerId
- parent: resolveProductの戻り値
- args: なし
- context: { db: db }
contextにはセットした{db}がそのまま入ってきます.したがって全resolve関数で共通で使う値は最初にcontextで渡してあげましょう.
なお,resolve関数の中身についてはデータベースへの問い合わせなどGraphQLの説明から離れるためこの記事への記載は省略します.Github上のコードを参照してみていただきたいと思います.
最後に
今回はGraphQLのAPIをNode.jsで実装してみました.
コードは Githubにアップしております. https://github.com/hiroyky/graphql_api_sample