0%

转型计划之初级后端(中)

年轻人, 你渴望力量吗

参考

资源

前言

转型计划之初级后端(上) 是解剖分析部分, 这篇是重构部分,利用完整的重构来检验自己是否已经达到融会贯通的程度。

前置知识

分析样板

寻找合适的样板

第一步当然是要找一个好的 Boilerplate, 一般情况下是在 yeoman 这个网站找
这里我在了解学习 nestjs 的过程中在 github 上发现了一个很不错的 Boilerplate: nestjs-realworld-example-app, 所以就决定是它了。

使用 mysql 容器

先把 nestjs-realworld-example-app git clone https://github.com/lujakob/nestjs-realworld-example-app.git 到本地
然后读 README.md 文档,查看开发环境是否符合要求,然后按照 README.md 文档的提示将这个 Boilerplate 运行起来, 确保没有错误发生。

按照 README.md 文档的要求, 需要安装 mysql 数据库,这里我一开始是决定使用 WSL 来做这件事情, 但不久前,我看完了 docker 的文档,所以决定使用 docker 来做这件事情, 这样更符合现代的开发部署流程。
docker 的安装与使用请参考: Securely build, share and run any application, anywhere: docker
在项目根目录 mkdir mysql touch docker-compose.yml
docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
version: '3.1'

services:

db:
image: mysql
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: nestjsrealworld
ports:
- "3306:3306"
volumes:
- ./mysql:/var/lib/mysql

adminer:
image: adminer
restart: always
ports:
- 8080:8080

docker-compose.yml 配置文件的编写请参考: hub.docker.com: mysql
然后按照 README.md 要求 cp ormconfig.json.example ormconfig.json
然后修改 ormconfig.json 内容如下:

1
2
3
4
5
6
7
8
9
10
{
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "rootpassword",
"database": "nestjsrealworld",
"entities": ["src/**/**.entity{.ts,.js}"],
"synchronize": true
}

现在 README.md 要求的开发环境已经准备好了, 只需查看 package.json 的 script 字段
在这里加入了 "db": "docker-compose up -d" 一行
package.json:

1
2
3
4
5
6
7
8
9
10
11
12
{
"scripts": {
"start": "node index.js",
"start:watch": "nodemon",
"prestart:prod": "tsc",
"start:prod": "node dist/main.js",
"test": "jest --config=jest.config.json --forceExit",
"test:watch": "jest --watch --config=jest.config.json",
"test:coverage": "jest --config=jest.config.json --coverage --coverageDirectory=coverage",
"db": "docker-compose up -d"
},
}

现在先后执行 npm run db npm run start 项目就运行起来了。
默认情况下 docker 是开机自启动的, 这样子就算你重启计算机 docker 也会自动运行你设置的 restart: always 容器
因此 npm run db 只需要首次执行时, 执行一次即可.

分析入口文件

确保项目能够运行后, 打开入口文件 index.js
index.js

1
2
3
/* eslint-disable */
require('ts-node/register')
require('./src/main')

竟然是个 js 文件, 而不是 ts.
可以看到这里引入了一个 ts-node 的 register, 然后才是真正的入口文件 ‘./src/main’
如果你用过 babel 的话, 你就会知道 babel 也有一个 @babel/register, 可以直接运行 ES Next 的代码.
那么这个 ts-node 应该就是可以直接运行 tsnodejs 了.
这可是一个好东西, 所以点开 ts-node 点个 star 然后继续查看真正的入口文件 ‘./src/main’
main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { NestFactory } from '@nestjs/core'
import { ApplicationModule } from './app.module'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'

async function bootstrap (): Promise<void> {
const appOptions = { cors: true }
const app = await NestFactory.create(ApplicationModule, appOptions)
app.setGlobalPrefix('api')

const options = new DocumentBuilder()
.setTitle('NestJS Realworld Example App')
.setDescription('The Realworld API description')
.setVersion('1.0')
.setBasePath('api')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, options)
SwaggerModule.setup('/docs', app, document)

await app.listen(3000)
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap()

这里我们可以看到入口文件的意图很明显, 定义并执行了一个 bootstrap 函数, bootstrap 函数做了两件事情:

  1. 一个是使用 NestFactory 工厂类的静态方法 create 返回一个实现 INestApplication 接口的应用程序对象

  2. 还有一件事情是添加了一个新的路由 ‘/docs’, 指向 Swagger UI 页面。
    Swagger UI 这里不做展开, 请参考: OpenAPI(Swagger)

值得一提的是, nest 官网文档 docs.nestjs.com 说:

NestFactory exposes a few static methods that allow creating an application instance. The create() method returns an application object, which fulfills the INestApplication interface.

