せっかく TypeGraphQL で TypeScript Ready な GraphQL にしたのに Sequelize はもったいない。
先週の記事(【2019年10月版】Apollo + Sequelize で GraphQL やるなら TypeScript バッチリの TypeGraphQL がおすすめ!、【2019年10月版】Cloud Functions + Cloud SQL で TypeGraphQL + Sequelize の Apollo Server を動かす) にて Cloud Functions 上で Apollo + TypeGraphQL を使って GraphQL の API エンドポイントを作成してみましたが、その際に使った ORM は Sequelize でありまして、以下のような不満がありました。
Classの宣言にアノテーションでRDB用のマッピングも書けたらなぁ。。。
はい、まさにその通りでありまして、Sequelize の init 処理は無駄無(ry なわけでございます。
この点を劇的に改善する TypeORM
を使用して、TypeScript Native な GraphQL サーバーを Cloud Functions に立て、Cloud SQL 上の MySQL へ ORM させてみたいと思います!
参考サイト
今回も情報量多めなので参考サイトのご紹介から参ります。
まずは TypeORM の本家サイト。
TypeORM の本家サイトはチュートリアルというか使い方メインで記載されているので、しっかりリファレンスを参照したい場合は githubの /doc 内を読んだ方がよいですね。
TypeORM もフルボリュームの ORM ですので一通りのチュートリアルだけでも異常なボリュームがあります。
そして、実は、GraphQL の本家リポジトリには実装サンプルがありまして、その中で "TypeORM" を使っているサンプルがあります!!(あらやだ、すごい・・・)
- https://github.com/MichalLytek/type-graphql/tree/master/examples/typeorm-basic-usage
- https://github.com/MichalLytek/type-graphql/tree/master/examples/typeorm-lazy-relations
このようなサンプルを示していただけると TypeGraphQL と TypeORMの組み合わせに関しては基本的には大丈夫なんだという安心感があってありがたいですね。
また、TypeORMの使い方に関して Qiita の記事がありましたのでご紹介いたします。ありがとうございます!
それでは上記のページなどを参考に、前回までの記事で作成した Sequelize 用のプロジェクトを修正していきたいと思います。
1. 依存関係の修正
まずは依存パッケージのインストールですが、前回の記事から Sequelize 関連の構成が変わっているので package.json
は以下のようになりました。
...
"dependencies": {
"@types/bluebird": "^3.5.28",
"apollo-server": "^2.9.7",
"apollo-server-cloud-functions": "^2.9.7",
"apollo-server-core": "^2.9.7",
"firebase-admin": "^8.0.0",
"firebase-functions": "^3.0.0",
"graphql": "^14.5.8",
"merge-graphql-schemas": "^1.7.0",
"mysql2": "^1.7.0",
"reflect-metadata": "^0.1.13",
"type-graphql": "^0.17.5",
"typeorm": "^0.2.20"
},
"devDependencies": {
"@types/node": "^12.11.6",
"tslint": "^5.12.0",
"typescript": "^3.2.2"
},
...
TypeORM はアノテーション使うので本来であれば、tsconfig.json に修正が必要("emitDecoratorMetadata": true,"experimentalDecorators": trueの追加)ですが、TypeGraphQL で設定済みのため、ここでは割愛させていただきます。
2. 接続設定の切り替え
TypeORM は接続設定にいくつかの形式を提供してくれていますが、今回は外部ファイルのormconfig.js
を使用しました。
例によって、Cloud SQL の接続先情報を functions.config()
から取ってくるためです。
ということで接続の設定は以下のようになります。
const functions = require('firebase-functions');
module.exports = [
{
name: "docker",
type: "mysql",
host: "localhost",
port: 3306,
username: "example",
password: "example",
database: "example",
entities: ["lib/models/*.js"],
synchronize: true,
logging: ["query", "error"]
},
{
name: "cloud-sql",
type: "mysql",
username: functions.config().db.user,
password: functions.config().db.pw,
database: functions.config().db.name,
entities: ["lib/models/*.js"],
synchronize: true,
extra: {
socketPath: `/cloudsql/${functions.config().db.conn}`,
}
}
]
ここではローカル開発のDockerへの接続設定に"docker"という名前を付け、GCP 向けには "cloud-sql" という名前を付けています。
また、この接続で使用するスキーマ定義を entities
で明示的に指定しています。接続先ごとにモデルを分けたい、なんて時も利用できます。
それと今回、使用している synchronize
オプションはプロダクション環境では使用するな、とのことです。基本的に変更のあったモデルは "Drop & Create" しているようです。(common-connection-options)
接続設定の "extra" の項目は各ドライバへ直接渡すパラメタを記載します。Cloud SQL への接続には UNIXドメインソケットが推奨ですので、Sequelize 同様に mysql.js にソケット経由で接続させるために "socketPath" を指定しております。
で、このファイルは自動的に TypeORM が読み取ってくれますがどちらの接続を使うかは createConnection
メソッドの引数で指定します。
import { createConnection } from "typeorm";
export const connection = createConnection("cloud-sql");
この connection
を利用する方法は後ほどご紹介いたします。
3. スキーマ定義
今回の TypeORM を採用して可能になったのがアノテーションによるDBスキーマ定義です。TypeGraphQLとTypeORMでスキーマ定義だけで同じモデルのコードが共有できるなんて、素敵すぎます。
早速ですが前回のユーザーモデルは以下のようになりました。
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany, BaseEntity } from "typeorm";
import { ObjectType, Field, ID } from 'type-graphql'
import { Todo } from "./todo";
@Entity()
@ObjectType()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
@Field(type => ID)
public id!: number;
@Column()
@Field()
public name!: string;
@OneToMany(type => Todo, todo => todo.user)
@Field(type => [Todo])
public todos?: Todo[];
@CreateDateColumn({ type: "timestamp" })
public readonly createdAt!: Date;
@UpdateDateColumn({ type: "timestamp" })
public readonly updatedAt!: Date;
}
RDBのフィールド設定、リレーションの設定と GraphQLのスキーマ設定が一回のモデル定義で完了しています。
前回の Sequelize の定義と比べるまでもなく、タイプの量が圧倒的に減っています。
@Column
でDBのフィールドであることと、フィールドの型などを宣言しますが、省略した場合は varchar(255)
となるようです。(MySQLの場合)
自動生成のID項目ですが、TypeORMでも @PrimaryGeneratedColumn('uuid)'
はサポートされているはずですが、MySQLでは対応が不十分なようです・・・
Closeされているけどうまく動かなかったですね。。。
また自動生成モノでは @CreateDateColumn
、@UpdateDateColumn
などがあります。
リレーションの設定では@OneToMany
の定義がありますが、逆の @MenyToOne
側は以下のように書いています。
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn, BaseEntity } from "typeorm";
import { ObjectType, Field, ID } from 'type-graphql'
import { User } from "./user";
@Entity()
@ObjectType()
export class Todo extends BaseEntity {
@PrimaryGeneratedColumn()
@Field(type => ID)
public id!: number;
@ManyToOne(type => User, user => user.todos)
@Field(type => User)
public user!: User;
@Column()
@Field()
public title!: string;
@Column("text")
@Field()
public content?: string;
@CreateDateColumn({ type: "timestamp" })
@Field()
public readonly createdAt!: Date;
@UpdateDateColumn({ type: "timestamp" })
@Field()
public readonly updatedAt!: Date;
}
こちらもこのフィールド定義だけでリレーションが出来てしまうので便利です。
4. リゾルバの修正
実は、上記の手順でモデルのClass宣言に extends BaseEntity
していたのをあえて黙っておりました。
TypeORM では BaseEntity を extend しないすっぴんの Class でも Repository を使うことで同じことが可能です。
まず BaseEntity を継承するとどんなことができるようになるの?という点は以下のページをご覧ください。
リンクのタイトルでもうお分かりかと思いますが、要は ActiveRecord パターンで書きたかったのです。
このリゾルバの処理で実際にレコードの操作を行いますが、Data Mapper pattern + Repository では実現できることが同じなのに記述量が増えてしまったので ActiveRecord パターンを採用しています。
特にサンプル実装は簡易なCRUD操作だけだしね・・・ということもあります。複雑なビジネスロジックには Data Mapper + Repository の方が適している場合もあるかもしれません。
実際に、先ほど挙げた TypeGraphQL のサンプル実装では Data Mapper + Repository を使用した実装を行っています。
どちらのパターンでもTypeORMは対応可能ですので、仕様とコーディングのコストでどちらかを採用すればよいかと思います。
というわけで、今回の Active Record パターンでのリゾルバ実装は以下のようになりました。
import { Resolver, Query, InputType, Field, Arg, Ctx, Mutation } 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!: number;
@Field()
name!: string;
}
@Resolver()
export class TodoAppResolver {
@Query(returns => User, { nullable: true })
async user(@Arg("id") id: number): Promise<User | undefined> {
return User.findOne(id);
}
@Mutation(returns => User)
async addUser(@Arg("data") newUser: AddUserInput, @Ctx() ctx: Context): Promise<User> {
const user = User.create(newUser);
return user.save();
}
@Mutation(returns => User, { nullable: true })
async updateUser(@Arg("data") updateUser: UpdateUserInput, @Ctx() ctx: Context): Promise<User | null> {
const user = await User.findOne(updateUser.id);
if (user !== undefined) {
user.name = updateUser.name;
return await user.save();
}
return null;
}
}
実はここは Sequelize よりコード量が増えてます。。。いや、なのでRepositoryパターンをやめたのですが、コード量が減る Active Record パターンを使っても、やっぱり Sequelize よりコード量増えてます。。。
ここは DeepPartial でそのまま Create、Updateできるメソッドがないのが辛いっすね~。Active Record パターンがダメかと思ったのですが、Repository 側にもそのようなメソッドがないので諦めました。
5. Apollo Server の用意
ここは前回のものとほとんど変わらないですが、接続設定については TypeORM 用のコードを加える必要があります。
async function init() {
const conn = await db.connection;
BaseEntity.useConnection(conn);
}
...
exports.gqltest = functions.https.onRequest(server.createHandler({
cors: {
origin: true,
credentials: true,
},
}));
init().catch(console.log);
BaseEntity
はデフォルトでdefault
という名前の接続を参照しに行きます。今回は名前指定でコネクションを作成しているために、BaseEntity.useConnection()
で作成した接続を使用するように指定しています。
サーバー起動時の修正はこの部分だけでOKです。
6. Functions の Node.js ランタイムは 8 じゃないとダメ。
ちょっとショッキングなのですが、functionsで利用する node.js ランタイムは前回、10
を指定しておりましたが、今回は 8
じゃないとダメです。
package.json を以下のように修正してください。
...
"engines": {
"node": "8"
},
...
Cloud Functions 起動時の接続でMySQLとの接続が切断されてしまうので、色々試したところ、node.js "8" で落ち着きました。こういうところが"β版"なのかなぁ。。。
7. デプロイ
では、以下のコマンドでデプロイしましょう!Apollo、行きまーす!
$ firebase deploy --only functions:gqltest
例によって Functions のログ監視は必須です。こちらの環境では Node.js 8
のインスタンスで一応は安定していますが、Cloud Functionsの起動時に他サービスとの接続が不安定なのはあるあるなので、ダメなら再デプロイが必須です。(困ったもんだ。。。)
Functions の Httpsエンドポイントにアクセスしたら GraphQL の Playgroud が立ち上がるはずです!
8. Cloud Shell から MySQL を確認。
GraphQLのPlayground画面での確認は割愛いたしますが、Cloud SQLでの確認結果は載せておきましょう。
前回同様、Cloud Shell から MySQL に接続いたします。
$ gcloud sql connect graphql-sample-xxxx --user=example --quiet
Whitelisting your IP for incoming connection for 5 minutes...done.
Connecting to database with SQL user [example].Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 25401
Server version: 5.7.14-google-log (Google)
Copyright (c) 2000, 2019, Oracle and/or its affiliates. All rights reserved.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> use example;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
mysql> select * from user;
+----+-------+----------------------------+----------------------------+
| id | name | createdAt | updatedAt |
+----+-------+----------------------------+----------------------------+
| 1 | user1 | 2019-10-28 10:15:02.958659 | 2019-10-28 10:15:02.958659 |
| 2 | user2 | 2019-10-28 10:17:30.685594 | 2019-10-28 10:17:30.685594 |
| 3 | user3 | 2019-10-28 10:17:40.487853 | 2019-10-28 10:17:40.487853 |
| 4 | user4 | 2019-10-28 10:22:54.050144 | 2019-10-28 10:22:54.050144 |
| 5 | user5 | 2019-10-28 10:27:47.665271 | 2019-10-28 10:27:47.665271 |
| 6 | user5 | 2019-10-28 10:30:17.408823 | 2019-10-28 10:30:17.408823 |
+----+-------+----------------------------+----------------------------+
6 rows in set (0.15 sec)
mysql>quit;
MySQL 側にレコードがバンバン追加できているのが確認できました!
ここでちょっと注意が必要な点は、Sequelize で作成されたテーブルは users
でしたが、TypeORMは user
です。モデル名がそのままテーブル名になっております。
というわけで、TypeORMが生成するDBのテーブル名やフィールド名などがちょっと気に食わない・・・という場合は先ほどご紹介した記事 で修正できるようです。
本日は以上といたします。