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


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 相关文章推荐
asp.net刷新本页面的六种方法总结
Jan 07 Javascript
收集json解析的四种方法分享
Jan 17 Javascript
jQuery新的事件绑定机制on()示例应用
Jul 18 Javascript
JS实现往下不断流动网页背景的方法
Feb 27 Javascript
jQuery实现冻结表格行和列
Apr 29 Javascript
jQuery自动添加表单项的方法
Jul 13 Javascript
javascript针对不确定函数的执行方法
Dec 16 Javascript
微信小程序使用video组件播放视频功能示例【附源码下载】
Dec 08 Javascript
JS块级作用域和私有变量实例分析
May 11 Javascript
layui 弹出删除确认界面的实例
Sep 06 Javascript
JavaScript实现网页下拉菜单效果
Nov 20 Javascript
javascript函数式编程基础
Sep 15 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
1982年日本摄影师镜头下的中国孩子 那无忧无虑的童年
2020/03/12 杂记
使用JSON实现数据的跨域传输的php代码
2011/12/20 PHP
php基于session实现数据库交互的类实例
2015/08/03 PHP
通过源码解析Laravel的依赖注入
2018/01/22 PHP
js null,undefined,字符串小结
2010/08/21 Javascript
Js-$.extend扩展方法使方法参数更灵活
2013/01/15 Javascript
jquery 实现上下滚动效果示例代码
2013/08/09 Javascript
js正则表达式匹配数字字母下划线等
2015/04/14 Javascript
jquery实现简单实用的打分程序实例
2015/07/23 Javascript
详解有关easyUI的拖动操作中droppable,draggable用法例子
2017/06/03 Javascript
js实现首屏延迟加载实现方法 js实现多屏单张图片延迟加载效果
2017/07/17 Javascript
React Native 搭建开发环境的方法步骤
2017/10/30 Javascript
详解在vue-test-utils中mock全局对象
2018/11/07 Javascript
JS中的算法与数据结构之集合(Set)实例详解
2019/08/20 Javascript
JS实现滑动导航效果
2020/01/14 Javascript
Node登录权限验证token验证实现的方法示例
2020/05/25 Javascript
Vue + Element-ui的下拉框el-select获取额外参数详解
2020/08/14 Javascript
javascript实现搜索筛选功能实例代码
2020/11/12 Javascript
[45:10]NB vs Liquid Supermajor小组赛 A组胜者组决赛 BO3 第二场 6.2
2018/06/04 DOTA
python网页请求urllib2模块简单封装代码
2014/02/07 Python
Python多线程编程(五):死锁的形成
2015/04/05 Python
Python实现自动登录百度空间的方法
2017/06/10 Python
Python实现的堆排序算法示例
2018/04/29 Python
Django中反向生成models.py的实例讲解
2018/05/30 Python
Python开发入门——迭代的基本使用
2020/09/03 Python
Python虚拟环境virtualenv创建及使用过程图解
2020/12/08 Python
webView加载html图片遇到的问题解决
2019/10/08 HTML / CSS
HTML5开发动态音频图的实现
2020/07/02 HTML / CSS
Speedo速比涛中国官方网站:全球领先泳装运动品牌
2018/04/24 全球购物
娇韵诗Clarins意大利官方网站:法国天然护肤品牌
2020/03/11 全球购物
将"引用"作为函数返回值类型的格式、好处和需要遵守的规则
2016/02/09 面试题
班级标语大全
2014/06/21 职场文书
万能检讨书开头与结尾怎么写
2015/02/17 职场文书
Python操作CSV格式文件的方法大全
2021/07/15 Python
厉害!这是Redis可视化工具最全的横向评测
2021/07/15 Redis
动漫APP软件排行榜前十名,半次元上榜,第一款由腾讯公司推出
2022/03/18 杂记