基于游标的分页接口实现代码示例


Posted in Javascript onNovember 12, 2018

前言

分页接口的实现,在偏业务的服务端开发中应该很常见,PC时代的各种表格,移动时代的各种feed流、timeline。

出于对流量的控制,或者用户的体验,大批量的数据都不会直接返回给客户端,而是通过分页接口,多次请求返回数据。

而最常用的分页接口定义大概是这样的:

router.get('/list', async ctx => {
 const { page, size } = this.query

 // ...

 ctx.body = {
 data: []
 }
})

// > curl /list?page=1&size=10

接口传入请求的页码、以及每页要请求的条数,我个人猜想这可能和大家初学的时候所接触的数据库有关吧- -,我所认识的人里边,先接触MySQL、SQL Server什么的比较多一些,以及类似的SQL语句,在查询的时候基本上就是这样的一个分页条件:

SELECT <column> FROM <table> LIMIT <offset>, <rows>

或者类似的Redis中针对zset的操作也是类似的:

> ZRANGE <key> <start> <stop>

所以可能习惯性的就使用类似的方式创建分页请求接口,让客户端提供page、size两个参数。

这样的做法并没有什么问题,在PC的表格,移动端的列表,都能够整整齐齐的展示数据。

但是这是一种比较常规的数据分页处理方式,适用于没有什么动态的过滤条件的数据。

而如果数据是实时性要求非常高的那种,存在有大量的过滤条件,或者需要和其他数据源进行对照过滤,用这样的处理方式看起来就会有些诡异。

页码+条数 的分页接口的问题

举个简单的例子,我司是有直播业务的,必然也是存在有直播列表这样的接口的。

而直播这样的数据是非常要求时效性的,类似热门列表、新人列表,这些数据的来源是离线计算好的数据,但这样的数据一般只会存储用户的标识或者直播间的标识,像直播间观看人数、直播时长、人气,这类数据必然是时效性要求很高的,不可能在离线脚本中进行处理,所以就需要接口请求时才进行获取。

而且在客户端请求的时候也是需要有一些验证的,举例一些简单的条件:

  • 确保主播正在直播
  • 确保直播内容合规
  • 检查用户与主播之间的拉黑关系

这些在离线脚本运行的时候都是没有办法做到的,因为每时每刻都在发生变化,而且数据可能没有存储在同一个位置,可能列表数据来自MySQL、过滤的数据需要用Redis中来获取、用户信息相关的数据在XXX数据库,所以这些操作不可能是一个连表查询就能够解决的,它需要在接口层来进行,拿到多份数据进行合成。

而此时采用上述的分页模式,就会出现一个很尴尬的问题。

也许访问接口的用户戾气比较重,将第一页所有的主播全部拉黑了,这就会导致,实际接口返回的数据是0条,这个就很可怕了。

let data = [] // length: 10
data = data.filter(filterBlackList)
return data // length: 0

这种情况客户端是该按照无数据来展示还是说紧接着要去请求第二页数据呢。

所以这样的分页设计在某些情况下并不能够满足我们的需求,恰巧此时发现了Redis中的一个命令:scan。

游标+条数 的分页接口实现

scan命令用于迭代Redis数据库中所有的key,但是因为数据中的key数量是不能确定的,(线上直接执行keys会被打死的),而且key的数量在你操作的过程中也是时刻在变化的,可能有的被删除,可能期间又有新增的。

所以,scan的命令要求传入一个游标,第一次调用的时候传入0即可,而scan命令的返回值则有两项,第一项是下次迭代时候所需要的游标,而第二项是一个集合,表示本次迭代返回的所有key。

以及scan是可以添加正则表达式用来迭代某些满足规则的key,例如所有temp_开头的key:scan 0 temp_*,而scan并不会真的去按照你所指定的规则去匹配key然后返回给你,它并不保证一次迭代一定会返回N条数据,有极大的可能一次迭代一条数据都不返回。

如果我们明确的需要XX条数据,那么按照游标多次调用就好了。

