TypeScript で Apollo + Sequelize の GraphQL API を実装するなら・・・?
Sequelize のバージョン5以降は TypeScript のサポートが入っており、以前のようなモデルの初期化処理、というかマッピングの記述がだいぶ簡易になりました。
これまでの処理は User モデル定義するのに、まず以下の定義というか宣言処理を書いて・・・
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define(
'user',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: DataTypes.STRING,
},
{
timestamps: false,
tableName: 'user',
}
);
User.associate = models => {
User.hasMany(models.todo);
};
return User;
};
以下のmodels/index.js
でまとめて食わす、というようなやり方が主流だったかと思います。
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const sequelize = require('../conf/db');
const db = {
sequelize,
Sequelize,
};
fs.readdirSync(__dirname)
.filter(file => path.extname(file) === '.js' && file !== 'index.js')
.forEach(file => {
const model = sequelize.import(path.join(__dirname, file));
db[model.name] = model;
});
Object.keys(db).forEach(modelName => {
if ('associate' in db[modelName]) {
db[modelName].associate(db);
}
});
module.exports = db;
こんな感じで非常に冗長なよく変わらん処理を、コード補完も効かないjsファイルでプロジェクトごとに毎度、書いていたわけです。つまり こんなのフレームワーク側で処理するべきでしょ? と~~(声を大にして)~~言いたいわけです。
最近の Sequelize では Model
を継承した Class を宣言し、init
メソッドでマッピングを記述すれば CRUD 操作系のメソッドも備えたバッチリな永続化層がさくっと構築できます。
import { Model, UUID, UUIDV1, STRING } from "sequelize";
import { sequelize } from "../conf/db";
export class User extends Model {
public id!: string;
public name!: string;
public todos?: Todo[];
}
User.init(
{
id: {
type: UUID,
defaultValue: UUIDV1,
allowNull: false,
primaryKey: true
},
name: {
type: STRING,
allowNull: false
}
},
{
sequelize,
modelName: 'user',
}
);
// リレーションシップの宣言もここだけでよい。
User.hasMany(Todo);
Todo.belongsTo(User);
かの models/index.js
に相当するファイルはもうありませんよ!
今ではまとめて import するのに便利なファイルとして以下のように記述があるだけです。
export * from "./user"
これで import { User } from "models";
とかできちゃうわけです。
さらに、どうせ Sequelize のためにモデルクラスを定義するなら、それをそのままSchema定義に流用したいですよね?
ということで、アノテーションで GraphQL のスキーマ定義をやってくれる TypeGraphQL がイイ!!のであります。
先ほどの Sequelize のモデルクラスに以下のようにアノテーションを追加すると・・・
import { ObjectType, Field, ID } from 'type-graphql'
import { Model, UUID, UUIDV1, STRING } from "sequelize";
import { sequelize } from "../conf/db";
@ObjectType()
export class User extends Model {
@Field(type => ID)
public id!: string;
@Field()
public name!: string;
@Field(type => [Todo])
public todos?: Todo[];
}
...
GraphQLでもちゃんと User
Type が使えるようになります!!
以前は Sequlizeのモデル定義とは別に、GraphQL用のSchemaを定義する必要がありました。
module.exports = `
type User {
id: ID!
name: String
todos: [Todo!]!
}
`;
こんな感じでしょうか?しかも文字列で宣言なので当然、コード補完はなく、動かしてみて起動時にエラー、タイポがあればテスト時にエラー、エラーが出てみないと宣言に問題があるかわからないので、とりあえずデプロイして動かしてみて、トライ&エラー。。。なんて非効率なことをやっていたわけです。。。
TypeGraphQLでは、上記の文字列でSchemaを宣言する箇所そのものがいらなくなります。
その変わり Resolver 側のメソッドで入力値の型をきっちり宣言する必要があります。ですがそのお陰で型チェックが厳しく入り、コード補完も効いてコンパイルが通ればちゃんと動く(GraphQLとして整合性のとれた) GraphQL の API が作れるわけです。
というわけで早速、Sequelize + TypeGraphQL + Apollo +TypeScript のプロジェクトを作ってみましょう~!
本家のマニュアルはこちらから
今回は一通りの説明は致しますが、TypeGraphQLについてもSequelize+TypeScriptについてもGraphQLについてもフォローしなければならない情報が多いです。
該当箇所の本家マニュアル、サンプルなどを先に以下に貼り付けておきます。
- [TypeGraphQL: 本家サイト] (https://typegraphql.ml/)
- TypeGraphQL: インストール方法などのマニュアル
- TypeGraphQL: 実装サンプル
- Sequelize: TypeScriptについて
- Apollo Sequelize Typescript の実装例
上記を踏まえて、以下の手順で実装していきます。
1. プロジェクトの作成
npm install
するにも結構な量のパッケージが必要なので、package.jsonの dependencies を以下に記載します。
...
"dependencies": {
"@types/bluebird": "^3.5.28",
"apollo-server": "^2.9.7",
"dataloader-sequelize": "^2.0.1",
"graphql": "^14.5.8",
"merge-graphql-schemas": "^1.7.0",
"mysql2": "^1.7.0",
"reflect-metadata": "^0.1.13",
"sequelize": "^5.21.1",
"sequelize-graphql-schema": "^0.1.70",
"type-graphql": "^0.17.5"
},
"devDependencies": {
"@types/node": "^12.11.6",
"tslint": "^5.12.0",
"typescript": "^3.2.2"
},
...
量が多いですが、頑張って npm i
してください。(いや、package.jsonにコピペしてnpm i
でいいか。)
今回は RDBMS に MariaDB使いますのでクライアントとして mysql2
入れてますがこれはお好みでOKです。
typescript
は '3.2.2' ちょっと古め、というか最新のものではないですが、ちゃんと動きましたね。
また、tsconfig.json もいくつか制約を追加しないとダメなので(アノテーション使うので・・・)、動いた結果を以下に記載します。
...
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
},
...
targetは es2016
以降がマストなのと、esModuleInterop
、emitDecoratorMetadata
、experimentalDecorators
のこの辺りがマストですね。他のプロジェクトではなかなか使用しないオプションかと思います。
TypeGraphQLの本家サイトにも設定手順は記載されていますが、自分の環境ではそれだけではダメでした。後は環境というかお好みで調整&怒られたら適宜、調整して・・・といった感じです。
1-2. Sequelize の接続定義
db/conf.ts
を以下のような内容で作成しておきます。MariaDB の接続先の設定です。
import { Sequelize } from 'sequelize';
export const sequelize = new Sequelize("example","example" "example",
{
dialect: 'mysql',
host: "localhost",
port: 3306,
}
);
DBの接続は今回の主役ではないので思い切り手抜きしてまして、わかりにくくてすいません。。。example
はそれぞれ、データベース名
、ユーザ名
、パスワード
の3項目に対応しています。
で、ローカルホストの3306ポートに繋いでね、と。
2. Sequelizeモデル兼GraphQLスキーマ定義
今回の真骨頂その1ですが、冒頭にすでに掲げてしまいました。・・・まぁその、以下に再度掲載いたします。
import { ObjectType, Field, ID } from 'type-graphql'
import { Model, UUID, UUIDV1, STRING } from "sequelize";
import { Todo } from "./todo";
import { sequelize } from "../db/conf";
@ObjectType()
export class User extends Model {
@Field(type => ID)
public id!: string;
@Field()
public name!: string;
@Field(type => [Todo])
public todos?: Todo[];
}
User.init(
{
id: {
type: UUID,
defaultValue: UUIDV1,
allowNull: false,
primaryKey: true
},
name: {
type: STRING,
allowNull: false
}
},
{
sequelize,
modelName: 'user',
}
);
User.hasMany(Todo);
Todo.belongsTo(User);
Classの宣言にアノテーションでRDB用のマッピングも書けたらなぁ。。。と思いますが、今回は良しとしましょう。
Model
をextends
したUser
を宣言し、init
メソッドでDBのマッピングを宣言します。同時にUser
クラスのアノテーションで GraphQL の型とのマッピングを行っています。だいぶ記述量が減っています。
さらに見た目では若干、うんざりなコード量ですが、ほとんどコード補完が効くので実際のタイプする量は格段に減っています。
また、設定値の方もだいたい列挙値や型宣言されているので、コード補完が効くし候補リストに説明も出るので、以前のように "ここの設定、どう書くんだっけ?" みたいなことも格段に減ります。
Sequelize はもともとの機能で DBのマイグレーションをやってくれるので、ここでの宣言からテーブル作成も自動で行えます。
これ、開発の効率はかなりよいのではないでしょうか。。。(GraphQL書くのが一番大変だというツッコミは・・・あると思いますw)
3. Resolver 兼 GraphQLのスキーマ定義パート2
Resolver で実際の GraphQL の API インタフェースと、Sequelize を使ったビジネスロジックの実装、バインドを行います。
ここはビジネスロジックの本丸なのでしっかりと実装が必要ですが、ここでも TypeGraphQL のアノテーションが大活躍です。
試しには User
の CRUD 操作を実装してみましょう。
import { Resolver, Query, InputType, Field, Arg, Ctx, Mutation, Int } from 'type-graphql';
import { User } from "../models";
import { Context } from "apollo-server-core";
@InputType({ description: "New User Argument" })
class AddUserInput implements Partial<User> {
@Field()
name!: string;
}
@InputType({ description: "Update User Argument" })
class UpdateUserInput implements Partial<User> {
@Field()
id!: string;
@Field()
name!: string;
}
@Resolver()
export class TodoAppResolver {
@Query(returns => User, { nullable: true })
async user(@Arg("id") id: string): Promise<User | null> {
return User.findByPk(id);
}
@Mutation(returns => User)
async addUser(@Arg("data") newUser: AddUserInput, @Ctx() ctx: Context): Promise<User> {
return User.create(newUser);
}
@Mutation(returns => [Int, [User]])
async updateUser(@Arg("data") updateUser: UpdateUserInput, @Ctx() ctx: Context): Promise<[number, User[]]> {
return User.update(updateUser, {
where: {
id: updateUser.id
}
});
}
}
いきなり @InputType
や @Resolver()
、@Query
、@Mutation
などアノテーションの嵐で面食らうかもしれませんが、ココこそが 'TypeGraphQL' の本丸で、Resolverクラスで記述したメソッド、そのものを、そっくりそのままGraphQLのAPIとして公開してくれるように、ここで細かく宣言とアノテーションとを入れています。
また、User
モデルのメソッドにfindByPk
、create
、update
などのCRUD操作のメソッドが<User>
付きで使えるように=型パラメータ付きで戻り値もちゃんとUserとなっており、Promizeの中身がanyで戻ってこないようになっております。自前でキャストしなくても、TypeGraphQLのアノテーションが要求する戻り値
に合わせられるので、自然な記述ができます。TypeGraphQL+Sequelize、やるじゃない!
以前は、Resolverは js、引数や戻り値データ型は Schema で文字列で宣言、という悲惨な開発状況から一転、ここでは引数も戻り値もTypeScriptで宣言した通りに動くのです。素晴らしい。。。コード補完もバッチリ効くしね!
4. Apollo Server の起動準備
SequelizeとTypeGraphQLのお蔭でApolloのやることは本当にほとんどなくなってしまいました。
server.tsを以下のように記述しておきます。
import "reflect-metadata";
import { ApolloServer } from 'apollo-server';
import { buildSchema } from "type-graphql";
import * as db from "./db/conf";
async function serve() {
db.sequelize
.sync({ force: false, alter: true })
.catch(console.log);
const schema = await buildSchema({
resolvers: [__dirname + "/resolvers/**/*.js"],
});
const server = new ApolloServer({ schema, playground: true });
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
}
serve().catch(console.log);
serve
関数の中身ですが、まず sequelize.sync
の箇所は以下の記事を参考に、Apolloサーバー起動時に[Create/Alter] Tableさせてます。
続いて buildSchema
ですね。公式のチュートリアルでは /resolvers/**/*.ts
となっていますが、今回は "tsc" 通してから動かしますので実行時の *.js
を読み込むように設定してます。
そして、ApolloServerのインスタンスをnewしてlistenするだけ。
とんでもなく簡単になってますね。。。
5. MariaDB の起動
やっと出番です、MariaDB。しかし、わざわざインストールして設定して、なんてやってられないのでいつもの Docker Compose で一撃起動してしまいますよ!
version : "3"
services:
db:
image: mariadb
restart: always
ports:
- 3306:3306
environment:
- MYSQL_ROOT_PASSWORD=example
- MYSQL_DATABASE=example
- MYSQL_USER=example
- MYSQL_PASSWORD=example
adminer:
image: adminer
restart: always
ports:
- 8080:8080
接続設定は超適当で申し訳ないですが、とりあえず"example"です!
ポート3306を転送させて localhost:3306 で MariaDBに接続できるようにしております。
ちなみに mariadb
のラインナップは以下に各バージョン揃ってます。
(しっかし・・・ちょっと前まで MySQLのインストールに1時間くらいかかってたような気がしたんだけどな・・・)
で、以下のコマンドで起動します!
$ docker-compose up -d
これで、http://localhost:8080
で Adminer のサイトが立ち上がるのでデータのチェックもバッチリです!
6. Apollo Server の起動
ついにこの時がやってきました。**Apollo、行きまーす!!**のお時間です。
package.json に以下のスクリプトを追加しましょう。
...
"scripts": {
"start": "tsc && node lib/server.js",
}
...
そして、npm start
で起動です!
$ npm start
🚀 Server ready at http://localhost:4000/
Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` CHAR(36) BINARY NOT NULL , `name` VARCHAR(255) NOT NULL, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
Executing (default): SHOW FULL COLUMNS FROM `users`;
Executing (default): SELECT CONSTRAINT_NAME as constraint_name,CONSTRAINT_NAME as constraintName,CONSTRAINT_SCHEMA as constraintSchema,CONSTRAINT_SCHEMA as constraintCatalog,TABLE_NAME as tableName,TABLE_SCHEMA as tableSchema,TABLE_SCHEMA as tableCatalog,COLUMN_NAME as columnName,REFERENCED_TABLE_SCHEMA as referencedTableSchema,REFERENCED_TABLE_SCHEMA as referencedTableCatalog,REFERENCED_TABLE_NAME as referencedTableName,REFERENCED_COLUMN_NAME as referencedColumnName FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE where TABLE_NAME = 'users' AND CONSTRAINT_NAME!='PRIMARY' AND CONSTRAINT_SCHEMA='example' AND REFERENCED_TABLE_NAME IS NOT NULL;
Executing (default): ALTER TABLE `users` CHANGE `name` `name` VARCHAR(255) NOT NULL;
Executing (default): ALTER TABLE `users` CHANGE `createdAt` `createdAt` DATETIME NOT NULL;
Executing (default): ALTER TABLE `users` CHANGE `updatedAt` `updatedAt` DATETIME NOT NULL;
Executing (default): SHOW INDEX FROM `users`
....
MariaDBとの接続がうまくいけばそのままテーブル作成まで実行されてしまうはずです。Sequelize、賢いよ!
では、GraphQL Playground の画面からデータを投入してみましょう。
7. GraphQL Playground 画面での確認
ブラウザからhttp://localhost:4000
を開きます。
ユーザーを一件登録しますので、左上のクエリのペインに・・・
mutation CreateUser($userInput: AddUserInput!){
addUser(data: $userInput) {
id
name
}
}
と記述し、渡す引数を左下の'QUERY VARIABLES'のペインに記載します。
{
"userInput":{"name": "user1"}
}
これで画面中央の再生ボタンクリックでuser1
という名前のユーザーが登録されるはずですが・・・?
画面中央の再生ボタンクリックで右側のペインにidがUUIDで自動発番されたuser1
のレコードが返却されました!
Adminer の方でも確認しましょう。
ブラウザで localhost:8080
を開きます。DBへのログイン画面ではサーバーの接続先は adminer コンテナの中から解決できるホスト名、つまり docker-compose.ymlで指定したサービス名の db
もしくは docker-compose ps
で表示されるコンテナ名を指定します。あとはユーザー名、パスワード、データベースを example
と入れればログインできるはずです。
そこから、users
テーブルをクリックし、"データ"をクリックしてみてください。
(あたりまえだけど)ちゃんと入ってるーー!!!
ここまで、短かったような、長かったような・・・
8. リレーションを設定したテーブルはどうなっているのか?
上記のuser
モデルの定義・宣言の箇所でちらっと出てきたリレーションの設定ですが・・・
...
User.hasMany(Todo);
Todo.belongsTo(User);
最後に Todo
モデルとのリレーションを設定していますね・・・?
Todo
モデルは以下のような定義をしています。
@ObjectType()
export class Todo extends Model {
@Field(type => ID)
public id!: string;
@Field(type => User)
public user!: User;
@Field()
public title!: string;
@Field()
public content?: string;
@Field()
public readonly createdAt!: Date;
@Field()
public readonly updatedAt!: Date;
}
Todo.init({
id: {
type: UUID,
defaultValue: UUIDV1,
allowNull: false,
primaryKey: true,
},
title: {
type: STRING,
allowNull: false,
},
content: STRING,
},
{
sequelize,
modelName: 'todo',
}
)
と、Classのフィールド宣言はモリモリですが、init
でのDBスキーマの定義はシンプルにid
,title
,content
のみを定義しているだけです。
これが実際のテーブルには以下のように反映されます。
まず、フィールドの一覧で createdAt
と updatedAt
がありますね。。。
実は Sequelize は init
でのオプションでデフォルトでこのカラムを追加するスイッチが有効になっています。
無効にするには、
...
{
sequelize,
modelName: 'todo',
timestamps: false
}
)
と、timestamps
を false
に指定する必要があります。
ここではこのようなオプションがモリモリです。こちらもコード補完で候補一覧を出すとたくさん出てきますので、用途に合わせて選びながら設定が可能です。これもありがたいです。
続いて、userId
フィールド、indexにuserId
、さらに外部キー制約に userId
とこちらもモリモリでリレーションの設定が入ってます。
いや~、Sequelize、賢い。
本日は簡易なリレーションのみのチェックでしたが、実際のプロジェクトではリレーションバリバリのクエリがマストかと思います。
とりあえず動作確認はできたということで、本日はここまでとします!