从 NestJS 入门写一个简单的 BFF 层来做数据清洗
前言
在国内目前的互联网大厂中,后端基本上没有 JS 的容身之地,基本上用的都是 GO 和 JAVA,那么我们再去学习使用 JS 进行后端开发还有什么意义呢?一个比较重要的点就是拓宽我们的技术视野,可以去接触一下后端开发相关的知识和概念,不再把自己的认知只局限在前端并且增强自己的核心竞争力,还能得到一个全栈开发者名头。那我学习 JS 后端难道就完全没有用武之地了吗?其实在个人项目和一些创业公司里面还是可以用得上的,而且还有一个比较重要的场景,当后端提供的接口无用的字段太多,或者实现一个页面需要调用好多个接口的时候(简单来说就是后端可以提供的接口不够好用而且没法改)就会需要前端团队自己去维护一个**BFF 层(Backend for Frontend)**,这是最核心的战场,我们也不再做过多介绍了,接下来直接学习如果使用 NestJS 去开发一个 BFF 层吧。
快速开始
我们选择一个数据源作为后端已经写好的接口,我们这里使用JSONPlaceholder作为数据源,我们接下来会使用到https://jsonplaceholder.typicode.com/users接口和https://jsonplaceholder.typicode.com/posts?userId=1接口
项目创建:
全局安装 Nest CLI
1 | npm i -g @nestjs/cli |
初始化新项目
1 | nest new bff-demo |
启动项目
1 | cd bff-demo |
此时项目应当已经成功跑起来了,简单说明一下项目的结构
main.ts是入口文件,用来创建应用实例,监听 3000 端口。app.module.ts是根模块,用来管理子模。app.controller.ts是控制器,用于定义路由 (如 GET /),负责接收请求。app.service.ts是具体服务,用来写具体业务逻辑 (如返回 “Hello World”)。app.controller.spec.ts是测试文件,先不用管
数据清洗
我们在浏览器窗口发一下请求看一下返回的结构:
1 | [ |
我们先明确好几点任务:
- 请求上游的 User 接口。
- 扁平化:把嵌套的
address.city提取出来 - 重命名:把
username改成nickname - 剔除:删除掉无用的经纬度数据
创建模块
在项目根目录运行以下命令
1 | nest g resource user |
我们此时选择REST API和创建CURD示例
此时在/src目录下会新建一个名为user的文件夹
目录结构
此时我们再来看一下user的目录结构
user.module.ts:模块文件user.controller.ts:控制器user.service.ts:服务(写业务逻辑的地方)。user.service.sepc.ts:测试文件dto/:数据传输对象(用来定义请求参数的类型,比如“创建用户需要传什么字段”)。entities/:实体(通常对应数据库表结构,但在 BFF 里我们可以用来定义返回给前端的结构也就是给前端看的视图)。
依赖安装
1 | npm i --save @nestjs/axios axios |
代码编写
模块依赖配置
以下代码可以先cv进去记得在 user.module.ts 导入 HttpModule
1 | import { HttpModule } from '@nestjs/axios'; |
Entity
1 | /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ |
Srevice
1 | /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ |
Controller
1 | // src/user/user.controller.ts |
装饰器(Decorators)
你可能会好奇这些带有@的东西到底是什么,如果你有JAVA背景的话就该发现其实这些和Spring Boot里的注解高度相似,我就先简单介绍一下会用到的装饰器。
| 装饰器 | 作用 |
|---|---|
@Expose() |
暴露字段。只有加了它的字段才会返回给前端(配合 @Exclude 类级别使用时)。 |
@Exclude() |
隐藏字段。通常放在 Class 头上,表示“默认全隐藏,谁有 Expose 谁才显示”。 |
@Transform() |
自定义转换逻辑,把 A 变成 B。比如可以扁平化嵌套对象、把 status: 1 变成 已完成 等 |
@Controller |
作为路由前缀,例如@Controller('user')就是告诉NextJS但凡是以 /user 开头的请求,都在这里处理。 |
@Get,@Post等用来定义请求方法的的装饰器 |
定义具体的请求方法和子路径,用来处理 |
@Injectable |
帮忙进行依赖注入,当在Service前加上这个就不需要自己在Controller中自己手动初始化 |
@UseInterceptors |
本质是一个拦截器,主要工作在请求进来之前和响应出去之前的时候,就比如说Service层拿到数据之后在响应之前会根据响应的实体类来检查@Exclude和@Expose标签,把不该带的东西扣下,只放行干净的数据 |
@Type() |
类型转换。告诉 class-transformer 这个嵌套对象的具体类型,否则会被当成普通 Object。方便拦截和清洗数据,你会在后面的数据聚合部分能看到 |
避坑
拦截器 (ClassSerializerInterceptor) 生效的前提是:你返回的必须是“类的实例”,而不是普通的 JS 对象。
- 错误写法此时拦截器认为这不是Entity,直接原样返回,数据清洗失败
1
2
3
4
5// Service
async findAll() {
const { data } = await http.get(...);
return data; // 👈 这里返回的是普通 JSON 对象 (Plain Object)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23{
"id": 1,
"name": "Leanne Graham",
"email": "Sincere@april.biz",
"website": "hildegard.org",
"username": "Bret",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
} - 正确写法此时的响应 belike:
1
2
3
4
5
6// Service
async findAll() {
const { data } = await http.get(...);
// 👇 必须用 new 变成实例,或者用 plainToInstance 方法
return data.map(user => new UserEntity(user));
}1
2
3
4
5
6
7
8
9{
"id": 1,
"name": "Leanne Graham",
"nickname": "Bret",
"email": "Sincere@april.biz",
"website": "hildegard.org",
"location": "Gwenborough, Kulas Light",
"companyName": "Romaguera-Crona"
}
一些说明
你可能很奇怪 firstValueFrom 这玩意到底是干什么用的,简单来说,firstValueFrom 是 RxJS 库提供的一个工具函数,它的作用是:
把一个 Observable(可观察对象/流)转换成一个 Promise。
在 NestJS 中,这通常是为了让你能用更舒服的 async / await 语法来写代码。
这里只了解,看一下这个更为舒服的写法是什么样的:
1 | // 加上 async/await,代码逻辑像同步代码一样清晰 |
你可能好奇这段注释是干什么用的
1 | /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ |
这是防止 eslint 报错的,我偷懒不想写 UserRawData 接口来表述返回的原始结构了,直接上 any
清理效果
1 | [ |
数据聚合
我们先给自己确定一个新目标,在查询/user/1的时候不仅要查询到用户信息还要查询到对应的所有帖子的信息,这本来是需要调用好几个接口的,而且一般的后端基本上都不会这么写的,但是为了方便前端用,不要在前端写太多复杂的非 UI 逻辑,就只能前端自己写一个BFF层了
创建模块
创建 src/user/entities/post.entity.ts
代码编写
Entity
post.entity.ts
1 | import { Exclude, Expose } from 'class-transformer'; |
user.entity.ts
1 | // 👇 1. 引入 Type 和 PostEntity |
修改UserEntity
Serivce
此时我们可以发送并发请求
1 | import { Injectable, NotFoundException } from '@nestjs/common'; |
聚合效果
1 | { |
全局拦截器
如果每一个 Controller 都加上 @UseInterceptors(ClassSerializerInterceptor) 是否会有些累赘?(虽然在你使用cli创建模块的时候已经自动带上了),而且万一忘记写了怎么办?那要怎么做呢,那干脆在main.ts里面加上全局的安检吧
如下:
1 | // src/main.ts |
恭喜你,目前你已经学会怎么用 NestJS 去写一个简单的 BFF 层,恭喜你离一个高贵的前端开发者更近了一步!!!