// 用一个递归简单的实现获取十个匹配的key
await function getKeys (pattern, oldCursor = 0, res = []) {
 const [ cursor, data ] = await redis.scan(oldCursor, pattern)

 res = res.concat(data)
 if (res.length >= 10) return res.slice(0, 10)
 else return getKeys(cursor, pattern, res)
}

await getKeys('temp_*') // length: 10

这样的使用方式给了我一些思路,打算按照类似的方式来实现分页接口。

不过将这样的逻辑放在客户端,会导致后期调整逻辑时候变得非常麻烦。需要发版才能解决,新老版本兼容也会使得后期的修改束手束脚。

所以这样的逻辑会放在服务端来开发,而客户端只需要将接口返回的游标cursor在下次接口请求时携带上即可。

大致的结构

对于客户端来说,这就是一个简单的游标存储以及使用。

但是服务端的逻辑要稍微复杂一些:

  • 首先,我们需要有一个获取数据的函数
  • 其次需要有一个用于数据过滤的函数
  • 有一个用于判断数据长度并截取的函数
function getData () {
 // 获取数据
}

function filterData () {
 // 过滤数据
}

function generatedData () {
 // 合并、生成、返回数据
}

实现

node.js 10.x已经变为了LTS,所以示例代码会使用10的一些新特性。

因为列表大概率的会存储为一个集合,类似用户标识的集合,在Redis中是set或者zset。

如果是数据源来自Redis,我的建议是在全局缓存一份完整的列表,定时更新数据,然后在接口层面通过slice来获取本次请求所需的部分数据。

P.S. 下方示例代码假设list的数据中存储的是一个唯一ID的集合,而通过这些唯一ID再从其他的数据库获取对应的详细数据。

redis> SMEMBER list
 > 1
 > 2
 > 3

mysql> SELECT * FROM user_info
+-----+---------+------+--------+
| uid | name | age | gender |
+-----+---------+------+--------+
| 1 | Niko | 18 | 1 |
| 2 | Bellic | 20 | 2 |
| 3 | Jarvis | 22 | 2 |
+-----+---------+------+--------+

列表数据在全局缓存

// 完整列表在全局的缓存
let globalList = null

async function updateGlobalData () {
 globalList = await redis.smembers('list')
}

updateGlobalData()
setInterval(updateGlobalData, 2000) // 2s 更新一次

获取数据 过滤数据函数的实现

因为上边的scan示例采用的是递归的方式来进行的,但是可读性并不是很高,所以我们可以采用生成器Generator来帮助我们实现这样的需求:

// 获取数据的函数
async function * getData (list, size) {
 const count = Math.ceil(list.length / size)

 let index = 0

 do {
 const start = index * size
 const end = start + size
 const piece = list.slice(start, end)
 
 // 查询 MySQL 获取对应的用户详细数据
 const results = await mysql.query(`
 SELECT * FROM user_info
 WHERE uid in (${piece})
 `)

 // 过滤所需要的函数,会在下方列出来
 yield filterData(results)
 } while (index++ < count)
}

同时,我们还需要有一个过滤数据的函数,这些函数可能会从一些其他数据源获取数据,用来校验列表数据的合法性,比如说,用户A有一个黑名单,里边有用户B、用户C,那么用户A访问接口时,就需要将B和C进行过滤。
抑或是我们需要判断当前某条数据的状态,例如主播是否已经关闭了直播间,推流状态是否正常,这些可能会调用其他的接口来进行验证。

// 过滤数据的函数
async function filterData (list) {
 const validList = await Promise.all(list.map(async item => {
 const [
 isLive,
 inBlackList
 ] = await Promise.all([
 http.request(`https://XXX.com/live?target=${item.id}`), redis.sismember(`XXX:black:list`, item.id)
 ])

 // 正确的状态
 if (isLive && !inBlackList) {
 return item
 }
 }))

 // 过滤无效数据
 return validList.filter(i => i)
}

最后拼接数据的函数

上述两个关键功能的函数实现后,就需要有一个用来检查、拼接数据的函数出现了。

用来决定何时给客户端返回数据,何时发起新的获取数据的请求:

