FLINTERS Engineer's Blog

FLINTERSのエンジニアが綴る技術ブログ

Prismaを使ってサクッとGraphQLのバックエンドを作成する!

こんにちは。菅野です。

最近はスマホアプリ全盛期ですね。誰しもが毎日たくさんのアプリを使っていると思います。
アプリでしかサービスを展開していないものもたくさんあったりします。

そのアプリを開発するときには、バックエンド側はFirebaseのようなmBaaSを利用するのが一番手軽だと思います。
ただ、mBaaSが用意するデータベースの中にはちょっと特殊なものもありますよね。別のサービスに乗り換えたくなったときとかに困るのでは?と思ったりもします。
あと、DBはRDBであることが要件の場合も少し悩ましいです。

そこで、mBaaSに頼らずに手軽にアプリのデータ置き場を作成できるか試してみるためにPrismaを使ってGraphQLのバックエンドを作成してみたので紹介します。

Prismaとは

www.prisma.io

Prismaとはnode.jsのORMです。特徴はGraphQLとの親和性の高さです。

良いところ

  • GraphQLと親和性が高い
  • DBのマイグレーションツールも付属
  • TypeScriptに対応しているので型安全
  • N+1問題に対応してるみたい

今回はこのPrismaを使ってGraphQLサーバーをササッと作ってみようと思います。

Prismaでテーブルを作成

説明が長くなってしまうので、TypeScriptの環境は整っている前提で話を進めます。

Prismaの環境を整える

まず、prismaパッケージをnpmなりyarnなりでインストールします。
このパッケージはツールなのでdevDependenciesの方へ追加します。
yarn add --dev prisma

次にnpxかyarn runでprismaの環境を作ります。
yarn prisma init

すると、prismaディレクトリにschema.prismaが出来上がるのでここに設定を記述します。

まず必要になるのはDBの接続先なので、記述しておきます。
今回はPostgreSQLを使ったので下記のような設定になります。

datasource db {
  provider = "postgresql"
  url      = "postgresql://user:password@localhost:5432/prisma_sample?schema=public"
}
スキーマ定義を作る

そしてスキーマ定義も追記します。
ユーザーとチャンネルと投稿があるSlack風の何かを考えてみました。
一から手書きする以外にも、既存のテーブルからスキーマ定義を生成する機能もあるようです。

完成形は以下です。どことなくGraphQLのSDLに似ている気がします。

