likes
comments
collection
share

手写TS装饰器之@JsonProperty 优雅实现前后端数据自由转换

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

"前端后端,真是分久必合、合久必分呢"

在前后端联调接口的过程中,开发人员经常会遇到如下情况:

  • 字段命名不一致。例如同样表示 结束时间,后端接口返回 collect_end_time,而前端习惯命名 endTime

  • 字段命名风格不一致。参考:collect_end_timeendTime,后端习惯使用 下划线 风格,而前端更倾向于 驼峰 风格

  • 字段值存在转换关系。例如 collect_end_timeendTime,后端要求存 ms 单位的时间戳,而前端喜欢使用 s 单位下的时间进行计算

所以在这种情况下,获取数据时经常要将后端接口返回的 json 遍历成前端所需要 js obj,而发送数据时,前端要经常将 js obj 遍历成后端所接受的 json 格式。可见,每次从接口获取数据以及发送数据时,都要将数据遍历一番才能得到各端青睐的数据格式。

前端开发人员的预期是,1、可以通过 fromJson 方法将后端返回的数据转成前端使用的数据格式;2、通过 toJson 方法将前端的格式转成后端接口所需要的数据格式。因此我们基于装饰器功能,实现 @JsonProperty 以及 JsonConverter 基类。

定义接口 IJsonConverter

IJsonConverter 接口应该包括两个功能

  1. toJsonjs obj 转成 json

  2. fromJsonjson 转成 js obj

interface IJsonConverter<O, J> {
  /**
   * 将js obj转成json格式
   */
  toJson(obj: O): J;

  /**
   * 将json转成js obj格式
   */
  fromJson(json: J): O;
}

实现类属性装饰器 @JsonProperty

@JsonProperty 属性装饰器的定位在于收集 json/js obj 数据结构中的参数命名以及转换关系。(不熟悉装饰器的小伙伴可参考 Decorators)。

收集参数命名与转换关系的逻辑涉及到一个重要的方法 Reflect.getMetadataReflect.defineMetadata,具体用法参考API说明

import 'reflect-metadata';

const keyForJsonProperty = Symbol.for('$JsonProperty$');

