NestJS + Prisma 增删改查实践

1,982 阅读3分钟

在用腻了 egg.js 之后,开始尝试一下 NestJS吧!

官网链接:NestJSPrisma

当前版本

node: 20.11.0 @nestjs/cli: 10.3.2 prisma: 5.18.0

初始化项目

Set up Prisma NestJS官网

安装NestJS

npm i -g @nestjs/cli
nest new project-name
cd project-name

这里需要选择 package manager,本文是用 npm 作为包管理器的

安装Prisma

npm i prisma --save-dev
npx prisma init

执行完上面两条命令之后,会发现多了一个 prisma 的文件夹和 .env 文件

  • prisma : 指定数据库连接,以及数据库 `schema
  • .env: 用来配置环境变量

在VSCODE安装一下prisma插件,这样prisma.schema就可以高亮和格式化了

接下来修改一下连接数据库的配置,本文连接的是 MySQL,其他数据库可以去官方文档进行查看。 Prisma连接关系型数据库

先修改 .env 的环境变量

// 数据库类型://用户名:密码@ip:端口/数据库名
DATABASE_URL="mysql://root:123456@localhost:3306/demo"

然后修改 prisma/prisma.schema,建立一个User表。

prisma.schema
generator client {
	provider = "prisma-client-js"
}

datasource db {
	provider = "mysql"
	url = env("DATABASE_URL")
}

model User {
	id         Int        @id @default(autoincrement())
	name       String     @unique
	password   String
	avatar     String?
	createAt   DateTime   @default(now())
	updateAt   DateTime   @updatedAt
}

配置好 prisma.schema 之后,执行下面的命令来修改数据库。每次执行 prisma 都会生成一个变更记录,可以在 prisma/migrations 文件夹下面查看。

npx prisma migrate dev --name init

执行完之后,再去查看数据库结构,User 表已经建立好了。到这里,开发环境就算搭建好了。

Pasted image 20240827110543.png

prisma.service.ts

prisma 注入到 NestJS,需要在 src 目录下面建一个新文件

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
  
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
	async onModuleInit() {
		await this.$connect();
	}
}

快速生成增删改查代码

NestJS 提供了快速生成增删改查的工具,可以通过 nest -h 来查看详细指令

// g = generate res=resource
nest g res user /module

执行完之后可以看到,在 src 下面多了 src/module/user 文件夹。

如果有修改过 src/app.module.ts 的文件结构, 先修改下 src/app.module.ts 文件,把 user 模块引入进来。

如果没有,NestJS 会自动帮你引入进来。

下面是实现增删改查的必要文件

user.module.ts

需要把 prismaService 放入 providers 中注入

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
// 需要引入prismaService
import { PrismaService } from '../../prisma.service';

@Module({
	controllers: [UserController],
	providers: [UserService, PrismaService],
})

export class UserModule {}
dto/create-user.dto.ts
export class CreateUserDto {
	name: string;
	password: string;
	avatar?: string;
	id?: number;
}
user.controller.ts
import {
	Controller,
	Get,
	Post,
	Body,
	Patch,
	Param,
	Delete,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { Prisma } from '@prisma/client';

@Controller('user')
export class UserController {
	constructor(private readonly userService: UserService) {}

	@Post()
	create(@Body() createUserDto: CreateUserDto) {
		return this.userService.createUser(createUserDto);
	}
	
	@Get()
	findAll(params: {
		skip?: number;
		take?: number;
		cursor?: Prisma.UserWhereUniqueInput;
		where?: Prisma.UserWhereInput;
		orderBy?: Prisma.UserOrderByWithRelationInput;
	}) {
		return this.userService.users(params);
	}

	@Get(':id')
	findOne(@Param('id') id: string) {
		return this.userService.user({ id: +id });
	}

	@Patch(':id')
	update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
		return this.userService.updateUser({
			where: { id: +id },
			data: updateUserDto,
		});
	}


	@Delete(':id')
	remove(@Param('id') id: string) {
		return this.userService.deleteUser({ id: +id });
	}
}
user.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma.service';
import { User, Prisma } from '@prisma/client';

@Injectable()
export class UserService {
	constructor(private prisma: PrismaService) {}
	
	async user(
		userWhereUniqueInput: Prisma.UserWhereUniqueInput,
	): Promise<User | null> {
		return this.prisma.user.findUnique({
			where: userWhereUniqueInput,
			});
	} 
	
	async users(params: {
		skip?: number;
		take?: number;
		cursor?: Prisma.UserWhereUniqueInput;
		where?: Prisma.UserWhereInput;
		orderBy?: Prisma.UserOrderByWithRelationInput;
	}): Promise<User[]> {
		const { skip, take, cursor, where, orderBy } = params;
		return this.prisma.user.findMany({
			skip,
			take,
			cursor,
			where,
			orderBy,
		});
	}
	
	
	async createUser(data: Prisma.UserCreateInput): Promise<User> {
		return this.prisma.user.create({
			data,
		});
	}
	
	async updateUser(params: {
		where: Prisma.UserWhereUniqueInput;
		data: Prisma.UserUpdateInput;
	}): Promise<User> {
		const { where, data } = params;
		return this.prisma.user.update({
			data,
			where,
		});
	}
	
	async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
		return this.prisma.user.delete({
			where,
		});
	}
}

最后执行 npm run start:dev 就可以启动了,默认端口3000。

npm run start:dev

开启Swagger、开启CORS

NestJS + Swagger 官网

安装 @nestjs/swagger

npm i --save @nestjs/swagger

NestJS 中,只需要在 main.js 中配置几行就能启用。

// main.ts

import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

// ...

// swagger
const config = new DocumentBuilder()
	.setTitle('NestjsAPI')
	.setDescription('The Nestjs API description')
	.setVersion('1.0')
	.addBearerAuth()
	.build();

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('swaggerApiDocs', app, document);

配置好 main.ts 之后,执行 npm run start:dev。等服务启动完访问 http://localhost:3000/swaggerApiDocs#/ 就可以看到 swagger 的页面了。

Pasted image 20240830111947.png

但是这里还是写着default,且没有任何注释说明。可以在 user.controller.ts 中通过 ApiTagsApiOperation 给每个模块和接口定义备注。刷新一下 swagger 的页面就能看到备注已经生效了。

// user.controller.ts
// ...
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@ApiTags('User')
@Controller('user')
export class UserController {

	// ...

	@ApiOperation({ summary: 'Create user' })
	@Post()
	create(@Body() createUserDto: CreateUserDto) {
		return this.userService.createUser(createUserDto);
	}
	
	// ....
}

使用 swagger 来进行调试,会发现请求的参数是空,每次调用都需要手动输入参数。这时候可以配置 dto/create-user.dto.ts 来告诉 swagger,传参的格式是什么。配置完后,再刷新 swagger ,就能看到 Request body 里面的参数了。

// dto/create-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
	@ApiProperty({ required: true })
	name: string;
	
	@ApiProperty({ required: true })
	password: string;
	
	@ApiProperty()
	avatar?: string;
	
	@ApiProperty()
	id?: number;
}

CORS NestJS官网

如果进行前后端联调,需要开启 CORS,开启 CORS 的方式也很简单,只需要在 main.ts 增加下面的配置就行了。

// main.ts 开启cors

app.enableCors({
	origin: true,
	methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
	allowedHeaders: 'Content-Type, Accept, Authorization',
	credentials: true,
});

环境变量、连接Redis

安装prisma的时候会生成 .env 文件,这里用 dotenv 的方式来实现环境变量的方法。\

npm i --save @nestjs/config

然后可以定义属于你的环境变量文件,比如说 .env.production

新建一个 .env.production 文件来实验一下,加一个TEST_ENV 作为变量

// .env.production
// 修改成生产的数据库连接url
DATABASE_URL="mysql://root:123456@localhost:3306/demo"

TEST_ENV="production"

同时增加 .env.test 文件

// .env.test
DATABASE_URL="mysql://root:123456@localhost:3306/demo"

TEST_ENV="test"

安装 dotenv-cli

npm i --save-dev dotenv-cli

修改package.json, 让启动命令增加dotenv来制定要加载的命令

{
	"scripts": {
		"build": "nest build",
		"start:dev": "dotenv -e .env.test -- nest start --watch",
		"start:prd": "dotenv -e .env.production -- node dist/main",
	},
}

需要在 app.module.ts 中,引入 ConfigModule 并将其配置成全局共享。这里同时展示一下,如何连接 Redis。连接 Redis 需要先安装 ioredis 包。

npm i ioredis
// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisModule } from '@nestjs-modules/ioredis';

@Module({
	imports: [
		ConfigModule.forRoot({
			// 全局配置,其他模块读取的环境变量
			isGlobal: true,
			// 靠前的配置文件优先级高
			// envFilePath: [`.env.production`, '.env'],
		}),
		/**
		  * 启用redis缓存模块
		  */
		RedisModule.forRootAsync({
			useFactory: (configService: ConfigService) => {
				const redis = {
					password: configService.get('REDIS_PASSWORD'),
					host: configService.get('REDIS_HOST'),
					port: configService.get('REDIS_PORT'),
					db: configService.get('REDIS_DB'),
				};

				return {
					type: 'single',
					url: `redis://:${redis.password}@${redis.host}:${redis.port}/${redis.db}`,
				};
			},
			inject: [ConfigService],
		}),
	]
})
export class AppModule {}