关键字 static methods, 这里我看了 NestFactory 方法的实现: github.com/nestjs/nest/-/blob/packages/core/nest-factory.ts#L252:14 发现并不是我想象的 类 NestFactory 上定义了一个 NestFactory.create 的静态方法,而是 NestFactory 只是类 NestFactoryStatic 的实例, 而所谓的静态方法只是 NestFactoryStatic 类的成员方法。
可是 typescript 明明支持 static 关键字啊!!为什么要定义一个 NestFactoryStatic 类? NestFactoryStatic类的名字里有个 Static, 所以它的成员方法就变为静态方法也是比较有意思的地方。静态类的成员方法简称静态方法, 很合理。哈哈。
同样值得一提的是,静态方法 create 是一个多态方法, 这一点文档里面没有提到, 而且它的返回值竟然是一个被 new Proxy 代理包装后的对象: [github.com/nestjs/nest/-/blob/packages/core/nest-factory.ts#L228:17]:https://sourcegraph.com/github.com/nestjs/nest/-/blob/packages/core/nest-factory.ts#L228:17
其实 NestFactory 的实现是没必要去知道的, 如果一个 api 必须看源码才能知道怎么用, 这是违反最小知识原则, 只是觉得有趣纪录一下。
wczgsxhnba
真正需要了解的是 create 的参数: appOptions, 这个时候静态类型语言的优势就体现出来了, 可以选中 create 直接按快捷键 F12 找到 create 的类型定义

1
create<T extends INestApplication = INestApplication>(module: any, options?: NestApplicationOptions): Promise<T>;
1
create<T extends INestApplication = INestApplication>(module: any, httpAdapter: AbstractHttpAdapter, options?: NestApplicationOptions): Promise<T>;

options 是一个可选的, 需要尊重 NestApplicationOptions 类型的对象, 选中 NestApplicationOptions 继续按快捷键 F12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { CorsOptions } from './external/cors-options.interface';
import { HttpsOptions } from './external/https-options.interface';
import { NestApplicationContextOptions } from './nest-application-context-options.interface';
/**
* @publicApi
*/
export interface NestApplicationOptions extends NestApplicationContextOptions {
/**
* CORS options from [CORS package](https://github.com/expressjs/cors#configuration-options)
*/
cors?: boolean | CorsOptions;
/**
* Whether to use underlying platform body parser.
*/
bodyParser?: boolean;
/**
* Set of configurable HTTPS options
*/
httpsOptions?: HttpsOptions;
}

写的很明白, 没什么好说的。
入口文件分析完毕, 服务器运行在 3000 端口, 所以可以点开 http://127.0.0.1:3000/docs/ 玩玩 Swagger UI

分析 module

接下来就可以看一看入口文件中导入的 ‘./app.module’

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 { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ArticleModule } from './article/article.module';
import { UserModule } from './user/user.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Connection } from 'typeorm';
import { ProfileModule } from './profile/profile.module';
import { TagModule } from './tag/tag.module';

@Module({
imports: [
TypeOrmModule.forRoot(),
ArticleModule,
UserModule,
ProfileModule,
TagModule
],
controllers: [
AppController
],
providers: []
})
export class ApplicationModule {
constructor(private readonly connection: Connection) {}
}

可以看到 app.module 导入了所有的业务模块(module 与 controller), 这里可以对照一下项目结构

可以发现所有的业务模块都使用单独一个文件夹区分, 每个业务模块中都有 module controller service entity interface 和 dto, 同样的所有的单个业务模块的这些文件在 module 里面被注入。 module 是单个业务模块的中心, 而所有的业务模块都向 app.module 汇集。
从单个业务模块向入口文件看, 应用逻辑呈收敛状, 反之则呈放射状。
而且可以看到 module implements NestModule 的 configure(consumer: MiddlewareConsumer) 方法 有一个被注入的 consumer: MiddlewareConsumer
对象, 这个 consumer 对象, 可以给所有在此声明的路径与方法应用中间件。 如果要用一个词形容 module 的话, 那就是中心化。
article.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Module({
imports: [TypeOrmModule.forFeature([ArticleEntity, Comment, UserEntity, FollowsEntity]), UserModule],
providers: [ArticleService],
controllers: [
ArticleController
]
})
export class ArticleModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthMiddleware)
.forRoutes(
{path: 'articles/feed', method: RequestMethod.GET},
{path: 'articles', method: RequestMethod.POST},
{path: 'articles/:slug', method: RequestMethod.DELETE},
{path: 'articles/:slug', method: RequestMethod.PUT},
{path: 'articles/:slug/comments', method: RequestMethod.POST},
{path: 'articles/:slug/comments/:id', method: RequestMethod.DELETE},
{path: 'articles/:slug/favorite', method: RequestMethod.POST},
{path: 'articles/:slug/favorite', method: RequestMethod.DELETE});
}
}

值得注意的是这一行中 imports: [TypeOrmModule.forFeature([ArticleEntity, Comment, UserEntity, FollowsEntity]), UserModule], 除了 UserModule 其他都是与数据库对应的 Entity, 这个 UserModule 的特殊之处是通过 exports: [UserService] 导出了 UserService (在 user.module.ts 文件中), 使得所有 imports UserModule 的 Controller 都可以使用 UserService。 这一点(Shared modules)在 nestjs 的官方文档中已经说的很清楚了: https://docs.nestjs.com/modules:

Now any module that imports the CatsModule has access to the CatsService and will share the same instance with all other modules that import it as well.

分析 controller

module 看完后看 controller
article.controller.ts

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import { Get, Post, Body, Put, Delete, Query, Param, Controller } from '@nestjs/common'
import { Request } from 'express'
import { ArticleService } from './article.service'
import { CreateArticleDto, CreateCommentDto } from './dto'
import { ArticlesRO, ArticleRO, CommentsRO } from './article.interface'

import { User } from '../user/user.decorator'

import {
ApiUseTags,
ApiBearerAuth,
ApiResponse,
ApiOperation
} from '@nestjs/swagger'

@ApiBearerAuth()
@ApiUseTags('articles')
@Controller('articles')
export class ArticleController {
constructor (private readonly articleService: ArticleService) {}

@ApiOperation({ title: 'Get all articles' })
@ApiResponse({ status: 200, description: 'Return all articles.' })
@Get()
async findAll (@Query() query): Promise<ArticlesRO> {
return await this.articleService.findAll(query)
}

@Get(':slug')
async findOne (@Param('slug') slug): Promise<ArticleRO> {
return await this.articleService.findOne({ slug })
}

@Get(':slug/comments')
async findComments (@Param('slug') slug): Promise<CommentsRO> {
return await this.articleService.findComments(slug)
}

@ApiOperation({ title: 'Create article' })
@ApiResponse({ status: 201, description: 'The article has been successfully created.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Post()
async create (@User('id') userId: number, @Body('article') articleData: CreateArticleDto) {
return this.articleService.create(userId, articleData)
}

@ApiOperation({ title: 'Update article' })
@ApiResponse({ status: 201, description: 'The article has been successfully updated.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Put(':slug')
async update (@Param() params, @Body('article') articleData: CreateArticleDto) {
// Todo: update slug also when title gets changed
return this.articleService.update(params.slug, articleData)
}

@ApiOperation({ title: 'Delete article' })
@ApiResponse({ status: 201, description: 'The article has been successfully deleted.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Delete(':slug')
async delete (@Param() params) {
return this.articleService.delete(params.slug)
}

@ApiOperation({ title: 'Create comment' })
@ApiResponse({ status: 201, description: 'The comment has been successfully created.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Post(':slug/comments')
async createComment (@Param('slug') slug, @Body('comment') commentData: CreateCommentDto) {
return await this.articleService.addComment(slug, commentData)
}

@ApiOperation({ title: 'Delete comment' })
@ApiResponse({ status: 201, description: 'The article has been successfully deleted.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Delete(':slug/comments/:id')
async deleteComment (@Param() params) {
const { slug, id } = params
return await this.articleService.deleteComment(slug, id)
}

@ApiOperation({ title: 'Favorite article' })
@ApiResponse({ status: 201, description: 'The article has been successfully favorited.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Post(':slug/favorite')
async favorite (@User('id') userId: number, @Param('slug') slug) {
return await this.articleService.favorite(userId, slug)
}

@ApiOperation({ title: 'Unfavorite article' })
@ApiResponse({ status: 201, description: 'The article has been successfully unfavorited.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Delete(':slug/favorite')
async unFavorite (@User('id') userId: number, @Param('slug') slug) {
return await this.articleService.unFavorite(userId, slug)
}

@ApiOperation({ title: 'Get article feed' })
@ApiResponse({ status: 200, description: 'Return article feed.' })
@ApiResponse({ status: 403, description: 'Forbidden.' })
@Get('feed')
async getFeed (@User('id') userId: number, @Query() query): Promise<ArticlesRO> {
return await this.articleService.findFeed(userId, query)
}
}

转型计划之初级后端(上) 分析的那个 challenge-api 项目中, 路由注册是通过 app-routes.js 遍历一个中心化的 ‘./src/routes’ 将 ‘./src/controllers’ 中定义的 controllers 与 ‘./src/routes’ 中定义的 routes 给一一对应上的这种方式实现的。
而 nest 中的 controller 却恰恰相反, 路由是完全去中心化的, 中心化的工作交给了 module 去做。
可以看到 ArticleController 的 constructor 中莫名其妙多了一个 articleService, 这是通过 module 注入进来的,即所谓的控制反转, 依赖注入。
具体请参考: https://docs.nestjs.com/controllers

分析 service

接下来看 Service, Service 是大部分业务逻辑的承载者。
article.service.ts

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository, getRepository, DeleteResult } from 'typeorm'
import { ArticleEntity } from './article.entity'
import { Comment } from './comment.entity'
import { UserEntity } from '../user/user.entity'
import { FollowsEntity } from '../profile/follows.entity'
import { CreateArticleDto } from './dto'

import { ArticleRO, ArticlesRO, CommentsRO } from './article.interface'
import * as slug from 'slug'

@Injectable()
export class ArticleService {
constructor (
@InjectRepository(ArticleEntity)
private readonly articleRepository: Repository<ArticleEntity>,
@InjectRepository(Comment)
private readonly commentRepository: Repository<Comment>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(FollowsEntity)
private readonly followsRepository: Repository<FollowsEntity>
) {}

async findAll (query): Promise<ArticlesRO> {
const qb = await getRepository(ArticleEntity)
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author')

qb.where('1 = 1')

if ('tag' in query) {
qb.andWhere('article.tagList LIKE :tag', { tag: `%${query.tag}%` })
}

if ('author' in query) {
const author = await this.userRepository.findOne({
username: query.author
})
qb.andWhere('article.authorId = :id', { id: author.id })
}

if ('favorited' in query) {
const author = await this.userRepository.findOne({
username: query.favorited
})
const ids = author.favorites.map(el => el.id)
qb.andWhere('article.authorId IN (:ids)', { ids })
}

qb.orderBy('article.created', 'DESC')

const articlesCount = await qb.getCount()

if ('limit' in query) {
qb.limit(query.limit)
}

if ('offset' in query) {
qb.offset(query.offset)
}

const articles = await qb.getMany()

return { articles, articlesCount }
}

async findFeed (userId: number, query): Promise<ArticlesRO> {
const _follows = await this.followsRepository.find({ followerId: userId })
const ids = _follows.map(el => el.followingId)

const qb = await getRepository(ArticleEntity)
.createQueryBuilder('article')
.where('article.authorId IN (:ids)', { ids })

qb.orderBy('article.created', 'DESC')

const articlesCount = await qb.getCount()

if ('limit' in query) {
qb.limit(query.limit)
}

if ('offset' in query) {
qb.offset(query.offset)
}

const articles = await qb.getMany()

return { articles, articlesCount }
}

async findOne (where): Promise<ArticleRO> {
const article = await this.articleRepository.findOne(where)
return { article }
}

async addComment (slug: string, commentData): Promise<ArticleRO> {
let article = await this.articleRepository.findOne({ slug })

const comment = new Comment()
comment.body = commentData.body

article.comments.push(comment)

await this.commentRepository.save(comment)
article = await this.articleRepository.save(article)
return { article }
}

async deleteComment (slug: string, id: string): Promise<ArticleRO> {
let article = await this.articleRepository.findOne({ slug })

const comment = await this.commentRepository.findOne(id)
const deleteIndex = article.comments.findIndex(
_comment => _comment.id === comment.id
)

if (deleteIndex >= 0) {
const deleteComments = article.comments.splice(deleteIndex, 1)
await this.commentRepository.delete(deleteComments[0].id)
article = await this.articleRepository.save(article)
return { article }
} else {
return { article }
}
}

async favorite (id: number, slug: string): Promise<ArticleRO> {
let article = await this.articleRepository.findOne({ slug })
const user = await this.userRepository.findOne(id)

const isNewFavorite =
user.favorites.findIndex(_article => _article.id === article.id) < 0
if (isNewFavorite) {
user.favorites.push(article)
article.favoriteCount++

await this.userRepository.save(user)
article = await this.articleRepository.save(article)
}

return { article }
}

async unFavorite (id: number, slug: string): Promise<ArticleRO> {
let article = await this.articleRepository.findOne({ slug })
const user = await this.userRepository.findOne(id)

const deleteIndex = user.favorites.findIndex(
_article => _article.id === article.id
)

if (deleteIndex >= 0) {
user.favorites.splice(deleteIndex, 1)
article.favoriteCount--

await this.userRepository.save(user)
article = await this.articleRepository.save(article)
}

return { article }
}

async findComments (slug: string): Promise<CommentsRO> {
const article = await this.articleRepository.findOne({ slug })
return { comments: article.comments }
}

async create (
userId: number,
articleData: CreateArticleDto
): Promise<ArticleEntity> {
const article = new ArticleEntity()
article.title = articleData.title
article.description = articleData.description
article.slug = this.slugify(articleData.title)
article.tagList = articleData.tagList || []
article.comments = []

const newArticle = await this.articleRepository.save(article)

const author = await this.userRepository.findOne({ where: { id: userId } })

if (Array.isArray(author.articles)) {
author.articles.push(article)
} else {
author.articles = [article]
}

await this.userRepository.save(author)

return newArticle
}

async update (slug: string, articleData: any): Promise<ArticleRO> {
const toUpdate = await this.articleRepository.findOne({ slug: slug })
const updated = Object.assign(toUpdate, articleData)
const article = await this.articleRepository.save(updated)
return { article }
}

async delete (slug: string): Promise<DeleteResult> {
return await this.articleRepository.delete({ slug: slug })
}

slugify (title: string) {
return `${slug(title, { lower: true })}-${(
(Math.random() * Math.pow(36, 6)) |
0
).toString(36)}`
}
}

可以看到, article.interface article.entity Dto 这些周边文件都是这个 article.service 的组成部分
其中 article.entity 为与数据库表一一对应的实体, 可以直接根据 ER 图编写,打开 article.entity 你可以看到很多 @ManyToOne @OneToMany 这样的注解

1
2
3
4
5
6
@ManyToOne(type => UserEntity, user => user.articles)
author: UserEntity;

@OneToMany(type => Comment, comment => comment.article, { eager: true })
@JoinColumn()
comments: Comment[];

这些注解顾名思义就是用来描述数据库实体之间的关系的, 实体之间的关系这部分也在 ER 图中表现出来了。
而 DTO (Data Transfer Object), 可以看到都是 service 里面一些函数需要操作的数据对象, 这部分是前端传递过来的。
而 article.interface 定义了各种 RO, 所谓 RO 我的理解是 return object 的缩写。 这些都是返回给前端的数据对象。

总结

到这一步, 开发流程已经非常明朗了: 分析需求 >> 找出所有实体 >> 画 ER 图 >> 切分业务模块 >> 根据 ER 图编写 entity >> 编写 module 并在 app.module 导入 >> 编写 controller 的同时编写 service(此时按路由切分更细粒度的业务模块, 写好一个路由测试一个路由, 当然需要协同的除外)写完后在 module 导入 >> 使用 postman 测试路由 >> 完

改造样板

现在已经分析完 nestjs-realworld-example-app 这个样板的基本开发逻辑, 可以开始动手改造了

日志与异常捕获(exception-filters and logger)

首先要做的是将 challenge-api 中的error.jslogger.js 重构过来
nestjs 其实已经做了这件事情, error.js 对应 https://docs.nestjs.com/exception-filters, 而 logger.js 对应 https://docs.nestjs.com/techniques/logger

继续回过头来看 logger.js
logger.js

1
2
3
4
5
6
7
8
9
10
11
12
13
const { createLogger, format, transports } = require('winston')

const logger = createLogger({
level: config.LOG_LEVEL,
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.simple()
)
})
]
})

可以发现 logger.js 的引擎盖下(under the bonnet, 非常有意思的英语描述, 而中文习惯说: 驱动)是 winston.
winston 可是一个好东西, 所以点开 winston 点个 star, 然后继续
知道 logger.js 是使用了 winston 实现的后, 现在可以去 npm 搜索下关键词 nest winston 或者 Nest Logging
如果没有的话, 就需要自己实现了。不过一直很幸运, 找到一个 nestjs-winston 看 README.md 是符合要求的,点个 star 然后继续

当你发现可见的人越来越少时,你就需要警惕了, 因为你不是走在时代的前沿, 就是走在未开垦的荒漠。其实这两点并不冲突。

安装运行时依赖: npm install --save nest-winston winston 然后根据 README.md 文档的指示结合 logger.js 编写:
config.ts

1
2
export const SECRET = 'secret-key'
export const LOG_LEVEL = process.env.LOG_LEVEL || 'debug'

app.controller.ts

1
2
3
4
5
6
7
8
9
10
11
12
import { Get, Controller, Inject, HttpException, HttpStatus } from '@nestjs/common'
import { Logger } from 'winston'
@Controller('api')
export class AppController {
constructor(@Inject('winston') private readonly logger: Logger) { }
@Get()
root (): string {
this.logger.info('access {/, GET} route', [AppController.name])
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN)
return 'Hello World!'
}
}

app.module.ts

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
45
import { Module } from '@nestjs/common'

import { AppController } from './app.controller'

import { Connection } from 'typeorm'
import { TypeOrmModule } from '@nestjs/typeorm'

import { ArticleModule } from './article/article.module'
import { UserModule } from './user/user.module'
import { ProfileModule } from './profile/profile.module'
import { TagModule } from './tag/tag.module'

import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston'
import * as winston from 'winston'
import { LOG_LEVEL } from './config'

const { format, transports } = winston
@Module({
imports: [
WinstonModule.forRoot({
// options here
level: LOG_LEVEL,
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
nestWinstonModuleUtilities.format.nestLike()
)
})
]
}),
TypeOrmModule.forRoot(),
ArticleModule,
UserModule,
ProfileModule,
TagModule
],
controllers: [
AppController
],
providers: []
})
export class ApplicationModule {
constructor (private readonly connection: Connection) {}
}

main.ts

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 { ApplicationModule } from './app.module'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'

async function bootstrap (): Promise<void> {
const appOptions = { cors: true }
const app = await NestFactory.create(ApplicationModule, appOptions)
app.setGlobalPrefix('api')
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER))

const options = new DocumentBuilder()
.setTitle('NestJS Realworld Example App')
.setDescription('The Realworld API description')
.setVersion('1.0')
.setBasePath('api')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, options)
SwaggerModule.setup('/docs', app, document)

await app.listen(3000)
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap()

修改完成后,执行 npm run start:watch, 可以在 package.json 的 scripts 字段中看到
package.json

1
2
3
4
"scripts": {
"start": "node index.js",
"start:watch": "nodemon",
}

npm run start:watch 实际执行的是 nodemon, 而 nodemon 实际执行的是 nodemon index.js -w ./src , 而 nodemon index.js -w .src/ 实际执行的是 require('ts-node/register');require('./src/main.ts') 而…… 禁止套娃!!
nodemon 可是一个好东西,nodemon 可以让应用支持热重载,这对节省开发时间很有帮助! 所以点开 nodemon 点个 star, 然后继续

服务启动后访问 http://127.0.0.1:3000/api/api, 可以看到如下内容

1
2
3
4
{
"statusCode": 403,
"message": "Forbidden"
}

没错, 响应了一个带有错误消息的 json, 这是因为我刚在 app.controller.ts 中硬编码了一个错误 throw new HttpException('Forbidden', HttpStatus.FORBIDDEN)
可以看到终端控制台上, 我主动调用的 this.logger.info('access {/, GET} route', [AppController.name]) 日志被打印出来了,但是硬编码抛出的错误并没有被捕获处理!
回过头来看 challenge-api 的入口文件 topcoder-platform/challenge-api/-/blob/app.js#L68:5 可以发现 challenge-api 的作者是通过自定义一个全局中间件来做这件事情的,而 nestjs 中也有做这件事情的东西, 也就是上面链接的异常过滤器: https://docs.nestjs.com/exception-filters
所以任务来了, 我们实现一个异常过滤器来捕获并纪录程序抛出的错误:
先创建好文件: mkdir -p src/common/filters/;touch src/common/filters/all-exception.filter.ts
all-exception.filter.ts

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
45
46
47
48
49
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, LoggerService  } from '@nestjs/common'
import { Request, Response } from 'express'


@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor (private readonly logger: LoggerService) {}

catch (exception: HttpException | Error, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR
// Get the location where the error was thrown from to use as a logging tag
const stackTop =
exception.stack
.split("\n")[1]
.split('at ')[1]
.split(' ')[0]

const message = exception.message.message || exception.message
const meta = exception.message.meta
const logMessage = {
status,
message,
meta,
}

this.logger.error(JSON.stringify(logMessage), stackTop, "TRACE")



const method = request.method
const url = request.url
const requestTime = request.params.requestTime || 0

this.logger.log(
`${method} ${url} - ${status} - ${Date.now() - requestTime}ms`,
"Access"
)

response.status(status).send({
...logMessage,
})
}
}

