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


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 相关文章推荐
十个优秀的Ajax/Javascript实例网站收集
Mar 31 Javascript
js比较和逻辑运算符的介绍
Mar 10 Javascript
javascript制作的cookie封装及使用指南
Jan 02 Javascript
JavaScript中原型和原型链详解
Feb 11 Javascript
60行js代码实现俄罗斯方块
Mar 31 Javascript
javascript实现连续赋值
Aug 10 Javascript
详解Node全局变量global模块
Sep 28 Javascript
AngularJs用户输入动态模板XSS攻击示例详解
Apr 21 Javascript
Javascript获取某个月的天数
May 30 Javascript
微信小程序登录按钮遮罩浮层效果的实现方法
Dec 16 Javascript
微信小程序实现多行文字超出部分省略号显示功能
Oct 23 Javascript
Vue实现点击按钮复制文本内容的例子
Nov 09 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
url decode problem 解决方法
2011/12/26 PHP
php计算年龄精准到年月日
2015/11/17 PHP
laravel5创建service provider和facade的方法详解
2016/07/26 PHP
php中类和对象:静态属性、静态方法
2017/04/09 PHP
js类中获取外部函数名的方法
2007/08/19 Javascript
jquery pagination插件实现无刷新分页代码
2009/10/13 Javascript
页面中iframe相互传值传参
2009/12/13 Javascript
一段实现页面上的图片延时加载的js代码
2010/02/11 Javascript
圣诞节Merry Christmas给博客添加浪漫的下雪效果基于jquery实现
2012/12/27 Javascript
使用jQuery快速解决input中placeholder值在ie中无法支持的问题
2014/01/02 Javascript
javascript类型转换使用方法
2014/02/08 Javascript
Js保留小数点的4种效果实现代码分享
2014/04/12 Javascript
使用jquery菜单插件HoverTree仿京东无限级菜单
2014/12/18 Javascript
详细解读JavaScript的跨浏览器事件处理
2015/08/12 Javascript
跟我学习javascript的var预解析与函数声明提升
2015/11/16 Javascript
JS作为值的函数用法示例
2016/06/20 Javascript
jquery实现界面无刷新加载登陆注册
2016/07/30 Javascript
纯JS实现只能输入数字的简单代码
2017/06/21 Javascript
11行JS代码制作二维码生成功能
2018/03/09 Javascript
VUE兄弟组件传值操作实例分析
2019/10/26 Javascript
微信小程序实现弹框效果
2020/05/26 Javascript
vue 扩展现有组件的操作
2020/08/14 Javascript
vue3.0实现点击切换验证码(组件)及校验
2020/11/18 Vue.js
python实现C4.5决策树算法
2018/08/29 Python
Python使用ctypes调用C/C++的方法
2019/01/29 Python
Python 内置变量和函数的查看及说明介绍
2019/12/25 Python
Python 解码Base64 得到码流格式文本实例
2020/01/09 Python
Python文本文件的合并操作方法代码实例
2020/03/31 Python
Window版下在Jupyter中编写TensorFlow的环境搭建
2020/04/10 Python
Python HTMLTestRunner库安装过程解析
2020/05/25 Python
Volcom英国官方商店:美国殿堂级滑板、冲浪、滑雪服装品牌
2019/03/13 全球购物
文明家庭先进事迹材
2014/01/27 职场文书
学校安全教育制度
2014/01/31 职场文书
物业工程部主管岗位职责
2015/04/16 职场文书
小学校园广播稿
2015/08/18 职场文书
MySQL安装后默认自带数据库的作用详解
2021/04/27 MySQL