如果是在 service 或者 controller 中使用, 可以在 constructor 里面引入,和其他service的使用方法一样。

这里测试一下前面配置的 .env.test 中的 TEST_ENV 究竟有没有生效,修改一下 app.service.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AppService {
	constructor(private configService: ConfigService) {}
	
	getHello(): string {
		return 'Hello World!' + this.configService.get('TEST_ENV');
	}
}

验证效果:

  • .env.test测试 -> npm run start:dev -> localhost:3000
  • .env.procution 测试 -> npm run build -> npm run start:prd -> 浏览器输入 localhsot:3000

参数类型校验

Class validator NestJS官网

可以使用 class-validator 插件来对传入参数进行类型校验。

npm i --save class-validator class-transformer

main.ts 里面修改以下代码

import {
	ValidationPipe,
	BadRequestException,
	HttpStatus,
} from '@nestjs/common';
import { ValidationError } from 'class-validator';

// ...

app.useGlobalPipes(
	new ValidationPipe({
	// 设置校验失败后的响应数据格式
	exceptionFactory: (errors: ValidationError[]) => {
		const message = Object.values(errors[0].constraints!)[0];
		return new BadRequestException({
			message,
			code: HttpStatus.BAD_REQUEST,
		});
	},
	}),
);

// ...

dto/create-user.dto.ts 里,根据需求放入相对应的注解。这里演示几个最简单的

