Closed16

NestJS触ってみる

ゲントクゲントク

環境構築

.
├── app
│   ├── .gitignore
│   └── Dockerfile
├── docker-compose.yml
└── external
    └── db
        ├── .gitignore
        ├── Dockerfile
        └── data

# docker-compose.yml
version: "3.9"
services:
  app:
    build: ./app
    container_name: app-container
    tty: true
    working_dir: /__work/app
    volumes:
      - ./app:/__work/app
    ports:
      - 8080:8080

  db:
    build: ./external/db
    container_name: db-container
    working_dir: /__work/db
    volumes:
      - ./external/db/data:/var/lib/postgresql/data
    ports:
      - 5432:5432
# app/Dockerfile
FROM node:16

WORKDIR /__work

RUN yarn global add @nestjs/cli
# external/db/Dockerfile
FROM postgres:13.4

ENV POSTGRES_USER=user \
    POSTGRES_PASSWORD=password \
    TZ=Asia/Tokyo
docker-compose up -d
docker-compose exec app bash
nest new --skip-git プロジェクト名

わーっといろいろ作られる。
typescriptの設定、eslint/prettierの設定もしてくれてかなり嬉しかった。

app/crud/src/main.tsでポートが3000になっているので、8080に変更して、yarn startしたらhello worldできた

ゲントクゲントク

prismaのセットアップ

yarn add -D prisma
yarn add @prisma/client
yarn prisma init

shema.prismaなどが作られる
いっしょに作られた.envに、DBの情報を書く(書き方
ここでは、DBはデフォルトで作られてたpostgresを使う

DATABASE_URL="postgresql://user:password@db-container:5432/postgres"
ゲントクゲントク

schema書いてyarn prisma migrate devしようとしたところ

Error: Error in migration engine: Can't reach database server at `db-container`:`5432`

接続できない、、原因調査

ゲントクゲントク
db-container | 2021-11-13 18:10:11.887 JST [51] ERROR:  relation "_prisma_migrations" does not exist at character 126
db-container | 2021-11-13 18:10:11.887 JST [51] STATEMENT:  SELECT "id", "checksum", "finished_at", "migration_name", "logs", "rolled_back_at", "started_at", "applied_steps_count" FROM "_prisma_migrations" ORDER BY "started_at" ASC
ゲントクゲントク

今回の目的はNestJSを触ることなので、ちょっとこの問題からは逃げる
.envを

DATABASE_URL="postgresql://user:password@localhost:5432/postgres"

でlocalhostを見るようにして、ローカルからyarn prisma migrate devする
appコンテナーからdbコンテナーにprisma migrateしたかったけど

ゲントクゲントク

ExpressではなくFastifyを使用するように設定
よくわかってないがExpressよりめちゃ早いらしいので、設定も簡単だし、やっておく

yarn add @nestjs/platform-fastify
// main.ts
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.listen(8080);
}
bootstrap();
ゲントクゲントク

CRUD generator使ってみる

yarn nest g resource

taskディレクトリと、その中にmodule, controller, service, dto, entities, テストの雛形が作られた

ゲントクゲントク

単純なCRUDできるところまで作れた
nestの機能使っているところとそうでないところで分けたらよさそうだと思ったのでそうした

src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── domains
│   ├── task
│   │   ├── task.entity.ts
│   │   └── task.repository.interface.ts
│   └── usecases
│       ├── create-task.ts
│       ├── find-task.ts
│       ├── get-all-task.ts
│       ├── index.ts
│       ├── remove-task.ts
│       └── update-task.ts
├── infrastructure
│   └── prisma
│       └── repository
│           └── task.repository.ts
├── main.ts
├── nest-modules
│   └── task
│       ├── dto
│       │   ├── create-task.dto.ts
│       │   └── update-task.dto.ts
│       ├── task.controller.spec.ts
│       ├── task.controller.ts
│       ├── task.module.ts
│       ├── task.service.spec.ts
│       └── task.service.ts
└── prisma.service.ts
ゲントクゲントク

domains/task(ここはnest関係ない)

何にも依存していないtask.entity.ts
uuid作るのここでやってるけど、外に出したほうがいいかな?

import { v4 as uuid } from 'uuid';

interface CreateTask {
  title: string;
  content: string;
}

interface RebuildTask {
  id: string;
  title: string;
  content: string;
}

export class Task {
  private constructor(
    public readonly id: string,
    public readonly title: string,
    public readonly content: string,
  ) {}