async function generatedData ({
 cursor,
 size,
}) {
 let list = globalList

 // 如果传入游标,从游标处截取列表
 if (cursor) {
 // + 1 的作用在下边有提到
 list = list.slice(list.indexOf(cursor) + 1)
 }

 let results = []

 // 注意这里的是 for 循环, 而非 map、forEach 之类的
 for await (const res of getData(list, size)) {
 results = results.concat(res)

 if (results.length >= size) {
 const list = results.slice(0, size)
 return {
 list,
 // 如果还有数据,那么就需要将本次
 // 我们返回列表最后一项的 ID 作为游标,这也就解释了接口入口处的 indexOf 为什么会有一个 + 1 的操作了
 cursor: list[size - 1].id,
 }
 }
 }

 return {
 list: results,
 }
}

非常简单的一个for循环,用for循环就是为了让接口请求的过程变为串行,在第一次接口请求拿到结果后,并确定数据还不够,还需要继续获取数据进行填充,这时才会发起第二次请求,避免额外的资源浪费。

在获取到所需的数据以后,就可以直接return了,循环终止,后续的生成器也会被销毁。

以及将这个函数放在我们的接口中,就完成了整个流程的组装:

router.get('/list', async ctx => {
 const { cursor, size } = this.query

 const data = await generatedData({
 cursor,
 size,
 })

 ctx.body = {
 code: 200,
 data,
 }
})

这样的结构返回值大概是,一个list与一个cursor,类似scan的返回值,游标与数据。

客户端还可以传入可选的size来指定一次接口期望的返回条数。

不过相对于普通的page+size分页方式,这样的接口请求势必会慢一些(因为普通的分页可能一页返回不了固定条数的数据,而这个在内部可能执行了多次获取数据的操作)。

不过用于一些实时性要求强的接口上,我个人觉得这样的实现方式对用户会更友好一些。

两者之间的比较

这两种方式都是很不错的分页方式,第一种更常见一些,而第二种也不是灵丹妙药,只是在某些情况下可能会好一些。

第一种方式可能更多的会应用在B端,一些工单、报表、归档数据之类的。

而第二种可能就是C端用会比较好一些,毕竟提供给用户的产品;

在PC页面可能是一个分页表格,第一个展示10条,第二页展示出来8条,但是第三页又变成了10条,这对用户体验来说简直是个灾难。

而在移动端页面可能会相对好一些,类似无限滚动的瀑布流,但是也会出现用户加载一次出现2条数据,又加载了一次出现了8条数据,在非首页这样的情况还是勉强可以接受的,但是如果首页就出现了2条数据,啧啧。

而用第二种,游标cursor的方式能够保证每次接口返回数据都是size条,如果不够了,那就说明后边没有数据了。
对用户来说体验会更好一些。(当然了,如果列表没有什么过滤条件,就是一个普通的展示,那么建议使用第一种,没有必要添加这些逻辑处理了)

小结

当然了,这只是从服务端能够做到的一些分页相关的处理,但是这依然没有解决所有的问题,类似一些更新速度较快的列表,排行榜之类的,每秒钟的数据可能都在变化,有可能第一次请求的时候,用户A在第十名,而第二次请求接口的时候用户A在第十一名,那么两次接口都会存在用户A的记录。

针对这样的情况,客户端也要做相应的去重处理,但是这样一去重就会导致数据量的减少。
这又是一个很大的话题了,不打算展开来讲。。
一个简单的欺骗用户的方式,就是一次接口请求16条,展示10条,剩余6条存在本地下次接口拼接进去再展示。

文中如果有什么错误,或者关于分页各位有更好的实现方式、自己喜欢的方式,不妨交流一番。

参考资料

redis | scan

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对三水点靠木的支持。

