一、背景



最近在维护一个老项目的时候,发现页面严重卡顿,页面长时间展示“加载等待中”。经过分析发现有一个老接口调用延时非常高,平均调用时间在3s以上。
前端菜鸟让老接口提速60%的原理与实现_java
每次在加载页面和翻页时都会停顿很久,严重影响体验。老接口服务存在以下几个问题:

  • 太多无效数据:接口返回数组的每条数据都包含了上百个字段,而前端展示只使用了其中10字段,太多的无效数据占据了接口传输时间。
  • 接口调用链过长:接口存在复杂逻辑,并且老接口内部还调用了其他n个接口的服务,导致前端调用接口延时过长。
  • 代码年久失修:老接口服务没人维护,无人知道如何修改和部署,没有文档,调用全靠猜。


作为一个前端工程师,如何在不修改老接口代码的情况下去优化这个接口延时过长的case呢?笔者决定做一个node代理层,用下面三个方法进行优化:

  • 按需加载 -> graphQL:通过描述接口协议字段的结构,然后配置指定规则schema,对数据进行字段的按需加载。
  • 数据缓存 -> redis:用redis来对老接口服务返回的数据进行缓存,让用户请求绕过老接口的复杂逻辑,直接获取数据。
  • 轮询更新 -> schedule:用node-schedule定时更新数据缓存,保证用户每次请求获取最新数据。


整体架构如下图所示:




二、按需加载graphQL



由于前端需要绘制一个图表,我们每次请求接口都要返回1000多条数据,返回的数组中,每一条数据都有上百个字段,其实我们前端只用到其中的10个字段进行展示和绘制图表。
如何从一百多个字段中,抽取任意n个字段,这就用到graphQL。graphQL按需加载数据只需要三步:
  • 定义数据池 root;
  • 描述数据池中数据结构 schema;
  • 自定义查询数据 query。


1. 定义数据池root


由于原业务逻辑和接口协议比较复杂,没法一一在文中叙述。为了方便理解,我用“屌丝追求女神”的场景来说明graphQL按需加载字段的实现。
首先我们定义一个女神girls数据池,里面包含女神的所有信息,如下:






















// 数据池var root = {    girls: [{        id: 1,        name: '女神一',        iphone: 12345678910,        weixin: 'xixixixi',        height: 175,        school: '剑桥大学',        wheel: [{ name: '备胎1号', money: '24万元' }, { name: '备胎2号', money: '26万元' }]    },    {        id: 2,        name: '女神二',        iphone: 12345678910,        weixin: 'hahahahah',        height: 168,        school: '哈佛大学',        wheel: [{ name: '备胎3号', money: '80万元' }, { name: '备胎4号', money: '200万元' }]    }]}

数据池包含了两个女神的所有信息,包括女神的名字(name)、手机(iphone)、微信(weixin)、身高(height)、学校(school)、备胎们的信息(wheel)。接下来我们再对这些数据结构进行描述。

2. 描述数据池中数据结构schema























const { buildSchema } = require('graphql');
// 描述数据结构 schemavar schema = buildSchema(`    type Wheel {        name: String,        money: String    }    type Info {        id: Int        name: String        iphone: Int        weixin: String        height: Int        school: String        wheel: [Wheel]    }    type Query {        girls: [Info]    }`);

上面这段代码就是女神信息的schema,schema其实就是将女神的信息进行结构化,经过结构化的数据,才可以进行数据按需获取。
在nodejs中使用graphql这个库,里面包含了graphQL操作字段的所有api。我们用buildSchema这个方法来构建女神信息的schema。
那么如何描述女神信息的schema呢?首先我们用type Query定义了一个对女神信息的查询,里面包含了很多女孩girls的信息Info,这些信息是一堆数组,所以是[Info]。
我们在type Info中描述了一个女孩的所有信息的维度,包括名字(name)、手机(iphone)、微信(weixin)、身高(height)、学校(school)、备胎集合(wheel)。
数据类型主要是String和Int,如果出现了嵌套对象类型,就参考备胎(wheel)的定义方式,单独用type定义一个Wheel备胎类型,这样就可以进行结构化的复用类型了。

3. 定义查询规则query


得到女神的信息描述(schema)后,就可以自定义各种组合,获取女神的信息了。比如我想和女神认识,只需要拿到她的名字(name)和微信号(weixin)。查询规则代码如下:















const { graphql } = require('graphql');
// 定义查询内容const query = `    {        girls {            name            weixin        }    }`;
// 查询数据const result = await graphql(schema, query, root);