  public static create = ({ title, content }: CreateTask) =>
    new Task(uuid(), title, content);

  public static rebuild = ({ id, title, content }: RebuildTask) =>
    new Task(id, title, content);

  public isEqualTo = (task: Task) => task.id === this.id;
}

データ永続化の抽象task.repository.interface.ts
後述のusecaseはこれを使って機能を書いていく

import { Task } from './task.entity';

export const TASK_REPOSITORY = 'TASK_REPOSITORY';

export interface ITaskRepository {
  create: (task: Task) => Promise<void>;
  findOne: (id: string) => Promise<Task | null>;
  getAll: () => Promise<Task[]>;
  update: (task: Task) => Promise<void>;
  remove: (id: string) => Promise<void>;
}
ゲントクゲントク

domains/usecase(ここもnest関係ない)
repositoryを受け取って、それを使ってアプリケーションの機能(usecase)を実現する
適当にthrow new Errorしてしまっているが、nestでエラーcatchするときにerror.message ===とかしなくちゃいけないので、本当はカスタムエラー作ったほうがいいと思う

例としてupdate-task.ts

import { Task } from '../task/task.entity';
import { ITaskRepository } from '../task/task.repository.interface';

interface IProps {
  id: string;
  title?: string;
  content?: string;
}

export const updateTask =
  (repository: ITaskRepository) =>
  async ({ id, title, content }: IProps) => {
    const storedTask = await repository.findOne(id);

    if (storedTask === null) {
      throw new Error('task not found');
    }

    const task = Task.rebuild({
      id,
      title: title ?? storedTask.title,
      content: content ?? storedTask.content,
    });

    await repository.update(task);
  };
ゲントクゲントク

nest-modules/task
dto
class-validatorを使って、リクエストボディのバリデーションを書いている

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class UpdateTaskDto {
  @IsNotEmpty()
  @IsString()
  id: string;

  @IsOptional()
  @IsString()
  title?: string;

  @IsOptional()
  @IsString()
  content?: string;
}
ゲントクゲントク

nest-modules/task
task.controller.ts
リクエストをdtoとして受け取って、serviceを実行する
エラーはここでキャッチして、HttpExceptionにする

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { TaskService } from './task.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';

@Controller('task')
export class TaskController {
  constructor(private readonly taskService: TaskService) {}

  @Post()
  async create(@Body() createTaskDto: CreateTaskDto) {
    try {
      return await this.taskService.create(createTaskDto);
    } catch (error) {
      throw new HttpException(
        { status: HttpStatus.INTERNAL_SERVER_ERROR, error: 'something wrong' },
        500,
      );
    }
  }

  @Get()
  async findAll() {
    try {
      return await this.taskService.getAll();
    } catch {
      throw new HttpException(
        { status: HttpStatus.INTERNAL_SERVER_ERROR, error: 'something wrong' },
        500,
      );
    }
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    try {
      return await this.taskService.findOne(id);
    } catch (error) {
      if (error instanceof Error && error.message === 'task not found') {
        throw new HttpException(
          { status: HttpStatus.NOT_FOUND, error: `task not found(id: ${id})` },
          404,
        );
      } else {
        throw new HttpException(
          {
            status: HttpStatus.INTERNAL_SERVER_ERROR,
            error: 'something wrong',
          },
          500,
        );
      }
    }
  }

  @Patch()
  async update(@Body() updateTaskDto: UpdateTaskDto) {
    try {
      return await this.taskService.update(updateTaskDto);
    } catch (error) {
      if (error instanceof Error && error.message === 'task not found') {
        throw new HttpException(
          {
            status: HttpStatus.NOT_FOUND,
            error: `task not found(id: ${updateTaskDto.id})`,
          },
          404,
        );
      } else {
        throw new HttpException(
          {
            status: HttpStatus.INTERNAL_SERVER_ERROR,
            error: 'something wrong',
          },
          500,
        );
      }
    }
  }

  @Delete(':id')
  async remove(@Param('id') id: string) {
    try {
      return await this.taskService.remove(id);
    } catch (error) {
      if (error instanceof Error && error.message === 'task not found') {
        throw new HttpException(
          {
            status: HttpStatus.NOT_FOUND,
            error: `task not found(id: ${id})`,
          },
          404,
        );
      } else {
        throw new HttpException(
          {
            status: HttpStatus.INTERNAL_SERVER_ERROR,
            error: 'something wrong',
          },
          500,
        );
      }
    }
  }
}
ゲントクゲントク

