Nest.js学習にっき(notiz-dev/nestjs-prisma-starterを使いながら)

急遽、仕事で使いたいなーと思って Nest.js を調べている。

※画像引用:https://nestjs.com/

エレガントにゃんこがトップを飾っていてかっこいい。それだけで Nest.js が最高のフレームワークだとすら思えてくる。

……とはいえ、Nest.js のことはあまり知らない。知らないことを学ぶ時って、はじめは当然何も分からないよね。現に今、Nest.js のスターターキットを git clone して読んでいるところだけど、今のところは「何これ?」って感じだ。DI って概念、久々に見たな〜なんて思っている。Java のお仕事メインだった頃は当然のように登場していたけれど、TypeScript のお仕事メインになってからは、(たまに見るけど)あんまり DI と仲良くしてこなかった。

触ってる Nest.js のスターターキットはこれ。

https://github.com/notiz-dev/nestjs-prisma-starter

Nest.js と Prisma、GraphQL が合わさったすごくいい感じのスターターキットだ。まさにこれが欲しかった。GitHub のスターが現時点で 1700 件付いているのもよく分かる。このプロジェクトで採用されている GraphQL はコードファースト——つまり、コードを書けば schema.graphql が自動生成されるというスタイルだ。これまでぼくは、スキーマファースト(schema.graphql をベースに、コードを生成する)のスタイルでの扱い方しかやったことがない。でもいい感じだ。コードファーストの方が無駄がない。

Nest.js 学習記録 with notiz-dev/nestjs-prisma-starter

以下は、ぼくの学習記録だ。notiz-dev/nestjs-prisma-starterのコードを読みながら、Nest.js のことを学ぼうと思う。そして、その過程でメモしたことを、そのままここに貼り付ける。

isGlobal が true のモジュールはグローバルで使える

例えばこれ

