Nestjs быстрый старт

Самым популярным серверным решением для приложений на Node.js является Express. Это минималистичный фреймворк, сравнимый с Sinatra из мира ruby или Flask из мира python. Но... есть одно «но»: слишком много кода приходится писать руками, нет единой продуманной архитектуры.

А когда хочется всего этого «из коробки» приходит Nest. По-умолчанию он использует всё тот же Express внутри себя. И, вдохновлённый Angular, даёт сверху много нужных плюшек.

План на сегодня: настроить окружение и поднять в docker сам Nestjs + Postgres в качестве базы данных. Как, возможно, уже догадался читатель, эта заметка вводная. В дальнейших планах рассказать про работу с TypeORM, валидацию и тестирование. В целом, у Nest отличная документация, поэтому рассмотрен он будет коротко и на живых примерах. Для лёгкого старта: чтобы затронуть вещи, которые не слишком подробно раскрыты в официальной доке.

Используемые технологии

  • GraphQL — синтаксис, который описывает как запрашивать данные. Имеет несколько практических реализаций.
  • PostgreSQL — одна из баз данных. Исходим из предположения, что наши данные будут в основном отдаваться на чтение. Для чтения postgres хороша, но выбирайте из своих нужд.
  • TypeORM — ORM для множества баз данных с поддержкой TypeScript. Это чтобы не писать сырые запросы руками.
  • Docker — программная платформа для быстрой разработки, тестирования и развертывания приложений.

Nestjs

Установка nest и генерация нового приложения:

$ yarn add global nestjs
$ nest new nest-api
$ cd nest-api
nest_cli

Установка зависимостей:

$ yarn add @nestjs/config @nestjs/graphql \
  @nestjs/platform-fastify @nestjs/typeorm \
  apollo-server-fastify graphql typeorm pg

Забрать приложение docker-desktop можно здесь.

Конфигурация

Следуя хорошим практикам, переменные окружения станем брать из .env-файла. Для этого нужно создать файл конфигурации, который позволит получать эти переменные динамически. Плагин ApolloServerPluginLandingPageLocalDefault не является обязательным: он предоставляет более удобный интерфейс для GraphQL-запросов.

Nest позволяет выбрать стиль написания кода: scheme-first или code-first. В первом случае схема GraphQL пишется руками, а типы TypeScript генерируются автоматически. Во втором — наоборот, схему вручную не пишем. Здесь выбран второй вариант. Название файла схемы указывается в конфиге autoSchemaFile.

// src/config.ts
import { join } from 'path';
import { ApolloServerPluginLandingPageLocalDefault } from 'apollo-server-core';

export default (): any => ({
  envFilePath: `.env.${process.env.MODE}`,
  database: {
    type: 'postgres',
    host: 'postgres', // так будет назван docker-контейнер! при обычном запуске указать 127.0.0.1
    port: process.env.POSTGRES_PORT,
    username: process.env.POSTGRES_USER,
    password: process.env.POSTGRES_PASSWORD,
    database: process.env.POSTGRES_DB,
    entities: [join(__dirname, '**', '*.entity.{ts,js}')],
    migrations: [join(__dirname, '**', '*.migration.{ts,js}')],
    synchronize: process.env.MODE != 'production',
  },
  gql: {
    playground: false,
    plugins:
      process.env.MODE == 'production'
      ? []
      : [ApolloServerPluginLandingPageLocalDefault()],
    autoSchemaFile: 'schema.gql',
  },
});

В корне проекта создать один или несколько .env-файлов с переменными окружения. Настраиваем dev-окружение, поэтому для примера приводится файл .env.development:

POSTGRES_DB=nestjs
POSTGRES_USER=nestjs
POSTGRES_PASSWORD=fRzYg8Vq&w8b
POSTGRES_PORT=5432
MODE=development

Чтобы Nest читал переменные из env, в src/app.module.ts включим глобально ConfigModule и передадим для загрузки наш конфиг.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppService } from './app.service';
import { AppController } from './app.controller';
import config from './config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [config],
    }),
    TypeOrmModule.forRootAsync({ useFactory: () => config().database }),
    GraphQLModule.forRootAsync({ useFactory: () => config().gql }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Наконец, в main.ts укажем использование Fastify вместо Express. По словам его разработчиков (что подтверждают и ребята из Nest) Fastify гораздо быстрее своего собрата.

// src/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(),
  );
  // nest_api имя контейнера, если его не задать по http://localhost:3000
  // достучаться к приложению будет нереально
  await app.listen(3000, 'nest_api');
}
bootstrap();

Логика

Для GraphQL сгенерируем сущность, называемую в терминологии Nest ресурсом.

$ nest g resource users
"Nest resource"

Nest автоматически сгенерирует всё необходимое в src/user и подключит модуль в начальную точку приложения: src/app.module.ts.