ここで引っかかったこと

updateのtitleとかcontentをオプショナルにしたはずなのに、string | undefinedではなくstringになってしまう

自動生成されたtsconfigが緩かった

  • strict: trueにしてnoImplicitAny: falsestrictNullChecks: falseを外した
  • dtoでclassをinterfaceっぽく扱っているので、"strictPropertyInitialization": false,は入れないといけない

HttpExceptionを拾ってもらうために、main.tsにpipeを設定する必要がある

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(8080);
}
bootstrap();
ゲントクゲントク

nest-modules/task
task.service.ts
usecaseにrepositoryを食わせて、呼ぶだけ

import { Inject, Injectable } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import {
  ITaskRepository,
  TASK_REPOSITORY,
} from 'src/domains/task/task.repository.interface';
import * as Usecase from 'src/domains/usecases';

@Injectable()
export class TaskService {
  public constructor(
    @Inject(TASK_REPOSITORY)
    private readonly taskRepository: ITaskRepository,
  ) {}

  public create = async (createTaskDto: CreateTaskDto) => {
    const { title, content } = createTaskDto;

    await Usecase.createTask(this.taskRepository)({ title, content });
  };

  public findOne = async (id: string) =>
    await Usecase.findTask(this.taskRepository)({ id });

  public getAll = async () => await Usecase.getAllTask(this.taskRepository)();

  public update = async (updateTaskDto: UpdateTaskDto) => {
    const { id, title, content } = updateTaskDto;

    await Usecase.updateTask(this.taskRepository)({ id, title, content });
  };

  public remove = async (id: string) => {
    await Usecase.removeTask(this.taskRepository)({ id });
  };
}

modulesからrepositoryをDIするが、interfaceだとうまくいかないっぽく、token的なもの(TASK_REPOSITORY)を設定してあげる必要がある
https://github.com/nestjs/nest/issues/43#issuecomment-300092490

ゲントクゲントク

ここまでやったところでアプリのだいたいの動きは書けた状態
最後に、データ永続化の具象をprismaで実装して、task.module.tsに設定してDIする

infrastructure/prisma/repository
task.repository.ts

import { Injectable } from '@nestjs/common';
import { Task } from 'src/domains/task/task.entity';
import { ITaskRepository } from 'src/domains/task/task.repository.interface';
import { PrismaService } from 'src/prisma.service';

@Injectable()
export class TaskRepository implements ITaskRepository {
  public constructor(private readonly prismaService: PrismaService) {}

  public create = async (task: Task) => {
    const { id, title, content } = task;
    await this.prismaService.task.create({
      data: {
        id,
        title,
        content,
      },
    });
  };

  public findOne = async (id: string) => {
    const task = await this.prismaService.task.findUnique({
      where: {
        id,
      },
    });

    return task === null
      ? null
      : Task.rebuild({
          id: task.id,
          title: task.title,
          content: task.content ?? '',
        });
  };

  public getAll = async () => {
    const taskList = await this.prismaService.task.findMany();

    return taskList.map((task) =>
      Task.rebuild({
        id: task.id,
        title: task.title,
        content: task.content ?? '',
      }),
    );
  };

  public update = async (task: Task) => {
    await this.prismaService.task.update({
      where: {
        id: task.id,
      },
      data: {
        title: task.title,
        content: task.content,
      },
    });
  };

  public remove = async (id: string) => {
    await this.prismaService.task.delete({
      where: {
        id,
      },
    });
  };
}

(serviceにもあったが書いてなかったので)クラスに@Injectable()デコレータをつけてあげると、moduelsでDIできるようになる

repositoryで使っているprismaServiceはこう
prisma.service.ts

import { PrismaClient } from '.prisma/client';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  onModuleInit = async () => await this.$connect();
  onModuleDestroy = async () => await this.$disconnect();
}
ゲントクゲントク

nest-modules/task
task.module.ts

DI

import { Module } from '@nestjs/common';
import { TaskService } from './task.service';
import { TaskController } from './task.controller';
import { PrismaService } from 'src/prisma.service';
import { TaskRepository } from 'src/infrastructure/prisma/repository/task.repository';
import { TASK_REPOSITORY } from 'src/domains/task/task.repository.interface';

@Module({
  controllers: [TaskController],
  providers: [
    TaskService,
    {
      useClass: TaskRepository,
      provide: TASK_REPOSITORY,
    },
    PrismaService,
  ],
})
export class TaskModule {}
このスクラップは2021/11/19にクローズされました