likes
comments
collection
share

使用装饰器实现接口字段映射与Mock

作者站长头像
站长
· 阅读数 15

前言

实现了个能满足题目要求的小插件,type-json-mapper,对如何实现不感兴趣的小伙伴可以直接跳到 使用 。文中代码只是示例代码,只为讲清原理,源码已经开源 github.com/LuciferHuan…,欢迎 star 😉

背景

一个前端项目稳定运行一段时间以后。

突然有一天,后端同学找到你,告诉你原先的 Student.name 要改成 Student.fullName,你一遍遍去查代码,查找 Student.name → 修改 → 自测,确保修改不会有问题。

终于,你成功把 Student.name 都改成了 Student.fullName。

然而,没过几天,某个一直正常的功能突然不能使用了,你开始调试,发现原先接口一直返回整数类型的 age 字段突然变成字符串类型了,你找到后端,后端同学来了一句 “前端不做检验吗?”

使用装饰器实现接口字段映射与Mock

下次会改什么字段,下次哪个字段类型又会出问题,想想都孩怕,难道没有一劳永逸的办法能解决这个问题吗?

使用装饰器实现接口字段映射与Mock

当然有,有点 oop 编程语言基础的,马上就会想到,这不就是加个 adapter 的事吗,很多语言都内置 adaapter,but,找了一圈发现没有能实现类似功能的插件(难道我姿势不对???)

算了,不找了(懒了),干脆自己造个轮子

需求

最核心的问题就是要达到:接口字段的修改不能影响项目中实际使用的字段,无论是字段名的修改还是类型的修改

这里考虑使用装饰器附带额外信息,主要是接口字段信息,与需要转换的类型

既然可以转换类型了,考虑把字段 “翻译” 功能加上

既然能转换了,能就再加个 Mock 吧,摆脱开发过程中对后端接口的依赖

设计

语言:typescript 构建工具:rollup 自动化测试:jest 代码规范:eslint + prettier 提交规范:commitlint

Decorator

首先,我们需要一个对象

使用装饰器实现接口字段映射与Mock

不对,是这个对象 {}

class Lesson {
  public name: string;
  public teacher: string;
  public datetime: string;
  public applicants: number;
  public compulsory: boolean;
  constructor() {
    this.name = "";
    this.teacher = "";
    this.datetime = "";
    this.compulsory = false;
  }
}

上面的代码,就是我们构造出的 Lesson 类,它的属性字段就是我们会在项目中实际使用的字段

现在我们需要把这个类的属性字段与接口返回的字段对应上,这时候就需要用到 装饰器 了,随便取个名字,我这里是用 mapperProperty,接收两个参数,第一个是接口返回的字段名,第二个是期望最终得到的类型(不是接口字段本身的类型)

class Lesson {
  @mapperProperty("ClassName", "string")
  public name: string;
  @mapperProperty("TeacherName", "string")
  public teacher: string;
  @mapperProperty("DateTime", "datetime")
  public datetime: string;
  @mapperProperty("ApplicantNumber", "int")
  public applicants: number;
  @mapperProperty("Compulsory", "boolean")
  public compulsory: boolean;
  constructor() {
    this.name = "";
    this.teacher = "";
    this.datetime = "";
    this.date = "";
    this.time = "";
    this.compulsory = false;
  }
}

如上面的代码,我们给每个属性字段都加上了装饰器,并告知了接口中对应的字段名称,以及我们希望得到的类型。 例如代码中的 applicants 字段,对应了接口中的 ApplicantNumber 字段,无论接口返回的是字符串还是数值类型,我们都希望最终得到的是 int 类型(指代整数)的数据

接下来要把接口字段名称与我们期望得到的类型先缓存起来

这里我们借助 Reflect Metadata 实现缓存

示例代码如下

function mapperProperty(apiField, type) {
  Reflect.metadata("key", {
    apiField, // 接口字段名
    type, // 期望类型
  });
}

Reflect Metadata 是 ES7 的一个提案,它主要用来在声明的时候添加和读取元数据;我们使用 reflect-metadata 来模拟该功能

Transform

有了接口字段名与期望的类型,接下来的转换就简单了

  1. 第一步,先读取上一步缓存的元数据信息

    const instance = new Lesson();
    const meta = Reflect.getMetadata("key", instance, "applicants");
    console.log(meta);
    

    这里的 key 即元数据的键,上面的代码是读取 Lesson 类中 applicants 字段的元数据,meta 打印的结果如下

    {
        apiField: 'ApplicantNumber',
        type: 'int'
    }
    
  2. 第二步,转换

    function deserialize(clazz, json) {
      const instance = new clazz();
      const meta = Reflect.getMetadata("key", instance, "applicants");
      const { apiField, type } = meta;
      const ori = json[apiField]; // json 为接口返回的数据
      let value;
      switch (type) {
        case "int":
          value = parseInt(ori, 10);
          break;
        // 其它类型转换
      }
      // 后续处理
    }
    

    到这基本就实现了最核心的能力,只要愿意可以扩展更多类型,欢迎一起来完善

