背景
在项目开发中,交互的高流畅度是所有开发共同追求的目标,但是有时候并不是完全如人所愿,就总会有那么几个接口非常的费时间,甚至达到分钟级别。例如JAVA要查询的数据量过于庞大,要查询的字段又关联着很多其他的表,不管JAVA那边遇到了什么麻烦,对用户的直观的感受就是接口响应速度过于慢。
虽然我们也可以进行一些优化,例如对请求进行拆分,但是我们可能还是会面临优化后接口响应时间还是很长的问题。响应时间很长的话,用户在屏幕前面可以等,但是也可以不等,当用户不等的时候,直接关掉浏览器或者切换其他页面的时候,我们后台java服务还在进行着这个进程。这显然就是一个问题。
解决思路
明确目标以及必要条件
首先明确我们的目的:我们希望在页面关掉的时候,JAVA可以接收到请求已经终止的消息,JAVA服务可以kill该进程。
前提条件: 既然JAVA需要接收终止查询的消息,那么JAVA端就需要提供一个终止查询的接口。
例如接口名字为 /api/terminateQuery
一些弯路
继续这个思路,我们很容易的就想到我们可以在前端组件销毁时候,我只需要调用终止查询接口,通知java终止查询即可。所以我们就可以得到以下代码:
// 以react为例,又或者vue的beforeDestroy
useEffect(() =>{
...
return () => {
// 调用JAVA终止查询接口
requet(/api/terminateQuery)
}
},[dep])
这个逻辑有没有什么问题?
按照这个思路,下一步,给JAVA传什么参数呢?那肯定要包含当前请求的requestId,我们可以在发起请求的时候通过uuid生成requestId,放到请求头中。前端组件再缓存起来这个requestId,后面用来通知JAVA终止查询。好像一切听着都很合理,但是:
- 如果一个组件内有多个请求,这些请求都很耗时呢?
- 多个耗时的请求没在一个组件内,那我们前面的逻辑是不是又要写一遍?
- 浏览器的请求并发一般都是有数量限制的,如果我们页面有多个这种耗时请求,此时又该怎么办?
继续这个思路的改进
- 对于耗时请求,因为浏览器的并发限制,我们不能让其长时间处于pending状态,否则会阻塞其他请求,那么就得进行异步查询,第一次请求只是发起请求(createTask),告诉后台服务我们要查询什么,查询结果有没有不重要,赶紧响应了。然后再去查询前面请求的结果(descripeTaskResult)。 也就是说我们把一个请求拆成了两个请求。
- 前端要统一的去封装请求方法,没有必要在每个组件内都写一遍类似的逻辑。
- 第二次去查询结果不一定可以查得到结果,因为前端也不知道JAVA服务到底什么时候能响应,所以前端需要时轮询结果,所以会产生第三次,第四次。。。。
- 既然是前端轮询结果,那么服务端就需要在把查询到的结果缓存起来,等待下一次查询。
- 那么服务端不再收到客户端的请求,那么就可以认为页面切走或者关闭了,后台服务杀死查询进程。
开干开干
ps: 因为我平时接触的是有中间层node的,所以第4,5步后续会用node来示例,如果没有这一层的,可以辛苦一下你们的JAVA。(嘿嘿嘿)
每个项目肯定都有自己封装好的request方法,我们可以在原有的requet方法基础上去修改。啊?你们项目没有,那赶紧封装一个,这绩效不就来了?
前端请求逻辑
按照前面提到的了逻辑,那就是我们调用接口后台创建一个任务,然后再定时查询任务结果。
// apiId: 请求路径
// data: 请求参数
// option 配置项
if (option.async) {
const res = callAsyncApi(apiId, data, option);
}
下面去完善我们的异步请求的方法
const callAsyncApi = async(apiId, data, option) => {
const { format, onFinish, onOk, onErr, ...otherOpt } = option;
const requestId = uuid.v4();
// 发起查询任务请求
const asyncTeskId = await createAsyncTask(apiId, data, requestId, { ...otherOpt, onErr });
if (!asyncTeskId) return null;
await sleep(500);
let res = null;
// 轮询查询结果
for (let i = 0; i < 1000000; i++) {
if (typeof cancelSources[requestId] === 'undefined') {
if (typeof onFinish === 'function') onFinish(null, new Error('canceled'));
return null;
}
// 查询结果
res = await getAsyncTaskResult(asyncTeskId, requestId, otherOpt);
// 对于taskIsFinished这个方法,可以结合自己的实际业务开发。
if (taskIsFinished(res)) break;
await sleep(Math.min(5000, 1000 * i));
}
if (res.success) {
if (typeof onOk === 'function') onOk(res.data);
if (typeof onFinish === 'function') onFinish(res.data, null);
return res.data;
}
}
然后完善我们的创建服务端任务和查询任务结果逻辑,这里自然要有一个创建查询任务接口和查询结果接口
const createAsyncTask = async (apiId, data, requestId, option) => {
// 创建查询任务接口
const url = '/api/createAsyncTask';
// 发起实际请求的逻辑
const {asyncTaskId} = await request({
requestId,
data: { apiId, ...data },
url,
...option
})
return asyncTaskId
}
const getAsyncTaskResult = async (asyncTaskId, requestId, option) => {
// 创建查询任务接口
const url = '/api/describeAsyncTaskResult';
const res = await request({
requestId,
data: { asyncTaskId },
url,
...option
})
return res
}
node服务逻辑(eggjs)
一些准备工作
很显然我们嗐需要两个模块,第一个就是异步任务的控制中心,第二个就是我们node端用来存放数据的仓库。
先完成任务的控制中心,他核心就三个任务:
- 存储当前正在发起的请求,并生成一个定时器。
- 一段时间后,当不再接收到前端轮询的请求时候,通知JAVA服务停止查询进程。
- 如果在这段延时时间内收到了请求,那么重新记时间。 所以就是Egg.js中Agent进程,在Agent进程中
'use strict'
const timer = {};
async function cancelTask(agent, id) {
try {
const option = {
...一些必要配置项
}
const res = await agent.curl(`${java终止查询接口}`, option);
// 相关请求日志的打印
} catch (e) {
// 相关请求日志的打印
}
}
function clearTimer(taskId) {
if (timer[taskId]) {
clearTimeout(timer[taskId]);
delete timer[taskId];
}
}
module.exports = (agent) => {
// 这里可以写一些初始化的逻辑
// 如果不定期查询任务执行结果,任务就会被终止
agent.messenger.on('async-task-watchdog', ({ taskId= '' }) => {
if (timer.taskId) clearTimeout(timer.taskId);
timerTaskId = setTimeout(() => {
cancelTask(agent, taskId);
clearTimer(taskId);
}, 10000)
});
agent.messenger.on('async-task-finish', ({ taskId= '' }) => {
if (timer.taskId) clearTimer(taskId);
});
}
接下来完成我们存放数据的仓库。
这里考虑到长时间的请求可能返回结果会很大,所以我们用一个文件把返回结果存起来。
class FilecacheService extends Service {
constructor(item) {
super(item);
this.cacheDir = `${存放数据的文件位置}`
}
// 获取文件名
getFileName(name, key) {
return `${name}_${md5(key).cacheJson}`
}
// 设置缓存数据
async set(name, key, data, timeOut = 86400000) {
const fileName = this.getFileName(name, key);
const file = path.resolve(this.cacheDir, fileName);
if (data === null) {
try {
fs.remove(file)
} catch (e) {
return;
}
} else {
const expireTime = Date.now() + timeOut;
await writeJson(file, { data, expireTime })
}
}
// 获取缓存数据
async get(name, key, ignoreExpireTime = false) {
const fileName = this.getFileName(name, key);
const file = path.resolve(this.cacheDir, fileName);
const data = await readJson(file);
if (!data) {
return null;
}
if (ignoreExpireTime || data.expireTime > Date.now()) {
return data.data;
} else {
await fs.remove(file);
return null;
}
}
// node服务定时任务来执行,定期来清除过期文件
async clear(name) {
const now = Date.now();
const files = await fs.readdir(this.cacheDir);
for (const file of files) {
const fileName = path.resolve(this.cacheDir, file);
const data = await readJson(fileName);
if (data && data.expireTime > now) {
await fs.remove(fileName);
}
}
}
}
接口开发
在Controller中创建两个接口,一个创建任务的时候调用的接口
async startTask(asyncTaskId, apiId, data) {
// 这里具体要结合实际项目,目的就是在node服务发起对JAVA的请求,在请求获取结果后将返回结果在node端缓存起来。
// 这里是自己调用自己的api,如果做好了node端接口和java接口的映射,直接去调用JAVA也不是不行。
await this.ctx.callSelfApi({
apiId: apiCfg.apiId,
onFinish: async(res) => {
await this.app.service.fileCache.set('AsyncTask', asyncTaskId, res);
}
})
}
async CreateAsyncTask(params) {
const { requestId = '' } = this.ctx.gatawayParams;
// 获取requestId,如果获取不到的话,那么服务端自己生成一个requestId
const asyncTaskId = requestId || uuid.v4();
// 开始真正的对发起对JAVA服务的请求
this.startTask(asyncTaskId, apiId, data);
this.app.messenger.sendToAgent('async-task-watchdog', { asyncTaskId });
return { asyncTaskId };
}
另一个是我们查询请求的结果接口
async DescribeAsyncTaskResult(params) {
const { asyncTaskId } = params;
// 查询是否已经有结果
const asyncTaskResult = this.ctx.service.fileCache.get('AsyncTask', asyncTaskId);
// 根据是否有返回结果决定下一步做什么
const action = asyncTaskResult ? 'async-task-finish' : 'async-task-watchdog';
this.app.messenger.sendToAgent(action, { asyncTaskId });
// 返回结果
return asyncTaskResult
}
结语
总结
对于特别费时的接口,我们可以采用从node端发起请求的方法,创建一个异步任务,来解决浏览器的请求并发数量限制。前端通过定时轮询的方式获取请求的结果。并且解决了在用户关闭浏览器后,后台服务进程仍然在进行查询的问题。
最后
当然如果让JAVA服务少查一些字段,或者只是返回部分的内容,请求拆分,可以大大加快响应速度。但这里只是探讨在常规的优化方法下都没有办法,我们必须得接受一个响应时间特别长的请求的时候的应对策略。