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

5,811 阅读3分钟

"前端后端,分久必合、合久必分"

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

  1. 字段命名与风格不一致。例如同样表示 结束时间,后端接口返回下划线格式 collect_end_time,而前端习惯驼峰命名 endTime
  2. 字段值存在转换关系。例如后端返回doc_url,而前端需要解码后使用,即atob(doc_url);同样对于collect_end_timeendTime,后端要求存 ms 单位的时间戳,而前端更习惯使用 s 单位下的时间进行计算;
  3. 后端返回多个字段/属性,前端需要将其组合使用。例如后端同时返回doc_urlsub_id,而前端要使用doc_url?sub=sub_id的形式。

所以在获取接口数据(response data)时,要将后端接口返回的 json 结构遍历成前端所需要 js obj 结构,而请求数据(request payload)时,前端要经常将 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 属性装饰器的定位在于『收集』数据结构中的参数命名以及转换关系。(不熟悉装饰器的小伙伴可参考 Decorators)。

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

import 'reflect-metadata';

const enum ConvertTag {
  TO_JSON = 'TO_JSON',
  FROM_JSON = 'FROM_JSON',
}

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

/**
 * 标记json名称并声明转换函数与默认值
 * @param jsonName 对应的json名称
 * @param converter.toJson 该字段从obj转成json时使用的方法,默认 undefined
 * @param converter.toJsonDef 调用toJson时出现undefined情况时用该值填充,默认 undefined
 * @param converter.toJsonIgnore toJson后出现undefined是否需要移除改属性,默认 undefined
 * @param converter.fromJson 该字段从json转成obj时使用的方法,默认 undefined
 * @param converter.fromJsonDef 调用fromJson时出现undefined情况时用该值填充
 * @param converter.fromJsonIgnore fromJson后出现undefined是否需要移除改属性,默认 undefined
 */