Object and Array

对象与数组的转换与基本类型的转换大差不差,这里我将对象、数组的装饰器命名为 deepMapperProperty,只需将第二个参数的类型,改为接收一个类即可

示例代码如下

function deepMapperProperty(apiField, clazz) {
  Reflect.metadata("key", {
    apiField, // 接口字段名
    clazz, // 子级
  });
}

取值方式同上,不再赘述了,只需改一下转换的代码

转换对象的示例代码如下,递归调用一下即可

const { clazz } = meta;
if (clazz) {
  value = deserialize(clazz, value);
}

数组则直接使用 map 遍历

function deserializeArr(clazz, list) {
  return list.map((ele) => deserialize(clazz, ele));
}

Mock

模拟数据部分,是直接返回的前端项目中使用的字段,而非修改接口字段的返回值

实现模拟数据拢共分三步:

  1. 与转换同样的步骤,要先读取字段的期望类型,这里只需要类型即可

    • 遍历读取类中各个字段的元数据,得到各个字段的期望类型
  2. 根据期望类型使用不同的随机函数,生成相应类型的数据,这里我封装了三种类型的随机函数

    • 获取随机整数
    • 获取随机字符串
    • 获取随机小数
  3. 针对对象与数组特殊处理

    • 对象:这个简单,老规矩,递归解决
    • 数组:数组需要先随机生成一下数组长度,再使用 map 遍历,递归调用一下 mock 函数

使用

安装

npm i type-json-mapper

属性装饰器

内置三种类属性装饰器:

@mapperProperty(apiField, type)

基本数据类型使用该装饰器

接收两个参数:

  • apiField:接口字段名

  • type:字段转换类型(可选值:string | int | flot | boolean | date | time | datetime)

@deepMapperProperty (apiField, Class)

对象/数组使用该装饰器

接收两个参数:

  • apiField:接口字段名

  • Class:类

@filterMapperProperty(apiField, filterFunc)

自定义过滤器(翻译)使用该装饰器

接收两个参数:

  • apiField:接口字段名

  • filterFunc:自定义过滤器函数

const filterFunc = (value) => {
  return "translated text";
};

方法

deserialize(Clazz, json)

反序列化 json 对象

  • Clazz:类

  • json:接口返回的对象数据

deserializeArr(Clazz, list)

反序列化数组

  • Clazz:类

  • list:接口返回的数组数据

mock(Clazz, option)

生成模拟数据

  • Clazz:类

  • option:mock 配置

mock 配置

名称类型描述默认值
fieldLengthObject字段长度-
arrayFieldsstring[]数组类型字段-

fieldLength

数据类型length 含义
string字符串长度
int最大整数
float字符长度(保留两位小数)

例:

class Student {
  @mapperProperty("StudentID", "string")
  public id: string;
  @mapperProperty("StudentName", "string")
  public name: string;
  @mapperProperty("StudentAge", "int")
  public age: number;
  @mapperProperty("Grade", "float")
  public grade: number;

  constructor() {
    this.id = "";
    this.name = "";
    this.age = 0;
    this.grade = 0;
  }
}

mock(Student, { fieldLength: { age: 20, grade: 4, name: 6 } });
/**
 * age: 20 表示随机生成的 age 字段的范围在 1 ~ 20 之间
 * grade: 4 表述随机生成的 grade 字段是两位整数加两位小数的形式,共4个数字字符(如:23.33)
 * name: 6 表述将随机生成长度为 6 的随机字符串
 */

使用示例

这里预先造了几个类,并给类属性加上了装饰器

import {
  mapperProperty,
  deepMapperProperty,
  filterMapperProperty,
} from "type-json-mapper";

class Lesson {
  @mapperProperty("ClassName", "string")
  public name: string;
  @mapperProperty("Teacher", "string")
  public teacher: string;
  @mapperProperty("DateTime", "datetime")
  public datetime: string;
  @mapperProperty("Date", "date")
  public date: string;
  @mapperProperty("Time", "time")
  public time: string;
  @mapperProperty("Compulsory", "boolean")
  public compulsory: boolean;

  constructor() {
    this.name = "";
    this.teacher = "";
    this.datetime = "";
    this.date = "";
    this.time = "";
    this.compulsory = false;
  }
}

class Address {
  @mapperProperty("province", "string")
  public province: string;
  @mapperProperty("city", "string")
  public city: string;
  @mapperProperty("full_address", "string")
  public fullAddress: string;

  constructor() {
    this.province = "";
    this.city = "";
    this.fullAddress = "";
  }
}

