继上一篇后端的“破接口”,我用NestJS中间层处理——登录鉴权后,我们再来解决一类核心问题:数据格式,细分的话又包括
数据格式的不统一
数据多层嵌套
前言
上一篇里面说到,后端给的接口返回数据长这个样子
说白了就是列表数据,会被一个对象深度包裹,实际数据在properties属性
中
而传参的时候,同样也得把数据深层嵌套放在properties属性
中
如果我们细看的话,传参里面又有一些问题,包括
- 数据格式不统一
- 重复值
- 甚至,以上二者还能杂交:又重复又数据格式不统一
或许后端最初开发的时候是有一些目的性,但是现在从前后端交互的角度看,无论传参还是返回数据,都太繁琐了,而且毫无规范性!
而且作为强迫症的我,也不能接受这种又缩略、又“平”、还甚至偶尔来个不规律驼峰的变量名,甚至是不规范的变量类型和拼写错误(推荐使用VS Code的插件Code Spell Checker
)
数据转换
明确了现在后端接口数据的问题,我们要做的就很明晰了,包括两部分
- 入参格式化
- 驼峰转换成非驼峰
- 数据格式转换(数组拼接成逗号连接的字符串)
- 返回数据格式化
- 非驼峰转换成驼峰
- 数据格式转换(逗号连接的字符串转换为数组)
- 返回数据格式统一(成功/失败的基础格式一致)
- 返回数据内容简化(不再使用
xxxObject.properties
包裹)
因为入参和返回数据都有驼峰/非驼峰的转换,以及数据格式的转换,所以考虑先做个基建公共模块,完成这两部分的互转
数据转换模块搭建
在src/modules
目录下,创建一个全局的data format
模块做数据格式转换
配置到全局是因为
- 以后如果有多个模块,可以在不同模块之间通用,类似于上一篇身份校验里面的
Auth
模块 - 数据格式转换在每个模块中都可能用到,这样不用再反复在
module文件
中imports
// data-format.module.ts
import { Global, Module } from '@nestjs/common';
import { DataFormatService } from './data-format.service';
@Global()
@Module({})
export class DataFormatModule {
static forRoot() {
return {
module: DataFormatModule,
providers: [DataFormatService],
exports: [DataFormatService],
};
}
}
在app.module.ts
中,添加这个全局注册的模块
// app.module.ts
@Module({
imports: [
......
DataFormatModule.forRoot(),
],
})
export class AppModule {}
数据转换基础方法
搭建完了data format模块
,接下来就是创建基础的数据转换方法,应该包括
- 数据在数据库中的变量名
flatKey
以及转换方法transformToFlat
- 数据在中间层中的变量名(驼峰)
camelKey
以及转换方法transformToCamel
- 如果数据为空,是否忽略添加的标记
ignoreEmpty
export interface TransformRule {
flatKey: string; // 平的key
camelKey: string; // 驼峰形式的key
transformToCamel?: (value: any, data?: any) => any; // 转换到驼峰形式的时候,做的格式转换
transformToFlat?: (value: any, data?: any) => any; // 转换到平形式的时候,做的格式转换
ignoreEmpty?: boolean; // 是否忽略空值(null/undefined/数字0/空字符串)
}
在data format模块
的service层
,创建数据格式转换方法transformData
,入参包括
- 需要转换的数据
data
- 转换规则
transformMap
- 转换为平峰(后端&数据库格式)/驼峰(中间层&前端使用格式)
toCamel
// 数据格式转换(驼峰/平峰)
private transformData = <T extends object>(
data: T,
transformMap: TransformRule[],
toCamel: boolean = false,
) => {
// 最后生成的格式化数据
const formattedData: Record<string, any> = {};
// 遍历当前的数据
Object.keys(data).forEach((key) => {
// 根据转换方式,用当前的key拿到这条数据的转换规则
const rule = toCamel
? transformMap.find((r) => r.flatKey === key)
: transformMap.find((r) => r.camelKey === key);
// 只有当转换规则存在,才进行数据格式转换,否则忽略数据
if (rule) {
const targetKey = toCamel ? rule.camelKey : rule.flatKey;
// 获取到转换时候的方法
const transformFn = toCamel
? rule.transformToCamel
: rule.transformToFlat;
const formattedItem = transformFn
? transformFn(data[key], data)
: data[key];
// 如果指定了要忽略空值,而且当前值就是空值,则不赋值
if (rule.ignoreEmpty && !formattedItem) {
} else {
formattedData[targetKey] = formattedItem;
}
}
});
return formattedData;
};
这样一来,一个模块的数据转换,无论转换方向,都可以统一管理
通用的转换规则封装
创建一个data-format rule
文件,封装一些比较通用的转换规则的生成方法
// data-format.rule.ts
// 新旧key一致的普通转换(不包含格式转换)
const sameCommonTransformRule = (key: string): TransformRule => ({
flatKey: key,
camelKey: key,
});
// 普通的转换规则(不包含格式转换)
const commonTransformRule = (
flatKey: string,
camelKey: string,
): TransformRule => ({
flatKey,
camelKey,
});
// 日期格式的转换(统一不用处理,直接转换即可)
const dateRule = (flatKey: string, camelKey: string): TransformRule => ({
flatKey,
camelKey,
});
// 字符串和数组转换逻辑(数据库里存字符串,展示时候为数组)
const stringArrayTransformRule = (
flatKey: string,
camelKey: string,
): TransformRule => ({
flatKey,
camelKey,
transformToCamel: (value?: any) => value?.split(',') || [],
transformToFlat: (value?: string | string[]) =>
typeof value === 'string' ? value : value?.join(','),
});
// 数字类型转换(展示时候得是数字)
const numberShowTransformRule = (
flatKey: string,
camelKey: string,
): TransformRule => ({
flatKey,
camelKey,
transformToCamel: (value: string) => Number(value),
});
// 双向数字类型转换(保存的时候为数字,展示也应该是数字)
const numberTransformRule = (
flatKey: string,
camelKey: string,
): TransformRule => ({
flatKey,
camelKey,
transformToCamel: (value: string) => Number(value),
transformToFlat: (value: string) => Number(value),
});
// 枚举类型转换
const enumTransformRule = (
flatKey: string,
camelKey: string,
): TransformRule => ({
flatKey,
camelKey,
transformToCamel: (value: any) => getEnumData(value),
transformToFlat: (value?: any) => Number(value),
});
基于这些生成方法,封装一些很常用的转换规则(在多个模块中都用得上的字段)
id
name
- 员工工号
staffNumber
- 开始日期
startDate
- 结束日期
endDate
- 备注
remark
- 类型
type
// data-format.rule.ts
// id字段的转换规则
const idRule = idTransformRule('id', 'id');
// 员工id字段的转换规则
const staffIdRule = idTransformRule('staffid', 'staffId');
// name字段的转换规则
const nameRule = sameCommonTransformRule('name');
// 员工工号staffNumber转换规则
const staffNumberRule = commonTransformRule('staffno', 'staffNumber');
// 开始日期startDate字段的转换规则
const startDateRule = dateRule('startdate', 'startDate');
// 结束日期endDate字段的转换规则
const endDateRule = dateRule('enddate', 'endDate');
// 备注remark字段的转换规则
const remarkRule = sameCommonTransformRule('remark');
// 类型type字段的转换规则
const typeRule = enumTransformRule('type', 'type');
入参格式化
入参的数据格式化发生在新增/修改的接口中
而在这些接口中,从前端传递过来的数据,在Controller层
接收,此时应该还是驼峰形式的
数据接收完成后,接着会以驼峰形式传递到Service层
,在这里中按照后端的要求传递过去
因此,格式转换应该放在Service层
这里以系统中的教育信息模块Education Module
为例做说明
首先,正常创建Service层
中的create
方法
// education.service.ts
export class EducationService {
private readonly service;
constructor(
private readonly configService: ConfigService,
) {
this.service = createService(this.configService.get<string>(HR_BASE_URL));
}
// 创建教育信息
async create(education: Partial<EducationDto>, userInfo: AuthInfoDto) {
return this.service({
url: '/education/create',
method: 'post',
data: {
fun: 'CreateObj',
user: userInfo,
param: {
// 这里就是后端要求的格式了
XxxObject: {
id: 0,
type: 'type',
// properties存放传递的数据
properties: {},
},
},
},
});
}
}
接下来我们在DataFormatService
中创建education
的转换逻辑,需要两个参数
- 待转化的数据
data
- 转换方向
toCamel
(平峰/驼峰),为了简写给个默认值
// data-format.service.ts
@Injectable()
export class DataFormatService {
......
// 教育信息
education(data: any, toCamel: boolean = true) {
const transformMap: TransformRule[] = [
idRule,
staffIdRule,
enumTransformRule('education', 'education'),
enumTransformRule('degree', 'degree'),
sameCommonTransformRule('school'),
sameCommonTransformRule('major'),
dateRule('graduationdate', 'graduationDate'),
typeRule,
];
return this.transformData(data, transformMap, toCamel);
}
}
转换逻辑有了,最后只需要在EducationService
中引入DataFormatService
,调用education
的转换方法,就大功告成了
// education.service.ts
export class EducationService {
private readonly service;
constructor(
private readonly configService: ConfigService,
private readonly dataFormatService: DataFormatService,
) {
this.service = createService(this.configService.get<string>(HR_BASE_URL));
}
......
async create(education: Partial<EducationDto>, userInfo: AuthInfoDto) {
return this.service({
......
data: {
fun: 'CreateObj',
user: userInfo,
param: {
XxxObject: {
id: 0,
type: 'type',
properties: this.dataFormatService.education(education, false),
},
},
},
});
}
}
这样一来,就可以在前端放心用驼峰格式,而对接给后端时候,传递的数据格式也能符合他的要求了,而且没有在转换规则中指定的属性将不会出现(例如staffNumber
字段)
返回数据格式转换
对于后端返回的接口数据,有了之前登录鉴权的经验,完全可以继续使用NestJS
中的后置拦截器Interceptor
处理
因为返回数据有好几个问题,例如
- 成功/失败时候数据格式不统一
- 返回数据被
XXXObject
深度包裹 - 数据类型没有区分(
Number
/String
)
所以需要逐个处理
接口返回数据统一化
我们先观察一下目前的接口返回格式,分成两种
- 成功:
resFlag
(固定是0)、errMsg
、result
- 失败:
resFlag
(固定是-1)、errMsg
、trace
接下来就需要固定成统一格式,我们设定包括三个内容
code
:状态码,成功是200,错误可以返回一些定制化的数字data
:返回数据,对应原先的result
(成功)和trace
(失败)message
:消息,成功是success,失败则是对应的失败信息
在src/interceptors
下创建ResponseInterceptor
拦截器
// response.interceptor.ts
import {
CallHandler,
ExecutionContext,
HttpStatus,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((res) => {
const {
data: { errMsg, trace, resFlag, result },
} = res;
// 最后返回的格式化数据
const responseData = {};
// 用resFlag判断接口返回成功/失败
if (resFlag === 0) {
Object.assign(responseData, {
code: HttpStatus.OK,
data: result,
message: errMsg || 'success',
});
} else {
Object.assign(responseData, {
// 这里可以扩展,不同的错误类别返回不同的错误码
code: 10001,
data: trace,
message: errMsg || 'error',
});
}
return responseData;
}),
);
}
}
之后,每个模块使用的时候,在Controller层顶部
添加拦截器,即可实现返回数据的统一化
// education.controller.ts
@ApiTags('education 教育')
@Controller('education')
@UseGuards(JwtGuard)
@UseInterceptors(ResponseInterceptor)
export class EducationController {
constructor(private educationService: EducationService) {}
......
}
返回详细数据简化
返回数据简化其实逻辑上也就是一个拦截器Interceptor
的事情,有了刚才接口返回数据统一化的经验,我们先搞清楚目前后端接口返回数据长啥样就行
对于我能看到的接口,我尽量都浏览了一遍,总结了如下规律
- 数据被
XXXObject
包裹- 返回为数组:每一项都是
XXXObject
- 返回为对象:单个
XXXObject
- 返回为数组:每一项都是
- 数据没有被
XXXObject
包裹
对于没有被
XXXObject
包裹的,原样返回包裹了的,我们的目的是拿到其中的
properties
属性
OK,总结完成,我们还是在src/interceptors
下创建一个DataFormatInterceptor
拦截器
// data-format.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { DataFormatService } from 'src/modules/data-format/data-format.service';
@Injectable()
export class DataFormatInterceptor implements NestInterceptor {
constructor(private readonly dataFormatService: DataFormatService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((res) => {
const {
data: { resFlag, result },
} = res;
if (resFlag === 0) {
res.data.result = this.dataFormatService.responseSuccessDto(result);
}
return res;
}),
);
}
}
这里考虑到数据简化也是一种数据格式转化,所以把这部分逻辑放在了DataFormat模块
中
@Injectable()
export class DataFormatService {
// 是否为XXXObject
private isResponseSuccessDto = (data: any): data is ResponseSuccessDto => {
if (!(data instanceof Object)) {
return false;
}
if (data instanceof Array) {
return false;
}
// XXXObject中应该有如下所有字段
const expectedKeys = ['id', 'mogokey', 'type', 'properties'];
return expectedKeys.every((key) => key in data);
};
// 响应数据格式化(基础格式化)
responseSuccessDto(data: any) {
// 数组类型数据,遍历每一项去简化
if (data instanceof Array) {
return (
data?.map((item: any) => {
if (this.isResponseSuccessDto(item)) {
const { properties, id } = item;
delete properties.objid;
return { id, ...properties };
} else {
return item;
}
}) || []
);
} else {
// 非数组类型数据,直接提取
if (!data) {
return data;
}
const { properties } = data;
if (properties) {
const id = properties?.objid || data.id;
delete properties.objid;
return { id, ...properties };
} else {
return data;
}
}
}
}
OK,这样一来,数据简化的逻辑都搞定了,使用的时候只用在Controller层
添加一个Interceptor
即可,不过由于我们之前添加上ResponseInterceptor
了,所以需要明确一下顺序
- 数据返回,先做简化
- 简化完成后再执行返回格式统一
在NestJS中,多个拦截器是按照从前往后逐个进入的,但是对于响应数据的处理,则是由后往前逐个返回的
所以写的时候ResponseInterceptor
在前,DataFormatInterceptor
在后
// education.controller.ts
@ApiTags('education 教育')
@Controller('education')
@UseGuards(JwtGuard)
// 先进入Response拦截,但是实际处理是DataFormat在先
@UseInterceptors(
ResponseInterceptor,
DataFormatInterceptor,
)
export class EducationController {
......
}
返回数据格式调整
前面我们让中间层接收了驼峰格式的变量,那最后返回给前端的时候也应该返回驼峰格式的,此外还要做一些数据格式的转换(例如字符串转数组、字符串转数字等)
这个就相对简单了,我们在各个模块里面添加一个Interceptor
单独处理即可
以Education教育模块
为例,创建一个EducationInterceptor
// education.interceptor.ts
@Injectable()
export class EducationInterceptor implements NestInterceptor {
constructor(private readonly dataFormatService: DataFormatService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((res) => {
const {
data: { resFlag, result },
} = res;
if (resFlag === 0) {
if (result.list) {
res.data.result.list = result.list.map((item: any) =>
this.dataFormatService.education(item),
);
} else if (!(result instanceof Array)) {
if (typeof result !== 'string') {
res.data.result = this.dataFormatService.education(result);
}
}
}
return res;
}),
);
}
}
在Controller层
中,我们的操作顺序应该是
- 先数据简化
- 再把数据转换成驼峰
- 最后,响应数据格式统一
因此几个后置拦截器的书写顺序应该是:ResponseInterceptor
、EducationInterceptor
、DataFormatInterceptor
假分页
对于后端传递过来的上百上千条数据,这个确实爱莫能助了,我只能说,在中间层里面稍微截断一部分,返回需要的那部分,给前端做一个交互
这里需要做两件事情
- 请求参数添加
pagination
相关,包括页码pageIndex
和每页内容pageSize
- 返回的数据使用一个专门的分页格式,包括
数据列表list
和数据总数total
接下来就分这两部分去实现
pagination请求参数封装
分页需要的pageIndex
和pageSize
两个参数并不复杂,麻烦的是,如果我们每个Get请求
都写一遍这个pageIndex
和pageSize
,代码也太臃肿了
在NestJS中,可以使用装饰器Decorator
,专门用来获取Query参数中的pageIndex和pageSize两个变量
首先在src/decorators
目录下创建文件
在文件中,写入提取pageIndex
和pageSize
的逻辑,其实就是从请求的query拿到两个参数即可
// pagination.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Pagination = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
// 都给个默认值0,以防止pageIndex是undefined等情况的出现
const pageIndex = parseInt(request.query.pageIndex) || 0;
const pageSize = parseInt(request.query.pageSize) || 0;
return { pageIndex, pageSize };
},
);
在接口中使用的时候,可以像@Query
这样直接调用,拿到的参数就是经过装饰器修改并返回的值
@Get('list')
async getEducationList(
@Req() request: any,
// pagination直接包括了pageIndex和pageSize两个参数,而且都转换成Number了
@Pagination() pagination: PaginationDto,
@Query('staffId') staffId?: number,
) {
const searchParams = {};
staffId && Object.assign(searchParams, { staffId });
const result = await this.educationService.getList(
searchParams as EducationSearchDto,
request.user,
);
Object.assign(result.data, pagination);
return result;
}
分页数据格式化返回
封装好了分页参数,最后就是对分页数据做处理了,说白了就是对数组做截断
不过这里需要考虑得智能一点,因为有些列表接口的用途并不只是渲染Table
,下拉框Select
还分页这不害人嘛
所以最后分两种情况
- 如果分页参数传递了,而且都有值,对数组截断返回
- 如果分页参数没有传递,那就原样返回所有数据
OK,接下来就是继续之前数据格式化的逻辑,创建一个PaginationInterceptor
// pagination.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
@Injectable()
export class PaginationInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((res) => {
const {
data: { pageIndex, pageSize, result },
} = res;
// 这个是针对那些没有传递pagination相关参数的接口做的逻辑判断
if (pageIndex != null && pageSize != null) {
res.data.result = this.getPagedData(result, pageIndex, pageSize);
}
return res;
}),
);
}
private getPagedData(data: any[], pageIndex: number, pageSize: number) {
// 当分页的两个参数都不是0,才对数组截断
if (pageIndex !== 0 && pageSize !== 0) {
const startIndex = (pageIndex - 1) * pageSize;
const sliceCount = pageSize;
return {
list: data.slice(startIndex, startIndex + sliceCount),
total: data.length,
};
}
// 分页两个参数有一个是0,全量返回
else {
return {
list: data,
total: data.length,
};
}
}
}
我们看到这个拦截器是依据res.data
里是否有pagination参数
,才会执行分页逻辑的,这个也是考虑到全局后置拦截器的使用中,有一些接口跟分页无关,避免报错
不过也正是因为这一点,我们在使用分页的时候,也需要给后端返回的数据添加pagination信息
,告诉拦截器我们需要执行分页
@Get('list')
async getEducationList(
@Req() request: any,
@Pagination() pagination: PaginationDto,
@Query('staffId') staffId?: number,
) {
......
const result = await XXX
// 添加pagination信息,指定这个接口走分页逻辑
Object.assign(result.data, pagination);
return result;
}
最后一步,在Controller层
的全局后置拦截器上添加上PaginationInterceptor
综合前面几个数据格式化逻辑,我们的执行顺序应该是
- 先数据简化
- 再分页(为了让
data
统一成Object
格式,之前后端没有做这个的统一) - 再把数据转换成驼峰
- 最后,响应数据格式统一
所以书写上PaginationInterceptor
放在倒数第二位
// education.controller.ts
@ApiTags('education 教育')
@Controller('education')
@UseGuards(JwtGuard)
@UseInterceptors(
ResponseInterceptor,
EducationInterceptor,
PaginationInterceptor,
DataFormatInterceptor,
)
export class EducationController {
......
}
总结
到这儿,后端的“破接口”的数据格式基本上就改造好了,不用担心变量名看不懂,前端要做分页也能有个假的逻辑在
要说还有什么可以完善的,当然还是有的,比如
PaginationInterceptor
的处理,是不是不用每次都强行在路由中添加pagination参数
- 假分页,是不是可以考虑在中间层做缓存,每次返回不同部分,而不是每次都重新请求接口重新截取
- 每个模块的数据格式调整,因为基础逻辑一致,是不是可以统一做一个
Interceptor
诸如此类,在实际开发中肯定还有提升空间,一切的一切,就留给后面继续完善吧!下一篇,继续更新数据加密相关~