1. 程式人生 > >基於遊標的分頁介面實現

基於遊標的分頁介面實現

``` 所以可能習慣性的就使用類似的方式建立分頁請求介面,讓客戶端提供`page`、`size`兩個引數。 這樣的做法並沒有什麼問題,在`PC`的表格,移動端的列表,都能夠整整齊齊的展示資料。 但是這是一種比較常規的資料分頁處理方式,適用於沒有什麼動態的過濾條件的資料。 而如果資料是實時性要求非常高的那種,存在有大量的過濾條件,或者需要和其他資料來源進行對照過濾,用這樣的處理方式看起來就會有些詭異。 ## 頁碼+條數 的分頁介面的問題 舉個簡單的例子,我司是有直播業務的,必然也是存在有直播列表這樣的介面的。 而直播這樣的資料是非常要求時效性的,類似熱門列表、新人列表,這些資料的來源是離線計算好的資料,但這樣的資料一般只會儲存使用者的標識或者直播間的標識,像直播間觀看人數、直播時長、人氣,這類資料必然是時效性要求很高的,不可能在離線指令碼中進行處理,所以就需要介面請求時才進行獲取。 而且在客戶端請求的時候也是需要有一些驗證的,舉例一些簡單的條件: - 確保主播正在直播 - 確保直播內容合規 - 檢查使用者與主播之間的拉黑關係 這些在離線指令碼執行的時候都是沒有辦法做到的,因為每時每刻都在發生變化,而且資料可能沒有儲存在同一個位置,可能列表資料來自`MySQL`、過濾的資料需要用`Redis`中來獲取、使用者資訊相關的資料在`XXX`資料庫,所以這些操作不可能是一個連表查詢就能夠解決的,它需要在介面層來進行,拿到多份資料進行合成。 而此時採用上述的分頁模式,就會出現一個很尷尬的問題。 也許訪問介面的使用者戾氣比較重,將第一頁所有的主播全部拉黑了,這就會導致,實際介面返回的資料是`0`條,這個就很可怕了。 ```javascript let data = [] // length: 10 data = data.filter(filterBlackList) return data // length: 0 ``` 這種情況客戶端是該按照無資料來展示還是說緊接著要去請求第二頁資料呢。 ![](https://user-images.githubusercontent.com/9568094/48297078-1f5d9e80-e4db-11e8-97bf-8de9b194b912.jpg) 所以這樣的分頁設計在某些情況下並不能夠滿足我們的需求,恰巧此時發現了`Redis`中的一個命令:`scan`。 ## 遊標+條數 的分頁介面實現 `scan`命令用於迭代`Redis`資料庫中所有的`key`,但是因為資料中的`key`數量是不能確定的,(_線上直接執行`keys`會被打死的_),而且`key`的數量在你操作的過程中也是時刻在變化的,可能有的被刪除,可能期間又有新增的。 所以,`scan`的命令要求傳入一個遊標,第一次呼叫的時候傳入`0`即可,而`scan`命令的返回值則有兩項,第一項是下次迭代時候所需要的遊標,而第二項是一個集合,表示本次迭代返回的所有`key`。 以及`scan`是可以新增正則表示式用來迭代某些滿足規則的`key`,例如所有`temp_`開頭的`key`:`scan 0 temp_*`,而`scan`並不會真的去按照你所指定的規則去匹配`key`然後返回給你,它並不保證一次迭代一定會返回`N`條資料,有極大的可能一次迭代一條資料都不返回。 如果我們明確的需要`XX`條資料,那麼按照遊標多次呼叫就好了。 ```javascript // 用一個遞迴簡單的實現獲取十個匹配的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`在下次介面請求時攜帶上即可。 ### 大致的結構 對於客戶端來說,這就是一個簡單的遊標儲存以及使用。 但是服務端的邏輯要稍微複雜一些: 1. 首先,我們需要有一個獲取資料的函式 2. 其次需要有一個用於資料過濾的函式 3. 有一個用於判斷資料長度並擷取的函式 ```javascript function getData () { // 獲取資料 } function filterData () { // 過濾資料 } function generatedData () { // 合併、生成、返回資料 } ``` ### 實現 > `node.js 10.x`已經變為了`LTS`,所以示例程式碼會使用`10`的一些新特性。 因為列表大概率的會儲存為一個集合,類似使用者標識的集合,在`Redis`中是`set`或者`zset`。 如果是資料來源來自`Redis`,我的建議是在全域性快取一份完整的列表,定時更新資料,然後在介面層面通過`slice`來獲取本次請求所需的部分資料。 _P.S. 下方示例程式碼假設`list`的資料中儲存的是一個唯一ID的集合,而通過這些唯一ID再從其他的資料庫獲取對應的詳細資料。_ ```bash 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 | +-----+---------+------+--------+ ``` #### 列表資料在全域性快取 ```javascript // 完整列表在全域性的快取 let globalList = null async function updateGlobalData () { globalList = await redis.smembers('list') } updateGlobalData() setInterval(updateGlobalData, 2000) // 2s 更新一次 ``` #### 獲取資料 過濾資料函式的實現 因為上邊的`scan`示例採用的是遞迴的方式來進行的,但是可讀性並不是很高,所以我們可以採用生成器`Generator`來幫助我們實現這樣的需求: ```javascript // 獲取資料的函式 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進行過濾。 抑或是我們需要判斷當前某條資料的狀態,例如主播是否已經關閉了直播間,推流狀態是否正常,這些可能會呼叫其他的介面來進行驗證。 ```javascript // 過濾資料的函式 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) } ``` #### 最後拼接資料的函式 上述兩個關鍵功能的函式實現後,就需要有一個用來檢查、拼接資料的函數出現了。 用來決定何時給客戶端返回資料,何時發起新的獲取資料的請求: ```javascript 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`了,迴圈終止,後續的生成器也會被銷燬。 以及將這個函式放在我們的介面中,就完成了整個流程的組裝: ```javascript 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](http://doc.redisfans.com/key/scan.html) posted @ 2018-11-12 11:06
賈順名 閱讀(...) 評論(...) 編輯 收藏 重新整理評論重新整理頁面返回頂部

公告

Copyright ©2018 賈順名