はじめに
業務でGraphQLを使うことになったんですが、GraphQLのモジュール宣言って面倒くさいですよね。
こういうの。
const typeDefs = gql`
type User {
id: Int!
name: String!
email: String!
recipes: [Recipe!]!
}
type Recipe {
id: Int!
title: String!
ingredients: String!
direction: String!
user: User!
}
type Query {
user(id: Int!): User
allRecipes: [Recipe!]!
recipe(id: Int!): Recipe
}
type Mutation {
createUser(name: String!, email: String!, password: String!): User!
createRecipe(userId: Int!, title: String!, ingredients: String!, direction: String!): Recipe!
}
`
const resolvers = {
Query: {
async user(root, { id }, { models }) {
return models.User.findByPk(id)
},
async allRecipes(root, args, { models }) {
return models.Recipe.findAll()
},
async recipe(root, { id }, { models }) {
return models.Recipe.findByPk(id)
}
},
Mutation: {
async createUser(root, { name, email, password }, { models }) {
return models.User.create({
name,
email,
password: await bcrypt.hash(password, 10),
})
},
async createRecipe(root, { userId, title, ingredients, direction }, { models }) {
return models.Recipe.create({ userId, title, ingredients, direction })
}
},
User: {
async recipes(user) {
return user.getRecipes()
},
},
Recipe: {
async user(recipe) {
return recipe.getUser()
},
},
}
DBのテーブルをひとつ追加するのに修正箇所が多すぎると感じたので、簡略化するためのモジュールクラスを作ってみました。
もしかしたら、もっといい方法や似たようなライブラリがあるかもしれませんが、とりあえず、一例として共有します。
環境構築から説明しますが、不要な方は読み飛ばしてください。
リポジトリ
この記事に記載のコードは上記リポジトリに格納されています。
環境等はこちらをご参照ください。
環境構築
開発環境はWindowsで、WSL上にリポジトリを置き、さらにNode環境を構築したDockerコンテナ上で作業しています。
ただ、今回の内容にはほぼ関係ないので「Linux上で作業している」くらいにとらえてもらえれば良いかと。
こちらの環境を前提に説明していきます。
ちなみにNode.jsはインストール済みの体でよろしく。
こちらのサイトを参考にセットアップしていきます。
使用するDBは参考サイトと同じくSQLiteを使用します。
Sequelize関数の修正
一部Sequelizeのバージョンアップの影響で関数を変更する必要があります。
resolvers
のQuery
の中で実行しているfindById
をfindByPk
に変更しましょう。
async user(root, { id }, { models }) {
return models.User.findById(id)
}
↓
async user(root, { id }, { models }) {
return models.User.findByPk(id)
}
DBファイルの生成
DBのファイルはプッシュしていませんので、サンプルコードをクローンした場合は、以下のコマンドを実行してください。
DBファイルを生成。
touch ./database/database.sqlite
DBをマイグレートします。
npx sequelize db:migrate
Playgroundの設定
dockerコンテナ上で実行する場合、Playgroundが動作しない場合があります。
そんな時はsrc/index.js
を以下のように修正しましょう。
const { ApolloServer } = require('apollo-server')
const { typeDefs, resolvers } = require('../server/modules')
const models = require('../models')
// playgroundをロード
const { ApolloServerPluginLandingPageGraphQLPlayground } = require('apollo-server-core')
const server = new ApolloServer({
typeDefs,
resolvers,
context: { models },
plugins: [ApolloServerPluginLandingPageGraphQLPlayground({})], // playgroundを設定する
})
server.listen({
port: 3000,
}).then(({ url }) => console.log('Server is running on localhost:3000'))
動作確認
サーバを起動して、Playgroundが起動したら成功です。
GraphQLモジュール
モジュール部分はserver/modules.js
という1ファイルにまとめています。
リポジトリではEnvironment-construction-completed
タグを付けてますが、ここにも全文を載せてみます。
const { gql } = require('apollo-server')
const bcrypt = require('bcryptjs')
const typeDefs = gql`
type User {
id: Int!
name: String!
email: String!
recipes: [Recipe!]!
}
type Recipe {
id: Int!
title: String!
ingredients: String!
direction: String!
user: User!
}
type Query {
user(id: Int!): User
allRecipes: [Recipe!]!
recipe(id: Int!): Recipe
}
type Mutation {
createUser(name: String!, email: String!, password: String!): User!
createRecipe(userId: Int!, title: String!, ingredients: String!, direction: String!): Recipe!
}
`
const resolvers = {
Query: {
async user(root, { id }, { models }) {
return models.User.findByPk(id)
},
async allRecipes(root, args, { models }) {
return models.Recipe.findAll()
},
async recipe(root, { id }, { models }) {
return models.Recipe.findByPk(id)
}
},
Mutation: {
async createUser(root, { name, email, password }, { models }) {
return models.User.create({
name,
email,
password: await bcrypt.hash(password, 10),
})
},
async createRecipe(root, { userId, title, ingredients, direction }, { models }) {
return models.Recipe.create({ userId, title, ingredients, direction })
}
},
User: {
async recipes(user) {
return user.getRecipes()
},
},
Recipe: {
async user(recipe) {
return recipe.getUser()
},
},
}
module.exports = { typeDefs, resolvers }
このファイル内には、DBのUserテーブルとRecipeテーブルの情報が書いてあります。
DBに新たなテーブルを追加した場合、このファイルに追加することになりますが、複数個所に手を加えなければいけないので少々面倒です。
また、すべてのテーブルの情報が1ファイルに混在しているのも混乱の素になります。
そこで、テーブルごとにクラス化することを考えてみようと思います。
今回は色々試し試し実装していったので、基本クラスとかは作ってないです。
同じことを試そうとする方がいらっしゃれば、基本クラスに切り出したりしてみてください。
Userテーブル
まずはUserテーブル関連の処理をモジュールクラス化します。
コード全文載せちゃいます。
const bcrypt = require('bcryptjs')
class UserModule {
get apiType() {
const result = [];
result.push(`
type User {
id: Int!
name: String!
email: String!
recipes: [Recipe!]!
}
`);
return result.join('\n');
}
get queryType() {
const result = [];
result.push(`user(id: Int!): User`);
return result.join('\n');
}
get queryList() {
const result = [];
result.push(this.user);
return Object.fromEntries(result.map((func) => [func.name, func]));
}
get mutationType() {
const result = [];
result.push(`createUser(name: String!, email: String!, password: String!): User!`);
return result.join('\n');
}
get mutationList() {
const result = [];
result.push(this.createUser);
return Object.fromEntries(result.map((func) => [func.name, func]));
}
get otherResolver() {
return {
User: {
async recipes(user) {
return user.getRecipes()
},
},
};
}
async user(root, { id }, { models }) {
return models.User.findByPk(id);
}
async createUser(root, { name, email, password }, { models }) {
return models.User.create({
name,
email,
password: await bcrypt.hash(password, 10),
});
}
}
module.exports = UserModule;
ひとつひとつ説明します。
API宣言
API(型)宣言部です。
get apiType() {
const result = [];
result.push(`
type User {
id: Int!
name: String!
email: String!
recipes: [Recipe!]!
}
`);
return result.join('\n');
}
基本的には文字列を返します。
InputTypesなどを追加したい時はresult.push()
を追加することで対応可能です。
result.push(`
input ReviewInput {
stars: Int!
commentary: String
}
`);
Query
Query部はふたつの関数に分かれています。
Queryの実体はクラスのメンバ関数として実装します。
get queryType() {
const result = [];
result.push(`user(id: Int!): User`);
return result.join('\n');
}
get queryList() {
const result = [];
result.push(this.user);
return Object.fromEntries(result.map((func) => [func.name, func]));
}
:
中略
:
async user(root, { id }, { models }) {
return models.User.findByPk(id);
}
追加したい場合は、メンバ関数としてQueryの実体を実装した後、いずれもresult.push()
を追記するだけでよいです。
Mutation
Mutation部も基本的にQueryと同様です。
get mutationType() {
const result = [];
result.push(`createUser(name: String!, email: String!, password: String!): User!`);
return result.join('\n');
}
get mutationList() {
const result = [];
result.push(this.createUser);
return Object.fromEntries(result.map((func) => [func.name, func]));
}
:
中略
:
async createUser(root, { name, email, password }, { models }) {
return models.User.create({
name,
email,
password: await bcrypt.hash(password, 10),
});
}
Resolver
追加のResolverを作りたいときは、関数を持ったオブジェクトを返すようにします。
get otherResolver() {
return {
User: {
async recipes(user) {
return user.getRecipes()
},
},
};
}
Recipeテーブル
Userテーブルと同様にRecipeテーブルも新しいファイルを作成してモジュールクラスを実装します。
class RecipeModule {
get apiType() {
const result = [];
result.push(`
type Recipe {
id: Int!
title: String!
ingredients: String!
direction: String!
user: User!
}
`);
return result.join('\n');
}
get queryType() {
const result = [];
result.push('allRecipes: [Recipe!]!');
result.push('recipe(id: Int!): Recipe');
return result.join('\n');
}
get queryList() {
const result = [];
result.push(this.allRecipes);
result.push(this.recipe);
return Object.fromEntries(result.map((func) => [func.name, func]));
}
get mutationType() {
const result = [];
result.push('createRecipe(userId: Int!, title: String!, ingredients: String!, direction: String!): Recipe!');
return result.join('\n');
}
get mutationList() {
const result = [];
result.push(this.createRecipe);
return Object.fromEntries(result.map((func) => [func.name, func]));
}
get otherResolver() {
return {
Recipe: {
async user(recipe) {
return recipe.getUser()
},
},
};
}
async allRecipes(root, args, { models }) {
return models.Recipe.findAll()
}
async recipe(root, { id }, { models }) {
return models.Recipe.findByPk(id)
}
async createRecipe(root, { userId, title, ingredients, direction }, { models }) {
return models.Recipe.create({ userId, title, ingredients, direction })
}
}
module.exports = RecipeModule;
モジュールファイル
作成したモジュールクラスをGraphQLに渡します。
多態性を持たせているので、配列にインスタンスを作成してぶん回します。
const { gql } = require('apollo-server')
const UserModule = require('./modules/UserModule')
const RecipeModule = require('./modules/RecipeModule')
const moduleList = [
new UserModule(),
new RecipeModule(),
]
const typeDefs = gql`
${moduleList.map((m) => m.apiType).join('\n')}
type Query {
${moduleList.map((m) => m.queryType).join('\n')}
}
type Mutation {
${moduleList.map((m) => m.mutationType).join('\n')}
}
`
const resolvers = {
Query: {
...Object.fromEntries(
moduleList.map((m) => Object.entries(m.queryList)).flat()
),
},
Mutation: {
...Object.fromEntries(
moduleList.map((m) => Object.entries(m.mutationList)).flat()
),
},
...Object.fromEntries(
moduleList.map((m) => Object.entries(m.otherResolver)).flat()
),
}
module.exports = { typeDefs, resolvers }
おわりに
以上が私が行った簡略化方法になります。
ライブラリを使用したり、デザインパターンを用いたとかではなく、小手先の方法に始終していますが、DBのテーブルを追加したり、修正したりするのがわかりやすくなりました。
もうちょっと頑張ればTypeScript化したり、さらに簡略化してライブラリ化とかも可能だと思いますが需要はあるんでしょうかね。
もっと簡単な方法があるぜとか、便利ライブラリがあるぜみたいな情報があれば教えてください。