对女神的名字、微信构造了一个query查询,注意这个语法不是我们前端的json语法,是graphQL特定的语法。
查询的时候,我们使用graphql这个库里面的graphql方法,将女神信息描述schema、女神数据池root、查询语句query一并传入graphql方法,这样就可以对数据进行按需加载了。
筛选结果如下:
前端菜鸟让老接口提速60%的原理与实现_java_02
我们按需获取到了女神的名字、微信,剔除女神了其他不需要的信息手机、身高、学校、备胎,这就是graphQL的核心思想:按需加载数据。
又比如我想进一步和女神发展,我需要拿到她备胎信息,查询一下她备胎们(wheel)的家产(money)分别是多少,分析一下自己能不能获取优先择偶权。查询规则代码如下:

















const { graphql } = require('graphql');
// 定义查询内容const query = `    {        girls {            name            wheel {                money            }        }    }`;
// 查询数据const result = await graphql(schema, query, root);

这个例子我们涉及到了一个嵌套查询,把女神名下所有备胎的money全查了出来筛选结果如下:

我们通过女神的例子,展现了如何通过graphQL按需加载数据。映射到我们业务具体场景中:老接口返回的每条数据都包含100个字段,我们配置schema,获取其中的10个字段,这样就避免了剩下90个不必要字段的传输。
graphQL还有另一个好处就是可以灵活配置。这个接口需要10个字段,另一个接口要5个字段,第n个接口需要另外x个字段,按照传统的做法我们要做出n个接口才能满足,现在只需要一个接口配置不同query就能满足所有情况了。


三、缓存redis



第二个优化手段,使用redis缓存。老接口内部还调用了多个其他第三方接口,极其耗时耗资源。我们用redis来缓存老接口的聚合数据,下次再调用老接口,直接从缓存中获取数据即可,避免高耗时的复杂调用,简化后代码如下:
























const redis = require("redis");const { promisify } = require("util");
// 链接redis服务const client = redis.createClient(6379, '127.0.0.1');
// promise化redis方法,以便用async/awaitconst getAsync = promisify(client.get).bind(client);const setAsync = promisify(client.set).bind(client);
async function list() {    // 先获取缓存中数据,没有缓存就去拉取天秀接口    let result = await getAsync("缓存");    if (!result) {        // 拉接口        const data = await 老接口();        result = data;        // 设置缓存数据        await setAsync("缓存", data)    }    return result;}
list();

我们用redis的npm包来进行缓存相关的操作,redis类似咱们的数据库,开始的时候先用redis.createClient建立连接。
由于redis提供的方法都是异步回调的函数,所以我们用promisify给所有函数包一下让我们能用async/await进行同步调用。
每次接口调用的时候,我们先通过getAsync来读取redis缓存中的数据,如果有数据,直接返回,绕过老接口复杂调用。
如果没有数据,就调用老接口,用setAsync将老接口返回的数据存入缓存中,以便下次调用。主体流程如下图所示:
前端菜鸟让老接口提速60%的原理与实现_java_03
因为redis存储的是字符串,所以在设置缓存的时候,需要加上JSON.stringify(data)。将数据放在redis缓存里有几个好处,可以实现多接口复用、多机共享缓存等。


四、轮询更新schedule



最后一个优化手段:轮询更新 -> schedule。
数据源一直在变化,会导致缓存的数据与数据源不一致,需要定时更新。更新的方法有很多种,听专业的后端小伙伴说有分段式数据缓存、主从同步读写分离、高并发同步策略等等。
由于我不是专业的后端人员,并且老接口调用量不大,对应的数据源更新频率低。所以我用了最简单的轮询更新策略。代码如下:








const schedule = require('node-schedule');
// 每个小时更新一次缓存schedule.scheduleJob('* * 0 * * *', async () => {    const data = await 天秀接口();    // 设置redis缓存数据    await setAsync("缓存", data)});

用node-schedule这个库来进行定时轮询更新缓存,设置轮询间隔为* * 0 * * *,这句代码的意思就是设置每个小时的第0分钟就开始执行缓存更新逻辑,将获取到的数据更新到缓存中。
这样每当前端在调用接口的时候,就能获取到最新数据,避免了直接调用老接口,直接将缓存中的数据取出并快速返回前端。这就是redis缓存和轮询更新的好处。


五、结语


经过以上三个方法优化后,接口请求耗时从3s降到了860ms,用户体验得到了显著的提升
前端菜鸟让老接口提速60%的原理与实现_java_04
这些代码都是从业务中简化后的逻辑,真实的线上ToC业务场景远比这要复杂:分段式数据存储、主从同步 读写分离、高并发同步策略等等。
每一块技术点都需要专研和实践,由于笔者是前端开发,对后端知识和技术理解有限,如有什么说的不对和不完善的地方,欢迎在评论区与我交流。