这里以使用 TypeScript 开发,以及使用 Axios 库发起请求的例子。
没接触过 TypeScript 的话可以看下我之前的文章,有一个开发入门介绍: 快速上手 TypeScript 。
因为需要切换测试环境和生产环境,所以这里把配置单独保存在 config 里了,类型是:
// src/config/avata.ts
interface AvataConfig {
// API Key 用于网关鉴权
apiKey: string
// API Secret 用于接口服务调用签名
apiSecret: string
// API Url 是接口域名和公共前缀
apiUrl: string
}
具体的配置管理可以自己调整,这里是解释一下下面的 import { avata } from '@/config'
这一句是干什么的。
环境变量可以通过 cross-env 管理,用法这里就不赘述了。
签名的规范详见 #13 ,为了方便维护,这里保存为单独的文件用来生成签名(这里我把 Log 都移除了,自己在调试的时候可以在里面打印一些关键数据看看处理结果)。
// src/libs/axios/signature.ts
import { SHA256 } from 'crypto-js'
import { avata } from '@/config'
import type { AxiosRequestConfig } from 'axios'
interface Obj {
[key: string]: any
}
/**
* 提取 URL 里的信息
* @param fullUrl - 完整的 URL ,可能包含 ?a=1&b=2 后面的尾巴
* @returns 一个包含请求路径和 Query 参数对象的对象
* `path`: 请求路径,仅截取域名后及 Query 参数前部分,例:`/v1beta1/accounts`
* `query`: Query 参数对象,会把 `key1=value1&key2=value2` 转为对象
*/
function extractUrlInfo(fullUrl: string): {
path: string
query: Obj
} {
const [url, queryStr] = decodeURIComponent(fullUrl).split('?')
// 去掉域名,拿到请求路径
const path = url.startsWith('http')
? `/${url.split('/').slice(3).join('/')}`
: url
// 提取 URL 后面的 Query 部分
const query: Obj = {}
if (queryStr) {
const qArr = queryStr.split('&')
qArr.forEach((q) => {
const [k, v] = q.split('=')
query[k] = v
})
}
return { path, query }
}
/**
* 添加对象的键前缀
* @param source - 需要处理的对象数据源
* @param prefix - 需要添加的前缀
* @param isParams - 是否 config.params 里的数据
* @returns 处理后的对象
*/
function addKeyPrefix(source: Obj, prefix: string, isParams?: boolean) {
const result: Obj = {}
// Params 里的值需要处理为字符串
// 见 https://github.com/bianjieai/opb-faq/issues/13
for (const k in source) {
if (Object.prototype.hasOwnProperty.call(source, k)) {
result[`${prefix}_${k}`] = isParams ? String(source[k]) : source[k]
}
}
return result
}
/**
* 对键进行排序
* @description 签名还需要对键排序,否则会报 authentication failed
* @see https://github.com/bianjieai/opb-faq/issues/60
* @param target - 要排序的数据
* @returns 排序结果
* 对象:直接按照键排序后的新结果
* 数组:如果里面是对象,也是会进行排序
* 其他:原样返回
*/
function sortKeys(target: any): any {
// 非数组和非对象,直接返回
if (
!Array.isArray(target) &&
Object.prototype.toString.call(target) !== '[object Object]'
) {
return target
}
// 处理数组
if (Array.isArray(target)) {
return target.map((i) => sortKeys(i))
}
// 处理对象
const keys = Object.keys(target).sort()
const newObj: Obj = {}
keys.forEach((k) => {
newObj[k] = sortKeys(target[k])
})
return newObj
}
/**
* 合并参数
* @param path - 通过 extractUrlInfo 拿到的 Path 请求路径
* @param query - 通过 extractUrlInfo 拿到的 Query 参数对象
* @param params - 传入 config.params
* @param data - 传入 config.data
* @returns 对键进行了排序的合并结果
*/
function mergeParams(
path: string,
query: Obj = {},
params: Obj = {},
data: Obj = {}
): Obj {
// 合并处理了键前缀的结果
const originResult: Obj = {
path_url: path,
...addKeyPrefix(query, 'query'),
...addKeyPrefix(params, 'query', true),
...addKeyPrefix(data, 'body'),
}
// 对键进行排序
const result = sortKeys(originResult)
console.log('[libs/axios/signature]', JSON.stringify(result))
return result
}
/**
* 获取 Avata API 的签名
* @param config - Axios 的请求配置
* @returns 按照 SHA256(Params+Timestamp+ApiSecret) 的算法生成的 API 签名
*/
export function getAvataSignature(config: AxiosRequestConfig): string {
const { url, headers, params, data } = config
const { path, query } = extractUrlInfo(url)
const sha256Data = mergeParams(path, query, params, data)
const timestamp = headers['X-Timestamp'] || Date.now()
const signature = String(
SHA256(`${JSON.stringify(sha256Data)}${timestamp}${avata.apiSecret}`)
)
return signature
}
Axios 支持请求拦截,拦截器我也是一个独立文件维护:
// src/libs/axios/request.ts
import { avata } from '@/config'
import { getAvataSignature } from './signature'
import type { AxiosInstance } from 'axios'
/**
* 请求拦截
* 添加一些全局要带上的东西
*/
export function useRequest(instance: AxiosInstance) {
instance.interceptors.request.use(
// 正常拦截
(config) => {
// 处理 Avata API 配置,请求以 `/avata/xxx` 开头的都是 Avata API
if (config.url.startsWith('/avata')) {
// 根据命令行的指定环境处理为完整的请求 URL
config.url = config.url.replace('/avata', avata.apiUrl)
// 添加请求头
const timestamp = Date.now()
config.headers['X-Api-Key'] = avata.apiKey
config.headers['X-Timestamp'] = timestamp
config.headers['X-Signature'] = getAvataSignature(config)
}
// 返回处理后的配置
return Promise.resolve(config)
},
// 异常拦截
(err) => Promise.reject(err)
)
}
入口文件创建 Axios 实例,然后对这个实例启用拦截器(其他的响应拦截器就不展示了,我还封装了一些判断是否请求成功的方法,所以统一用了命名导出)。
import axios from 'axios'
import { axiosConfig } from './config'
import { useRequest } from './request'
/**
* 创建一个独立的 Axios 实例
* 把常用的公共请求配置放这里添加
*/
const instance = axios.create(axiosConfig)
useRequest(instance)
export { instance as axios }
这里请求一个不存在的接口作为测试,你可以在上面的文件里,把一些关键参数打印出来看看。
import { axios } from '@/libs/axios'
async function example(): Promise<void> {
const res = await axios({
method: 'post',
url: '/avata/example?a=1&b=2&c=3',
params: {
aa: 1,
bb: 2,
cc: 'a%20a',
},
data: {
aaa: 'Hello',
bbb: 'World',
ccc: 123,
},
})
console.log(res)
}
文昌返回的分页参数可能携带"=",这会导致extractUrlInfo方法里的const [k, v] = q.split('=')处理时丢失数据,建议改成new URL(fullUrl).searchParams来遍历并处理参数