model Post {
  id        Int      @id @default(autoincrement())
  content   String
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  channel   Channel  @relation(fields: [channelId], references: [id])
  channelId Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Channel {
  id          Int     @id @default(autoincrement())
  title       String  @db.VarChar(255)
  description String? @db.VarChar(255)
  posts       Post[]
  members     User[]  @relation("ChannelUser")
}

enum Role {
  USER
  ADMIN
}

model User {
  id       Int       @id @default(autoincrement())
  email    String    @unique
  name     String
  channels Channel[] @relation("ChannelUser")
  posts    Post[]
  role     Role      @default(USER)
}
マイグレーションしてみる

ここまで来たらコマンド一発ですべてのテーブルが作成できます。
yarn prisma migrate dev --name init
コマンドを実行するとマイグレーション用のSQLファイルが作成され、そしてDBにテーブルが作成されます。

f:id:zakknak:20210325040654p:plain:w600

どうでしょう?なかなか良い感じです。
多対多の関連では自動的に関連テーブルが作られています。

GraphQLサーバーを作成

TypeGraphQLでGraphQLのリゾルバーを作成する

GraphQLサーバーを作るにあたり必要となるのがリゾルバーです。
ゾルバーはGraphQLでの問い合わせ内容を実際に実行するための実装のことです。

Prismaのサイトではリゾルバーを書くライブラリとしてGraphQL Tools、Nexus、TypeGraphQLが紹介されています。
その中で、GraphQL Toolsは手書きでSDLを書かなくてはならず、schema.prismaに書いたのに二度手間っぽくなるので除外。
Nexusはちょっと目がチカチカするので見送り。
選ばれたのはTypeGraphQLでした。

typegraphql.com

特徴は、TypeScriptのデコレーターを使ってコードにGraphQLのスキーマ定義を注釈してやる感じの書き心地なこと。
注意点は、type-graphql以外に、Installation · TypeGraphQLに書かれている通りのパッケージのインストールやtsconfig.jsonの設定が必要です。
あと、prismaスキーマ定義から自動でリゾルバーを生成する機能があります。 自動生成のためには
typegraphql-prisma @prisma/client graphql-scalars graphql-fields
が必要です。

環境が整ったらschema.prismaの定義からリゾルバーの自動生成を試してみます。
ゾルバーを自動生成するためにschema.prismaに以下を追記します。

generator gen {
  provider = "prisma-client-js"
}

generator typegraphql {
  provider = "typegraphql-prisma"
  output   = "../src/generated/typegraphql-prisma"
}

ここまで来たら
yarn prisma generate
prismaクライアントが生成され、リゾルバーのコードがoutputに指定した場所に自動生成されます。

Express GraphQLでGraphQLのサーバーを作成する

GraphQLと言ったらApolloなイメージがありますが、express-graphqlでExpressでもサーバーを作れます。
Expressの方がいろんなところで採用されていて触る機会が多いのでそっちを試してみました。

yarn add express express-graphqlで必要なものを入れたらサクッと作ります。

まず、各リゾルバーに引き回すコンテキストにPrismaClient(と、ついでにExpressのRequestオブジェクト)を入れたいのでその定義とPrismaClientのインスタンスをexportするコードを作成します。

// context.ts
import { PrismaClient } from "@prisma/client";
import { Request } from "express";

export interface Context {
  prisma: PrismaClient
  request: Request
}

export const prisma = new PrismaClient();

以下はGraphQLのサーバー部分に渡すschemeを作成する処理です。
ゾルバー自体は自動生成しているので、たくさんある中から気に入ったものを読み込んで渡すだけでOKです。何も書く必要はありません。

// scheme.ts
import { buildSchemaSync } from "type-graphql";
import { FindManyUserResolver, FindUniqueUserResolver, UserRelationsResolver, CreateUserResolver, FindManyChannelResolver, CreateChannelResolver, ChannelRelationsResolver, PostCrudResolver } from "../generated/typegraphql-prisma";

export const schema = buildSchemaSync({
  "resolvers": [
    FindManyUserResolver,
    FindUniqueUserResolver,
    UserRelationsResolver,
    CreateUserResolver,
    FindManyChannelResolver,
    CreateChannelResolver,
    ChannelRelationsResolver,
    PostCrudResolver,
  ],
  "validate": false,
});

最後にサーバーを起動する部分です。特に説明することはありません。

// main.ts
import "reflect-metadata";
import express from "express";
import { graphqlHTTP } from "express-graphql";
import { schema } from "schema";
import { prisma } from "context";

const app = express();
app.use("/graphql", graphqlHTTP(async (request) => ({
  schema,
  "context": {prisma, request},
})));
app.listen(9000);

console.log("http://localhost:9000/graphql");

以上。書いたコードはこれだけです。

遊んでみる

あっけなくサーバーが出来たので早速動作を確認してみましょう。

とても便利なクライアントのGraphQL Playgroundを使うと簡単に動作確認が出来ます。
GitHub - graphql/graphql-playground: 🎮 GraphQL IDE for better development workflows (GraphQL Subscriptions, interactive docs & collaboration)

クライアントをインストールしたら先程作ったExpressのサーバーを起動して、GraphQL Playgroundも起動します。

f:id:zakknak:20210325055434p:plain 起動したらURL ENDPOINTにエンドポイントを入力してOPENを押します。
するとGraphQLを書いて実行できる画面が開きます。

データを保存してみる

まずは、何かしらのデータを保存してみましょう。
GraphQLではミューテーションでデータの変更をさせることが出来ます。さて、どう書けばよいのでしょう?
GraphQL Playgroundにはサーバーから取得したスキーマ定義からドキュメントを表示する機能があり、これが死んじゃいそうなくらい便利です。いや、死なないけど。*1*2

f:id:zakknak:20210325055828p:plain

サーバ内部で生成されたスキーマ定義に従って、以下のようなミューテーションを書いて実行しました。

f:id:zakknak:20210325060323p:plain

出来た風の動きをしているので、一応DBも確認してみます。

PrismaにはDBのレコードを編集するツールがあり、
yarn prisma studio
で起動できます。(何でもあるなぁ)

f:id:zakknak:20210325060749p:plain:w400 f:id:zakknak:20210325061210p:plain

きちんとDBに保存されているみたいです。
関連テーブルは表示されなくて、各レコードから関連を追うことが出来ます。便利。

データを検索してみる

次はクエリを試しましょう。

f:id:zakknak:20210325062516p:plain

f:id:zakknak:20210325062532p:plain

いい感じですね。
GraphQLは一発で関連をすべて取ってくることが出来るのですごく便利です!

もう少しちゃんとしてみる

ササッとGraphQLが使えるデータ置き場が作れました。
ですが、流石にこれだとカオス過ぎるのでもう少し作り込んでみようと思います。

具体的には、誰でもユーザー作れちゃっていいのか?とか、他人のユーザー情報からその人のすべての購読チャンネルやすべての投稿を取得出来ても良いのか?とかです。
普通はそんなこと無いはずなので、そこんところをうまく作ってみます。

カスタムディレクティブ

GraphQLにはディレクティブというものがあり、これを使うと特定の機能を対象に付加することが出来ます。

たとえば組み込みの@deprecatedはフィールドが非推奨なことをドキュメントで明示できます。

type SomeType {
  newField: String!
  oldField: String! @deprecated(reason: "Use `newField` instead.")
}

TypeGraphQLはカスタムディレクティブに対応しているので、自作の処理を適用できます。
ここでは権限があるユーザーしか利用できないようにする@adminOnlyを作成してみます。

@graphql-tools/utilsパッケージをインストールし、SchemaDirectiveVisitorを利用して作成します。
判定処理は適当です。

import { SchemaDirectiveVisitor } from "@graphql-tools/utils";
import { GraphQLField, defaultFieldResolver } from "graphql";
import { Context } from "context";

export class AdminOnlyDirective extends SchemaDirectiveVisitor {

  public visitFieldDefinition(field: GraphQLField<never, Context>) {
    const { resolve = defaultFieldResolver } = field;
    field.resolve = async function(...args) {
      const [, , ctx] = args;
      // 実際にはJWTなどできちんと判断するようにする
      if (ctx.request.header("role") === "ADMIN") {
        return resolve.apply(this, args);
      }
      throw new Error("not authorized");
    };
  }

}
ゾルバーを作る

カスタムディレクティブを作ったら、それを使用して権限を持っているユーザーしかチャンネルを作成できないリゾルバーを実装してみます。

@Resolver(Channel)
export class CustomChannelResolver {

  @Directive("@adminOnly")
  @Mutation((returns) => Channel)
  async adminCreateChannel(
    @Arg("title", (type) => String) title: string,
    @Arg("members", (type) => [Int]) argMembers: number[],
    @Arg("description", (type) => String, {"nullable": true}) description: string,
    @Ctx() ctx: Context,
  ) {
    const members = {"connect": argMembers.map(x => ({"id": x}))};
    return ctx.prisma.channel.create({
      "data": {
        title,
        members,
        description,
      },
    });
  }

}

このような実装になりました。

SDL的には

type Mutation {
  adminCreateChannel(
    description: String
    members: [Int!]!
    title: String!
  ): Channel! @adminOnly
}

のようになるイメージです。

ディレクティブは一度作れば再利用できるので、他の場所にも簡単に権限周りを適用できます。

ゾルバーとカスタムディレクティブを組み込む

ついでに、アクセス対象のユーザーがログインユーザー自身であるときのみアクセスを許可する@ownAccessOnlyを作成し、ユーザーの関連を追うリゾルバーも作ってそれに付加します。

@Resolver(User)
export class CustomUserRelationsResolver {

  @Directive("@ownAccessOnly")
  @FieldResolver(_type => [Channel], {"nullable": false})
  async channels(@Root() user: User, @Ctx() ctx: Context, @Args() args: UserChannelsArgs): Promise<Channel[]> {
    return ctx.prisma.user.findUnique({"where": {"id": user.id}}).channels(args);
  }

  @Directive("@ownAccessOnly")
  @FieldResolver(_type => [Post], {"nullable": false})
  async posts(@Root() user: User, @Ctx() ctx: Context, @Args() args: UserPostsArgs): Promise<Post[]> {
    return ctx.prisma.user.findUnique({"where": {"id": user.id}}).posts(args);
  }
}

作ったリゾルバーを追加して、buildSchemaSyncで作成したスキーマにディレクティブを更に追加します。

export const schema = buildSchemaSync({
  "resolvers": [
    // 省略...
    CustomUserRelationsResolver,
    CustomChannelResolver,
  ],
  "validate": false,
});

SchemaDirectiveVisitor.visitSchemaDirectives(schema, {
  "adminOnly": AdminOnlyDirective,
  "ownAccessOnly": OwnAccessOnlyDirective,
});
動作確認

これでオリジナルの処理が走るようになります。試してみましょう。

ヘッダーにUSERを入れてadminCreateChannelを呼び出すとエラーになりました。
f:id:zakknak:20210325080504p:plain

ADMINと入れて呼び出すと無事に作成処理が走りました。良さそうです。
f:id:zakknak:20210325080657p:plain

次はユーザーとその投稿を一緒に検索してみます。
ログインユーザー自身の呼び出しを想定してクエリを発行すると結果が返ってきます。
f:id:zakknak:20210325080923p:plain

自分じゃないユーザーとその投稿をまとめて取得しようとするとエラーになりました。
f:id:zakknak:20210325081259p:plain

もちろん、名前だけなら取得できます。良いですねぇ。
f:id:zakknak:20210325081517p:plain

おしまい

こんな感じで割と簡単にデータ置き場が作成できました。
実用として使うにはまだまだ考えることがたくさんありそうですが、あまり品質を求めないプロトタイプの作成用には良いのではないでしょうか?

早く、何も作らなくてもアプリが出来上がる時代が来てほしいですねぇ。
こちらからは以上です。

*1:便利すぎて死亡した場合、保険はおりるのだろうか。

*2:余談だが「鬼のように〇〇」という言い回しがあるが、大抵の場合そもそも鬼が〇〇だったというエビデンスに欠けるんだよなぁ