近期团队内在推行前后端标准化,目的是解决前后端同学在研发流程中协作方面的痛点,同时提供前后端交互规范的合规检查,保障API的合规,帮助研发同学发现API中潜在的风险。
与此同时,平台中心提供了中心化的标准化检查,会对收集到的日志进行规范校验和聚合,为后续的分析和解决问题提供数据依据。
大体了解了一下,检测能力共4个大类,(URL、请求、响应、特殊校验),细分的类别包括URL参数必须编码、URL长度限制、请求方法规范、请求头大小的规范、请求参数重复、请求参数为空,响应参数为空,返回的结构体为空,返回码规范、隐私字段规范、金额规范等。
都是一些常规的规范检查,个人觉得很必要,因为在平时的开发过程中总是发现后端在请求方式和字段上胡乱使用,反馈也没有起到太大的作用,这次算是一个比较好的机会让他们治理一波。
因为前后端的交互主要在接口的请求返回上,所以日志的输入就是 URL和请求的参数和返回的数据。
试想一下,如果我们要解决这个问题,我们要从哪些方面入手?
可以分为三个方面,从开发时、编译时、运行时来考虑。
- 在开发时 我们可以通过eslint插件来进行代码的分析提示。
-
在编译时,我们可以通过webpack插件来对代码进行分析检测。
-
在运行时,我们也可以通过一些手段来对数据进行分析。
因为团队内历史项目太多了,少说几百个,也有一些不怎么迭代的项目。所以三管齐下才能做到各个兼顾。
开发时、编译时 本次不分析。 本次主要是看一下运行时是怎么做的。
前面有提到,我们需要拿到项目中每一个前后端交互的URL,请求参数,返回参数,然后发送给平台中心去做检测。 如果我们在项目中的每一个请求的位置都去手动的拿到这三个数据,那么会耗费巨大的人力,且有可能会改错代码影响正常的业务。并且按照改动即测试的原则,这又是一笔很大的成本。
所以我们需要想一个更好的办法,既要达成我们的目标,又要对业务项目影响尽可能的少。思考一下,我们要的数据都是围绕的请求的。URL、请求参数(包括header)、响应数据。 从请求方法入手是最好的方式。
目前请求Web的请求方法就两种 ajax 和 fetch,无论你是什么请求库 axios还是request 等,本质上使用的都是这两个。因为考虑到兼容性,所以ajax居多。但是我们要做工作肯定要两个都考虑的。
所以我们的方法是复写这两个原生方法。
大体的流程图是这样的
fetch
我们先考虑一下fetch的复写。fetch相比ajax考虑的要多一些。
既然要复写,前提是我们要对fetch API足够的了解。
我们需要关注以下几点
第一点
fetch的第一个参数。正常我们的写法是 fetch(‘https://xxx')
,但是 fetch的第一个参数不只是string类型,也有可能是一个Request对象。
比如 这种
const request = new Request('https://example.com/data.json', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
fetch(request)
所以url 这块需要格外关注
第二点
参数部分,一般来说 get 的参数是拼接到URL后面的,post的参数是放在body中的。 但是恰恰就是有这么一批人瞎jb用。post请求URL后面拼参数的,这种人我司还真不少。
为了cover住这些大神的操作。所以 我们无论 method是什么类型,都要解析params和body。
Url后面的参数 比较好获取和解析。简单使用一个qs的parse就可以生成一个对象结构的。
Post请求的body 需要我们额外关注,和第一点一样。我们要考虑Request的问题。另外还需要考虑body的类型,是否是formData类型的,以及URLSearchParams类型的。这种类型虽然说用的比较少。但是我们也是要关注一下。至于普通的类型。因为最终都需要.JSON. stringify的。所以类型最好判断。
第三点
Header部分,这里首先要考虑Request的问题。其次要考虑Header是否使用了Headers类。
至此,请求的三个重要的参数就都处理好了。代码附在后面。
接下来我们看一下处理返回都要考虑哪些。
返回的数据嘛,肯定是在then中。这里需要注意。我们在其他的请求库中使用fetch的都是
fetch().then((res)=> 直接使用)
我们能直接这么用,是因为请求库帮我们做了一些事情。我们直接使用原生的fetch api是需要
fetch().then((res)=> res.json()).then((res)=> xxx)
。 有res.json 这么一步。
另外 fetch的then返回的 response对象是一个流(stream)对象,它的body属性是一个只能读取一次的ReadableStream对象。这意味着,当我们读取Response对象的body属性时,只能读取一次,读取后就会被清空,再次读取时就会返回空值。这是因为ReadableStream对象是一种流式数据结构,它的数据只能被消费一次。当我们读取Response对象的body属性时,实际上是在消费ReadableStream对象的数据。一旦数据被消费,就不能再次读取。
所以我们既要不影响业务后续的读取,又想获得返回的数据。最好办法就是copy一份。恰好,Response对象上有clone方法 可以用来创建一个副本。注意这个副本也是一个流对象,也只能消费一次。
这里我们就要处理返回值了,因为我们本质上是要做字段级别的合规检测。所以如果返回的结果是的content-type不是application/json 类型的,其实我们也不用考虑,不过这因情况而定。
总之,返回数据和header 我们都从副本中消费就可以。然后记得返回 实际的response,因为我们不能影响业务使用。
至于上报的方式有很多,这里就不展开讲了。
整体代码
function rewriteFetch() {
// 不是function
if (typeof fetch !== 'function') {
return
}
// 文件类型就不检测了
if (window.location && window.location.protocol === 'file:') {
return
}
try {
window.fetch = function(input, init = {}) {
const fetchUrl = input.url || input.href || input || '' // Request | URL | string | 兜底
const startTime = +new Date()
// 整理要上报的日志
const requestLog = {
referrer: document.referrer || input.referrer || init.referrer,
// 请求响应字段
httpClient: 'FETCH',
request: {
url: fetchUrl,
method: init.method || input.method,
params: getUrlParams(fetchUrl),
data: getReqBodyData(input.body, init.body),
headers: getHeaders(input.body, init.body),
},
}
return fetch && fetch.apply(window, arguments).then(function (res: Response) {
try {
if (!res || typeof res.clone !== 'function') {
return res
}
const copy = res.clone()
if (getType<'Headers'>(copy.headers) === 'Headers') {
const contentType = copy.headers.get('content-type') || ''
if (contentType.toLowerCase().indexOf('application/json') < 0) {
return res
}
}
copy.json().then(data => {
// 最终的上报的数据
const finData = {
...requestLog,
duration: +new Date() - startTime,
response: {
data,
headers: getHeaders(copy.headers),
status: copy.status,
statusText: copy.statusText,
},
}
// 这里写一些上报逻辑
// 。。。。。。
})
return res
} catch (error) {
return res
}
}).catch(function (err) {
// 如果响应失败了,也可以把请求的参数上报一下
// ....
throw err
})
}
} catch (error) {
console.log(error)
}
}
function getUrlParams(url: string) {
try {
const search = new URL(url, document.baseURI).search.slice(1)
return querystring.parse(search)
} catch (error) {
console.log('[getUrlParams] error:', error)
return {}
}
}
function getReqBodyData(...args: (Document|XMLHttpRequestBodyInit)[]) {
const data: Record<string, any> = {}
for (const body of args) {
try {
const type = getType<'FormData' | 'URLSearchParams'>(body)
if (type === 'FormData' || type === 'URLSearchParams') {
const data = body as FormData | URLSearchParams
for (const key of data.keys()) {
data[key] = data.getAll(key)
}
} else if (type === 'String') {
let queryObj: Record<string, any> = {}
try {
queryObj = JSON.parse(body as string) || {}
} catch (error) {
queryObj = querystring.parse(body as string)
console.log('[getReqBodyData] JSON.parse() error:', error)
}
for (const key in queryObj) {
data[key] = queryObj[key]
}
}
} catch (error) {
console.log('[getReqBodyData] error:', error)
}
}
return data
}
function getHeaders(...args: (Headers | Record<string, any>)[]) {
const data: Record<string, any> = {}
for (const headers of args) {
try {
const type = getType<'Headers'>(headers)
if (type === 'Headers') {
for (const key of headers.keys()) {
data[key] = headers.get(key)
}
} else if (type === 'Object') {
for (const key in headers) {
data[key] = headers[key]
}
}
} catch (error) {
console.log('[getHeaders] error:', error)
}
}
return data
}
下一篇我们再介绍ajax的复写以及注意点。
文章评论