config.ts

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 { utilities as nestWinstonModuleUtilities } from 'nest-winston'
import * as winston from 'winston'

const { format, transports } = winston

export const SECRET = 'secret-key'
export const LOG_LEVEL = process.env.LOG_LEVEL || 'debug'
export const PORT = 3000
export const LOGGER_CONFIG = {
// options here
level: LOG_LEVEL,
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
nestWinstonModuleUtilities.format.nestLike()
)
}),
new transports.File({
filename: 'combined.log',
level: 'info'
})
]
}

main.ts

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
import { NestFactory } from '@nestjs/core'
import { ApplicationModule } from './app.module'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'
import { WINSTON_MODULE_NEST_PROVIDER, WinstonModule } from 'nest-winston'
import { AllExceptionsFilter } from './common/filters/all-exception.filter'
import { LOGGER_CONFIG, PORT } from './config'

const logger = WinstonModule.createLogger(LOGGER_CONFIG)

async function bootstrap (): Promise<void> {
const appOptions = { cors: true, logger }
const app = await NestFactory.create(ApplicationModule, appOptions)
app.setGlobalPrefix('api')
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER))
app.useGlobalFilters(new AllExceptionsFilter(logger))

const options = new DocumentBuilder()
.setTitle('NestJS Realworld Example App')
.setDescription('The Realworld API description')
.setVersion('1.0')
.setBasePath('api')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, options)
SwaggerModule.setup('/docs', app, document)

