分頁接口的實現,在偏業務的服務端開發中應該很常見,PC時代的各類表格,移動時代的各類feed
流、timeline
。javascript
出於對流量的控制,或者用戶的體驗,大批量的數據都不會直接返回給客戶端,而是經過分頁接口,屢次請求返回數據。html
而最經常使用的分頁接口定義大概是這樣的:java
router.get('/list', async ctx => { const { page, size } = this.query // ... ctx.body = { data: [] } }) // > curl /list?page=1&size=10
接口傳入請求的頁碼、以及每頁要請求的條數,我我的猜測這可能和你們初學的時候所接觸的數據庫有關吧- -,我所認識的人裏邊,先接觸MySQL
、SQL Server
什麼的比較多一些,以及相似的SQL
語句,在查詢的時候基本上就是這樣的一個分頁條件:node
SELECT <column> FROM <table> LIMIT <offset>, <rows>
或者相似的Redis
中針對zset
的操做也是相似的:mysql
> ZRANGE <key> <start> <stop>
因此可能習慣性的就使用相似的方式建立分頁請求接口,讓客戶端提供page
、size
兩個參數。
這樣的作法並無什麼問題,在PC
的表格,移動端的列表,都可以整整齊齊的展現數據。正則表達式
可是這是一種比較常規的數據分頁處理方式,適用於沒有什麼動態的過濾條件的數據。
而若是數據是實時性要求很是高的那種,存在有大量的過濾條件,或者須要和其餘數據源進行對照過濾,用這樣的處理方式看起來就會有些詭異。redis
舉個簡單的例子,我司是有直播業務的,必然也是存在有直播列表這樣的接口的。
而直播這樣的數據是很是要求時效性的,相似熱門列表、新人列表,這些數據的來源是離線計算好的數據,但這樣的數據通常只會存儲用戶的標識或者直播間的標識,像直播間觀看人數、直播時長、人氣,這類數據必然是時效性要求很高的,不可能在離線腳本中進行處理,因此就須要接口請求時才進行獲取。sql
並且在客戶端請求的時候也是須要有一些驗證的,舉例一些簡單的條件:數據庫
這些在離線腳本運行的時候都是沒有辦法作到的,由於每時每刻都在發生變化,並且數據可能沒有存儲在同一個位置,可能列表數據來自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
條存在本地下次接口拼接進去再展現。
文中若是有什麼錯誤,或者關於分頁各位有更好的實現方式、本身喜歡的方式,不妨交流一番。