java接口响应时间过长,前端Node优化解决方案

479 阅读7分钟

背景

在项目开发中,交互的高流畅度是所有开发共同追求的目标,但是有时候并不是完全如人所愿,就总会有那么几个接口非常的费时间,甚至达到分钟级别。例如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终止查询。好像一切听着都很合理,但是:

  1. 如果一个组件内有多个请求,这些请求都很耗时呢?
  2. 多个耗时的请求没在一个组件内,那我们前面的逻辑是不是又要写一遍?
  3. 浏览器的请求并发一般都是有数量限制的,如果我们页面有多个这种耗时请求,此时又该怎么办?

继续这个思路的改进

  1. 对于耗时请求,因为浏览器的并发限制,我们不能让其长时间处于pending状态,否则会阻塞其他请求,那么就得进行异步查询,第一次请求只是发起请求(createTask),告诉后台服务我们要查询什么,查询结果有没有不重要,赶紧响应了。然后再去查询前面请求的结果(descripeTaskResult)。 也就是说我们把一个请求拆成了两个请求。
  2. 前端要统一的去封装请求方法,没有必要在每个组件内都写一遍类似的逻辑。
  3. 第二次去查询结果不一定可以查得到结果,因为前端也不知道JAVA服务到底什么时候能响应,所以前端需要时轮询结果,所以会产生第三次,第四次。。。。
  4. 既然是前端轮询结果,那么服务端就需要在把查询到的结果缓存起来,等待下一次查询。
  5. 那么服务端不再收到客户端的请求,那么就可以认为页面切走或者关闭了,后台服务杀死查询进程。

开干开干

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端用来存放数据的仓库

先完成任务的控制中心,他核心就三个任务:

  1. 存储当前正在发起的请求,并生成一个定时器。
  2. 一段时间后,当不再接收到前端轮询的请求时候,通知JAVA服务停止查询进程。
  3. 如果在这段延时时间内收到了请求,那么重新记时间。 所以就是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服务少查一些字段,或者只是返回部分的内容,请求拆分,可以大大加快响应速度。但这里只是探讨在常规的优化方法下都没有办法,我们必须得接受一个响应时间特别长的请求的时候的应对策略。