await app.listen(3000)
logger.log(`Server started on port ${PORT}`)
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap()

app.module.ts

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
import { Module } from '@nestjs/common'

import { AppController } from './app.controller'

import { Connection } from 'typeorm'
import { TypeOrmModule } from '@nestjs/typeorm'

import { ArticleModule } from './article/article.module'
import { UserModule } from './user/user.module'
import { ProfileModule } from './profile/profile.module'
import { TagModule } from './tag/tag.module'

import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston'
import * as winston from 'winston'
import { LOGGER_CONFIG } from './config'

const { format, transports } = winston
@Module({
imports: [
WinstonModule.forRoot(LOGGER_CONFIG),
TypeOrmModule.forRoot(),
ArticleModule,
UserModule,
ProfileModule,
TagModule
],
controllers: [
AppController
],
providers: []
})
export class ApplicationModule {
constructor (private readonly connection: Connection) {}
}

再次打开 http://127.0.0.1:3000/api/api 可以看到抛出的错误已经被成功捕获。但是这里有一个小问题, 那就是请求耗时并不准确, 下一步就是要解决这个问题。

使用 interceptors 修复请求耗时不准的问题

请求耗时不准确的原因是在 all-exception.filter.ts 文件中的这一行代码 const requestTime = request.params.requestTime || 0
很明显请求参数并没有带上 requestTime 这个属性, 这里我为了让 requestTime 有值设了一个 0。
既然问题是请求参数没有 requestTime 这个属性,那么我们只要在服务器接受这个请求的时候, 给请求参数加上这个属性就行了
这件事情可以由 nestjs 中的拦截器来做: https://docs.nestjs.com/interceptors
由于 nestjs 中的拦截器用到了 rxjs, 而我因为没有使用的 rxjs 场景, 所以并没有仔细的读过 rxjs 文档,
对里面的一些操作细节还不是很熟悉, 所以先花一两天时间把 rxjs 中文网 的文档读一遍, 然后再继续写。
突然感觉 rxjs 真的很热门, flutter app 里面也有使用 rxjs 管理数据状态的方案.
花了半天时间, RxJS 的基本概念和基本使用都了解的差不多了, 继续:
先新建文件 mkdir -p src/common/interceptors/;touch src/common/interceptors/logging.interceptor.ts
logging.interceptor.ts

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
  import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
