Самым популярным серверным решением для приложений на 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
Установка зависимостей:
$ 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 автоматически сгенерирует всё необходимое в 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
После успешной сборки и запуска, можно пройти по адресу http://localhost:3000/graphql
,
где опробовать выполнение созданного нами query-запроса:
Поскольку пользователей в базе нет, ожидаемо увидеть пустой список.