// ---- src/app.module.ts ----
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, load: [config] }),
    PrismaModule.forRoot({
      isGlobal: true,
      prismaServiceOptions: {

ここで登録した ConfigModule や PrismaModule は、他のモジュールで個別に import せずとも使えるようになる。プロジェクト全体に渡って使いたい共通的なものを、毎回毎回 import に明記するのはめんどくさいもんね。

だから例えば、このプロジェクトに存在する「ユーザーモジュール」には、PrismaModule のインポートの明記がなくとも、下記のように PrismaModule が内包するサービスを利用できるみたいだ。

// ---- src/users/users.service.ts ----
@Injectable()
export class UsersService {
  constructor(
    private prisma: PrismaService,
    private passwordService: PasswordService
  ) {}

GraphQL のルートってどこから登録されてるの?

REST API はコントローラで次のように実装されている。

// ---- src/app.module.ts ----
@Module({
  imports: [
    // 略
  ],
  controllers: [AppController],
  providers: [AppService, AppResolver],
})
export class AppModule {}
// ---- src/app.controller.ts ----
import { Controller, Get, Param } from '@nestjs/common'
import { AppService } from './app.service'

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello()
  }

  @Get('hello/:name')
  getHelloName(@Param('name') name: string): string {
    return this.appService.getHelloName(name)
  }
}

じゃあ、GraphQL は?

GraphQL のエンドポイントは、/graphql だ。だけどそのルートは、別にコントローラで自前で定義しているわけじゃないらしい。

どうやら答えはこれっぽい。

// ---- src/app.module.ts ----
@Module({
  imports: [
    // ---- 中略 ----

    // ここ
    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      driver: ApolloDriver,
      useClass: GqlConfigService,
    }),

Nest.js の世界は「Module」という単位で色んなものをやり取りするみたいだ。

学習中に調べている中で分かりやすかったのが、GraphQL スターターパック | Prisma + NestJS + Next.JS 製 個人ブログサイトを Cloud Run で運用しよう内での説明。

方針も決まったので、サーバーを立てていきましょう。NestJS はインスタンスの管理に Modules という単位でまとめるアーキテクチャを採用しており、アプリケーションコードも外部ライブラリも、Modules という単位で提供することが基本方針です。こうすることで、

  • NestJS の世界で統一的にインスタンス管理できる
  • NestJS の世界でインターフェースを統一できる

というメリットがあります。GraphQL ライブラリもこの例に漏れず、Modules として提供されており…(以下略)

つまり Nest.js アプリケーションのルートモジュールである app.module.ts にコントローラやプロバイダーが登録されているのと同じように、@nestjs/graphql から提供された GraphQLModule の中のどこかに、エンドポイント「/graphql」に GraphQL サーバーを立ち上げるためのコードがあるのだろう。もちろん、そのほかの各種サービスや機能も。

それにしても上記のリンクの書籍、かなり良さそう。この辺りのこと学習したい人にはうってつけの気配がある。

GraphQL で動かすモジュールを一つ自作してみる

Web 掲示板をイメージした簡単な API を作ってみて、その過程で理解を深めていくよ。

掲示板のデータモデルを列挙すると……

- 掲示板(Bbs)
  - 掲示板のID
  - 掲示板の名前
  - 掲示板に立てられたスレッドのリスト

- スレッド(BbsThread)
  - スレッドのID
  - スレッド名
  - スレッドの説明
  - スレッドに投稿されたレスのリスト
  - スレッドを立てた掲示板

- レス(BbsThreadResponse)
  - レスのID
  - レスの内容
  - レスを投稿したユーザー
  - レスを投稿した日時
  - レスを投稿したスレッド

- ユーザー(BbsUser)
  - ユーザーのID
  - ユーザー名
  - ユーザーが投稿したレスのリスト

こんなところか。コードファーストのアプローチで、これらのデータを操作可能な API を作ってみるよ。

まず Nest CLI を入れよう。

npm i -g @nestjs/cli

そしてスターターキットのプロジェクトのルートディレクトリで、下記コマンドでモジュール雛形を生成する。

# bbsという名前のモジュールを生成
nest generate module bbs

すると app.module.ts に、下記のように bbs モジュールが追加されている。

import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GqlConfigService } from './gql-config.service';

// ▼ 自動で追加
import { BbsModule } from './bbs/bbs.module';

@Module({
  imports: [
    AuthModule,
    UsersModule,
    PostsModule,

    // ▼ 自動で追加
    BbsModule,
  ],
  controllers: [AppController],
  providers: [AppService, AppResolver],

さらに、BbsModuleの雛形コードも生成されている。

// ---- src/bbs/bbs.module.ts ----
import { Module } from '@nestjs/common';

@Module({})
export class BbsModule {}

次にモデル——厳密にはNest.jsにおけるObject Typeを定義する。

Nest.js公式はObject Typeについて次のように説明しているよ。

GraphQL スキーマのほとんどの定義はオブジェクト タイプです。定義する各オブジェクト タイプは、アプリケーション クライアントが対話する必要があるドメイン オブジェクトを表す必要があります。 (原文:Most of the definitions in a GraphQL schema are object types. Each object type you define should represent a domain object that an application client might need to interact with. )

https://docs.nestjs.com/graphql/resolvers#object-types

端的にいえば「Object Type」はschema.graphqlでいうところの「type」だ。ドメインオブジェクトであり、アプリケーションクライアントが対話する必要のあるものであり、レスポンスだ。

そういうわけだから掲示板、スレッド、レス、ユーザーの4種類のObject Typeを定義するよ。

// ---- src/bbs/models/bbs.model.ts ----
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { BbsThread } from './bbs-thread.model';

/**
 * 下記の構造を持つモデルを表す。
 * - 掲示板(Bbs)
 *   - 掲示板のID
 *   - 掲示板の名前
 *   - 掲示板に立てられたスレッドのリスト
 */
@ObjectType()
export class Bbs {
  @Field((type) => ID)
  bbsId: string;

  @Field((type) => String)
  bbsName: string;

  @Field((type) => [BbsThread], {
    nullable: true,
  })
  threads?: BbsThread[];
}
// ---- src/bbs/models/bbs-thread.model.ts ----
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { BbsThreadResponse } from './bbs-thread-response.model';
import { Bbs } from './bbs.model';

/**
 * 下記の構造を持つモデルを表す。
 * - スレッド(BbsThread)
 *   - スレッドのID
 *   - スレッド名
 *   - スレッドの説明
 *   - スレッドに投稿されたレスのリスト
 *   - スレッドを立てた掲示板
 */
@ObjectType()
export class BbsThread {
  @Field((type) => ID)
  threadId: string;

  @Field((type) => String)
  threadName: string;

  @Field((type) => String)
  description: string;

  @Field((type) => [BbsThreadResponse], {
    nullable: true,
  })
  responses?: BbsThreadResponse[];

  @Field((type) => Bbs)
  bbs: Bbs;
}
// ---- src/bbs/models/bbs-thread-response.model.ts ----
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { BbsThread } from './bbs-thread.model';
import { BbsUser } from './bbs-user.model';

/**
 * 下記の構造を持つモデルを表す。
 * - レス(BbsThreadResponse)
 *   - レスのID
 *   - レスの内容
 *   - レスを投稿したユーザー
 *   - レスを投稿した日時
 *   - レスを投稿したスレッド
 */
@ObjectType()
export class BbsThreadResponse {
  @Field((type) => ID)
  responseId: string;

  @Field((type) => String)
  content: string;

  @Field((type) => BbsUser)
  user: BbsUser;

  @Field((type) => Date)
  postedAt: Date;

  @Field((type) => BbsThread)
  thread: BbsThread;
}
// ---- src/bbs/models/bbs-user.model.ts ----
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { BbsThreadResponse } from './bbs-thread-response.model';

/**
 * 下記の構造を持つモデルを表す。
 * - ユーザー(BbsUser)
 *   - ユーザーのID
 *   - ユーザー名
 *   - ユーザーが投稿したレスのリスト
 */
@ObjectType()
export class BbsUser {
  @Field((type) => ID)
  userId: string;

  @Field((type) => String)
  userName: string;

  @Field((type) => [BbsThreadResponse], {
    nullable: true,
  })
  responses?: BbsThreadResponse[];
}

ObjectTypeはこれでOK。次はResolverを実装しよう。

GraphQLはREST APIと違って、グラフみたいにデータを取れる。

エンティティ間をIDをたどって何度もAPIをクエリせずとも、一度に必要な情報をグラフで辿って取得できる。——そのために必要なサーバー側の実装は、フィールド単位のリゾルバだ。Query単位のリゾルバだけではなく、GraphQLの要求クエリのフィールドを個別で解決するためのリゾルバを用意することで、関連エンティティを必要に応じて辿ったり、辿らなかったりできる。

説明するよりコードを見た方が早いよね。掲示板、スレッド、レス、そしてユーザーの合計4種類のリゾルバは次のように実装すればいい。(Service未実装だから、現状では返す値はダミーの値だよ)

// ---- src/bbs/bbs.resolver.ts ----
import {
  Args,
  ID,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { Bbs } from './models/bbs.model';

@Resolver((of) => Bbs)
export class BbsResolver {
  constructor() {
    // 今後Serviceを実装してコンストラクタに追加する
  }

  @Query((returns) => Bbs)
  async bbs(@Args('id', { type: () => ID }) id: string) {
    return {
      bbsId: id,
      bbsName: 'bbsName',
    };
  }

  @ResolveField()
  async threads(@Parent() bbs: Bbs) {
    // Parentの情報を元に、今後Serviceを使って情報を取得する
    return [
      {
        threadId: 'threadId',
        threadName: 'threadName',
        description: 'description',
      },
    ];
  }
}
// ---- src/bbs/bbs-thread.resolver.ts ----
import {
  Args,
  ID,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { BbsThread } from './models/bbs-thread.model';

@Resolver((of) => BbsThread)
export class BbsThreadResolver {
  constructor() {
    // 今後Serviceを実装してコンストラクタに追加する
  }

  @Query((returns) => BbsThread)
  async bbsThread(@Args('id', { type: () => ID }) id: string) {
    return {
      threadId: id,
      description: 'description',
      threadName: 'threadName',
    };
  }

  @ResolveField()
  async responses() {
    return [
      {
        responseId: 'responseId',
        content: 'content',
        postedAt: new Date(),
      },
    ];
  }

  @ResolveField()
  async bbs(@Parent() bbsThread: BbsThread) {
    // Parentの情報を元に、今後Serviceを使って情報を取得する
    return {
      bbsId: 'bbsId',
      bbsName: 'bbsName',
    };
  }
}
// ---- src/bbs/bbs-thread-response.resolver.ts ----
import {
  Args,
  ID,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { BbsThreadResponse } from './models/bbs-thread-response.model';

@Resolver((of) => BbsThreadResponse)
export class BbsThreadResponseResolver {
  constructor() {
    // 今後Serviceを実装してコンストラクタに追加する
  }

  @Query((returns) => BbsThreadResponse)
  async bbsThreadResponse(@Args('id', { type: () => ID }) id: string) {
    return {
      responseId: id,
      content: 'content',
      postedAt: new Date(),
    };
  }

  @ResolveField()
  async thread() {
    return {
      threadId: 'threadId',
      threadName: 'threadName',
      description: 'description',
    };
  }

  @ResolveField()
  async user(@Parent() bbsThreadResponse: BbsThreadResponse) {
    // Parentの情報を元に、今後Serviceを使って情報を取得する
    return {
      userId: 'userId',
      userName: 'userName',
    };
  }
}
// ---- src/bbs/bbs-user.resolver.ts ----
import {
  Args,
  ID,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { BbsUser } from './models/bbs-user.model';

@Resolver((of) => BbsUser)
export class BbsUserResolver {
  constructor() {
    // 今後Serviceを実装してコンストラクタに追加する
  }

  @Query((returns) => BbsUser)
  async bbsUser(@Args('id', { type: () => ID }) id: string) {
    return {
      userId: id,
      userName: 'userName',
    };
  }

  @ResolveField()
  async responses(@Parent() bbsUser: BbsUser) {
    // Parentの情報を元に、今後Serviceを使って情報を取得する
    return [
      {
        responseId: 'responseId',
        content: 'content',
        postedAt: new Date(),
      },
    ];
  }
}

Resolverはこんなところだね。ポイントは@ResolveFieldデコレータで、要求クエリのフィールド単位の解決を行っている点だね。BbsUserResolverで言えば、 「クエリがresponsesを要求してきた場合なら、@ResolveFieldのresponsesで解決&データを返す」 ことができる。

Resolverを実装したら、最後にこれを掲示板のモジュール——BbsModuleに登録しよう。

// ---- src/bbs/bbs.module.ts ----
import { Module } from '@nestjs/common';
import { BbsResolver } from './bbs.resolver';
import { BbsUserResolver } from './bbs-user.resolver';
import { BbsThreadResolver } from './bbs-thread.resolver';
import { BbsThreadResponseResolver } from './bbs-thread-response.resolver';

@Module({
  imports: [],
  providers: [
    BbsResolver,
    BbsUserResolver,
    BbsThreadResolver,
    BbsThreadResponseResolver,
  ],
})
export class BbsModule {}

これで完了。 npx nest start --watch でNest.jsアプリケーションを起動すれば、実装したObjectTypeとResolverに従い、schema.graphqlが自動生成される。

# ---- src/schema.graphql ----
type Query {
  bbs(id: ID!): Bbs!
  bbsThread(id: ID!): BbsThread!
  bbsThreadResponse(id: ID!): BbsThreadResponse!
  bbsUser(id: ID!): BbsUser!
}

type Bbs {
  bbsId: ID!
  bbsName: String!
  threads: [BbsThread!]
}

type BbsThread {
  bbs: Bbs!
  description: String!
  responses: [BbsThreadResponse!]
  threadId: ID!
  threadName: String!
}

type BbsThreadResponse {
  content: String!
  postedAt: DateTime!
  responseId: ID!
  thread: BbsThread!
  user: BbsUser!
}

type BbsUser {
  responses: [BbsThreadResponse!]
  userId: ID!
  userName: String!
}

そして、その定義通りにGraphQLサーバーを動かすことができるよ。localhost:3000/graphqlにアクセスすれば、GraphQL Playgroundが立ち上がるから、そこでクエリを試し投げできる。ちゃんと@ResolveFieldデコレータで実装した通り、グラフを辿ったデータ取得ができる。便利だよね。

Serviceは未実装だけど、Nest.js + GraphQLの基本的な動きはこんなところか。学習進めよっと。

次に学習したいことを列挙しておく。

  • スキーマのdescriptionを書いてみる
  • Mutationを作ってみる
  • ArgsTypeで入力の検証を行う
  • Serviceを実装して、prisma でDBとやり取りする
  • GraphQLエンドポイントを変えたり複数に増やしたりしてみる

長くなったから、続きは下記で書くよ。