LoggerService
} from "@nestjs/common"
import { Observable } from "rxjs"
import { tap } from "rxjs/operators"
import { Request, Response } from 'express'

@Injectable()
export class LoggingInterceptor implements NestInterceptor {

constructor(private readonly logger: LoggerService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()
const method = request.method
const url = request.url

const requestTime = Date.now()

// Add request time to params to be used in exception filters
request.params.requestTime = requestTime

return next
.handle()
.pipe(
tap(() =>
this.logger.log(
`${method} ${url} - ${response.res.statusCode} - ${Date.now() - requestTime}ms`,
),
),
)
}
}

main.ts
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 { NestFactory } from '@nestjs/core'
import { ApplicationModule } from './app.module'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'
import { WINSTON_MODULE_NEST_PROVIDER, WinstonModule } from 'nest-winston'
import { LoggingInterceptor } from './common/interceptors/logging.interceptor'
import { AllExceptionsFilter } from './common/filters/all-exception.filter'
import { LOGGER_CONFIG, PORT } from './config'

const logger = WinstonModule.createLogger(LOGGER_CONFIG)

async function bootstrap (): Promise<void> {
const appOptions = { cors: true, logger }
const app = await NestFactory.create(ApplicationModule, appOptions)
app.setGlobalPrefix('api')
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER))
app.useGlobalInterceptors(new LoggingInterceptor(logger))
app.useGlobalFilters(new AllExceptionsFilter(logger))

