当前位置: 首页 > 后端技术 > Node.js

基于游标的分页接口实现

时间:2023-04-03 12:05:24 Node.js

分页接口在面向业务的服务器开发中应该是很常见的,比如PC时代的各种表格,移动时代的各种提要流和时间线。由于流量控制或用户体验等原因,大量数据不会直接返回给客户端,而是通过分页接口多次请求返回。最常用的分页接口定义大概是这样的:router.get('/list',asyncctx=>{const{page,size}=this.query//...ctx.body={data:[]}})//>curl/list?page=1&size=10接口传入请求的页码,以及每页请求的条目数,个人猜测这可能和你接触到的数据库有关和你初学的时候--,在我认识的人中,第一次接触MySQL、SQLServer、类似SQL语句的人比较多。查询的时候,基本就是这样的分页条件:SELECTFROM

LIMIT,或者Redis中zset的类似操作也类似:>ZRANGE所以大家可能会习惯性的用类似的方法创建一个分页请求接口,让客户端提供page和size两个参数。这种方法没有错。数据在PC端以表格形式整齐展示,在移动端以列表形式呈现。但这是一种比较常规的数据分页处理方式,适用于没有任何动态过滤条件的数据。但是,如果数据对实时性要求非常高,过滤条件较多,或者需要与其他数据源进行比较过滤,使用这种处理方式会显得有些奇怪。页码+条目数的分页界面问题,举个简单的例子。我们公司有直播业务,所以一定要有直播列表这样的接口。但直播等数据的时效性很强,类似于热门榜单、新人榜单。这些数据的来源都是离线计算出来的数据,但是这样的数据一般只存储用户id或者直播间id,比如直??播间的观看人数、直播时长、热度,这样的数据对时效性要求一定很高,而且是无法在离线脚本中处理,所以只有在请求接口时才需要获取。而且在客户端请求的时候,还需要进行一些验证。比如一些简单的条件:保证主播在直播,保证直播内容合规,检查用户和主播的黑名单关系。这些在离线脚本运行时是不可能的。可以做到,因为每时每刻都在变化,而且数据不一定存放在同一个位置。可能列表数据来自MySQL,过滤后的数据需要在Redis中获取,而用户信息相关的数据在XXX数据库中,所以这些操作无法通过表链接查询来解决。需要在接口层进行,获取多个数据进行合成。这时候如果采用上述的分页方式,就会出现一个很尴尬的问题。可能访问接口的用户比较敌对,屏蔽了首页的所有锚点,导致实际接口返回0数据,很恐怖。letdata=[]//length:10data=data.filter(filterBlackList)returndata//length:0在这种情况下,客户端应该将其显示为无数据还是应该立即请求第二页数据。所以这样的分页设计在某些情况下不能满足我们的需求。正好这时候发现了Redis中的一个命令:scan。游标的分页界面+项数实现了scan命令遍历Redis数据库中的所有key,但是由于无法确定数据中的key个数,(_直接在线执行key会kill_),并且keys的数量也在你操作的过程中一直在变化,有的可能会删除,有的可能会在期间添加。所以scan命令需要传入一个cursor,第一次调用时传入0即可,scan命令的返回值有两项,第一项是需要的cursor下一次迭代,第二个Item是表示本次迭代返回的所有键的集合。而scan可以加正则表达式迭代某些符合规则的key,比如所有temp_开头的key:scan0temp_*,scan不会真正按照你指定的规则匹配到key然后返回给你,它确实不保证1次迭代返回N条数据,很有可能1次迭代不返回1条数据。如果我们明明需要XX条数据,那么根据游标调用多次就可以了。//使用递归简单实现获取十个匹配的keyawait函数if(res.length>=10)returnres.slice(0,10)elsereturngetKeys(cursor,pattern,res)}awaitgetKeys('temp_*')//length:10这样的用法给了我一些想法,我打算用类似的方式实现分页接口。但是,把这样的逻辑放在客户端,后面调整逻辑会很麻烦。需要发布才能解决问题,新旧版本的兼容也会给后期的修改带来困难。所以,这样的逻辑会在服务端开发,客户端只需要在下次接口请求时携带接口返回的游标即可。一般结构是一个简单的游标存储和供客户端使用。但是服务端的逻辑稍微复杂一点:首先,我们需要一个获取数据的函数;其次,我们需要一个数据过滤的功能;有判断数据长度并拦截的函数functiongetData(){//获取数据}functionfilterData(){//过滤数据}functiongeneratedData(){//合并,生成,返回数据}实现节点。js10.x已经变成了LTS,所以示例代码会用到10的一些新特性。因为列表很大概率会存储为一个集合,类似于用户ID的集合,在Redis中是set或zset。如果数据源来自Redis,我的建议是全局缓存一个完整的列表,定时更新数据,然后在接口层面使用slice获取本次请求需要的部分数据。附言下面的示例代码假设列表数据中存储了一组唯一ID,通过这些唯一ID从其他数据库获取对应的详细数据。redis>SMEMBERlist>1>2>3mysql>SELECT*FROMuser_info+-----+--------+-----+--------+|uid|姓名|年龄|性别|+-----+---------+-----+------+|1|妮可|18|1||2|贝利奇|20|2||3|贾维斯|22|2|+-----+--------+-----+--------+list全局缓存中的数据//完整列表在全局缓存中letglobalList=nullasyncfunctionupdateGlobalData(){globalList=awaitredis.smembers('list')}updateGlobalData()setInterval(updateGlobalData,2000)//2s更新一次获取数据过滤数据函数的实现是因为扫描实例上面是以递归的方式进行的,但是可读性不是很高,所以我们可以使用生成器Generator来帮助我们实现这样的需求://获取数据的函数asyncfunction*getData(list,size){constcount=Math.ceil(list.length/size)letindex=0do{conststart=index*sizeconstend=start+sizeconstpiece=list.slice(start,end)//查询MySQL获取对应的用户明细dataconstresults=awaitmysql.query(`SELECT*FROMuser_infoWHEREuidin(${piece})`)//下面会列出过滤需要的函数yieldfilterData(results)}while(index++{const[isLive,inBlackList]=awaitPromise.all([http.request(`https://XXX.com/live?target=${item.id}`),redis.sismember(`XXX:black:list`,item.id)])//正确的状态if(isLive&&!inBlackList){returnitem}}))//过滤无效数据returnvalidList.filter(i=>i)}最后是拼接数据的功能上面两个关键的功能实现之后,还需要一个校验和拼接数据的功能。用来决定什么时候返回数据给客户端,什么时候发起新的获取数据的请求:asyncfunctiongeneratedData({cursor,size,}){letlist=globalList//如果传入了cursor,则截取listfromthecursorif(cursor){//+1的作用在下面提到list=list.slice(list.indexOf(cursor)+1)}letresults=[]//注意这里是for循环,不是map,forEachlikeforawait(constresofgetData(list,size)){results=results.concat(res)if(results.length>=size){constlist=results.slice(0,size)return{list,//如果还有数据,那么我们就需要用我们这次返回的列表中最后一项的ID作为游标,这也解释了为什么在接口入口游标处的indexOf处会有+1操作:list[size-1].id,}}}return{list:results,}}是一个非常简单的for循环。for循环用于使接口请求过程串行化。第一次接口请求得到结果后,判断数据不够,需要继续获取数据进行填充,才会发起第二次请求,避免额外的资源浪费。获取到需要的数据后,可以直接返回,循环终止,后面的生成器也会被销毁。而把这个函数放到我们的接口中就完成了整个流程的组装:})ctx.body={code:200,data,}})这样的结构返回值大概是一个list和一个cursor,类似于scan、cursor和data的返回值。客户端还可以传入一个可选的大小来指定接口一次返回的预期记录数。但是相对于普通的page+size的分页方式,这样的接口请求势必会更慢(因为普通的分页可能无法每页返回固定数量的数据,而这可能会在内部进行多次操作获取数据).不过对于一些实时性要求比较强的接口,个人认为这种实现方式会更加人性化。两种方法比较两种方法都是非常好的分页方法,第一种比较常用,第二种也不是万能的,但在某些情况下可能更好。第一种方式可能更多的应用在B端,比如一些工单、报表、归档数据等。第二种可能还是用C端比较好,毕竟是提供给用户的产品;在PC页面上,可能是一个分页表,第一页显示10条,第二页显示8条,但是第三页也显示。变成了10,这对用户体验来说是一场灾难。移动端,页面可能相对好一些,类似无限滚动的瀑布,但是用户加载一次也是2条数据,再次加载后是8条数据,勉强可以接受非主页的情况。是的,但是如果首页有2条数据,啧啧。使用第二种方法,游标方法可以保证每次接口返回的数据都是sizebar。如果不够,说明后面没有数据。用户体验会更好。(当然,如果列表没有过滤条件,只是普通展示,那么推荐使用第一种,不需要添加这些逻辑处理)总结当然,这只是一些分页相关的可以从服务端进行处理,但是这仍然不能解决所有的问题,像一些更新速度比较快的榜单,排行榜等,数据可能每秒都在变化,有可能在第一次请求的时候,用户A在第10位,第二次请求接口时用户A在第11位,那么在两个接口中都会有用户A的记录。针对这样的情况,客户端也应该进行相应的去重处理,但是这样的去重会导致数据量的减少。这是另一个大话题,我不打算展开。.一个简单的欺骗用户的方法就是一次请求16个词条,显示10个词条,剩下的6个词条保存在本地界面,下次拼接显示。如果文章中有什么错误,或者大家有更好的分页实现方式,或者自己喜欢的方式,不妨一起交流。参考资料扫描