本文章是NestJS笔记,涵盖了从入门到测试再到部署的所有内容。
前置知识:
1. 装饰器
1.1 什么是装饰器
装饰器是一种特殊的类型声明,它可以附加在类、方法、属性、参数上面,本质是一个函数
注意:装饰器写法需要开启一项配置
在
tsconfig.json
中"experimentalDecorators": true
1.2 类装饰器
const doc: ClassDecorator = (target) => {
// 使用该装饰器,会把该构造函数传入到函数的参数中
console.log(target); // 输出的是xiaotian这个构造函数
// 可以在原型上添加属性和方法
target.prototype.name = "小天";
};
@doc
class xiaotian {
constructor() {}
}
// 与上面的写法相同
// class xiaotian {
// constructor() {
// }
// }
// doc(xiaotian)
1.3 属性装饰器
const doc: PropertyDecorator = (target: any, key: string | symbol) => {
// target 指向xiaotian原型对象,key是name
console.log(target, key); // {} name
};
class xiaotian {
@doc
public name: string;
constructor() {
this.name = "小天";
}
}
1.4 方法装饰器
const doc: MethodDecorator = (
target: any,
key: string | symbol,
descriptor: any
) => {
// target指向xiaotian原型对象,key是函数名,descriptor是描述符
console.log(target, key, descriptor);
/**
* {} getName {
* value: [Function: getName],
* writable: true.
* enumerable: false,
* configurable: true
* }
*/
};
class xiaotian {
public name: string;
constructor() {
this.name = "小天";
}
@doc
getName() {}
}
1.5 参数装饰器
const doc: ParameterDecorator = (
target: any,
key: string | symbol | undefined,
index: any
) => {
// target指向xiaotian原型对象,key是函数名,index是索引
console.log(target, key, index);
/**
* {} getName 1
*/
};
class xiaotian {
getName(name: string, @doc age: number) {}
}
2. 装饰器实现底层@Get
import axios from "axios";
const Get = (url: string) => {
// 使用装饰器工厂(柯里化函数)
return (target: any, key: any, descriptor: PropertyDescriptor) => {
const fn = descriptor.value;
axios
.get(url)
.then((res) => {
fn(res);
})
.catch((e) => {});
};
};
class Controller {
constructor() {}
@Get("https://api.com")
getList(res: any) {
console.log(res);
}
}
一、NestJS 官方 cli
1. 快速上手
1.1 全局安装 cli
npm i -g @nestjs/cli
1.2 创建项目
nest new [项目名]
1.3 查看项目命令
- 查看
nest
命令
Usage: nest <command> [options]
Options:
-v, --version Output the current version. # 输出当前版本。
-h, --help Output usage information. # 输出使用情况信息。
Commands:
new|n [options] [name] Generate Nest application. # 生成Nest应用程序。
build [options] [app] Build Nest application. # 构建Nest应用程序。
start [options] [app] Run Nest application. # 运行Nest应用程序。
info|i Display Nest project details. # 显示Nest项目详细信息。
add [options] <library> Adds support for an external library to your project. # 将对外部库的支持添加到项目中。
generate|g [options] <schematic> [name] [path] Generate a Nest element. # 生成Nest元素。
Schematics available on @nestjs/schematics collection: # 可以在@nestjs/示意图集合中找到示意图:
┌───────────────┬─────────────┬──────────────────────────────────────────────┐
│ name │ alias │ description │
│ application │ application │ Generate a new application workspace │
│ class │ cl │ Generate a new class │
│ configuration │ config │ Generate a CLI configuration file │
│ controller │ co │ Generate a controller declaration │
│ decorator │ d │ Generate a custom decorator │
│ filter │ f │ Generate a filter declaration │
│ gateway │ ga │ Generate a gateway declaration │
│ guard │ gu │ Generate a guard declaration │
│ interceptor │ itc │ Generate an interceptor declaration │
│ interface │ itf │ Generate an interface │
│ library │ lib │ Generate a new library within a monorepo │
│ middleware │ mi │ Generate a middleware declaration │
│ module │ mo │ Generate a module declaration │
│ pipe │ pi │ Generate a pipe declaration │
│ provider │ pr │ Generate a provider declaration │
│ resolver │ r │ Generate a GraphQL resolver declaration │
│ resource │ res │ Generate a new CRUD resource │
│ service │ s │ Generate a service declaration │
│ sub-app │ app │ Generate a new application within a monorepo │
└───────────────┴─────────────┴──────────────────────────────────────────────┘
- 查看
nest g
的命令
nest g --help
Usage: nest generate|g [options] <schematic> [name] [path]
Generate a Nest element.
Schematics available on @nestjs/schematics collection:
┌───────────────┬─────────────┬──────────────────────────────────────────────┐
│ name │ alias │ description │
│ application │ application │ Generate a new application workspace │
│ class │ cl │ Generate a new class │
│ configuration │ config │ Generate a CLI configuration file │
│ controller │ co │ Generate a controller declaration │
│ decorator │ d │ Generate a custom decorator │
│ filter │ f │ Generate a filter declaration │
│ gateway │ ga │ Generate a gateway declaration │
│ guard │ gu │ Generate a guard declaration │
│ interceptor │ itc │ Generate an interceptor declaration │
│ interface │ itf │ Generate an interface │
│ library │ lib │ Generate a new library within a monorepo │
│ middleware │ mi │ Generate a middleware declaration │
│ module │ mo │ Generate a module declaration │
│ pipe │ pi │ Generate a pipe declaration │
│ provider │ pr │ Generate a provider declaration │
│ resolver │ r │ Generate a GraphQL resolver declaration │
│ resource │ res │ Generate a new CRUD resource │
│ service │ s │ Generate a service declaration │
│ sub-app │ app │ Generate a new application within a monorepo │
└───────────────┴─────────────┴──────────────────────────────────────────────┘
Options:
-d, --dry-run Report actions that would be taken without writing out results. # 测试文件
-p, --project [project] Project in which to generate files.
--flat Enforce flat structure of generated element.
--no-flat Enforce that directories are generated.
--spec Enforce spec files generation. (default: true)
--spec-file-suffix [suffix] Use a custom suffix for spec files.
--skip-import Skip importing (default: false)
--no-spec Disable spec files generation. # -g --no-spec 不创建测试文件
-c, --collection [collectionName] Schematics collection to use.
-h, --help Output usage information.
例如:
- 生成一个
user
模块
nest g module user
- 生成一个
user
模块的controller
nest g controller user
2.创建第一个 NestJS 应用
项目目录结构
src
|—— app.controller.spec.ts # 测试文件
|—— app.controller.ts # 控制器:书写路由
|—— app.module.ts # 根模块
|—— app.service.ts # 书写逻辑部分
|—— main.ts # 入口文件
3. 核心概念
4. NestJS 的生命周期
5. 编写接口
- 在
app.controller.ts
文件中:
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";
// 该路由模块的基础路径
@Controller("api")
export class AppController {
constructor(private readonly appService: AppService) {}
// get 请求路径:/api/app
@Get("app")
getApp(): string {
return "hello nestjs";
}
}
- 在
main.ts
文件中,添加全局统一请求路径
使用app.setGlobalPrefix(xx)
,例如:app.setGlobalPrefix('/api/v1')
,请求的路径就是:/api/v1/xxx
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("/api/v1");
await app.listen(3000);
}
bootstrap();
- 将逻辑拆分到
xx.service.ts
中
以user.service.ts
为例:
- 在
user.service.ts
中:
import { Injectable } from "@nestjs/common";
@Injectable()
export class UserService {
getUser() {
return {
code: 200,
msg: "success",
data: [],
};
}
}
- 在
user.controller.ts
中:
将 userService 注入到容器中
constructor(private userService: UserService) {}
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
@Controller("user")
export class UserController {
// 将userService注入到容器中(依赖注入)
// 写法1:语法糖
constructor(private userService: UserService) {}
// 写法2:等价写法1
userService: UserService;
constructor() {
this.userService = new UserService();
}
@Get("user")
getUser(): Object {
return this.userService.getUser();
}
}
二、控制器 Controller
1. 路由通配符
@Get('ab*cd')
findAll() {
return 'This route uses a wildcard';
}
2. 请求参数
使用注解的方式获取请求的参数
2.1 params 参数
请求路径:http://127.0.0.1:3000/api/v1/user/1
- 写法 1:
@Param()
@Get('/:id')
findParmas(@Param() params: any) {
console.log(params);
return 'success'
}
打印结果为:
{
id: "1";
}
- 写法 2:
@Param('id')
@Get('/:id')
findParmas(@Param('id') id: any) {
console.log(id);
return 'success'
}
打印结果为:
1;
2.2 query 参数
请求路径:http://127.0.0.1:3000/api/v1/user?username=wifi&password=123
@Get()
findUser(@Query() query: any) {
console.log(query);
return 'success'
}
打印结果为:
{ username: 'wifi', password: '123' }
2.3 body 参数
请求路径:
POST /api/v1/user/body HTTP/1.1
Host: 127.0.0.1:3000
User-Agent: Apifox/1.0.0 (https://apifox.com)
Accept: */*
Host: 127.0.0.1:3000
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
username=wifi&password=123
@Post('/body')
getBody(@Body() body: any) {
console.log(body);
return 'success'
}
打印结果为:
{ username: 'wifi', password: '123' }
2.4 headers 参数
请求路径:
添加了一个Authorization
GET /api/v1/user HTTP/1.1
Host: 127.0.0.1:3000
Authorization: ea341c68-0939-493f-824c-53bd76792a61
Host: 127.0.0.1:3000
@Get()
getHeaders(@Headers() headers: any) {
console.log(headers);
return 'success'
}
打印结果为:
{
authorization: '6e8b0c8e-b9f4-4a22-a289-c15f43f0a705',
host: '127.0.0.1:3000'
}
2.5 req 参数(所有参数)
@Get()
getReq(@Request() req: any) {
console.log(req);
return 'success'
}
会打印请求对象所有信息
3. 状态码
@Post()
@HttpCode(204)
create() {
return 'This action adds a new cat';
}
4. 头部信息
@Post()
@Header('Cache-Control', 'none')
create() {
return 'This action adds a new cat';
}
5. 重定向
@Get()
@Redirect('https://nestjs.com', 301)
三、模块 Module
1. 配置项
providers | 将由 Nest 注入器实例化并且至少可以在该模块中共享的提供程序享。 |
---|---|
controllers | 此模块中定义的必须实例化的控制器集 |
imports | 导出此模块所需的提供程序的导入模块列表 |
exports | 这个模块提供的providers 子集应该在引入此模块的其他模块中可用。您可以使用提供者本身,也可以只使用其标记(provide 值)。 |
2. 全局模块
使用@Global
装饰器
import { Module, Global } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Global()
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
export class CatsModule {}
四、提供者 Providers
1. 基本使用(自定义名称)
自定义名称:useClass
- 在
user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
// 简写形式
providers: [UserService]
// 完整写法
providers: [
{
provide: '名称', // 自定义
useClass: UserService
}
]
})
export class UserModule {}
- 在
user.controller.ts
中
使用@Inject
装饰器,传递的参数是provide
自定义的名称
import { Controller, Get, Inject } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(@Inject('userService') private userService: UserService) {}
@Get('user')
getUser(): Object {
return this.userService.getUser()
}
}
注意:在第二种(完整写法)上,在
user.controller.ts
中写的时候,才必须需要加@Inject
装饰器,第一种简写形式不需要,如下:
import { Controller, Get, Inject } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private userService: UserService) {}
@Get('user')
getUser(): Object {
return this.userService.getUser()
}
}
2. 基本使用(自定义注入值)
自定义注入值:useValue
- 在
user.module.ts
中
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
@Module({
controllers: [UserController],
providers: [
{
provide: 'hahaha',
useValue: ['QQ', '微信', '抖音'] // 可以自定义类型
}
]
})
export class UserModule {}
- 在
use.controller.ts
中
import { Controller, Get, Inject } from '@nestjs/common';
@Controller('user')
export class UserController {
constructor(
@Inject('hahaha') private app: string[]
) {}
@Get('user')
getUser(): Object {
return this.app
}
}
3. 在一个 service 使用另一个 service
- 新建文件
-
新建
auth
目录 -
新建
auth/auth.module.ts
文件 -
新建
auth/auth.controller.ts
文件 -
新建
auth/auth.service.ts
文件
-
在
app.module.ts
中导入auth
模块 -
在
auth
模块使用user
模块的service
- 在
user.module.ts
导出
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
@Module({
controllers: [UserController],
providers: [UserService],
// 要在auth模块使用,需要将userService导出
exports: [UserService],
})
export class UserModule {}
- 在
auth.module.ts
导入userService
// auth.module.ts
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UserService } from "src/user/user.service";
@Module({
controllers: [AuthController],
providers: [AuthService, UserService],
})
export class AuthModule {}
auth.controller.ts
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signin")
signin(@Body() dto: { username: string; password: string }) {
const { username, password } = dto;
if (!username || !password) {
throw new HttpException(
"用户名或密码不能为空",
HttpStatus.EXPECTATION_FAILED
);
}
return this.authService.signin(username, password);
}
@Post("signup")
signup(@Body() dto: { username: string; password: string }) {
const { username, password } = dto;
return this.authService.signup(username, password);
}
}
- 在
auth.service.ts
中使用
import { Injectable } from "@nestjs/common";
import { UserService } from "../user/user.service";
// service一定要加@Injectable,这个DI系统才会把他添加为一个实例
@Injectable()
export class AuthService {
// 使用user
constructor(private userService: UserService) {}
signin(username: string, password: string) {
let res = this.userService.getUser();
return {
code: 200,
data: {
username,
password,
},
res,
};
}
signup(username: string, password: string) {
/**
* 用户信息的ts类型
* type User = {
* username: string,
* password: string,
* age: number;
* gender: string;
* }
* 但是在登陆注册时,不需要传递这么多参数,但是ts会报错,可以使用ts的Partial类型,将User类型转换为Partial类型,即所有属性都是可选的
*/
return {
code: 200,
data: {
username,
password,
},
};
}
}
五、异常过滤器 Exception Filter
1. 全局异常过滤器
1.1 异常过滤器-内置 http 异常Exception
官方文档 | NestJS - 一个渐进式的 Node.js 框架
1.2 基本使用
import { NotFoundException } from "@nestjs/common";
@Controller("user")
export class UserController {
@Get("user")
findAll(): Object {
throw new NotFoundException("用户不存在");
}
}
- 响应结果
{
"message": "用户不存在",
"error": "Not Found",
"statusCode": 404
}
1.3 进阶使用:全局异常过滤器
- 在
src
目录下新建filtters
目录,新建http-exception.filtter.ts
文件
// src/filtters/http-exception.filtter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from "@nestjs/common";
@Catch(HttpException) // 捕获HttpException错误,如果为空则捕获所有错误
export class HttpExceptionFiltter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
// host表示整个nest进程
// switchToHttp 可以找到整个程序的上下文
const ctx = host.switchToHttp();
// 响应、请求对象
const response = ctx.getResponse();
const request = ctx.getRequest();
// http状态码
const status = exception.getStatus();
// message
const message = exception.message;
// 返回给接口的数据
// 重写状态码,改变响应体
response.status(status).json({
code: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
/**
* 第3点,throw new NotFoundException("用户不存在");
* 如果NotFoundException传入内容exception.message为传入的内容
* 如果NotFoundException未传入内容,则默认为HttpException信息
* */
msg: exception.message || HttpException.name,
});
}
}
- 在
main.ts
中
useGlobalFilters
全局过滤器
注意:全局过滤器
app.useGlobalFilters
只能有一个
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { HttpExceptionFiltter } from "./filtters/http-exception.filtter";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("/api/v1");
// 将filtter配置成全局过滤器
app.useGlobalFilters(new HttpExceptionFiltter());
const port = 3000;
await app.listen(port);
}
bootstrap();
- 在
user.controller.ts
中使用
import { Controller, Get, NotFoundException } from "@nestjs/common";
@Controller("user")
export class UserController {
@Get("user")
findAll(): Object {
// throw new NotFoundException()
throw new NotFoundException("用户不存在");
}
}
- 请求接口返回的数据
{
"code": 404,
"timestamp": "2024-07-15T18:20:48.503Z",
"path": "/api/v1/user/user",
"method": "GET",
"msg": "用户不存在"
}
2. 局部过滤器
- 使用 cli 创建文件
在filtters
目录下创建名为typeorm.filter.ts
的文件
nest g f filtters/typeorm --flat --no-spec
import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common";
import { QueryFailedError, TypeORMError } from "typeorm";
@Catch(TypeORMError) // 捕获TypeORM的错误
export class TypeormFilter implements ExceptionFilter {
catch(exception: TypeORMError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
// 响应对象
const response = ctx.getResponse();
let code = 500;
if (exception instanceof QueryFailedError) {
code = exception.driverError.errno;
}
response.status(500).json({
code: code,
message: exception.message,
});
}
}
- 在控制器中使用:
user.controller.ts
import { Controller, Get, UseFilters } from "@nestjs/common";
import { TypeormFilter } from "../filtters/typeorm.filter";
@Controller("user")
// 使用局部过滤器
@UseFilters(new TypeormFilter())
export class UserController {
@Get()
getData() {}
}
也可以这样写:
import { Controller, Get, UseFilters } from "@nestjs/common";
import { TypeormFilter } from "../filtters/typeorm.filter";
// 这部分可以单独拆分到一个文件中,供其他模块使用
export function TypeOrmDecorator() {
return UseFilters(new TypeormFilter());
}
@Controller("user")
// 使用局部过滤器
@TypeOrmDecorator()
export class UserController {
@Get()
getData() {}
}
六、管道 Pipe
1. 什么是管道
管道Pipe
是具有 @Injectable()
装饰器的类。管道应实现 PipeTransform
接口
管道有两个典型的应用场景:
- 转换:管道将输入数据转换为所需的数据输出(例如,将字符串转换为整数)
- 验证:对输入数据进行验证,如果验证成功继续传递; 验证失败则抛出异常
在这两种情况下, 管道 参数(arguments)
会由控制器(controllers) 的路由处理程序
进行处理。Nest 会在调用这个方法之前插入一个管道,管道会先拦截方法的调用参数,进行转换或是验证处理,然后用转换好或是验证好的参数调用原方法。
当在 Pipe 中发生异常,controller 不会继续执行任何方法
2. 管道类型
3. nest 中创建校验类管道
的过程(DTO 参数校验)
创建基于装饰器的校验的类管道,校验参数
dto
使用PartialType
,跟ts中的Partial
一样,变为可选参数
import { PartialType } from '@nestjs/mapped-types';
import { CreateRoleDto } from './create-role.dto';
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}
3.1 安装
使用到的第三方库:(类验证器)
-
class-transformer
:转化请求数据为 DTO 类的实例 -
class-validator
:使用正则等逻辑进行校验
npm i --save class-validator class-transformer
3.2 使用步骤
- 全局配置管道
在main.ts
中
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("/api/v1");
// 配置全局拦截器
app.useGlobalPipes(
new ValidationPipe({
// 去除在类上不存在的字段
whitelist: true,
})
);
await app.listen(3000);
}
bootstrap();
- 创建 class 类,即 Entity、DTO
在auth
目录下新建dto
目录,在dto
目录下新建signin-user.dto.ts
文件(用来校验登录接口的 dto 数据。
- 设置校验规则
在signin-user.dto.ts
// signin-user.dto.ts
import {
IsNotEmpty,
IsString,
Length,
ValidationArguments,
} from "class-validator";
export class SigninUserDto {
@IsString()
@IsNotEmpty()
@Length(6, 20, {
// message?: string | ((validationArguments: ValidationArguments) => string)
message: (validationArguments: ValidationArguments) => {
const { value, property, targetName, constraints } = validationArguments;
/**
* value:当前用户传入的值
* property:当前属性名
* targetName:当前类
* constraints:校验长度范围
*/
console.log(value, property, targetName, constraints);
// 打印结果:wifi username SigninUserDto [ 6, 20 ]
return `用户名长度必须在${constraints}`;
},
})
username: string;
@IsString()
@IsNotEmpty()
@Length(6, 20)
password: string;
}
如果校验不通过,返回给前端的数据为:
{
"message": ["用户名长度必须在6,20"],
"error": "Bad Request",
"statusCode": 400
}
- 使用该实体类或者 DTO
在参数的后面加上定义的 DTO 类型SigninUserDto
在auth.controller.ts
中:
// auth.controller.ts
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SigninUserDto } from "./dto/signin-user.dto";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signin")
// 对参数进行校验
signin(@Body() dto: SigninUserDto) {
const { username, password } = dto;
return this.authService.signin(username, password);
}
@Post("signup")
signup(@Body() dto: SigninUserDto) {
const { username, password } = dto;
return this.authService.signup(username, password);
}
}
4. 参数转换
4.1 内置管道
Nest
自带九个开箱即用的管道,即
ValidationPipe
ParseIntPipe
ParseFloatPipe
ParseBoolPipe
ParseArrayPipe
ParseUUIDPipe
ParseEnumPipe
DefaultValuePipe
ParseFilePipe
他们从 @nestjs/common
包中导出。
验证不是管道唯一的用处,管道也可以将输入数据转换为所需的输出。
4.2 简单使用
在user.controller.ts
中
请求 url:http://127.0.0.1:3000/api/v1/123
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
@Controller("user")
export class UserController {
constructor(private userService: UserService) {}
@Get("/no/:id")
async findOne(@Param("id") id: number) {
// 这里Param参数获取到的id参数是一个字符串
console.log(typeof id); // string
return {
id,
};
}
@Get(":id")
async findOne(@Param("id", ParseIntPipe) id: number) {
console.log(typeof id); // number 将参数进行了转换
return {
id,
};
}
}
多参数(3 个以上)使用 DTO:详细看六、3.nest中创建校验类管道的过程
5. 自定义 pipe(多参数转换)
-
新建
pipes
目录,在pipes
目录下新建create-user.pip.ts
-
在
create-user.pip.ts
中
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
import { CreateUserDto } from "../dto/create-user.dto";
@Injectable()
export class CreateUserPipe implements PipeTransform {
transform(value: CreateUserDto, metadata: ArgumentMetadata) {
console.log(value);
// { username: 'xiaotianwifi', password: '123456' }
console.log(metadata);
// { metatype: [class CreateUserDto], type: 'body', data: undefined }\
// 对数据进行判断操作
return value;
}
}
- 在
user.controller.ts
中使用
import { Body, Controller, Post } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { CreateUserPipe } from "./pipes/create-user.pipe";
@Controller("user")
export class UserController {
constructor(private userService: UserService) {}
@Post("create")
// 在Body装饰器中传递pipe
createUser(@Body(CreateUserPipe) dto: CreateUserDto) {
return "ok";
}
}
6. 全局 pipe
- 定义一个接口:
在test.controller.ts
中
import { Body, Controller, Post } from "@nestjs/common";
import { IsNotEmpty, IsString } from "class-validator";
class TestDto {
@IsString()
@IsNotEmpty()
msg: string;
@IsString()
id: string;
}
@Controller("test")
export class TestController {
@Post()
getTest(@Body() dto: TestDto) {
console.log(dto);
return "ok";
}
}
注意:只有添加了装饰器的属性才不会被排除
// 不会被排除 @IsString() id: string; // 排除,和password一样 id: string;
- 情景:当用户请求
/test
接口时,如果传递的Body
参数为:
{
"id": "1",
"msg": "xiaotianwifi",
"password": "123456"
}
而像password
这样敏感的参数是后端不需要接受的,但是接口中的console.log(dto);
打印的结果为:
{ "id": "1", "msg": "xiaotianwifi", "password": "123456" }
如果不需要password
这样的参数:可以main.ts
中配置全局管道
// main.ts
app.useGlobalPipes(
new ValidationPipe({
// 去除在类上不存在的字段
whitelist: true,
})
);
这样console.log(dto);
打印的结果就只有需要的参数了
{ "id": "1", "msg": "xiaotianwifi" }
7. 提供默认值
@Get()
async findAll(
@Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
return this.catsService.findAll({ activeOnly, page });
}
8. 总结
- 校验参数:通过
dto
来校验,dto定义了参数的ts类型,结合class-validator
和class-transformer
进行校验- 参数转换:通过参数装饰器转换
8.1 校验参数
user.dto.ts
// user.dto.ts
import {
IsNotEmpty,
IsString,
Length,
ValidationArguments,
} from "class-validator";
export class UserDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
@Length(6, 20)
password: string;
}
user.controller.ts
@Get()
async findAll(@Query('user') user: UserDto) {}
8.2 参数转换
@Get()
async findAll(@Query('id', ParseIntPipe) id: number) {}
@Get()
async findAll(@Query('id', new ParseIntPipe()) id: number) {}
七、守卫 Guards
守卫是一个使用 @Injectable()
装饰器的类。 守卫应该实现 CanActivate
接口。中间件不知道在调用next()
函数后将执行哪个处理程序。另一方面,守卫可以访问ExecutionContext
实例(上下文对象)。它们的设计方式与异常过滤器、管道和拦截器非常相似,目的是让您在请求/响应周期中的确切位置插入处理逻辑,并以声明的方式进行插入。
请注意,守卫在所有
中间件
之后执行,但在拦截器
或管道
之前执行。
1. 基本使用
- 在
src
下新建guards
目录,新建admin.guard.ts
文件
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
return true;
// return false;
}
}
- 返回
true
:可以正常响应数据
{
"msg": "success",
"username": "xiaotianwifi",
"password": "123456"
}
- 返回
false
:响应错误
{
"message": "Forbidden resource",
"error": "Forbidden",
"statusCode": 403
}
2. 使用守卫判断用户权限
这里关于req.user
和AuthGuard('jwt')
的知识,可以查看十二 3.2校验token
部分
// src/admin.guard.ts
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
// 1. 获取请求对象
const req = context.switchToHttp().getRequest();
// @Req() req这里的req.user是通过AuthGuard('jwt')中的validate方法返回的
// PassportModule来添加的(自动添加的)
console.log(req.user);
// 2. 获取请求中的用户信息进行逻辑上的判断 -> 角色判断
/**
* 查询数据库是否有角色权限,如果有返回true,没有返回false
*/
return true;
}
}
非常重要的知识
- 装饰器的执行顺序:方法的装饰器如果有多个,则是从下往上执行
- 如果使用
UseGuards
传递多个守卫,则是从前往后执行,如果前面的 Guard 没有通过,则后面的 Guard 不会执行
2.1 装饰器的执顺序
装饰器的执顺序:从下到上
@Post('create')
@UseGuards(AdminGuard)
@UseGuards(AuthGuard('jwt'))
createUser(@Body() dto: CreateUserDto, @Req() req) {
const { username, password } = dto;
return this.userService.createUser(username, password);
}
上述代码,他会先执行:@UseGuards(AuthGuard('jwt'))
,再去执行@UseGuards(AdminGuard)
如果要在guards/admin.guards.ts
中console.log(req.user)
,则需要@UseGuards(AuthGuard('jwt'))
放在下面,因为该装饰器会在request
上加上user
字段
2.2 UseGuards 传递多个守卫执行顺序
UseGuards 传递多个守卫执行顺序:从前往后
注意:如果前面的 Guard 没有通过,则后面的 Guard 不会执行
@Post('create')
@UseGuards(AuthGuard('jwt'), AdminGuard)
createUser(@Body() dto: CreateUserDto, @Req() req) {
const { username, password } = dto;
return this.userService.createUser(username, password);
}
3. 全局守卫
3.1 在main.ts
中使用(不建议)
app.useGlobalGuards();
弊端:无法使用 DI,无法访问到 userService
3.2 在app.module.ts
中使用
import { Module } from "@nestjs/common";
import { AuthModule } from "./auth/auth.module";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AdminGuard } from "./guards/admin.guard";
@Module({
imports: [AuthModule, UserModule, ConfigModule.forRoot({ isGlobal: true })],
controllers: [],
providers: [
{
provide: APP_GUARD,
useClass: AdminGuard,
},
],
})
export class AppModule {}
八、拦截器 Interceptor
1. 基础知识
每个拦截器都有 intercept()
方法,它接收 2 个参数。 第一个是 ExecutionContext
实例(与守卫完全相同的对象)。 ExecutionContext
继承自 ArgumentsHost
。 ArgumentsHost
是传递给原始处理程序的参数的一个包装 ,它根据应用程序的类型包含不同的参数数组。可以用拦截器对数据脱敏处理。
2. 基本使用
- 新建
interceptors
目录,在interceptors
目录下新建serialize.interceptor.ts
文件
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from "@nestjs/common";
import { Observable, map } from "rxjs";
@Injectable()
export class SerializeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
console.log("这是在拦截器执行之前", req); // 可以获取到request信息
return next.handle().pipe(
map((data) => {
console.log("这里在拦截器执行之后", data);
return data;
})
);
}
}
- 在
auth.controller.ts
文件中使用
import { Body, Controller, Post, UseInterceptors } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SigninUserDto } from "./dto/signin-user.dto";
import { SerializeInterceptor } from "../interceptors/serialize.interceptor";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signin")
// 拦截器
@UseInterceptors(SerializeInterceptor)
async signin(@Body() dto: SigninUserDto) {
const { username, password } = dto;
const token = await this.authService.signin(username, password); // 登录成功,拿到用户token信息
return {
access_token: token,
};
}
}
在1
中的console.log('这里在拦截器执行之后', data);
打印的 data 数据为:signin 接口返回的数据
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyNTA5NDYsImV4cCI6MTcyMTMzNzM0Nn0.yJzCw0elkOMoNPfFxZxzrTP5o-LmDbLxy9UvOGpjE4U"
}
3. 全局使用
在main.ts
中
app.useGlobalInterceptors(new SerializeInterceptor());
4. 进阶使用(序列化)处理敏感数据
4.1 排除响应的属性
假设我们想要自动排除用户实体中的password
属性。我们可以按以下方式对实体进行注解:
- 新建
auth.entity.ts
文件
定义实体类型
import { Exclude } from "class-transformer";
export class SigninUserEntiy {
username: string;
@Exclude()
password: string;
constructor(partial: Partial<SigninUserEntiy>) {
Object.assign(this, partial);
}
}
- 在
auth.controller.ts
中
@UseInterceptors(ClassSerializerInterceptor)
@Get()
findOne(): SigninUserEntity {
return new SigninUserEntity({
username: 'username',
password: 'password',
});
}
必须返回这个类的一个实例。如果返回一个普通的 JavaScript 对象,例如
{ user: new UserEntity() }
,该对象将无法正确序列化。
import {
Body,
ClassSerializerInterceptor,
Controller,
Post,
UseInterceptors,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
// 校验参数类型
import { SigninUserDto } from "./dto/signin-user.dto";
// 序列化参数
import { SigninUserEntiy } from "./auth.entity";
@Controller("auth")
// 使用拦截器 ClassSerializerInterceptor是nest内置的
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signin")
async signin(@Body() dto: SigninUserDto) {
const { username, password } = dto;
// 返回的实例数据,才是序列化后的
let signinUserEntity = new SigninUserEntiy(dto);
const token = await this.authService.signin(username, password); // 登录成功,拿到用户token信息
return {
access_token: token,
info: dto,
info2: signinUserEntity,
};
}
}
- 发送请求:
curl
:
curl --location --request POST 'http://127.0.0.1:3000/api/v1/auth/signin' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:3000' \
--header 'Connection: keep-alive' \
--data-raw '{
"username": "xiaotianwifi",
"password": "123456"
}'
响应结果
:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEzMDcwMTcsImV4cCI6MTcyMTM5MzQxN30.XW8b6dekhRQ_ZONfzC9ZDaCqyUDiEM2ecN57nvGMQyU",
"info": {
"username": "xiaotianwifi",
"password": "123456"
},
"info2": {
"username": "xiaotianwifi"
}
}
info
是完整结果,info2
是序列化后的结果
- 该序列化也可在数据库实体部分中对数据直接进行过滤,在调用的时候会直接返回该实体,不需要手动
new
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { Exclude } from "class-transformer";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
@Exclude()
password: string;
}
4.2 排除接受的数据
参考:
六、6. 全局pipe
4.2 确保输出数据的安全:自定义拦截器
参考:
八、5.自定义拦截器
5. 自定义拦截器
5.1 基本使用
- 新建
interceptors
目录,在interceptors
目录下新建serialize.interceptor.ts
文件
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { Observable, map } from "rxjs";
@Injectable()
export class SerializeInterceptor implements NestInterceptor {
constructor(private dto: any) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
// console.log('这是在拦截器执行之前', req);
// 属于后置拦截器
return next.handle().pipe(
map((data) => {
console.log("这里在拦截器执行之后", data);
// 对dto序列化
return plainToInstance(this.dto, data, {
// 设置为true,所有经过该拦截器的接口都徐亚设置@Expose或@Exclude
// Expose就是设置哪些字段需要暴露,Exclude就是设置哪些字段不需要暴露
excludeExtraneousValues: true,
});
})
);
}
}
- 在
test.controller.ts
中使用
import { Body, Controller, Post, UseInterceptors } from "@nestjs/common";
import { Expose } from "class-transformer";
import { IsNotEmpty, IsString } from "class-validator";
import { SerializeInterceptor } from "../interceptors/serialize.interceptor";
// 接收参数类型和校验
class TestDto {
@IsString()
@IsNotEmpty()
msg: string;
@IsString()
id: string;
}
// 响应参数
class PublicDto {
@Expose()
msg: string;
}
@Controller("test")
export class TestController {
@Post()
// 响应的dto是PublicDto,因为该拦截器是后置拦截器
@UseInterceptors(new SerializeInterceptor(PublicDto))
getTest(@Body() dto: TestDto) {
console.log(dto);
return dto;
}
}
- 结果
Body
:
{
"id": "1",
"msg": "xiaotianwifi",
"password": "123456"
}
console.log(dto)
:
因为这里配置了全局管道:
app.useGlobalPipes(
new ValidationPipe({
// 去除在类上不存在的字段
whitelist: true,
})
);
所以打印结果为:
{ id: '1', msg: 'xiaotianwifi' }
- 响应的数据:
{
"msg": "xiaotianwifi"
}
5.2 自定义拦截器的装饰器
-
自定义拦截器部分和
5.1基本使用 1
一样 -
新建
decorators
目录,在decorators
目录新建serialize.decorator.ts
文件
import { UseInterceptors } from "@nestjs/common";
import { SerializeInterceptor } from "../interceptors/serialize.interceptor";
interface ClassConstructor {
new (...args: any[]): any;
}
export function Serialize(dto: ClassConstructor) {
return UseInterceptors(new SerializeInterceptor(dto));
}
- 在
test.controller.ts
中使用
import { Body, Controller, Post } from "@nestjs/common";
import { Expose } from "class-transformer";
import { IsNotEmpty, IsString } from "class-validator";
import { Serialize } from "../decorators/serialize.decorator";
class TestDto {
@IsString()
@IsNotEmpty()
msg: string;
@IsString()
id: string;
}
class PublicDto {
@Expose()
msg: string;
}
@Controller("test")
export class TestController {
@Post()
// 自定义拦截器的装饰器
@Serialize(PublicDto)
getTest(@Body() dto: TestDto) {
console.log(dto);
return dto;
}
}
6. 总结
在请求时,对不需要的 dto 字段进行排除,使用后置拦截器对响应的数据做出处理。dto 可以共用 entity 实例的,只需要在对应字段上加上注解即可。
九、中间件 Middleware
1. 什么是中间件
中间件是在路由处理程序之前调用的函数。中间件函数可以访问请求
和响应
对象,以及应用程序的请求-响应周期中的next()
中间件函数。通常,next中间件函数由一个名为next
的变量表示。
中间件函数可以执行以下任务:
- 执行任何代码。
- 对请求和响应对象进行更改。
- 结束请求-响应周期。
- 调用堆栈中的下一个中间件函数。
- 如果当前中间件函数未结束请求-响应周期,则必须调用
next()
将控制权传递给下一个中间件函数。否则,请求将被搁置。
2. 基本使用
- 新建
user.middleware.ts
文件
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class UserMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('中间件执行了');
next()
}
}
- 在
user.module.ts
中使用
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { UserMiddleware } from './user.middleware';
@Module({
controllers: [UserController],
providers: [UserService]
})
export class UserModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// 写法1:指定生效地址
consumer.apply(UserMiddleware).forRoutes('user')
// 写法2:对user路由的get请求进行处理
consumer.apply(UserMiddleware).forRoutes({ path: 'user', method: RequestMethod.GET })
// 写法3:对整个控制器处理
consumer.apply(UserMiddleware).forRoutes(UserController)
}
}
3. 全局中间件
全局中间件可以做接口的白名单
在main.ts
中:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Request, Response, NextFunction } from 'express';
function AppMiddleware(req: Request, res: Response, next: NextFunction) {
// 可以做接口白名单
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('/api/v1')
app.use(AppMiddleware)
await app.listen(3000);
}
bootstrap();
配置跨域中间件也可以跟express
一样使用cors
十、多环境配置两种方案
1. dotenv
支持.env
文件
1.1 安装dotenv
npm i dotenv
1.2 使用
- 新建
.env
文件
TOKEN=1234567890
USERNAME=wifi
PASSWORD=123456
- 在
index.js
使用
require("dotenv").config();
console.log(process.env);
打印的结果为:
{
...
npm_config_prefix: '/usr/local',
npm_node_execpath: '/usr/local/bin/node',
TOKEN: '1234567890',
USERNAME: 'wifi',
PASSWORD: '123456'
}
2. config
支持yaml
、yml
、json
文件
2.1 安装config
npm i config
2.2 json 文件格式(基本使用)
- 新建
config
目录,在config
目录下新建default.json
// config/default.json
{
"token": "my-token",
"db": {
"host": "localhost",
"port": 27017,
"username": "root",
"password": "123456"
}
}
- 在
index.js
使用
const config = require("config");
// 该db需要跟default.json的db对应
const dbConfig = config.get("db");
console.log(dbConfig);
输出结果为:
{
host: 'localhost',
port: 27017,
username: 'root',
password: '123456'
}
2.3 json 文件格式(进阶使用)
- 在
config
目录下新建production.json
文件(文件名不可自定义)
{
"db": {
"host": "www.wifi.com",
"port": 8080
}
}
- 在终端中切换 node 运行环境,将环境换为
production
win:
set NODE_ENV=production
mac:
export NODE_ENV=production
这时候他会合并default.json
和production.json
的字段,以production.json
为准,打印结果为:
{
host: 'www.wifi.com',
port: 8080,
username: 'root',
password: '123456'
}
2.4 yaml 文件格式
需要安装js-yaml
,也会自动合并字段
default.yaml
和production.yaml
# default.yaml
token: my-token
db:
host: localhost
port: 27017
username: root
password: 123456
# production.yaml
db:
host: www.wifi.com
port: 8080
- 运行
index.js
结果为:
{
host: 'www.wifi.com',
port: 8080,
username: 'root',
password: 123456
}
2.5 corss-env 使用
可以在package.json
中配置脚本环境
package.json
"scripts": {
"pro": "cross-env NODE_ENV=production nodemon index.js", // production环境
"dev": "cross-env NODE_ENV=development nodemon index.js" // development环境
}
-
config
目录下三个文件default.yaml
# default token: my-token db: host: localhost port: 27017 username: root password: 123456
development.yaml
# development.yaml db: host: 127.0.0.1 port: 7979
production.yaml
# production db: host: www.wifi.com port: 8080
-
执行脚本
npm run dev
{ host: '127.0.0.1', port: 7979, username: 'root', password: 123456 }
npm run pro
{ host: 'www.wifi.com', port: 8080, username: 'root', password: 123456 }
3. env 与 config 的优缺点
env
适合简单配置,config
适合嵌套复杂配置
十一、@nestjs/config(在 nest 中使用官方 config 设置模块)
1. 安装
npm i @nestjs/config
2. 基本使用
2.1 user 模块分别导入
- 在
app.module.ts
中:
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
UserModule,
// forRoot 读取根目录下的.env文件
// 在app模块的controllers和servivice中都可以使用,而在在跨模块的user模块中无法使用
ConfigModule.forRoot(),
],
controllers: [],
providers: [],
})
export class AppModule {}
- 在
user.controller.ts
中:
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";
@Controller("user")
export class UserController {
constructor(
private userService: UserService,
private configService: ConfigService
) {}
@Get("user")
getUser(): Object {
let db = this.configService.get("DB");
console.log(db);
return this.userService.getUser();
}
}
这个时候console.log(db)
会报错,需要在user.module.ts
中进行导入:
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule.forRoot()],
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
这个时候就可以正常读取根目录下的.env
文件了
2.2 user 模块全局导入
- 在
app.module.ts
中:
在ConfigModule.forRoot()
中配置isGlobal: true
,表示全局使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
// 配置isGlobal,可在全局使用
isGlobal: true,
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
- 在
user.controller.ts
中:
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";
@Controller("user")
export class UserController {
constructor(
private userService: UserService,
private configService: ConfigService
) {}
@Get("user")
getUser(): Object {
let db = this.configService.get("DB");
console.log(db);
return this.userService.getUser();
}
}
3. 进阶使用(env 文件)
3.1 envFilePath
结合cross-env
app.module.ts
import { Module } from '@nestjs/common';
import { UserModule } from './user/user.module';
import { ConfigModule } from '@nestjs/config';
const envFilePath = `.env.${process.env.NODE_ENV}`
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
// 配置isGlobal,可在全局使用
isGlobal: true,
// 加载指定路径的.env文件
envFilePath: envFilePath
})
],
controllers: [],
providers: [],
})
package.json
"scripts": {
"start:dev": "cross-env NODE_ENV=development nest start --watch",
"start:prod": "cross-env NODE_ENV=production node"
}
3.2 load
结合dotenv
使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import * as dotenv from "dotenv";
const envFilePath = `.env.${process.env.NODE_ENV}`;
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
// 配置isGlobal,可在全局使用
isGlobal: true,
// 加载指定路径的.env文件
envFilePath: envFilePath,
// 读取.env文件作为公共配置文件
load: [() => dotenv.config({ path: ".env" })],
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
4. 进阶使用(yaml 文件)
嵌套配置 yaml 文件读取
- 安装
js-yaml
和@types/js-yaml
js-yaml
用于解析 yaml 文件,@types/js-yaml
存放 js-yaml 文件类型声明
npm i js-yaml
npm i -D @types/js-yaml
- 在根目录下新建
config
目录,在config
目录下新建config.yml
、config.development.yml
、config.production.yml
文件
# config.yml
db:
mysql1:
host: 127.0.0.1
name: mysql-dev
post: 3306
mysql2:
host: 127.0.0.2
name: mysql-dev
post: 3307
# config.development.yml
db:
mysql1:
name: mysql-dev1
mysql2:
name: mysql-dev2
# config.production.yml
db:
mysql1:
name: mysql-prod1
mysql2:
name: mysql-prod2
- 在
src
目录下新建configuration.ts
文件
安装lodash
,用于拼接文件
npm i lodash
import { readFileSync } from "fs";
import * as yaml from "js-yaml";
import { join } from "path";
import * as _ from "lodash";
const YAML_COMMON_CONFIG_FILENAME = "config.yml";
// 读取yml文件路径
const filePath = join(__dirname, "../config", YAML_COMMON_CONFIG_FILENAME);
const envPath = join(
__dirname,
"../config",
`config.${process.env.NODE_ENV}.yml`
);
const commonConfig = yaml.load(readFileSync(filePath, "utf-8"));
const envConfig = yaml.load(readFileSync(envPath, "utf-8"));
// 因为ConfigModule有一个load方法,需要传入一个函数
export default () => _.merge(commonConfig, envConfig);
- 在
app.module.ts
中
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import Configuration from "./configuration";
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
isGlobal: true,
load: [Configuration],
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
- 在
user.controller.ts
中
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";
@Controller("user")
export class UserController {
constructor(
private userService: UserService,
private configService: ConfigService
) {}
@Get("user")
getUser(): Object {
let db = this.configService.get("db");
console.log(db);
return this.userService.getUser();
}
}
执行npm run start:prod
后console.log(db);
的结果为:
# 环境不同,字段已经进行合并和覆盖
{
mysql1: { host: '127.0.0.1', name: 'mysql-prod1', post: 3306 },
mysql2: { host: '127.0.0.2', name: 'mysql-prod2', post: 3307 }
}
5. 进阶使用:配置文件参数校验 Joi
- 安装
npm i joi
- 使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import * as Joi from "joi";
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
// 环境参数校验
validationSchema: Joi.object({
// 默认3306,选择范围:3306、3309(只能是这两个)
DB_PORT: Joi.number().default(3306).valid(3306, 3309),
NODE_ENV: Joi.string()
.valid("production", "development")
.default("development"),
DB_URL: Joi.string().domain(),
DB_HOST: Joi.string().ip(),
}),
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
6. 进阶使用:使用 nest 内置的方法生成配置项registerAs
- 新建
database.ts
registerAs
有两个参数,参数 1 是key
,参数 2 是回调,返回一个对象
import { registerAs } from "@nestjs/config";
export default registerAs("database", () => {
return {
host: process.env.DATABASE_HOST || "localhost",
port: process.env.DATABASE_PORT || 3306,
};
});
- 在
app.module.ts
中注入
ConfigModule.forRoot({
isGlobal: true,
load: [database], // 注入后才能使用
});
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./user/user.module";
import { TypeOrmConfigService } from "./TypeOrmConfigService";
import { User } from "./user/user.entity";
import { ConfigModule } from "@nestjs/config";
import database from "./database";
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
isGlobal: true,
load: [database] // 注入后才能使用
})
TypeOrmModule.forRootAsync({
useClass: TypeOrmConfigService
}),
TypeOrmModule.forFeature([User]),
],
controllers: [],
providers: [],
})
export class AppModule { }
- 使用
通过databaseConfig.KEY
注入
@Inject(databaseConfig.KEY) private config: ConfigType<typeof databaseConfig>
import { Inject, Injectable } from "@nestjs/common";
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from "@nestjs/typeorm";
import { User } from "./user/user.entity";
import { ConfigType } from "@nestjs/config";
import databaseConfig from "./database";
@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
constructor(
// ConfigType可以通过内置方法获取到该类的类型
@Inject(databaseConfig.KEY)
private config: ConfigType<typeof databaseConfig>
) {}
createTypeOrmOptions(): TypeOrmModuleOptions {
return {
type: "mysql",
host: this.config.host,
port: this.config.port,
username: "root",
password: "123456",
database: "nest_test",
entities: [User],
synchronize: true,
logging: ["error"],
};
}
}
十二、数据库使用(TypeORM)
1. ORM 是什么
ORM:对象关系映射,其主要作用是在编程中,把面向对象的概念跟数据库中的概念对应起来。举例:
定义一个对象,那就对应着一张表,这个对象的实例,就对应着表中的一条记录。
2. 在 nest 中使用 TypeORM(基础)
- 安装
npm i @nestjs/typeorm typeorm mysql2
- 在
app.module.ts
中使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { TypeOrmModule } from "@nestjs/typeorm";
@Module({
imports: [
UserModule,
TypeOrmModule.forRoot({
type: "mysql",
host: "localost",
port: 3306,
username: "root",
password: "123456",
database: "nest_test",
// 配置实体
entities: [],
// 同步本地的schema与数据库 => 初始化的时候去使用(开发阶段设置true)
synchronize: true,
// 日志等级
logging: ["error"],
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
3. 在 nest 中使用 TypeORM(结合 env 进阶使用)
- 在
app.module.ts
中使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from "@nestjs/config";
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
isGlobal: true,
}),
// 进阶写法 异步导入
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService], // 注入到useFactory中
useFactory: (configService: ConfigService) =>
({
type: "mysql",
host: configService.get("DB_HOST"),
port: 3306,
username: "nest_test",
password: "123456",
database: "nest_test",
entities: [],
synchronize: true,
logging: ["error"],
} as TypeOrmModuleOptions),
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
4. 使用 TypeORM 创建多个实体
- 在
src/user
目录下新建user.entity.ts
文件
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
// 添加@Entity装饰器来表示实体
@Entity()
export class User {
// 主键
@PrimaryGeneratedColumn()
id: number;
// 列
@Column({ unique: true }) // unique: true 创建唯一数据
username: string;
@Column()
password: string;
@Colum({
default: false,
})
// mysql中没有布尔类型,会自动转为0/1
isAdmin: boolean;
// 创建时候的字段
@CreateDateColum()
createTime: Date;
// 更新时候的字段
@UpdateDateColum()
updateTime: Date;
}
- 在
app.module.ts
中
配置TypeOrmModule.forRootAsync
的entities
import { User } from "./user/user.entity";
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService], // 注入到useFactory中
useFactory: (configService: ConfigService) =>
({
type: "mysql",
host: configService.get("DB_HOST"),
port: 3306,
username: "nest_test",
password: "123456",
database: "nest_test",
// 配置
entities: [User],
synchronize: true,
logging: ["error"],
} as TypeOrmModuleOptions),
});
5. 一对一关系
需要有@JoinColumn()
,建立表的关联
- 新建
profile.entity.ts
文件
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./user.entity";
@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;
@Column()
gender: number;
@Column()
photo: string;
@Column()
address: string;
// 使用函数是为了方便调用
@OneToOne(() => User)
// JoinColumn告诉TypeORM,在哪个表格去创建关联的字段
// 默认生成userId字段,通过user表的主键和表名进行拼接。通过name,可以自定义关联字段名
@JoinColumn({ name: "uid" })
// 注入到user字段,返回的是user实体
user: User;
}
user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
// 将profile注入到user实体上
@OneToOne(() => Profile, (profile) => profile.user)
profile: Profile;
}
- 在
app.module.ts
中进行导入
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { User } from "./user/user.entity";
import { Profile } from "./user/profile.entity";
import { Roles } from "./roles/roles.entity";
import { Logs } from "./logs/logs.entity";
@Module({
imports: [
UserModule,
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) =>
({
type: "mysql",
host: configService.get("DB_HOST"),
port: 3306,
username: "nest_test",
password: "123456",
database: "nest_test",
// 导入的entity
entities: [User, Profile, Roles, Logs],
synchronize: true,
logging: ["error"],
} as TypeOrmModuleOptions),
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
6. 一对多关系
需要有@JoinColumn()
,建立表的关联
-
需要在
app.module.ts
中的entities
中导入(省略) -
在
user.entity.ts
中
import { Logs } from "src/logs/logs.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
// 一对多
// 建立与数据库之间的关联关系,将查询出来的数据塞入logs属性中
@OneToMany(() => Logs, (logs) => logs.user)
logs: Logs[];
}
- 在
logs.entity.ts
中
import { User } from "src/user/user.entity";
import { Column, Entity, ManyToOne, JoinColumn, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Logs {
@PrimaryGeneratedColumn()
id: number;
@Column()
path: string;
@Column()
method: string;
@Column()
data: string;
@Column()
result: number;
// 多对一
@ManyToOne(() => User, (user) => user.logs) // 参数2:参数是user实体
@JoinColumn()
user: User;
}
7. 多对多关系
使用@JoinTable
建立中间表
-
需要在
app.module.ts
中的entities
中导入(省略) -
在
user.entity.ts
中
import { Logs } from "src/logs/logs.entity";
import {
Column,
Entity,
JoinTable,
ManyToMany,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { Roles } from "../roles/roles.entity";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
@ManyToMany(() => Roles, (roles) => roles.users)
@JoinTable({
name: "user_roles",
joinColumns: [{ name: "user_id" }],
inverseJoinColumns: [{ name: "role_id" }],
})
roles: Roles[];
}
- 在
roles.entity.ts
中
import { User } from "src/user/user.entity";
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Roles {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => User, (user) => user.roles)
users: User[];
}
8. 旧项目已有数据库,用 TypeORM 生成对接
typeorm-model-generator
生成器
- 安装
npm i -D typeorm-model-generator
- 在
package.json
中新增脚本
# typeorm-model-generator -h 地址 -p 端口号 -d 数据库名 -u 用户名 -x 密码 -e 数据库类型 -o 输出路径
typeorm-model-generator -h 127.0.0.1 -p 3306 -d nest_test -u root -x 123456 -e mysql -o .
9. CRUD 操作
9.1 基本使用
- 在
user.module.ts
导入
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";
@Module({
// 导入
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
- 在
user.service.ts
中使用
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { Repository } from "typeorm";
@Injectable()
export class UserService {
// 依赖注入
constructor(
// readonly可加可不加
@InjectRepository(User) private readonly userRepository: Repository<User>
) {}
async getUser() {
let res = await this.userRepository.find();
return {
code: 200,
msg: "success",
data: res,
};
}
}
9.2 查找
find()
findAll() {
return this.userRepository.find()
}
findAll()
find(username: string) {
return this.userRepository.findOne({ where: { username } })
}
9.3 新增
create()
:需要传入 User 实体
// 创建User实体
const user = new User();
user.username = "wifi";
user.password = "123456";
this.userRepository.save(userTmp);
create(user: User) {
const userTmp = this.userRepository.create(user)
return this.userRepository.save(userTmp)
}
9.4 更新
update()
update(id: number, user: Partial<User>) {
return this.userRepository.update(id, user)
}
9.5 删除
delete()
delete(id: number) {
return this.userRepository.delete(id)
}
9.6 联合查询:一对一
根据上面的 5 点(一对一关系),进行对表的一对一联合查询
findProfile(id: number) {
return this.userRepository.findOne({
where: {
id
},
relations: {
profile: true
}
})
}
9.6 联合查询:一对多
findUserLogs(id: number) {
const user = this.userRepository.findOne({ where: { id } })
return this.logsRepository.find({
where: {
user // logs返回的是user实体,具体查看上面第6点一对多
},
relations: {
user: true // 返回关联的字段信息
}
})
}
9.9 QueryBuilder 高级查询
聚合,分页查询
findLogsByGroup(id: number) {
// SELECT logs.result, COUNT(logs.result) from logs, user WHERE user.id = logs.userId AND user.id = 2 GROUP BY logs.result;
return this.logsRepository.createQueryBuilder('logs')
.select('logs.result') // logs.result是根据this.logsRepository.createQueryBuilder('logs')的logs来的
.addSelect('COUNT(logs.result)', 'count')
.leftJoinAndSelect('logs.user', 'user') // user对查询出来的字段取别名
.where('user.id = :id', { id }) // :id这种写法是为了防止sql注入
.groupBy('logs.result')
.orderBy('result', 'DESC') // 对result字段进行倒序排序
.addOrderby('count', 'DESC')
.offset(2)
.limit(10)
.getRawMany();
}
9.10 TypeORM 使用原生 sql 查询
通过query()
this.userRepository.query("select * from logs");
9.11 remove()
和delete()
的区别
建议使用:
remove()
remove
可以一次性删除单个或者多个实例,并且remove
可以触发BeforeRemove
,AfterRemove
钩子;
await repository.remove(user); // user实体
await repository.remove([user1, user2, user3]);
钩子函数写在实体类上面:
user.entity.ts
中:
import { Logs } from "src/logs/logs.entity";
import { AfterInsert, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { Roles } from "../roles/roles.entity";
import { Profile } from "./profile.entity";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
// AfterRemove 钩子函数
@AfterInsert()
afterInsert() {
console.log("afterInsert", this.id, this.username); // 可以拿到数据
}
}
delete
可以一次性删除单个或多个 id 实例,或者给定条件,delete()
是硬删除
await repository.delete(1); // id
10. 数据库代码重构
10.1 写法 1:forRoot 同步写法
- 在项目根目录中创建
ormconfig.ts
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { User } from "./src/user/user.entity";
import { Logs } from "./src/logs/logs.entity";
import { Roles } from "./src/roles/roles.entity";
import { Profile } from "./src/user/profile.entity";
import * as fs from "fs";
import * as dotenv from "dotenv";
import { DataSource, DataSourceOptions } from "typeorm";
// 通过环境变量读取不同的.env文件
const getEnv = (env: string): Record<string, unknown> => {
if (fs.existsSync(env)) {
// 判断文件路径是否存在
return dotenv.parse(fs.readFileSync(env));
}
return {};
};
// 通过dotenv来解析不同的配置
const buiildConnectionOptions = () => {
const defaultConfig = getEnv(".env");
const envConfig = getEnv(`.env.${process.env.NODE_ENV || "development"}`);
// 配置合并
const config = { ...defaultConfig, ...envConfig };
const entitiesDir =
process.env.NODE_ENV === "development"
? [__dirname + "/**/*.entity.ts"]
: [__dirname + "/**/*.entity{.js,.ts"];
return {
type: "mysql",
host: config["DB_HOST"],
port: config["DB_PORT"],
username: "nest_test",
password: "123456",
database: "nest_test",
// entities: [User, Profile, Roles, Logs],
// 如果导入文件过多,依次导入会非常麻烦(windows下路径会有问题)
entities: entitiesDir,
synchronize: true,
logging: ["error"],
} as TypeOrmModuleOptions;
};
export const connectionParams = buiildConnectionOptions();
export default new DataSource({
...connectionParams,
migrations: ["src/migrations/**"],
subscribers: [],
} as DataSourceOptions);
- 在
app.module.ts
中
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { connectionParams } from "ormconfig";
import { UserModule } from "./user/user.module";
@Module({
imports: [UserModule, TypeOrmModule.forRoot(connectionParams)],
controllers: [],
providers: [],
})
export class AppModule {}
根据上面步骤,就将app.module.ts
中的数据库配置文件提取到ormconfig.ts
中了。
10.2 写法 2:forRootAsync 异步写法
- 新建
TypeOrmConfigService.ts
文件
import { Injectable } from "@nestjs/common";
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from "@nestjs/typeorm";
import { User } from "./user/user.entity";
@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
createTypeOrmOptions(): TypeOrmModuleOptions {
return {
type: "mysql",
host: "localost",
port: 3306,
username: "root",
password: "123456",
database: "nest_test",
entities: [User],
synchronize: true,
logging: ["error"],
};
}
}
- 在
app.module.ts
中使用
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./user/user.module";
import { TypeOrmConfigService } from "./TypeOrmConfigService";
import { User } from "./user/user.entity";
@Module({
imports: [
UserModule,
TypeOrmModule.forRootAsync({
// useClass: 使用该类
useClass: TypeOrmConfigService,
}),
// 这里一定要在 imports 中导入使用的实体类,否则 nestjs 框架无法通过 new 进行实例化相应类型的实例对象
TypeOrmModule.forFeature([User]),
],
controllers: [],
providers: [],
})
export class AppModule {}
十三、JWT 登陆鉴权
1. 安装
npm install --save @nestjs/jwt passport-jwt
npm i @nestjs/passport
npm i @types/passport-jwt -D
2. auth 模块(在 1 模块使用 2 模块)
- 新建文件
-
新建
auth
目录 -
新建
auth/auth.module.ts
文件 -
新建
auth/auth.controller.ts
文件 -
新建
auth/auth.service.ts
文件
-
在
app.module.ts
中导入auth
模块 -
在
auth
模块使用user
模块的service
- 在
user.module.ts
导出
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
@Module({
controllers: [UserController],
providers: [UserService],
// 要在auth模块使用,需要将userService导出
exports: [UserService],
})
export class UserModule {}
- 在
auth.module.ts
导入userService
// auth.module.ts
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UserService } from "src/user/user.service";
@Module({
controllers: [AuthController],
providers: [AuthService, UserService],
})
export class AuthModule {}
auth.controller.ts
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signin")
signin(@Body() dto: { username: string; password: string }) {
const { username, password } = dto;
if (!username || !password) {
throw new HttpException(
"用户名或密码不能为空",
HttpStatus.EXPECTATION_FAILED
);
}
return this.authService.signin(username, password);
}
@Post("signup")
signup(@Body() dto: { username: string; password: string }) {
const { username, password } = dto;
return this.authService.signup(username, password);
}
}
- 在
auth.service.ts
中使用
import { Injectable } from "@nestjs/common";
import { UserService } from "../user/user.service";
// service一定要加@Injectable,这个DI系统才会把他添加为一个实例
@Injectable()
export class AuthService {
// 使用user
constructor(private userService: UserService) {}
signin(username: string, password: string) {
let res = this.userService.getUser();
return {
code: 200,
data: {
username,
password,
},
res,
};
}
signup(username: string, password: string) {
/**
* 用户信息的ts类型
* type User = {
* username: string,
* password: string,
* age: number;
* gender: string;
* }
* 但是在登陆注册时,不需要传递这么多参数,但是ts会报错,可以使用ts的Partial类型,将User类型转换为Partial类型,即所有属性都是可选的
*/
return {
code: 200,
data: {
username,
password,
},
};
}
}
3. JWT 集成(正式)
3.1 生成 token
登录接口返回 token:
流程:sign 接口 -> service -> jwtService(jwtModule 内置的)
- 在
auth.module.ts
中:
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { UserService } from "../user/user.service";
@Module({
imports: [
// 注册秘钥:写法1
// JwtModule.register({
// secret: 'c6beBF1C-6Df4-4e1E-22B6-BfbccB8d8FDc', // 设置秘钥
// signOptions: { expiresIn: '60s' }, // 过期时间
// })
// 注册秘钥:写法2(异步读取环境变量)
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
secret: configService.get<string>("SECRET"), // 设置秘钥
signOptions: { expiresIn: "1d" }, // 过期时间:1天
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, UserService],
})
export class AuthModule {}
- 在
auth.service.ts
中书写业务逻辑
this.jwt.sign()
生成 token 信息
this.jwt.verify()
校验 token 信息
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { UserService } from "../user/user.service";
import { JwtService } from "@nestjs/jwt";
// 一定要加@Injectable,这个DI系统才会把他添加为一个实例
@Injectable()
export class AuthService {
constructor(private userService: UserService, private jwt: JwtService) {}
async signin(username: string, password: string) {
// 通过userService的getUser方法获取用户的用户名和密码
let user = this.userService.getUser(username);
// 对用户密码进行比对,密码正确执行下面if判断
if (user && user.password === password) {
// 验证用户密码是否正确,正确后生成token
// this.jwt.sign() 生成token信息
// this.jwt.verify() 校验token信息
return await this.jwt.signAsync(
{
username: user.username,
id: "真实的用户id",
},
{
expiresIn: "1d", // 局部设置过期时间
}
);
}
// 密码不正确,返回身份信息校验失败
throw new UnauthorizedException();
}
}
- sign 接口,在
auth.controller.ts
中:
import { Body, Controller, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
// 校验参数,校验失败响应错误信息
import { SigninUserDto } from "./dto/signin-user.dto";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signin")
async signin(@Body() dto: SigninUserDto) {
const { username, password } = dto;
// 密码正确,返回生成的token
const token = await this.authService.signin(username, password); // 登录成功,拿到用户token信息
return {
access_token: token,
};
}
}
- 发送请求
curl
为:
curl --location --request POST 'http://127.0.0.1:3000/api/v1/auth/signin' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:3000' \
--header 'Connection: keep-alive' \
--data-raw '{
"username": "xiaotianwifi",
"password": "123456"
}'
- 返回的结果为:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyNDA3NzMsImV4cCI6MTcyMTMyNzE3M30.S8mGaeADX3kxYWqkovv_s3SWmECiB6GRo-tlk2psUco"
}
这样就实现了 token 的颁发,前端在需要登录的接口,在Headers
的Authorization
中携带 token,通过下面讲的校验token
即可对用户身份进行校验。
3.2 校验 token
- 在
jwt.strategy.ts
通过要求在请求中存在有效的 JWT 来保护端点。Passport 也可以帮助我们实现这一点。它提供了passport-jwt
策略,用于使用 JSON Web Token 保护 RESTful 端点。首先,在 auth
文件夹中创建一个名为 jwt.strategy.ts
的文件,并添加以下代码:
import { ExtractJwt, Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(protected configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>("SECRET"),
});
}
// 对token进行校验
async validate(payload: any) {
// 将数据加到request.user上
return { userId: payload.sub, username: payload.username };
}
}
- 在
auth.module.ts
中
-
导入 Passport
-
将 JwtStrategy 注入到 DI 系统中,供 passportModule 使用
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PassportModule } from "@nestjs/passport";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtStrategy } from "./jwt.strategy";
import { UserService } from "../user/user.service";
@Module({
imports: [
// 导入Passport
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
secret: configService.get<string>("SECRET"),
signOptions: { expiresIn: "1d" },
};
},
}),
],
controllers: [AuthController],
providers: [
AuthService,
// 将JwtStrategy注入到DI系统中,供passportModule使用
JwtStrategy,
UserService,
],
})
export class AuthModule {}
- 在
user.controller.ts
中
例如:创建用户接口,需要判断用户是否有 token
@UseGuards(AuthGuard('jwt'))
守卫AuthGuard
的校验逻辑是在jwt.strategy.ts
的validate
中定义的
import { Body, Controller, Post, Req, UseGuards } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { AuthGuard } from "@nestjs/passport";
@Controller("user")
// 守卫AuthGuard的校验逻辑是在jwt.strategy.ts的validate中定义的
// 对所有user模块接口都校验
@UseGuards(AuthGuard("jwt"))
export class UserController {
constructor(private userService: UserService) {}
@Post("create")
// 只校验create接口
@UseGuards(AuthGuard("jwt"))
createUser(@Body() dto: CreateUserDto, @Req() req) {
// @Req() req这里的req.user是通过AuthGuard('jwt')中的validate方法返回的
// PassportModule来添加的(自动添加的)
console.log(req.user);
const { username, password } = dto;
return this.userService.createUser(username, password);
}
}
- 发送请求
curl
为:
curl --location --request POST 'http://127.0.0.1:3000/api/v1/user/create' \
--header 'Authorization: Bearer yJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyMzY4NTIsImV4cCI6MTcyMTMyMzI1Mn0.lUttPVP8Q-h11zXpIIH5MZ9pS5a-mmqDXAeXCH5wR5E' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:3000' \
--header 'Connection: keep-alive' \
--data-raw '{
"username": "xiaotianwifi",
"password": "123456"
}'
请求头携带的 token 信息
--header 'Authorization: Bearer yJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyMzY4NTIsImV4cCI6MTcyMTMyMzI1Mn0.lUttPVP8Q-h11zXpIIH5MZ9pS5a-mmqDXAeXCH5wR5E
- 响应结果为:(正确 token)
{
"msg": "success",
"username": "xiaotianwifi",
"password": "123456"
}
- 响应结果为:(错误 token)
{
"message": "Unauthorized",
"statusCode": 401
}
3.3 校验 JWT 代码优化
新建guards
目录,新建jwt.guard.ts
import { AuthGuard } from "@nestjs/passport";
export class JwtGuard extends AuthGuard("jwt") {
constructor() {
// 调用super执行AuthGuard
super();
}
}
在使用@UseGuards(AuthGuard('jwt'))
的地方可以替换成@UseGuards(JwtGuard)
Post('create')
// 原
// @UseGuards(AuthGuard('jwt'))
// 优化后
@UseGuards(JwtGuard)
createUser(@Body() dto: CreateUserDto, @Req() req) {
const { username, password } = dto;
return this.userService.createUser(username, password);
}
十四、授权
1. 授权是什么
授权(Authorization)也叫权限控制,指的是确定用户能够执行什么操作的过程。例如,管理员用户被允许创建、编辑和删除帖子。非管理员用户只能被授权阅读帖子。
授权与认证是正交且独立的。然而,授权需要认证机制。
处理授权有许多不同的方法和策略。采用的方法取决于项目的特定应用需求。下面介绍基于RBAC
和CASL
实现。
区别:
RBAC
:基于角色的权限控制
CASL
:基于策略的权限控制
2. 基于RBAC实现-权限控制
2.1 什么是RBAC
RBAC
:基于角色的访问控制(RBAC),是围绕角色和权限定义的策略中立的访问控制机制。
2.2 实现步骤
- 在
src
目录下新建enum
目录,在enum
目录新建role.enum.ts
文件
该文件用于设置角色权限的常量
export enum Role {
User = 'user',
Admin = 'admin',
}
- 新建
decorators
目录,在decorators
目录下新建roles.decorator.ts
文件
该文件用于设置路由的注解信息
SetMetadata
设置注解信息
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enum/role.enum';
export const ROLES_KEY = 'roles'; // ROLES_KEY:自定义
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); // SetMetadata设置注解信息
- 新建
guards
目录,在guards
目录下新建role.guard.ts
文件
该文件用于创建自定义角色路由守卫,用来判断用户是否有权限访问该接口
getAllAndOverride
:读取路由上的metadata
getAllAndMerge
:合并两者
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enum/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserService } from '../user/user.service';
@Injectable()
export class RoleGuard implements CanActivate {
constructor(
// 去访问加在controller上的注解信息的
private reflector: Reflector,
private userService: UserService
) {}
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
// 思路:jwt -> userId -> user -> roles
// jwt部分需要知道useGuards的执行顺序
/**
* getAllAndOverride:读取路由上的metadata
* getAllAndMerge:合并两者
*/
const requireRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), // 从路由上读
context.getClass() // 从controller上读
])
console.log(requireRoles); // 返回的是一个数组 [ 'user' ]
if (!requireRoles) {
return true
}
const req = context.switchToHttp().getRequest()
const user = this.userService.getUser(req.user.username)
/**
* 获取到user实体上的,判断是否有权限,有则返回true,没有返回false
*/
// user.role.map......
return true
}
}
- 在
controller
中进行拦截限制,在role.controller.ts
中
注:需要知道
@UseGuards()
的执行顺序
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../decorators/roles.decorator';
import { Role } from '../enum/role.enum';
import { RoleGuard } from '../guards/role.guard';
import { JwtGuard } from '../guards/jwt.guard';
@Controller('role')
@Roles(Role.Admin) // 添加注解信息
@UseGuards(JwtGuard, RoleGuard) // 需要知道@UseGuards()的执行顺序,否则拿不到req.user的信息,在JWT部分有讲
export class RoleController {
@Get()
@Roles(Role.User)
findAll() {
return 'ok';
}
}
3. 基于CASL实现
3.1 什么是CASL
CASL 是一个可用于浏览器和服务器的授权库,它限制了特定客户端被允许访问的资源。它被设计成可以逐步采用,可以在基于简单声明的授权和基于完整主题和属性的授权之间轻松扩展。
官网地址:https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types
3.2 安装
安装 @casl/ability
包
npm i @casl/ability
3.3 casl库的基本使用
通过语义化的can
和cannot
就可以完成权限控制
// import { defineAbility } from '@casl/ability'
const { defineAbility } = require('@casl/ability')
const user = {
id: 1,
isAdmin: false
}
class Post {
constructor(attrs) {
Object.assign(this, attrs)
}
}
const ability = defineAbility((can, cannot) => {
can('read', 'all') // 'read'是自定义的,只要跟后面匹配就行
/**
* 文章没有发布,isPublished为false,可以更新;
* 文章发布,isPublished为true,不允许更新,只有管理员可以
* can('update', 'Post', { isPublished: false, author: user.id })
*/
// ['content']:对Post某些字段做一些限制,表示用户只能对content字段进行修改,在下面flag的时候就可以添加判断条件
can('update', 'Post', ['content'], { isPublished: false, author: user.id })
cannot('delete', 'Post')
if (user.isAdmin) {
// 设置管理员可以更新和删除
can('update', 'Post')
can('delete', 'Post')
}
})
const somePost = new Post({ author: 1, isPublished: false })
// const flag = ability.can('update', somePost) // true
// 给flag添加判断条件
const flag = ability.can('update', somePost, 'id') // false
console.log(flag);
3.4 casl与nestjs的集成
- 为了说明 CASL 的机制,我们将定义两个实体类:
User
和Article
。
class User {
id: number;
isAdmin: boolean;
}
User
类由两个属性组成,id
是唯一的用户标识,isAdmin
表示用户是否具有管理员权限。
class Article {
id: number;
isPublished: boolean;
authorId: number;
}
Article
类有 3 个属性,分别为id
、isPublished
、authorId
。id
为文章唯一标识,isPublished
为文章是否已发表,authorId
为撰写文章的用户 ID。
- 例子需求分析
- 管理员可以管理(创建/读取/更新/删除)所有实体
- 用户对所有内容都具有只读访问权限
- 用户可以更新他们的文章 (
article.authorId === userId
) - 已发布的文章无法删除 (
article.isPublished === true
)
- 在
src
目录下新建enum
目录,新建action.enum.ts
可以从创建一个 Action
枚举开始,该枚举表示用户可以对实体执行的所有可能操作:
export enum Action {
Manage = 'manage',
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
⚠️注意:
manage
是 CASL 中的一个特殊关键字,代表 “any” 动作。
- 在
auth
目录下新建CaslAbilityFactory.ts
,主要用来创建ability
对象的
CaslAbilityFactory.ts
也可以叫casl.service.ts
,根据文件名可以知道是需要在module
中providers
和exports
的。
可以在 CaslAbilityFactory
上定义 createForUser()
方法。此方法将为给定用户创建 Ability
对象:
import { Injectable } from "@nestjs/common";
import { Article, User } from "./auth.dto";
import { AbilityBuilder, ExtractSubjectType, InferSubjects, createMongoAbility } from "@casl/ability";
import { Action } from "../enum/action.enum";
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
@Injectable()
export class CaslAbilityService {
createForUser(user: User) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility)
if (user.isAdmin) {
can(Action.Manage, 'all')
} else {
can(Action.Read, 'all')
}
can(Action.Update, Article, { authorId: user.id })
cannot(Action.Delete, User, { isAdmin: true })
return build({
detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>
})
}
}
all
是 CASL 中的一个特殊关键字,代表 “任何科目”。
使用 AbilityBuilder
类创建了 Ability
实例。can
和 cannot
接受相同的参数但具有不同的含义,can
允许对指定的主题执行操作,而 cannot
禁止。两者都可以接受最多 4 个参数。
- 在
src/guards
目录下新建casl.guard.ts
文件,主要用来创建自定义管道的
如果没有该操作权限,则被拦截,返回false
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { CaslAbilityService } from '../auth/casl-ability.service';
import { Article, User } from "../auth/auth.dto"
import { Action } from "../enum/action.enum";
@Injectable()
export class CaslGuard implements CanActivate {
constructor(
private caslAbilityService: CaslAbilityService
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const user = new User();
user.isAdmin = false
let flag = true
const ability = this.caslAbilityService.createForUser(user)
// 验证
flag = ability.can(Action.Read, Article); // true
// flag = ability.can(Action.Delete, Article); // false
// flag = ability.can(Action.Create, Article); // false
return flag
}
}
验证:
const user = new User();
user.isAdmin = false;
const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Read, Article); // true
ability.can(Action.Delete, Article); // false
ability.can(Action.Create, Article); // false
const user = new User();
user.id = 1;
const article = new Article();
article.authorId = user.id;
const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Update, article); // true
article.authorId = 2;
ability.can(Action.Update, article); // false
- 在
user.controller.ts
中使用
主要的操作判断还是在5的casl.guard.ts
管道中
import { Controller, Get, UseGuards } from '@nestjs/common';
import { CaslGuard } from '../guards/casl.guard';
@Controller('user')
@UseGuards(CaslGuard)
export class UserController {
@Get()
getUser() {
return 'success'
}
}
3.5 高级:使用自定义装饰器(让casl更灵活)
在3.4的第4点CaslAbilityFactory.ts
中的can
和cannot
是在内部实现的,其实可以通过自定义装饰器来动态控制需要拦截的参数
- 新建
casl.service.ts
文件
import { Injectable } from "@nestjs/common";
import { Article, User } from "./auth.dto";
import { AbilityBuilder, ExtractSubjectType, InferSubjects, createMongoAbility } from "@casl/ability";
import { Action } from "../enum/action.enum";
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
@Injectable()
export class CaslAbilityService {
createForUser(user: User) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility)
// can('manage', 'all') // manage all是内部保留字段
// 这部分的数据,要通过数据库查询到用户的操作权限,进而做出判断
can('read', User)
cannot('update', User)
const ability = build({
detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>
})
return ability
}
}
通过
can/cannot
方法来配置权限,使用控制反转,将如何使用交给用户ability.can(操作权限, 实体, [字段]) ability.can('update', User, 'id') // id:具体根据User中的某个字段来限制
- 新建两个枚举文件
- 新建
casl.enum.ts
文件
该文件用于根据不同自定义装饰器,使用SetMetadata
设置不同的meta字段
export enum CheckPoliciesKey {
Handler = 'CheckPolicies_Handler',
Can = 'CheckPolicies_Can',
Cannot = 'CheckPolicies_Cannot'
}
- 新建
action.enum.ts
文件
该文件用于枚举出不同权限用户可以做出的操作策略
export enum Action {
Manage = 'manage',
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete'
}
- 新建
casl.decorator.ts
文件
思路:实现:
Guards
->routes meta
->@CheckPolicies
@Can
@Cannot
@CheckPolicies
:接收一个函数,传递一个ability,返回一个布尔值
@Can/@Cannot
:接收两/三个参数:Action
、Subject
、Condition
该参数通过
Controller
使用自定义装饰器时传参
import { AnyMongoAbility, InferSubjects } from '@casl/ability';
import { SetMetadata } from '@nestjs/common';
import { Action } from '../enum/action.enum';
import { CheckPoliciesKey } from '../enum/casl.enum';
type PolicyHandleCallback = (ability: AnyMongoAbility) => boolean;
// @CheckPolicies:接收一个函数,传递一个ability,返回一个布尔值
export const CheckPolicies = (...handlers: PolicyHandleCallback[]) =>
SetMetadata(CheckPoliciesKey.Handler, handlers);
// export const Can = (action: Action, subject: InferSubjects<any>, conditions?: any) => {
// console.log(action, subject, conditions);
// /**
// * 形参ability:通过SetMetadata,传递meta信息为一个函数,在casl.guard.ts文件中调用该函数并传递ability参数
// * 实参ability:在自定义管道中传参
// */
// // ability.can(Action.Read, 'all')
// let fn = (ability: AnyMongoAbility) => ability.can(action, subject, conditions)
// return SetMetadata(CheckPoliciesKey.Can, fn)
// }
// @Can:接收两/三个参数:Action、Subject、Condition
export const Can = (
action: Action,
subject: InferSubjects<any>,
conditions?: any,
) =>
SetMetadata(CheckPoliciesKey.Can, (ability: AnyMongoAbility) =>
ability.can(action, subject, conditions),
);
// @Cannot:接收两/三个参数:Action、Subject、Condition
export const Cannot = (
action: Action,
subject: InferSubjects<any>,
conditions?: any,
) =>
SetMetadata(CheckPoliciesKey.Cannot, (ability: AnyMongoAbility) =>
ability.can(action, subject, conditions),
);
形参ability
:通过SetMetadata,传递meta信息为一个函数,在casl.guard.ts文件中调用
该函数并传递ability参数(在3、casl.decorator.ts
中)实参ability
:在自定义管道中传参(在4、casl.guard.ts
文件中)
该文件用来创建自定义装饰器,供controller使用,传递的参数会加到SetMetadata
中
let fn = (ability: AnyMongoAbility) => ability.can(action, subject, conditions)
return SetMetadata(CheckPoliciesKey.Can, fn)
- 新建
casl.guard.ts
文件
该文件用来创建自定义管道
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"
import { Reflector } from '@nestjs/core'
import { CheckPoliciesKey } from "../enum/casl.enum"
import { CaslAbilityService } from '../auth/casl-ability.service';
import { Article, User } from "../auth/auth.dto"
import { Action } from "../enum/action.enum";
import { AnyMongoAbility } from "@casl/ability";
type PolicyHandleCallback = (ability: AnyMongoAbility) => boolean;
@Injectable()
export class CaslGuard implements CanActivate {
constructor(
private reflector: Reflector, // 用于读取meta字段
private caslAbilityService: CaslAbilityService
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
// 读取meta字段
// @CheckPolicies
const handlers = this.reflector.getAllAndOverride(CheckPoliciesKey.Handler, [
context.getHandler(),
context.getClass()
]) as Partial<PolicyHandleCallback | PolicyHandleCallback[]>
// @Can
const canHandlers: Partial<PolicyHandleCallback | PolicyHandleCallback[]> = this.reflector.getAllAndOverride(CheckPoliciesKey.Can, [
context.getHandler(),
context.getClass()
])
// @Cannot
const cannotHandlers = this.reflector.getAllAndOverride<Partial<PolicyHandleCallback | PolicyHandleCallback[]>>(CheckPoliciesKey.Cannot, [
context.getHandler(),
context.getClass()
])
// 判断:如果用户未设置上述任何一个,则返回true
if (!handlers && !canHandlers && !cannotHandlers) {
return true
}
// 获取到ability,通过ability.can(Action.Read, Article)进行角色操作权限判断
const ability = this.caslAbilityService.createForUser()
let flag = true
if (handlers) {
if (handlers instanceof Array) {
flag = flag && handlers.every(handler => {
/**
* 本质是通过 ability.can(Action.Read, Article); // true/false 来判断
* 但是这里使用到了控制反转,每个参数都是函数,函数在SetMetadata的第二个参数
*/
return handler(ability)
})
} else if (typeof handlers === 'function') {
flag = flag && handlers(ability)
}
}
if (flag && canHandlers) {
flag = flag && (Array.isArray(canHandlers) ? canHandlers : [canHandlers]).every(handler => handler(ability))
}
if (flag && cannotHandlers) {
flag = flag && (Array.isArray(cannotHandlers) ? cannotHandlers : [cannotHandlers]).every(handler => handler(ability))
}
return flag
}
}
⚠️注意:在使用
CaslAbilityService
时候,需要将CaslAbilityService
从模块到处,在使用他的模块中进行导入
这里比较难理解的是:使用了控制反转,将
ability.can()
的使用,交给了自定义装饰器的传参。
handlers
、canHandlers
、cannotHandlers
三个都是函数/函数数组【fn】(在3、casl.decorator.ts
文件中let fn = (ability: AnyMongoAbility) => ability.can(action, subject, conditions) return SetMetadata(CheckPoliciesKey.Can, fn)
- 使用
⚠️注意:在1、casl.service.ts
中限制了user的操作:
这部分的数据,要通过数据库查询到用户的操作权限,进而做出判断
can('read', User)
cannot('update', User)
- 在
user.controller.ts
中使用
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Can, CheckPolicies } from '../decorators/casl.decorator';
import { Action } from '../enum/action.enum';
import { CaslGuard } from '../guards/casl.guard';
import { User } from '../auth/auth.dto';
@Controller('user')
@UseGuards(CaslGuard)
@CheckPolicies((ability) => ability.can(Action.Read, User))
@Can(Action.Read, User) // 通过
export class UserController {
@Get()
@Can(Action.Update, User) // 不通过
getUser() {
return 'success'
}
}
- 结果:
当不使用@Can(Action.Update, User)
时,会响应success
;
当使用后,则响应:
{
"message": "Forbidden resource",
"error": "Forbidden",
"statusCode": 403
}
十五、日志收集
1. 使用 NestJS 内置的日志模块
内置Logger
实例
1.1 在main.ts
全局日志
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { Logger } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// logger: false // 关闭所有日志
logger: ["error", "warn"], // 只打印错误和警告日志
});
app.setGlobalPrefix("/api/v1");
const port = 3000;
await app.listen(port);
// 打印日志
const logger = new Logger();
logger.log(`服务允许在${port}端口`);
logger.warn(`服务允许在${port}端口`);
logger.error(`服务允许在${port}端口`);
}
bootstrap();
[Nest] 9232 - 2024/07/15 22:42:00 LOG 服务允许在3000端口 # 颜色为绿色
[Nest] 9232 - 2024/07/15 22:42:00 WARN 服务允许在3000端口 # 颜色为橙色
[Nest] 9232 - 2024/07/15 22:42:00 ERROR 服务允许在3000端口 # 颜色为红色
1.2 在user.controller.ts
中
import { Controller, Get, Logger } from "@nestjs/common";
import { UserService } from "./user.service";
@Controller("user")
export class UserController {
private logger = new Logger(UserController.name); // 区分模块名称
constructor(private userService: UserService) {
this.logger.log("UserController Init");
}
@Get("user")
findAll(): Object {
this.logger.log(`请求findAll成功`);
return this.userService.findAll();
}
}
- 在
UserController
初始化好后,会执行:
private logger = new Logger(UserController.name) // 区分模块名称
constructor(
private userService: UserService
) {
this.logger.log('UserController Init')
}
# LOG [UserController] 这个里的UserController就是new Logger(UserController.name)的UserController.name
[Nest] 9232 - 2024/07/15 22:42:00 LOG [UserController] UserController Init
- 接口 log 日志
@Get('user')
findAll(): Object {
this.logger.log(`请求findAll成功`)
return this.userService.findAll()
}
[Nest] 9232 - 2024/07/15 22:54:03 LOG [UserController] 请求findAll成功
2. 集成第三方日志模块-Pino
2.1 安装
npm i nestjs-pino
2.2 使用
- 在
user.module.ts
中导入
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { LoggerModule } from "nestjs-pino";
@Module({
imports: [
TypeOrmModule.forFeature([User]),
// 导入pino日志模块
LoggerModule.forRoot(),
],
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
- 在
user.controller.ts
使用
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { Logger } from "nestjs-pino";
@Controller("user")
export class UserController {
constructor(private userService: UserService, private logger: Logger) {
this.logger.log("UserService Init");
}
@Get("user")
findAll(): Object {
this.logger.log("findAll接口请求成功");
return this.userService.findAll();
}
}
{"level":30,"time":1721055802833,"pid":34300,"hostname":"WKQT253","msg":"UserService Init"}
- this.logger.log(‘findAll 接口请求成功’)
{"level":30,"time":1721056214051,"pid":9076,"hostname":"WKQT253","req":{"id":1,"method":"GET","url":"/api/v1/user/user","query":{},"params":{"0":"user/user"},"headers":{"host":"localhost:3000","connection":"keep-alive","cache-control":"max-age=0","sec-ch-ua":"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Windows\"","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","sec-fetch-site":"none","sec-fetch-mode":"navigate","sec-fetch-user":"?1","sec-fetch-dest":"document","accept-encoding":"gzip, deflate, br, zstd","accept-language":"zh-CN,zh;q=0.9","if-none-match":"W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""},"remoteAddress":"::1","remotePort":58082},"msg":"findAll接口请求成功"}
# 默认在接口请求的时候会打印一次
{"level":30,"time":1721056214078,"pid":9076,"hostname":"WKQT253","req":{"id":1,"method":"GET","url":"/api/v1/user/user","query":{},"params":{"0":"user/user"},"headers":{"host":"localhost:3000","connection":"keep-alive","cache-control":"max-age=0","sec-ch-ua":"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Windows\"","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","sec-fetch-site":"none","sec-fetch-mode":"navigate","sec-fetch-user":"?1","sec-fetch-dest":"document","accept-encoding":"gzip, deflate, br, zstd","accept-language":"zh-CN,zh;q=0.9","if-none-match":"W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""},"remoteAddress":"::1","remotePort":58082},"res":{"statusCode":304,"headers":{"x-powered-by":"Express","etag":"W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""}},"responseTime":28,"msg":"request completed"}
2.3 pino-pretty 解决日志“丑”的方案
pino
终端打印的日志不便于查看,使用pino-pretty
中间件解决
- 安装
npm i pino-pretty
- 使用:在
user.module.ts
中
imports: [
LoggerModule.forRoot({
pinoHttp: {
transport: {
// 无需用import导入,只用安装即可
target: 'pino-pretty',
options: {
colorize: true
}
}
}
})
],
- this.logger.log(‘UserService Init’)
[23:17:37.516] INFO (37496): UserService Init
- 请求接口时
[23:24:59.120] INFO (31880): request completed
req: {
"id": 1,
"method": "GET",
"url": "/api/v1/user/user",
"query": {},
"params": {
"0": "user/user"
},
"headers": {
"host": "localhost:3000",
"connection": "keep-alive",
"cache-control": "max-age=0",
"sec-ch-ua": "\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"upgrade-insecure-requests": "1",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"sec-fetch-site": "none",
"sec-fetch-mode": "navigate",
"sec-fetch-user": "?1",
"sec-fetch-dest": "document",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "zh-CN,zh;q=0.9",
"if-none-match": "W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""
},
"remoteAddress": "::1",
"remotePort": 60401
}
res: {
"statusCode": 304,
"headers": {
"x-powered-by": "Express",
"etag": "W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""
}
}
responseTime: 29
2.4 pino-roll 用于生产环境(滚动日志)
pino-roll
可以将日志输出到文件
- 安装
npm i pino-roll
- 使用:在
user.module.ts
中
import { join } from "path";
LoggerModule.forRoot({
pinoHttp: {
transport: {
// targets设置多个中间件
targets: [
{
level: "info",
target: "pino-pretty",
options: {
colorize: true,
},
},
{
level: "info",
target: "pino-roll",
options: {
file: join("log", "log.txt"),
frequency: "daily",
size: "10m",
mkdir: true,
},
},
],
},
},
});
2.5 生产环境,开发环境全局配置
在app.module.ts
中
LoggerModule.forRoot({
pinoHttp: {
transport: {
targets: [
process.env.NODE_ENV === "development"
? {
// 安装pino-pretty
level: "info",
target: "pino-pretty",
options: {
colorize: true,
},
}
: {
// 安装pino-roll
level: "info",
target: "pino-roll",
options: {
file: join("log", "log.txt"),
frequency: "daily",
size: "10m",
mkdir: true,
},
},
],
},
},
});
3. 集成第三方日志模块-winston
3.1 安装
npm i nest-winston winston
3.2 使用
- 在
main.ts
中
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
// 导入包
import { createLogger } from "winston";
import * as winston from "winston";
import { WinstonModule, utilities } from "nest-winston";
async function bootstrap() {
const instance = createLogger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(), // 日志时间
utilities.format.nestLike()
),
}),
],
});
const app = await NestFactory.create(AppModule, {
// 重构nest的logger实例
logger: WinstonModule.createLogger({
instance: instance,
}),
});
app.setGlobalPrefix("/api/v1");
const port = 3000;
await app.listen(port);
}
bootstrap();
- 在
app.module.ts
中
如果要全局注册使用 logger,需要在app.module.ts
中使用@Global()
注解,使app
模块变成全局模块,进行全局注册,使用exports
将 logger 导出使用
import { Global, Logger, Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
@Global()
@Module({
imports: [UserModule],
controllers: [],
// 从@nestjs/common进行导入。因为在main.ts中重构官方的logger实例
// 全局提供logger
providers: [Logger],
exports: [Logger],
})
export class AppModule {}
- 在
user.controller.ts
进行使用
import { Controller, Get, Inject, Logger, LoggerService } from "@nestjs/common";
@Controller("user")
export class UserController {
constructor(
// 写法1:
// @Inject(Logger) private readonly logger: LoggerService
// 写法2:
private readonly logger: Logger
) {
this.logger.log("init");
}
}
缺点:需要打印日志的时候,要手动导入 logger
3.3 winston-daily-rotate-file 滚动日志
- 安装
npm i winston-daily-rotate-file
- 使用:在
main.ts
中
-
import 'winston-daily-rotate-file'
文件全部导入 -
new winston.transports.DailyRotateFile
记录日志
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { createLogger } from "winston";
import * as winston from "winston";
import { WinstonModule, utilities } from "nest-winston";
import "winston-daily-rotate-file";
async function bootstrap() {
const instance = createLogger({
transports: [
new winston.transports.Console({
level: "info",
format: winston.format.combine(
// winston.format.timestamp() 日志时间
winston.format.timestamp(),
utilities.format.nestLike()
),
}),
// 将日志记录到文件中
new winston.transports.DailyRotateFile({
level: "info",
format: winston.format.combine(
// winston.format.timestamp() 日志时间
winston.format.timestamp(),
winston.format.simple()
),
dirname: "log",
filename: "application-%DATE%.log",
datePattern: "YYYY-MM-DD-HH",
zippedArchive: true, // 文件压缩
maxSize: "20m",
maxFiles: "15d", // 文件保存时间:15天
}),
],
});
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger({
instance: instance,
}),
});
app.setGlobalPrefix("/api/v1");
const port = 3000;
await app.listen(port);
}
bootstrap();
- 根据上面 3.2 节,即可正常保存日志到文件中
3.4 全局异常过滤器配合 winston 记录日志
src/filtters/http-exception.filtter.ts
中
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
LoggerService,
} from "@nestjs/common";
@Catch(HttpException)
export class HttpExceptionFiltter implements ExceptionFilter {
// 依赖注入
constructor(private logger: LoggerService) {}
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception.getStatus();
// 记录错误日志
this.logger.error(exception.message, exception.stack);
response.status(status).json({
code: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
msg: exception.message || HttpException.name,
});
}
}
- 在
main.ts
中
app.useGlobalFilters(new HttpExceptionFiltter(logger))
传入 logger 记录日志
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { createLogger } from "winston";
import * as winston from "winston";
import { WinstonModule, utilities } from "nest-winston";
import "winston-daily-rotate-file";
import { HttpExceptionFiltter } from "./filtters/http-exception.filtter";
async function bootstrap() {
const instance = createLogger({
transports: [
new winston.transports.Console({
level: "info",
format: winston.format.combine(
// winston.format.timestamp() 日志时间
winston.format.timestamp(),
utilities.format.nestLike()
),
}),
new winston.transports.DailyRotateFile({
level: "info",
format: winston.format.combine(
// winston.format.timestamp() 日志时间
winston.format.timestamp(),
winston.format.simple()
),
dirname: "log",
filename: "application-%DATE%.log",
datePattern: "YYYY-MM-DD-HH",
zippedArchive: true, // 文件压缩
maxSize: "20m",
maxFiles: "15d", // 文件保存时间:15天
}),
],
});
const logger = WinstonModule.createLogger({
instance: instance,
});
const app = await NestFactory.create(AppModule, {
logger: logger,
});
app.setGlobalPrefix("/api/v1");
// 传入logger记录日志
app.useGlobalFilters(new HttpExceptionFiltter(logger));
const port = 3000;
await app.listen(port);
}
bootstrap();
3.5 日志模块代码重构
通过上面的知识,在main.ts
中的代码非常的臃肿,需要将不同的模块进行抽离,下面将讲述如何抽离日志模块的代码。
- 通过 cli 创建日志模块
nest g mo logs
会在src
下新建logs
目录,并且新建logs.module.ts
文件
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { WinstonModule, WinstonModuleOptions, utilities } from "nest-winston";
import { Console } from "winston/lib/winston/transports";
import * as winston from "winston";
import * as DailyRotateFile from "winston-daily-rotate-file";
const consoleTransPorts = new Console({
level: "info",
format: winston.format.combine(
// winston.format.timestamp() 日志时间
winston.format.timestamp(),
utilities.format.nestLike()
),
});
const dailyTransPorts = new DailyRotateFile({
level: "info",
format: winston.format.combine(
// winston.format.timestamp() 日志时间
winston.format.timestamp(),
winston.format.simple()
),
dirname: "log",
filename: "application-%DATE%.log",
datePattern: "YYYY-MM-DD-HH",
zippedArchive: true, // 文件压缩
maxSize: "20m",
maxFiles: "15d", // 文件保存时间:15天
});
@Module({
imports: [
// 异步导入
WinstonModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
// configService主要用来读取环境变量的
useFactory: (configService: ConfigService) => {
return {
transports: [consoleTransPorts, dailyTransPorts],
} as WinstonModuleOptions;
},
}),
],
})
export class LogsModule {}
- 在
main.ts
中
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";
async function bootstrap() {
const app = await NestFactory.create(AppModule, {});
// 用winston的provider去替换nest的logger
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
app.setGlobalPrefix("/api/v1");
const port = 3000;
await app.listen(port);
}
bootstrap();
- 在
user.controller.ts
文件中使用
import { Controller, Get, Inject, LoggerService } from "@nestjs/common";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";
@Controller("user")
export class UserController {
constructor(
// 注入logger
@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService
) {
this.logger.log("init");
}
@Get("user")
findAll(): Object {
this.logger.log("成功");
return "ok";
}
}
打印结果:
# Nest
[Nest] 38236 - 2024/07/16 03:46:03 LOG [InstanceLoader] UserModule dependencies initialized +4ms
# Winston
[NestWinston] 38236 2024/7/16 03:46:03 LOG init
十六、测试
单元测试(unit test):测试的是局部函数的逻辑
集成测试(e2e test):测试的是整体的流程或者功能完整性
1. 单元测试:单元测试框架Jest
单元测试:后端主要是模拟单个接口方法是否正常
Jest 官网:https://jestjs.io/zh-Hans/
1.1 基本使用
注:该章用的是 nest 中简单对 jest 的使用,如果是其他框架,执行
jest --watch
- 在终端执行
npm run test:watch
,会有以下内容
PASS src/test.spec.ts
PASS src/test/__test__/test.controller.spec.ts
PASS src/test/__test__/test.service.spec.ts
Test Suites: 3 passed, 3 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 4.087 s
Ran all test suites related to changed files.
Watch Usage
› Press a to run all tests. # 终端输入a,会进行所有单元测试
› Press f to run only failed tests.
› Press p to filter by a filename regex pattern. # 输入p,文件名正则表达式模式过滤
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.
- 在
src
下,新建test.spec.ts
文件
describe("test jest hello world", () => {
it("should be true", () => {
expect(true).toBe(true);
});
it("should be true1", () => {
expect(true).toBe(true);
});
it("should be true2", () => {
expect(true).toBe(true);
});
describe("test jest hello world", () => {
it("should be true", () => {
expect(true).toBe(true);
});
it("should be true1", () => {
expect(true).toBe(true);
});
it("should be true2", () => {
expect(true).toBe(true);
});
});
});
- 终端输入
p
Pattern Mode Usage
› Press Esc to exit pattern mode.
› Press Enter to filter by a filenames regex pattern.
pattern › # 这里输入需要测试的文件路径,例如:src/test.spec.ts
会输入以下测试结果:
PASS src/test.spec.ts
# 可以看到describe和it的层级关系
test jest hello world
√ should be true (6 ms)
√ should be true1
√ should be true2 (1 ms)
test jest hello world
√ should be true
√ should be true1
√ should be true2
Test Suites: 1 passed, 1 total
Tests: 6 passed, 6 total
Snapshots: 0 total
Time: 1.93 s, estimated 4 s
Ran all test suites matching /src\\test.spec.ts/i.
Watch Usage: Press w to show more.
1.2 在 Nest 中使用
1.2.1 测试 controller
例如:Auth
模块
在src/auth/__test__/auth.controller.spec.ts
文件中:
import { Test, TestingModule } from "@nestjs/testing";
import { AuthController } from "../auth.controller";
import { AuthService } from "../auth.service";
import { SigninUserDto } from "../dto/signin-user.dto";
describe("AuthController(登录认证模块)", () => {
let controller: AuthController;
/**
* AuthController会依赖到AuthService,由于AuthService没有注入,会测试失败,需要模拟一个AuthService
*/
let mockAuthService: Partial<AuthService>;
beforeEach(async () => {
// 模拟的AuthService,这样就与后续的依赖项无关联了:例如:用到了UserService等无关联的依赖
mockAuthService = {
// 因为该方法返回的是一个Promise<string>
signin: (username: string, password: string) => {
return Promise.resolve("token");
},
};
// 假设了一个测试模块,和auth.module.ts中Module的作用是一样的
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
// 通过module.get可以拿到AuthController实例
controller = module.get<AuthController>(AuthController);
});
it("鉴权-初始化-实例化", () => {
// 使用expect来断言controller是否被创建成功
expect(controller).toBeDefined();
});
it("鉴权-控制器-signin注册", async () => {
const res = controller.signin({
username: "xiaotianwifi",
password: "123456",
} as SigninUserDto);
expect(await res).not.toBeNull();
expect((await res).access_token).toBe("token");
/**
* signin接口返回的是:
* return {
access_token: token,
}
*/
// expect((await res).access_token).toBe('token1') // 测试失败
});
});
测试结果为:
PASS src/auth/__test__/auth.controller.spec.ts
AuthController(登录认证模块)
√ 鉴权-初始化-实例化 (15 ms)
√ 鉴权-控制器-signin注册 (2 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.177 s
Ran all test suites related to changed files.
Watch Usage: Press w to show more.
1.2.2 测试 service
在src/auth/__test__/auth.service.spec.ts
文件中:
import { Test, TestingModule } from "@nestjs/testing";
import { AuthService } from "../auth.service";
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
import { UserService } from "../../user/user.service";
import { User } from "../../user/user.entity";
import { UnauthorizedException } from "@nestjs/common";
describe("AuthService测试", () => {
let service: AuthService;
let userService: Partial<UserService>; // UserService并不是所有方法都要实现,所以用Partial可选参数
let jwt: Partial<JwtService>;
// 用来模拟登录用户
const mockUser = {
username: "xiaotianwifi",
password: "123456",
};
beforeEach(async () => {
userService = {
getUser: (username: string) => {
return mockUser as User;
},
};
jwt = {
signAsync: (
payload: string | object | Buffer,
options?: JwtSignOptions
): Promise<string> => {
return Promise.resolve("token");
},
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthService],
providers: [
AuthService,
{
provide: UserService,
useValue: userService,
},
{
provide: JwtService,
useValue: jwt,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
});
it("鉴权-初始化-实例化-service", () => {
expect(service).toBeDefined();
});
it("用户登录成功", async () => {
const user = await service.signin(mockUser.username, mockUser.password);
expect(user).toBe("token");
});
it("用户用户名密码错误", async () => {
await expect(service.signin(mockUser.username, "123")).rejects.toThrow(
new UnauthorizedException()
);
});
});
测试结果为:
PASS src/auth/__test__/auth.service.spec.ts
AuthService测试
√ 鉴权-初始化-实例化-service (16 ms)
√ 用户登录成功 (3 ms)
√ 用户用户名密码错误 (9 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.801 s, estimated 3 s
Ran all test suites matching /src\\auth\\__test__\\auth.service.spec.ts/i.
Watch Usage: Press w to show more.
1.3 Jest 相关的 api
1.3.1 匹配器 expect
-
匹配器:就是做一次断言
-
匹配器的使用:
// expect是测试的值,toBe是期望的值
expect(1 + 1).toBe(2);
1.3.2 安装和移除
-
什么是安装和移除:js 测试生命周期的方法
-
有哪些:
beforeEach(() => {
initializeCityDatabase();
});
afterEach(() => {
clearCityDatabase();
});
beforeAll(() => {
return initializeCityDatabase();
});
afterAll(() => {
return clearCityDatabase();
});
-
beforeEach
:每个测试用例之前 -
afterEach
: 每个测试用例之后 -
beforeAll
:一次性设置
,所有的测试用例之前 -
afterAll
:一次性设置
,所有的测试用例之后
2. 集成测试
集成测试:模拟用户客户端发送请求,到服务端响应过程
流程:
-
创建 App 实例
-
数据库初始化
-
监听端口
-
接收请求并响应
-
测试完成清理测试数据(例如:生成的数据库数据)
2.1 基本使用
在main.ts
定义的全局配置,如管道、过滤器之类的,在测试用例里面不方便二次使用,所有可以将全局配置单独拆分到setup.ts
中
- 在
src/setup.ts
中
import { INestApplication, ValidationPipe } from "@nestjs/common";
export function setup(app: INestApplication) {
app.setGlobalPrefix("/api/v1");
// 配置全局拦截器
app.useGlobalPipes(
new ValidationPipe({
// 去除在类上不存在的字段
whitelist: true,
})
);
}
- 在
main.ts
中
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { setup } from "./setup";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
setup(app);
await app.listen(3000);
}
bootstrap();
- 在
/test/app.e2e-spec.ts
中
- 使用:
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "./../src/app.module";
import { setup } from "./../src/setup";
describe("AppController (e2e)", () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// app全局配置
setup(app);
await app.init();
});
it("/ (GET)", () => {
return request(app.getHttpServer())
.get("/api/v1/user")
.expect(200)
.expect("Hello World!");
});
});
- 测试结果:执行
npm run test:e2e
PASS test/app.e2e-spec.ts
AppController (e2e)
√ / (GET) (212 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.148 s
Ran all test suites.
2.2 自定义 AppFactory 及脚本完成数据库初始化与清理
- 在
test
目录下新建app.factory.ts
文件
import { Test, TestingModule } from "@nestjs/testing";
import { AppModule } from "../src/app.module";
import { INestApplication } from "@nestjs/common";
import { setup } from "../src/setup";
import { DataSource } from "typeorm";
import datasource from "../src/ormconfig";
/**
* 写法1:const app = new AppFactory().init() init -> return app实例
* 写法2:OPP面向对象写法 get instance() -> app, private app, AppFactory constructor的部分进行初始化
* 写法2使用:const appFactory = AppFactory.init()(因为AppFactory.init返回了一个AppFactory实例) const app = appFactory.instance 获取app实例
*/
export class AppFactory {
connection: DataSource;
constructor(private app: INestApplication) {}
get instance() {
return this.app;
}
// 初始化App实例
static async init() {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleFixture.createNestApplication();
setup(app);
const port = 3000;
await app.listen(port);
await app.init();
return new AppFactory(app);
}
// 初始化db数据库
async initDB() {
// 如果数据库没有初始化就去初始化
if (!datasource.isInitialized) {
await datasource.initialize();
}
this.connection = datasource; // 该datasource是从ormconfig.ts文件中引入的,可以参考数据库typeorm部分
}
// 清楚数据库数据 -> 避免测试数据污染
async cleanup() {
// 获取到所有的数据库名称
const entities = this.connection.entityMetadatas;
for (const entity of entities) {
// 获取到所有的存储库
const repository = this.connection.getRepository(entity.name);
// 删除所有表中数据
await repository.query(`DELETE FROM ${entity.tableName}`);
}
}
// 断开与数据库的链接 -> 避免后续数据库连接过多而无法连接
async destory() {
await this.connection.destroy();
}
}
- 在
test
目录下新建setup-jest.ts
文件
import { INestApplication } from "@nestjs/common";
import { AppFactory } from "./app.factory";
let appFactory: AppFactory;
let app: INestApplication;
// 将beforeEach和afterEach挂载到全局去使用
global.beforeEach(async () => {
appFactory = await AppFactory.init();
await appFactory.initDB();
app = appFactory.instance;
});
global.afterEach(async () => {
await appFactory.cleanup();
// 解决测试后端口占用的问题
await app.close();
});
- 在
jest-e2e.json
中:
确保 2 中的文件会在全局自动加载
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
// 添加如下代码
"setupFilesAfterEnv": ["<rootDir>/setup-jest.ts"]
}
- 注意:如果有多个测试用例,会导致某些测试用例完成后清空数据库,可以在
package.json
中配置最大线程--maxWorkers=1
"test:e2e": "jest --config ./test/jest-e2e.json --maxWorkers=1"
- 在
app.e2e-spec.ts
中使用
注意:端对端(e2e)的测试的
it
必须要写return
import * as request from "supertest";
describe("AppController (e2e)", () => {
it("/ (GET)", () => {
return request(global.app.getHttpServer())
.get("/api/v1/user")
.expect(200)
.expect("Hello World!");
});
});
2.3 集成 pactum 测试库发起请求
- 安装
npm i pactum -D
- 使用
import * as pacutm from "pactum";
describe("AppController (e2e)", () => {
beforeEach(() => {
pacutm.request.setBaseUrl("http://127.0.0.1:3000");
});
it("/ (GET)", () => {
return pacutm
.spec()
.get("/api/v1/user")
.expectStatus(200)
.expectBodyContains("Hello World!");
});
});
十七、项目部署
1. pm2 的好处
- pm2 进程守护可以在程序崩溃后自动重启
- pm2 自带日志记录功能,可以很方便的记录错误日志和自定义日志
- pm2 可以启动多个 node 进程,充分利用服务器资源
2. pm2 的基本使用
2.1 安装 pm2
npm i pm2 -g
2.2 查看 pm2 版本
pm2 --version
2.3 启动 pm2
pm2 start index.js
3. pm2 常用指令
3.1 启动应用程序
pm2 start index.js
3.2 列出启动的所有应用程序
pm2 list
3.3 重启应用程序
pm2 restart 应用id/name
3.4 杀死并重启所有进程
pm2 restart all
3.5 查看应用程序详细信息
pm2 info 应用id/name
pm2 show
3.6 显示指定应用程序的日志
pm2 log 应用id/name
3.7 监控应用程序
pm2 monit 应用id/name
3.8 停止应用程序
pm2 stop 应用id/name
3.9 停止所有应用程序
pm2 stop all
3.10 关闭并删除指定应用程序
pm2 delete 应用id/name
3.11 关闭并删除所有应用程序
pm2 delete all
3.12 杀死 pm2 管理的所有进程
pm2 kill
4. pm2 常用配置
- 新建
pm2.confif.json
文件
{
"name": "应用程序名称",
"script": "入口文件名称",
"watch": true, // 文件被修改是否自动重启
"ignore_watch": [
// 忽略监听哪些文件的改变
"node_modules",
"logs"
],
"error_file": "logs/错误日志文件名称.log",
"out_file": "logs/自定义日志文件名称.log",
"log_date_format": "yyyy-MM-dd HH:mm:ss" // 给日志添加时间
}
- 运行
pm2 start pm2.config.json
5. 负载均衡
node 是单线程的,服务器是多核的,要充分利用服务器资源,可以使用 pm2 启动多个 node 进程,只需要在配置文件中增加 instances
配置,可以启动多个 node 进程(想启动几个就启动几个,但是不能超过服务器 cpu 的核数)
{
"name": "应用程序名称",
"script": "入口文件名称",
"watch": true, // 文件被修改是否自动重启
"ignore_watch": [
// 忽略监听哪些文件的改变
"node_modules",
"logs"
],
"error_file": "logs/错误日志文件名称.log",
"out_file": "logs/自定义日志文件名称.log",
"log_date_format": "yyyy-MM-dd HH:mm:ss", // 给日志添加时间
"instances": 4 // 开启多个node进程,不能超过cpu核数
}
十八、OpenAPI 生成接口文档
1. 安装
npm install --save @nestjs/swagger
2. 使用
安装过程完成后,打开 main.ts
文件并使用 SwaggerModule
类初始化 Swagger:
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle("接口文档标题")
.setDescription("接口文档描述")
.setVersion("1.0")
.addTag("cats")
.build();
const document = SwaggerModule.createDocument(app, config);
// docs文档url:通过http://localhost:3000/docs
SwaggerModule.setup("docs", app, document);
await app.listen(3000);
}
bootstrap();
docs 文档 url:通过
http://localhost:3000/docs
访问 swagger 文档文档 json 数据:通过
http://localhost:3000/docs-json
访问
十九、补充
1. session
session 是服务器为每个用户的浏览器创建的一个会话对象,这个 session 会记录到浏览器的 cookie 来区分用户。
在 nest 中默认框架是 express,可以安装 express 的 session 使用
1.1 安装
npm i express-session -S
npm i @types/express-session -D
1.2 使用
- 在
main.ts
中
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import * as session from "express-session";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("/api/v1");
app.use(
session({
secret: "xiaotian", // 生成服务端session,可以理解为加盐
rolling: true, // 在每次请求时强行设置cookie,这将充值cookie的过期时间(默认为false)
name: "xiaotian_sid", // 生成客户端cookie的名字,默认为connect.sid
cookie: {
maxAge: 9999999, // 过期时间
}, // 设置返回到前端的key属性,默认值为{path: '/, httpOnly: true, secure: false, maxAge: null}
})
);
await app.listen(3000);
}
bootstrap();
2. 生成图片验证码
2.1 安装
npm install svg-captcha -S
2.2 使用
注意:前端请求时需要携带 cookie
import {
Controller,
Get,
Post,
Body,
Param,
Request,
Query,
Headers,
HttpCode,
Res,
Req,
} from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import * as svgCaptcha from "svg-captcha";
@Controller("user")
export class UserController {
constructor(private readonly userService: UserService) {}
@Get("code")
createCaptcha(@Req() req, @Res() res) {
// 也可通过@Session
const captcha = svgCaptcha.create({
size: 4, //生成几个验证码
fontSize: 50, //文字大小
width: 100, //宽度
height: 34, //高度
background: "#cc9966", //背景颜色
});
req.session.code = captcha.text; //存储验证码记录到session
// 返回图片格式
res.type("image/svg+xml");
res.send(captcha.data);
}
@Post("create")
createUser(@Req() req, @Body() body) {
console.log(req.session.code, body);
if (
req.session.code.toLocaleLowerCase() === body?.code?.toLocaleLowerCase()
) {
return {
message: "验证码正确",
};
} else {
return {
message: "验证码错误",
};
}
}
}
3. 文件上传
3.1 安装multer
@nestjs/platform-express
:Nest自带了
npm i multer -s
npm i @types/multer -D
3.2 生成目录
nest g res upload
3.3 使用
- 在
upload.module.ts
中
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: join(__dirname, '../images'), // 存放目录
filename: (req, file, callback) => {
// extname可以截取文件后缀
const fileName = `${new Date().getTime() + extname(file.originalname)}`
return callback(null, fileName) // callback(error: Error, filename: string): void
}
}) // 文件上传存放位置
})
],
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule {}
运行项目会在根目录生成的dist
目录下会有images
目录
- 在
upload.controller.ts
中
- 会使用到
@UseInterceptors
这个中间件,FileInterceptor
和FilesInterceptor
是nest内置的专门用来处理文件的。
FileInterceptor
:处理单文件上传
FilesInterceptor
:处理多文件上传
- 获取文件
@UploadedFile
来获取文件对象
import { Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { UploadService } from './upload.service';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
@Controller('upload')
export class UploadController {
@Post()
// file_1 是文件上传的参数名
@UseInterceptors(FileInterceptor('file_1')) // 专门用来处理文件的中间件
upload(@UploadedFile() file: File) {
return file
}
}
- 响应结果
{
"fieldname": "file_1",
"originalname": "1.webp.jpg",
"encoding": "7bit",
"mimetype": "image/jpeg",
"destination": "C:\\Users\\Administrator\\Desktop\\nest-notes\\use_demo\\dist\\images",
"filename": "1721495863226.jpg",
"path": "C:\\Users\\Administrator\\Desktop\\nest-notes\\use_demo\\dist\\images\\1721495863226.jpg",
"size": 81355
}
4. 配置静态资源
使用useStaticAssets
, 在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() {
// NestExpressApplication 提供类型推断
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// 用来配置静态资源目录
app.useStaticAssets(join(__dirname, 'images'), {
prefix: '/wifi' // 加访问路径前缀
})
await app.listen(3000);
}
bootstrap();
5. 文件下载
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express'
import { join } from 'path';
@Controller('download')
export class DownloadController {
@Get()
download(@Res() res: Response) {
const url = join(__dirname, '../images/1721495863226.jpg')
res.download(url)
}
}