基於游標的分頁介面實現

来源:https://www.cnblogs.com/jiasm/archive/2018/11/12/9945326.html
-Advertisement-
Play Games

分頁介面的實現,在偏業務的服務端開發中應該很常見,PC時代的各種表格,移動時代的各種 流、 。 出於對流量的控制,或者用戶的體驗,大批量的數據都不會直接返回給客戶端,而是通過分頁介面,多次請求返回數據。 而最常用的分頁介面定義大概是這樣的: 介面傳入請求的頁碼、以及每頁要請求的條數,我個人猜想這可能 ...


分頁介面的實現,在偏業務的服務端開發中應該很常見,PC時代的各種表格,移動時代的各種feed流、timeline

出於對流量的控制,或者用戶的體驗,大批量的數據都不會直接返回給客戶端,而是通過分頁介面,多次請求返回數據。

而最常用的分頁介面定義大概是這樣的:

router.get('/list', async ctx => {
  const { page, size } = this.query

  // ...

  ctx.body = {
    data: []
  }
})

// > curl /list?page=1&size=10

介面傳入請求的頁碼、以及每頁要請求的條數,我個人猜想這可能和大家初學的時候所接觸的資料庫有關吧- -,我所認識的人裡邊,先接觸MySQLSQL Server什麼的比較多一些,以及類似的SQL語句,在查詢的時候基本上就是這樣的一個分頁條件:

SELECT <column> FROM <table> LIMIT <offset>, <rows>

或者類似的Redis中針對zset的操作也是類似的:

> ZRANGE <key> <start> <stop>

所以可能習慣性的就使用類似的方式創建分頁請求介面,讓客戶端提供pagesize兩個參數。
這樣的做法並沒有什麼問題,在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_開頭的keyscan 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在下次介面請求時攜帶上即可。

大致的結構

對於客戶端來說,這就是一個簡單的游標存儲以及使用。
但是服務端的邏輯要稍微複雜一些:

  1. 首先,我們需要有一個獲取數據的函數
  2. 其次需要有一個用於數據過濾的函數
  3. 有一個用於判斷數據長度並截取的函數
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條存在本地下次介面拼接進去再展示。

文中如果有什麼錯誤,或者關於分頁各位有更好的實現方式、自己喜歡的方式,不妨交流一番。

參考資料


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 最近的項目需要使用小程式的藍牙功能與硬體設備進行連接相互傳送數據指令,聯調過程中發現一些問題,於是想著記錄下來,方便以後查看! 1.0一般使用藍牙功能肯定是想連接某一個藍牙設備,所以需要知道這個藍牙設備的名稱,一般來說都是掃描二維碼連接,那麼當你掃描這個設備二維碼的時候,就需要去初始化你手機上的藍牙 ...
  • Vue.js大總結 vue漸進式的理解 vue可以開發很多插件,可以把很多插件組合到一起,漸進的增加vue的功能 update beforeUpdated 在這兩個鉤子中不要修改data數據,否則會死迴圈, 因為數據修改後update會執行,執行後又會修改數據,如此便會死迴圈 data data為什 ...
  • 需求: 有一組圖片,每隔3秒鐘去切換一張,最終是不停的切換 技術要點: 切換圖片 每隔三秒做一件事 步驟分析: 1.確定事件:文檔載入完成的事件onload 2.事件要觸發:init() 3.函數裡面要做一些事情(通常會去操作元素,提供交互) 1.開啟一個定時器:執行切換圖片的函數changeImg ...
  • vue中的插槽————slot 什麼是插槽? 插槽(Slot)是Vue提出來的一個概念,正如名字一樣,插槽用於決定將所攜帶的內容,插入到指定的某個位置,從而使模板分塊,具有模塊化的特質和更大的重用性。 插槽顯不顯示、怎樣顯示是由父組件來控制的,而插槽在哪裡顯示就由子組件來進行控制 怎麼用插槽? 預設 ...
  • <script> (function(){ var code = GetQueryString('code'); if(code){ alert(code) return false; }else{ shouquan(); } function shouquan(){ var redirect_ur ...
  • 一個常見的場景,獲取:標簽背景圖片鏈接: 如字元串:var bgImg = "url(\"https://img30.360buyimg.com/sku/jfs/t26203/262/100869187/204098/1d1479e9/5b84b80bNf39db45f.jpg\")"; 腳本: 劃 ...
  • 1 'use strict' 2 3 function 找出最長公子串 (...strings) { 4 let arraiesOfSubStrings = [] 5 arrayOfStrings.reduce((accumulator, currentValue) => { 6 arraiesOf... ...
  • 大家在做http請求的時候可能會遇到跨域問題,這裡為大家提供解決方案,親自實驗有效。 一般是報上述錯誤。 首先在C盤新建一個文件夾,命名按照下麵的來。 打開谷歌瀏覽器的設置 在“目標” 的exe後面添加下麵這段內容,不要忘記打空格。並且下麵這段內容不要放在包含“exe”的雙引號裡面,放在外面 --d ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...