Javascript 相关文章推荐
动态刷新 dorado树的js代码
Jun 12 Javascript
javascript学习笔记(十九) 节点的操作实现代码
Jun 20 Javascript
滚动图片效果 jquery实现回旋滚动效果
Jan 08 Javascript
AngularJS API之copy深拷贝详解及实例
Sep 14 Javascript
最原始的jQuery注册验证方式
Oct 11 Javascript
给easyui的datebox控件添加清空按钮的实现方法
Nov 09 Javascript
jQuery的中 is(':visible') 解析及用法(必看)
Feb 12 Javascript
简单实现jquery隔行变色
Nov 09 jQuery
js实现一个简单的MVVM框架示例
Jan 15 Javascript
Vue项目History模式404问题解决方法
Oct 31 Javascript
搭建基于express框架运行环境的方法步骤
Nov 15 Javascript
通过实例解析vuejs如何实现调试代码
Jul 16 Javascript
React Hooks的深入理解与使用
Nov 12 #Javascript
详解如何解决vue开发请求数据跨域的问题(基于浏览器的配置解决)
Nov 12 #Javascript
jQuery 操作 HTML 元素和属性的方法
Nov 12 #jQuery
微信小程序左滑删除功能开发案例详解
Nov 12 #Javascript
微信运维交互机器人的示例代码
Nov 12 #Javascript
用jQuery将JavaScript对象转换为querystring查询字符串的方法
Nov 12 #jQuery
手动下载Chrome并解决puppeteer无法使用问题
Nov 12 #Javascript
You might like
DISCUZ在win2003环境下 Unable to access ./include/common.inc.php in... 的问题终极解决方案
2011/11/21 PHP
为PHP5.4开启Zend OPCode缓存
2014/12/26 PHP
为何说PHP引用是个坑,要慎用
2018/04/02 PHP
PHP cookie与session会话基本用法实例分析
2019/11/18 PHP
javascript学习基础笔记之DOM对象操作
2011/11/03 Javascript
使用JavaScript构建JSON格式字符串实现步骤
2013/03/22 Javascript
jQuery实现用户注册的表单验证示例
2013/08/28 Javascript
运用JQuery的toggle实现网页加载完成自动弹窗
2014/03/18 Javascript
jquery+html5制作超酷的圆盘时钟表
2015/04/14 Javascript
在JavaScript中使用NaN值的方法
2015/06/05 Javascript
JS仿百度自动下拉框模糊匹配提示
2016/07/25 Javascript
Mvc提交表单的四种方法全程详解
2016/08/10 Javascript
jQuery.ajax向后台传递数组问题的解决方法
2017/05/12 jQuery
JavaScript数组,JSON对象实现动态添加、修改、删除功能示例
2018/05/26 Javascript
JavaScript简单实现关键字文本搜索高亮显示功能示例
2018/07/25 Javascript
vue proxy 的优势与使用场景实现
2020/06/15 Javascript
three.js中多线程的使用及性能测试详解
2021/01/07 Javascript
R语言 vs Python对比:数据分析哪家强?
2017/11/17 Python
2018年Python值得关注的开源库、工具和开发者(总结篇)
2018/01/04 Python
python实现图书管理系统
2018/03/12 Python
深入浅析python 中的self和cls的区别
2020/06/20 Python
python 从list中随机取值的方法
2020/11/16 Python
HTML5 canvas实现移动端上传头像拖拽裁剪效果
2016/03/14 HTML / CSS
美国眼镜网站:EyeBuyDirect
2017/04/13 全球购物
专门经营化妆刷的美国彩妆品牌:Sigma Beauty
2017/09/11 全球购物
英国健身仓库:Bodybuilding Warehouse
2019/03/06 全球购物
UNIX文件系统常用命令
2012/05/25 面试题
商场端午节活动方案
2014/01/29 职场文书
银行竞聘演讲稿范文
2014/04/23 职场文书
身边的榜样活动方案
2014/08/20 职场文书
综治维稳工作承诺书
2014/08/30 职场文书
个人催款函范文
2015/06/23 职场文书
麦田里的守望者读书笔记
2015/06/30 职场文书
合作意向书范本
2019/04/17 职场文书
MySQL中的隐藏列的具体查看
2021/09/04 MySQL
微信小程序 根据不同用户切换不同TabBar
2022/04/21 Javascript