⚠️⚠️⚠️ Node で GraphQL サーバ実装するなら、以下の構成の方がオススメです ⚠️⚠️⚠️
はじめに
はじめまして。突然ですが、GraphQL、めちゃくちゃ良い技術です。
Rails に載った GraphQL を業務で使ってますが、フロントエンド開発がフッ軽になります。
もっと GraphQL に詳しくなりたい。でも、現在、フロントエンドエンジニアとして勤務中の私には、実務で GraphQL を触ることができたとしても せいぜい Type をちょろっと修正するくらい。
そこで、趣味で書いてる Vue.js 製 WEB アプリの API に GraphQL を採用することにしました。
導入から API として動かすところまでを勉強がてら実装したので、せっかくだし最小限の構成をご紹介します。備忘録も兼ねて。
・・・Rails に対してのモチベーションが高くない ☺️ ので、今回は express に載せてます。
実際にやってみた
TypeScript 使ってますが、サンプルコードの中では 面倒臭いので 厳密に取り扱っていない箇所があります。そーりー。
下準備
package.json を用意
{
"name": "graphql-on-express",
"dependencies": {
"@types/cors": "^2.8.10",
"@types/express": "^4.17.11",
"@types/express-graphql": "^0.9.0",
"@types/mysql": "^2.15.18",
"@types/node": "^14.14.35",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-graphql": "^0.12.0",
"graphql": "^15.5.0",
"mysql": "^2.17.1",
"typeorm": "^0.2.31",
"typescript": "^4.2.3"
},
"devDependencies": {
"ts-node": "^9.1.1",
"tsconfig-paths": "^3.9.0"
},
}
DB まわりのアレコレは TypeORM というライブラリに任せます。
・・・package.json で足りない項目がある場合はテキトーに埋めてください 😇
TypeScript、TypeORM のコンフィグを用意
{
"compilerOptions": {
"sourceMap": false,
"noImplicitAny": true,
"module": "commonjs",
"target": "es5",
"lib": [
"es2018",
"dom"
],
"moduleResolution": "node",
"removeComments": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strictFunctionTypes": false,
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
],
},
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
},
"include": [
"./src/**/*.ts"
]
}
{
"type": "mysql",
"host": "Your DB endpoint",
"port": 3306,
"username": "Your DB username",
"password": "Your DB password",
"database": "Your DB name",
"synchronize": false,
"logging": false,
"entities": [
"src/database/entity/**/*.ts"
],
"migrations": [
"src/database/migration/**/*.ts"
],
"subscribers": [
"src/database/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "src/database/entity",
"migrationsDir": "src/database/migration",
"subscribersDir": "src/database/subscriber"
}
}
node modules をインストール
$ npm i
データまわりの作業
Entity を用意
データベースとプログラムとの間でマッピングされたデータが、どのようなデータ構造をとるのかを定義します。
TypeORM では、このようなデータモデルを Entity という名称で表現するようです。
一般的な MVC フレームワークにおいて、Model と呼ばれているモノにイメージは近いですが、Model と違ってロジックを持たせることはあまり想定してなさ気です。だから、あくまでも "Model" じゃなくて ただの "Entity" (実体) なのかと思いました (小並感
import { Entity, BaseEntity, Column, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment')
id!: number;
@Column({ nullable: false })
name!: string;
}
サンプルなので、id と name というカラムだけをもったシンプルな構造を用意します。
マイグレーションする
なんと、TypeORM はマイグレーションの機能まで提供してくれています。ありがとう。
$ npx ts-node node_modules/.bin/typeorm migration:generate -n user
上記を実行すると、src/database/migration/xxxxxxxxxxxxx-user.ts
というファイルが生成されます。
続けて、以下を実行します。
$ npx ts-node node_modules/.bin/typeorm migration:run
ずらずらと SQL の実行ログが流れ・・・
(省略)
Migration userxxxxxxxxxxxxx has been executed successfully.
query: COMMIT
最後にこんなログが出力されれば成功です。
ターミナルから MySQL に直接ログインできる方は実際にテーブルを確認してみてください。
(省略)
mysql> desc user;
+-------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| name | varchar(255) | NO | | NULL | |
+-------------+--------------+------+-----+---------+----------------+
2 rows in set (0.02 sec)
こんな感じになってるはず。
・・・テーブル名的には users
であってほしいけど、Entity を複数形にしなきゃいけないのかな 🤔
ロジックまわりの作業
Type を定義
import { GraphQLObjectType, GraphQLNonNull, GraphQLString, GraphQLInt, GraphQLInputObjectType } from 'graphql';
export const UserType = new GraphQLObjectType({
name: 'User',
fields: {
id: {
type: new GraphQLNonNull(GraphQLInt),
},
name: {
type: new GraphQLNonNull(GraphQLString),
}
}
});
export const FetchUserInput = new GraphQLInputObjectType({
name: 'FetchUserInput',
fields: {
id: {
type: new GraphQLNonNull(GraphQLInt),
},
}
});
export const CreateUserInput = new GraphQLInputObjectType({
name: 'CreateUserInput',
fields: {
name: {
type: new GraphQLNonNull(GraphQLString),
},
}
});
Schema を定義
実際に運用する際は、Entity に対して query と mutation があり、場合によっては、そこからさらにバリエーションが派生する、なんてこともあります。
ファイルを細かく分けていて、サンプルコードをみてるだけだと「冗長じゃね?」と思うかもしれませんが、上記の理由から処理が増えることを視野に入れてこうしてます。
import { GraphQLSchema } from "graphql";
import { queryType as query, mutationType as mutation } from "./fields";
export const schema = new GraphQLSchema({
query,
mutation,
});
import { GraphQLObjectType } from 'graphql';
import { userField } from './user';
export const queryType = new GraphQLObjectType({
name: 'Query',
fields: {
...userField.query,
}
})
export const mutationType = new GraphQLObjectType({
name: 'Mutation',
fields: {
...userField.mutation,
}
})
import { userQuery as query } from './query';
import { userMutation as mutation } from './mutation';
export const userField = {
query,
mutation,
};
import { GraphQLNonNull } from 'graphql';
import * as resolvers from './resolvers';
import { FetchUserInput, UserType } from './types';
const fetchUsers = {
type: UserType,
args: {
input: {
type: new GraphQLNonNull(FetchUserInput)
}
},
resolve: resolvers.fetchUsers,
}
export const userQuery = {
fetchUsers,
}
import { GraphQLNonNull, GraphQLList } from 'graphql';
import * as resolvers from './resolvers';
import { UserType, CreateUserInput } from './types';
const createUser = {
type: new GraphQLList(UserType),
args: {
input: {
type: new GraphQLNonNull(CreateUserInput)
}
},
resolve: resolvers.createUser
}
export const userMutation = {
createUser,
}
"DB への問い合わせ" や "結果を受け取って返却する" などのコアとなる処理を用意
import { User } from "@/database/entity/user"
import { find, findOne, insert } from "../crud-assistant"
// e.g.
type CreateUserArgs = {
input: {
// any
}
}
// e.g.
type FetchUserArgs = {
input: {
// any
}
}
// e.g.
type FetchUsersArgs = {
input: {
// any
}
}
export const createUser = async (args: CreateUserArgs): Promise<typeof User> => {
return new Promise(async (resolve, reject) => {
const insertInput = args.input
const result = await insert<typeof User, CreateUserArgs["input"]>(User, insertInput)
if (!result) {
reject()
return
}
const findOneInput = args.input
const user = await findOne<typeof User, FetchUserArgs["input"]>(User, findOneInput)
if (user) {
resolve(user)
} else {
reject()
}
})
}
export const fetchUsers = async (args: FetchUsersArgs): Promise<Array<typeof User>> => {
return new Promise(async (resolve, reject) => {
const findInput = args.input
const users = await find<typeof User, FetchUsersArgs["input"]>(User, findInput)
if (users) {
resolve(users)
} else {
reject()
}
})
}
CRUD の処理を共通化しておきます。
TypeORM の Repository のメソッドと同名で公開。
import { BaseEntity, createConnection, getRepository, InsertResult } from "typeorm"
export const insert = async<E extends typeof BaseEntity, I>(Entity: E, input: I): Promise<InsertResult | null> => {
const connection = await createConnection()
const repository = getRepository<E>(Entity)
try {
return new Promise(async (resolve, reject) => {
const result = await repository
.insert({
...input
})
.catch(async (e) => {
reject()
})
await connection.close()
resolve(result || null)
})
} catch (e) {
await connection.close()
return Promise.resolve(e)
}
}
export const findOne = async<E extends typeof BaseEntity, I>(Entity: E, input: I): Promise<E | null> => {
const connection = await createConnection()
const repository = getRepository<E>(Entity)
try {
return new Promise(async (resolve, reject) => {
const result = await repository
.findOne({
...input
})
.catch(async (e) => {
reject()
})
await connection.close()
resolve(result || null)
})
} catch (e) {
await connection.close()
return Promise.resolve(e)
}
}
export const find = async <E extends typeof BaseEntity, I>(Entity: E, input: I): Promise<E[] | null> => {
const connection = await createConnection()
const repository = getRepository<E>(Entity)
try {
return new Promise(async (resolve, reject) => {
const result = await repository
.find({
...input
})
.catch(async (e) => {
reject()
})
await connection.close()
resolve(result || null)
})
} catch (e) {
await connection.close()
return Promise.resolve(e)
}
}
これでようやくロジックまわりのファイルが揃いました。
サーバまわりの作業
エントリポイントを用意
import * as express from 'express'
import { graphqlHTTP } from 'express-graphql'
import * as cors from 'cors'
import { schema } from './schema'
const port = 4000
const app = express()
app.use(cors())
app.use(express.static('./'));
app.use('/', graphqlHTTP({
schema,
graphiql: true
}))
app.listen(port, () => {
console.log(`Started server, http://localhost:${port}`)
});
エントリポイントを用意したら、
$ npx ts-node -r tsconfig-paths/register src/index.ts
を実行。
これで、http://localhost:4000 にアクセスすると Graph i QL という GUI が表示されるようになります。
API としてのリクエストには、POST を用います。
Vue アプリ側からは Vue Apollo 経由で API を call してます。その話はまたどこかでするかもしれないししないかもしれない。
おわりに
GraphQL について
冒頭でも触れましたが、フロントエンドの開発においてめちゃくちゃ便利です。
良い技術なわりに、あまり世に浸透していない気がする。もったいない 🥺
TypeORM について
便利ではありますが、提供してる型が微妙に扱いにくいと感じるところがあったり、(当たり前だけど) GraphQL 側にも型の指定が必要なので TypeORM ↔️ GraphQL 間で同じデータを指してるのに構造の定義が二重管理になっちゃったりと、小さな課題があるので要改善。暇なときに TypeORM 側のコードから GraphQL の Type 定義のコードを自動生成するようなスクリプトを書きたい。(すでに誰かが作ってるかも
まあ、使いこなせれば良きなライブラリな気はします。あと、日本語の文献が豊富ではないです。
おしまい。