export function JsonProperty<O, J>(jsonName: string, converter?: IJsonConverter<O, J>) {
  return function (target: Object, propertyName: string) {
    const value = {
      ...Reflect.getMetadata(keyForJsonProperty, target),
 
      [propertyName]: {
        name: jsonName,
        fn: converter?.toJson,
      },
     
      [jsonName]: {
        name: propertyName,
        fn: converter?.fromJson,
      },
    };

    Reflect.defineMetadata(keyForJsonProperty, value, target);
  };

NOTE:

  1. jsonName 是 json 中的字段命名(如例子中的 collect_end_time), propertyName 是 js obj 中的字段命名 (如例子中的 endTime

  2. target 这里指向的是类的 prototype 属性

  3. @JsonProperty 收集的映射关系就是:1、 propertyName => jsonName 以及转换关系,2、jsonName => propertyName 以及转换关系

  4. 解构 Reflect.getMetadata(keyForJsonProperty, target) 的原因是 @JsonProperty 会用于不同的属性上,因此不能覆盖已经收集的映射关系

  5. 需要注意的一点是,jsonName 应保证在当前类的范围内是唯一的,即同一类中不同属性对应的 jsonName不能重复

实现 JsonConverter 基类

JsonConverter 基类实现 IJsonConverter 接口,

export class JsonConverter<O = unknown, J = unknown> implements IJsonConverter<O, J> {
  public toJson(obj: O): J {
    const collectedMetaGroup = Reflect.getMetadata(keyForJsonProperty, this);

    if (Array.isArray(obj)) {
      return obj.map(o  => this.convert<O, J>(o, collectedMetaGroup)) as unknown as J;
    }

    return this.convert<O, J>(obj, collectedMetaGroup) as J;
  }

  public fromJson(json: J): O {
    const collectedMetaGroup = Reflect.getMetadata(keyForJsonProperty, this);

    if (Array.isArray(json)) {
      return json.map(j => this.convert<J, O>(j, collectedMetaGroup)) as unknown as O;
    }

    return this.convert<J, O>(json, collectedMetaGroup) as O;
  }

  private convert<X, Y>(value: X, collectedMetaGroup: Record<string, unknown>): Y {
    return Reflect.ownKeys(value).reduce((acc, key) => {
      const collectedMeta = collectedMetaGroup?.[key];

      if (collectedMeta === undefined || collectedMeta === null) {
        // 未使用JsonProperty收集依赖,说明toJson/formJson字段命名一直
        acc[key] = value[key] as unknown as Y;
        return acc;
      }

      const { name, fn } = collectedMeta;
      acc[name] = (fn?.(value[key]) ?? value[key]) as unknown as Y;
      return acc;
    }, {} as unknown as Record<string | symbol, Y>) as unknown as Y;
  }
}

测试子类

定义 TestClass 子类实现 ITestClass,并继承 JsonConverter 基类

interface ITestClass {
  createdTime: string;
  updatedTime: string;
  endTime: number;
  creator: string;
  results: IResultData[];
  user: IUserInfo;
}

export class TestClass extends JsonConverter<ITestClass> implements ITestClass {
  @JsonProperty('created_time')
  createdTime: string;

  @JsonProperty('updated_time')
  updatedTime: string;

  @JsonProperty<number, number>('collect_end_time', {
    toJson: time => time * 1000,
    fromJson: time => ~~(time / 1000),
  })
  endTime: number;

  creator: string; // 前后端的字段命名一致,且无须转换关系

  @JsonProperty<IResultData>('result_data', {
    toJson: ResultData.prototype.toJson.bind(ResultData.prototype),
    fromJson: ResultData.prototype.fromJson.bind(ResultData.prototype),
  })
  results: IResultData[];

  @JsonProperty<IUserInfo>('user_info', {
    toJson: user => UserInfo.prototype.toJson(user),
    fromJson: user => UserInfo.prototype.fromJson(user),
  })
  user: IUserInfo;
}

以其中 collect_end_timeendTime 为例:

  1. 后端接口返回字段 collect_end_time,且为下划线风格,而前端使用 endTime 承接并使用驼峰命名;

  2. collect_end_timeendTime,要使用s做单位,故 fromJson: time => ~~(time / 1000)

  3. endTimecollect_end_time,要使用ms做单位,故 toJson: time => time * 1000

其中,

interface IUserInfo {
  name: string;
  nick: string;
}

export class UserInfo extends JsonConverter<IUserInfo> implements IUserInfo {
  @JsonProperty('real_name')
  name: string;

  nick: string;
}
interface IResultData {
  id: string;
  content: string;
  author: string;
  submittedTime: string;
}

export class ResultData extends JsonConverter<IResultData> implements IResultData {
  id: string;

  @JsonProperty('data')
  content: string;

  author: string;

  @JsonProperty('created_time')
  submittedTime: string;
}

测试 TestClass

describe('测试TestClass', () => {
  const tc = new TestClass();

  it('fromJson', () => {
    const obj = tc.fromJson({
      created_time: '2022-11-07T09:01:39.056Z',
      updated_time: '2022-11-08T09:01:39.056Z',
      collect_end_time: 1667811721198,
      creator: 'RyanOnCloud',
      result_data: [{
        id: '1',
        data: '123',
        author: 'RyanOnCloud1',
        created_time: '2022-11-01T09:01:39.056Z',
      }, {
        id: '2',
        data: '234',
        author: 'RyanOnCloud2',
        created_time: '2022-11-02T09:01:39.056Z',
      }],
      user_info: {
        real_name: 'RyanOnCloud',
        nick: '大哈哈',
      },
    });

    expect(obj).toEqual({
      createdTime: '2022-11-07T09:01:39.056Z',
      updatedTime: '2022-11-08T09:01:39.056Z',
      endTime: 1667811721,
      creator: 'RyanOnCloud',
      results: [{
        id: '1',
        content: '123',
        author: 'RyanOnCloud1',
        submittedTime: '2022-11-01T09:01:39.056Z',
      }, {
        id: '2',
        content: '234',
        author: 'RyanOnCloud2',
        submittedTime: '2022-11-02T09:01:39.056Z',
      }],
      user: {
        name: 'RyanOnCloud',
        nick: '大哈哈',
      },
    });
  });

  it('toJson', () => {
    const json = tc.toJson({
      createdTime: '2022-11-07T09:01:39.056Z',
      updatedTime: '2022-11-08T09:01:39.056Z',
      endTime: 1667811721,
      creator: 'RyanOnCloud',
      results: [{
        id: '1',
        content: '123',
        author: 'RyanOnCloud1',
        submittedTime: '2022-11-01T09:01:39.056Z',
      }, {
        id: '2',
        content: '234',
        author: 'RyanOnCloud2',
        submittedTime: '2022-11-02T09:01:39.056Z',
      }],
      user: {
        name: 'RyanOnCloud',
        nick: '大哈哈',
      },
    });

    expect(json).toEqual({
      created_time: '2022-11-07T09:01:39.056Z',
      updated_time: '2022-11-08T09:01:39.056Z',
      collect_end_time: 1667811721000,
      creator: 'RyanOnCloud',
      result_data: [{
        id: '1',
        data: '123',
        author: 'RyanOnCloud1',
        created_time: '2022-11-01T09:01:39.056Z',
      }, {
        id: '2',
        data: '234',
        author: 'RyanOnCloud2',
        created_time: '2022-11-02T09:01:39.056Z',
      }],
      user_info: {
        real_name: 'RyanOnCloud',
        nick: '大哈哈',
      },
    });
  });
});
手写TS装饰器之@JsonProperty 优雅实现前后端数据自由转换

参考

Symbol MDN

WeakMap MDN

Reflect MDN

Decorators 官方文档

reflect-metadata npm

深入理解 TypeScript 之 Reflect-Metadata