はじめに
NestJSの公式ドキュメントにMVCパターンでの開発方法について書かれていますが、テンプレートエンジン(hbs)のセットアップ方法ぐらいしか載ってなかったので、Mode/View/Controllerの実装を一通りやってみました。
プロジェクト作成
npm i -g @nestjs/cli
nest new project
⚡ We will scaffold your app in a few seconds..
? Which package manager would you ❤️ to use? pnpm
CREATE project/.eslintrc.js (663 bytes)
CREATE project/.prettierrc (51 bytes)
CREATE project/README.md (4376 bytes)
CREATE project/nest-cli.json (171 bytes)
CREATE project/package.json (1946 bytes)
CREATE project/tsconfig.build.json (97 bytes)
CREATE project/tsconfig.json (546 bytes)
CREATE project/src/app.controller.ts (274 bytes)
CREATE project/src/app.module.ts (249 bytes)
CREATE project/src/app.service.ts (142 bytes)
CREATE project/src/main.ts (208 bytes)
CREATE project/src/app.controller.spec.ts (617 bytes)
CREATE project/test/jest-e2e.json (183 bytes)
CREATE project/test/app.e2e-spec.ts (630 bytes)
✔ Installation in progress... ☕
🚀 Successfully created project project
👉 Get started with the following commands:
$ cd project
$ pnpm run start
プロジェクトへ移動pnpm run start
を実行して、localhost:3000へアクセスしてみるとシンプルな「Hello World!」とだけのページが表示されました。
Model
NestJSではTypeORMを推してるように見えましたが、昨今の状況をみるとPrismaの方がエコシステムが揃っているような感じがするので、Prismaを採用します。
npx prisma init
データベースへ接続する前にデータベースがないのでPostgreSQL環境を用意します。
compose.yamlを作成します。
services:
postgresql:
image: postgres:16
environment:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
起動します。
docker compose up -d
次に.envを開いて DATABASE_URL
を設定します。
DATABASE_URL="postgresql://postgres:@127.0.0.1:5432/postgres?schema=public"
これで接続ができるはずなので、テーブルを作ってみます。
schema.prismaを更新します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
}
マイグレーションファイルの作成と実行をします。
npx prisma migrate dev --name init
テーブルが作られているかPrisma Studioを使って確認します。
npx prisma studio
起動してPostのテーブルができていたらOKです。
次にPrisma Clientをインストールします。
pnpm add @prisma/client
Prisma Clientを利用するためのサービスを作成します。
nest generate service prisma
src/prisma/prisma.service.ts:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
次はPostモデルを扱うサービスを作成します。
nest generate service post
src/post/post.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Post, Prisma } from '@prisma/client';
@Injectable()
export class PostService {
constructor(private prisma: PrismaService) {}
async post(
postWhereUniqueInput: Prisma.PostWhereUniqueInput,
): Promise<Post | null> {
return this.prisma.post.findUnique({
where: postWhereUniqueInput,
});
}
async posts(params: {
skip?: number;
take?: number;
cursor?: Prisma.PostWhereUniqueInput;
where?: Prisma.PostWhereInput;
orderBy?: Prisma.PostOrderByWithRelationInput;
}): Promise<Post[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.post.findMany({
skip,
take,
cursor,
where,
orderBy,
});
}
async createPost(data: Prisma.PostCreateInput): Promise<Post> {
return this.prisma.post.create({
data,
});
}
}
Controller
次にコントローラを用意してルーティングとリクエスト処理を書きます。
nest generate controller post
import {
Controller,
Get,
Param,
Post,
Body,
Render,
Res,
} from '@nestjs/common';
import { PostService } from './post.service';
import { Post as PostModel } from '@prisma/client';
import { Response } from 'express';
@Controller('posts')
export class PostController {
constructor(private readonly postService: PostService) {}
@Get()
@Render('posts/index')
async getPosts(): Promise<{ posts: PostModel[] }> {
const posts = await this.postService.posts({});
return {
posts,
};
}
@Post()
async create(
@Body() postData: { title: string; content?: string },
@Res() res: Response,
): Promise<void> {
const { title, content } = postData;
await this.postService.createPost({
title,
content,
});
return res.redirect('/posts');
}
@Get('new')
@Render('posts/new')
async new(): Promise<void> {}
@Get(':id')
@Render('posts/show')
async getPostById(@Param('id') id: string): Promise<{ post: PostModel }> {
const post = await this.postService.post({ id: Number(id) });
return {
post,
};
}
}
これで以下ルーティングが作られました。
- GET /posts Post一覧画面
- POST /posts Post作成処理
- GET /posts/new Post新規作成画面
- GET posts/:id Post詳細画面
本来ならPost作成時にはバリデーションを挟むと思いますが、今回は触れません。 詳しくはドキュメントを参考にしてください。
View
テンプレートエンジンはhbsを使うようです。
pnpm add hbs
main.tsを編集します。
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(join(__dirname, '..', 'public'));
app.setBaseViewsDir(join(__dirname, '..', 'views'));
app.setViewEngine('hbs');
await app.listen(3000);
}
bootstrap();
useStaticAssets
で静的アセットを置く場所を指定、 setBaseViewsDir
でビューファイルを置く場所を指定しているようです。
その後、 views
ディレクトリを作成し、その下にindex.hbsファイルを作成して一覧画面を作成します。
<html>
<head>
<meta charset='utf-8' />
<title>App</title>
</head>
<body>
<h2>Posts</h2>
<a href='/posts/new'>New Post</a>
<ul>
{{#each posts}}
<li>
<a href='/posts/{{id}}'>{{title}}</a>
</li>
{{/each}}
</ul>
</body>
</html>
後は詳細画面(views/posts/show.hbs)と新規登録画面(views/posts/new.hbs)を用意します。
show.hbs:
<html>
<head>
<meta charset='utf-8' />
<title>App</title>
</head>
<body>
<h2>{{post.title}}</h2>
<a href='/posts'>Back</a>
<p>{{post.content}}</p>
</body>
</html>
new.hbs:
<html>
<head>
<meta charset='utf-8' />
<title>App</title>
</head>
<body>
<h2>Create Post</h2>
<a href='/posts'>Back</a>
<form action='/posts' method='post'>
<label for='title'>Title</label>
<input type='text' name='title' />
<br />
<label for='content'>Content</label>
<textarea name='content'></textarea>
<br />
<input type='submit' value='Create' />
</form>
</body>
</html>
次にコントローラを作成します。
nest generate controller post
post.controller.ts:
import {
Controller,
Get,
Param,
Post,
Body,
Render,
Res,
} from '@nestjs/common';
import { PostService } from './post.service';
import { Post as PostModel } from '@prisma/client';
import { Response } from 'express';
@Controller('posts')
export class PostController {
constructor(private readonly postService: PostService) {}
@Get()
@Render('posts/index')
async getPublishedPosts(): Promise<{ posts: PostModel[] }> {
const posts = await this.postService.posts({});
return {
posts,
};
}
@Post()
async createDraft(
@Body() postData: { title: string; content?: string },
@Res() res: Response,
): Promise<void> {
const { title, content } = postData;
await this.postService.createPost({
title,
content,
});
return res.redirect('/posts');
}
@Get('new')
@Render('posts/new')
async createPost(): Promise<void> {}
@Get(':id')
@Render('posts/show')
async getPostById(@Param('id') id: string): Promise<{ post: PostModel }> {
const post = await this.postService.post({ id: Number(id) });
return {
post,
};
}
}
ここでlocalhost:3000/posts
へアクセスしてみるとデータの閲覧と登録ができるようになってると思います。
ここも本来Postの新規作成フォームではCSRF対策などを入れる必要があると思います。
導入には別途パッケージのインストールが必要になってきますが、Expressの場合に必要なcsurfがdeprecatedになっているので、他を探すかFastifyにするのが良さそうです...
所感
NestJSはバックエンドがメインの一面が強いため、あまりMVCパターンには向いてない感触を得ました。
ビューにはhbsを導入することでコントローラからデータを受け取ってテンプレートエンジンで画面描画するまではできますが、Ruby on RailsやLaravelのようにセットアップ不要で使えたり、ヘルパーメソッドが充実していたりなどサポートは手厚くはありませんでした。
やはり、バックエンドはNestJS、フロントエンドにはNext.jsなどを使うような構成が良さそうには感じました。
またカスタマイズできる(HTTPサーバーをExpressかFastify選べたり、ORMもTypeORMやPrismaか選べるなど)のがメリットでもありますが、その一方で色々自分でセットアップしなければならないところ(Hot reload、CSRF対策、暗号化、ハッシュ化などが自前でセットアップ必要だった)もありました。
そのため、個人的にはシンプルなCRUDアプリを効率よく開発するというよりは大規模なものに向いてそうな感じがしました。
ソースコード
今回のソースコードは公開していますので、よろしければ参考にしてみてください。