0%

Nestjs入门(三)

GLOBAL-BIZ-SUMMIT

Nestjs 入门(二)中,我们创建了一个基本的 Nestjs 应用。下面我们基于此进行扩展。

源码地址:awesome-nest

序列化

在 entity 中,有时候有些字段不一定要返还给前端,通常我们需要自己做一次筛选,而 Nestjs 中,配合 class-transformer,可以很方便的实现这个功能。

例如,我们有个 entity 的基类common.entity.ts,返还数据的时候,我们不希望把create_atupdate_at也带上,这时候就可以使用@Exclude()排除CommonEntity中的这两个字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
import { Exclude } from 'class-transformer'

export class CommonEntity {
@PrimaryGeneratedColumn('uuid')
id: string

@Exclude()
@CreateDateColumn({
comment: '创建时间',
})
create_at: number

@Exclude()
@UpdateDateColumn({
comment: '更新时间',
})
update_at: number
}

在对应请求的地方标记使用ClassSerializerInterceptor,此时,GET /api/v1/cats/1这个请求返回的数据中,就不会包含create_atupdate_at这两个字段。

1
2
3
4
5
6
7
8
9
10
11
@Controller('cats')
export class CatsController {
constructor(private readonly catsService: CatsService) {
}

@Get(':id')
@UseInterceptors(ClassSerializerInterceptor)
findOne(@Param('id') id: string): Promise<Array<Partial<CatEntity>>> {
return this.catsService.getCat(id)
}
}

如果某个 controller 中都需要使用ClassSerializerInterceptor来帮我们做一些序列化的工作,可以把 Interceptor 提升到整个 controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@UseInterceptors(ClassSerializerInterceptor)
@Controller('cats')
export class CatsController {
constructor(private readonly catsService: CatsService) {
}

@Get(':id')
findOne(@Param('id') id: string): Promise<Array<Partial<CatEntity>>> {
return this.catsService.getCat(id)
}

@Post()
create(@Body() createCatDto: CreateCatDto): Promise<void> {
return this.catsService.createCat(createCatDto)
}
}

甚至可以在main.ts中把它作为全局的 Interceptor,不过这样不方便进行细粒度地控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.setGlobalPrefix('api/v1')

app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))

await app.listen(config.port, config.hostName, () => {
Logger.log(
`Awesome-nest API server has been started on http://${config.hostName}:${config.port}`,
)
})
}

bootstrap()

在某些场景下,我们需要对 entity 中某个字段处理后再返回,可以使用@Transform()

1
2
3
4
5
6
7
8
9
10
11
12
@Entity('dog')
export class DogEntity extends CommonEntity {
@Column({ length: 50 })
@Transform(value => `dog: ${value}`)
name: string

@Column()
age: number

@Column({ length: 100, nullable: true })
breed: string
}

此时,name字段经过@Transform的包装就会变成dog: name的格式。如果我们需要根据已有字段构造一个新的字段,可以使用@Expose()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity('dog')
export class DogEntity extends CommonEntity {
@Column({ length: 50 })
@Transform(value => `dog: ${value}`)
name: string

@Column()
age: number

@Column({ length: 100, nullable: true })
breed: string

@Expose()
get isOld(): boolean {
return this.age > 10
}
}

上面代码会根据查询到的age字段动态计算isOld的值,此时通过 GET 方法请求返回的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"data": [
{
"id": "15149ec5-cddf-4981-89a0-62215b30ab81",
"name": "dog: nana",
"age": 12,
"breed": "corgi",
"isOld": true
}
],
"status": 0,
"message": "请求成功"
}

事务

在使用 MySQL 的时候,有时候我们需要使用事务,借助 TypeORM 中可以这样使用事务:

1
2
3
4
5
6
7
8
@Delete(':name')
@Transaction()
delete(
@Param('name') name: string,
@TransactionManager() manager: EntityManager,
): Promise<void> {
return this.catsService.deleteCat(name, manager)
}

@Transaction()将 controller 或者 service 中所有执行包装到一个数据库事务中,@TransportManager提供了一个事务实体管理器,它必须用于在该事务中执行查询:

1
2
3
async deleteCat(name: string, manager: EntityManager): Promise<void> {
await manager.delete(CatEntity, { name })
}

上面代码通过装饰器很方便地进行了事务的操作,如果事务执行过程中有任何错误会自动回滚。

当然,我们也可以手动创建查询运行程序实例,并使用它来手动控制事务状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { getConnection } from "typeorm";