const options = new DocumentBuilder()
.setTitle('NestJS Realworld Example App')
.setDescription('The Realworld API description')
.setVersion('1.0')
.setBasePath('api')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, options)
SwaggerModule.setup('/docs', app, document)

await app.listen(3000)
logger.log(`Server started on port ${PORT}`)
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap()

添加了这个全局拦截器后, 就能看到控制打印出每次请求的耗时了
要想理解拦截器的执行过程还得看官方文档: https://docs.nestjs.com/interceptors#call-handler

Consider, for example, an incoming POST /cats request. This request is destined for the create() handler defined inside the CatsController. If an interceptor which does not call the handle() method is called anywhere along the way, the create() method won’t be executed. Once handle() is called (and its Observable has been returned), the create() handler will be triggered. And once the response stream is received via the Observable, additional operations can be performed on the stream, and a final result returned to the caller.

当 HTTP 请求到达请求的路由方法之前,会被拦截器拦截, 并执行拦截器实现的 intercept 方法, intercept 执行完成后将 next.handle() 返回的 Observable 添加一些 RxJS operators(Pipeable 操作符), 根据rxjs 中文网 的文档我们知道 RxJS operators 将返回一个新的 Observable, nestjs 框架得到了intercept 方法返回的 Observable,然后执行请求的路由方法,并在此之后, 将得到的 Observable 进行 subscribe(订阅执行), 因此我们在路由方法之前与之后插入了代码逻辑, 既所谓的 面向切面编程(AOP).

使用 pipes 处理错误的请求体

接下来, 试想这样一个场景, 前端请求一个路由, 但是传递了一个错误的,不符合要求的请求体参数,一般情况下我们会在路由方法中做参数验证,如果验证未通过,就将错误响应给前端。但是,如果每个路由都这么做,会有很多冗余代码, 不够优雅。而 nestjs 有一个东西叫 pipes 可以解决这个问题: https://docs.nestjs.com/pipes
mkdir -p src/common/pipes/;touch src/common/pipes/body-validation.pipe.ts
body-validation.pipe.ts

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from "@nestjs/common"
import { validate } from "class-validator"
import { plainToClass } from "class-transformer"

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata) {
// Account for an empty request body
if (value == null) {
value = {}
}

if (!metatype || !this.toValidate(metatype)) {
return value
}

const object = plainToClass(metatype, value)
const errors = await validate(object, {
forbidUnknownValues: true,
whitelist: true,
forbidNonWhitelisted: true,
})

if (errors.length > 0) {
// Top-level errors
const topLevelErrors = errors
.filter(v => v.constraints)
.map(error => {
return {
property: error.property,
constraints: Object.values(error.constraints),
}
})

// Nested errors
const nestedErrors = errors
.filter(v => !v.constraints)
.map(error => {
const validationErrors = this.getValidationErrorsFromChildren(
error.property,
error.children,
)
return validationErrors
})

throw new BadRequestException({
message: "Validation failed",
meta: topLevelErrors.concat(...nestedErrors),
})
}

return value
}

