【徹底解説】【ORM】NestJS+PrismaでGraphQLサーバを実装してみた
こんちわ。
前回紹介した NestJS に加えて、Node.js 製の ORM である Prisma を用いて簡単な GraphQL サーバを作りましたのでご紹介します。
あまり情報も出回っていないため今回記事にしてみました。
少々長くなってしまいましたが、お付き合いいただければと思います。
まずは簡単な説明から
NestJS/GraphQL/Apollo それぞれ紹介しますが、読み飛ばして頂いて結構です。
NestJS とは(おさらい)
前回の記事参照。
GraphQL とは
Facebook 社が開発した WebAPI のクエリ言語で、REST がもつデメリットを解決する API 設計アプローチです。
REST API はリソースベースで設計されているため、クライアントのユースケースをベースとして設計する開発では限界を迎える事も少なくありません。
例えば以下のように多くのパラメータを抱え、複雑な記載になるケースとか。
GET /api/users?page_num=3&fields=accountId,name,image(w_250,h_250),address,birthday,platform=web
ここから更にモバイルアプリや Web それぞれ異なるデータが欲しいケースなど、画面によって様々な要件が出てくるため悪化します。API は用途ごとに極力分けるにしろ、
- step 数の増加
- API 間で重複コードが発生
- パフォーマンスの低下
などの悪影響に繋がります。こういった問題を OSFA(One-Size-Fits-All) 問題といいます。
"元々は一つのユースケースに最適化されるよう設計されていたのに、API が拡張されるにつれて多くのユースケースに応えるようになり、メンテナンス性・拡張しやすさに悪影響を及ぼすこと"を指します。
要は一つの API が多くの役割をしなければならない状態に陥る事を指します。
そしてこの問題を解決したものが GraphQL になります。
※GraphQL と REST の違いについてはまた別記事で詳しくまとめますが、N+1 問題やセキュリティ面など懸念事項はあるため、必ずしも GraphQL が優れている訳ではなさそうです。
用途毎に使い分けた方が良いように感じました。
Apollo とは
GraphQL のフロントエンド&バックエンドのライブラリです。
この図の通りですが、Apollo の導入によりクライアント・サーバーそれぞれの複雑性が隠蔽され、Apollo との接続にのみ注意すればよくなります。
なお、バックエンドなら Apollo Server、フロントエンドなら Apollo Client を導入する必要があります。
コードファーストとは
NestJSではGraphQL APIを構築するための手法として、コードファースト方式/スキーマファースト方式 の2つを提供しています。
- コードファースト方式:TypeScript コードをベースにスキーマ定義ファイルが自動生成されるパターン。
- スキーマファースト方式:スキーマ定義ファイルをベースにTypeScriptコードが自動生成されるパターン。
ビジネスロジックはどちらも自力で記述する必要があり、コーディング作業単体で見れば大きな差はありませんでした。
実際の開発において、スキーマが予め定義されている状況なら後者が良いですが、そうでなければ前者が一般的なようです。
今回は前者のコードファースト方式を紹介しています。
早速やってみよう
1. NestJS構築&GraphQLのインストール
まずは以下コマンドでNestJSをインストールし構築します。
npm install @nestjs/cli
nest new my-app
今回Prismaをメインに解説するため、NestJSの詳しい環境構築に関しては省略します。続いてGraphQL。
npm install @nestjs/graphql @nestjs/apollo graphql apollo-server-express
moduleファイルには後ほど定義します。
2. Prismaのセットアップ
2-1. 準備
prismaをインストールします。
npm install @prisma/client
npx prisma init
上記二行目を実行すると、以下ファイルが生成されます。
-
prisma/schema.prisma
ファイル: 作成するテーブルの構成を定義。 -
.env
ファイル: 接続する DB のアクセス先を記述。
それぞれ追記していきます。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// 追加.
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
posts Post[]
}
// 追加.
model Post {
id Int @id @default(autoincrement())
published Boolean? @default(false)
title String
content String?
author User? @relation(fields: [authorId], references: [id])
authorId Int?
}
追記したmodel XX { ... }
が今回作成するテーブルのモデルに該当します。
<!-- DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" -->
# mysql
DATABASE_URL="mysql://root:password@localhost:3306/sample_db"
データベースのアクセス先を指定しました。
尚、今回はmysqlを使用しています。
2-2. テーブルに初期データを設定
上記で定義したテーブルモデルに対して初期データを投入します。
ここは後述する手順 3 のPrisma Studioでも行えるため必須ではありませんが、手順2-3(マイグレーション)実行時にここで定義した初期データも投入されるようになるので便利です。
投入するデータを記述するseed.tsファイルを実行するために、ts-node が必要なのでインストール。
npm install ts-node
インストール完了したら、package.jsonに以下を追記。
"prisma": {
"seed": "ts-node ./prisma/seed/start.ts"
}
そして、seed.ts に投入するデータを記述。
import { PrismaClient, Prisma } from "@prisma/client";
const prisma = new PrismaClient();
const userData: Prisma.UserCreateInput[] = [
{
name: "Dustin Boswell",
email: "d-boswell@user.com",
posts: {
create: [
{
title: "readable code",
content: "coding design",
published: true,
},
],
},
},
{
name: "Scott Meyers",
email: "s-meyers@user.com",
posts: {
create: [
{
title: "Effective C++",
content: "C++ coding",
published: true,
},
],
},
},
{
name: "Joe Celco",
email: "j-celco@user.com",
posts: {
create: [
{
title: "SQL Puzzle",
content: "Questions",
published: true,
},
{
title: "Instant SQL Programming",
content: "SQL program",
},
],
},
},
{
name: "Takaaki Mizuno",
email: "t-mizuno@user.com",
posts: {
create: [
{
title: "WebAPI The Good Parts",
content: "API Design",
published: true,
},
],
},
},
];
async function main() {
console.log(`Start seeding ...`);
for (const u of userData) {
const user = await prisma.user.create({
data: u,
});
console.log(`Created user with id: ${user.id}`);
}
console.log(`Seeding finished.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
2-3. マイグレーションの実行
定義したら以下を実行。
prisma migrate dev --name init
マイグレーションが行われ、PrismaスキーマとDBスキーマが同期されます。
DBのテーブルを見てみると...
定義したPost、Userテーブルが出来ており、指定したカラムが入っていました。
また、マイグレーションの履歴フォルダが出来ています。
migrations/
└─ 20220614061434_init/ ※_init = --nameオプションで指定した名前が入る。
└─ migration.sql
もしもこの後テーブルのカラムを追加したくなった場合はschema.prisma
に変更を加えて以下のように実行します。
prisma migrate dev --name XXX
これで履歴が更新され、テーブルにも反映されます。
migrations/
└─ 20220614061434_init/
└─ migration.sql
└─ 20220614061434_XXX/
└─ migration.sql
3. GUI 上でテーブルにデータを追加(Prisma Studio)
PrismaではPrisma StudioというGUIツールを提供しています。
これにより接続したDBに対してブラウザ上でCRUD操作を行うことが可能です。すごく便利。
npx prisma studio
起動すると以下画面が表示されます。
Postテーブルを見ると、seed.tsで投入した初期データが表示されていますね。
レコードの追加・削除だけでなく、外部結合も難なく扱えるので非常に便利です。
4. Prisma クライアントを定義
NestJS→Prismaへ接続を行う処理を定義します。ここはどんな開発内容でも共通です。以下ファイルを作成して記述。
import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
// Prisma独自のシャットダウンフック.
this.$on("beforeExit", async () => {
await app.close();
});
}
}
5. テーブルの各データをモデルとして定義
次に、各カラムのDTOを定義します。
以下のように、NestJSのCLIコマンドを使用してクラスを生成します。
nest generate class post.models
import "reflect-metadata";
import { Field, ObjectType, Int } from "@nestjs/graphql";
import { User } from "./user.models";
@ObjectType()
export class Post {
@Field((type) => Int)
id: number;
@Field((type) => Boolean, { defaultValue: false, nullable: true })
published?: string | null;
@Field((type) => String)
title: string;
@Field((type) => String, { nullable: true })
content?: string | null;
@Field((type) => User, { nullable: true })
author?: User | null;
@Field((type) => Int)
authorId?: number;
}
userも同様。
nest generate class user.models
import "reflect-metadata";
import { Field, ObjectType, Int } from "@nestjs/graphql";
import { Post } from "./post.models";
@ObjectType()
export class User {
@Field((type) => Int)
id: number;
@Field((type) => String)
email: string;
@Field((type) => String, { nullable: true })
name?: string | null;
@Field((type) => [Post], { nullable: true })
posts?: [Post] | null;
}
6. Resolver の実装
GraphQLでデータ操作を行うためのResolverを作成し、Query
とMutation
を定義していきます。あとちょっとです。
先程と同様にnestのCLIで作成し、定義していきます。
nest generate resolver post
import {
InputType,
Field,
Resolver,
Args,
Query,
Mutation,
} from "@nestjs/graphql";
import { Inject } from "@nestjs/common";
import { Post } from "./post.models";
import { PrismaService } from "./prisma.service";
@InputType()
export class PostCreateInput {
@Field()
title: string;
@Field({ nullable: true })
content: string;
}
@Resolver(Post)
export class PostResolver {
constructor(@Inject(PrismaService) private prismaService: PrismaService) {}
@Query((returns) => Post, { nullable: true })
getPostById(@Args("id") id: number) {
return this.prismaService.post.findUnique({
where: { id },
});
}
@Query((returns) => [Post])
getAllPosts() {
const posts = this.prismaService.post.findMany({
include: {
// ★外部の関連レコードも返させる.
author: true,
},
});
return posts;
}
@Query((returns) => [Post])
getPublishedPosts() {
return this.prismaService.post.findMany({
where: { published: true },
});
}
@Query((returns) => [Post])
getFilteredPosts(@Args("searchString") searchString: string) {
return this.prismaService.post.findMany({
where: {
OR: [
{
title: { contains: searchString },
},
{
content: { contains: searchString },
},
],
},
});
}
@Mutation((returns) => Post)
createDraft(
@Args("data") data: PostCreateInput,
@Args("authorEmail") authorEmail: string
) {
return this.prismaService.post.create({
data: {
title: data.title,
content: data.content,
author: {
connect: { email: authorEmail },
},
},
});
}
@Mutation((returns) => Post, { nullable: true })
publishPost(@Args("id") id: string) {
return this.prismaService.post.update({
where: { id: Number(id) },
data: { published: true },
});
}
@Mutation((returns) => Post, { nullable: true })
deletePost(@Args("id") id: string) {
return this.prismaService.post.delete({
where: { id: Number(id) },
});
}
}
- IDをもとにPostを取得するQuery(getPostById)
- Postの一覧を取得するQuery(getAllPosts)
- 新しいレコードを作成するMutation(createDraft)
などを今回定義しています。
また、コメントに記載した通りですが、getAllPostsで呼び出すfindManyにinclude:
を記載しています。
Postレコードに紐付いたUserテーブルのレコードを参照して返したいので、今回このような記載にしています。
で、user も同様。
nest generate resolver user
import { InputType, Field, Resolver, Args, Mutation } from "@nestjs/graphql";
import { Inject } from "@nestjs/common";
import { User } from "./user.models";
import { PrismaService } from "./prisma.service";
@InputType()
export class UserCreateInput {
@Field({ nullable: true })
name: string | null;
@Field()
email: string;
}
@Resolver(User)
export class UserResolver {
constructor(@Inject(PrismaService) private prismaService: PrismaService) {}
@Mutation((returns) => User)
signupUser(@Args("data") data: UserCreateInput) {
return this.prismaService.user.create({
data: {
name: data.name,
email: data.email,
},
});
}
}
7. Module の生成
最後にModule。
import { Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
import { GraphQLModule } from "@nestjs/graphql";
import { UserResolver } from "./user.resolver";
import { PostResolver } from "./post.resolver";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "path";
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
playground: true,
autoSchemaFile: join(process.cwd(), "src/schema.gql"),
}),
],
controllers: [],
providers: [PrismaService, UserResolver, PostResolver],
})
export class AppModule {}
8. 実行
ようやく実行です。記事書くの長かった。。。
npm start
このタイミングでスキーマファイル(schema.gql)が自動生成されます。
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
type Post {
id: Int!
published: Boolean
title: String!
content: String
author: User
authorId: Int!
}
type User {
id: Int!
email: String!
name: String
posts: [Post!]
}
type Query {
getPostById(id: Float!): Post
posts: [Post!]!
getPublishedPosts: [Post!]!
getFilteredPosts(searchString: String!): [Post!]!
}
type Mutation {
signupUser(data: UserCreateInput!): User!
createDraft(data: PostCreateInput!, authorEmail: String!): Post!
publishPost(id: String!): Post
deletePost(id: String!): Post
}
input UserCreateInput {
name: String
email: String!
}
input PostCreateInput {
title: String!
content: String
}
リゾルバで定義したQueryやMutationが反映されてますね。
冒頭で述べた通りですが、もう一つのスキーマファースト方式ではこちらのファイルを記述していく流れになります。
で、http://localhost:3000/graphql
にアクセスしてGraphQL playgroundへ。
早速クエリを実行してみたいと思います。リゾルバで定義したgetAllPosts()を呼び出して、テーブル内のレコードを確認してみます。
すると、、
このように、値が取得できました!ネストされた部分もしっかり表示されてますね。
おわりに
大ボリュームになってしまいましたが、Prisma Studioなど非常に便利でした。
余談ですがPrisma はN+1問題の解決に注力しており、単一レコードを取得するfindUnique
では N+1 を防ぐための機構が組み込まれているそうです。
公式 Doc(Solving the n+1 problem)
こちらに関しては解説動画もありますのでご参考までに。
早速次の開発で使っていきたいと思います。
また、Prisma公式ではNext.jsとの組み合わせを推してました。機会があればやってみたいと思います。
Discussion