// 获取连接并创建新的queryRunner
const connection = getConnection();
const queryRunner = connection.createQueryRunner();

// 使用我们的新queryRunner建立真正的数据库连
await queryRunner.connect();

// 现在我们可以在queryRunner上执行任何查询,例如:
await queryRunner.query("SELECT * FROM users");

// 我们还可以访问与queryRunner创建的连接一起使用的实体管理器:
const users = await queryRunner.manager.find(User);

// 开始事务:
await queryRunner.startTransaction();

try {
// 对此事务执行一些操作:
await queryRunner.manager.save(user1);
await queryRunner.manager.save(user2);
await queryRunner.manager.save(photos);

// 提交事务:
await queryRunner.commitTransaction();
} catch (err) {
// 有错误做出回滚更改
await queryRunner.rollbackTransaction();
}

QueryRunner提供单个数据库连接。 使用查询运行程序组织事务。 单个事务只能在单个查询运行器上建立。

认证

在这个应用内,现在对用户还没有进行认证,通过用户认证可以判断该访问角色的合法性和权限。通常认证要么基于 Session,要么基于 Token。这里就以基于 Token 的 JWT(JSON Web Token) 方式进行用户认证。

首先安装相关依赖:

1
$ npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt

然后创建jwt.strategy.ts,用来验证 token,当 token 有效时,允许进一步处理请求,否则返回401(Unanthorized)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ExtractJwt, Strategy } from 'passport-jwt'
import { PassportStrategy } from '@nestjs/passport'
import { Injectable, UnauthorizedException } from '@nestjs/common'
import config from '../../config'
import { UserEntity } from '../entities/user.entity'
import { AuthService } from './auth.service'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.jwt.secret,
})
}

async validate(payload: UserEntity) {
const user = await this.authService.validateUser(payload)
if (!user) {
throw new UnauthorizedException('身份验证失败')
}
return user
}
}

然后创建auth.service.ts,上面的jwt.strategy.ts会使用这个服务校验 token,并且提供了创建 token 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { JwtService } from '@nestjs/jwt'
import { Injectable } from '@nestjs/common'
import { UserEntity } from '../entities/user.entity'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Token } from './auth.interface'
import config from '../../config'

@Injectable()
export class AuthService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
private readonly jwtService: JwtService,
) {
}

createToken(email: string): Token {
const accessToken = this.jwtService.sign({ email })
return {
expires_in: config.jwt.signOptions.expiresIn,
access_token: accessToken,
}
}

async validateUser(payload: UserEntity): Promise<any> {
return await this.userRepository.find({ email: payload.email })
}
}

这两个文件都会作为服务在对应的module中注册,并且引入PassportModuleJwtModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Module } from '@nestjs/common'
import { AuthService } from './auth/auth.service'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { JwtStrategy } from './auth/jwt.strategy'
import config from '../config'


@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register(config.jwt),
],
providers: [
AuthService,
JwtStrategy,
],
exports: [],
})
export class FeaturesModule {
}

这时候,就可以使用@UseGuards(AuthGuard())来对需要认证的 API 进行身份校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Param,
Post,
UseGuards,
UseInterceptors,
} from '@nestjs/common'

import { CatsService } from './cats.service'
import { CreateCatDto } from './cat.dto'
import { CatEntity } from '../entities/cat.entity'
import { AuthGuard } from '@nestjs/passport'

@Controller('cats')
@UseGuards(AuthGuard())
export class CatsController {
constructor(private readonly catsService: CatsService) {
}

@Get(':id')
@UseInterceptors(ClassSerializerInterceptor)
findOne(@Param('id') id: string): Promise<Array<Partial<CatEntity>>> {
return this.catsService.getCat(id)
}

@Post()
create(@Body() createCatDto: CreateCatDto): Promise<void> {
return this.catsService.createCat(createCatDto)
}
}

通过 Postman 模拟请求时,如果没有带上 token,就会返回下面结果:

1
2
3
4
5
6
7
{
"message": {
"statusCode": 401,
"error": "Unauthorized"
},
"status": 1
}

安全

Web 安全中,常见有两种攻击方式:XSS(跨站脚本攻击) 和 CSRF(跨站点请求伪造)。

对 JWT 的认证方式,因为没有 cookie,所以也就不存在 CSRF。如果你不是用的 JWT 认证方式,可以使用csurf这个库去解决这个安全问题。

