基于游標(biāo)的分頁(yè)接口實(shí)現(xiàn)代碼示例
前言
分頁(yè)接口的實(shí)現(xiàn),在偏業(yè)務(wù)的服務(wù)端開(kāi)發(fā)中應(yīng)該很常見(jiàn),PC時(shí)代的各種表格,移動(dòng)時(shí)代的各種feed流、timeline。
出于對(duì)流量的控制,或者用戶(hù)的體驗(yàn),大批量的數(shù)據(jù)都不會(huì)直接返回給客戶(hù)端,而是通過(guò)分頁(yè)接口,多次請(qǐng)求返回?cái)?shù)據(jù)。
而最常用的分頁(yè)接口定義大概是這樣的:
router.get('/list', async ctx => { const { page, size } = this.query // ... ctx.body = { data: [] } }) // > curl /list?page=1&size=10
接口傳入請(qǐng)求的頁(yè)碼、以及每頁(yè)要請(qǐng)求的條數(shù),我個(gè)人猜想這可能和大家初學(xué)的時(shí)候所接觸的數(shù)據(jù)庫(kù)有關(guān)吧- -,我所認(rèn)識(shí)的人里邊,先接觸MySQL、SQL Server什么的比較多一些,以及類(lèi)似的SQL語(yǔ)句,在查詢(xún)的時(shí)候基本上就是這樣的一個(gè)分頁(yè)條件:
SELECT <column> FROM <table> LIMIT <offset>, <rows>
或者類(lèi)似的Redis中針對(duì)zset的操作也是類(lèi)似的:
> ZRANGE <key> <start> <stop>
所以可能習(xí)慣性的就使用類(lèi)似的方式創(chuàng)建分頁(yè)請(qǐng)求接口,讓客戶(hù)端提供page、size兩個(gè)參數(shù)。
這樣的做法并沒(méi)有什么問(wèn)題,在PC的表格,移動(dòng)端的列表,都能夠整整齊齊的展示數(shù)據(jù)。
但是這是一種比較常規(guī)的數(shù)據(jù)分頁(yè)處理方式,適用于沒(méi)有什么動(dòng)態(tài)的過(guò)濾條件的數(shù)據(jù)。
而如果數(shù)據(jù)是實(shí)時(shí)性要求非常高的那種,存在有大量的過(guò)濾條件,或者需要和其他數(shù)據(jù)源進(jìn)行對(duì)照過(guò)濾,用這樣的處理方式看起來(lái)就會(huì)有些詭異。
頁(yè)碼+條數(shù) 的分頁(yè)接口的問(wèn)題
舉個(gè)簡(jiǎn)單的例子,我司是有直播業(yè)務(wù)的,必然也是存在有直播列表這樣的接口的。
而直播這樣的數(shù)據(jù)是非常要求時(shí)效性的,類(lèi)似熱門(mén)列表、新人列表,這些數(shù)據(jù)的來(lái)源是離線(xiàn)計(jì)算好的數(shù)據(jù),但這樣的數(shù)據(jù)一般只會(huì)存儲(chǔ)用戶(hù)的標(biāo)識(shí)或者直播間的標(biāo)識(shí),像直播間觀看人數(shù)、直播時(shí)長(zhǎng)、人氣,這類(lèi)數(shù)據(jù)必然是時(shí)效性要求很高的,不可能在離線(xiàn)腳本中進(jìn)行處理,所以就需要接口請(qǐng)求時(shí)才進(jìn)行獲取。
而且在客戶(hù)端請(qǐng)求的時(shí)候也是需要有一些驗(yàn)證的,舉例一些簡(jiǎn)單的條件:
- 確保主播正在直播
- 確保直播內(nèi)容合規(guī)
- 檢查用戶(hù)與主播之間的拉黑關(guān)系
這些在離線(xiàn)腳本運(yùn)行的時(shí)候都是沒(méi)有辦法做到的,因?yàn)槊繒r(shí)每刻都在發(fā)生變化,而且數(shù)據(jù)可能沒(méi)有存儲(chǔ)在同一個(gè)位置,可能列表數(shù)據(jù)來(lái)自MySQL、過(guò)濾的數(shù)據(jù)需要用Redis中來(lái)獲取、用戶(hù)信息相關(guān)的數(shù)據(jù)在XXX數(shù)據(jù)庫(kù),所以這些操作不可能是一個(gè)連表查詢(xún)就能夠解決的,它需要在接口層來(lái)進(jìn)行,拿到多份數(shù)據(jù)進(jìn)行合成。
而此時(shí)采用上述的分頁(yè)模式,就會(huì)出現(xiàn)一個(gè)很尷尬的問(wèn)題。
也許訪問(wèn)接口的用戶(hù)戾氣比較重,將第一頁(yè)所有的主播全部拉黑了,這就會(huì)導(dǎo)致,實(shí)際接口返回的數(shù)據(jù)是0條,這個(gè)就很可怕了。
let data = [] // length: 10 data = data.filter(filterBlackList) return data // length: 0
這種情況客戶(hù)端是該按照無(wú)數(shù)據(jù)來(lái)展示還是說(shuō)緊接著要去請(qǐng)求第二頁(yè)數(shù)據(jù)呢。
所以這樣的分頁(yè)設(shè)計(jì)在某些情況下并不能夠滿(mǎn)足我們的需求,恰巧此時(shí)發(fā)現(xiàn)了Redis中的一個(gè)命令:scan。
游標(biāo)+條數(shù) 的分頁(yè)接口實(shí)現(xiàn)
scan命令用于迭代Redis數(shù)據(jù)庫(kù)中所有的key,但是因?yàn)閿?shù)據(jù)中的key數(shù)量是不能確定的,(線(xiàn)上直接執(zhí)行keys會(huì)被打死的),而且key的數(shù)量在你操作的過(guò)程中也是時(shí)刻在變化的,可能有的被刪除,可能期間又有新增的。
所以,scan的命令要求傳入一個(gè)游標(biāo),第一次調(diào)用的時(shí)候傳入0即可,而scan命令的返回值則有兩項(xiàng),第一項(xiàng)是下次迭代時(shí)候所需要的游標(biāo),而第二項(xiàng)是一個(gè)集合,表示本次迭代返回的所有key。
以及scan是可以添加正則表達(dá)式用來(lái)迭代某些滿(mǎn)足規(guī)則的key,例如所有temp_開(kāi)頭的key:scan 0 temp_*,而scan并不會(huì)真的去按照你所指定的規(guī)則去匹配key然后返回給你,它并不保證一次迭代一定會(huì)返回N條數(shù)據(jù),有極大的可能一次迭代一條數(shù)據(jù)都不返回。
如果我們明確的需要XX條數(shù)據(jù),那么按照游標(biāo)多次調(diào)用就好了。
// 用一個(gè)遞歸簡(jiǎn)單的實(shí)現(xiàn)獲取十個(gè)匹配的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
這樣的使用方式給了我一些思路,打算按照類(lèi)似的方式來(lái)實(shí)現(xiàn)分頁(yè)接口。
不過(guò)將這樣的邏輯放在客戶(hù)端,會(huì)導(dǎo)致后期調(diào)整邏輯時(shí)候變得非常麻煩。需要發(fā)版才能解決,新老版本兼容也會(huì)使得后期的修改束手束腳。
所以這樣的邏輯會(huì)放在服務(wù)端來(lái)開(kāi)發(fā),而客戶(hù)端只需要將接口返回的游標(biāo)cursor在下次接口請(qǐng)求時(shí)攜帶上即可。
大致的結(jié)構(gòu)
對(duì)于客戶(hù)端來(lái)說(shuō),這就是一個(gè)簡(jiǎn)單的游標(biāo)存儲(chǔ)以及使用。
但是服務(wù)端的邏輯要稍微復(fù)雜一些:
- 首先,我們需要有一個(gè)獲取數(shù)據(jù)的函數(shù)
- 其次需要有一個(gè)用于數(shù)據(jù)過(guò)濾的函數(shù)
- 有一個(gè)用于判斷數(shù)據(jù)長(zhǎng)度并截取的函數(shù)
function getData () { // 獲取數(shù)據(jù) } function filterData () { // 過(guò)濾數(shù)據(jù) } function generatedData () { // 合并、生成、返回?cái)?shù)據(jù) }
實(shí)現(xiàn)
node.js 10.x已經(jīng)變?yōu)榱薒TS,所以示例代碼會(huì)使用10的一些新特性。
因?yàn)榱斜泶蟾怕实臅?huì)存儲(chǔ)為一個(gè)集合,類(lèi)似用戶(hù)標(biāo)識(shí)的集合,在Redis中是set或者zset。
如果是數(shù)據(jù)源來(lái)自Redis,我的建議是在全局緩存一份完整的列表,定時(shí)更新數(shù)據(jù),然后在接口層面通過(guò)slice來(lái)獲取本次請(qǐng)求所需的部分?jǐn)?shù)據(jù)。
P.S. 下方示例代碼假設(shè)list的數(shù)據(jù)中存儲(chǔ)的是一個(gè)唯一ID的集合,而通過(guò)這些唯一ID再?gòu)钠渌臄?shù)據(jù)庫(kù)獲取對(duì)應(yīng)的詳細(xì)數(shù)據(jù)。
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 | +-----+---------+------+--------+
列表數(shù)據(jù)在全局緩存
// 完整列表在全局的緩存 let globalList = null async function updateGlobalData () { globalList = await redis.smembers('list') } updateGlobalData() setInterval(updateGlobalData, 2000) // 2s 更新一次
獲取數(shù)據(jù) 過(guò)濾數(shù)據(jù)函數(shù)的實(shí)現(xiàn)
因?yàn)樯线叺膕can示例采用的是遞歸的方式來(lái)進(jìn)行的,但是可讀性并不是很高,所以我們可以采用生成器Generator來(lái)幫助我們實(shí)現(xiàn)這樣的需求:
// 獲取數(shù)據(jù)的函數(shù) 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) // 查詢(xún) MySQL 獲取對(duì)應(yīng)的用戶(hù)詳細(xì)數(shù)據(jù) const results = await mysql.query(` SELECT * FROM user_info WHERE uid in (${piece}) `) // 過(guò)濾所需要的函數(shù),會(huì)在下方列出來(lái) yield filterData(results) } while (index++ < count) }
同時(shí),我們還需要有一個(gè)過(guò)濾數(shù)據(jù)的函數(shù),這些函數(shù)可能會(huì)從一些其他數(shù)據(jù)源獲取數(shù)據(jù),用來(lái)校驗(yàn)列表數(shù)據(jù)的合法性,比如說(shuō),用戶(hù)A有一個(gè)黑名單,里邊有用戶(hù)B、用戶(hù)C,那么用戶(hù)A訪問(wèn)接口時(shí),就需要將B和C進(jìn)行過(guò)濾。
抑或是我們需要判斷當(dāng)前某條數(shù)據(jù)的狀態(tài),例如主播是否已經(jīng)關(guān)閉了直播間,推流狀態(tài)是否正常,這些可能會(huì)調(diào)用其他的接口來(lái)進(jìn)行驗(yàn)證。
// 過(guò)濾數(shù)據(jù)的函數(shù) 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) ]) // 正確的狀態(tài) if (isLive && !inBlackList) { return item } })) // 過(guò)濾無(wú)效數(shù)據(jù) return validList.filter(i => i) }
最后拼接數(shù)據(jù)的函數(shù)
上述兩個(gè)關(guān)鍵功能的函數(shù)實(shí)現(xiàn)后,就需要有一個(gè)用來(lái)檢查、拼接數(shù)據(jù)的函數(shù)出現(xiàn)了。
用來(lái)決定何時(shí)給客戶(hù)端返回?cái)?shù)據(jù),何時(shí)發(fā)起新的獲取數(shù)據(jù)的請(qǐng)求:
async function generatedData ({ cursor, size, }) { let list = globalList // 如果傳入游標(biāo),從游標(biāo)處截取列表 if (cursor) { // + 1 的作用在下邊有提到 list = list.slice(list.indexOf(cursor) + 1) } let results = [] // 注意這里的是 for 循環(huán), 而非 map、forEach 之類(lèi)的 for await (const res of getData(list, size)) { results = results.concat(res) if (results.length >= size) { const list = results.slice(0, size) return { list, // 如果還有數(shù)據(jù),那么就需要將本次 // 我們返回列表最后一項(xiàng)的 ID 作為游標(biāo),這也就解釋了接口入口處的 indexOf 為什么會(huì)有一個(gè) + 1 的操作了 cursor: list[size - 1].id, } } } return { list: results, } }
非常簡(jiǎn)單的一個(gè)for循環(huán),用for循環(huán)就是為了讓接口請(qǐng)求的過(guò)程變?yōu)榇校诘谝淮谓涌谡?qǐng)求拿到結(jié)果后,并確定數(shù)據(jù)還不夠,還需要繼續(xù)獲取數(shù)據(jù)進(jìn)行填充,這時(shí)才會(huì)發(fā)起第二次請(qǐng)求,避免額外的資源浪費(fèi)。
在獲取到所需的數(shù)據(jù)以后,就可以直接return了,循環(huán)終止,后續(xù)的生成器也會(huì)被銷(xiāo)毀。
以及將這個(gè)函數(shù)放在我們的接口中,就完成了整個(gè)流程的組裝:
router.get('/list', async ctx => { const { cursor, size } = this.query const data = await generatedData({ cursor, size, }) ctx.body = { code: 200, data, } })
這樣的結(jié)構(gòu)返回值大概是,一個(gè)list與一個(gè)cursor,類(lèi)似scan的返回值,游標(biāo)與數(shù)據(jù)。
客戶(hù)端還可以傳入可選的size來(lái)指定一次接口期望的返回條數(shù)。
不過(guò)相對(duì)于普通的page+size分頁(yè)方式,這樣的接口請(qǐng)求勢(shì)必會(huì)慢一些(因?yàn)槠胀ǖ姆猪?yè)可能一頁(yè)返回不了固定條數(shù)的數(shù)據(jù),而這個(gè)在內(nèi)部可能執(zhí)行了多次獲取數(shù)據(jù)的操作)。
不過(guò)用于一些實(shí)時(shí)性要求強(qiáng)的接口上,我個(gè)人覺(jué)得這樣的實(shí)現(xiàn)方式對(duì)用戶(hù)會(huì)更友好一些。
兩者之間的比較
這兩種方式都是很不錯(cuò)的分頁(yè)方式,第一種更常見(jiàn)一些,而第二種也不是靈丹妙藥,只是在某些情況下可能會(huì)好一些。
第一種方式可能更多的會(huì)應(yīng)用在B端,一些工單、報(bào)表、歸檔數(shù)據(jù)之類(lèi)的。
而第二種可能就是C端用會(huì)比較好一些,畢竟提供給用戶(hù)的產(chǎn)品;
在PC頁(yè)面可能是一個(gè)分頁(yè)表格,第一個(gè)展示10條,第二頁(yè)展示出來(lái)8條,但是第三頁(yè)又變成了10條,這對(duì)用戶(hù)體驗(yàn)來(lái)說(shuō)簡(jiǎn)直是個(gè)災(zāi)難。
而在移動(dòng)端頁(yè)面可能會(huì)相對(duì)好一些,類(lèi)似無(wú)限滾動(dòng)的瀑布流,但是也會(huì)出現(xiàn)用戶(hù)加載一次出現(xiàn)2條數(shù)據(jù),又加載了一次出現(xiàn)了8條數(shù)據(jù),在非首頁(yè)這樣的情況還是勉強(qiáng)可以接受的,但是如果首頁(yè)就出現(xiàn)了2條數(shù)據(jù),嘖嘖。
而用第二種,游標(biāo)cursor的方式能夠保證每次接口返回?cái)?shù)據(jù)都是size條,如果不夠了,那就說(shuō)明后邊沒(méi)有數(shù)據(jù)了。
對(duì)用戶(hù)來(lái)說(shuō)體驗(yàn)會(huì)更好一些。(當(dāng)然了,如果列表沒(méi)有什么過(guò)濾條件,就是一個(gè)普通的展示,那么建議使用第一種,沒(méi)有必要添加這些邏輯處理了)
小結(jié)
當(dāng)然了,這只是從服務(wù)端能夠做到的一些分頁(yè)相關(guān)的處理,但是這依然沒(méi)有解決所有的問(wèn)題,類(lèi)似一些更新速度較快的列表,排行榜之類(lèi)的,每秒鐘的數(shù)據(jù)可能都在變化,有可能第一次請(qǐng)求的時(shí)候,用戶(hù)A在第十名,而第二次請(qǐng)求接口的時(shí)候用戶(hù)A在第十一名,那么兩次接口都會(huì)存在用戶(hù)A的記錄。
針對(duì)這樣的情況,客戶(hù)端也要做相應(yīng)的去重處理,但是這樣一去重就會(huì)導(dǎo)致數(shù)據(jù)量的減少。
這又是一個(gè)很大的話(huà)題了,不打算展開(kāi)來(lái)講。。
一個(gè)簡(jiǎn)單的欺騙用戶(hù)的方式,就是一次接口請(qǐng)求16條,展示10條,剩余6條存在本地下次接口拼接進(jìn)去再展示。
文中如果有什么錯(cuò)誤,或者關(guān)于分頁(yè)各位有更好的實(shí)現(xiàn)方式、自己喜歡的方式,不妨交流一番。
參考資料
總結(jié)
以上就是這篇文章的全部?jī)?nèi)容了,希望本文的內(nèi)容對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,如果有疑問(wèn)大家可以留言交流,謝謝大家對(duì)腳本之家的支持。
相關(guān)文章
用Electron寫(xiě)個(gè)帶界面的nodejs爬蟲(chóng)的實(shí)現(xiàn)方法
這篇文章主要介紹了用Electron寫(xiě)個(gè)帶界面的nodejs爬蟲(chóng)的實(shí)現(xiàn)方法,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2019-01-01nodejs個(gè)人博客開(kāi)發(fā)第三步 載入頁(yè)面
這篇文章主要為大家詳細(xì)介紹了nodejs個(gè)人博客開(kāi)發(fā)的載入頁(yè)面,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-04-04Node.js中如何合并兩個(gè)復(fù)雜對(duì)象詳解
下面這篇文章主要給大家介紹了在Node.js中如何合并兩個(gè)復(fù)雜對(duì)象的方法,文中給出了詳細(xì)的示例代碼,相信對(duì)大家的理解和學(xué)習(xí)具有一定的參考借鑒價(jià)值,有需要的朋友可以參考,下面來(lái)一起看看吧。2016-12-12在Linux上用forever實(shí)現(xiàn)Node.js項(xiàng)目自啟動(dòng)
在一臺(tái)計(jì)算機(jī)上手動(dòng)跑Node項(xiàng)目簡(jiǎn)單,node xx.js就搞定了,想讓Node項(xiàng)目后臺(tái)運(yùn)行,雖然不能直接用node命令搞定,但是在安裝了forever這個(gè)包以后,還是很輕松的。不過(guò)要是在遠(yuǎn)程服務(wù)器上構(gòu)建Node項(xiàng)目,如果沒(méi)法自啟動(dòng),一旦服務(wù)器重啟,那就麻煩了。2014-07-07關(guān)于npm主版本升級(jí)及其相關(guān)知識(shí)點(diǎn)總結(jié)
npm是Node.js默認(rèn)的包管理器,以javascript?編寫(xiě)的軟件包管理系統(tǒng)用于分享和使用代碼,下面這篇文章主要給大家介紹了關(guān)于npm主版本升級(jí)及其相關(guān)知識(shí)點(diǎn)總結(jié)的相關(guān)資料,需要的朋友可以參考下2022-12-12node基于puppeteer模擬登錄抓取頁(yè)面的實(shí)現(xiàn)
本篇文章主要介紹了node基于puppeteer模擬登錄抓取頁(yè)面的實(shí)現(xiàn),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-05-05