// create-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNumber, IsOptional, IsString } from 'class-validator';

export class CreateUserDto {
	@ApiProperty({ required: true })
	@IsEmail()
	name: string;
	
	@ApiProperty({ required: true })
	@IsString()
	password: string;
	
	@ApiProperty()
	@IsString()
	@IsOptional()
	avatar?: string;
	
	@ApiProperty()
	@IsNumber()
	@IsOptional()
	id?: number;
}

这里用了 IsEmail 来举例,只要传进来的 name 不符合邮箱的规范,那么就会返回格式错误。

如果是 Get 请求,因为传入后端的都是字符串,需要在 class-validator 里面将数值进行二次转换。

@Type(() => Number)
id?: number;

格式化返回

先写一个返回类。

import { ApiProperty } from '@nestjs/swagger';

export const SUCCESS_CODE = 200;

export class ResultData {
	constructor(
		code = SUCCESS_CODE,
		message?: string,
		data?: any,
	) {
		this.code = code;
		this.message = message;
		this.data = data || null;
	}
	
	@ApiProperty({ type: 'number', default: SUCCESS_CODE })
	code: number;
	
	@ApiProperty({ type: 'string', default: 'ok' })
	message?: string;
	
	data?: any;
	
	static ok(message?: string, data?: any): ResultData {
		return new ResultData(SUCCESS_CODE, message, data);
	}
	
	static fail(code: number, message?: string, data?: any) {
		return new ResultData(code, message, data);
	}
}

然后在每个 controller 的返回里面加上 ResultData.ok(...) 或者 ResultData.fail(...),类似下面这个案例

@ApiOperation({ summary: 'Create user' })
@Post()
async create(@Body() createUserDto: CreateUserDto) {
	const result = await this.userService.createUser(createUserDto);
	return ResultData.ok('success', result);
}

Exception filters 异常处理

Exception filters 官网

Exception filtersNestJS 提供的错误捕获器。NestJS 在运行时会遇到很多种不同的错误,需要对不同类型的错误进行错误处理。

如果是 Prisma 产生的错误,它会直接返回,会导致默认格式不一样,导致前端处理不了。

还有 class-validaor 的错误处理,也需要对其进行错误的特殊处理

快速创建 filters 文件

## 生成全局错误捕获
nest g filter global /common/filter

## 生成prisma错误捕获
nest g filter prisma /common/filter

prisma.filter.ts

import {
	ArgumentsHost,
	Catch,
	ExceptionFilter,
	HttpStatus,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaFilter implements ExceptionFilter {
	catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
		const ctx = host.switchToHttp();
		const response = ctx.getResponse();
		response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
			code: exception.code,
			message: exception.message,
		});
	}
}

global.filter.ts

import {
	ArgumentsHost,
	BadRequestException,
	Catch,
	ExceptionFilter,
	HttpException,
	HttpStatus,
} from '@nestjs/common';