对于 XSS,可以使用helmet去做安全防范。helmet 中有 12 个中间件,它们会设置一些安全相关的 HTTP 头。比如xssFilter就是用来做一些 XSS 相关的保护。

对于单 IP 大量请求的暴力攻击,可以用express-rate-limit来进行限速。

对于常见的跨域问题,Nestjs 提供了两种方式解决,一种通过app.enableCors()的方式启用跨域,另一种像下面一样,在 Nest 选项对象中启用。

最后,所有这些设置都是作为全局的中间件启用,最后main.ts中,和安全相关的设置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import * as helmet from 'helmet'
import * as rateLimit from 'express-rate-limit'

async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true })

app.use(helmet())
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
}),
)

await app.listen(config.port, config.hostName, () => {
Logger.log(
`Awesome-nest API server has been started on http://${config.hostName}:${config.port}`,
)
})
}

HTTP 请求

Nestjs 中对Axios进行了封装,并把它作为 HttpService 内置到HttpModule中。HttpService返回的类型和 Angular 的 HttpClient Module一样,都是observables,所以可以使用 rxjs 中的操作符处理各种异步操作。

首先,我们需要导入HttpModule

1
2
3
4
5
6
7
8
9
10
11
import { Global, HttpModule, Module } from '@nestjs/common'

import { LunarCalendarService } from './services/lunar-calendar/lunar-calendar.service'

@Global()
@Module({
imports: [HttpModule],
providers: [LunarCalendarService],
exports: [HttpModule, LunarCalendarService],
})
export class SharedModule {}

这里我们把 HttpModule作为全局模块,在sharedModule中导入并导出以便其他模块使用。这时候我们就可以使用HttpService,比如我们在LunarCalendarService中注入HttpService,然后调用其 get方法请求当日的农历信息。这时候get返回的是 Observable

对于这个 Observable流,可以通过pipe进行一系列的操作,比如我们直接可以使用 rxjs 的map操作符帮助我们对数据进行一层筛选,并且超过 5s 后就会报 timeout 错误,catchError会帮我们捕获所有的错误,返回的值通过of操作符转换为observable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { HttpService, Injectable } from '@nestjs/common'
import { of, Observable } from 'rxjs'
import { catchError, map, timeout } from 'rxjs/operators'

@Injectable()
export class LunarCalendarService {
constructor(private readonly httpService: HttpService) {
}

getLunarCalendar(): Observable<any> {
return this.httpService
.get('https://www.sojson.com/open/api/lunar/json.shtml')
.pipe(
map(res => res.data.data),
timeout(5000),
catchError(error => of(`Bad Promise: ${error}`))
)
}
}

如果需要对axios 进行配置,可以直接在 Module 注册的时候设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Global, HttpModule, Module } from '@nestjs/common'

import { LunarCalendarService } from './services/lunar-calendar/lunar-calendar.service'

@Global()
@Module({
imports: [
HttpModule.register({
timeout: 5000,
maxRedirects: 5,
}),
],
providers: [LunarCalendarService],
exports: [HttpModule, LunarCalendarService],
})
export class SharedModule {}

模板渲染

在 Nestjs 中,可以使用 hbs 作为模板渲染引擎:

1
$ npm install --save hbs

main.ts中,我们告诉 express,static文件夹用来存储静态文件,views中含了模板文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { NestFactory } from '@nestjs/core'
import { NestExpressApplication } from '@nestjs/platform-express'
import { join } from 'path'

import { AppModule } from './app.module'
import config from './config'
import { Logger } from './shared/utils/logger'

async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: true,
})

app.setGlobalPrefix('api/v1')

app.useStaticAssets(join(__dirname, '..', 'static'))
app.setBaseViewsDir(join(__dirname, '..', 'views'))
app.setViewEngine('hbs')

await app.listen(config.port, config.hostName, () => {
Logger.log(
`Awesome-nest API server has been started on http://${config.hostName}:${config.port}`,
)
})
}

views下新建一个catsPage.hbs的文件,假设,我们需要在里面填充的数据结构是这样:

1
2
3
4
5
6
7
8
9
10
11
{
cats: [
{
id: 1,
name: 'yyy',
age: 12,
breed: 'black cats'
}
],
title: 'Cats List',
}

此时,可以这样写模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<style>
.table .default-td {
width: 200px;
}

.table tbody>tr:nth-child(2n-1) {
background-color: rgb(219, 212, 212);
}