// 状态映射关系
const stateMap = { "1": "读书中", "2": "辍学", "3": "毕业" };

class Student {
  @mapperProperty("StudentID", "string")
  public id: string;
  @mapperProperty("StudentName", "string")
  public name: string;
  @mapperProperty("StudentAge", "int")
  public age: number;
  @mapperProperty("StudentSex", "string")
  public sex: string;
  @mapperProperty("Grade", "float")
  public grade: number;
  @deepMapperProperty("Address", Address)
  public address?: Address;
  @deepMapperProperty("Lessons", Lesson)
  public lessons?: Lesson[];
  @filterMapperProperty("State", (val: number) => stateMap[`${val}`])
  public status: string;
  @filterMapperProperty("Position", (val: number) => stateMap[`${val}`])
  public position: string;
  public extra: string;

  constructor() {
    this.id = "";
    this.name = "";
    this.age = 0;
    this.sex = "";
    this.grade = 0;
    this.address = undefined;
    this.lessons = undefined;
    this.status = "";
    this.position = "";
    this.extra = "";
  }
}

以下是接口返回的数据:

const json = [
  {
    StudentID: "123456",
    StudentName: "李子明",
    StudentAge: "10",
    StudentSex: 1,
    Grade: "98.6",
    Address: {
      province: "广东",
      city: "深圳",
      full_address: "xxx小学三年二班",
    },
    Lessons: [
      {
        ClassName: "中国上下五千年",
        Teacher: "建国老师",
        DateTime: 1609430399000,
        Date: 1609430399000,
        Time: 1609430399000,
        Compulsory: 1,
      },
      {
        ClassName: "古筝的魅力",
        Teacher: "美丽老师",
        DateTime: "",
      },
    ],
    State: 1,
    Position: 123,
    extra: "额外信息",
  },
  {
    StudentID: "888888",
    StudentName: "丁仪",
    StudentAge: "18",
    StudentSex: 2,
    Grade: null,
    Address: {
      province: "浙江",
      city: "杭州",
      full_address: "xxx中学高三二班",
    },
    Lessons: [],
    State: 2,
  },
];

开始转换,因接口返回的是数组,这里使用 deserializeArr

import { deserializeArr } from "type-json-mapper";
try {
  const [first, second] = deserializeArr(Student, json);
  console.log(first);
  console.log(second);
} catch (err) {
  console.error(err);
}

输出结果如下

// first
{
  id: "123456",
  name: "李子明",
  age: 10,
  sex: "1",
  grade: 98.6,
  address: { province: "广东", city: "深圳", fullAddress: "xxx小学三 年二班" },
  lessons: [
    {
      name: "中国上下五千年",
      teacher: "建国老师",
      datetime: "2020-12-31 23:59:59",
      date: "2020-12-31",
      time: "23:59:59",
      compulsory: true,
    },
    {
      name: "古筝的魅力",
      teacher: "美丽老师",
      datetime: "",
      date: undefined,
      time: undefined,
      compulsory: undefined,
    },
  ],
  status: "读书中",
  position: 123,
  extra: "额外信息",
};
// second
{
  id: "888888",
  name: "丁仪",
  age: 18,
  sex: "2",
  grade: null,
  address: { province: "浙江", city: "杭州", fullAddress: "xxx中学高三二班" },
  lessons: [],
  status: "辍学",
  position: undefined,
  extra: undefined,
};

如果后端接口还没开发完成,我们还可以直接 mock

import { mock } from "type-json-mapper";
const res = mock(Student, {
  fieldLength: { age: 20, grade: 4, name: 6 },
  arrayFields: ["lessons"],
});
console.log(res);

输出结果如下

{
  id: 'QGBLBA',
  name: 'KTFH6d',
  age: 4,
  sex: 'IINfTm',
  grade: 76.15,
  address: { province: 'qvbCte', city: 'DbHfFZ', fullAddress: 'BQ4uIL' },
  lessons: [
    {
      name: 'JDtNMx',
      teacher: 'AeI6hB',
      datetime: '2023-2-18 15:00:07',
      date: '2023-2-18',
      time: '15:00:07',
      compulsory: true
    },
    {
      name: 'BIggA8',
      teacher: '8byaId',
      datetime: '2023-2-18 15:00:07',
      date: '2023-2-18',
      time: '15:00:07',
      compulsory: false
    },
    {
      name: 'pVda1n',
      teacher: 'BPCmwa',
      datetime: '2023-2-18 15:00:07',
      date: '2023-2-18',
      time: '15:00:07',
      compulsory: false
    }
  ],
  status: '',
  position: '',
  extra: ''
}

后记

虽然要多维护一套类,看似麻烦(确实很麻烦😂),但是加强了代码的健壮性,摆脱对接口的依赖;最重要的是堵住了后端的嘴(bushi)