@Catch()
export class GlobalFilter implements ExceptionFilter {
	catch(exception: any, host: ArgumentsHost) {
		const ctx = host.switchToHttp();
		const response = ctx.getResponse();
		const status =
			exception instanceof HttpException
			? exception.getStatus()
			: HttpStatus.INTERNAL_SERVER_ERROR;
		if (exception instanceof BadRequestException) {
			response.status(status).json(exception.getResponse());
		} else {
			response.status(status).json({
				code: status,
				message: `Service Error: ${exception}`,
			});
		}
	}

}

接下来配置 main.ts

import { GlobalFilter } from './common/filter/global/global.filter';
import { PrismaFilter } from './common/filter/prisma/prisma.filter';

// ...

// 全局错误捕获器
app.useGlobalFilters(new GlobalFilter());
app.useGlobalFilters(new PrismaFilter());

// ...

全局守卫、jwt校验

全局守卫意味着每个请求到达 controller 之前,会经过全局守卫。全局守卫比较适合做权限校验,将不符合条件的用户请求全部拦截。

JWT NestJS 官网

安装 @nest/jwt

npm i --save @nestjs/jwt

module/user.module.ts 中注册 jwt 模块

import { JwtModule } from '@nestjs/jwt';
  
@Module({
	imports: [
		JwtModule.register({
			global: true,
			secret: 'SECRET',
			signOptions: { expiresIn: '7d' },
		}),
	],
// ...
})

service 生成 token

const access_token = await this.jwtService.signAsync(payload);

使用 nest 快速生成 guard 文件

nest g gu auth /common/guard

auth.guard.ts

import {
	Injectable,
	CanActivate,
	HttpException,
	HttpStatus,
	ExecutionContext,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthGuard implements CanActivate {
	// 全局守卫
	async canActivate(context: ExecutionContext): Promise<boolean> {
		// context 请求的(Response/Request)的引用
		// 获取请求头部数据
		const request = context.switchToHttp().getRequest();
		
		// 获取请求头中的 authorization 字段
		let token = context.switchToRpc().getData().headers.authorization;
		token = this.extractTokenFromHeader(token);
		
		// 验证token的合理性以及根据token做响应的操作
		if (token) {
			try {
				// 校验 token
				const jwtService = new JwtService();
				const res = jwtService.verify(token, { secret: 'SECRET' });
				
				// 传递用户信息到 controller
				request['user'] = res;
				
				return res;
			} catch (e) {
				throw new HttpException(
					'没有授权访问,请先登陆',
					HttpStatus.UNAUTHORIZED,
				);
			}
		} else {
			// 白名单验证
			if (this.hasUrl(this.urlList, request.url)) {
				return true;
			}
			throw new HttpException(
				'没有授权访问,请先登陆',
				HttpStatus.UNAUTHORIZED,
			);
		}
	}
	
	// 白名单
	private urlList: string[] = ['/', '/user'];
	
	// 验证请求是否为白名单的路由
	private hasUrl(urlList: string[], url: string): boolean {
		let flag: boolean = false;
		if (urlList.indexOf(url.split('?')[0]) >= 0) {
			flag = true;
		}
		return flag;
	}
	
	private extractTokenFromHeader(authorization: string): string | undefined {
		const [type, token] = authorization?.split(' ') ?? [];
		return type === 'Bearer' ? token : undefined;
	}
}

main.ts 中注册全局守卫,注册完成后,访问非白名单的url,就会报没有登录的错误。

// main.ts
import { AuthGuard } from './common/guard/auth/auth.guard';

// ...

app.useGlobalGuards(new AuthGuard());

// ...

生产环境

如果生产环境走的是服务器上的CICD,在 npm install 之后需要执行以下 npx prisma generate 来生成必要的 typescript 文件。

package.jsonscript 中增加一个指令

{
	"prisma:generate": "dotenv -e .env.production -- npx prisma generate"
}

这里贴上一个部署用的 Dockerfile

# node镜像
FROM node:20-alpine

ENV NODE_VERSION 20.15.1
# Set working dir inside base docker image
WORKDIR /usr/src/app
# Copy our project files to docker image
COPY . .
# npm 源,选用国内镜像源以提高下载速度
RUN npm config set registry http://mirrors.cloud.tencent.com/npm/
RUN npm install -g node-gyp
# Install project dependencies
RUN npm install
# Generate Prisma client files
RUN npm run prisma:generate
RUN npm run build

EXPOSE 3000

CMD ["npm", "run","start:prd"]

谢谢你能看到这里,希望对你有帮助。如有问题,欢迎指出

仓库地址:github.com/zhanyis/nes…