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エンドポイントを変えたり複数に増やしたりしてみる
長くなったから、続きは下記で書くよ。