.table tbody>tr:nth-child(2n) {
background-color: rgb(172, 162, 162);
}
</style>
</head>
<body>
<p>{{ title }}</p>
<table class="table">
<thead>
<tr>
<td class="id default-td">id</td>
<td class="name default-td">name</td>
<td class="age default-td">age</td>
<td class="breed default-td">breed</td>
</tr>
</thead>
<tbody>
{{#each cats}}
<tr>
<td>{{id}}</td>
<td>{{name}}</td>
<td>{{age}}</td>
<td>{{breed}}</td>
</tr>
{{/each}}
</tbody>
</table>
</body>
</html>

需要注意的是,如果你有拦截器,数据会先经过拦截器的处理,然后再填充到模板中。

在 controller 中,通过@Render指定模板的名称,并且在 return 中返回需要填充的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Get('page')
@Render('catsPage')
getCatsPage() {
return {
cats: [
{
id: 1,
name: 'yyy',
age: 12,
breed: 'black cats'
}
],
title: 'Cats List',
}
}

Nestjs 还支持和其他 SSR 框架集成,比如 Next,Angular Universal,Nuxt。具体使用 Demo 可以分别查看这几个项目nestifynest-angularsimple-todos

Swagger 文档

Nestjs 中也提供了对 swagger 文档的支持,方便我们对 API 进行追踪和测试:

1
$ npm install --save @nestjs/swagger swagger-ui-express

main.ts中构件文档:

1
2
3
4
5
6
7
8
9
10
const options = new DocumentBuilder()
.setTitle('Awesome-nest')
.setDescription('The Awesome-nest API Documents')
.setBasePath('api/v1')
.addBearerAuth()
.setVersion('0.0.1')
.build()

const document = SwaggerModule.createDocument(app, options)
SwaggerModule.setup('docs', app, document)

此时,访问http://localhost:3300/docs就可以看到 swagger 文档的页面。

对于不同的 API 可以在 controller 中使用@ApiUseTags()进行分类,对于需要认证的 API,可以加上@ApiBearerAuth(),这样在 swagger 中填完 token 后,就可以直接测试 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
@ApiUseTags('cats')
@ApiBearerAuth()
@Controller('cats')
@UseGuards(AuthGuard())
export class CatsController {
constructor(private readonly catsService: CatsService) {}

@Get('page')
@Render('catsPage')
getCatsPage(): Promise<any> {
return this.catsService.getCats()
}
}

![5258bc6b-80ab-4701-bcad-4407bc703c07](/assets/images/nest 入门/5258bc6b-80ab-4701-bcad-4407bc703c07.png)

对于我们定于的 DTO,为了使 SwaggerModule 可以访问类属性,我们必须用 @ApiModelProperty() 装饰器标记所有这些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { ApiModelProperty } from '@nestjs/swagger'
import { IsEmail, IsString } from 'class-validator'

export class AccountDto {
@ApiModelProperty()
@IsString()
@IsEmail()
readonly email: string

@ApiModelProperty()
@IsString()
readonly password: string
}

![2019-07-31_21-49-08](/assets/images/nest 入门/2019-07-31_21-49-08.png)

对于 swagger 文档更多的用法,可以看官网OpenAPI (Swagger)的内容。

热重载

在开发的时候,运行npm run start:dev的时候,是进行全量编译,如果项目比较大,全量编译耗时会比较长,这时候我们可以利用 webpack 来帮我们做增量编译,这样会大大增加开发效率。

首先,安装 webpack 相关依赖:

1
$ npm i --save-dev webpack webpack-cli webpack-node-externals ts-loader

在根目录下创建一个webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const webpack = require('webpack');
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
entry: ['webpack/hot/poll?100', './src/main.ts'],
watch: true,
target: 'node',
externals: [
nodeExternals({
whitelist: ['webpack/hot/poll?100'],
}),
],
module: {
rules: [
{
test: /.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
mode: 'development',
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [new webpack.HotModuleReplacementPlugin()],
output: {
path: path.join(__dirname, 'dist'),
filename: 'server.js',
},
};

main.ts中启用 HMR:

1
2
3
4
5
6
7
8
9
10
11
12
declare const module: any;

async function bootstrap() {
const app = await NestFactory.create(ApplicationModule);
await app.listen(3000);

if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();

package.json中增加下面两个命令:

1
2
3
4
5
6
{
"scripts": {
"start": "node dist/server",
"webpack": "webpack --config webpack.config.js"
}
}

运行npm run webpack之后,webpack 开始监视文件,然后在另一个命令行窗口中运行npm start

欢迎关注我的其它发布渠道