"前端后端,分久必合、合久必分"
在前后端联调接口的过程中,开发人员经常会遇到如下情况:
- 字段命名与风格不一致。例如同样表示 结束时间,后端接口返回下划线格式
collect_end_time,而前端习惯驼峰命名endTime; - 字段值存在转换关系。例如后端返回
doc_url,而前端需要解码后使用,即atob(doc_url);同样对于collect_end_time与endTime,后端要求存ms单位的时间戳,而前端更习惯使用s单位下的时间进行计算; - 后端返回多个字段/属性,前端需要将其组合使用。例如后端同时返回
doc_url与sub_id,而前端要使用doc_url?sub=sub_id的形式。
所以在获取接口数据(response data)时,要将后端接口返回的 json 结构遍历成前端所需要 js obj 结构,而请求数据(request payload)时,前端要经常将 js obj 遍历成后端所接受的 json 格式。可见,每次从接口获取数据以及发送数据时,都要将数据遍历一番才能得到各端青睐的数据格式。
作为前端开发人员,预期是:
- 通过
fromJson方法将『后端数据』转成『前端数据』; - 通过
toJson方法将『前端数据』转成『后端数据』。
因此我们将基于装饰器功能实现一种优雅的转换方式,即实现 @JsonProperty 属性装饰器以及 JsonConverter 基类。
定义接口 IJsonConverter
IJsonConverter 接口应该包括两个功能
-
toJson将 js obj(前端数据) 转成 json(后端数据) -
fromJson将 json 转成 js obj。
interface IJsonConverter<O, J> {
/**
* 将js obj转成json格式
*/
toJson(obj: O): J;
/**
* 将json转成js obj格式
*/
fromJson(json: J): O;
}
实现类属性装饰器 @JsonProperty
@JsonProperty属性装饰器的定位在于『收集』数据结构中的参数命名以及转换关系。(不熟悉装饰器的小伙伴可参考 Decorators)。
收集参数命名与转换关系的逻辑涉及到一个重要的方法 Reflect.getMetadata 与 Reflect.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:
-
jsonName是 json 中的字段命名(如例子中的collect_end_time),propertyName是 js obj 中的字段命名 (如例子中的endTime) -
target这里指向的是类的prototype属性 -
@JsonProperty收集的映射关系就是:1、propertyName=>jsonName以及转换关系,2、jsonName=>propertyName以及转换关系 -
解构
Reflect.getMetadata(keyForJsonProperty, target)的原因是@JsonProperty会用于不同的属性上,因此不能覆盖已经收集的映射关系 -
🔴 这里为防止
jsonName与propertyName重名,或者jsonName重复,这里采用ES6里的Symbol语法
实现 JsonConverter 基类
JsonConverter 基类实现 IJsonConverter 接口。
🔴 最重要的两个方法 toJson 和 formJson
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 注意区分 Array 与 PureObject
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);
});
完美~~~