private toValidate(metatype: any): boolean {
const types: Array<() => any> = [String, Boolean, Number, Array, Object]
return !types.includes(metatype)
}

private getValidationErrorsFromChildren(parent, children, errors = []) {
children.forEach(child => {
if (child.constraints) {
errors.push({
property: `${parent}.${child.property}`,
constraints: Object.values(child.constraints),
})
} else {
return this.getValidationErrorsFromChildren(
`${parent}.${child.property}`,
child.children,
errors,
)
}
})
return errors
}
}

main.ts

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
import { NestFactory } from '@nestjs/core'
import { ApplicationModule } from './app.module'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'
import { WINSTON_MODULE_NEST_PROVIDER, WinstonModule } from 'nest-winston'
import { LoggingInterceptor } from './common/interceptors/logging.interceptor'
import { AllExceptionsFilter } from './common/filters/all-exception.filter'
import { ValidationPipe } from './common/pipes/body-validation.pipe'
import { LOGGER_CONFIG, PORT } from './config'

const logger = WinstonModule.createLogger(LOGGER_CONFIG)

async function bootstrap (): Promise<void> {
const appOptions = { cors: true, logger }
const app = await NestFactory.create(ApplicationModule, appOptions)
app.setGlobalPrefix('api')
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER))
app.useGlobalInterceptors(new LoggingInterceptor(logger))
app.useGlobalFilters(new AllExceptionsFilter(logger))
app.useGlobalPipes(new ValidationPipe())

const options = new DocumentBuilder()
.setTitle('NestJS Realworld Example App')
.setDescription('The Realworld API description')
.setVersion('1.0')
.setBasePath('api')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, options)
SwaggerModule.setup('/docs', app, document)

await app.listen(3000)
logger.log(`Server started on port ${PORT}`)
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap()

点开 http://127.0.0.1:3000/docs 找到 POST ​/users​/login 点击 Try it out, 然后 Execute,可以看到 Response body 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"status": 400,
"message": "Validation failed",
"meta": [
{
"property": "username",
"constraints": [
"username should not be empty"
]
},
{
"property": "email",
"constraints": [
"email should not be empty"
]
},
{
"property": "password",
"constraints": [
"password should not be empty"
]
}
]
}

pipes 与 exception filters 基本上是一样的, 只不过是一个验证或转换请求体,一个捕获异常。pipes 中使用了两个库: class-validatorclass-transformer
一个是对类成员的赋值进行验证, 一个是将字面量对象转为类对象或其实例。都是很好的东西, 所以点个 star 继续。

其他(Guards and Middleware)

到这里这篇文章就可以结束了,基本概念只有 Guards 与 Middleware 没有涉及, Middleware 可以参考这个样板的:
auth.middleware.ts

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 { HttpException } from '@nestjs/common/exceptions/http.exception'
import { NestMiddleware, HttpStatus, Injectable } from '@nestjs/common'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { Request, Response, NextFunction } from 'express'
import * as jwt from 'jsonwebtoken'
import { SECRET } from '../config'
import { UserService } from './user.service'

@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor (private readonly userService: UserService) {}

async use (req: Request, res: Response, next: NextFunction) {
const authHeaders = req.headers.authorization
if (authHeaders && (authHeaders as string).split(' ')[1]) {
const token = (authHeaders as string).split(' ')[1]
const decoded: any = jwt.verify(token, SECRET)
const user = await this.userService.findById(decoded.id)

if (!user) {
throw new HttpException('User not found.', HttpStatus.UNAUTHORIZED)
}

req.user = user.user
next()
} else {
throw new HttpException('Not authorized.', HttpStatus.UNAUTHORIZED)
}
}
}

而使用也可以在 module 中看到:
user.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'
import { UserController } from './user.controller'
import { TypeOrmModule } from '@nestjs/typeorm'
import { UserEntity } from './user.entity'
import { UserService } from './user.service'
import { AuthMiddleware } from './auth.middleware'

@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
providers: [UserService],
controllers: [
UserController
],
exports: [UserService]
})
export class UserModule implements NestModule {
public configure (consumer: MiddlewareConsumer) {
consumer
.apply(AuthMiddleware)
.forRoutes({ path: 'user', method: RequestMethod.GET }, { path: 'user', method: RequestMethod.PUT })
}
}

Guards 与 Middleware 区别在官方文档里说的很清楚: https://docs.nestjs.com/guards
还有一个区别就是 Middleware 的应用是中心化的, 而 Guards 的应用是去中心化的,就和路由的 HTTP 方法注解一样。

本篇完。