🤧

【徹底解説】【ORM】NestJS+PrismaでGraphQLサーバを実装してみた

2022/06/29に公開

こんちわ。
前回紹介した 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に以下を追記。

package.json
"prisma": {
    "seed": "ts-node ./prisma/seed/start.ts"
    }

そして、seed.ts に投入するデータを記述。

prisma/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へ接続を行う処理を定義します。ここはどんな開発内容でも共通です。以下ファイルを作成して記述。

src/prisma.service.ts
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
src/post.models.ts
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
src/user.models.ts
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を作成し、QueryMutationを定義していきます。あとちょっとです。
先程と同様にnestのCLIで作成し、定義していきます。

nest generate resolver post
src/post.resolver.ts
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
src/user.resolver.ts
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。

src/app.module.ts

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