如何封装一个平台的 sdk 工具(Node 实现)
在接入第三平台接口时,如果第三平台没有提供相关的 sdk 服务时,就需要自己进行相关的 sdk 封装,来优雅简单的使用接口,否则只是在每一个使用到地方都写一遍,就会过于冗余,从而导致代码越来越多,屎山越来越高。
这里就拿抖店的第三方接口接入为例,开发一个在项目中可以简单地使用的 sdk 工具。
这里接口封装,就拿抖店来举例子好啦
平台 Sdk 工具构建流程
大家可以先看一下抖店官方的对接文档,这样就对后面要做的事情有了一个比较清晰的认识:op.jinritemai.com/docs/guide-…
抖店官方是没有提供 Node 直接使用的 Sdk 的,所以我们要进行一个封装,来优雅地使用它
基类的实现
我们需要对每一个接口进行接入的时候,最好创建一个基类,然后进行继承,这样可以避免在每一次调用时都重复实现相同的流程。
那么这个基类需要实现哪些功能呢?这些都可以通过上面官方给出的文档中知晓。具体可以看下面的内容
- 接受接口请求内容传入:参数、请求路径
- 公共请求头的构建
- 请求签名的生成
- 发起对外请求并返回
根据上面的内容,来进行实现就比较清晰了
具体实现
1. 基类构建
type DyRequestCommonParameters<T> = {
method: string;
app_key: string;
access_token: string;
param_json?: T;
timestamp: string;
v: string;
sign: string;
sign_method: string;
};
/**
* @description 基本抖店返回格式
*/
type DyBaseResponse<P> = {
/** 主返回码,10000是成功 */
code: number;
/** 当前此请求日志id */
log_id: string;
/** 主返回码描述,10000的描述是success */
msg: string;
/** 子返回码 */
sub_code: string | number;
/** 子返回码描述 */
sub_msg: string;
/** 数据返回内容 */
data?: P;
};
class DyServicesBase<T> {
/** 用户凭证 */
protected accessToken: string;
/** 参数 */
protected parameters: DyRequestCommonParameters<T> = {
/** 请求方法 */
method: "",
/** 应用key */
app_key: "",
/** 请求时间戳 */
timestamp: "",
v: "",
sign: "",
sign_method: "",
access_token: "",
};
/** app_secret */
protected appSecret: string;
/** baseUrl,基本url */
protected baseUrl: string;
/**
* @param {string} accessToken 用户凭证
* @param {string} requestMethod 请求方法
*/
constructor(accessToken: string, requestMethod: string) {
this.parameters.method = requestMethod;
this.parameters.access_token = accessToken;
this.buildRequestHeader();
}
}
上面就是抖店 sdk 服务的基础构建了,其中包括了抖店要求我们传入的数据内容。接口子类需要传入的数据参数的内容,其中包括了用户的 access_token和具体接口的请求路径requestMethod
。
2. 公共请求头的构建
/**
* @description 构建公共请求头
*/
protected buildRequestHeader () {
const appId = process.env['downstream.platform.dy.appId'];
this.appSecret = process.env['downstream.platform.dy.appSecret'];
this.baseUrl = `${process.env['downstream.platform.dy.url']}/${this.parameters.method.replace('.', '/')}`;
this.parameters.app_key = appId;
// eslint-disable-next-line id-length
this.parameters.v = '2';
this.parameters.timestamp = dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss');
}
请求头的构建则是通过官方文档中,在每一个接口文档中对会标明必穿的请求头内容,所以我们需要将这个请求头进行一下构建,传入我们本身应用的相关凭证内容,来获取调用官方接口的权限和验证。
3. 请求体与签名生成
有了必传的请求头内容之后,我们需要在进行接口请求中要求传入的相关参数的构建以及根据传入参数生成的签名。
/** 签名加密枚举类 */
enum SignType {
/** md5加密 */
SIGN_MD5 = 'md5',
/** sha1加密 */
SIGN_SHA1 = 'sha1',
/** sha256加密 */
SIGN_SHA256 = 'sha256',
/** 返回值转成大写 */
SIGN_UPPER = 'upper',
}
/**
* @descripiton 生成签名
*/
private generateSign () {
const { app_key: appKey, method, timestamp, v: version } = this.parameters;
let { param_json: paramJson } = this.parameters;
// NOTE: 先将paramJson进行排序,然后再进行签名
paramJson = sortObjectKeys(paramJson);
// NOTE: 将对象属性进行encode
paramJson = handleParamEncodeObj(paramJson);
this.parameters.param_json = paramJson;
// NOTE: 签名字符串---以app_key、method、param_json、timestamp、v这个顺序,把以上参数的键值对依次拼接在一起
let signString = `app_key${appKey}method${method}param_json${JSON.stringify(this.parameters.param_json)}timestamp${timestamp}v${version}`;
signString = this.appSecret + signString + this.appSecret;
// NOTE: 获取一下签名的加密方式,是使用md5还是hmac-sha256
const encryption = process.env['downstream.platform.distribute.dy.encryption'].split('&').reverse();
for (const encryptType of encryption) {
signString = handlePlatformEncrypt(encryptType, signString, this.appSecret);
}
this.parameters.sign = signString;
[this.parameters.sign_method] = encryption;
}
/**
* @description 对象键值排序
*/
export function sortObjectKeys (obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
const sortedKeys = Object.keys(obj).sort();
const result = {};
for (const key of sortedKeys) {
result[key] = sortObjectKeys(obj[key]);
}
return result;
}
/**
* @description 将数据参数中的对象数据全部encode
*/
export function handleParamEncodeObj (params) {
for (const key in params) {
if (params[key] instanceof Object) {
params[key] = JSON.stringify(params[key]);
}
}
return params;
}
/**
* @description 处理签名规则
* @param {string} type 处理类型
* @param {string} data 处理数据
*/
const handlePlatformEncrypt = (type: string, data: string, secret = ''): string => {
let cryptData = '';
switch (type) {
case SignType.SIGN_MD5:
cryptData = md5(data);
break;
case SignType.SIGN_SHA1:
cryptData = crypto.createHmac('sha1', secret).update(data)
.digest('hex');
break;
case SignType.SIGN_SHA256:
cryptData = crypto.createHmac('sha256', secret).update(data)
.digest('hex');
break;
case SignType.SIGN_UPPER:
cryptData = data.toUpperCase();
break;
}
return cryptData;
};
根据代码中的备注,我相信大家应该可以看出来请求签名的生成步骤了:
- 对传入的参数进行排序,根据字典序进行排序
- 将参数属性内容进行 encode 操作
- 将之前构建完成的请求头和请求参数进行按照官方指定顺序的样子进行凭借。
- 设置签名的加密方式,官方会要求使用 md5 还是 hmac-sha256 加密
- 根据加密方式,对请求数据进行加密,生成请求签名。
这里因为要对参数数据进行加密,所以引入了可以进行加密的库:crypto 和 md5
4. 发起对外请求并返回
在外成上面所有需要携带的数据构建之后,就可以发起对外的请求了。
/**
* @description 发起对外请求
*/
protected async externalRequests<P> (isRetry = false, retryCount = 0): Promise<P> {
// NOTE: 生成一下签名
this.generateSign();
// NOTE: 设置一下请求体和请求内容
const { requestUrl, requestBody } = this.settingQueryUrl();
const responseData = await axios.post(
requestUrl,
requestBody,
{
headers: { 'Content-Type': 'application/json;charset=\'utf-8\'' },
transformResponse: (data) => {
if (['product.createComponentTemplateV2', 'product.addV2'].includes(this.parameters.method)) {
return JSONbig.parse(data);
}
return JSON.parse(data);
},
}
);
const dyResponseData = responseData.data as DyBaseResponse<P>;
if (dyResponseData.code === 10000) {
return dyResponseData.data;
}
if (isRetry && retryCount < 3) {
const retryData = await this.externalRequests<P>(true, retryCount + 1);
return retryData;
}
Reflect.deleteProperty(dyResponseData, 'data');
throw new Error(JSON.stringify(dyResponseData));
}
/**
* @description 设置请求体内容与请求内容
*/
private settingQueryUrl () {
let requestUrl = `${this.baseUrl}?`;
const requestBody: T = this.parameters.param_json;
for (const key in this.parameters) {
if (key === 'param_json') {
continue;
}
requestUrl += `${key}=${this.parameters[key]}&`;
}
return {
requestUrl: requestUrl.substring(0, requestUrl.length - 1),
requestBody,
};
}
在这里对外请求发起时,进行重复三次的操作,因为有些时候,在对第三接口接口请求频繁时,可能会出现 qps 超限的问题,也有可能是第三方接口出错的问题,所以增加重试机制,增加请求成功率。当然这一块大家可以根据自己的业务策略进行调整。
还有一个需要注意的就是会看到在 axios 请求中,使用了transformResponse
的回调, 这是因为在实际开发使用中,会发现第三方接口会返回超长的number
类型的数据,但是在 axios 中无法自动转为 string,这就会导致在获取接口返回数据时,对超限的数据进行裁切,导致数据不正确的问题。
所以为了解决这个问题,就是用transformResponse
回调,对这类数据进行一个大数处理,对超限数据转换成大数进行返回。这里为了不重复造轮子,使用了**json-bigint
**这个库,来对数据进行处理。
汇总实现
下面就是一个完整的 Node 端实现对抖店接口调用的基类实现汇总:
/** 签名加密枚举类 */
enum SignType {
/** md5加密 */
SIGN_MD5 = "md5",
/** sha1加密 */
SIGN_SHA1 = "sha1",
/** sha256加密 */
SIGN_SHA256 = "sha256",
/** 返回值转成大写 */
SIGN_UPPER = "upper",
}
/**
* @description 处理签名规则
* @param {string} type 处理类型
* @param {string} data 处理数据
*/
const handlePlatformEncrypt = (
type: string,
data: string,
secret = ""
): string => {
let cryptData = "";
switch (type) {
case SignType.SIGN_MD5:
cryptData = md5(data);
break;
case SignType.SIGN_SHA1:
cryptData = crypto.createHmac("sha1", secret).update(data).digest("hex");
break;
case SignType.SIGN_SHA256:
cryptData = crypto
.createHmac("sha256", secret)
.update(data)
.digest("hex");
break;
case SignType.SIGN_UPPER:
cryptData = data.toUpperCase();
break;
}
return cryptData;
};
/**
* @description 对象键值排序
*/
const sortObjectKeys = (obj) => {
if (typeof obj !== "object" || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
const sortedKeys = Object.keys(obj).sort();
const result = {};
for (const key of sortedKeys) {
result[key] = sortObjectKeys(obj[key]);
}
return result;
};
/**
* @description 将数据参数中的对象数据全部encode
*/
const handleParamEncodeObj = (params) => {
for (const key in params) {
if (params[key] instanceof Object) {
params[key] = JSON.stringify(params[key]);
}
}
return params;
};
/**
* @description 抖店基础服务类,提供公共参数构建与请求发起
*/
export class DyServicesBase<T> {
/** 用户凭证 */
protected accessToken: string;
/** 参数 */
protected parameters: DyRequestCommonParameters<T> = {
method: "",
app_key: "",
timestamp: "",
// eslint-disable-next-line id-length
v: "",
sign: "",
sign_method: "",
access_token: "",
};
/** app_secret */
protected appSecret: string;
/** baseUrl,基本url */
protected baseUrl: string;
/**
* @param {string} accessToken 用户凭证
* @param {string} requestMethod 请求方法
*/
constructor(accessToken: string, requestMethod: string) {
this.parameters.method = requestMethod;
this.parameters.access_token = accessToken;
this.buildRequestHeader();
}
/**
* @description 构建公共请求头
*/
protected buildRequestHeader() {
const appId = process.env["downstream.platform.distribute.dy.appId"];
this.appSecret = process.env["downstream.platform.distribute.dy.appSecret"];
this.baseUrl = `${
process.env["downstream.platform.distribute.dy.url"]
}/${this.parameters.method.replace(".", "/")}`;
this.parameters.app_key = appId;
// eslint-disable-next-line id-length
this.parameters.v = "2";
this.parameters.timestamp = dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss");
}
/**
* @descripiton 生成签名
*/
private generateSign() {
const { app_key: appKey, method, timestamp, v: version } = this.parameters;
let { param_json: paramJson } = this.parameters;
// NOTE: 先将paramJson进行排序,然后再进行签名
paramJson = sortObjectKeys(paramJson);
// NOTE: 将对象属性进行encode
paramJson = handleParamEncodeObj(paramJson);
this.parameters.param_json = paramJson;
// NOTE: 签名字符串---以app_key、method、param_json、timestamp、v这个顺序,把以上参数的键值对依次拼接在一起
let signString = `app_key${appKey}method${method}param_json${JSON.stringify(
this.parameters.param_json
)}timestamp${timestamp}v${version}`;
signString = this.appSecret + signString + this.appSecret;
// NOTE: 获取一下签名的加密方式,是使用md5还是hmac-sha256
const encryption = process.env[
"downstream.platform.distribute.dy.encryption"
]
.split("&")
.reverse();
for (const encryptType of encryption) {
signString = handlePlatformEncrypt(
encryptType,
signString,
this.appSecret
);
}
this.parameters.sign = signString;
[this.parameters.sign_method] = encryption;
}
/**
* @description 发起对外请求
*/
protected async externalRequests<P>(
isRetry = false,
retryCount = 0
): Promise<P> {
// NOTE: 生成一下签名
this.generateSign();
// NOTE: 设置一下请求体和请求内容
const { requestUrl, requestBody } = this.settingQueryUrl();
const responseData = await axios.post(requestUrl, requestBody, {
headers: { "Content-Type": "application/json;charset='utf-8'" },
transformResponse: (data) => {
if (
["product.createComponentTemplateV2", "product.addV2"].includes(
this.parameters.method
)
) {
return JSONbig.parse(data);
}
return JSON.parse(data);
},
});
const dyResponseData = responseData.data as DyBaseResponse<P>;
if (dyResponseData.code === 10000) {
return dyResponseData.data;
}
if (isRetry && retryCount < 3) {
const retryData = await this.externalRequests<P>(true, retryCount + 1);
return retryData;
}
Reflect.deleteProperty(dyResponseData, "data");
throw new Error(JSON.stringify(dyResponseData));
}
/**
* @description 设置请求体内容与请求内容
*/
private settingQueryUrl() {
let requestUrl = `${this.baseUrl}?`;
const requestBody: T = this.parameters.param_json;
for (const key in this.parameters) {
if (key === "param_json") {
continue;
}
requestUrl += `${key}=${this.parameters[key]}&`;
}
return {
requestUrl: requestUrl.substring(0, requestUrl.length - 1),
requestBody,
};
}
}
接口使用
有了上面的基类实现之后,我们的接口实现就非常简单了,下面是一个接口实现的例子,以及对接口的使用的例子
interface ServicesInterface<T, P> {
/** 设置参数 */
setParam(param: T): void;
/** 发起请求 */
execute(): Promise<P>;
}
/** 抖店----获取当前类目id的可选品牌请求值 */
type DyGetBrandListRequest = {
category_id: number;
}
/** 抖店----获取当前类目id的可选品牌返回值 */
type DyGetBrandListResponse = {
/** 当前类目是否要求品牌有授权 */
auth_required: boolean;
/** 授权的品牌列表 */
auth_brand_list: DyBrandInfo[];
/** 未授权的品牌列表 */
brand_list: DyBrandInfo[];
}
/** 抖店----品牌信息 */
type DyBrandInfo = {
/** 品牌id */
brand_id: number;
/** 中文名 */
name_cn: string;
/** 英文名 */
name_en: string;
}
import { DyServicesBase } from "../../dy.services.base";
import { ServicesInterface } from "../../../../services.interface";
/**
* @description 获取当前类目id的可选品牌接口
*/
export class DyGetBrandList
extends DyServicesBase<DyGetBrandListRequest>
implements ServicesInterface<DyGetBrandListRequest, DyGetBrandListResponse>
{
constructor(accessToken: string) {
super(accessToken, "brand.list");
}
setParam(param: DyGetBrandListRequest): void {
this.parameters.param_json = param;
}
async execute(): Promise<DyGetBrandListResponse> {
const responseData = await this.externalRequests<DyGetBrandListResponse>();
return responseData;
}
}
上面这个就是一个获取抖店当前类目id的可选品牌接口的实现,代码实现非常的清爽,只需要进行参数设置,以及请求发起即可,然后再把对应的请求参数类型、返回类型设置一下,即可完成。
然后再业务层面调用的例子实现如下:
const brandListObj = new DyGetBrandList(this.clsService.get(DOWNSTREAM_USER_ACCESS_TOKEN));
brandListObj.setParam({ category_id: categoryId });
const brandListResponse = await brandListObj.execute();
ok,大功告成
总结
这里只是给出了抖店这一第三平台接口调用的封装,其实各个平台的第三方接口调用的过程都是大差不差的,大部分区别就在请求体数据的构建和签名加密方式的不同而已。 如果平台没有对Node的接口调用sdk的支持的话,我们也可以很快的根据第三方接口平台给出的例子和步骤,完成一个优雅的sdk封装,来供业务进行使用和开发。