后端的“破接口”,我用NestJS中间层处理(2)——数据格式

212 阅读14分钟

继上一篇后端的“破接口”,我用NestJS中间层处理——登录鉴权后,我们再来解决一类核心问题:数据格式,细分的话又包括

数据格式的不统一

数据多层嵌套

前言

上一篇里面说到,后端给的接口返回数据长这个样子

层层包裹的返回数据.png

说白了就是列表数据,会被一个对象深度包裹,实际数据在properties属性

深度包裹的返回数据.png

而传参的时候,同样也得把数据深层嵌套放在properties属性

传参截图.png

如果我们细看的话,传参里面又有一些问题,包括

  • 数据格式不统一
  • 重复值
  • 甚至,以上二者还能杂交:又重复数据格式不统一

返回数据混乱.png

或许后端最初开发的时候是有一些目的性,但是现在从前后端交互的角度看,无论传参还是返回数据,都太繁琐了,而且毫无规范性!

而且作为强迫症的我,也不能接受这种又缩略、又“”、还甚至偶尔来个不规律驼峰的变量名,甚至是不规范的变量类型拼写错误(推荐使用VS Code的插件Code Spell Checker

变量起名逻辑.png

数据转换

明确了现在后端接口数据的问题,我们要做的就很明晰了,包括两部分

  • 入参格式化
    • 驼峰转换成非驼峰
    • 数据格式转换(数组拼接成逗号连接的字符串
  • 返回数据格式化
    • 非驼峰转换成驼峰
    • 数据格式转换(逗号连接的字符串转换为数组
    • 返回数据格式统一(成功/失败的基础格式一致)
    • 返回数据内容简化(不再使用xxxObject.properties包裹)

数据转换的流程节点.png

因为入参和返回数据都有驼峰/非驼峰的转换,以及数据格式的转换,所以考虑先做个基建公共模块,完成这两部分的互转

数据转换模块搭建

src/modules目录下,创建一个全局data format模块做数据格式转换

data format模块路径.png

配置到全局是因为

  • 以后如果有多个模块,可以在不同模块之间通用,类似于上一篇身份校验里面的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/空字符串)
}

数据转换逻辑图.png

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层

入参格式转换逻辑.png

这里以系统中的教育信息模块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字段)

前端传递的数据格式.png

实际传递给后端的格式.png

返回数据格式转换

对于后端返回的接口数据,有了之前登录鉴权的经验,完全可以继续使用NestJS中的后置拦截器Interceptor处理

因为返回数据有好几个问题,例如

  • 成功/失败时候数据格式不统一
  • 返回数据被XXXObject深度包裹
  • 数据类型没有区分(Number/String

所以需要逐个处理

接口返回数据统一化

我们先观察一下目前的接口返回格式,分成两种

  • 成功:resFlag(固定是0)、errMsgresult
  • 失败:resFlag(固定是-1)、errMsgtrace

接下来就需要固定成统一格式,我们设定包括三个内容

  • 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层中,我们的操作顺序应该是

  • 先数据简化
  • 再把数据转换成驼峰
  • 最后,响应数据格式统一

因此几个后置拦截器的书写顺序应该是:ResponseInterceptorEducationInterceptorDataFormatInterceptor

假分页

对于后端传递过来的上百上千条数据,这个确实爱莫能助了,我只能说,在中间层里面稍微截断一部分,返回需要的那部分,给前端做一个交互

这里需要做两件事情

  • 请求参数添加pagination相关,包括页码pageIndex每页内容pageSize
  • 返回的数据使用一个专门的分页格式,包括数据列表list数据总数total

接下来就分这两部分去实现

pagination请求参数封装

分页需要的pageIndexpageSize两个参数并不复杂,麻烦的是,如果我们每个Get请求都写一遍这个pageIndexpageSize,代码也太臃肿了

在NestJS中,可以使用装饰器Decorator,专门用来获取Query参数中的pageIndex和pageSize两个变量

首先在src/decorators目录下创建文件

Pagination装饰器创建目录.png

在文件中,写入提取pageIndexpageSize的逻辑,其实就是从请求的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 {
  ......
}

总结

到这儿,后端的“破接口”的数据格式基本上就改造好了,不用担心变量名看不懂,前端要做分页也能有个假的逻辑在

冷兔宝宝-非常棒.gif

要说还有什么可以完善的,当然还是有的,比如

  • PaginationInterceptor的处理,是不是不用每次都强行在路由中添加pagination参数
  • 假分页,是不是可以考虑在中间层做缓存,每次返回不同部分,而不是每次都重新请求接口重新截取
  • 每个模块的数据格式调整,因为基础逻辑一致,是不是可以统一做一个Interceptor

诸如此类,在实际开发中肯定还有提升空间,一切的一切,就留给后面继续完善吧!下一篇,继续更新数据加密相关~