Для проверки запросов создадим простой resolver и подключим модель User к TypeORM. Изменения будут в следующих файлах:

// src/users/dto/create-user.input.ts
import { InputType, Field } from '@nestjs/graphql';

@InputType()
export class CreateUserInput {
  // Field это поле для GraphQL
  // Если его не поставить, поле name не будет видно на стороне клиента!
  @Field(() => String)
  name: string;
}

Модель таблицы базы данных:

// src/users/entities/user.entity.ts
import { ObjectType, Field, Int } from '@nestjs/graphql';
import { Column, PrimaryGeneratedColumn, Entity } from 'typeorm';

@ObjectType()
// users - название таблицы в базе, можно назвать как угодно
@Entity({ name: 'users' })
export class User {
  @Field(() => Int)
  @PrimaryGeneratedColumn()
  id: number;

  @Field(() => String)
  @Column()
  name: string;
}

Модуль со всеми зависимостями ресурса users:

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersResolver } from './users.resolver';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersResolver, UsersService],
})
export class UsersModule {}

Сервис (логика модуля):

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  // получить доступ к методам TypeORM для User
  constructor(@InjectRepository(User) private readonly repository: Repository<User>) {}

  // найти в базе и вернуть список пользователей
  async findAll() {
    return await this.repository.find();
  }
}

Resolver (примерно как роутер в REST):

// src/users/users.resolver.ts
import { Resolver, Query } from '@nestjs/graphql';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

@Resolver(() => User)
export class UsersResolver {
  // доступ к сервису
  constructor(private readonly usersService: UsersService) {}

  // query-запрос вернёт список сущностей типа User
  // это для GraphQL на клиенте, примерный аналог GET-запроса
  @Query(() => [User], { name: 'users' })
  findAll() {
    // обращение к методу findAll из сервиса
    return this.usersService.findAll();
  }
}

Docker

Пришло время упаковать всё в контейнер. Для nest будет ручная сборка через Dockerfile, остальные образы берутся готовыми из Docker Hub.

Dockerfile

В корне проекта создать новую директорию .docker, где будут лежать скрипты и файл сборки nest. В ней Dockerfile со следующим содержимым:

# образ для development
FROM node:16.13.2-alpine AS development

# Создать директорию внутри контейнера
WORKDIR ./app

# Установить зависимости
COPY package*.json ./
RUN npm i -g @nestjs/cli
RUN npm install

# Скопировать приложение из текущей директории в WORKDIR-директорию
COPY . .

# Скомпилировать приложение
RUN npm run build

# образ для production по той же схеме
FROM node:16.13.2-alpine AS production

ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

WORKDIR ./app

COPY package*.json ./
RUN npm install --only=production
COPY . .
COPY --from=development ./app/dist ./dist

CMD ["node", "dist/main"]

И там же скрипт инициализации базы данных: он создаст новую базу и пользователя для неё со всеми привилегиями.

#!/bin/bash
# .docker/init-user-db.sh
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE USER nestjs;
    CREATE DATABASE nestjs;
    GRANT ALL PRIVILEGES ON DATABASE nestjs TO nestjs;
EOSQL

docker-compose

В корне проекта docker-compose.dev.yml:

# docker-compose.dev.yml
version: '3'

services:
  # postgres
  db:
    image: postgres:14.1-alpine
    restart: unless-stopped
    container_name: postgres
    env_file: .env.development # какой env-файл использовать
    volumes:
      - ./.docker/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh:ro
      # если нужен дамп реальной базы вместо скрипта указать его
      # - ./.docker/db.sql:/docker-entrypoint-initdb.d/db.sql
      - pg_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  # удобный веб-интерфейс для баз данных
  adminer:
    image: adminer
    restart: unless-stopped
    container_name: adminer
    ports:
      - "8080:8080"

  # nestjs
  nest_api:
    container_name: nest_api
    image: nest-api:1.0.0
    build:
      context: .              # контекст сборки, для нас это корень проекта
      target: development     # точка из Dockerfile
      dockerfile: .docker/Dockerfile
    command: npm run start:dev # запуск команды nestjs для разработки
    env_file: .env.development
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    restart: unless-stopped
    depends_on: # ждёт запуска базы
      - db

volumes:
  pg_data:

При желании по аналогии с docker-compose.dev.yml можно сделать такой же файл конфигурации для боевой среды.

Ну, и дабы не копировать node_modules в контейнер, создадим в корне файл .dockerignore:

node_modules

Осталось собрать образ для nest и запустить окружение в Docker:

$ docker-compose -f docker-compose.dev.yml build
$ docker-compose -f docker-compose.dev.yml up -d
"Build"

После успешной сборки и запуска, можно пройти по адресу http://localhost:3000/graphql, где опробовать выполнение созданного нами query-запроса:

"GraphQL"

Поскольку пользователей в базе нет, ожидаемо увидеть пустой список.