export function JsonProperty<FromValue = unknown, ToValue = unknown>(
  jsonName: string,
  converter: ConverterConfig<FromValue, ToValue> = {},
) {
  return function (target: Object, propertyName: string) {
    const metaData = Reflect.getMetadata(globalUniqueKeyForJsonProperty, target) ?? {};
    metaData[Symbol(propertyName)] = {
      name: jsonName,
      fn: converter.toJson,
      defaultValue: converter.toJsonDef,
      $$tag: ConvertTag.TO_JSON,
      removal: converter.toJsonIgnore,
    };
    metaData[Symbol(jsonName)] = {
      name: propertyName,
      fn: converter.fromJson,
      defaultValue: converter.formJsonDef,
      $$tag: ConvertTag.FROM_JSON,
      removal: converter.fromJsonIgnore,
    };

    Reflect.defineMetadata(globalUniqueKeyForJsonProperty, metaData, 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. 🔴 这里为防止jsonNamepropertyName重名,或者jsonName重复,这里采用ES6里的Symbol语法

实现 JsonConverter 基类

JsonConverter 基类实现 IJsonConverter 接口。

🔴 最重要的两个方法 toJsonformJson

  public toJson(obj: O): J {
    return this.postprocessJson(this.convert<O, J>(this.preprocessObj(obj), ConvertTag.TO_JSON));
  }

  public fromJson(json: J): O {
    return this.postprocessObj(this.convert<J, O>(this.preprocessJson(json), ConvertTag.FROM_JSON));
  }

入参与返回值的前后处理:这些方法可以子类 override

  /**
   * 前处理js obj,符合某种特殊要求
   * @note 处理toJson的入参
   * @note 子类可重写该方法
   */
  protected preprocessObj<O>(obj: O): O {
    return obj;
  }

  /**
   * 后处理转换之后的js obj
   * @note 处理fromJson的返回值
   * @note 子类可重写该方法
   */
  protected postprocessObj<O>(obj: O): O {
    return obj;
  }

  /**
   * 前处理json
   * @note 处理fromJson的入参
   * @note 子类可重写该方法
   */
  protected preprocessJson<J>(json: J): J {
    return json;
  }

  /**
   * 后处理json
   * @note 处理toJson的返回值
   * @note 子类可重写该方法
   */
  protected postprocessJson<J>(json: J): J {
    return json;
  }

实现 this.convert 注意区分 ArrayPureObject

  private convert<FromValue, ToValue>(value: FromValue, tag: ConvertTag): ToValue {
    const collectedMetaGroup: MetaGroup<FromValue, ToValue> = Reflect.getMetadata(globalUniqueKeyForJsonProperty, this);

    if (Array.isArray(value)) {
      return value.map(val => this.baseConvert<FromValue, ToValue>(val, collectedMetaGroup, tag)) as ToValue;
    }

    return this.baseConvert<FromValue, ToValue>(value, collectedMetaGroup, tag);
  }

  private baseConvert<FromValue, ToValue>(
    value: FromValue,
    collectedMetaGroup: MetaGroup<FromValue, ToValue>,
    tag: ConvertTag,
  ): ToValue {
    const validKeys = Reflect.ownKeys(collectedMetaGroup).filter(key => tag === collectedMetaGroup[key].$$tag);

    return validKeys.reduce((acc, key) => {
      const { name, removal } = collectedMetaGroup[key];
      const result = this.getValueByPath<FromValue, ToValue>(value, key, collectedMetaGroup);

      if ((typeof removal === 'function' && removal(result)) || removal === result) {
        return acc;
      }

      this.setValueByPath<FromValue, ToValue>(result, name, acc);
      return acc;
    }, {} as unknown as Record<string | symbol, ToValue>) as ToValue;
  }

类内部封装的utils方法:

  private getValueByPath<FromValue, ToValue>(
    value: FromValue,
    key: symbol | string,
    collectedMetaGroup: MetaGroup<FromValue, ToValue>,
  ): ToValue {
    const { fn, defaultValue } = collectedMetaGroup[key];
    const paths = this.splitSymbol(key).split('|');

    // 这里已经保证了,如果取不到值,默认返回undefined,所以接下来只需要判断undefined就好了
    const values = paths.map(p => get(value, p, undefined) as FromValue);

    if (values.length <= 1) {
      return (isUndefined(values[0]) ? defaultValue : (fn?.(values[0]) ?? values[0] ?? defaultValue)) as ToValue;
    }

    return (values.every(isUndefined) ? defaultValue : fn?.(values as FromValue) ?? defaultValue) as ToValue;
  }

  private setValueByPath<FromValue, ToValue>(
    result: FromValue | ToValue,
    key: symbol | string,
    value: Record<string | symbol, ToValue>,
  ): void {
    const paths = this.splitSymbol(key).split('|');
    const pathHasSpliter = paths.length > 1; // 判断是否是多维路径

    if (pathHasSpliter && Array.isArray(result) && paths.length === result.length) {
      paths.forEach((p, i) => set(value, p, result[i]));
    } else {
      set(value, paths[0], result);
    }
  }

  private splitSymbol(value: unknown): string {
    if (typeof value !== 'symbol') {
      return String(value);
    }

    const matched = value.toString().match(/(?<=Symbol\().+(?=\))/g);
    return matched?.[0] ?? '';
  }

测试子类

class BindResultRespModel extends JsonConverter<IBoundRespModel> implements IBoundRespModel {
  @JsonProperty<string, string[]>('bind_sheet.doc_id.domain_id|bind_sheet.doc_id.pad_id', {
    fromJson: ([domainId, padId]) => `${domainId}$${window.atob(padId)}`,
    formJsonDef: '',
  })
  sheetId?: string;

  @JsonProperty('bind_sheet.sub_id', {
    fromJson: window.atob,
    formJsonDef: '',
  })
  sheetSubId?: string;

  @JsonProperty<string, string[]>('bind_sheet.doc_url|bind_sheet.sub_id', {
    fromJson: ([url, tab]) => `${window.atob(url)}?tab=${window.atob(tab)}`,
    formJsonDef: '',
  })
  sheetUrl?: string;

  @JsonProperty('bind_sheet.sub_type')
  sheetType?: SheetType;

  @JsonProperty('total')
  maxSyncCnt!: number;

  @JsonProperty('sync_total')
  currSyncCnt!: number;

  @JsonProperty<string, number[]>('sync_total|total', {
    fromJson: ([syncTotal, total]) => (syncTotal >= total ? '100%' : `${(syncTotal / total * 100).toFixed(2)}%`),
    formJsonDef: '0%',
  })
  syncProcess!: string;
}

class BindResultReqModel extends JsonConverter<IBoundReqModel> implements IBoundReqModel {
  @JsonProperty('form_id')
  globalPadId!: string;

  @JsonProperty('task_id')
  taskId!: string;
}

测试用例

export const bindResultRespModel = new BindResultRespModel();
export const bindResultReqModel = new BindResultReqModel();

it('1. result.bind_sheet 为 null', () => {
  const json = {
    bind_sheet: null,
    total: 30,
    sync_total: 3,
  };
  const expected = {
    maxSyncCnt: json.total,
    currSyncCnt: json.sync_total,
    syncProcess: `${(json.sync_total / json.total * 100).toFixed(2)}%`,
    sheetId: '',
    sheetSubId: '',
    sheetType: undefined,
    sheetUrl: '',
  };

  expect(bindResultRespModel.fromJson(json)).toEqual(expected);
});

it('2. result.bind_sheet 有值', () => {
  const json = {
    bind_sheet: {
      doc_id: {
        domain_id: '300000000',
        pad_id: 'RkVXRFdMR2xRaVli',
        scode: null,
      },
      sub_id: 'bHpmMWZ1',
      doc_url: 'Ly9kb2NzLnFxLmNvbS9zaGVldC9EUmtWWFJGZE1SMnhSYVZsaQ==',
      doc_type: null,
      sub_type: 'grid',
    },
    total: 30,
    sync_total: 30,
  };
  const expected = {
    maxSyncCnt: json.total,
    currSyncCnt: json.sync_total,
    syncProcess: '100%',
    sheetId: `${json.bind_sheet.doc_id.domain_id}$${atob(json.bind_sheet.doc_id.pad_id)}`,
    sheetSubId: atob(json.bind_sheet.sub_id),
    sheetType: json.bind_sheet.sub_type,
    sheetUrl: `${atob(json.bind_sheet.doc_url)}?tab=${atob(json.bind_sheet.sub_id)}`,
  };

  expect(bindResultRespModel.fromJson(json)).toEqual(expected);
});

it('3. 测试BindResultReqModel', () => {
  const obj = {
    globalPadId: 'mock_global_pad_id',
    taskId: 'mock_task_id',
  };
  const expected = {
    form_id: obj.globalPadId,
    task_id: obj.taskId,
  };

  expect(bindResultReqModel.toJson(obj)).toEqual(expected);
});

完美~~~

参考

Symbol MDN

WeakMap MDN

Reflect MDN

Decorators 官方文档

reflect-metadata npm

深入理解 TypeScript 之 Reflect-Metadata