前言

分页接口的实现,在偏业务的服务端开发中应该很常见,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

总结

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

基于游标的分页接口实现代码示例的更多相关文章

  1. Html5页面二次分享的实现

    这篇文章主要介绍了Html5页面二次分享的实现的相关资料,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  2. HTML5跳转小程序wx-open-launch-weapp的示例代码

    这篇文章主要介绍了HTML5跳转小程序wx-open-launch-weapp的相关知识,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  3. ios – UIScrollView的平滑自定义分页

    我在UIScrollView中有两个(可能更多)视图,并希望使用分页.当我尝试使用UIScrollView的默认分页选项时出现问题,因为视图具有不同的宽度,无法正确分页.所以我已经实现了一个有效的自定义分页代码.但是,当滚动较慢时,它不会按预期运行.(它回到没有动画的原始位置.)以下是我目前通过uiscrollviewdelegate进行自定义分页的方法我想要的是:是)我有的:解决方法尝试下面的自

  4. ios – 使用子视图控制器分页滚动视图

    使用分页实现水平滚动视图的最佳做法是什么,每页有一个视图控制器?由于iOS5具有用于视图控制器容器/包含的API,因此PageControl示例仍然是实现此功能的最佳方式吗?

  5. ios – UITextField secureEntry错位了游标

    我在UITextField上切换secureEntry属性时遇到问题.切换属性时,将调整字符大小,但光标位于错误的位置:解决方法这是我的解决方法:禁用然后启用UITextField也有帮助,但它会突然将我的软键盘从一个更改为另一个

  6. ios – Objective C接口,委托和协议

    所以我试图围绕Objctive-C接口,代理和协议.所以我有一个问题:委托是否必须在单独的文件中,或者它是否是您班级中定义的方法?协议就像java接口吗?

  7. 可可触摸 – 启用预览和分页的UICollectionView

    在AppStore中显示搜索结果时,我正在尝试模仿苹果公司的功能.(参考:http://searchengineland.com/apple-app-search-shows-only-one-result-at-a-time-133818)它显示像卡中的详细应用程序信息,并且它被分页.当中间的一个活动卡片和滚动视图的分页行为仍然完整时,我被困在如何使上一张和第二张卡片显示.我已经尝试使用UICo

  8. ios – 启用了内容插入的UIScrollView分页工作很奇怪

    我创建了具有内容插入的UIScrollView.第一次,scrollView.contentOffset.x为-160.0但是奇怪的问题是当我点击scrollView(黄色区域)时,内容偏移x值将重置为0并显示为这样.我尝试过几次,但是点击滚动视图会将内容偏移量重置为0.我该如何防止这种情况?解决方法UIScrollView分页通过滚动与scrollView宽度相同的页面(在您的情况下为480个宽

  9. ios – watchOS错误:控制器的接口描述中的未知属性

    解决方法创建IBOutlet作为WKInterfacePicker的属性,您将不会收到消息.

  10. 泛型 – MonoTouch和支持变体通用接口

    如果是这样,MonoTouch中针对这种情况的推荐解决方法是什么?解决方法这实际上取决于编译器而不是Mono版本.IOW有些东西可能适用于Mono2.10而不适用于MonoTouch6.x.当前版本的MonoTouch附带了smcs编译器和基于2.1的配置文件.较新的功能,如协方差,需要一个完整的4.0编译器和运行时.未来版本的MonoTouch将提供4.0/4.5运行时和编译器.

随机推荐

  1. Error: Cannot find module ‘node:util‘问题解决

    控制台 安装 Vue-Cli 最后一步出现 Error: Cannot find module 'node:util' 问题解决方案1.问题C:\Windows\System32>cnpm install -g @vue/cli@4.0.3internal/modules/cjs/loader.js:638 throw err; &nbs

  2. yarn的安装和使用(全网最详细)

    一、yarn的简介:Yarn是facebook发布的一款取代npm的包管理工具。二、yarn的特点:速度超快。Yarn 缓存了每个下载过的包,所以再次使用时无需重复下载。 同时利用并行下载以最大化资源利用率,因此安装速度更快。超级安全。在执行代码之前,Yarn 会通过算法校验每个安装包的完整性。超级可靠。使用详细、简洁的锁文件格式和明确的安装算法,Yarn 能够保证在不同系统上无差异的工作。三、y

  3. 前端环境 本机可切换node多版本 问题源头是node使用的高版本

    前言投降投降 重头再来 重装环境 也就分分钟的事 偏要折腾 这下好了1天了 还没折腾出来问题的源头是node 使用的高版本 方案那就用 本机可切换多版本最终问题是因为nodejs的版本太高,导致的node-sass不兼容问题,我的node是v16.14.0的版本,项目中用了"node-sass": "^4.7.2"版本,无法匹配当前的node版本根据文章的提

  4. nodejs模块学习之connect解析

    这篇文章主要介绍了nodejs模块学习之connect解析,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  5. nodejs npm package.json中文文档

    这篇文章主要介绍了nodejs npm package.json中文文档,本文档中描述的很多行为都受npm-config(7)的影响,需要的朋友可以参考下

  6. 详解koa2学习中使用 async 、await、promise解决异步的问题

    这篇文章主要介绍了详解koa2学习中使用 async 、await、promise解决异步的问题,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  7. Node.js编写爬虫的基本思路及抓取百度图片的实例分享

    这篇文章主要介绍了Node.js编写爬虫的基本思路及抓取百度图片的实例分享,其中作者提到了需要特别注意GBK转码的转码问题,需要的朋友可以参考下

  8. CentOS 8.2服务器上安装最新版Node.js的方法

    这篇文章主要介绍了CentOS 8.2服务器上安装最新版Node.js的方法,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  9. node.js三个步骤实现一个服务器及Express包使用

    这篇文章主要介绍了node.js三个步骤实现一个服务器及Express包使用,文章通过新建一个文件展开全文内容,具有一定的参考价值,需要的小伙伴可以参考一下

  10. node下使用UglifyJS压缩合并JS文件的方法

    下面小编就为大家分享一篇node下使用UglifyJS压缩合并JS文件的方法,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧

返回
顶部