NestJSをMVCパターンでアプリ構築してみる

Sep 7, 2024

はじめに

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アプリを効率よく開発するというよりは大規模なものに向いてそうな感じがしました。

ソースコード

今回のソースコードは公開していますので、よろしければ参考にしてみてください。