前言
来到现在这家公司前,我也算是快4年经验的前端开发了,Vue/React双持的熟练工,PC/移动端/小程序多个项目的0-1开发经验,外加现在AI工具加持,总觉得应付日常开发不成问题。
可谁曾想,入职的头一天,clone项目看了一眼,也可能是我有点“代码洁癖”,我一度怀疑我是不是没睡醒
洗把脸回来再看了几遍,我把项目里奇怪的地方总结了一下
所有请求都传到同一个ip地址,不区分path
所有传参用
JSON.stringify拼接在一块儿用户所有信息明文携带在各个请求的参数里面
长列表数据不做分页,一次性返回
请求成功/失败时候,返回的数据格式不统一
长相一致的请求
项目运行后,相信有些小伙伴会打开控制台看看接口,但我第一次神奇地发现,怎么这个项目的接口名全是ip地址啊?这都谁跟谁啊?
不是,这不用path区分的吗?对于我这个前端来说,这debug多费劲啊……
最开始时候,我能在有的页面看到十几个往同一ip发送的接口,现在找不到这种页面了,但是超过5个都长“一张脸”的接口,每次debug都要点开看参数才知道谁是谁,是不是也太累了点……
诡异的传参拼接
行吧,长一样的接口,我勉强也能接受,就debug时候点开看看参数区分一下吧
但是当我点开请求想查看传参的时候,我发现事情没我想得这么简单……
不是,这所有参数都JSON.stringify,debug的时候眼睛不累的吗?咱就是说不必要连传参都搞这么“防御性”吧
明文传递的用户信息
再看一遍接口的stringify参数,我傻眼了,居然有一个完整user字段,把用户的名字、部门、工号、卡号、入职日期等等全都明文展示出来了
这,真的,没问题吗?安全吗?
而且退一步说,有必要每次都带这么多信息去判断用户状态吗?
长列表数据一次性返回
OK,上面的传参和明文用户信息我也忍了,没准人设计有一定道理呢
但是我又发现,只要是表格/列表渲染,页面就莫名其妙很卡很卡,要么加载好久,要么直接卡死
打开控制台的一瞬间,我傻了
1分钟多的一个返回数据256MB的接口?这怎么写的?
当我换了其他的列表页面,打开控制台,又看到了这个
有证据了,跟后端battle,结果他说他一开始就没!设!计!分!页!,甚至还嘴角上扬起来“这数据不需要做分页啊”
冗余数据嵌套
刚才这个分页问题里面,如果细心,应该也发现,真实的数据都包裹在内部的内部
那就是说我列表渲染我还得给你从list.properties.XXX取值咯?
我一寻思,不能传参也是这样的吧,一看代码,得,又给我说中了,这参数真的套路深啊
区分不了的提交
虽然我知道小公司会没办法注意一些规范,但没想到,git上面的提交记录,后端居然每次都写个1
最后的挣扎
最后,抱着一丝侥幸心理(希望这些都不是真的),我在项目中找了一下接口相关的代码,发现,还真是JSON.stringify + 明文用户信息,而且确实不区分path,直接传递到ip
完犊子,实锤了……
其他问题
当然还不止以上这些问题,还有一些细碎的是我开发过程中发现的,诸如:
- 返回数据格式不统一(成功时候数据返回在
result,失败时候却返回在trace,有的数据包裹在XXXObject多层嵌套,有的没有) - 使用非自增字段作为数据库表的主键(主键的设定有时候会听业务人员要求,这个我大为震惊)
- 返回的数据都是
string没有number - 不支持变量驼峰命名
- 不可以传递
null给后端
当然,有些问题可能只是个人习惯,有些吧,估计也是后端懒,有的我就不是很懂了(一只半道出家的前端崽)
但是这种感觉……真的超影响开发体验的好吗?
解决方案
OK,吐槽归吐槽,活儿还是要干的嘛。就在我头疼这后端给的“破接口”以后怎么用的时候,灵机一动,先想了一个方案:
在前端项目中封装一个函数,结合axios拦截器实现数据清洗
这个方案一开始觉得可行,但是写了一会儿,就觉得这个方法会有几个问题:
- 页面端查看接口的时候,依然还是ip+长字符串的参数,还是不方便debug接口问题
- 项目耦合,如果后续有多个项目,我得在各个前端项目中重复写好几遍
- 数据的处理比较繁琐,毕竟还有不同的接口状态返回格式(成功/失败),以及不同接口返回数据的格式
node中间层
axios拦截器这条路走不通了,我想了想,毕竟后端问题大头是数据嘛,那我在前后端中间做个桥梁不就好了,这个桥梁只要满足
- 转发我请求的参数给后端(前端按照
RESTful),转换数据格式 - 把后端返回的数据做清洗
- 后端需要的用户数据,加密传递,到中间层后再解密给后端
以前在企业项目中,在一次后端还没给接口的时候,写个Koa2 + Sequelize + MySQL替代一下mockjs,至少玩个真实的数据库和接口的交互嘛。
之前的某次面试中,我也主动提到了这个经历。当时面试官眉头一皱:你是写了个中间层还是接口?
当时我还没有接触过中间层的概念,直到这个时候,我突然意识到,这个中间层不就是我要的吗
作为前端崽,中间层也只会用nodejs写,于是node中间层的方案就这么定了下来
中间层技术选型
node的框架主流的包括
- Express
- Koa2
- NestJS
最后我选择了NestJS搭建中间层,主要考虑了它丰富的工具(管道、守卫、拦截器等,当然这也是双刃剑),层次分明,条理清晰
当然,也有自己的一点小私心,就是多多使用一些新工具~
提交记录规范
提交记录规范这个我一直使用type:(scope)<subject>这一套,类似于这个commit规范
学习Sunday老师(栋哥)的课程后,知道了cz-config这个自动化工具,直接在项目里配置了一套,可以参考中后台解决方案学习心得这篇文章
项目搭建
项目直接使用NestJS脚手架@nestjs/cli创建
nest new 项目名称
脚手架自动生成文件
NestJS脚手架不只是生成项目,还非常方便地提供了一些方法,可以使用nest g XXX自动生成文件
我在项目中常用的包括
生成指定名称的
controller/service/module/interceptor文件(最基础)
nest g controller 路径/模块名
nest g service 路径/模块名
nest g module 路径/模块名
nest g interceptor 路径/模块名
不生成
test测试文件
nest g controller/service/module 路径/模块名 --no-spec
干生成
dry
nest g controller/service/module 路径/模块名 -d
这个超好用,可以模拟语句实际生成的效果,告知用户执行命令后创建/更新哪些文件,并不实际创建文件
扁平化生成文件
flat
nest g itc 路径/模块名 --flat
这个我一般用在指定目录下直接生成interceptor拦截器,而非再次创建目录
可以看出文件名都是创建路径path最后一部分(haha),唯一区别是文件的创建路径
- 有flat:剔除路径
path最后一部分,在这个目录下创建文件 - 没有flat:完整路径下,创建文件
文件名简称
可以用nest --help查看所有nest脚手架支持创建的文件,其中alias这一列就是文件名简称
例如:nest g controller可以简写成nest g co
环境配置
项目中肯定会涉及一些通用的环境变量,而在开发环境和生产环境可能还有区别,因此需要做一个环境配置
这里使用了NestJS官方的@nestjs/config,本质上是对dotenv的封装
可以在项目根目录下添加若干.env文件,区分环境
为了让全局都能读取到配置项,要添加forRoot并开启isGlobal属性,结合cross-env区分不同环境
// app.module.ts
// 默认读取.env.development
const envFilePath = [`.env.${process.env.NODE_ENV || 'development'}`, '.env'];
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath,
}),
......
],
controllers: [],
providers: [],
})
// package.json
"scripts": {
......
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production nest start --watch",
}
这样一来就可以在各个文件中使用configService.get读取配置了
但是考虑到扩展性和维护性,把配置项的key用常量做封装
// config.enum.ts
export const BASE_URL = 'BASE_URL';
// attendance.service.ts
@Injectable()
export class AttendanceService {
private readonly service;
constructor(private readonly configService: ConfigService) {
this.service = createService(this.configService.get<string>(BASE_URL));
}
}
项目架构
面向切面编程AOP
我们知道前端常用的MVVM框架Vue/React都有生命周期,这一生命周期其实可以和页面的渲染过程对应上
例如
beforeCreate:浏览器加载DOM,初步解析
created/mounted:浏览器渲染页面,挂载DOM
updated:DOM更新
unmounted:页面卸载,DOM销毁
而这些渲染过程的节点,我们都可以叫做切面,可以理解成某个时间节点
我们可以选择在这些时间节点上做一些事情,也可以日后移除
而这些添加/删除操作的过程,不会影响整个页面渲染流程
切面的核心就在于,保留了流程的完整性,同时还提供了扩展性
和前端项目类似,NestJS写接口的时候,也要考虑代码可维护性和复用性,主要使用了面向切面编程思想AOP(Aspect-Oriented Programming)
从大体上看,接口发送和接收数据的过程涉及到
- 两端
- 客户端
- 服务端
- 三层(服务端)
Controller:接收请求/返回数据Service:数据库数据的CURD操作Data Access:详细的数据库数据
图片选自慕课网Brain老师的NestJS 入门到实战 前端必学服务端新趋势
在此基础上,nestjs基于切面做了更细的处理
- 请求发出
- 守卫:经典的例如登录守卫
- 前置拦截器
- 管道:可以校验请求参数是否符合要求
- 服务器返回数据
- 后置拦截器:可对接口返回的数据进行清洗调整
- 过滤器:异常/错误数据处理
这样一来,用户对接口就可以有更精细的控制,同时这种控制也不影响主流程,可以随时添加/删除/扩展
图片选自慕课网Brain老师的NestJS 入门到实战 前端必学服务端新趋势
项目功能
针对于我自己遇到的后端接口的问题,整个NestJS中间层主要做了以下几个事情
- 登录鉴权:解决之前后端接口对用户数据的明文传输
- 返回数据格式转换:解决返回数据嵌套过深、数据格式没有转换、缺少驼峰命名等问题
- 假分页:性能上优化不了,在返回给前端的数据上尽量精简
登录鉴权
我们知道HTTP作为一个无状态的协议,每一次发送到服务端的请求,服务端是没办法直接根据请求判断用户登录状态的,所以主流的方法是在请求里携带一些信息一并传递给服务端
常用的包括
Cookie + Session:其中Cookie存储在客户端,Session存储在服务端
JWT:Json Web Token,请求头Header中携带认证信息
其中JWT是目前比较主流的跨域认证方案
- 包括了Header头部、Payload载荷和Signature签名三个部分
- 可以设置一个过期时间,在有效期内可以无限次使用
- 可以在Payload中携带一些信息,通过解密才能获取
可以在Json Web Token官网上看到对于其详细的介绍
JWT流程
JWT使用过程比较流程化,大致为
- 做登录接口(用户名 + 密码)
- 在登录成功的时候,根据用户信息+密钥+过期时间,生成Token,添加在登录接口的返回信息中
- 其他接口请求的时候,需要在请求头上带上Token
- 服务端对接收到的请求做Token的验证
- 通过,返回对应数据
- 未通过,返回
401UnAuthorized
在官网的身份验证章节可以看到整个JWT的实现流程
登录基础流程实现
在实现JWT之前,我们先需要把登录流程给做了,创建一个User Module,包含Controller和Service
// user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
// user.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserService } from './user.service';
import { LoginResponseDto } from './dto';
@ApiTags('user 用户')
@Controller('user')
export class UserController {
constructor(private userService: UserService) {}
@Post('login')
@ApiOperation({
summary: '用户登录',
description: '用户登录,获取登录用的token令牌',
})
@ApiBody({ description: '登录信息(用户名、密码)', type: UserDto })
@ApiResponse({
status: 200,
description: '登录成功',
type: LoginResponseDto,
})
async login(@Body() user: UserDto) {
const result = await this.userService.login(user);
return result;
}
}
因为是转发给后端接口,所以Service层中只是遵循了后端接口的要求拼接参数,生成axios的post请求
// user.service.ts
import { Injectable } from '@nestjs/common';
import { UserDto } from './dto';
import { ConfigService } from '@nestjs/config';
import { createService } from 'src/utils/service';
import { HR_BASE_URL } from 'src/enum/config.enum';
@Injectable()
export class UserService {
private readonly service;
constructor(private readonly configService: ConfigService) {
this.service = createService(this.configService.get<string>(HR_BASE_URL));
}
async login(user: UserDto) {
return this.service({
url: '/login',
method: 'post',
data: {
fun: 'HmrLogin',
param: {
userName: user.username,
password: user.password,
},
},
});
}
}
到这里已经可以正常完成登录请求了,发送用户名密码到/login接口,可以正常返回用户信息
后置拦截登录信息
接下来就是要在用户登录成功的情况下,把JWT生成并添加在返回数据中,这个过程应该发生在中间层拿到后端数据,准备返回给客户端之前(下图红框标记)
从前面NestJS提供的切面控制工具来看,这一操作应该使用后置拦截器Interceptor最合适
在User Module中创建一个Token Interceptor
// token.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Observable, map } from 'rxjs';
import { HR_AUTH_EXPIRE_TIME } from 'src/enum';
import { AuthService } from 'src/modules/auth/auth.service';
import { addTimeToTimestamp } from 'src/utils/moment';
@Injectable()
export class TokenInterceptor implements NestInterceptor {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((res) => {
const {
data: { resFlag, result },
} = res;
if (resFlag === 0) {
const { id, type } = result;
if (result && id && type) {
// 1. 使用id和type作为payload生成token
const authExpireTime =
this.configService.get<string>(HR_AUTH_EXPIRE_TIME);
const token = this.authService.certificate(
{ id, type },
authExpireTime,
);
// 2. 生成token过期时间
const expireTime = addTimeToTimestamp(authExpireTime);
Object.assign(result, { token, expireTime });
}
}
return res;
}),
);
}
}
添加为User Controller的全局后置拦截器(这里有点嫌设置单个路由拦截器写的太多了,偷懒了一点)
// user.controller.ts
import { UseInterceptors } from '@nestjs/common';
import { TokenInterceptor } from './interceptors/token.interceptor';
@UseInterceptors(TokenInterceptor)
export class UserController {
......
}
封装JWT生成逻辑
Token Interceptor里面,实现了两个事情
- Token生成:根据
id + type(后端要求必须携带的用户信息)作为payload,以及配置好的过期时间生成 - expireTime生成:这个专门提供给前端的,在超时的时候不必通过接口的401结果再退出登录,可以在项目启动的时候直接返回登录页
考虑到JWT可能以后会扩展到多个模块(不同系统的登录用不同的密钥/逻辑等),单独封装一个Auth模块处理JWT鉴权
在Service层中添加生成Token的方法certificate
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { AuthInfoDto } from './dto';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
certificate(user: AuthInfoDto, expireTime?: string) {
const token = this.jwtService.sign(user, { expiresIn: expireTime });
return token;
}
}
在Auth Module中需要设置好JWT的密钥、加密算法等配置项
此外,为了在其他模块中使用Auth Service,需要在Auth Module中导出,后续其他模块直接导入整个Auth Module即可
// auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SECRET } from 'src/enum';
@Module({
imports: [
// 这里因为密钥放在配置项,所以需要导入ConfigService配置
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
return {
secret: configService.get<string>(SECRET),
signOptions: {
algorithm: 'HS256',
},
};
},
}),
],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
这样之后,我们再请求/login登录接口,就可以返回Token和过期时间了
Token信息的验证
Token生成好了,下一步就是我们带入验证了,思路上很简单
- 使用密钥解密Token
- 正常解密:把
payload解析出来,放到request.user参数中 - 过了有效期/不能解密:返回
401Unauthorized
- 正常解密:把
在官网的通行证(认证)章节可以看到完整的实现流程
这一部分NestJS官方其实基本封装好了,我们只需要按照要求搭建
首先安装@nestjs/passport、passport两个包,另外因为我们用的JWT,所以还要安装passport-jwt
npm i @nestjs/passport passport passport-jwt
然后创建一个Strategy文件,起名为JwtStrategy
// auth.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SECRET } from 'src/enum';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(protected configService: ConfigService) {
super({
// 从请求头的Authorization的Bearer取值
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// 验证过期时间,过期的话校验失败
ignoreExpiration: false,
// 校验用的密钥
secretOrKey: configService.get<string>(SECRET),
});
}
// 如果JWT通过校验,validate方法会自动执行,其中参数就是payload
async validate(payload: any) {
return payload;
}
}
最后把这个Strategy放到Auth Module的prividers里即可
// auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtStrategy } from './auth.strategy';
@Module({
......
providers: [AuthService, JwtStrategy],
......
})
export class AuthModule {}
完成了这一套操作之后,我们只需要在需要鉴权的路由上添加守卫Guard,使用UseGuards,就可以实现JWT登录鉴权了,payload解析出来的数据会出现在request.user中
import {
Controller,
Post,
Req,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Post('test')
@UseGuards(AuthGuard('jwt'))
async test(@Req() request: any) {
console.log(request.user);
}
为了方便后面使用,可以把这个守卫单独封装成一个JwtGuard
// jwt.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
constructor() {
super();
}
}
此时对于添加了JwtGuard的请求,只要在请求头的Authorization中正确添加之前返回的Token,就可以在request.user拿到payload信息了
这些操作实际上都是JwtModule里的PassportModule实现的
OK,这样的话登录鉴权部分算是做完了,拿到的Payload也可以直接按照后端原来的接口格式丢过去
// user.controller.ts
export class UserController {
constructor(private userService: UserService) {}
@Put('password/change')
@UseGuards(JwtGuard)
async changePassword(
@Req() request: any,
@Body() passwordInfo: PasswordInfoDto,
) {
const result = await this.userService.changePassword(
passwordInfo,
request.user,
);
return result;
}
}
// user.service.ts
import { Injectable } from '@nestjs/common';
import { PasswordInfoDto, UserDto } from './dto';
import { ConfigService } from '@nestjs/config';
import { createService } from 'src/utils/service';
import { HR_BASE_URL } from 'src/enum/config.enum';
import { AuthInfoDto } from 'src/modules/auth/dto';
@Injectable()
export class UserService {
private readonly service;
constructor(private readonly configService: ConfigService) {
this.service = createService(this.configService.get<string>(HR_BASE_URL));
}
async changePassword(passwordInfo: PasswordInfoDto, userInfo: AuthInfoDto) {
const { oldPassword, newPassword, confirmPassword } = passwordInfo;
return this.service({
url: '/password/update',
method: 'post',
data: {
fun: 'HmrChangePassword',
user: userInfo,
param: {
oldPassWord: oldPassword,
newPassWord: newPassword,
newCheckPassWord: confirmPassword,
},
},
});
}
}
结尾
到这里,原来后端接口里面的明文用户信息的问题,就基本上解决了
当然,接口的问题还不止于此,下一篇,就开始处理最头疼的问题:数据格式转换