国产探花免费观看_亚洲丰满少妇自慰呻吟_97日韩有码在线_资源在线日韩欧美_一区二区精品毛片,辰东完美世界有声小说,欢乐颂第一季,yy玄幻小说排行榜完本

首頁 > 編程 > JavaScript > 正文

基于游標的分頁接口實現代碼示例

2019-11-19 12:32:06
字體:
來源:轉載
供稿:網友

前言

分頁接口的實現,在偏業務的服務端開發中應該很常見,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: 10data = 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條數據,那么按照游標多次調用就好了。

// 用一個遞歸簡單的實現獲取十個匹配的keyawait 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 > 3mysql> SELECT * FROM user_info+-----+---------+------+--------+| uid | name | age | gender |+-----+---------+------+--------+| 1 | Niko | 18 | 1 || 2 | Bellic | 20 | 2 || 3 | Jarvis | 22 | 2 |+-----+---------+------+--------+

列表數據在全局緩存

// 完整列表在全局的緩存let globalList = nullasync 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

總結

以上就是這篇文章的全部內容了,希望本文的內容對大家的學習或者工作具有一定的參考學習價值,如果有疑問大家可以留言交流,謝謝大家對武林網的支持。

發表評論 共有條評論
用戶名: 密碼:
驗證碼: 匿名發表
主站蜘蛛池模板: 大石桥市| 吐鲁番市| 乌拉特前旗| 开化县| 东莞市| 沂源县| 淳安县| 文化| 昌乐县| 论坛| 集安市| 东乡族自治县| 东乌珠穆沁旗| 彭水| 阜城县| 包头市| 加查县| 木里| 泽库县| 尉氏县| 青阳县| 温州市| 海兴县| 陆良县| 天等县| 枣强县| 呼图壁县| 沙田区| 松江区| 天镇县| 登封市| 通江县| 石河子市| 安徽省| 黄梅县| 阳朔县| 茌平县| 莱州市| 加查县| 泸溪县| 阳朔县|